Skip to content

Commit

Permalink
fix: Change user-agent string to be opt-in
Browse files Browse the repository at this point in the history
  • Loading branch information
JanEbbing committed Feb 15, 2023
1 parent 09e6d42 commit 24ab267
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 12 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion deepl/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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":
Expand All @@ -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")
Expand Down
64 changes: 55 additions & 9 deletions deepl/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
11 changes: 10 additions & 1 deletion deepl/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions tests/test_general.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

0 comments on commit 24ab267

Please sign in to comment.