diff --git a/CHANGELOG.md b/CHANGELOG.md index deb5171..7804677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- **`User-Agent` client identification and `app_info`**: every request `PipelexAPIClient` sends, authenticated or anonymous, now carries a `User-Agent` of the form `[app_info] pipelex-sdk-python/ mthds-python/ python/ (; )`, following the workspace client-identification spec, so the platform can attribute SDK traffic in its analytics. The new `app_info` constructor argument takes a `pipelex_sdk.user_agent.AppInfo` (`name`, `version`, `url`, `details`, shaped like Stripe's `appInfo`) that puts the integrator's own name first; an invalid token raises `ValueError` at construction, an empty `version`, `url` or `details` counts as absent, and the built header is readable as `client.user_agent`. See `docs/client-identification.md`. + ## [v0.10.2] - 2026-09-22 ### Added diff --git a/README.md b/README.md index 1db41ac..705ba83 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ The SDK never reads the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL` / ` `request_timeout_seconds` (constructor argument, default 20 min) sets the per-instance blocking-execute ceiling the inherited protocol routes (`execute` / `start` / `validate` / `models` / `version`) use. +`app_info` (constructor argument, an `AppInfo` from `pipelex_sdk.user_agent`) puts your application's name in front of the SDK's own tokens in the `User-Agent` that every request carries — `acme-invoicer/1.4.0 pipelex-sdk-python/0.11.0 mthds-python/0.15.0 python/3.12.4 (linux; x86_64)` — which the platform uses to attribute traffic in its analytics. The header follows the workspace spec `docs/specs/client-identification.md`; see [`docs/client-identification.md`](docs/client-identification.md). + The client is **async-only** (httpx `AsyncClient`) and is an async context manager. ## Quickstart @@ -143,6 +145,7 @@ There is no barrel import — package `__init__.py` files stay empty. Import eac - **Codegen tree** — `from pipelex_sdk.codegen_writer import write_codegen_tree, CodegenTreeWriteReport` to write one, `from pipelex_sdk.codegen_check import run_codegen_check, CodegenCheckReport, CodegenDrift, DriftCategory` to verify one, with the format primitives in `pipelex_sdk.codegen_lock` (`CodegenLock`, `parse_lock`, `load_lock`, `validate_artifact_path`, ...) and `pipelex_sdk.codegen_stamp` (`STAMPABLE_SUFFIXES`, `is_stampable_artifact_path`, `compute_content_hash`, `parse_stamped`, ...) - **Typed errors** — `from pipelex_sdk.errors import ApiResponseError, ApiUnreachableError, PipelineExecuteTimeoutError, PagingNotTerminatingError, RunFailedError, RunTimeoutError, RunLifecycleUnavailableError, RunStillRunningError, CodegenError, CodegenLockError, ...` - **Version** — `from pipelex_sdk.version import __version__` +- **Client identification** — `from pipelex_sdk.user_agent import AppInfo, build_user_agent, is_token` - **Protocol surface** (the MTHDS standard's wire types) comes from the `mthds` dependency — e.g. `from mthds.protocol.exceptions import PipelineRequestError`, `from mthds.protocol.models import ValidationResult` (the neutral verdict union that `PipelexValidationResult` narrows). - **Input-form descriptors and pipe I/O contracts** come from `mthds` too, because they are the standard's artifacts and this SDK only carries them: `from mthds.protocol.input_form import InputForm, InputFormField, ListField, TextField, ...` and `from mthds.protocol.pipe_io_contracts import PipeIOContracts, PipeInputContract, PresenceMarker, IOMultiplicity, ...`. `PipelexValidationReport.input_form` and `.pipe_io_contracts` are typed with them, so a node narrows on its `kind` and a slot's presence and multiplicity read as enums — but `pipelex_sdk` does not re-export the vocabulary, and importing it from here is the one supported path. diff --git a/docs/architecture.md b/docs/architecture.md index c4f8e54..0577399 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,8 @@ Resolved at construction time, Pipelex-only — the SDK **never** consults the ` A token is **optional** (anonymous access is allowed; protocol routes work against anonymous bare runners, product routes return `401`). The base URL is validated host-only (no path/query/fragment/embedded credentials; http/https only). +`app_info` (constructor argument, an `AppInfo`) names the integrator in the `User-Agent`; it is validated when constructed, and the whole header is built and length-checked when the client is constructed ([`client-identification.md`](client-identification.md)). + `request_timeout_seconds` (constructor argument, default `1200.0` — 20 min) sets the per-instance blocking-execute ceiling the inherited protocol routes (`execute` / `start` / `validate` / `models` / `version`) read; the SDK's own poll and product GETs use the shorter `_POLL_REQUEST_TIMEOUT_SECONDS` instead. ## Conventions @@ -58,7 +60,7 @@ The client inherits `mthds`'s `_send` (one raw HTTP request, no status interpret - **`_request_product`** — the product-route path. Serializes the body with `pydantic_core.to_json` (supporting PUT/PATCH/DELETE as well as GET/POST), uses the management-call timeout, maps a non-2xx response to `ApiResponseError`, and is **empty-body tolerant** (a 2xx with no body — DELETE / onboarding / update — returns `None`). - **`_request_json`** — the plainer path for `health` (and, if ever added, the build extensions). Takes an absolute URL, raises `PipelineRequestError` on a non-2xx response. Transport failures still map to `ApiUnreachableError`. -`start_client` is overridden so the `Authorization` header is sent only when a token is configured — anonymous access (empty token) omits it. +`start_client` is overridden so the `Authorization` header is sent only when a token is configured — anonymous access (empty token) omits it — and so the spec-conforming `User-Agent` (built once at construction by `pipelex_sdk.user_agent`, with the optional `app_info` in front) is a default header on every request, authenticated or not. See [`client-identification.md`](client-identification.md) and the workspace spec `docs/specs/client-identification.md`. The `problem+json` / `HTTPException` error body is parsed by `_parse_error_body` into `(error_type, server_message, validation_errors, code)`, handling both `{"detail": {...}}` and `{"detail": "..."}` shapes plus top-level `error_type` / `message` / `code`, and falling through to empty on a non-JSON or non-object body. `validation_errors` is parsed leniently (best-effort error-path enrichment; only reachable via the out-of-scope build-route 422s). diff --git a/docs/client-identification.md b/docs/client-identification.md new file mode 100644 index 0000000..8f441ff --- /dev/null +++ b/docs/client-identification.md @@ -0,0 +1,48 @@ +# Client identification (`User-Agent`) + +Every request this SDK sends to the Pipelex API carries a `User-Agent` header that says which program made it. The platform reads that header to tell a run started from an SDK script apart from one started by the web app, the MCP server, the CLI or a hand-written `curl`, and records the result in product analytics and its access log. The convention is shared by every first-party client and is fixed by the workspace spec `docs/specs/client-identification.md`; this page describes how this SDK follows it. + +## What the header contains + +The value is a list of product tokens, outermost first: the integrator's own name when one is given, then this SDK, then the `mthds` library whose transport `PipelexAPIClient` inherits, then the Python runtime with its operating system and architecture. + +``` +acme-invoicer/1.4.0 pipelex-sdk-python/0.11.0 mthds-python/0.15.0 python/3.12.4 (linux; x86_64) +``` + +- `pipelex-sdk-python/` carries the installed `pipelex-sdk` distribution's version, read through `importlib.metadata`, so it cannot drift from the package that ships. +- `mthds-python/` carries the installed `mthds` distribution's version. When that metadata cannot be read, the token is omitted rather than guessed. +- `python/ (; )` reads `sys.version_info`, `platform.system().lower()` and `platform.machine()`. A platform value that is empty or not a valid token is left out of the comment, and the comment is dropped when neither is readable; the runtime token always stays. + +The header is built once, when the client is constructed, and is exposed as `client.user_agent`. It is a default header of the one `httpx.AsyncClient` that `start_client` creates, so every API request carries it, authenticated or anonymous, including `health`, uploads and the product routes. The object-store fetches of the artifact stack use their own client and are left with httpx's default `User-Agent`, because that traffic goes to a third party. + +The header is self-declared and unauthenticated. It is for analytics and diagnostics only, and the platform never uses it to decide authorization, rate limits or entitlements. + +## Naming your application with `app_info` + +An integrator can put its own name in front of the SDK's tokens by passing an `AppInfo`, shaped like Stripe's `appInfo`: + +```python +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.user_agent import AppInfo + +client = PipelexAPIClient( + app_info=AppInfo(name="acme-invoicer", version="1.4.0", details=["batch"], url="https://acme.example"), +) +# client.user_agent starts with "acme-invoicer/1.4.0 (batch; +https://acme.example) pipelex-sdk-python/..." +``` + +| Field | Required | Meaning | +|---|---|---| +| `name` | yes | An RFC 9110 token (letters, digits and the `tchar` punctuation, with no space, slash, parenthesis or semicolon), such as `acme-invoicer` | +| `version` | no | A token, such as `1.4.0` | +| `url` | no | A URL, rendered in the comment as `+url`; it must be visible ASCII and may not contain whitespace, parentheses, backslashes or semicolons | +| `details` | no | A list of comment parameters, each a token or `token=value`, where the value is a token or a `name/version` product | + +It renders as `name/version (
; +url)`, dropping `/version` when there is no version and the comment when there are neither details nor a URL. An empty `version`, `url` or `details` counts as absent rather than invalid, so `version=""` is stored as `None`. An invalid field is refused when the `AppInfo` is constructed, with a `pydantic.ValidationError`, which is a `ValueError`; it is never silently dropped or rewritten. A header longer than the spec's 512-character ceiling is refused with a `ValueError` when the client is constructed. + +Do not put a secret, a user identifier, an email address or a hostname in `app_info`: the header is logged and analysed. + +## Relation to `mthds` + +The spec places the header builder of the `mthds` library in `mthds.runners.api.user_agent`. The `mthds` version this SDK pins does not ship it yet, so `pipelex_sdk.user_agent` builds the whole header itself and mirrors the public shape the `mthds` builder has: an `AppInfo` model with `name`, `version`, `url` and `details`, and a `ValueError` on an invalid token. diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 77cd024..13d44f5 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -102,6 +102,7 @@ ) from pipelex_sdk.upload import UploadRecord, UploadSource from pipelex_sdk.upload import upload_file as _upload_file_impl +from pipelex_sdk.user_agent import AppInfo, build_user_agent from pipelex_sdk.validation_models import PipelexValidationResultAdapter, ValidationErrorItem if TYPE_CHECKING: @@ -232,9 +233,17 @@ class PipelexAPIClient(MthdsAPIClient): match the JS SDK exactly. The base URL is validated host-only (no path/query/fragment/credentials; http/https only). `request_timeout_seconds` sets the per-instance blocking-execute ceiling the inherited protocol routes read (default 20 min). + `app_info` (an `AppInfo`) puts the integrator's own name before this SDK's tokens in the + `User-Agent` every request carries (see `pipelex_sdk.user_agent`). """ - def __init__(self, api_key: str | None = None, base_url: str | None = None, request_timeout_seconds: float | None = None) -> None: + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + request_timeout_seconds: float | None = None, + app_info: AppInfo | None = None, + ) -> None: # Pipelex-only resolution — this SDK never reads the mthds resolver (`MTHDS_*` # env vars, `~/.mthds/config`). That config stores a (base_url, api_key) pair for # whatever runner the vendor-neutral mthds tooling targets; borrowing its key while @@ -288,6 +297,11 @@ def __init__(self, api_key: str | None = None, base_url: str | None = None, requ self.request_timeout_seconds: float = ( request_timeout_seconds if request_timeout_seconds is not None else self._DEFAULT_REQUEST_TIMEOUT_SECONDS ) + #: The integrator's own name, placed before this SDK's tokens in the `User-Agent`. + self.app_info: AppInfo | None = app_info + #: The `User-Agent` sent on every request (spec: `docs/specs/client-identification.md`), + #: built once here so an over-long header fails at construction, not on the first call. + self.user_agent: str = build_user_agent(app_info) self.client: httpx.AsyncClient | None = None #: Cached `/v1/version` handshake outcome — whether the durable lifecycle is served. self._lifecycle_available: bool | None = None @@ -295,9 +309,13 @@ def __init__(self, api_key: str | None = None, base_url: str | None = None, requ @override def start_client(self) -> PipelexAPIClient: """Initialize the HTTP client. The Authorization header is sent only when a key - is configured — anonymous access (empty key) omits it, matching the JS SDK. + is configured — anonymous access (empty key) omits it, matching the JS SDK. The + `User-Agent` is a default header of this one client, so every API request carries it; + the object-store client of `artifacts` is separate and keeps httpx's own. """ - headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} + headers = {"User-Agent": self.user_agent} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" self.client = httpx.AsyncClient(headers=headers) return self diff --git a/pipelex_sdk/user_agent.py b/pipelex_sdk/user_agent.py new file mode 100644 index 0000000..a251440 --- /dev/null +++ b/pipelex_sdk/user_agent.py @@ -0,0 +1,163 @@ +"""The `User-Agent` this SDK sends on every request to the Pipelex API. + +The header follows the workspace spec `docs/specs/client-identification.md`: product tokens, +outermost first — the integrator's `app_info`, then this SDK, then the `mthds` library whose +transport it inherits, then the Python runtime and its `(; )` comment: + + acme-invoicer/1.4.0 pipelex-sdk-python/0.11.0 mthds-python/0.15.0 python/3.12.4 (linux; x86_64) + +The header is self-declared and unauthenticated: the platform reads it for analytics only. + +The builder lives here rather than in `mthds` because the pinned `mthds` does not ship one yet; +the public shape (`AppInfo`, `ValueError` on an invalid token) matches the one `mthds` will expose. +""" + +from __future__ import annotations + +import platform +import re +import sys +from importlib.metadata import PackageNotFoundError, version + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from pipelex_sdk.version import __version__ + +#: This SDK's product-token name in the spec's closed registry (the repo name, not the PyPI name). +SDK_TOKEN_NAME = "pipelex-sdk-python" +#: The token name of the `mthds` library on this SDK's transport path. +MTHDS_TOKEN_NAME = "mthds-python" +#: The PyPI distribution whose installed version the `mthds-python` token carries. +MTHDS_DISTRIBUTION_NAME = "mthds" +#: The spec's ceiling on the whole header. +MAX_USER_AGENT_LENGTH = 512 + +# RFC 9110 `token` = 1*tchar; tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +_TOKEN_PATTERN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +# A comment parameter value is a `token` or a `name/version` product. +_PARAM_VALUE_PATTERN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+(/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)?$") +# A URL rendered as `+url` inside a comment: visible ASCII only (so no whitespace, control or +# non-ASCII character, which httpx could not encode into the header), and none of the characters +# that would close the comment or start a new parameter. +# The class is `!`..`~` (0x21-0x7E) with `(` `)` `;` `\` cut out of it. +_URL_PATTERN = re.compile(r"^[!-'*-:<-\[\]-~]+$") + + +def is_token(value: str) -> bool: + """Whether `value` is a non-empty RFC 9110 `token` (only `tchar` characters).""" + return _TOKEN_PATTERN.fullmatch(value) is not None + + +def _is_comment_param(value: str) -> bool: + """Whether `value` is a comment parameter: a `token`, or `token=value` with a token or `name/version` value.""" + name, separator, param_value = value.partition("=") + if not separator: + return is_token(value) + return is_token(name) and _PARAM_VALUE_PATTERN.fullmatch(param_value) is not None + + +class AppInfo(BaseModel): + """The integrator's own name, placed before this SDK's tokens in the `User-Agent` (Stripe's `appInfo`). + + It renders as `name/version (
; +url)`, dropping the `/version` and the comment when + they are empty. An empty `version` or `url` counts as absent and is stored as `None`. An + invalid field raises `ValueError` (pydantic's `ValidationError`, a `ValueError` subclass) at + construction — it is never silently dropped or rewritten. + """ + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + name: str + version: str | None = None + url: str | None = None + details: list[str] = Field(default_factory=list[str]) + + @field_validator("name") + @classmethod + def _validate_name(cls, value: str) -> str: + if not is_token(value): + msg = f"app_info.name {value!r} must be an RFC 9110 token (letters, digits and !#$%&'*+-.^_`|~ only)" + raise ValueError(msg) + return value + + @field_validator("version") + @classmethod + def _validate_version(cls, value: str | None) -> str | None: + if value == "": + return None + if value is not None and not is_token(value): + msg = f"app_info.version {value!r} must be an RFC 9110 token (letters, digits and !#$%&'*+-.^_`|~ only)" + raise ValueError(msg) + return value + + @field_validator("url") + @classmethod + def _validate_url(cls, value: str | None) -> str | None: + if value == "": + return None + if value is not None and _URL_PATTERN.fullmatch(value) is None: + msg = f"app_info.url {value!r} must be visible ASCII without whitespace, parentheses, backslashes or semicolons" + raise ValueError(msg) + return value + + @field_validator("details") + @classmethod + def _validate_details(cls, value: list[str]) -> list[str]: + for detail in value: + if not _is_comment_param(detail): + msg = f"app_info.details entry {detail!r} must be a token or token=value, the value a token or name/version" + raise ValueError(msg) + return value + + def render(self) -> str: + """The product token (and optional comment) this app info contributes to the header.""" + product = f"{self.name}/{self.version}" if self.version is not None else self.name + params = list(self.details) + if self.url is not None: + params.append(f"+{self.url}") + if not params: + return product + return f"{product} ({'; '.join(params)})" + + +def _mthds_version() -> str | None: + """The installed `mthds` distribution's version, or `None` when its metadata is unavailable.""" + try: + return version(MTHDS_DISTRIBUTION_NAME) + except PackageNotFoundError: + return None + + +def _runtime_token() -> str: + """`python/ (; )`, keeping each platform part readable as a token. + + A part that is empty or not a token is left out, and the comment with it when neither is readable, + matching `mthds-python`'s runtime token. + """ + major, minor, micro = sys.version_info[:3] + runtime = f"python/{major}.{minor}.{micro}" + platform_parts = [part for part in (platform.system().lower(), platform.machine()) if is_token(part)] + if platform_parts: + return f"{runtime} ({'; '.join(platform_parts)})" + return runtime + + +def build_user_agent(app_info: AppInfo | None = None) -> str: + """Build the spec's `User-Agent`: `[app_info] pipelex-sdk-python/ [mthds-python/] python/ (; )`. + + Raises: + ValueError: when the rendered header exceeds the spec's 512-character ceiling. + """ + parts: list[str] = [] + if app_info is not None: + parts.append(app_info.render()) + parts.append(f"{SDK_TOKEN_NAME}/{__version__}") + mthds_version = _mthds_version() + if mthds_version is not None: + parts.append(f"{MTHDS_TOKEN_NAME}/{mthds_version}") + parts.append(_runtime_token()) + user_agent = " ".join(parts) + if len(user_agent) > MAX_USER_AGENT_LENGTH: + msg = f"The User-Agent would be {len(user_agent)} characters, over the {MAX_USER_AGENT_LENGTH}-character ceiling; shorten app_info" + raise ValueError(msg) + return user_agent diff --git a/tests/unit/test_client_user_agent.py b/tests/unit/test_client_user_agent.py new file mode 100644 index 0000000..f153d4c --- /dev/null +++ b/tests/unit/test_client_user_agent.py @@ -0,0 +1,77 @@ +"""Tests that `PipelexAPIClient` sends the spec's `User-Agent` on real requests (httpx `MockTransport`).""" + +import asyncio +import os +from importlib.metadata import version +from typing import Any + +import httpx +import pytest +from pytest_mock import MockerFixture + +from pipelex_sdk.client import PipelexAPIClient +from pipelex_sdk.user_agent import AppInfo, build_user_agent +from pipelex_sdk.version import __version__ + +_BASE_URL = "http://localhost:8081" +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +class TestClientUserAgent: + @pytest.fixture(autouse=True) + def _isolate_env(self, mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {}, clear=True) + + @pytest.fixture + def captured(self, mocker: MockerFixture) -> list[httpx.Request]: + """Route every AsyncClient `start_client` builds through a MockTransport recording the requests.""" + requests: list[httpx.Request] = [] + + def _handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"status": "ok", "id": "u1"}) + + def _client_factory(**kwargs: Any) -> httpx.AsyncClient: + return _REAL_ASYNC_CLIENT(transport=httpx.MockTransport(_handler), **kwargs) + + mocker.patch("pipelex_sdk.client.httpx.AsyncClient", side_effect=_client_factory) + return requests + + @staticmethod + async def _health_then_me(client: PipelexAPIClient) -> None: + async with client: + await client.health() + await client._request_product("GET", "me") + + def test_authenticated_client_sends_user_agent_on_every_request(self, captured: list[httpx.Request]) -> None: + client = PipelexAPIClient(api_key="pk-test", base_url=_BASE_URL) + asyncio.run(self._health_then_me(client)) + assert [request.url.path for request in captured] == ["/health", "/v1/me"] + for request in captured: + assert request.headers["User-Agent"] == client.user_agent + assert request.headers["Authorization"] == "Bearer pk-test" + + def test_anonymous_client_also_sends_user_agent(self, captured: list[httpx.Request]) -> None: + client = PipelexAPIClient(api_key="", base_url=_BASE_URL) + asyncio.run(self._health_then_me(client)) + assert len(captured) == 2 + for request in captured: + assert request.headers["User-Agent"] == client.user_agent + assert "Authorization" not in request.headers + + def test_header_carries_sdk_and_mthds_tokens(self, captured: list[httpx.Request]) -> None: + client = PipelexAPIClient(base_url=_BASE_URL) + asyncio.run(self._health_then_me(client)) + assert captured[0].headers["User-Agent"].startswith(f"pipelex-sdk-python/{__version__} mthds-python/{version('mthds')} python/") + + def test_app_info_leads_the_header(self, captured: list[httpx.Request]) -> None: + app_info = AppInfo(name="acme-invoicer", version="1.4.0", details=["batch"], url="https://acme.example") + client = PipelexAPIClient(base_url=_BASE_URL, app_info=app_info) + asyncio.run(self._health_then_me(client)) + assert client.app_info == app_info + assert client.user_agent == build_user_agent(app_info) + assert captured[1].headers["User-Agent"].startswith(f"acme-invoicer/1.4.0 (batch; +https://acme.example) pipelex-sdk-python/{__version__} ") + + def test_over_long_app_info_fails_at_construction(self) -> None: + with pytest.raises(ValueError, match="512-character ceiling"): + PipelexAPIClient(base_url=_BASE_URL, app_info=AppInfo(name="a" * 600)) diff --git a/tests/unit/test_user_agent.py b/tests/unit/test_user_agent.py new file mode 100644 index 0000000..883cbba --- /dev/null +++ b/tests/unit/test_user_agent.py @@ -0,0 +1,147 @@ +"""Tests for `pipelex_sdk.user_agent` — `AppInfo` rendering and refusals, and the header's composition.""" + +import re +from importlib.metadata import PackageNotFoundError, version + +import pytest +from pydantic import ValidationError +from pytest_mock import MockerFixture + +from pipelex_sdk.user_agent import MAX_USER_AGENT_LENGTH, AppInfo, build_user_agent, is_token +from pipelex_sdk.version import __version__ + +# The spec's runtime token: `python/ (; )`. +_RUNTIME_PATTERN = r"python/\d+\.\d+\.\d+ \([^;()]+; [^;()]+\)" + + +class TestUserAgent: + # ── is_token ───────────────────────────────────────────────────── + + @pytest.mark.parametrize("value", ["acme-invoicer", "1.4.0", "0.2.16-rc.01", "a", "!#$%&'*+-.^_`|~"]) + def test_is_token_accepts_tchar_strings(self, value: str) -> None: + assert is_token(value) is True + + @pytest.mark.parametrize("value", ["", "acme invoicer", "a/b", "a(b", "a;b", "a=b", "café", "a\tb", 'a"b', "a,b"]) + def test_is_token_refuses_non_tchar_strings(self, value: str) -> None: + assert is_token(value) is False + + # ── AppInfo rendering ──────────────────────────────────────────── + + @pytest.mark.parametrize( + ("app_info", "expected"), + [ + (AppInfo(name="acme-invoicer"), "acme-invoicer"), + (AppInfo(name="acme-invoicer", version="1.4.0"), "acme-invoicer/1.4.0"), + (AppInfo(name="acme-invoicer", url="https://acme.example"), "acme-invoicer (+https://acme.example)"), + ( + AppInfo(name="pipelex-mcp", version="0.17.0", details=["workshop", "host=claude-code/2.1.4"]), + "pipelex-mcp/0.17.0 (workshop; host=claude-code/2.1.4)", + ), + ( + AppInfo(name="acme", version="2", details=["console", "host=openai"], url="https://acme.example/bot"), + "acme/2 (console; host=openai; +https://acme.example/bot)", + ), + ], + ) + def test_app_info_renders_name_version_details_url(self, app_info: AppInfo, expected: str) -> None: + assert app_info.render() == expected + + def test_app_info_empty_optional_fields_count_as_absent(self) -> None: + app_info = AppInfo(name="acme", version="", url="", details=[]) + assert app_info.version is None + assert app_info.url is None + assert app_info.render() == "acme" + + def test_app_info_details_default_is_empty(self) -> None: + assert AppInfo(name="acme").details == [] + + # ── AppInfo refusals ───────────────────────────────────────────── + + @pytest.mark.parametrize("name", ["", "acme invoicer", "acme/1", "acme(x)", "acmé"]) + def test_app_info_refuses_invalid_name(self, name: str) -> None: + with pytest.raises(ValueError, match=r"app_info\.name"): + AppInfo(name=name) + + @pytest.mark.parametrize("app_version", ["1 4", "1/4", "1;4"]) + def test_app_info_refuses_invalid_version(self, app_version: str) -> None: + with pytest.raises(ValueError, match=r"app_info\.version"): + AppInfo(name="acme", version=app_version) + + @pytest.mark.parametrize( + "url", + [ + "https://acme.example/a b", + "https://acme.example/(x)", + "https://acme.example;x", + "https://acme.example\\x", + "https://acme.example\n", + "https://acme.example/\x01", + "https://café.example", + ], + ) + def test_app_info_refuses_invalid_url(self, url: str) -> None: + with pytest.raises(ValueError, match=r"app_info\.url"): + AppInfo(name="acme", url=url) + + @pytest.mark.parametrize("detail", ["", "two words", "a;b", "=value", "key=", "key=a b", "key=a/b/c", "(x)"]) + def test_app_info_refuses_invalid_detail(self, detail: str) -> None: + with pytest.raises(ValueError, match=r"app_info\.details"): + AppInfo(name="acme", details=["ok", detail]) + + def test_app_info_refusal_is_a_validation_error_subclassing_value_error(self) -> None: + with pytest.raises(ValidationError) as exc_info: + AppInfo(name="bad name") + assert isinstance(exc_info.value, ValueError) + + def test_app_info_refuses_unknown_fields(self) -> None: + with pytest.raises(ValidationError): + AppInfo.model_validate({"name": "acme", "extra": "x"}) + + def test_app_info_is_frozen(self) -> None: + app_info = AppInfo(name="acme") + with pytest.raises(ValidationError): + app_info.name = "other" # type: ignore[misc] + + # ── build_user_agent ───────────────────────────────────────────── + + def test_default_header_lists_sdk_mthds_then_runtime(self) -> None: + expected_prefix = f"pipelex-sdk-python/{__version__} mthds-python/{version('mthds')} " + user_agent = build_user_agent() + assert user_agent.startswith(expected_prefix) + assert re.fullmatch(re.escape(expected_prefix) + _RUNTIME_PATTERN, user_agent) + + def test_app_info_is_placed_first(self) -> None: + user_agent = build_user_agent(AppInfo(name="acme-invoicer", version="1.4.0")) + assert user_agent.startswith(f"acme-invoicer/1.4.0 pipelex-sdk-python/{__version__} mthds-python/") + + def test_runtime_token_reads_interpreter_and_platform(self, mocker: MockerFixture) -> None: + mocker.patch("pipelex_sdk.user_agent.platform.system", return_value="Linux") + mocker.patch("pipelex_sdk.user_agent.platform.machine", return_value="x86_64") + mocker.patch("pipelex_sdk.user_agent.sys.version_info", (3, 12, 4, "final", 0)) + mocker.patch("pipelex_sdk.user_agent.version", return_value="0.15.0") + mocker.patch("pipelex_sdk.user_agent.__version__", "0.11.0") + user_agent = build_user_agent(AppInfo(name="acme-invoicer", version="1.4.0")) + assert user_agent == "acme-invoicer/1.4.0 pipelex-sdk-python/0.11.0 mthds-python/0.15.0 python/3.12.4 (linux; x86_64)" + + def test_unreadable_platform_part_is_left_out(self, mocker: MockerFixture) -> None: + mocker.patch("pipelex_sdk.user_agent.platform.system", return_value="") + mocker.patch("pipelex_sdk.user_agent.platform.machine", return_value="x86_64") + user_agent = build_user_agent() + assert re.search(r" python/\d+\.\d+\.\d+ \(x86_64\)$", user_agent) + + def test_unreadable_platform_drops_the_comment(self, mocker: MockerFixture) -> None: + mocker.patch("pipelex_sdk.user_agent.platform.system", return_value="") + mocker.patch("pipelex_sdk.user_agent.platform.machine", return_value="two words") + user_agent = build_user_agent() + assert re.search(r" python/\d+\.\d+\.\d+$", user_agent) + + def test_missing_mthds_metadata_omits_the_mthds_token(self, mocker: MockerFixture) -> None: + mocker.patch("pipelex_sdk.user_agent.version", side_effect=PackageNotFoundError("mthds")) + user_agent = build_user_agent() + assert "mthds-python" not in user_agent + assert user_agent.startswith(f"pipelex-sdk-python/{__version__} python/") + + def test_over_long_header_is_refused(self) -> None: + app_info = AppInfo(name="a" * MAX_USER_AGENT_LENGTH) + with pytest.raises(ValueError, match="512-character ceiling"): + build_user_agent(app_info)