diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a9c2cb..212a0cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# 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 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
### Fixed
diff --git a/CLAUDE.md b/CLAUDE.md
index 4345a6e..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_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.** 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 fcae8b2..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_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}`.
+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 52c94cb..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_API_URL` first (brand + JS parity);
-- falling back to the `mthds` resolver (`MTHDS_API_KEY` / `MTHDS_API_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 46d51e2..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.credentials import load_credentials
from mthds.protocol.exceptions import PipelineRequestError
from mthds.runners.api.client import MthdsAPIClient
from pydantic import BaseModel, TypeAdapter, ValidationError
@@ -89,7 +88,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.
@@ -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`.
@@ -109,7 +107,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 +137,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
@@ -148,31 +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_API_URL`), falling back to the `mthds` resolver (`MTHDS_API_KEY` /
- `MTHDS_API_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_token: str | None = None, api_base_url: str | None = None) -> None:
- credentials = load_credentials()
-
- # Pipelex-primary, mthds fallback. `credentials` 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
- # 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
+ 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 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=""`) 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
else:
- self.api_token = credentials["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
+ 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
@@ -185,19 +201,27 @@ 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 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
@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 +235,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 +280,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 +301,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..8bc0520 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.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 d51adaa..94e5b4c 100644
--- a/tests/unit/test_client_construction.py
+++ b/tests/unit/test_client_construction.py
@@ -8,69 +8,55 @@
from pipelex_sdk.client import PipelexAPIClient
-_MTHDS_DEFAULT_CREDENTIALS = {"api_key": "", "api_url": "https://api.pipelex.com", "runner": "api", "telemetry": "0"}
-
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_credentials", 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 == ""
-
- 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(
- "pipelex_sdk.client.load_credentials",
- return_value={"api_key": "mthds-key", "api_url": "https://mthds.example.com", "runner": "api", "telemetry": "0"},
- )
+ assert client.api_key == ""
+
+ 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)
client = PipelexAPIClient()
- assert client.api_token == "pk-live"
- assert client.api_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"},
- )
+ assert client.api_key == "pk-live"
+ assert client.base_url == "http://localhost:8081"
+
+ 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_token == "mthds-key"
- assert client.api_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:
- 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"
+ 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"
+ 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 == ""
-
- 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."""
- mocker.patch(
- "pipelex_sdk.client.load_credentials",
- return_value={"api_key": "mthds-key", "api_url": "https://mthds.example.com", "runner": "api", "telemetry": "0"},
- )
- 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(
@@ -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(api_base_url=bad_url)
+ 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 75d3e07..24e18e3 100644
--- a/tests/unit/test_client_execute.py
+++ b/tests/unit/test_client_execute.py
@@ -31,15 +31,8 @@ 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_credentials",
- return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"},
- )
-
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..3d92342 100644
--- a/tests/unit/test_client_health.py
+++ b/tests/unit/test_client_health.py
@@ -24,15 +24,8 @@ 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_credentials",
- return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"},
- )
-
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..1a7f3cd 100644
--- a/tests/unit/test_client_lifecycle.py
+++ b/tests/unit/test_client_lifecycle.py
@@ -36,27 +36,19 @@ 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_credentials",
- return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"},
- )
-
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..75e5ab3 100644
--- a/tests/unit/test_client_product.py
+++ b/tests/unit/test_client_product.py
@@ -48,15 +48,8 @@ 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_credentials",
- return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"},
- )
-
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..300d333 100644
--- a/tests/unit/test_client_run_fallback.py
+++ b/tests/unit/test_client_run_fallback.py
@@ -42,15 +42,8 @@ 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_credentials",
- return_value={"api_key": "", "api_url": "", "runner": "api", "telemetry": "0"},
- )
-
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..df1d63a 100644
--- a/tests/unit/test_client_transport.py
+++ b/tests/unit/test_client_transport.py
@@ -24,15 +24,8 @@ 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_credentials",
- return_value={"api_key": "", "api_url": _BASE_URL, "runner": "api", "telemetry": "0"},
- )
-
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..8f8d028 100644
--- a/tests/unit/test_client_validate.py
+++ b/tests/unit/test_client_validate.py
@@ -25,15 +25,8 @@
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"},
- )
-
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..960876d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -250,7 +250,7 @@ wheels = [
[[package]]
name = "mthds"
-version = "0.6.1"
+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/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/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/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/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]]
@@ -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.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" },