Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/<version>` before `mthds-python/<version>` 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/<version> mthds-python/<version> python/<x.y.z> (<os>; <arch>)`, 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 `(<os>; <arch>)` comment by this SDK, only an empty one.

## [v0.12.0] - 2026-09-24

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<v>` before `mthds-python/<v>`, 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).

Expand Down
18 changes: 9 additions & 9 deletions docs/client-identification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` carries the installed `pipelex-sdk` distribution's version, read through `importlib.metadata`, so it cannot drift from the package that ships.
- `mthds-python/<version>` carries the installed `mthds` distribution's version. When that metadata cannot be read, the token is omitted rather than guessed.
- `python/<major.minor.micro> (<os>; <arch>)` 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/<version>` is the `mthds` library's own token, carrying the version that package reports; `mthds` adds it, not this SDK.
- `python/<major.minor.micro> (<os>; <arch>)` 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/..."
```
Expand All @@ -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 (<details>; +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 (<details>; +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/<version>` token (from `pipelex_sdk.user_agent.pipelex_sdk_token()`) in front of the base's `mthds-python/<version>`, 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`.
22 changes: 14 additions & 8 deletions pipelex_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,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:
Expand Down Expand Up @@ -231,8 +231,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__(
Expand Down Expand Up @@ -295,15 +295,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/<v> mthds-python/<v>`."""
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
Expand Down
Loading
Loading