From df1776287fc289167b74eb9090feb986e90fefe1 Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 15 Nov 2026 20:24:36 +0300 Subject: [PATCH] feat: optional client_name for tools built on the SDK Tools and integrations that wrap the SDK can name themselves. The name goes ahead of the SDK's own name in the client headers, as in the JS SDK. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017B6pTTFVhKWwcKLit7MEdz --- README.md | 3 +++ src/textbee/_client.py | 21 +++++++++++++++++++-- tests/test_client.py | 17 ++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6c3c2a6..9eb77a7 100644 --- a/README.md +++ b/README.md @@ -279,9 +279,12 @@ Textbee( api_key="your-api-key", base_url="https://api.textbee.dev/api/v1", # the default timeout=30.0, # seconds for each connect and each read + client_name="my-app/1.2.0", # optional ) ``` +Set `client_name` when you publish a tool or integration built on this SDK. It names your tool in the request headers, ahead of the SDK's own name. Use `name/version`. + ### Self-hosted textbee Point `base_url` at your own instance. A bare origin gets `/api/v1` added, so all of these reach the same API: diff --git a/src/textbee/_client.py b/src/textbee/_client.py index b66a5e1..d30b3c7 100644 --- a/src/textbee/_client.py +++ b/src/textbee/_client.py @@ -26,6 +26,7 @@ DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1" DEFAULT_TIMEOUT = 30.0 CLIENT_ID = f"textbee-python/{__version__}" +_CLIENT_NAME_PART = re.compile(r"[A-Za-z0-9._-]{1,64}/[A-Za-z0-9._+-]{1,32}") DateInput = Union[str, datetime] QueryValue = Union[str, int, None] @@ -46,6 +47,7 @@ def __init__( *, base_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, + client_name: str | None = None, ) -> None: """Create a client. @@ -63,6 +65,11 @@ def __init__( ``timeout`` is in seconds and applies to each connect and read, not to the request as a whole. + + ``client_name`` names an app or tool built on this SDK, for example + ``my-app/1.2.0``. It is sent ahead of the SDK's own name in the client + headers. Each space-separated part must look like ``name/version``, or + ``ValueError`` is raised. """ api_key = (api_key or "").strip() if not api_key: @@ -74,6 +81,7 @@ def __init__( self._api_key = api_key self._base_url = _normalize_base_url(base_url) self._timeout = timeout + self._client_id = _client_id(client_name) def __repr__(self) -> str: return f"Textbee(base_url={self._base_url!r})" @@ -292,8 +300,8 @@ def _request( headers = { "x-api-key": self._api_key, - "user-agent": CLIENT_ID, - "x-sdk-client": CLIENT_ID, + "user-agent": self._client_id, + "x-sdk-client": self._client_id, "accept": "application/json", } data = None @@ -466,3 +474,12 @@ def _error_message(payload: Any, status: int) -> str: return "; ".join(value) return f"HTTP {status}" + + +def _client_id(client_name: str | None) -> str: + if client_name is None: + return CLIENT_ID + parts = client_name.split() if isinstance(client_name, str) else [] + if not parts or len(parts) > 4 or not all(_CLIENT_NAME_PART.fullmatch(p) for p in parts): + raise ValueError("client_name must look like name/version, for example my-app/1.2.0") + return " ".join([*parts, CLIENT_ID]) diff --git a/tests/test_client.py b/tests/test_client.py index 7c4b125..ba181e1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -299,7 +299,7 @@ def test_never_puts_the_api_key_in_the_error_or_repr(self, http: FakeUrlopen) -> assert API_KEY not in repr(client()) -class TestTelemetryHeaders: +class TestClientHeaders: def test_identifies_the_sdk_and_its_version_on_every_request(self, http: FakeUrlopen) -> None: client().get_devices() @@ -308,6 +308,21 @@ def test_identifies_the_sdk_and_its_version_on_every_request(self, http: FakeUrl assert http.last.headers["x-sdk-client"] == expected assert http.last.headers["accept"] == "application/json" + def test_puts_a_client_name_ahead_of_the_sdk(self, http: FakeUrlopen) -> None: + Textbee(API_KEY, client_name=" my-tool/1.2.0 host/2.0.0-beta.1 ").get_devices() + + expected = f"my-tool/1.2.0 host/2.0.0-beta.1 textbee-python/{__version__}" + assert http.last.headers["user-agent"] == expected + assert http.last.headers["x-sdk-client"] == expected + + @pytest.mark.parametrize( + "client_name", + ["", "my tool", "my-tool", "a/1 b/2 c/3 d/4 e/5", "my-tool/1.0\nx: y"], + ) + def test_rejects_a_malformed_client_name(self, client_name: str) -> None: + with pytest.raises(ValueError): + Textbee(API_KEY, client_name=client_name) + class TestResponseEnvelopes: def test_returns_get_messages_as_data_and_meta_because_it_has_no_outer_wrapper(