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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 19 additions & 2 deletions src/textbee/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -46,6 +47,7 @@ def __init__(
*,
base_url: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
client_name: str | None = None,
) -> None:
"""Create a client.

Expand All @@ -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:
Expand All @@ -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})"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
17 changes: 16 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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(
Expand Down
Loading