diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2b1d3..47d0870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added * Add [example](examples/json) to translate JSON inputs. +* Added platform and python version information to the user-agent string that is sent with API calls, along with an opt-out. +* Added method for applications that use this library to identify themselves in API requests they make. ## [1.13.0] - 2023-01-26 @@ -19,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note: older library versions also support the new languages, this update only adds new code constants. ### Changed -* Added system, python and request library version information to the user-agent string that is sent with API calls. ### Deprecated ### Removed ### Fixed diff --git a/README.md b/README.md index da626d5..87f3545 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,20 @@ Note that glossaries work for all target regional-variants: a glossary for the target language English (`"EN"`) supports translations to both American English (`"EN-US"`) and British English (`"EN-GB"`). +### Writing a Plugin + +If you use this library in an application, please identify the application with +`deepl.Translator.set_app_info`, which needs the name and version of the app: + +```python +translator = deepl.Translator(...).set_app_info("sample_python_plugin", "1.0.2") +``` + +This information is passed along when the library makes calls to the DeepL API. +Both name and version are required. Please note that setting the `User-Agent` header +via `deepl.http_client.user_agent` will override this setting, if you need to use this, +please manually identify your Application in the `User-Agent` header. + ### Exceptions All module functions may raise `deepl.DeepLException` or one of its subclasses. @@ -481,6 +495,21 @@ The proxy argument is passed to the underlying `requests` session, see the [documentation for requests][requests-proxy-docs]; a dictionary of schemes to proxy URLs is also accepted. +#### Anonymous platform information + +By default, we send some basic information about the platform the client library is running on with each request, see [here for an explanation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent). This data is completely anonymous and only used to improve our product, not track any individual users. If you do not wish to send this data, you can opt-out when creating your `deepl.Translator` object by setting the `send_platform_info` flag like so: + +```python +translator = deepl.Translator(..., send_platform_info=False) +``` + +You can also customize the `user_agent` by setting its value explicitly before constructing your `deepl.Translator` object. + +```python +deepl.http_client.user_agent = 'my custom user agent' +translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]) +``` + ## Command Line Interface The library can be run on the command line supporting all API functions. Use the diff --git a/deepl/__main__.py b/deepl/__main__.py index 8c49be0..6c8dd3c 100644 --- a/deepl/__main__.py +++ b/deepl/__main__.py @@ -207,6 +207,14 @@ def get_parser(prog_name): help="print additional information, can be supplied multiple times " "for more verbose output", ) + parser.add_argument( + "--no-platform-info", + default=False, + action="store_true", + dest="noplatforminfo", + help="if this flag is enabled, do not send additional information " + "about the platform with API requests.", + ) parser.add_argument( "--auth-key", @@ -535,6 +543,7 @@ def main(args=None, prog_name=None): server_url=server_url, proxy=proxy_url, skip_language_check=True, + send_platform_info=not args.noplatforminfo, ) if args.command == "text": @@ -549,7 +558,13 @@ def main(args=None, prog_name=None): sys.exit(1) # Remove global args so they are not unrecognised in action functions - del args.verbose, args.server_url, args.auth_key, args.proxy_url + del ( + args.verbose, + args.server_url, + args.auth_key, + args.proxy_url, + args.noplatforminfo, + ) args = vars(args) # Call action function corresponding to command with remaining args command = args.pop("command") diff --git a/deepl/http_client.py b/deepl/http_client.py index 30c7d4b..756daad 100644 --- a/deepl/http_client.py +++ b/deepl/http_client.py @@ -9,14 +9,12 @@ import random import requests import time +from functools import lru_cache from typing import Dict, Optional, Tuple, Union from .util import log_info -user_agent = ( - f"deepl-python/{version.VERSION} ({platform.platform()}) " - f"python/{platform.python_version()} requests/{requests.__version__}" -) +user_agent = None max_network_retries = 5 min_connection_timeout = 10.0 @@ -62,7 +60,11 @@ def sleep_until_deadline(self): class HttpClient: - def __init__(self, proxy: Union[Dict, str, None] = None): + def __init__( + self, + proxy: Union[Dict, str, None] = None, + send_platform_info: bool = True, + ): self._session = requests.Session() if proxy: if isinstance(proxy, str): @@ -73,8 +75,14 @@ def __init__(self, proxy: Union[Dict, str, None] = None): "containing URL strings for the http and https keys." ) self._session.proxies.update(proxy) - self._session.headers = {"User-Agent": user_agent} - pass + self._send_platform_info = send_platform_info + self._app_info_name = None + self._app_info_version = None + + def set_app_info(self, app_info_name: str, app_info_version: str): + self._app_info_name = app_info_name + self._app_info_version = app_info_version + return self def close(self): self._session.close() @@ -94,7 +102,15 @@ def request_with_backoff( backoff = _BackoffTimer() try: - headers.setdefault("User-Agent", user_agent) + headers.setdefault( + "User-Agent", + _generate_user_agent( + user_agent, + self._send_platform_info, + self._app_info_name, + self._app_info_version, + ), + ) request = requests.Request( method, url, data=data, headers=headers, **kwargs ).prepare() @@ -151,7 +167,15 @@ def request( If no response is received will raise ConnectionException.""" try: - headers.setdefault("User-Agent", user_agent) + headers.setdefault( + "User-Agent", + _generate_user_agent( + user_agent, + self._send_platform_info, + self._app_info_name, + self._app_info_version, + ), + ) request = requests.Request( method, url, data=data, headers=headers, **kwargs ).prepare() @@ -206,3 +230,25 @@ def _should_retry(self, response, exception, num_retries): return status_code == http.HTTPStatus.TOO_MANY_REQUESTS or ( status_code >= http.HTTPStatus.INTERNAL_SERVER_ERROR ) + + +@lru_cache(maxsize=4) +def _generate_user_agent( + user_agent_str: Optional[str], + send_platform_info: bool, + app_info_name: Optional[str], + app_info_version: Optional[str], +): + if user_agent_str: + library_info_str = user_agent_str + else: + library_info_str = f"deepl-python/{version.VERSION}" + if send_platform_info: + library_info_str += ( + f" ({platform.platform()}) " + f"python/{platform.python_version()} " + f"requests/{requests.__version__}" + ) + if app_info_name and app_info_version: + library_info_str += f" {app_info_name}/{app_info_version}" + return library_info_str diff --git a/deepl/translator.py b/deepl/translator.py index 81df349..a060071 100644 --- a/deepl/translator.py +++ b/deepl/translator.py @@ -454,6 +454,10 @@ class Translator: URL strings for the 'http' and 'https' keys. This is passed to the underlying requests session, see the requests proxy documentation for more information. + :param send_platform_info: (Optional) boolean that indicates if the client + library can send basic platform info (python version, OS, http library + version) to the DeepL API. True = send info, False = only send client + library version :param skip_language_check: Deprecated, and now has no effect as the corresponding internal functionality has been removed. This parameter will be removed in a future version. @@ -475,6 +479,7 @@ def __init__( *, server_url: Optional[str] = None, proxy: Union[Dict, str, None] = None, + send_platform_info: bool = True, skip_language_check: bool = False, ): if not auth_key: @@ -488,7 +493,7 @@ def __init__( ) self._server_url = server_url - self._client = http_client.HttpClient(proxy) + self._client = http_client.HttpClient(proxy, send_platform_info) self.headers = {"Authorization": f"DeepL-Auth-Key {auth_key}"} def __del__(self): @@ -701,6 +706,10 @@ def close(self): if hasattr(self, "_client"): self._client.close() + def set_app_info(self, app_info_name: str, app_info_version: str): + self._client.set_app_info(app_info_name, app_info_version) + return self + @property def server_url(self): return self._server_url diff --git a/tests/test_general.py b/tests/test_general.py index f772210..6c0d326 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -3,9 +3,12 @@ # license that can be found in the LICENSE file. from .conftest import example_text, needs_mock_server, needs_mock_proxy_server +from requests import Response +from unittest.mock import patch, Mock import deepl import pathlib import pytest +import os def test_version(): @@ -86,6 +89,85 @@ def test_server_url_selected_based_on_auth_key(server): assert translator_free.server_url == "https://api-free.deepl.com" +@patch("requests.adapters.HTTPAdapter.send") +def test_user_agent(mock_send): + mock_send.return_value = _build_test_response() + translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]) + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert "requests/" in ua_header + assert " python/" in ua_header + assert "(" in ua_header + + +@patch("requests.adapters.HTTPAdapter.send") +def test_user_agent_opt_out(mock_send): + mock_send.return_value = _build_test_response() + translator = deepl.Translator( + os.environ["DEEPL_AUTH_KEY"], send_platform_info=False + ) + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert "requests/" not in ua_header + assert " python/" not in ua_header + assert "(" not in ua_header + + +@patch("requests.adapters.HTTPAdapter.send") +def test_custom_user_agent(mock_send): + mock_send.return_value = _build_test_response() + old_user_agent = deepl.http_client.user_agent + deepl.http_client.user_agent = "my custom user agent" + translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]) + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert ua_header == "my custom user agent" + deepl.http_client.user_agent = old_user_agent + + +@patch("requests.adapters.HTTPAdapter.send") +def test_user_agent_with_app_info(mock_send): + mock_send.return_value = _build_test_response() + translator = deepl.Translator( + os.environ["DEEPL_AUTH_KEY"], + ).set_app_info("sample_python_plugin", "1.0.2") + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert "requests/" in ua_header + assert " python/" in ua_header + assert "(" in ua_header + assert " sample_python_plugin/1.0.2" in ua_header + + +@patch("requests.adapters.HTTPAdapter.send") +def test_user_agent_opt_out_with_app_info(mock_send): + mock_send.return_value = _build_test_response() + translator = deepl.Translator( + os.environ["DEEPL_AUTH_KEY"], + send_platform_info=False, + ).set_app_info("sample_python_plugin", "1.0.2") + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert "requests/" not in ua_header + assert " python/" not in ua_header + assert "(" not in ua_header + assert " sample_python_plugin/1.0.2" in ua_header + + +@patch("requests.adapters.HTTPAdapter.send") +def test_custom_user_agent_with_app_info(mock_send): + mock_send.return_value = _build_test_response() + old_user_agent = deepl.http_client.user_agent + deepl.http_client.user_agent = "my custom user agent" + translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]).set_app_info( + "sample_python_plugin", "1.0.2" + ) + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert ua_header == "my custom user agent sample_python_plugin/1.0.2" + deepl.http_client.user_agent = old_user_agent + + @needs_mock_proxy_server def test_proxy_usage( server, @@ -194,3 +276,26 @@ def test_usage_team_document_limit( assert not usage.document.limit_reached assert not usage.character.limit_reached assert usage.team_document.limit_reached + + +def _build_test_response(): + response = Mock(spec=Response) + response.status_code = 200 + response.text = ( + '{"translations": [{"detected_source_language": "EN", ' + '"text": "protonstrĂ¥le"}]}' + ) + response.headers = { + "Content-Type": "application/json", + "Server": "nginx", + "Content-Length": str(len(response.text.encode("utf-8"))), + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + } + response.encoding = "utf-8" + response.history = None + response.raw = None + response.is_redirect = False + response.stream = False + response.url = "https://api.deepl.com/v2/translate" + return response