Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version> (Python; <python version>) webshare-python/<version>`. The `source` client option is unchanged and still replaces that product token.

## [0.1.1] - 2026-08-25

### Changed
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version> (Python; <python version>)`) — 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/<version> (Python; <python version>) webshare-python/<version>`.
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

Expand Down
4 changes: 2 additions & 2 deletions src/webshare/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/webshare/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version> (Python; <runtime>)``).
source: Replaces the caller-identification product token that leads the
``User-Agent`` (default ``WebshareSDK/<version> (Python; <runtime>)``).
"""

def __init__(
Expand Down
5 changes: 3 additions & 2 deletions src/webshare/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 13 additions & 8 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading