diff --git a/dropbox/__init__.py b/dropbox/__init__.py index c3a358cb..691e1551 100644 --- a/dropbox/__init__.py +++ b/dropbox/__init__.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from ._http import build_range_headers as build_range_headers from dropbox.dropbox_client import ( # noqa: F401 # pylint: disable=unused-import __version__, Dropbox, diff --git a/dropbox/_http.py b/dropbox/_http.py new file mode 100644 index 00000000..ea8e4f64 --- /dev/null +++ b/dropbox/_http.py @@ -0,0 +1,43 @@ +def format_byte_range(byte_range): + """Format a byte range as an HTTP Range header value.""" + + if byte_range is None: + return None + + if not isinstance(byte_range, tuple) or len(byte_range) != 2: + raise ValueError("byte_range must be a (start, end) tuple") + + start, end = byte_range + + if start is None and end is None: + raise ValueError("byte_range must specify start or end") + + for value in (start, end): + if value is None: + continue + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("byte_range values must be non-negative integers") + if value < 0: + raise ValueError("byte_range values must be non-negative") + + if start is not None and end is not None and end < start: + raise ValueError("byte_range end must be greater than or equal to start") + + if start is None: + return "bytes=-{}".format(end) + + if end is None: + return "bytes={}-".format(start) + + return "bytes={}-{}".format(start, end) + + +def build_range_headers(byte_range): + """Build HTTP Range headers for a download request.""" + + range_header = format_byte_range(byte_range) + + if range_header is None: + return None + + return {"Range": range_header} diff --git a/dropbox/base.py b/dropbox/base.py index b5f06306..d4d49ce6 100644 --- a/dropbox/base.py +++ b/dropbox/base.py @@ -33,7 +33,9 @@ class DropboxBase(object): __metaclass__ = ABCMeta @abstractmethod - def request(self, route, namespace, request_arg, request_binary, timeout=None): + def request( + self, route, namespace, request_arg, request_binary, timeout=None, extra_headers=None + ): pass # ------------------------------------------ @@ -1491,13 +1493,14 @@ def files_delete_batch_check(self, async_job_id): ) return r - def files_download(self, path, rev=None): + def files_download(self, path, rev=None, extra_headers=None): """ Download a file from a user's Dropbox. Route attributes: scope: files.content.read + :param object extra_headers: Additional HTTP headers for this request. :param path: The path of the file to download. :type path: str :param rev: Field is deprecated. Please specify revision in ``path`` @@ -1522,10 +1525,11 @@ def files_download(self, path, rev=None): "files", arg, None, + extra_headers=extra_headers, ) return r - def files_download_to_file(self, download_path, path, rev=None): + def files_download_to_file(self, download_path, path, rev=None, extra_headers=None): """ Download a file from a user's Dropbox. @@ -1533,6 +1537,7 @@ def files_download_to_file(self, download_path, path, rev=None): scope: files.content.read :param str download_path: Path on local machine to save file. + :param object extra_headers: Additional HTTP headers for this request. :param path: The path of the file to download. :type path: str :param rev: Field is deprecated. Please specify revision in ``path`` @@ -1550,6 +1555,7 @@ def files_download_to_file(self, download_path, path, rev=None): "files", arg, None, + extra_headers=extra_headers, ) self._save_body_to_file(download_path, r[1]) return r[0] diff --git a/dropbox/dropbox_client.py b/dropbox/dropbox_client.py index f1adb837..8d7cf1ff 100644 --- a/dropbox/dropbox_client.py +++ b/dropbox/dropbox_client.py @@ -284,7 +284,15 @@ def clone( ), ) - def request(self, route, namespace, request_arg, request_binary, timeout=None): + def request( + self, + route, + namespace, + request_arg, + request_binary, + timeout=None, + extra_headers=None, + ): """ Makes a request to the Dropbox API and in the process validates that the route argument and result are the expected data types. The @@ -304,6 +312,7 @@ def request(self, route, namespace, request_arg, request_binary, timeout=None): server. After the timeout the client will give up on connection. If `None`, will use default timeout set on Dropbox object. Defaults to `None`. + :param dict extra_headers: Additional HTTP headers for this request. :return: The route's result. """ @@ -338,6 +347,7 @@ def request(self, route, namespace, request_arg, request_binary, timeout=None): auth_type, request_binary, timeout=timeout, + extra_headers=extra_headers, ) decoded_obj_result = json.loads(res.obj_result) if isinstance(res, RouteResult): @@ -521,6 +531,7 @@ def request_json_string_with_retry( auth_type, request_binary, timeout=None, + extra_headers=None, ): """ See :meth:`request_json_object` for description of parameters. @@ -542,6 +553,7 @@ def request_json_string_with_retry( auth_type, request_binary, timeout=timeout, + extra_headers=extra_headers, ) except AuthError as e: if e.error and e.error.is_expired_access_token(): @@ -591,6 +603,7 @@ def request_json_string( auth_type, request_binary, timeout=None, + extra_headers=None, ): """ See :meth:`request_json_string_with_retry` for description of @@ -611,11 +624,13 @@ def request_json_string( url = self._get_route_url(fq_hostname, func_name) headers = {"User-Agent": self._user_agent} + managed_auth_header = None + auth_types = auth_type.replace(" ", "").split(",") if (USER_AUTH in auth_types or TEAM_AUTH in auth_types) and self._oauth2_access_token: - headers["Authorization"] = "Bearer %s" % self._oauth2_access_token if self._headers: headers.update(self._headers) + managed_auth_header = "Bearer %s" % self._oauth2_access_token elif APP_AUTH in auth_types: if self._app_key is None or self._app_secret is None: raise BadInputException( @@ -624,14 +639,20 @@ def request_json_string( auth_header = base64.b64encode( "{}:{}".format(self._app_key, self._app_secret).encode("utf-8") ) - headers["Authorization"] = "Basic {}".format(auth_header.decode("utf-8")) if self._headers: headers.update(self._headers) + managed_auth_header = "Basic {}".format(auth_header.decode("utf-8")) elif auth_type == NO_AUTH: pass else: raise BadInputException("Unhandled auth type: {}".format(auth_type)) + if extra_headers: + headers.update(extra_headers) + + if managed_auth_header: + headers["Authorization"] = managed_auth_header + # The contents of the body of the HTTP request body = None # Whether the response should be streamed incrementally, or buffered diff --git a/generate_base_client.py b/generate_base_client.py index 4348b6eb..7fc3d5b7 100755 --- a/generate_base_client.py +++ b/generate_base_client.py @@ -67,7 +67,17 @@ def main(): o = subprocess.check_output( ( - ["python", "-m", "stone.cli", "python_client", dropbox_pkg_path] + [ + "python", + "-m", + "stone.cli", + os.path.join( + os.path.dirname(__file__), + "generator", + "dropbox_python_client.stoneg.py", + ), + dropbox_pkg_path, + ] + specs + ["-a", "host", "-a", "style", "-a", "auth", "-a", "scope"] + [ diff --git a/generator/dropbox_python_client.stoneg.py b/generator/dropbox_python_client.stoneg.py new file mode 100644 index 00000000..6896bcbd --- /dev/null +++ b/generator/dropbox_python_client.stoneg.py @@ -0,0 +1,219 @@ +from stone.backends import python_client +from stone.backends.helpers import fmt_underscores +from stone.backends.python_helpers import fmt_func +from stone.ir import ( + is_nullable_type, + is_struct_type, + is_union_type, + is_user_defined_type, + is_void_type, +) + + +class DropboxPythonClientBackend(python_client.PythonClientBackend): + """Generate Dropbox-specific Python client methods.""" + + def generate(self, api): + """Generates a module called "base".""" + + with self.output_to_relative_path("%s.py" % self.args.module_name): + self.emit_raw(python_client.base) + found_deprecated = False + for namespace in api.namespaces.values(): + for route in namespace.routes: + if route.deprecated: + self.emit("import warnings") + found_deprecated = True + break + if found_deprecated: + break + self.emit() + self._generate_imports(api.namespaces.values()) + self.emit() + self.emit() + self.emit("class %s(object):" % self.args.class_name) + with self.indent(): + self.emit("__metaclass__ = ABCMeta") + self.emit() + self.emit("@abstractmethod") + self.emit( + "def request(self, route, namespace, request_arg, " + "request_binary, timeout=None, extra_headers=None):" + ) + with self.indent(): + self.emit("pass") + self.emit() + self._generate_route_methods(api.namespaces.values()) + + @staticmethod + def _supports_extra_headers(namespace, route): + return namespace.name == "files" and route.name == "download" + + def _generate_route_method_decl( + self, + namespace, + route, + arg_data_type, + request_binary_body, + method_name_suffix="", + extra_args=None, + ): + if not self._supports_extra_headers(namespace, route): + return super()._generate_route_method_decl( + namespace, + route, + arg_data_type, + request_binary_body, + method_name_suffix=method_name_suffix, + extra_args=extra_args, + ) + + args = ["self"] + + if extra_args: + args += extra_args + + if request_binary_body: + args.append("f") + + if is_struct_type(arg_data_type): + for field in arg_data_type.all_fields: + if is_nullable_type(field.data_type): + args.append("{}=None".format(field.name)) + elif field.has_default: + if is_user_defined_type(field.data_type): + ns = field.data_type.namespace + else: + ns = None + + args.append( + "{}={}".format( + field.name, + self._generate_python_value(ns, field.default), + ) + ) + else: + args.append(field.name) + elif is_union_type(arg_data_type): + args.append("arg") + elif not is_void_type(arg_data_type): + raise AssertionError("Unhandled request type: %r" % arg_data_type) + + args.append("extra_headers=None") + + method_name = fmt_func( + route.name + method_name_suffix, + version=route.version, + ) + namespace_name = fmt_underscores(namespace.name) + + self.generate_multiline_list( + args, + "def {}_{}".format(namespace_name, method_name), + ":", + ) + + def _generate_route_helper(self, namespace, route, download_to_file=False): + if not self._supports_extra_headers(namespace, route): + return super()._generate_route_helper( + namespace, + route, + download_to_file=download_to_file, + ) + + arg_data_type = route.arg_data_type + result_data_type = route.result_data_type + response_binary_body = route.attrs.get("style") == "download" + + if download_to_file: + assert response_binary_body + self._generate_route_method_decl( + namespace, + route, + arg_data_type, + False, + method_name_suffix="_to_file", + extra_args=["download_path"], + ) + else: + self._generate_route_method_decl( + namespace, + route, + arg_data_type, + False, + ) + + with self.indent(): + extra_request_args = [] + if download_to_file: + extra_request_args.append( + ( + "download_path", + "str", + "Path on local machine to save file.", + ) + ) + + extra_request_args.append( + ( + "extra_headers", + "object", + "Additional HTTP headers for this request.", + ) + ) + + if route.doc: + func_docstring = self.process_doc(route.doc, self._docf) + else: + func_docstring = None + + self._generate_docstring_for_func( + namespace, + arg_data_type, + result_data_type, + route.error_data_type, + overview=func_docstring, + extra_request_args=extra_request_args, + extra_return_arg=( + None if download_to_file else ":class:`requests.models.Response`" + ), + footer=(None if download_to_file else python_client.DOCSTRING_CLOSE_RESPONSE), + attrs=route.attrs, + ) + + self._maybe_generate_deprecation_warning(route) + self.generate_multiline_list( + [field.name for field in arg_data_type.all_fields], + before="arg = {}.{}".format( + python_client.fmt_namespace(arg_data_type.namespace.name), + python_client.fmt_class(arg_data_type.name), + ), + ) + + args = [ + "{}.{}".format( + python_client.fmt_namespace(namespace.name), + python_client.fmt_func( + route.name, + version=route.version, + ), + ), + "'{}'".format(namespace.name), + "arg", + "None", + "extra_headers=extra_headers", + ] + + self.generate_multiline_list( + args, + "r = self.request", + compact=False, + ) + + if download_to_file: + self.emit("self._save_body_to_file(download_path, r[1])") + self.emit("return r[0]") + else: + self.emit("return r") + + self.emit() diff --git a/test/unit/test_dropbox_unit.py b/test/unit/test_dropbox_unit.py index ef91395c..c47e76f4 100644 --- a/test/unit/test_dropbox_unit.py +++ b/test/unit/test_dropbox_unit.py @@ -1,23 +1,27 @@ #!/usr/bin/env python +import inspect import json -import mock import pickle +from datetime import datetime, timedelta +import mock import pytest +import requests # Tests OAuth Flow from dropbox import DropboxOAuth2Flow, session, Dropbox, create_session +from dropbox.base import DropboxBase +from dropbox.common import PathRoot +from dropbox.content_hash import content_hash from dropbox.dropbox_client import ( BadInputException, DropboxTeam, RouteResult, + USER_AUTH, ) -from dropbox.content_hash import content_hash -from dropbox.common import PathRoot from dropbox.exceptions import AuthError, BadInputError from dropbox.oauth import OAuth2FlowNoRedirectResult, DropboxOAuth2FlowNoRedirect -from datetime import datetime, timedelta APP_KEY = "dummy_app_key" APP_SECRET = "dummy_app_secret" @@ -404,6 +408,80 @@ def test_Dropbox_with_valid_offline_token(self, session_instance): session=session_instance, ) + def test_files_download_passes_extra_headers(self): + dbx = Dropbox(oauth2_access_token=ACCESS_TOKEN) + + captured = {} + + def fake_request( + route, + namespace, + request_arg, + request_binary, + timeout=None, + extra_headers=None, + ): + captured["extra_headers"] = extra_headers + return object(), object() + + dbx.request = fake_request + extra_headers = {"Range": "bytes=0-99"} + dbx.files_download( + "/test.txt", + extra_headers=extra_headers, + ) + + assert captured["extra_headers"] == extra_headers + + def test_base_request_signature_accepts_extra_headers(self): + parameters = inspect.signature(DropboxBase.request).parameters + + assert "extra_headers" in parameters + assert parameters["extra_headers"].default is None + + def test_extra_headers_are_added_to_http_request(self): + session_obj = create_session() + + post_response = requests.Response() + post_response.status_code = 200 + post_response.headers = { + "dropbox-api-result": "{}", + } + post_response._content = b"" + + session_obj.post = mock.MagicMock(return_value=post_response) + + dbx = Dropbox( + oauth2_access_token=ACCESS_TOKEN, + session=session_obj, + headers={ + "Authorization": "client-auth", + "Range": "bytes=100-199", + "X-Client-Header": "client-value", + }, + ) + + dbx.request_json_string( + "content", + "files/download", + dbx._ROUTE_STYLE_DOWNLOAD, + '{"path": "/test.txt"}', + USER_AUTH, + None, + extra_headers={ + "Authorization": "extra-auth", + "Range": "bytes=0-99", + "Dropbox-API-Arg": "incorrect", + }, + ) + + headers = session_obj.post.call_args.kwargs["headers"] + + assert headers["Authorization"] == "Bearer %s" % ACCESS_TOKEN + assert headers["Range"] == "bytes=0-99" + assert headers["X-Client-Header"] == "client-value" + assert headers["Dropbox-API-Arg"] == '{"path": "/test.txt"}' + def test_Dropbox_with_expired_offline_token(self, session_instance): # Test Offline Case w/ invalid access Dropbox( @@ -648,7 +726,16 @@ def _capture_upload(self, dbx, data, **upload_kwargs): # Stub out the network layer and capture the serialized argument sent. captured = {} - def fake_request(host, name, style, serialized_arg, auth, binary, timeout=None): + def fake_request( + host, + name, + style, + serialized_arg, + auth, + binary, + timeout=None, + extra_headers=None, + ): captured["arg"] = json.loads(serialized_arg) return RouteResult(self._fake_file_metadata(len(data), content_hash(data))) diff --git a/test/unit/test_http.py b/test/unit/test_http.py new file mode 100644 index 00000000..60b6e50e --- /dev/null +++ b/test/unit/test_http.py @@ -0,0 +1,50 @@ +import pytest + +from dropbox._http import format_byte_range, build_range_headers + + +@pytest.mark.parametrize( + "byte_range, expected", + [ + (None, None), + ((0, 99), "bytes=0-99"), + ((100, None), "bytes=100-"), + ((None, 500), "bytes=-500"), + ], +) +def test_format_byte_range(byte_range, expected): + assert format_byte_range(byte_range) == expected + + +@pytest.mark.parametrize( + "byte_range", + [ + (None, None), + (-1, 10), + (10, -1), + (10, 5), + "0-10", + (0,), + ], +) +def test_format_byte_range_rejects_invalid_values(byte_range): + with pytest.raises((TypeError, ValueError)): + format_byte_range(byte_range) + + +def test_format_byte_range_rejects_bool(): + with pytest.raises(TypeError): + format_byte_range((True, 10)) + + +@pytest.mark.parametrize( + "byte_range, expected", + [ + (None, None), + ((0, 99), {"Range": "bytes=0-99"}), + ((100, None), {"Range": "bytes=100-"}), + ((None, 500), {"Range": "bytes=-500"}), + ], +) +def test_build_range_headers(byte_range, expected): + assert build_range_headers(byte_range) == expected