From 71ce7504be83b3b2c70ba9a2b083281debd54933 Mon Sep 17 00:00:00 2001 From: thomashebrard Date: Wed, 23 Sep 2026 15:25:14 +0200 Subject: [PATCH] Build the User-Agent through the mthds 0.16.0 client seam PipelexAPIClient overrides user_agent_sdk_tokens() to put pipelex-sdk-python before mthds-python and calls init_user_agent(app_info) from __init__. The package's own AppInfo and header builder are removed; pipelex_sdk.user_agent re-exports the mthds AppInfo and keeps only this SDK's token. mthds pin moves to 0.16.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 ++ README.md | 4 +- docs/architecture.md | 2 +- docs/client-identification.md | 18 +-- pipelex_sdk/client.py | 22 ++-- pipelex_sdk/user_agent.py | 164 +++----------------------- pyproject.toml | 2 +- tests/unit/test_client_user_agent.py | 11 +- tests/unit/test_user_agent.py | 170 +++++++++------------------ uv.lock | 8 +- 10 files changed, 117 insertions(+), 292 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5893fb..afc2509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### Changed + +- **`User-Agent` built through the `mthds` client seam**: `PipelexAPIClient` now overrides `user_agent_sdk_tokens()` to put `pipelex-sdk-python/` before `mthds-python/` and calls `init_user_agent(app_info)` from its constructor, so the builder, the runtime token and the 512-character ceiling are those of `mthds.runners.api.user_agent`; the header keeps its shape, `[app_info] pipelex-sdk-python/ mthds-python/ python/ (; )`, and the `mthds` pin moves from `0.14.0` to `0.16.0`. +- **`pipelex_sdk.user_agent.AppInfo` is the `mthds` class (Breaking)**: the module re-exports `mthds.runners.api.user_agent.AppInfo`, so the import keeps working but `details` is typed `tuple[str, ...]` instead of `list[str]` and the model is no longer strict — a tuple is now accepted, a list is still accepted at run time and stored as a tuple, and a type checker flags a list. +- **`pipelex_sdk.user_agent` slimmed to this SDK's token (Breaking)**: `build_user_agent`, `is_token`, `MAX_USER_AGENT_LENGTH`, `MTHDS_TOKEN_NAME` and `AppInfo.render()` are removed in favour of their `mthds.runners.api.user_agent` counterparts (`build_user_agent`, `render_app_info`, `MAX_USER_AGENT_LENGTH`), the module keeps `SDK_TOKEN_NAME` and adds `pipelex_sdk_token()`, and a platform value that is not a token is no longer dropped from the `(; )` comment by this SDK, only an empty one. + ## [v0.11.0] - 2026-09-23 ### Added diff --git a/README.md b/README.md index 705ba83..d93699f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ 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). +`app_info` (constructor argument, an `AppInfo`, the `mthds` class re-exported 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.16.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. @@ -145,7 +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` +- **Client identification** — `from pipelex_sdk.user_agent import AppInfo, SDK_TOKEN_NAME, pipelex_sdk_token` (`AppInfo` is `mthds.runners.api.user_agent.AppInfo`, re-exported) - **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 0577399..f143590 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,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 — 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`. +`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 through the `mthds` seam — `user_agent_sdk_tokens()` overridden to put `pipelex-sdk-python/` before `mthds-python/`, and `init_user_agent(app_info)` called from `__init__` — 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 index 8f441ff..36a0b06 100644 --- a/docs/client-identification.md +++ b/docs/client-identification.md @@ -7,27 +7,27 @@ Every request this SDK sends to the Pipelex API carries a `User-Agent` header th 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) +acme-invoicer/1.4.0 pipelex-sdk-python/0.11.0 mthds-python/0.16.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. +- `mthds-python/` is the `mthds` library's own token, carrying the version that package reports; `mthds` adds it, not this SDK. +- `python/ (; )` is built by `mthds` from `sys.version_info`, `platform.system().lower()` and `platform.machine()`. An empty platform value 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 built once, when the client is constructed, and is exposed as `client.user_agent`; `client.app_info` holds the `AppInfo` it was built with. 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`: +An integrator can put its own name in front of the SDK's tokens by passing an `AppInfo`, shaped like Stripe's `appInfo`. `pipelex_sdk.user_agent.AppInfo` is the `mthds` class `mthds.runners.api.user_agent.AppInfo` re-exported, so either import names the same type: ```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"), + 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/..." ``` @@ -37,12 +37,12 @@ client = PipelexAPIClient( | `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 | +| `details` | no | A tuple of comment parameters, each a token or `token=value`, where the value is a token or a `name/version` product. A list is accepted at run time and stored as a tuple, but the field is typed `tuple[str, ...]` | -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. +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, or an unknown one, is refused when the `AppInfo` is constructed, with a `pydantic.ValidationError`, which is a `ValueError`; it is never silently dropped or rewritten. The model is frozen but not strict, so pydantic's usual coercions apply. 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. +The header builder belongs to the `mthds` library, in `mthds.runners.api.user_agent`, and `MthdsAPIClient` exposes a seam for the SDKs built on it. `PipelexAPIClient` overrides the class method `user_agent_sdk_tokens()` to return its own `pipelex-sdk-python/` token (from `pipelex_sdk.user_agent.pipelex_sdk_token()`) in front of the base's `mthds-python/`, and calls `init_user_agent(app_info)` from its `__init__`, which sets `app_info` and builds `user_agent`. It calls the seam rather than the base's `__init__` because that constructor reads the `mthds` resolver, which this client must not. The runtime token, the `AppInfo` validation and the 512-character ceiling are therefore the ones `mthds` applies, and `pipelex_sdk.user_agent` holds only this SDK's token name and the re-exported `AppInfo`. diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 13d44f5..8acecad 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -102,7 +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.user_agent import AppInfo, pipelex_sdk_token from pipelex_sdk.validation_models import PipelexValidationResultAdapter, ValidationErrorItem if TYPE_CHECKING: @@ -233,8 +233,8 @@ 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`). + `app_info` (an `AppInfo`, the `mthds` class re-exported by `pipelex_sdk.user_agent`) puts the + integrator's own name before this SDK's tokens in the `User-Agent` every request carries. """ def __init__( @@ -297,15 +297,21 @@ def __init__( 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) + # This `__init__` does not call the base's (whose resolver it must not read), so it + # calls the base's seam instead: `init_user_agent` sets `app_info` and builds + # `user_agent` from `user_agent_sdk_tokens()` below, once, so an over-long header + # fails at construction rather than on the first call. + self.init_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 + @classmethod + @override + def user_agent_sdk_tokens(cls) -> tuple[str, ...]: + """This SDK's token in front of the base's: `pipelex-sdk-python/ mthds-python/`.""" + return (pipelex_sdk_token(), *super().user_agent_sdk_tokens()) + @override def start_client(self) -> PipelexAPIClient: """Initialize the HTTP client. The Authorization header is sent only when a key diff --git a/pipelex_sdk/user_agent.py b/pipelex_sdk/user_agent.py index a251440..69a19a8 100644 --- a/pipelex_sdk/user_agent.py +++ b/pipelex_sdk/user_agent.py @@ -1,163 +1,29 @@ -"""The `User-Agent` this SDK sends on every request to the Pipelex API. +"""This SDK's part of the `User-Agent` every request to the Pipelex API carries. -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: +The header follows the workspace client-identification spec: 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) + acme-invoicer/1.4.0 pipelex-sdk-python/0.11.0 mthds-python/0.16.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. +The builder, the runtime token and `AppInfo` belong to `mthds.runners.api.user_agent`; this module +only contributes this SDK's own token and re-exports `AppInfo`, so `from pipelex_sdk.user_agent +import AppInfo` names the very class `MthdsAPIClient` accepts. `PipelexAPIClient` puts the token in +front of the base's through the `user_agent_sdk_tokens()` seam. """ 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 mthds.runners.api.user_agent import AppInfo, product_token from pipelex_sdk.version import __version__ +__all__ = ["SDK_TOKEN_NAME", "AppInfo", "pipelex_sdk_token"] + #: 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 +def pipelex_sdk_token() -> str: + """This SDK's own product token, `pipelex-sdk-python/`.""" + return product_token(SDK_TOKEN_NAME, __version__) diff --git a/pyproject.toml b/pyproject.toml index 0a01c2f..3d62aa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ ] dependencies = [ - "mthds==0.14.0", + "mthds==0.16.0", "pydantic>=2.10.6,<3.0.0", "typing-extensions>=4.0.0", "httpx>=0.23.0,<1.0.0", diff --git a/tests/unit/test_client_user_agent.py b/tests/unit/test_client_user_agent.py index f153d4c..3ee4f2c 100644 --- a/tests/unit/test_client_user_agent.py +++ b/tests/unit/test_client_user_agent.py @@ -2,15 +2,15 @@ import asyncio import os -from importlib.metadata import version from typing import Any import httpx import pytest +from mthds.version import __version__ as mthds_version from pytest_mock import MockerFixture from pipelex_sdk.client import PipelexAPIClient -from pipelex_sdk.user_agent import AppInfo, build_user_agent +from pipelex_sdk.user_agent import AppInfo from pipelex_sdk.version import __version__ _BASE_URL = "http://localhost:8081" @@ -62,16 +62,15 @@ def test_anonymous_client_also_sends_user_agent(self, captured: list[httpx.Reque 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/") + assert captured[0].headers["User-Agent"].startswith(f"pipelex-sdk-python/{__version__} mthds-python/{mthds_version} 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") + 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"): + with pytest.raises(ValueError, match="512-character limit"): 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 index 883cbba..feede70 100644 --- a/tests/unit/test_user_agent.py +++ b/tests/unit/test_user_agent.py @@ -1,147 +1,93 @@ -"""Tests for `pipelex_sdk.user_agent` — `AppInfo` rendering and refusals, and the header's composition.""" +"""Tests for `pipelex_sdk.user_agent` and the `User-Agent` `PipelexAPIClient` builds through the `mthds` seam.""" +import os import re -from importlib.metadata import PackageNotFoundError, version import pytest +from mthds.runners.api import user_agent as mthds_user_agent +from mthds.version import __version__ as mthds_version 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.client import PipelexAPIClient +from pipelex_sdk.user_agent import SDK_TOKEN_NAME, AppInfo, pipelex_sdk_token from pipelex_sdk.version import __version__ +_BASE_URL = "http://localhost:8081" # The spec's runtime token: `python/ (; )`. _RUNTIME_PATTERN = r"python/\d+\.\d+\.\d+ \([^;()]+; [^;()]+\)" class TestUserAgent: - # ── is_token ───────────────────────────────────────────────────── + @pytest.fixture(autouse=True) + def _isolate_env(self, mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {}, clear=True) - @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 + # ── The re-exported AppInfo ────────────────────────────────────── - @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 + def test_app_info_is_the_mthds_class(self) -> None: + assert AppInfo is mthds_user_agent.AppInfo - # ── 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 + @pytest.mark.parametrize("details", [["batch", "host=openai"], ("batch", "host=openai")]) + def test_app_info_details_accept_a_list_or_a_tuple(self, details: list[str] | tuple[str, ...]) -> None: + # A list is typed as a tuple but still accepted at run time (the model is not strict). + assert AppInfo.model_validate({"name": "acme", "details": details}).details == ("batch", "host=openai") def test_app_info_empty_optional_fields_count_as_absent(self) -> None: - app_info = AppInfo(name="acme", version="", url="", details=[]) + 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) + assert app_info.details == () @pytest.mark.parametrize( - "url", + "fields", [ - "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", + {"name": "acme invoicer"}, + {"name": "acme", "version": "1 4"}, + {"name": "acme", "url": "https://café.example"}, + {"name": "acme", "details": ["a;b"]}, + {"name": "acme", "extra": "x"}, ], ) - 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: + def test_app_info_refuses_an_invalid_field_with_a_value_error(self, fields: dict[str, object]) -> None: with pytest.raises(ValidationError) as exc_info: - AppInfo(name="bad name") + AppInfo.model_validate(fields) 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"}) + # ── This SDK's token ───────────────────────────────────────────── - def test_app_info_is_frozen(self) -> None: - app_info = AppInfo(name="acme") - with pytest.raises(ValidationError): - app_info.name = "other" # type: ignore[misc] + def test_sdk_token_is_the_registered_name_and_package_version(self) -> None: + assert SDK_TOKEN_NAME == "pipelex-sdk-python" + assert pipelex_sdk_token() == f"pipelex-sdk-python/{__version__}" - # ── build_user_agent ───────────────────────────────────────────── + def test_sdk_tokens_put_this_sdk_before_mthds(self) -> None: + assert PipelexAPIClient.user_agent_sdk_tokens() == (f"pipelex-sdk-python/{__version__}", f"mthds-python/{mthds_version}") - 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) + # ── The whole header ───────────────────────────────────────────── - 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_header_without_app_info_starts_with_the_sdk_tokens(self) -> None: + user_agent = PipelexAPIClient(base_url=_BASE_URL).user_agent + expected_prefix = f"pipelex-sdk-python/{__version__} mthds-python/{mthds_version} " + assert re.fullmatch(re.escape(expected_prefix) + _RUNTIME_PATTERN, user_agent) - 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") + def test_header_is_exactly_the_spec_shape(self, mocker: MockerFixture) -> None: + mocker.patch("mthds.runners.api.user_agent.platform.system", return_value="Linux") + mocker.patch("mthds.runners.api.user_agent.platform.machine", return_value="x86_64") + mocker.patch("mthds.runners.api.user_agent.sys.version_info", _VersionInfo(3, 12, 4)) + mocker.patch("mthds.runners.api.user_agent.__version__", "0.16.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) + client = PipelexAPIClient(base_url=_BASE_URL, app_info=AppInfo(name="acme-invoicer", version="1.4.0")) + assert client.user_agent == "acme-invoicer/1.4.0 pipelex-sdk-python/0.11.0 mthds-python/0.16.0 python/3.12.4 (linux; x86_64)" + + def test_over_long_header_fails_at_construction(self) -> None: + with pytest.raises(ValueError, match="512-character limit"): + PipelexAPIClient(base_url=_BASE_URL, app_info=AppInfo(name="a" * 600)) + + +class _VersionInfo: + """A stand-in for `sys.version_info`, which `runtime_token` reads by attribute.""" + + def __init__(self, major: int, minor: int, micro: int) -> None: + self.major = major + self.minor = minor + self.micro = micro diff --git a/uv.lock b/uv.lock index f4154b6..242c111 100644 --- a/uv.lock +++ b/uv.lock @@ -212,7 +212,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -221,9 +221,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/59/4ca9539571a2030f9427aeddb01bc334910953ebdfa4ab7db2051a6b8ae7/mthds-0.14.0.tar.gz", hash = "sha256:d2b4a9cd064004dfd5b802bb71c9894601fba9e598426f96e4bede16d52c3900", size = 221186, upload-time = "2026-09-06T21:44:54.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/03/4aca733536f741f741d0cba3ae3d23dfd56f691c53a0d7e3e7baea371585/mthds-0.16.0.tar.gz", hash = "sha256:cc8f54bc76c9ed13273e7cd2e1c66767e5478fc3640a26481bf099451d56aa29", size = 259394, upload-time = "2026-09-23T12:13:31.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/0b/32908eeed33396c5aafd4bcb2a1adc38d0ab8d85c2fde3af0bf4c2f73745/mthds-0.14.0-py3-none-any.whl", hash = "sha256:59a706205b6e6df47345caac01038588d21ab9e9c246afc765ee50b5f27f05a8", size = 87192, upload-time = "2026-09-06T21:44:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/d0/00/a80f90f0ed7ddff9886f925fbb8dd0061ec0b0e1af49901971664f1a3e12/mthds-0.16.0-py3-none-any.whl", hash = "sha256:67468669451278e8c17409c5fed60eafab8453fd5cd92f333d788b21f7500ebe", size = 103813, upload-time = "2026-09-23T12:13:30.288Z" }, ] [[package]] @@ -326,7 +326,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, - { name = "mthds", specifier = "==0.14.0" }, + { name = "mthds", specifier = "==0.16.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" }, { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, { name = "pylint", marker = "extra == 'dev'", specifier = "==4.0.4" },