From d3ee5bfa2751e343fe36e77a52eef5f56d07bd7e Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:32:13 +0200 Subject: [PATCH 1/2] Release v0.2.0 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014V513wLcoqKEPYtbsCApBU --- CHANGELOG.md | 13 +++++ CLAUDE.md | 2 +- README.md | 2 +- docs/architecture.md | 4 +- pipelex_sdk/client.py | 70 +++++++++++++++----------- pipelex_sdk/errors.py | 2 +- pyproject.toml | 4 +- tests/unit/test_client_construction.py | 58 ++++++++++----------- tests/unit/test_client_execute.py | 6 +-- tests/unit/test_client_health.py | 6 +-- tests/unit/test_client_lifecycle.py | 10 ++-- tests/unit/test_client_product.py | 6 +-- tests/unit/test_client_run_fallback.py | 6 +-- tests/unit/test_client_transport.py | 6 +-- tests/unit/test_client_validate.py | 6 +-- uv.lock | 10 ++-- 16 files changed, 117 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9c2cb..a95c156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [v0.2.0] - 2026-07-02 + +### Added + +- Added a `request_timeout_seconds` constructor parameter to `PipelexAPIClient`, setting a per-instance blocking-execute ceiling for the inherited protocol routes (`execute`, `start`, `validate`, `models`, `version`). + +### Changed + +- **BREAKING:** Renamed `PipelexAPIClient` constructor parameters and attributes to match the `mthds` base client and the `@pipelex/sdk` JavaScript counterpart: `api_token` → `api_key` and `api_base_url` → `base_url`. *(Migration: update all instantiations and property reads to the new names.)* +- **BREAKING:** Renamed API URL environment variables for workspace-wide consistency: `PIPELEX_API_URL` → `PIPELEX_BASE_URL` and the `mthds` fallback `MTHDS_API_URL` → `MTHDS_BASE_URL`. No read aliases are kept for the old names. +- Bumped the `mthds` dependency from `>=0.6.1` to `>=0.7.0`, and updated config loading to import `load_config` from `mthds.config` (previously `load_credentials` from `mthds.config.credentials`). +- Updated documentation (`README.md`, `CLAUDE.md`, `docs/architecture.md`) and unit tests to reflect the new client signature, environment variables, and `mthds` configuration. + ## [v0.1.1] - 2026-07-01 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 4345a6e..2866c79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ It is the **hosted superset**: the five normative MTHDS Protocol routes (inherit - **One-way dependency: `pipelex-sdk → mthds`.** This package depends on `mthds` and never the reverse. - **Inheritance, not re-implementation.** `class PipelexAPIClient(MthdsAPIClient)`. Reuse the base transport (`_send`, `_url`), body-builders, the reusable protocol methods, `runner_type`, and the async context-manager. Add lifecycle/product/health on top. The base's single-underscore transport methods are treated as a documented **protected extension surface** — do not rename or fork them. - **Brand boundary (MTHDS vs Pipelex).** MTHDS = the open standard's brand; Pipelex = the runtime/product brand. Protocol routes and their models belong to `mthds` and keep neutral names; Pipelex-specific surfaces (lifecycle, product routes, implementation envelopes) live here. Name by which brand owns the concept. -- **Credentials.** Resolve `PIPELEX_API_KEY` / `PIPELEX_API_URL` first, then fall back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_API_URL`, `~/.mthds/config`). Token is **optional** (anonymous allowed). Default base URL `https://api.pipelex.com`. +- **Credentials.** Resolve `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` first, then fall back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL`, `~/.mthds/config`). Token is **optional** (anonymous allowed). Default base URL `https://api.pipelex.com`. - **Async-only.** httpx `AsyncClient`, `async def` throughout. No sync facade in v0.1. - **No barrel.** `__init__.py` files stay empty — no re-exports, no docstrings. Import via full paths (`from pipelex_sdk.client import PipelexAPIClient`). diff --git a/README.md b/README.md index fcae8b2..3c3411c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ pip install pipelex-sdk ## Configuration -Credentials resolve, in order: explicit constructor arguments → `PIPELEX_API_KEY` / `PIPELEX_API_URL` → `MTHDS_API_KEY` / `MTHDS_API_URL` (and `~/.mthds/config`) → defaults. The token is **optional** — anonymous access works against the protocol routes (e.g. a local bare runner); the product routes return `401`. The default base URL is `https://api.pipelex.com`. The base URL is host-only (no path/query/fragment); every endpoint composes as `{base}/v1/{endpoint}`. +Credentials resolve, in order: explicit constructor arguments → `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` → `MTHDS_API_KEY` / `MTHDS_BASE_URL` (and `~/.mthds/config`) → defaults. The token is **optional** — anonymous access works against the protocol routes (e.g. a local bare runner); the product routes return `401`. The default base URL is `https://api.pipelex.com`. The base URL is host-only (no path/query/fragment); every endpoint composes as `{base}/v1/{endpoint}`. The client is **async-only** (httpx `AsyncClient`) and is an async context manager. diff --git a/docs/architecture.md b/docs/architecture.md index 52c94cb..65a71ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,8 +35,8 @@ The Pipelex narrowing of the `/v1/validate` verdict union is one such implementa Resolved at construction time: -- `PIPELEX_API_KEY` / `PIPELEX_API_URL` first (brand + JS parity); -- falling back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_API_URL`, `~/.mthds/config`) as a secondary source. +- `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` first (brand + JS parity); +- falling back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL`, `~/.mthds/config`) as a secondary source. A token is **optional** (anonymous access is allowed; protocol routes work against anonymous bare runners, product routes return `401`). The default base URL is `https://api.pipelex.com`. The base URL is validated host-only (no path/query/fragment/embedded credentials; http/https only). diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 46d51e2..963d802 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -26,7 +26,7 @@ from urllib.parse import quote, urlparse import httpx -from mthds.config.credentials import load_credentials +from mthds.config import load_config from mthds.protocol.exceptions import PipelineRequestError from mthds.runners.api.client import MthdsAPIClient from pydantic import BaseModel, TypeAdapter, ValidationError @@ -89,7 +89,7 @@ from pipelex_sdk.runs import RunResultState from pipelex_sdk.validation_models import PipelexValidationResult -# The client composes every endpoint from one origin (PIPELEX_API_URL): `{base}/v1/{endpoint}`. +# The client composes every endpoint from one origin (PIPELEX_BASE_URL): `{base}/v1/{endpoint}`. # The same paths are served by the Pipelex Hosted API (api.pipelex.com) and by a bare # OSS pipelex-api runner (localhost:8081) — the protocol surface is identical; only the # hosted extensions (e.g. run polling) differ, detectable via GET /v1/version. @@ -109,7 +109,7 @@ _GATEWAY_TIMEOUT_THRESHOLD_SECONDS = 28.0 _PIPELEX_API_KEY_ENV = "PIPELEX_API_KEY" -_PIPELEX_API_URL_ENV = "PIPELEX_API_URL" +_PIPELEX_BASE_URL_ENV = "PIPELEX_BASE_URL" # `VersionInfo.implementation` of the bare open-source runner (no run store). Anything # else — the hosted implementation first — is assumed to serve the durable run-lifecycle @@ -139,7 +139,7 @@ class MthdsFile(BaseModel): class PipelexAPIClient(MthdsAPIClient): """Client for the Pipelex hosted API — and any MTHDS-compliant runner. - One base URL (`PIPELEX_API_URL`); every endpoint is `/v1/`: + One base URL (`PIPELEX_BASE_URL`); every endpoint is `/v1/`: - **protocol** (`execute` / `start` / `validate` / `models` / `version`) — inherited from `MthdsAPIClient`; works against any MTHDS-compliant runner, hosted or bare. - **run lifecycle** (`get_run_status` / `get_run_result` / `wait_for_result`) — the @@ -149,30 +149,36 @@ class PipelexAPIClient(MthdsAPIClient): on the structured `ApiResponseError.code`, not the HTTP status. Construction resolves credentials Pipelex-first (`PIPELEX_API_KEY` / - `PIPELEX_API_URL`), falling back to the `mthds` resolver (`MTHDS_API_KEY` / - `MTHDS_API_URL`, `~/.mthds/config`). The token is optional — anonymous access works + `PIPELEX_BASE_URL`), falling back to the `mthds` resolver (`MTHDS_API_KEY` / + `MTHDS_BASE_URL`, `~/.mthds/config`). The token is optional — anonymous access works against the protocol routes; product routes return `401`. The base URL is validated host-only (no path/query/fragment/credentials; http/https only). """ - def __init__(self, api_token: str | None = None, api_base_url: str | None = None) -> None: - credentials = load_credentials() + def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None: + config = load_config() - # Pipelex-primary, mthds fallback. `credentials` already layers env (MTHDS_*) > + # Pipelex-primary, mthds fallback. `config` already layers env (MTHDS_*) > # file (~/.mthds/config) > default, so this ladder gives the full precedence: - # explicit arg > PIPELEX_* env > MTHDS_* env > file > default. The token is optional + # explicit arg > PIPELEX_* env > MTHDS_* env > file > default. The key is optional # and an empty string ("") means anonymous — so the first layer that is *present* - # wins even when it is empty. We test `is not None` (not truthiness) to honor an - # explicit `api_token=""` / `PIPELEX_API_KEY=""`, matching the JS SDK's `??` chain. - self.api_token: str - if api_token is not None: - self.api_token = api_token - elif (pipelex_env_token := os.environ.get(_PIPELEX_API_KEY_ENV)) is not None: - self.api_token = pipelex_env_token + # wins even when it is empty. + # + # DO NOT collapse this into `api_key or os.environ.get(...) or config["api_key"]`. + # `or` treats "" as falsy and would fall through, silently discarding an explicit + # anonymous request (`api_key=""` / `PIPELEX_API_KEY=""`) and reaching for the next + # configured key instead. We test `is not None` (presence, not truthiness) precisely + # to honor the empty string, matching the JS SDK's `??` chain. (The `base_url` line + # below correctly uses `or`: there an empty value has no special meaning.) + self.api_key: str + if api_key is not None: + self.api_key = api_key + elif (pipelex_env_key := os.environ.get(_PIPELEX_API_KEY_ENV)) is not None: + self.api_key = pipelex_env_key else: - self.api_token = credentials["api_key"] + self.api_key = config["api_key"] - resolved_base_url = api_base_url or os.environ.get(_PIPELEX_API_URL_ENV) or credentials["api_url"] or DEFAULT_API_BASE_URL + resolved_base_url = base_url or os.environ.get(_PIPELEX_BASE_URL_ENV) or config["base_url"] or DEFAULT_API_BASE_URL normalized_base_url = resolved_base_url.rstrip("/") # The base URL must be host-only: a path-prefixed value (e.g. `.../v1`) would # compose as `/v1/v1/...` and fail with a misleading endpoint error instead of a @@ -185,19 +191,23 @@ def __init__(self, api_token: str | None = None, api_base_url: str | None = None "Endpoints compose as {base}/v1/{endpoint}." ) raise PipelineRequestError(msg) - self.api_base_url: str = normalized_base_url + self.base_url: str = normalized_base_url #: Origin root derived from the base URL — `/health` lives here, not under `/v1`. self.origin_url: str = _origin_of(normalized_base_url) + #: Per-request timeout the inherited protocol routes (`execute` / `start` / `validate` + #: / `models` / `version`) read — the blocking-execute ceiling. The SDK's own poll and + #: product GETs pass `_POLL_REQUEST_TIMEOUT_SECONDS` explicitly instead. + self.request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS self.client: httpx.AsyncClient | None = None #: Cached `/v1/version` handshake outcome — whether the durable lifecycle is served. self._lifecycle_available: bool | None = None @override def start_client(self) -> PipelexAPIClient: - """Initialize the HTTP client. The Authorization header is sent only when a token - is configured — anonymous access (empty token) omits it, matching the JS SDK. + """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. """ - headers = {"Authorization": f"Bearer {self.api_token}"} if self.api_token else {} + headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} self.client = httpx.AsyncClient(headers=headers) return self @@ -211,12 +221,12 @@ async def _send_or_unreachable(self, method: str, url: str, *, content: bytes | try: return await self._send(method, url, content=content, request_timeout=request_timeout) except httpx.TimeoutException as exc: - msg = f"Could not reach Pipelex API at {self.api_base_url} (timeout)" - raise ApiUnreachableError(msg, api_url=self.api_base_url, code="ABORT_TIMEOUT") from exc + msg = f"Could not reach Pipelex API at {self.base_url} (timeout)" + raise ApiUnreachableError(msg, api_url=self.base_url, code="ABORT_TIMEOUT") from exc except httpx.TransportError as exc: code = type(exc).__name__ - msg = f"Could not reach Pipelex API at {self.api_base_url} ({code})" - raise ApiUnreachableError(msg, api_url=self.api_base_url, code=code) from exc + msg = f"Could not reach Pipelex API at {self.base_url} ({code})" + raise ApiUnreachableError(msg, api_url=self.base_url, code=code) from exc async def _request_product(self, method: str, endpoint: str, *, body: object | None = None) -> Any: """Issue a Pipelex-product request (`/v1/me`, `/v1/methods`, `/v1/billing/*`, …) @@ -256,7 +266,7 @@ def _raise_api_response_error(self, *, method: str, endpoint: str, response: htt msg = f"API {method} /{_API_PREFIX}/{endpoint} failed ({response.status_code}): {detail}" raise ApiResponseError( msg, - api_url=self.api_base_url, + api_url=self.base_url, status=response.status_code, status_text=response.reason_phrase, response_body=body_text, @@ -277,9 +287,9 @@ def _raise_if_lifecycle_unavailable(self, response: httpx.Response, url: str) -> msg = ( f"The durable run lifecycle is not available: {url} returned 404. Run polling is a " f"hosted-API extension (/{_API_PREFIX}/{_RUNS}/*), not part of the MTHDS Protocol; " - "PIPELEX_API_URL points at a bare runner that does not serve it." + "PIPELEX_BASE_URL points at a bare runner that does not serve it." ) - raise RunLifecycleUnavailableError(msg, api_url=self.api_base_url) + raise RunLifecycleUnavailableError(msg, api_url=self.base_url) # ── Protocol surface: `execute` override (gateway-timeout translation) ── diff --git a/pipelex_sdk/errors.py b/pipelex_sdk/errors.py index b7de7c5..05a5cc2 100644 --- a/pipelex_sdk/errors.py +++ b/pipelex_sdk/errors.py @@ -133,7 +133,7 @@ def __init__(self, message: str, run_id: str, timeout_seconds: float) -> None: class RunLifecycleUnavailableError(PipelineRequestError): """Raised when the durable run lifecycle (`/v1/runs/*`) is not served by the - configured `PIPELEX_API_URL`. + configured `PIPELEX_BASE_URL`. Run polling is a hosted-API extension, not part of the MTHDS Protocol: the open-source `pipelex-api` runner executes methods but has no run store, so it diff --git a/pyproject.toml b/pyproject.toml index a39fa9f..5654006 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pipelex-sdk" -version = "0.1.1" +version = "0.2.0" description = "The Python client for the Pipelex hosted API — the MTHDS Protocol surface plus the durable run lifecycle and the Pipelex product surface, built on the `mthds` protocol base." authors = [{ name = "Evotis S.A.S.", email = "oss@pipelex.com" }] maintainers = [{ name = "Pipelex staff", email = "oss@pipelex.com" }] @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ - "mthds>=0.6.1", + "mthds>=0.7.0", "pydantic>=2.10.6,<3.0.0", "backports.strenum>=1.3.0 ; python_version < '3.11'", "typing-extensions>=4.0.0", diff --git a/tests/unit/test_client_construction.py b/tests/unit/test_client_construction.py index d51adaa..2fad2c2 100644 --- a/tests/unit/test_client_construction.py +++ b/tests/unit/test_client_construction.py @@ -8,7 +8,7 @@ from pipelex_sdk.client import PipelexAPIClient -_MTHDS_DEFAULT_CREDENTIALS = {"api_key": "", "api_url": "https://api.pipelex.com", "runner": "api", "telemetry": "0"} +_MTHDS_DEFAULT_CREDENTIALS = {"api_key": "", "base_url": "https://api.pipelex.com", "runner": "api"} class TestClientConstruction: @@ -16,61 +16,61 @@ class TestClientConstruction: def _isolate_env(self, mocker: MockerFixture) -> None: """Hermetic construction — no real env vars, mthds resolver returns defaults.""" mocker.patch.dict(os.environ, {}, clear=True) - mocker.patch("pipelex_sdk.client.load_credentials", return_value=dict(_MTHDS_DEFAULT_CREDENTIALS)) + mocker.patch("pipelex_sdk.client.load_config", return_value=dict(_MTHDS_DEFAULT_CREDENTIALS)) def test_defaults_to_hosted_base_and_anonymous(self) -> None: client = PipelexAPIClient() - assert client.api_base_url == "https://api.pipelex.com" + assert client.base_url == "https://api.pipelex.com" assert client.origin_url == "https://api.pipelex.com" - assert client.api_token == "" + assert client.api_key == "" def test_pipelex_env_takes_precedence_over_mthds(self, mocker: MockerFixture) -> None: - mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-live", "PIPELEX_API_URL": "http://localhost:8081"}, clear=True) + mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-live", "PIPELEX_BASE_URL": "http://localhost:8081"}, clear=True) mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "mthds-key", "api_url": "https://mthds.example.com", "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "mthds-key", "base_url": "https://mthds.example.com", "runner": "api"}, ) client = PipelexAPIClient() - assert client.api_token == "pk-live" - assert client.api_base_url == "http://localhost:8081" + assert client.api_key == "pk-live" + assert client.base_url == "http://localhost:8081" def test_falls_back_to_mthds_credentials(self, mocker: MockerFixture) -> None: mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "mthds-key", "api_url": "https://mthds.example.com", "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "mthds-key", "base_url": "https://mthds.example.com", "runner": "api"}, ) client = PipelexAPIClient() - assert client.api_token == "mthds-key" - assert client.api_base_url == "https://mthds.example.com" + assert client.api_key == "mthds-key" + assert client.base_url == "https://mthds.example.com" def test_explicit_args_override_env_and_credentials(self, mocker: MockerFixture) -> None: - mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-env", "PIPELEX_API_URL": "http://env.example.com"}, clear=True) - client = PipelexAPIClient(api_token="arg-token", api_base_url="https://arg.example.com") - assert client.api_token == "arg-token" - assert client.api_base_url == "https://arg.example.com" + mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-env", "PIPELEX_BASE_URL": "http://env.example.com"}, clear=True) + client = PipelexAPIClient(api_key="arg-token", base_url="https://arg.example.com") + assert client.api_key == "arg-token" + assert client.base_url == "https://arg.example.com" def test_explicit_empty_token_forces_anonymous_over_env(self, mocker: MockerFixture) -> None: - """An explicit `api_token=""` means anonymous and must win over a configured env token.""" + """An explicit `api_key=""` means anonymous and must win over a configured env token.""" mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-env"}, clear=True) - client = PipelexAPIClient(api_token="") - assert client.api_token == "" + client = PipelexAPIClient(api_key="") + assert client.api_key == "" def test_explicit_empty_token_forces_anonymous_over_credentials(self, mocker: MockerFixture) -> None: - """An explicit `api_token=""` must win over an mthds credential token too.""" + """An explicit `api_key=""` must win over an mthds credential token too.""" mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "mthds-key", "api_url": "https://mthds.example.com", "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "mthds-key", "base_url": "https://mthds.example.com", "runner": "api"}, ) - client = PipelexAPIClient(api_token="") - assert client.api_token == "" + client = PipelexAPIClient(api_key="") + assert client.api_key == "" def test_strips_trailing_slash(self) -> None: - client = PipelexAPIClient(api_base_url="https://api.pipelex.com/") - assert client.api_base_url == "https://api.pipelex.com" + client = PipelexAPIClient(base_url="https://api.pipelex.com/") + assert client.base_url == "https://api.pipelex.com" assert client.origin_url == "https://api.pipelex.com" def test_origin_includes_port(self) -> None: - client = PipelexAPIClient(api_base_url="http://localhost:8081") + client = PipelexAPIClient(base_url="http://localhost:8081") assert client.origin_url == "http://localhost:8081" @pytest.mark.parametrize( @@ -87,4 +87,4 @@ def test_origin_includes_port(self) -> None: ) def test_rejects_non_host_only_base_url(self, bad_url: str) -> None: with pytest.raises(PipelineRequestError): - PipelexAPIClient(api_base_url=bad_url) + PipelexAPIClient(base_url=bad_url) diff --git a/tests/unit/test_client_execute.py b/tests/unit/test_client_execute.py index 75d3e07..6e99adf 100644 --- a/tests/unit/test_client_execute.py +++ b/tests/unit/test_client_execute.py @@ -34,12 +34,12 @@ class TestClientExecute: @pytest.fixture(autouse=True) def _mock_credentials(self, mocker: MockerFixture) -> None: mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "", "base_url": "", "runner": "api"}, ) def _client(self) -> PipelexAPIClient: - return PipelexAPIClient(api_token="test-token", api_base_url=_BASE_URL) + return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) def test_gateway_503_past_ceiling_translates_to_timeout(self, mocker: MockerFixture) -> None: client = self._client() diff --git a/tests/unit/test_client_health.py b/tests/unit/test_client_health.py index b9213da..23906b0 100644 --- a/tests/unit/test_client_health.py +++ b/tests/unit/test_client_health.py @@ -27,12 +27,12 @@ class TestClientHealth: @pytest.fixture(autouse=True) def _isolate(self, mocker: MockerFixture) -> None: mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "", "base_url": _BASE_URL, "runner": "api"}, ) def _client(self) -> PipelexAPIClient: - return PipelexAPIClient(api_token="t", api_base_url=_BASE_URL) + return PipelexAPIClient(api_key="t", base_url=_BASE_URL) def test_health_hits_origin_level_path_outside_v1(self, mocker: MockerFixture) -> None: client = self._client() diff --git a/tests/unit/test_client_lifecycle.py b/tests/unit/test_client_lifecycle.py index 346915f..39280be 100644 --- a/tests/unit/test_client_lifecycle.py +++ b/tests/unit/test_client_lifecycle.py @@ -40,23 +40,23 @@ class TestClientLifecycle: def _mock_credentials(self, mocker: MockerFixture) -> None: """Keep construction hermetic — never touch the real credentials file/env.""" mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "", "base_url": "", "runner": "api"}, ) def _client(self) -> PipelexAPIClient: - return PipelexAPIClient(api_token="test-token", api_base_url=_BASE_URL) + return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) # ── start (inherited body-building + bare-runner 404 translation) ── def test_start_targets_v1_url_and_returns_run_result_start(self, mocker: MockerFixture) -> None: """Start posts to /v1/start; a 202 parses into RunResultStart with the authoritative id.""" - client = PipelexAPIClient(api_token="t", api_base_url=f"{_BASE_URL}/") + client = PipelexAPIClient(api_key="t", base_url=f"{_BASE_URL}/") body = {"pipeline_run_id": "run_1", "state": "RUNNING", "created_at": "2026-06-10T00:00:00Z"} send_mock = mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=_response(202, json=body))) started = asyncio.run(client.start(pipe_code="answer")) - assert client.api_base_url == _BASE_URL + assert client.base_url == _BASE_URL assert send_mock.call_args.args[1] == f"{_BASE_URL}/v1/start" assert started.pipeline_run_id == "run_1" diff --git a/tests/unit/test_client_product.py b/tests/unit/test_client_product.py index 57a2356..ea98eeb 100644 --- a/tests/unit/test_client_product.py +++ b/tests/unit/test_client_product.py @@ -51,12 +51,12 @@ class TestClientProduct: @pytest.fixture(autouse=True) def _isolate(self, mocker: MockerFixture) -> None: mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "", "base_url": _BASE_URL, "runner": "api"}, ) def _client(self) -> PipelexAPIClient: - return PipelexAPIClient(api_token="test-token", api_base_url=_BASE_URL) + return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) def _mock_send(self, mocker: MockerFixture, client: PipelexAPIClient, response: httpx.Response) -> MockType: return mocker.patch.object(client, "_send", mocker.AsyncMock(return_value=response)) diff --git a/tests/unit/test_client_run_fallback.py b/tests/unit/test_client_run_fallback.py index 3fb14f9..2072f73 100644 --- a/tests/unit/test_client_run_fallback.py +++ b/tests/unit/test_client_run_fallback.py @@ -45,12 +45,12 @@ class TestClientRunFallback: @pytest.fixture(autouse=True) def _mock_credentials(self, mocker: MockerFixture) -> None: mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "", "base_url": "", "runner": "api"}, ) def _client(self) -> PipelexAPIClient: - return PipelexAPIClient(api_token="test-token", api_base_url=_BASE_URL) + return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) # ── Hosted (durable start + poll) ──────────────────────────── diff --git a/tests/unit/test_client_transport.py b/tests/unit/test_client_transport.py index fc6ec60..041566a 100644 --- a/tests/unit/test_client_transport.py +++ b/tests/unit/test_client_transport.py @@ -27,12 +27,12 @@ class TestClientTransport: @pytest.fixture(autouse=True) def _isolate(self, mocker: MockerFixture) -> None: mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "", "base_url": _BASE_URL, "runner": "api"}, ) def _client(self) -> PipelexAPIClient: - return PipelexAPIClient(api_token="t", api_base_url=_BASE_URL) + return PipelexAPIClient(api_key="t", base_url=_BASE_URL) # ── _request_product ───────────────────────────────────────────── diff --git a/tests/unit/test_client_validate.py b/tests/unit/test_client_validate.py index 6795a22..d0840c8 100644 --- a/tests/unit/test_client_validate.py +++ b/tests/unit/test_client_validate.py @@ -28,12 +28,12 @@ class TestClientValidate: @pytest.fixture(autouse=True) def _isolate(self, mocker: MockerFixture) -> None: mocker.patch( - "pipelex_sdk.client.load_credentials", - return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"}, + "pipelex_sdk.client.load_config", + return_value={"api_key": "", "base_url": _BASE_URL, "runner": "api"}, ) def _client(self) -> PipelexAPIClient: - return PipelexAPIClient(api_token="t", api_base_url=_BASE_URL) + return PipelexAPIClient(api_key="t", base_url=_BASE_URL) def _mock_send(self, mocker: MockerFixture, client: PipelexAPIClient, *, json_body: object) -> MockType: response = httpx.Response(200, json=json_body, request=httpx.Request("POST", f"{_BASE_URL}/v1/validate")) diff --git a/uv.lock b/uv.lock index 29599cc..9bb2138 100644 --- a/uv.lock +++ b/uv.lock @@ -250,7 +250,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.6.1" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, @@ -261,9 +261,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/33/c274de1115b6cbe8ac3475327a622893070f1ca40d9f2d0172d6fcae62b9/mthds-0.6.1.tar.gz", hash = "sha256:3d6b93306708f0ef8971285cfab4d9a488fca364e5db7e63a7dba2d065070eef", size = 132504, upload-time = "2026-07-01T07:09:50.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/fe/52bf1818f41b71658f24ee78dfe24730c04c1066e8b434f39fe23823cbb4/mthds-0.7.0.tar.gz", hash = "sha256:35e8c9d5f4b27e86fec02245f72bc2eb9c849790d39a8ffe6dcb001e7fe7d4cb", size = 136385, upload-time = "2026-07-02T20:17:58.875Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/52/0538d1bd11067f60f86ad74dc4d36bcad81ff3b549f71b3290aebd3a9aeb/mthds-0.6.1-py3-none-any.whl", hash = "sha256:5cd39d2b6fc5b43c5967162d80178b050748502515eda26916c2b80e8f3f1f42", size = 57705, upload-time = "2026-07-01T07:09:49.256Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9f/f12c6de8a2ed9938a5914559639921ded8275f42b8c43ac1af35fe7a488f/mthds-0.7.0-py3-none-any.whl", hash = "sha256:4e371b73ba259899f5ba0b460809d00f176ac44ad773073a3ebb6c10ee207866", size = 57898, upload-time = "2026-07-02T20:17:57.525Z" }, ] [[package]] @@ -350,7 +350,7 @@ wheels = [ [[package]] name = "pipelex-sdk" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, @@ -375,7 +375,7 @@ dev = [ requires-dist = [ { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.3.0" }, { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, - { name = "mthds", specifier = ">=0.6.1" }, + { name = "mthds", specifier = ">=0.7.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" }, From aafc4cf247c93c00d058d78a11d20a36d634074b Mon Sep 17 00:00:00 2001 From: Louis Choquel <8851983+lchoquel@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:03:51 +0200 Subject: [PATCH 2/2] feat!: Pipelex-only credential resolution + request_timeout_seconds, bump mthds to 0.7.1 - Drop the mthds resolver entirely: MTHDS_API_KEY / MTHDS_BASE_URL / ~/.mthds/config are never read. Resolution is api_key arg -> PIPELEX_API_KEY -> anonymous, and base_url arg -> PIPELEX_BASE_URL -> https://api.pipelex.com, matching the JS SDK's ?? chains exactly. - Presence semantics on base_url: an explicit empty string (arg or set-but-empty PIPELEX_BASE_URL) now fails fast with PipelineRequestError instead of silently targeting the hosted default. - Add request_timeout_seconds constructor parameter (default 20 min, inherited from the base ClassVar) for the blocking-execute ceiling. - Bump mthds dependency to >=0.7.1 and re-lock. - Update README, CLAUDE.md, docs/architecture.md, CHANGELOG, and unit tests to the new resolution chains. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014V513wLcoqKEPYtbsCApBU --- CHANGELOG.md | 12 +++-- CLAUDE.md | 2 +- README.md | 8 ++- docs/architecture.md | 10 ++-- pipelex_sdk/client.py | 70 +++++++++++++++----------- pyproject.toml | 2 +- tests/unit/test_client_construction.py | 58 +++++++++++---------- tests/unit/test_client_execute.py | 7 --- tests/unit/test_client_health.py | 7 --- tests/unit/test_client_lifecycle.py | 8 --- tests/unit/test_client_product.py | 7 --- tests/unit/test_client_run_fallback.py | 7 --- tests/unit/test_client_transport.py | 7 --- tests/unit/test_client_validate.py | 7 --- uv.lock | 8 +-- 15 files changed, 102 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a95c156..212a0cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,15 @@ ### Changed - **BREAKING:** Renamed `PipelexAPIClient` constructor parameters and attributes to match the `mthds` base client and the `@pipelex/sdk` JavaScript counterpart: `api_token` → `api_key` and `api_base_url` → `base_url`. *(Migration: update all instantiations and property reads to the new names.)* -- **BREAKING:** Renamed API URL environment variables for workspace-wide consistency: `PIPELEX_API_URL` → `PIPELEX_BASE_URL` and the `mthds` fallback `MTHDS_API_URL` → `MTHDS_BASE_URL`. No read aliases are kept for the old names. -- Bumped the `mthds` dependency from `>=0.6.1` to `>=0.7.0`, and updated config loading to import `load_config` from `mthds.config` (previously `load_credentials` from `mthds.config.credentials`). -- Updated documentation (`README.md`, `CLAUDE.md`, `docs/architecture.md`) and unit tests to reflect the new client signature, environment variables, and `mthds` configuration. +- **BREAKING:** Renamed the API URL environment variable for workspace-wide consistency: `PIPELEX_API_URL` → `PIPELEX_BASE_URL`. No read alias is kept for the old name. +- **BREAKING:** An empty base URL now raises `PipelineRequestError` instead of being treated as unset. Both layers of the `base_url` chain use presence semantics (matching the JS SDK's `??` chain): an explicit `base_url=""` argument or a set-but-empty `PIPELEX_BASE_URL` (e.g. an unfilled CI secret) fails fast at construction rather than silently targeting the hosted default with whatever API key is configured. +- **BREAKING:** `PipelexAPIClient` no longer reads the `mthds` resolver at all — `MTHDS_API_KEY`, `MTHDS_BASE_URL`, and `~/.mthds/config` are ignored. The mthds config stores a `(base_url, api_key)` credential pair for whatever runner the vendor-neutral `mthds` tooling targets; borrowing its key while ignoring its URL could send a key configured for another runner to `api.pipelex.com`. Resolution is now Pipelex-only, matching the JS SDK exactly: `api_key` argument → `PIPELEX_API_KEY` → anonymous, and `base_url` argument → `PIPELEX_BASE_URL` → the hosted default. *(Migration: set `PIPELEX_API_KEY` / pass `api_key`, and `PIPELEX_BASE_URL` / `base_url`, if you relied on `MTHDS_*` or `~/.mthds/config`.)* +- Bumped the `mthds` dependency from `>=0.6.1` to `>=0.7.1`. +- Updated documentation (`README.md`, `CLAUDE.md`, `docs/architecture.md`) and unit tests to reflect the new client signature, environment variables, and credential resolution. + +### Fixed + +- `PipelexAPIClient()` now targets the hosted API (`https://api.pipelex.com`) when nothing is configured, instead of leaking `mthds`'s local bare-runner default (`http://localhost:8081`) through the now-removed `mthds` resolver fallback. ## [v0.1.1] - 2026-07-01 diff --git a/CLAUDE.md b/CLAUDE.md index 2866c79..7b0f1e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ It is the **hosted superset**: the five normative MTHDS Protocol routes (inherit - **One-way dependency: `pipelex-sdk → mthds`.** This package depends on `mthds` and never the reverse. - **Inheritance, not re-implementation.** `class PipelexAPIClient(MthdsAPIClient)`. Reuse the base transport (`_send`, `_url`), body-builders, the reusable protocol methods, `runner_type`, and the async context-manager. Add lifecycle/product/health on top. The base's single-underscore transport methods are treated as a documented **protected extension surface** — do not rename or fork them. - **Brand boundary (MTHDS vs Pipelex).** MTHDS = the open standard's brand; Pipelex = the runtime/product brand. Protocol routes and their models belong to `mthds` and keep neutral names; Pipelex-specific surfaces (lifecycle, product routes, implementation envelopes) live here. Name by which brand owns the concept. -- **Credentials.** Resolve `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` first, then fall back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL`, `~/.mthds/config`). Token is **optional** (anonymous allowed). Default base URL `https://api.pipelex.com`. +- **Credentials.** Resolution is Pipelex-only — this SDK **never** reads the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL` / `~/.mthds/config`): that config is a `(base_url, api_key)` credential pair for whatever runner the vendor-neutral `mthds` tooling targets, and borrowing its key while ignoring its URL would send a foreign key to the hosted API. The **API key** resolves `api_key` arg → `PIPELEX_API_KEY` → anonymous (token is **optional**). The **base URL** resolves `base_url` arg → `PIPELEX_BASE_URL` → the hosted default `https://api.pipelex.com`. Both chains match the JS SDK exactly. `request_timeout_seconds` (constructor arg, default 20 min) sets the per-instance blocking-execute ceiling. - **Async-only.** httpx `AsyncClient`, `async def` throughout. No sync facade in v0.1. - **No barrel.** `__init__.py` files stay empty — no re-exports, no docstrings. Import via full paths (`from pipelex_sdk.client import PipelexAPIClient`). diff --git a/README.md b/README.md index 3c3411c..6916a6f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,13 @@ pip install pipelex-sdk ## Configuration -Credentials resolve, in order: explicit constructor arguments → `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` → `MTHDS_API_KEY` / `MTHDS_BASE_URL` (and `~/.mthds/config`) → defaults. The token is **optional** — anonymous access works against the protocol routes (e.g. a local bare runner); the product routes return `401`. The default base URL is `https://api.pipelex.com`. The base URL is host-only (no path/query/fragment); every endpoint composes as `{base}/v1/{endpoint}`. +The **API key** resolves, in order: explicit `api_key` argument → `PIPELEX_API_KEY` → anonymous. The token is **optional** — anonymous access works against the protocol routes (e.g. a local bare runner); the product routes return `401`. + +The **base URL** resolves, in order: explicit `base_url` argument → `PIPELEX_BASE_URL` → the hosted default `https://api.pipelex.com`. The base URL is host-only (no path/query/fragment); every endpoint composes as `{base}/v1/{endpoint}`. + +The SDK never reads the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL` / `~/.mthds/config`) — those settings configure the vendor-neutral `mthds` tooling and whichever runner it targets, not this Pipelex client. + +`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. The client is **async-only** (httpx `AsyncClient`) and is an async context manager. diff --git a/docs/architecture.md b/docs/architecture.md index 65a71ce..7372af2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,12 +33,14 @@ The Pipelex narrowing of the `/v1/validate` verdict union is one such implementa ## Credentials & configuration -Resolved at construction time: +Resolved at construction time, Pipelex-only — the SDK **never** consults the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL`, `~/.mthds/config`). That config stores a `(base_url, api_key)` credential pair for whatever runner the vendor-neutral `mthds` tooling targets; borrowing the key while ignoring the URL would send a credential configured for another runner to the hosted API (and `mthds`'s base-URL default, a local bare runner on `http://localhost:8081`, would preempt the hosted default). Both chains match the JS SDK exactly: -- `PIPELEX_API_KEY` / `PIPELEX_BASE_URL` first (brand + JS parity); -- falling back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_BASE_URL`, `~/.mthds/config`) as a secondary source. +- **API key** — `api_key` argument → `PIPELEX_API_KEY` → anonymous. Presence, not truthiness, decides the argument layer, so an explicit empty string (`api_key=""`) is honored as an anonymous request rather than falling through to a configured env key (mirrors the JS SDK's `??` chain). +- **Base URL** — `base_url` argument → `PIPELEX_BASE_URL` → the hosted default `https://api.pipelex.com`. Presence semantics at both layers (also the JS `??` chain): an explicit empty string — a `base_url=""` argument or a set-but-empty `PIPELEX_BASE_URL` (e.g. an unfilled CI secret) — reaches the host-only validator and raises `PipelineRequestError` at construction, rather than silently targeting the hosted default with whatever API key is configured. -A token is **optional** (anonymous access is allowed; protocol routes work against anonymous bare runners, product routes return `401`). The default base URL is `https://api.pipelex.com`. The base URL is validated host-only (no path/query/fragment/embedded credentials; http/https only). +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). + +`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 diff --git a/pipelex_sdk/client.py b/pipelex_sdk/client.py index 963d802..bde5dcc 100644 --- a/pipelex_sdk/client.py +++ b/pipelex_sdk/client.py @@ -26,7 +26,6 @@ from urllib.parse import quote, urlparse import httpx -from mthds.config import load_config from mthds.protocol.exceptions import PipelineRequestError from mthds.runners.api.client import MthdsAPIClient from pydantic import BaseModel, TypeAdapter, ValidationError @@ -99,7 +98,6 @@ #: Hosted default — the client composes every endpoint as `{base}/v1/{endpoint}`. DEFAULT_API_BASE_URL = "https://api.pipelex.com" -_DEFAULT_REQUEST_TIMEOUT_SECONDS = 1200.0 # 20 min — matches the runner's blocking-execute ceiling. _POLL_REQUEST_TIMEOUT_SECONDS = 30.0 # single status/result/product GETs; the hosted gateway caps responses at ~30s. _DEFAULT_DEGRADED_RETRY_SECONDS = 5 # matches the platform's `_DEGRADE_RETRY_AFTER_SECONDS`. @@ -148,37 +146,49 @@ class PipelexAPIClient(MthdsAPIClient): surface (added in Phase 3), reached through `_request_product` so callers branch on the structured `ApiResponseError.code`, not the HTTP status. - Construction resolves credentials Pipelex-first (`PIPELEX_API_KEY` / - `PIPELEX_BASE_URL`), falling back to the `mthds` resolver (`MTHDS_API_KEY` / - `MTHDS_BASE_URL`, `~/.mthds/config`). The token is optional — anonymous access works - against the protocol routes; product routes return `401`. The base URL is validated - host-only (no path/query/fragment/credentials; http/https only). + Construction is Pipelex-only — it never reads the `mthds` resolver (`MTHDS_API_KEY` / + `MTHDS_BASE_URL`, `~/.mthds/config`), whose values are a credential pair for whatever + runner the vendor-neutral `mthds` tooling targets, not for this client. The API key + resolves from the `api_key` argument, then `PIPELEX_API_KEY`, then anonymous; the token + is optional — anonymous access works against the protocol routes, while product routes + return `401`. The base URL resolves from the `base_url` argument, then + `PIPELEX_BASE_URL`, then the hosted default (`https://api.pipelex.com`). Both chains + 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). """ - def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None: - config = load_config() - - # Pipelex-primary, mthds fallback. `config` already layers env (MTHDS_*) > - # file (~/.mthds/config) > default, so this ladder gives the full precedence: - # explicit arg > PIPELEX_* env > MTHDS_* env > file > default. The key is optional - # and an empty string ("") means anonymous — so the first layer that is *present* - # wins even when it is empty. + def __init__(self, api_key: str | None = None, base_url: str | None = None, request_timeout_seconds: float | 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 + # ignoring its URL would send a credential configured for another runner to + # api.pipelex.com. So the api_key resolves arg > `PIPELEX_API_KEY` > anonymous, + # matching the JS SDK's `options.apiKey ?? process.env.PIPELEX_API_KEY`. # - # DO NOT collapse this into `api_key or os.environ.get(...) or config["api_key"]`. + # DO NOT collapse the argument layer into `api_key or os.environ.get(...)`: # `or` treats "" as falsy and would fall through, silently discarding an explicit - # anonymous request (`api_key=""` / `PIPELEX_API_KEY=""`) and reaching for the next - # configured key instead. We test `is not None` (presence, not truthiness) precisely - # to honor the empty string, matching the JS SDK's `??` chain. (The `base_url` line - # below correctly uses `or`: there an empty value has no special meaning.) + # anonymous request (`api_key=""`) and reaching for a configured env key instead. + # We test `is not None` (presence, not truthiness) precisely to honor the empty + # string, matching the JS SDK's `??` chain. The env layer needs no such care — + # its fallthrough target IS anonymous (""). self.api_key: str if api_key is not None: self.api_key = api_key - elif (pipelex_env_key := os.environ.get(_PIPELEX_API_KEY_ENV)) is not None: - self.api_key = pipelex_env_key else: - self.api_key = config["api_key"] - - resolved_base_url = base_url or os.environ.get(_PIPELEX_BASE_URL_ENV) or config["base_url"] or DEFAULT_API_BASE_URL + self.api_key = os.environ.get(_PIPELEX_API_KEY_ENV, "") + + # The base_url chain is presence-semantics too (`is not None` at the argument AND + # env layers), matching the JS SDK's `??` chain: an explicit empty string — an + # `base_url=""` argument or a set-but-empty `PIPELEX_BASE_URL` (e.g. an unfilled CI + # secret) — must reach the host-only validator below and fail fast, NOT silently + # fall through to the hosted default and send the configured API key there. + resolved_base_url: str + if base_url is not None: + resolved_base_url = base_url + else: + env_base_url = os.environ.get(_PIPELEX_BASE_URL_ENV) + resolved_base_url = env_base_url if env_base_url is not None else DEFAULT_API_BASE_URL normalized_base_url = resolved_base_url.rstrip("/") # The base URL must be host-only: a path-prefixed value (e.g. `.../v1`) would # compose as `/v1/v1/...` and fail with a misleading endpoint error instead of a @@ -195,9 +205,13 @@ def __init__(self, api_key: str | None = None, base_url: str | None = None) -> N #: Origin root derived from the base URL — `/health` lives here, not under `/v1`. self.origin_url: str = _origin_of(normalized_base_url) #: Per-request timeout the inherited protocol routes (`execute` / `start` / `validate` - #: / `models` / `version`) read — the blocking-execute ceiling. The SDK's own poll and - #: product GETs pass `_POLL_REQUEST_TIMEOUT_SECONDS` explicitly instead. - self.request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS + #: / `models` / `version`) read — the blocking-execute ceiling. The default is the + #: base's `_DEFAULT_REQUEST_TIMEOUT_SECONDS` ClassVar (20 min, the runner's + #: blocking-execute ceiling — part of the documented protected extension surface). + #: The SDK's own poll and product GETs pass `_POLL_REQUEST_TIMEOUT_SECONDS` instead. + self.request_timeout_seconds: float = ( + request_timeout_seconds if request_timeout_seconds is not None else self._DEFAULT_REQUEST_TIMEOUT_SECONDS + ) self.client: httpx.AsyncClient | None = None #: Cached `/v1/version` handshake outcome — whether the durable lifecycle is served. self._lifecycle_available: bool | None = None diff --git a/pyproject.toml b/pyproject.toml index 5654006..8bc0520 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ - "mthds>=0.7.0", + "mthds>=0.7.1", "pydantic>=2.10.6,<3.0.0", "backports.strenum>=1.3.0 ; python_version < '3.11'", "typing-extensions>=4.0.0", diff --git a/tests/unit/test_client_construction.py b/tests/unit/test_client_construction.py index 2fad2c2..94e5b4c 100644 --- a/tests/unit/test_client_construction.py +++ b/tests/unit/test_client_construction.py @@ -8,15 +8,12 @@ from pipelex_sdk.client import PipelexAPIClient -_MTHDS_DEFAULT_CREDENTIALS = {"api_key": "", "base_url": "https://api.pipelex.com", "runner": "api"} - class TestClientConstruction: @pytest.fixture(autouse=True) def _isolate_env(self, mocker: MockerFixture) -> None: - """Hermetic construction — no real env vars, mthds resolver returns defaults.""" + """Hermetic construction — no real env vars.""" mocker.patch.dict(os.environ, {}, clear=True) - mocker.patch("pipelex_sdk.client.load_config", return_value=dict(_MTHDS_DEFAULT_CREDENTIALS)) def test_defaults_to_hosted_base_and_anonymous(self) -> None: client = PipelexAPIClient() @@ -24,26 +21,24 @@ def test_defaults_to_hosted_base_and_anonymous(self) -> None: assert client.origin_url == "https://api.pipelex.com" assert client.api_key == "" - def test_pipelex_env_takes_precedence_over_mthds(self, mocker: MockerFixture) -> None: + def test_reads_pipelex_env_vars(self, mocker: MockerFixture) -> None: mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-live", "PIPELEX_BASE_URL": "http://localhost:8081"}, clear=True) - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "mthds-key", "base_url": "https://mthds.example.com", "runner": "api"}, - ) client = PipelexAPIClient() assert client.api_key == "pk-live" assert client.base_url == "http://localhost:8081" - def test_falls_back_to_mthds_credentials(self, mocker: MockerFixture) -> None: - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "mthds-key", "base_url": "https://mthds.example.com", "runner": "api"}, - ) + def test_mthds_resolver_is_never_consulted(self, mocker: MockerFixture) -> None: + """Regression: this SDK is Pipelex-only. `MTHDS_API_KEY` / `MTHDS_BASE_URL` are a + credential pair for whatever runner the vendor-neutral mthds tooling targets — an + unconfigured client must stay anonymous against the hosted default instead of + borrowing a key configured for another runner. + """ + mocker.patch.dict(os.environ, {"MTHDS_API_KEY": "mthds-key", "MTHDS_BASE_URL": "http://localhost:8081"}, clear=True) client = PipelexAPIClient() - assert client.api_key == "mthds-key" - assert client.base_url == "https://mthds.example.com" + assert client.api_key == "" + assert client.base_url == "https://api.pipelex.com" - def test_explicit_args_override_env_and_credentials(self, mocker: MockerFixture) -> None: + def test_explicit_args_override_env(self, mocker: MockerFixture) -> None: mocker.patch.dict(os.environ, {"PIPELEX_API_KEY": "pk-env", "PIPELEX_BASE_URL": "http://env.example.com"}, clear=True) client = PipelexAPIClient(api_key="arg-token", base_url="https://arg.example.com") assert client.api_key == "arg-token" @@ -55,15 +50,6 @@ def test_explicit_empty_token_forces_anonymous_over_env(self, mocker: MockerFixt client = PipelexAPIClient(api_key="") assert client.api_key == "" - def test_explicit_empty_token_forces_anonymous_over_credentials(self, mocker: MockerFixture) -> None: - """An explicit `api_key=""` must win over an mthds credential token too.""" - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "mthds-key", "base_url": "https://mthds.example.com", "runner": "api"}, - ) - client = PipelexAPIClient(api_key="") - assert client.api_key == "" - def test_strips_trailing_slash(self) -> None: client = PipelexAPIClient(base_url="https://api.pipelex.com/") assert client.base_url == "https://api.pipelex.com" @@ -83,8 +69,28 @@ def test_origin_includes_port(self) -> None: "ftp://api.pipelex.com", # non-http(s) scheme "api.pipelex.com", # no scheme "not a url", # garbage + "", # explicit empty string — presence semantics: it must fail, not fall through ], ) def test_rejects_non_host_only_base_url(self, bad_url: str) -> None: with pytest.raises(PipelineRequestError): PipelexAPIClient(base_url=bad_url) + + def test_set_but_empty_base_url_env_raises(self, mocker: MockerFixture) -> None: + """A set-but-empty `PIPELEX_BASE_URL` (e.g. an unfilled CI secret) must fail fast + instead of silently targeting the hosted default with whatever API key is configured. + """ + mocker.patch.dict(os.environ, {"PIPELEX_BASE_URL": "", "PIPELEX_API_KEY": "pk-live"}, clear=True) + with pytest.raises(PipelineRequestError): + PipelexAPIClient() + + def test_default_request_timeout(self) -> None: + """With no override, the blocking-execute ceiling defaults to 20 minutes.""" + client = PipelexAPIClient() + assert client.request_timeout_seconds == 1200.0 + + @pytest.mark.parametrize("timeout_seconds", [30.0, 0.0]) + def test_request_timeout_seconds_override(self, timeout_seconds: float) -> None: + """An explicit `request_timeout_seconds` sets the per-instance ceiling — including a falsy `0.0`.""" + client = PipelexAPIClient(request_timeout_seconds=timeout_seconds) + assert client.request_timeout_seconds == timeout_seconds diff --git a/tests/unit/test_client_execute.py b/tests/unit/test_client_execute.py index 6e99adf..24e18e3 100644 --- a/tests/unit/test_client_execute.py +++ b/tests/unit/test_client_execute.py @@ -31,13 +31,6 @@ def _response(status_code: int, *, json: object | None = None) -> httpx.Response class TestClientExecute: - @pytest.fixture(autouse=True) - def _mock_credentials(self, mocker: MockerFixture) -> None: - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "", "base_url": "", "runner": "api"}, - ) - def _client(self) -> PipelexAPIClient: return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) diff --git a/tests/unit/test_client_health.py b/tests/unit/test_client_health.py index 23906b0..3d92342 100644 --- a/tests/unit/test_client_health.py +++ b/tests/unit/test_client_health.py @@ -24,13 +24,6 @@ def _response(status_code: int, *, json: object | None = None, content: bytes | class TestClientHealth: - @pytest.fixture(autouse=True) - def _isolate(self, mocker: MockerFixture) -> None: - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "", "base_url": _BASE_URL, "runner": "api"}, - ) - def _client(self) -> PipelexAPIClient: return PipelexAPIClient(api_key="t", base_url=_BASE_URL) diff --git a/tests/unit/test_client_lifecycle.py b/tests/unit/test_client_lifecycle.py index 39280be..1a7f3cd 100644 --- a/tests/unit/test_client_lifecycle.py +++ b/tests/unit/test_client_lifecycle.py @@ -36,14 +36,6 @@ def _response(status_code: int, *, json: object = None, headers: dict[str, str] class TestClientLifecycle: - @pytest.fixture(autouse=True) - def _mock_credentials(self, mocker: MockerFixture) -> None: - """Keep construction hermetic — never touch the real credentials file/env.""" - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "", "base_url": "", "runner": "api"}, - ) - def _client(self) -> PipelexAPIClient: return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) diff --git a/tests/unit/test_client_product.py b/tests/unit/test_client_product.py index ea98eeb..75e5ab3 100644 --- a/tests/unit/test_client_product.py +++ b/tests/unit/test_client_product.py @@ -48,13 +48,6 @@ def __init__(self, method: str, url: str, body: object | None) -> None: class TestClientProduct: - @pytest.fixture(autouse=True) - def _isolate(self, mocker: MockerFixture) -> None: - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "", "base_url": _BASE_URL, "runner": "api"}, - ) - def _client(self) -> PipelexAPIClient: return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) diff --git a/tests/unit/test_client_run_fallback.py b/tests/unit/test_client_run_fallback.py index 2072f73..300d333 100644 --- a/tests/unit/test_client_run_fallback.py +++ b/tests/unit/test_client_run_fallback.py @@ -42,13 +42,6 @@ def _urls(send_mock: Any) -> list[str]: class TestClientRunFallback: - @pytest.fixture(autouse=True) - def _mock_credentials(self, mocker: MockerFixture) -> None: - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "", "base_url": "", "runner": "api"}, - ) - def _client(self) -> PipelexAPIClient: return PipelexAPIClient(api_key="test-token", base_url=_BASE_URL) diff --git a/tests/unit/test_client_transport.py b/tests/unit/test_client_transport.py index 041566a..df1d63a 100644 --- a/tests/unit/test_client_transport.py +++ b/tests/unit/test_client_transport.py @@ -24,13 +24,6 @@ def _response(status_code: int, *, json: object | None = None, content: bytes | class TestClientTransport: - @pytest.fixture(autouse=True) - def _isolate(self, mocker: MockerFixture) -> None: - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "", "base_url": _BASE_URL, "runner": "api"}, - ) - def _client(self) -> PipelexAPIClient: return PipelexAPIClient(api_key="t", base_url=_BASE_URL) diff --git a/tests/unit/test_client_validate.py b/tests/unit/test_client_validate.py index d0840c8..8f8d028 100644 --- a/tests/unit/test_client_validate.py +++ b/tests/unit/test_client_validate.py @@ -25,13 +25,6 @@ class TestClientValidate: - @pytest.fixture(autouse=True) - def _isolate(self, mocker: MockerFixture) -> None: - mocker.patch( - "pipelex_sdk.client.load_config", - return_value={"api_key": "", "base_url": _BASE_URL, "runner": "api"}, - ) - def _client(self) -> PipelexAPIClient: return PipelexAPIClient(api_key="t", base_url=_BASE_URL) diff --git a/uv.lock b/uv.lock index 9bb2138..960876d 100644 --- a/uv.lock +++ b/uv.lock @@ -250,7 +250,7 @@ wheels = [ [[package]] name = "mthds" -version = "0.7.0" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-strenum", marker = "python_full_version < '3.11'" }, @@ -261,9 +261,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/fe/52bf1818f41b71658f24ee78dfe24730c04c1066e8b434f39fe23823cbb4/mthds-0.7.0.tar.gz", hash = "sha256:35e8c9d5f4b27e86fec02245f72bc2eb9c849790d39a8ffe6dcb001e7fe7d4cb", size = 136385, upload-time = "2026-07-02T20:17:58.875Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/4c/f3ba4c7e0168facb250efd6b8510a5528db8509701591d05b996023322a0/mthds-0.7.1.tar.gz", hash = "sha256:60b8a14770342d67cd63c97cf73eedb14e85f1758bb88c590f0104234c56a8bd", size = 136752, upload-time = "2026-07-02T22:00:09.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/9f/f12c6de8a2ed9938a5914559639921ded8275f42b8c43ac1af35fe7a488f/mthds-0.7.0-py3-none-any.whl", hash = "sha256:4e371b73ba259899f5ba0b460809d00f176ac44ad773073a3ebb6c10ee207866", size = 57898, upload-time = "2026-07-02T20:17:57.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/30/67134348798d1dcc0270009bec3a6a331655a1dd6be3b8c5579190ed4ab2/mthds-0.7.1-py3-none-any.whl", hash = "sha256:b463eaeef67df6e52d70905c6318135ebe950c8eba6a979806dd2d132e4db191", size = 58015, upload-time = "2026-07-02T22:00:07.982Z" }, ] [[package]] @@ -375,7 +375,7 @@ dev = [ requires-dist = [ { name = "backports-strenum", marker = "python_full_version < '3.11'", specifier = ">=1.3.0" }, { name = "httpx", specifier = ">=0.23.0,<1.0.0" }, - { name = "mthds", specifier = ">=0.7.0" }, + { name = "mthds", specifier = ">=0.7.1" }, { 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" },