Skip to content

Commit 24ab267

Browse files
committed
fix: Change user-agent string to be opt-in
1 parent 09e6d42 commit 24ab267

File tree

6 files changed

+217
-12
lines changed

6 files changed

+217
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99
### Added
1010
* Add [example](examples/json) to translate JSON inputs.
11+
* Added platform and python version information to the user-agent string that is sent with API calls, along with an opt-out.
12+
* Added method for applications that use this library to identify themselves in API requests they make.
1113

1214

1315
## [1.13.0] - 2023-01-26
@@ -19,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1921

2022
Note: older library versions also support the new languages, this update only adds new code constants.
2123
### Changed
22-
* Added system, python and request library version information to the user-agent string that is sent with API calls.
2324
### Deprecated
2425
### Removed
2526
### Fixed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,20 @@ Note that glossaries work for all target regional-variants: a glossary for the
434434
target language English (`"EN"`) supports translations to both American English
435435
(`"EN-US"`) and British English (`"EN-GB"`).
436436

437+
### Writing a Plugin
438+
439+
If you use this library in an application, please identify the application with
440+
`deepl.Translator.set_app_info`, which needs the name and version of the app:
441+
442+
```python
443+
translator = deepl.Translator(...).set_app_info("sample_python_plugin", "1.0.2")
444+
```
445+
446+
This information is passed along when the library makes calls to the DeepL API.
447+
Both name and version are required. Please note that setting the `User-Agent` header
448+
via `deepl.http_client.user_agent` will override this setting, if you need to use this,
449+
please manually identify your Application in the `User-Agent` header.
450+
437451
### Exceptions
438452

439453
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
481495
[documentation for requests][requests-proxy-docs]; a dictionary of schemes to
482496
proxy URLs is also accepted.
483497

498+
#### Anonymous platform information
499+
500+
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:
501+
502+
```python
503+
translator = deepl.Translator(..., send_platform_info=False)
504+
```
505+
506+
You can also customize the `user_agent` by setting its value explicitly before constructing your `deepl.Translator` object.
507+
508+
```python
509+
deepl.http_client.user_agent = 'my custom user agent'
510+
translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"])
511+
```
512+
484513
## Command Line Interface
485514

486515
The library can be run on the command line supporting all API functions. Use the

deepl/__main__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ def get_parser(prog_name):
207207
help="print additional information, can be supplied multiple times "
208208
"for more verbose output",
209209
)
210+
parser.add_argument(
211+
"--no-platform-info",
212+
default=False,
213+
action="store_true",
214+
dest="noplatforminfo",
215+
help="if this flag is enabled, do not send additional information "
216+
"about the platform with API requests.",
217+
)
210218

211219
parser.add_argument(
212220
"--auth-key",
@@ -535,6 +543,7 @@ def main(args=None, prog_name=None):
535543
server_url=server_url,
536544
proxy=proxy_url,
537545
skip_language_check=True,
546+
send_platform_info=not args.noplatforminfo,
538547
)
539548

540549
if args.command == "text":
@@ -549,7 +558,13 @@ def main(args=None, prog_name=None):
549558
sys.exit(1)
550559

551560
# Remove global args so they are not unrecognised in action functions
552-
del args.verbose, args.server_url, args.auth_key, args.proxy_url
561+
del (
562+
args.verbose,
563+
args.server_url,
564+
args.auth_key,
565+
args.proxy_url,
566+
args.noplatforminfo,
567+
)
553568
args = vars(args)
554569
# Call action function corresponding to command with remaining args
555570
command = args.pop("command")

deepl/http_client.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@
99
import random
1010
import requests
1111
import time
12+
from functools import lru_cache
1213
from typing import Dict, Optional, Tuple, Union
1314
from .util import log_info
1415

1516

16-
user_agent = (
17-
f"deepl-python/{version.VERSION} ({platform.platform()}) "
18-
f"python/{platform.python_version()} requests/{requests.__version__}"
19-
)
17+
user_agent = None
2018
max_network_retries = 5
2119
min_connection_timeout = 10.0
2220

@@ -62,7 +60,11 @@ def sleep_until_deadline(self):
6260

6361

6462
class HttpClient:
65-
def __init__(self, proxy: Union[Dict, str, None] = None):
63+
def __init__(
64+
self,
65+
proxy: Union[Dict, str, None] = None,
66+
send_platform_info: bool = True,
67+
):
6668
self._session = requests.Session()
6769
if proxy:
6870
if isinstance(proxy, str):
@@ -73,8 +75,14 @@ def __init__(self, proxy: Union[Dict, str, None] = None):
7375
"containing URL strings for the http and https keys."
7476
)
7577
self._session.proxies.update(proxy)
76-
self._session.headers = {"User-Agent": user_agent}
77-
pass
78+
self._send_platform_info = send_platform_info
79+
self._app_info_name = None
80+
self._app_info_version = None
81+
82+
def set_app_info(self, app_info_name: str, app_info_version: str):
83+
self._app_info_name = app_info_name
84+
self._app_info_version = app_info_version
85+
return self
7886

7987
def close(self):
8088
self._session.close()
@@ -94,7 +102,15 @@ def request_with_backoff(
94102
backoff = _BackoffTimer()
95103

96104
try:
97-
headers.setdefault("User-Agent", user_agent)
105+
headers.setdefault(
106+
"User-Agent",
107+
_generate_user_agent(
108+
user_agent,
109+
self._send_platform_info,
110+
self._app_info_name,
111+
self._app_info_version,
112+
),
113+
)
98114
request = requests.Request(
99115
method, url, data=data, headers=headers, **kwargs
100116
).prepare()
@@ -151,7 +167,15 @@ def request(
151167
If no response is received will raise ConnectionException."""
152168

153169
try:
154-
headers.setdefault("User-Agent", user_agent)
170+
headers.setdefault(
171+
"User-Agent",
172+
_generate_user_agent(
173+
user_agent,
174+
self._send_platform_info,
175+
self._app_info_name,
176+
self._app_info_version,
177+
),
178+
)
155179
request = requests.Request(
156180
method, url, data=data, headers=headers, **kwargs
157181
).prepare()
@@ -206,3 +230,25 @@ def _should_retry(self, response, exception, num_retries):
206230
return status_code == http.HTTPStatus.TOO_MANY_REQUESTS or (
207231
status_code >= http.HTTPStatus.INTERNAL_SERVER_ERROR
208232
)
233+
234+
235+
@lru_cache(maxsize=4)
236+
def _generate_user_agent(
237+
user_agent_str: Optional[str],
238+
send_platform_info: bool,
239+
app_info_name: Optional[str],
240+
app_info_version: Optional[str],
241+
):
242+
if user_agent_str:
243+
library_info_str = user_agent_str
244+
else:
245+
library_info_str = f"deepl-python/{version.VERSION}"
246+
if send_platform_info:
247+
library_info_str += (
248+
f" ({platform.platform()}) "
249+
f"python/{platform.python_version()} "
250+
f"requests/{requests.__version__}"
251+
)
252+
if app_info_name and app_info_version:
253+
library_info_str += f" {app_info_name}/{app_info_version}"
254+
return library_info_str

deepl/translator.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,10 @@ class Translator:
454454
URL strings for the 'http' and 'https' keys. This is passed to the
455455
underlying requests session, see the requests proxy documentation for
456456
more information.
457+
:param send_platform_info: (Optional) boolean that indicates if the client
458+
library can send basic platform info (python version, OS, http library
459+
version) to the DeepL API. True = send info, False = only send client
460+
library version
457461
:param skip_language_check: Deprecated, and now has no effect as the
458462
corresponding internal functionality has been removed. This parameter
459463
will be removed in a future version.
@@ -475,6 +479,7 @@ def __init__(
475479
*,
476480
server_url: Optional[str] = None,
477481
proxy: Union[Dict, str, None] = None,
482+
send_platform_info: bool = True,
478483
skip_language_check: bool = False,
479484
):
480485
if not auth_key:
@@ -488,7 +493,7 @@ def __init__(
488493
)
489494

490495
self._server_url = server_url
491-
self._client = http_client.HttpClient(proxy)
496+
self._client = http_client.HttpClient(proxy, send_platform_info)
492497
self.headers = {"Authorization": f"DeepL-Auth-Key {auth_key}"}
493498

494499
def __del__(self):
@@ -701,6 +706,10 @@ def close(self):
701706
if hasattr(self, "_client"):
702707
self._client.close()
703708

709+
def set_app_info(self, app_info_name: str, app_info_version: str):
710+
self._client.set_app_info(app_info_name, app_info_version)
711+
return self
712+
704713
@property
705714
def server_url(self):
706715
return self._server_url

tests/test_general.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
# license that can be found in the LICENSE file.
44

55
from .conftest import example_text, needs_mock_server, needs_mock_proxy_server
6+
from requests import Response
7+
from unittest.mock import patch, Mock
68
import deepl
79
import pathlib
810
import pytest
11+
import os
912

1013

1114
def test_version():
@@ -86,6 +89,85 @@ def test_server_url_selected_based_on_auth_key(server):
8689
assert translator_free.server_url == "https://api-free.deepl.com"
8790

8891

92+
@patch("requests.adapters.HTTPAdapter.send")
93+
def test_user_agent(mock_send):
94+
mock_send.return_value = _build_test_response()
95+
translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"])
96+
translator.translate_text(example_text["EN"], target_lang="DA")
97+
ua_header = mock_send.call_args[0][0].headers["User-agent"]
98+
assert "requests/" in ua_header
99+
assert " python/" in ua_header
100+
assert "(" in ua_header
101+
102+
103+
@patch("requests.adapters.HTTPAdapter.send")
104+
def test_user_agent_opt_out(mock_send):
105+
mock_send.return_value = _build_test_response()
106+
translator = deepl.Translator(
107+
os.environ["DEEPL_AUTH_KEY"], send_platform_info=False
108+
)
109+
translator.translate_text(example_text["EN"], target_lang="DA")
110+
ua_header = mock_send.call_args[0][0].headers["User-agent"]
111+
assert "requests/" not in ua_header
112+
assert " python/" not in ua_header
113+
assert "(" not in ua_header
114+
115+
116+
@patch("requests.adapters.HTTPAdapter.send")
117+
def test_custom_user_agent(mock_send):
118+
mock_send.return_value = _build_test_response()
119+
old_user_agent = deepl.http_client.user_agent
120+
deepl.http_client.user_agent = "my custom user agent"
121+
translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"])
122+
translator.translate_text(example_text["EN"], target_lang="DA")
123+
ua_header = mock_send.call_args[0][0].headers["User-agent"]
124+
assert ua_header == "my custom user agent"
125+
deepl.http_client.user_agent = old_user_agent
126+
127+
128+
@patch("requests.adapters.HTTPAdapter.send")
129+
def test_user_agent_with_app_info(mock_send):
130+
mock_send.return_value = _build_test_response()
131+
translator = deepl.Translator(
132+
os.environ["DEEPL_AUTH_KEY"],
133+
).set_app_info("sample_python_plugin", "1.0.2")
134+
translator.translate_text(example_text["EN"], target_lang="DA")
135+
ua_header = mock_send.call_args[0][0].headers["User-agent"]
136+
assert "requests/" in ua_header
137+
assert " python/" in ua_header
138+
assert "(" in ua_header
139+
assert " sample_python_plugin/1.0.2" in ua_header
140+
141+
142+
@patch("requests.adapters.HTTPAdapter.send")
143+
def test_user_agent_opt_out_with_app_info(mock_send):
144+
mock_send.return_value = _build_test_response()
145+
translator = deepl.Translator(
146+
os.environ["DEEPL_AUTH_KEY"],
147+
send_platform_info=False,
148+
).set_app_info("sample_python_plugin", "1.0.2")
149+
translator.translate_text(example_text["EN"], target_lang="DA")
150+
ua_header = mock_send.call_args[0][0].headers["User-agent"]
151+
assert "requests/" not in ua_header
152+
assert " python/" not in ua_header
153+
assert "(" not in ua_header
154+
assert " sample_python_plugin/1.0.2" in ua_header
155+
156+
157+
@patch("requests.adapters.HTTPAdapter.send")
158+
def test_custom_user_agent_with_app_info(mock_send):
159+
mock_send.return_value = _build_test_response()
160+
old_user_agent = deepl.http_client.user_agent
161+
deepl.http_client.user_agent = "my custom user agent"
162+
translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]).set_app_info(
163+
"sample_python_plugin", "1.0.2"
164+
)
165+
translator.translate_text(example_text["EN"], target_lang="DA")
166+
ua_header = mock_send.call_args[0][0].headers["User-agent"]
167+
assert ua_header == "my custom user agent sample_python_plugin/1.0.2"
168+
deepl.http_client.user_agent = old_user_agent
169+
170+
89171
@needs_mock_proxy_server
90172
def test_proxy_usage(
91173
server,
@@ -194,3 +276,26 @@ def test_usage_team_document_limit(
194276
assert not usage.document.limit_reached
195277
assert not usage.character.limit_reached
196278
assert usage.team_document.limit_reached
279+
280+
281+
def _build_test_response():
282+
response = Mock(spec=Response)
283+
response.status_code = 200
284+
response.text = (
285+
'{"translations": [{"detected_source_language": "EN", '
286+
'"text": "protonstråle"}]}'
287+
)
288+
response.headers = {
289+
"Content-Type": "application/json",
290+
"Server": "nginx",
291+
"Content-Length": str(len(response.text.encode("utf-8"))),
292+
"Connection": "keep-alive",
293+
"Access-Control-Allow-Origin": "*",
294+
}
295+
response.encoding = "utf-8"
296+
response.history = None
297+
response.raw = None
298+
response.is_redirect = False
299+
response.stream = False
300+
response.url = "https://api.deepl.com/v2/translate"
301+
return response

0 commit comments

Comments
 (0)