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(