From a106b5fbcc727d51d99dd9e0d88acb985389442f Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Mon, 28 Sep 2026 15:36:59 +0200 Subject: [PATCH] feat: send the caller identification in the User-Agent --- CHANGELOG.md | 4 ++++ README.md | 15 ++++++++------- src/webshare/_base_client.py | 4 ++-- src/webshare/_client.py | 4 ++-- src/webshare/_http.py | 5 +++-- tests/test_client.py | 21 +++++++++++++-------- 6 files changed, 32 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d123512..d092849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org). ## [Unreleased] +### Changed + +- The caller identification moved from the `X-Webshare-Source` header into `User-Agent`, which now leads with the product token and ends with the library: `WebshareSDK/ (Python; ) webshare-python/`. The `source` client option is unchanged and still replaces that product token. + ## [0.1.1] - 2026-08-25 ### Changed diff --git a/README.md b/README.md index ba7894b..434dad8 100644 --- a/README.md +++ b/README.md @@ -220,13 +220,14 @@ url = build_proxy_url( URL from the config's `proxy_list_download_token`; `client.proxies.download(...)` fetches it directly and returns the text. -## Identification header - -Every request carries an `X-Webshare-Source` header identifying the caller -for API-side tracking. It names only the SDK and the Python runtime version -(default `WebshareSDK/ (Python; )`) — no user data. -Tools built on the SDK can replace it via the `source` client option, and -per-request headers override it as usual. +## Identification + +Every request carries a `User-Agent` that leads with a product token +identifying the caller for API-side tracking, followed by the library: +`WebshareSDK/ (Python; ) webshare-python/`. +It names only the SDK and the Python runtime, no user data. Tools built on the +SDK can replace the product token via the `source` client option, and +per-request headers override the whole header as usual. ## Supported versions diff --git a/src/webshare/_base_client.py b/src/webshare/_base_client.py index 9461e74..98f6bb0 100644 --- a/src/webshare/_base_client.py +++ b/src/webshare/_base_client.py @@ -114,8 +114,8 @@ def __init__( self.federated_user_id = federated_user_id self.retry_non_idempotent = retry_non_idempotent self.user_agent = f"webshare-python/{__version__}" - # X-Webshare-Source identifies the caller for API-side tracking; the - # `source` option replaces the whole value. + # The product token leading the User-Agent identifies the caller for API-side + # tracking; the `source` option replaces that token. self.source = source or (f"WebshareSDK/{__version__} (Python; {platform.python_version()})") # When the user injects an http_client without an explicit timeout, # the injected client's own timeout configuration is respected. diff --git a/src/webshare/_client.py b/src/webshare/_client.py index c517212..79d9a7c 100644 --- a/src/webshare/_client.py +++ b/src/webshare/_client.py @@ -92,8 +92,8 @@ class Webshare(BaseClient): unauthenticated: Construct a credential-free client for the handful of unauthenticated endpoints; calling an authenticated operation on such a client raises ``WebshareError``. - source: Replaces the ``X-Webshare-Source`` caller-identification - header (default ``WebshareSDK/ (Python; )``). + source: Replaces the caller-identification product token that leads the + ``User-Agent`` (default ``WebshareSDK/ (Python; )``). """ def __init__( diff --git a/src/webshare/_http.py b/src/webshare/_http.py index cbe7694..d71cc64 100644 --- a/src/webshare/_http.py +++ b/src/webshare/_http.py @@ -241,8 +241,9 @@ def build_headers( """ headers: dict[str, str] = { "Accept": "application/json", - "User-Agent": user_agent, - "X-Webshare-Source": source, + # The product token leads, so the API can tell a tool built on the SDK from a + # plain SDK call; the library that carried it follows. + "User-Agent": f"{source} {user_agent}", } if has_json_body: headers["Content-Type"] = "application/json" diff --git a/tests/test_client.py b/tests/test_client.py index 65981cf..9b69601 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -27,7 +27,7 @@ def test_auth_header_and_defaults(server: MockServer) -> None: assert request.path == "/api/v2/profile/" assert request.headers["Authorization"] == "Token test-key" assert request.headers["Accept"] == "application/json" - assert request.headers["User-Agent"] == f"webshare-python/{webshare.__version__}" + assert request.headers["User-Agent"].endswith(f" webshare-python/{webshare.__version__}") def test_api_key_from_environment(server: MockServer, monkeypatch: pytest.MonkeyPatch) -> None: @@ -178,25 +178,30 @@ def test_unauthenticated_client(server: MockServer, monkeypatch: pytest.MonkeyPa assert len(server.requests) == 1 -def test_source_header_default_format(server: MockServer) -> None: +def test_user_agent_default_format(server: MockServer) -> None: import re server.enqueue(json_body=PROFILE) with make_client(server) as client: client.profile.get() - source = server.requests[0].headers["X-Webshare-Source"] - assert re.fullmatch(r"WebshareSDK/\d+\.\d+\.\d+ \(Python; \d+\.\d+\.\d+[^)]*\)", source), source + user_agent = server.requests[0].headers["User-Agent"] + pattern = ( + r"WebshareSDK/\d+\.\d+\.\d+ \(Python; \d+\.\d+\.\d+[^)]*\) webshare-python/\d+\.\d+\.\d+" + ) + assert re.fullmatch(pattern, user_agent), user_agent + assert "X-Webshare-Source" not in server.requests[0].headers -def test_source_header_override(server: MockServer) -> None: +def test_user_agent_product_token_override(server: MockServer) -> None: server.enqueue(json_body=PROFILE) server.enqueue(json_body=PROFILE) with make_client(server, source="WebshareCLI/1.2.3") as client: client.profile.get() - assert server.requests[0].headers["X-Webshare-Source"] == "WebshareCLI/1.2.3" + expected = f"WebshareCLI/1.2.3 webshare-python/{webshare.__version__}" + assert server.requests[0].headers["User-Agent"] == expected # Per-request headers still win over everything. - client.profile.get(headers={"X-Webshare-Source": "custom/0"}) - assert server.requests[1].headers["X-Webshare-Source"] == "custom/0" + client.profile.get(headers={"User-Agent": "custom/0"}) + assert server.requests[1].headers["User-Agent"] == "custom/0" def test_default_headers_merge_case_insensitively(server: MockServer) -> None: