diff --git a/contracts/README.md b/contracts/README.md index 1a49815..c2ad4ff 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -2,9 +2,27 @@ The JSON and OpenAPI files here are shared Frontal API inputs. `sdk-endpoints.json` records the SDK's service methods and paths, while -`coverage-floor.json` records an existing coverage baseline. Keep the public -client hand-written and do not edit upstream OpenAPI snapshots by hand. +`coverage-floor.json` ratchets how many published snapshot operations the +inventory matches. Keep the public client hand-written and do not edit +upstream OpenAPI snapshots by hand. `reports/conformance.json` and `reports/migration-matrix.md` describe the implemented method and path coverage. Run `python scripts/check_contracts.py` after changing the endpoint inventory or service methods. + +The published `api.openapi.json` snapshot is a pinned upstream baseline +(`sha256` in `openapi/manifest.json`) that covers a subset of the API with a +different path vocabulary than the SDK implements, so the checker reports +inventory operations absent from the snapshot and snapshot operations outside +the inventory as informational drift, not failures. The report breaks the +drift down per domain so it is actionable. Update `coverage-floor.json` when +the matched baseline legitimately changes. + +`api.openapi.json` is shared byte-for-byte with the TypeScript SDK +(`sdk-typescript/contracts/openapi/api.openapi.json`). That repository's +manifest pins the upstream artifact as `sha256 c4603577…` while both +repositories committed `ca5f245…`, and `https://openapi.frontal.dev` no longer +serves the spec, so the committed snapshot predates the upstream baseline it +should track. Refreshing it is an upstream action: republish the artifact, +copy it in unchanged, and update the `sha256` in `openapi/manifest.json`. The +drift lines and `coverage-floor.json` then recompute automatically. diff --git a/contracts/coverage-floor.json b/contracts/coverage-floor.json index 08c6e1c..0f6b2ba 100644 --- a/contracts/coverage-floor.json +++ b/contracts/coverage-floor.json @@ -1,4 +1,4 @@ { - "api": 146, + "api": 142, "ai": 7 } diff --git a/contracts/reports/conformance.json b/contracts/reports/conformance.json index 67a2314..2c2a3c2 100644 --- a/contracts/reports/conformance.json +++ b/contracts/reports/conformance.json @@ -2,7 +2,22 @@ "repository": "sdk-python", "language": "Python", "status": "implemented", - "matched": 370, - "unmatched": 0, - "note": "Hand-written resource methods match contracts/sdk-endpoints.json. Payloads use the validated JSON boundary where operation schemas are absent." + "inventoryMethodsImplemented": 383, + "inventoryMethodsUnimplemented": 0, + "note": "inventoryMethodsImplemented counts contracts/sdk-endpoints.json entries backed by hand-written resource methods; inventoryMethodsUnimplemented counts entries with no method. Snapshot conformance is reported separately under openapiConformance.", + "openapiConformance": { + "api": { + "snapshotOperations": 383, + "matchedByInventory": 142, + "inventoryOperationsAbsentFromSnapshot": 227, + "snapshotOperationsOutsideInventory": 241 + }, + "ai": { + "snapshotOperations": 7, + "matchedByInventory": 7, + "inventoryOperationsAbsentFromSnapshot": 0, + "snapshotOperationsOutsideInventory": 0 + } + }, + "openapiNote": "contracts/openapi/api.openapi.json is the pinned upstream snapshot (sha256 in contracts/openapi/manifest.json) and covers a subset of the API with a different path vocabulary, so the gaps above are expected. It is authoritative and not edited here; contracts/coverage-floor.json ratchets the matched baseline." } diff --git a/contracts/reports/migration-matrix.md b/contracts/reports/migration-matrix.md index 9b4cdd4..4c7219d 100644 --- a/contracts/reports/migration-matrix.md +++ b/contracts/reports/migration-matrix.md @@ -4,8 +4,28 @@ _Status: hand-written endpoint methods follow the committed SDK endpoint invento | Metric | Value | | --- | ---: | -| Implemented endpoint methods | 370 | -| Contract endpoint entries | 370 | +| Implemented endpoint methods | 383 | +| Contract endpoint entries | 383 | | Coverage | 100% | Method and path coverage is complete for `contracts/sdk-endpoints.json`. Request and response payloads use a Pydantic-validated JSON boundary because the inventory does not include operation-specific payload schemas. + +## Published OpenAPI snapshot conformance + +The snapshots in `contracts/openapi/` are pinned upstream inputs (`sha256` +values in `contracts/openapi/manifest.json`) and are not edited in this +repository. `python scripts/check_contracts.py` reports how the endpoint +inventory lines up with them: + +| Metric | api surface | ai surface | +| --- | ---: | ---: | +| Snapshot operations | 383 | 7 | +| Matched by the inventory | 142 | 7 | +| Inventory operations absent from the snapshot | 227 | 0 | +| Snapshot operations outside the inventory | 241 | 0 | + +The published `api.openapi.json` baseline covers a subset of the API with a +different path vocabulary than the SDK implements (for example `/v1/integrations/...` +instead of the SDK's `/connectors/...`), so both gaps are expected drift, not +failures. The inventory remains the contract source for implemented methods, +and the matched baseline is ratcheted by `contracts/coverage-floor.json`. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..e6f176a --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,143 @@ +# Getting started + +Install the package, set `FRONTAL_API_KEY`, and make your first request in +under five minutes. + +## Install + +Python 3.9–3.12 is supported. + +```bash +pip install frontal +# or +uv add frontal +``` + +## Configure + +The client reads its API key from `FRONTAL_API_KEY`. The base URL, +environment header, and debug logging read `FRONTAL_API_URL`, +`FRONTAL_ENV`, and `FRONTAL_DEBUG` when their constructor arguments are +omitted. The SDK does not load `.env` files. + +```bash +export FRONTAL_API_KEY="frt_..." +``` + +## Hello world + +```python +from frontal_sdk import Frontal + +with Frontal() as client: + result = client.ai.generate_text( + {"model": "frontal-ai-fast", "prompt": "Say hello."} + ) + print(result.text) +``` + +The async client exposes the same services and operation methods: + +```python +import asyncio + +from frontal_sdk import AsyncFrontal + + +async def main() -> None: + async with AsyncFrontal() as client: + result = await client.ai.generate_text( + {"model": "frontal-ai-fast", "prompt": "Say hello."} + ) + print(result.text) + + +asyncio.run(main()) +``` + +## Define an agent + +Builders validate inputs before anything is sent: + +```python +from frontal_sdk import Frontal + +with Frontal() as client: + agent = ( + client.agents.define("Ticket triage") + .description("Route incoming support tickets") + .trigger("ticket.created") + .can_read("tickets") + .create() + ) +``` + +Message it, poll a run, or watch run events: + +```python +from frontal_sdk import AgentExecution, Frontal + +with Frontal() as client: + accessor = client.agents.use("agt_ticket_triager") + run = AgentExecution.model_validate( + accessor.message("support.ticket.created", {"ticketId": "t_987"}) + ) + final = accessor.wait_for_completion(run.id) + for event in accessor.watch(run.id): + print(event.event, event.data) +``` + +## Stream text + +`stream_text()` yields typed parts — text deltas, tool calls, a finish +part with usage, error parts, and a terminal done part: + +```python +with Frontal() as client: + for part in client.ai.stream_text( + {"model": "frontal-ai-fast", "prompt": "Count to three."} + ): + if part.type == "text": + print(part.text, end="", flush=True) + elif part.type == "finish": + print(f"\n[finished: {part.finish_reason}]") +``` + +## Handle errors + +Every API failure raises a `FrontalError` subclass carrying the error +`code`, `request_id`, `status_code`, a `transient` flag, an explicit +`safe_to_retry` signal, and — when the SDK knows one — a human-readable +`fix` hint: + +```python +from frontal_sdk import Frontal, FrontalError, RateLimitError, ValidationError + +with Frontal() as client: + try: + client.agents.list(query={"limit": 50}) + except RateLimitError as error: + print(f"Rate limited; retry after {error.retry_after}s") + except ValidationError as error: + for field in error.fields: + print(f" - {field.field}: {field.message}") + except FrontalError as error: + print(f"Request failed [{error.code}]: {error}") + if error.fix is not None: + print(f"Hint: {error.fix}") +``` + +A transient error from a write may still represent an operation the +server accepted, so inspect `safe_to_retry` before replaying it. + +## Next steps + +- [API resources](RESOURCES.md) — AI helpers, builders, endpoint methods, + pagination, and retry safety. +- [Functions API](FUNCTIONS.md) — function definitions, invocations, and + executions. +- [Architecture](ARCHITECTURE.md) — package layout, config, transport, + retries, and streaming semantics. +- [Runnable examples](../examples/README.md) — offline scripts covering + agents, functions, workflows, streaming, and error handling. +- [Testing](TESTING.md) — offline RESPX coverage and runnable documentation. diff --git a/docs/README.md b/docs/README.md index 8e92103..525bdfa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,6 @@ # Python documentation +- [Getting started](GETTING_STARTED.md) — install, first request, agents, streaming, and errors. - [Overview](OVERVIEW.md) — install, clients, service attributes, and type boundaries. - [Architecture](ARCHITECTURE.md) — package layout, config, transport, and retries. - [API resources](RESOURCES.md) — AI helpers, builders, and endpoint methods. diff --git a/docs/mcp.json b/docs/mcp.json index 302e8b0..20b70e2 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -4,6 +4,22 @@ "version": "1.0.0", "status": "stable", "documents": [ + { + "path": ".claude/rules/sdk-language.md", + "title": "sdk-language" + }, + { + "path": ".cursor/README.md", + "title": "README" + }, + { + "path": ".cursor/commands/review-sdk-contract.md", + "title": "review-sdk-contract" + }, + { + "path": ".cursor/commands/verify-sdk-change.md", + "title": "verify-sdk-change" + }, { "path": ".github/BRANCH_PROTECTION.md", "title": "BRANCH PROTECTION" @@ -12,6 +28,10 @@ "path": ".github/PULL_REQUEST_TEMPLATE.md", "title": "PULL REQUEST TEMPLATE" }, + { + "path": ".vscode/README.md", + "title": "README" + }, { "path": "AGENTS.md", "title": "AGENTS" @@ -20,6 +40,10 @@ "path": "CHANGELOG.md", "title": "CHANGELOG" }, + { + "path": "CLAUDE.md", + "title": "CLAUDE" + }, { "path": "CODE_OF_CONDUCT.md", "title": "CODE OF CONDUCT" @@ -64,6 +88,14 @@ "path": "docs/ENTERPRISE_READINESS.md", "title": "ENTERPRISE READINESS" }, + { + "path": "docs/FUNCTIONS.md", + "title": "FUNCTIONS" + }, + { + "path": "docs/GETTING_STARTED.md", + "title": "GETTING STARTED" + }, { "path": "docs/ONBOARDING.md", "title": "ONBOARDING" @@ -112,10 +144,86 @@ "path": "scripts/README.md", "title": "README" }, + { + "path": "skills/SOURCES.md", + "title": "SOURCES" + }, { "path": "skills/frontal-sdk/SKILL.md", "title": "SKILL" }, + { + "path": "skills/python-lint/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-lint/references/migration.md", + "title": "migration" + }, + { + "path": "skills/python-lint/references/rule-selection.md", + "title": "rule-selection" + }, + { + "path": "skills/python-packaging/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-packaging/references/build-backends.md", + "title": "build-backends" + }, + { + "path": "skills/python-pydantic-httpx-contracts/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-supply-chain/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-supply-chain/references/dependabot.md", + "title": "dependabot" + }, + { + "path": "skills/python-supply-chain/references/scanning-and-provenance.md", + "title": "scanning-and-provenance" + }, + { + "path": "skills/python-sync-async-clients/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-testing/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-testing/references/beyond-coverage.md", + "title": "beyond-coverage" + }, + { + "path": "skills/python-testing/references/configuration.md", + "title": "configuration" + }, + { + "path": "skills/python-testing/references/multi-version.md", + "title": "multi-version" + }, + { + "path": "skills/python-typing/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-typing/references/checker-selection.md", + "title": "checker-selection" + }, + { + "path": "skills/python-typing/references/py-typed-distribution.md", + "title": "py-typed-distribution" + }, + { + "path": "skills/python-typing/references/strictness-ratchet.md", + "title": "strictness-ratchet" + }, { "path": "templates/README.md", "title": "README" diff --git a/examples/README.md b/examples/README.md index 15e3ccf..a9301f8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,8 @@ Each script closes its HTTPX client when finished. | [`define_agent.py`](./define_agent.py) | Validated agent builder and scoped access | | [`create_function.py`](./create_function.py) | Function definition and invocation | | [`create_workflow.py`](./create_workflow.py) | Workflow creation and triggering an execution | +| [`stream_text.py`](./stream_text.py) | Streaming text deltas with finish and error parts | +| [`handle_errors.py`](./handle_errors.py) | Structured error handling with fix hints | Run scripts from the repository root after installing the development extra: @@ -21,6 +23,8 @@ uv run python examples/async_quickstart.py uv run python examples/define_agent.py uv run python examples/create_function.py uv run python examples/create_workflow.py +uv run python examples/stream_text.py +uv run python examples/handle_errors.py ``` The examples are also exercised offline by `tests/test_examples.py`, which diff --git a/examples/handle_errors.py b/examples/handle_errors.py new file mode 100644 index 0000000..5e57bae --- /dev/null +++ b/examples/handle_errors.py @@ -0,0 +1,27 @@ +"""Demonstrate structured error handling with the Frontal Python SDK.""" + +from frontal_sdk import Frontal, FrontalError, RateLimitError, ValidationError + + +def main() -> None: + with Frontal() as client: + try: + client.agents.list(query={"limit": 50}) + except RateLimitError as error: + print(f"Rate limited; retry after {error.retry_after}s") + if error.fix is not None: + print(f"Hint: {error.fix}") + except ValidationError as error: + print(f"Invalid request ({error.code}):") + for field in error.fields: + print(f" - {field.field}: {field.message}") + except FrontalError as error: + print(f"Request failed [{error.code}]: {error}") + if error.fix is not None: + print(f"Hint: {error.fix}") + if error.safe_to_retry: + print("This operation is safe to retry.") + + +if __name__ == "__main__": + main() diff --git a/examples/stream_text.py b/examples/stream_text.py new file mode 100644 index 0000000..5361574 --- /dev/null +++ b/examples/stream_text.py @@ -0,0 +1,21 @@ +"""Stream chat text deltas using the Frontal Python SDK.""" + +from frontal_sdk import Frontal + + +def main() -> None: + with Frontal() as client: + for part in client.ai.stream_text( + {"model": "frontal-ai-fast", "prompt": "Count to three."} + ): + if part.type == "text": + print(part.text, end="", flush=True) + elif part.type == "finish": + print(f"\n[finished: {part.finish_reason}]") + elif part.type == "error": + print(f"\n[stream error: {part.error}]") + # "done" parts terminate the stream; there is nothing to print. + + +if __name__ == "__main__": + main() diff --git a/frontal_sdk/__main__.py b/frontal_sdk/__main__.py new file mode 100644 index 0000000..6507e8e --- /dev/null +++ b/frontal_sdk/__main__.py @@ -0,0 +1,63 @@ +"""Allow running the Frontal Python SDK as a module: ``python -m frontal_sdk``. + +This is useful for quick diagnostics, testing, and documentation purposes. +""" + +from __future__ import annotations + +import os + +from frontal_sdk import __version__ + +DEFAULT_API_KEY_ENV = "FRONTAL_API_KEY" + + +def _check_api_key() -> bool: + """Check if a Frontal API key is available in the environment.""" + return bool(os.environ.get(DEFAULT_API_KEY_ENV)) + + +def main() -> None: + """Entry point for ``python -m frontal_sdk``.""" + print(f"Frontal Python SDK v{__version__}") + print("=" * 40) + + has_key = _check_api_key() + if has_key: + print("✓ Frontal API key detected in environment variables") + else: + print("⚠ No Frontal API key found (set FRONTAL_API_KEY)") + print(" Set FRONTAL_API_KEY to a key starting with 'frt_'") + print(" to make live API calls.") + + print() + print("Available services:") + services = [ + "agents", + "ai", + "workflows", + "functions", + "audit", + "auth", + "billing", + "blob", + "connectors", + "data", + "governance", + "lineage", + "observability", + "ontology", + "pipelines", + "schedules", + "webhooks", + ] + for svc in services: + print(f" - {svc}") + + print() + print("Use ``from frontal_sdk import Frontal`` to start coding.") + print("See ``https://frontal.dev/docs/python`` for the full documentation.") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/frontal_sdk/api/__init__.py b/frontal_sdk/api/__init__.py new file mode 100644 index 0000000..033e37a --- /dev/null +++ b/frontal_sdk/api/__init__.py @@ -0,0 +1,73 @@ +"""API resources for the Frontal Python SDK. + +This package re-exports the typed API resources from the ``resources`` +sub-package for backward compatibility, while providing a clean +``api.*`` namespace for new code. + +Typical usage:: + + from frontal_sdk import Frontal + from frontal_sdk.api import agents, ai, workflows + + with Frontal() as client: + agent = client.agents.define("MyAgent") + result = client.ai.generate_text(...) + runs = client.workflows.use("wf_123").executions() +""" + +from frontal_sdk.resources import ( # noqa: F401 + AI, + AgentAccessor, + AgentBuilder, + Agents, + AsyncAI, + Audit, + Auth, + Billing, + Blob, + Connectors, + Data, + FunctionAccessor, + FunctionBuilder, + Functions, + Governance, + Lineage, + Observability, + Ontology, + Pipelines, + Schedules, + SyncAI, + Webhooks, + WorkflowAccessor, + WorkflowApprovals, + WorkflowBuilder, + Workflows, + WorkflowSteps, + WorkflowTemplates, + tool, +) + +# Add lowercase convenience aliases for API imports +agents = Agents +ai = AI +audit = Audit +auth = Auth +billing = Billing +blob = Blob +connectors = Connectors +data = Data +functions = Functions +governance = Governance +lineage = Lineage +observability = Observability +ontology = Ontology +pipelines = Pipelines +schedules = Schedules +webhooks = Webhooks +workflow_accessor = WorkflowAccessor +workflow_approvals = WorkflowApprovals +workflow_builder = WorkflowBuilder +workflow_steps = WorkflowSteps +workflow_templates = WorkflowTemplates +workflows = Workflows +syncai = SyncAI diff --git a/frontal_sdk/core/__init__.py b/frontal_sdk/core/__init__.py index 3386506..5b2028a 100644 --- a/frontal_sdk/core/__init__.py +++ b/frontal_sdk/core/__init__.py @@ -1,6 +1,15 @@ """Core configuration, transport, and error types.""" +from __future__ import annotations + +from frontal_sdk.core.circuit_breaker import ( + CircuitBreaker, + CircuitBreakerConfig, + CircuitBreakerOpenError, + CircuitState, +) from frontal_sdk.core.config import ClientConfig +from frontal_sdk.core.constants import DEFAULT_BASE_URL, SDK_VERSION from frontal_sdk.core.errors import ( AuthenticationError, ConflictError, @@ -16,14 +25,22 @@ from frontal_sdk.core.operation import Operation from frontal_sdk.core.pagination import async_paginate, paginate from frontal_sdk.core.polling import async_poll_until, poll_until +from frontal_sdk.core.retry import BackoffStrategy, calculate_delay __all__ = [ "AsyncHttpClient", "async_paginate", "async_poll_until", "AuthenticationError", + "BackoffStrategy", + "calculate_delay", + "CircuitBreaker", + "CircuitBreakerConfig", + "CircuitBreakerOpenError", + "CircuitState", "ClientConfig", "ConflictError", + "DEFAULT_BASE_URL", "FrontalError", "HttpClient", "NetworkError", @@ -32,6 +49,7 @@ "paginate", "poll_until", "RateLimitError", + "SDK_VERSION", "ServerError", "TimeoutError", "ValidationError", diff --git a/frontal_sdk/core/circuit_breaker.py b/frontal_sdk/core/circuit_breaker.py new file mode 100644 index 0000000..826cb91 --- /dev/null +++ b/frontal_sdk/core/circuit_breaker.py @@ -0,0 +1,226 @@ +"""Circuit breaker that guards API calls from cascading failures.""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Literal, TypeVar + +from frontal_sdk.core.errors import FrontalError + +CircuitState = Literal["closed", "open", "half_open"] + +ResultT = TypeVar("ResultT") + + +@dataclass(frozen=True) +class CircuitBreakerConfig: + """Configuration for :class:`CircuitBreaker`. + + ``failure_threshold`` consecutive failures open the circuit; + ``reset_timeout`` seconds later it half-opens and lets one + probe request through. The ``on_*`` callbacks fire on the + matching transition. + """ + + failure_threshold: int = 5 + reset_timeout: float = 30.0 + on_open: Callable[[], None] | None = None + on_close: Callable[[], None] | None = None + on_half_open: Callable[[], None] | None = None + + def __post_init__(self) -> None: + if isinstance(self.failure_threshold, bool) or not isinstance( + self.failure_threshold, int + ): + raise TypeError("failure_threshold must be an integer") + if self.failure_threshold < 1: + raise ValueError("failure_threshold must be positive") + if not isinstance(self.reset_timeout, (int, float)): + raise TypeError("reset_timeout must be a number") + if self.reset_timeout <= 0: + raise ValueError("reset_timeout must be positive") + + +class CircuitBreakerOpenError(FrontalError): + """A request was rejected because the circuit breaker is open.""" + + def __init__(self, retry_after: float) -> None: + super().__init__( + f"Circuit breaker is open — retry after {retry_after:g} seconds", + code="CIRCUIT_BREAKER_OPEN", + ) + self.retry_after = retry_after + + +class CircuitBreaker: + """Counts consecutive failures and short-circuits requests. + + While the circuit is ``open`` (or a half-open probe is in + flight) calls raise :class:`CircuitBreakerOpenError` instead of + reaching the API. A successful probe closes the circuit again. + + Transition callbacks are invoked without the breaker lock held, + so a callback may read :attr:`state` or :attr:`failures`. It must + not call back into the breaker (for example :meth:`force_close`), + which recurses instead of blocking. + """ + + def __init__(self, config: CircuitBreakerConfig | None = None) -> None: + self._config = config or CircuitBreakerConfig() + self._lock = threading.Lock() + self._failures = 0 + self._last_failure = 0.0 + self._state: CircuitState = "closed" + self._probe_in_flight = False + + @property + def state(self) -> CircuitState: + """The current circuit state.""" + with self._lock: + return self._state + + @property + def failures(self) -> int: + """The current consecutive failure count.""" + with self._lock: + return self._failures + + def execute( + self, + fn: Callable[[], ResultT], + is_failure: Callable[[ResultT], bool] | None = None, + ) -> ResultT: + """Run ``fn`` through the breaker. + + ``is_failure`` classifies a returned (non-raising) result + as a failure, so HTTP error responses can count too. + """ + probe, announce = self._enter() + _notify(announce) + try: + try: + result = fn() + failed = is_failure is not None and is_failure(result) + except Exception: + with self._lock: + announce = self._record_failure(probe) + _notify(announce) + raise + with self._lock: + announce = self._record(failed, probe) + _notify(announce) + return result + finally: + with self._lock: + if probe: + self._probe_in_flight = False + + async def execute_async( + self, + fn: Callable[[], Awaitable[ResultT]], + is_failure: Callable[[ResultT], bool] | None = None, + ) -> ResultT: + """Run an awaitable ``fn`` through the breaker.""" + probe, announce = self._enter() + _notify(announce) + try: + try: + result = await fn() + failed = is_failure is not None and is_failure(result) + except Exception: + with self._lock: + announce = self._record_failure(probe) + _notify(announce) + raise + with self._lock: + announce = self._record(failed, probe) + _notify(announce) + return result + finally: + with self._lock: + if probe: + self._probe_in_flight = False + + def force_open(self) -> None: + """Manually force the circuit into the ``open`` state.""" + with self._lock: + self._state = "open" + self._last_failure = time.monotonic() + self._probe_in_flight = False + + def force_close(self) -> None: + """Manually reset the circuit to the ``closed`` state.""" + with self._lock: + announce = self._reset() + _notify(announce) + + def _enter(self) -> tuple[bool, list[Callable[[], None] | None]]: + """Apply state transitions before a call. + + Return whether this call is a half-open probe, along with the + transition callbacks to run once the lock has been released. + """ + with self._lock: + announce: list[Callable[[], None] | None] = [] + if self._state == "open": + elapsed = time.monotonic() - self._last_failure + if elapsed <= self._config.reset_timeout: + raise CircuitBreakerOpenError(self._config.reset_timeout - elapsed) + self._state = "half_open" + announce.append(self._config.on_half_open) + if self._state == "half_open": + if self._probe_in_flight: + raise CircuitBreakerOpenError(self._config.reset_timeout) + self._probe_in_flight = True + return True, announce + return False, announce + + def _record(self, failed: bool, probe: bool) -> list[Callable[[], None] | None]: + """Record a call that returned, returning the transition to announce. + + The caller holds ``self._lock``; taking it here would self-deadlock. + """ + if failed: + return self._record_failure(probe) + if probe: + return self._reset() + self._failures = 0 + return [] + + def _record_failure(self, probe: bool) -> list[Callable[[], None] | None]: + """Count a failure; the caller holds ``self._lock``.""" + self._failures += 1 + self._last_failure = time.monotonic() + if probe or self._failures >= self._config.failure_threshold: + if self._state != "open": + self._state = "open" + return [self._config.on_open] + return [] + + def _reset(self) -> list[Callable[[], None] | None]: + """Return the circuit to closed; the caller holds ``self._lock``.""" + self._failures = 0 + self._state = "closed" + return [self._config.on_close] + + +def _notify(callbacks: list[Callable[[], None] | None]) -> None: + """Invoke transition callbacks while no breaker lock is held. + + Callbacks run outside the lock so a callback may read ``state`` or + ``failures``, or call ``force_close``, without deadlocking the breaker. + """ + for callback in callbacks: + if callback is not None: + callback() + + +__all__ = [ + "CircuitBreaker", + "CircuitBreakerConfig", + "CircuitBreakerOpenError", + "CircuitState", +] diff --git a/frontal_sdk/core/constants.py b/frontal_sdk/core/constants.py new file mode 100644 index 0000000..2011e24 --- /dev/null +++ b/frontal_sdk/core/constants.py @@ -0,0 +1,32 @@ +"""Shared constants for the Frontal Python SDK.""" + +from __future__ import annotations + +API_KEY_PREFIX = "frt_" +DEFAULT_BASE_URL = "https://api.frontal.dev/v1" +EXPONENTIAL_BASE = 2 +JITTER_MAX = 0.1 +SDK_VERSION = "1.0.0" + +#: Status codes that trigger an automatic GET retry. Rate limits and +#: server errors are replay-safe; client errors and 408/425 only mark +#: an error as transient, never retried automatically. +DEFAULT_RETRY_ON = (429, 500, 502, 503, 504) + +#: Statuses that classify an error as transient (retryable by the +#: caller), matching the wider set the API may report. +TRANSIENT_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504}) + +#: Server failures the circuit breaker counts towards opening. +BREAKER_FAILURE_STATUS = frozenset({500, 502, 503, 504}) + +__all__ = [ + "API_KEY_PREFIX", + "BREAKER_FAILURE_STATUS", + "DEFAULT_BASE_URL", + "DEFAULT_RETRY_ON", + "EXPONENTIAL_BASE", + "JITTER_MAX", + "SDK_VERSION", + "TRANSIENT_STATUS", +] diff --git a/frontal_sdk/core/errors.py b/frontal_sdk/core/errors.py index 86ac980..7edeb44 100644 --- a/frontal_sdk/core/errors.py +++ b/frontal_sdk/core/errors.py @@ -10,7 +10,6 @@ class _ErrorContext(TypedDict, total=False): - code: str | None request_id: str | None status_code: int | None details: JSONValue @@ -32,6 +31,7 @@ def __init__( safe_to_retry: bool = False, details: JSONValue = None, fields: list[ErrorField] | None = None, + fix: str | None = None, ) -> None: super().__init__(message) self.code = code or "FRONTAL_ERROR" @@ -43,6 +43,7 @@ def __init__( self.safe_to_retry = safe_to_retry self.details = details self.fields = fields or [] + self.fix = fix class AuthenticationError(FrontalError): @@ -71,6 +72,7 @@ def __init__( details: JSONValue = None, fields: list[ErrorField] | None = None, safe_to_retry: bool = False, + fix: str | None = None, ) -> None: super().__init__( message, @@ -81,6 +83,7 @@ def __init__( safe_to_retry=safe_to_retry, details=details, fields=fields, + fix=fix, ) self.retry_after = retry_after @@ -121,6 +124,32 @@ class TimeoutError(NetworkError): """The request exceeded the configured timeout.""" +_API_KEY_HINT = "Check FRONTAL_API_KEY — it must be a valid key starting with frt_." +_NOT_FOUND_HINT = "Verify the resource id belongs to this workspace/environment." +_FIELDS_HINT = "Fix the offending properties listed in `fields`." +_BACKOFF_HINT = "Back off and retry after `retryAfter`, or lower concurrency." +_UNAVAILABLE_HINT = "Service is temporarily unavailable; retry with backoff." +_GENERIC_HINT = "Check the API response for details." + +_AUTH_CODES = frozenset({"UNAUTHORIZED", "INVALID_API_KEY"}) +_NOT_FOUND_CODES = frozenset({"NOT_FOUND"}) +_CONFLICT_CODES = frozenset({"VALIDATION_ERROR"}) +_INPUT_CODES = frozenset({"BAD_REQUEST", "VALIDATION_ERROR"}) +_RATE_LIMIT_CODES = frozenset({"RATE_LIMITED"}) +_UNAVAILABLE_CODES = frozenset({"SERVICE_UNAVAILABLE", "INTERNAL_ERROR"}) + +# HTTP status -> (error subtype, fix hint, error codes the hint applies to). +_STATUS_ERRORS: dict[int, tuple[type[FrontalError], str, frozenset[str]]] = { + 400: (ValidationError, _FIELDS_HINT, _INPUT_CODES), + 401: (AuthenticationError, _API_KEY_HINT, _AUTH_CODES), + 403: (AuthenticationError, _API_KEY_HINT, _AUTH_CODES), + 404: (NotFoundError, _NOT_FOUND_HINT, _NOT_FOUND_CODES), + 409: (ConflictError, _FIELDS_HINT, _CONFLICT_CODES), + 422: (ValidationError, _FIELDS_HINT, _INPUT_CODES), + 429: (RateLimitError, _BACKOFF_HINT, _RATE_LIMIT_CODES), +} + + def error_for_status( status_code: int, message: str, @@ -132,28 +161,41 @@ def error_for_status( retry_after: float | None = None, safe_to_retry: bool = False, ) -> FrontalError: - """Create the public error subtype corresponding to an HTTP status.""" + """Create the public error subtype corresponding to an HTTP status. + + The returned error carries the API's error ``code`` when available and a + human-readable ``fix`` remediation hint. The ``safe_to_retry`` flag + indicates whether retrying the same request may succeed (rate limits, + transient 5xx, network). UIs and agents can branch on this without a + status table. + """ arguments: _ErrorContext = { - "code": code, "request_id": request_id, "status_code": status_code, "details": details, "fields": fields, "safe_to_retry": safe_to_retry, } - if status_code in {401, 403}: - return AuthenticationError(message, **arguments) - if status_code == 404: - return NotFoundError(message, **arguments) - if status_code == 409: - return ConflictError(message, **arguments) - if status_code == 429: - return RateLimitError(message, retry_after=retry_after, **arguments) - if status_code in {400, 422}: - return ValidationError(message, **arguments) + error_type, fix = _classify(status_code, code) + if error_type is RateLimitError: + return RateLimitError( + message, code=code, retry_after=retry_after, fix=fix, **arguments + ) + return error_type(message, code=code, fix=fix, **arguments) + + +def _classify( + status_code: int, code: str | None +) -> tuple[type[FrontalError], str | None]: + """Map an HTTP status and API error code to an error type and fix hint.""" + if status_code in _STATUS_ERRORS: + error_type, hint, hint_codes = _STATUS_ERRORS[status_code] + return error_type, hint if code in hint_codes else None if status_code >= 500: - return ServerError(message, **arguments) - return FrontalError(message, **arguments) + return ServerError, _UNAVAILABLE_HINT if code in _UNAVAILABLE_CODES else None + # For unmapped status codes return a generic FrontalError carrying the + # API's error code when known. + return FrontalError, _GENERIC_HINT if code is None else None __all__ = [ diff --git a/frontal_sdk/core/http.py b/frontal_sdk/core/http.py index e560eca..09f9daa 100644 --- a/frontal_sdk/core/http.py +++ b/frontal_sdk/core/http.py @@ -167,6 +167,10 @@ def _api_error(response: httpx.Response, method: str) -> FrontalError: elif response.content: details = decoded if isinstance(decoded, (dict, list)) else response.text[:2048] + # ``safe_to_retry``: GET/HEAD/OPTIONS are idempotent; all other methods + # (POST/PUT/DELETE) are not automatically replayed — TS documents this + # policy ("Mutation requests are never replayed automatically"). + _safe = method in {"GET", "HEAD", "OPTIONS"} return error_for_status( response.status_code, message, @@ -175,7 +179,7 @@ def _api_error(response: httpx.Response, method: str) -> FrontalError: details=details, fields=fields, retry_after=_parse_retry_after(response.headers.get("retry-after")), - safe_to_retry=method in {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}, + safe_to_retry=_safe, ) diff --git a/frontal_sdk/core/retry.py b/frontal_sdk/core/retry.py new file mode 100644 index 0000000..dfb8b7e --- /dev/null +++ b/frontal_sdk/core/retry.py @@ -0,0 +1,47 @@ +"""Retry backoff strategies shared by the transports.""" + +from __future__ import annotations + +import random +from math import isfinite +from typing import Literal + +from frontal_sdk.core.constants import EXPONENTIAL_BASE, JITTER_MAX + +BackoffStrategy = Literal["exponential", "linear", "constant"] + + +def calculate_delay( + attempt: int, + *, + strategy: BackoffStrategy = "exponential", + base_delay: float = 1.0, + jitter: bool = True, +) -> float: + """Return the delay in seconds before retrying ``attempt``. + + ``exponential`` grows by ``base_delay * 2**attempt``, ``linear`` by + ``base_delay * (attempt + 1)``, and ``constant`` stays at + ``base_delay``. Jitter adds up to ``JITTER_MAX`` seconds to spread + concurrent retries. + """ + if isinstance(attempt, bool) or not isinstance(attempt, int): + raise TypeError("attempt must be an integer") + if attempt < 0: + raise ValueError("attempt must be non-negative") + if strategy not in {"exponential", "linear", "constant"}: + raise ValueError("unsupported backoff strategy") + if not isfinite(base_delay) or base_delay < 0: + raise ValueError("base_delay must be a finite non-negative number") + if strategy == "linear": + delay = base_delay * (attempt + 1) + elif strategy == "constant": + delay = base_delay + else: + delay = base_delay * float(EXPONENTIAL_BASE**attempt) + if not jitter: + return delay + return delay + random.uniform(0.0, JITTER_MAX) + + +__all__ = ["BackoffStrategy", "calculate_delay"] diff --git a/frontal_sdk/resources/agents.py b/frontal_sdk/resources/agents.py index 8be3831..4ca976d 100644 --- a/frontal_sdk/resources/agents.py +++ b/frontal_sdk/resources/agents.py @@ -267,6 +267,7 @@ def __init__( self._agents = agents def list(self, *, agent_id: str, query: QueryParams | None = None) -> JSONResultT: + """List runs for one agent, optionally filtered by ``query``.""" return self._agents._list_agent_runs(agent_id, query=query) def create( @@ -276,15 +277,19 @@ def create( body: RequestBodyInput = UNSET, query: QueryParams | None = None, ) -> JSONResultT: + """Start a new run for one agent with the given request body.""" return self._agents._create_run_for_agent(agent_id, body=body, query=query) def get(self, id: str, *, query: QueryParams | None = None) -> JSONResultT: + """Get a single run by id.""" return self._agents._get_run(id, query=query) def conversation(self, id: str, *, query: QueryParams | None = None) -> JSONResultT: + """Get the conversation transcript for a run by id.""" return self._agents._get_run_conversation(id, query=query) def stream(self, id: str, *, query: QueryParams | None = None) -> StreamResultT: + """Stream server-sent events for a run by id.""" return self._agents._stream_run(id, query=query) @@ -297,6 +302,7 @@ def __init__( self._agents = agents def list(self, *, agent_id: str, query: QueryParams | None = None) -> JSONResultT: + """List versions for one agent, optionally filtered by ``query``.""" return self._agents._list_agent_versions(agent_id, query=query) @@ -371,12 +377,18 @@ def configure( def description( self, text: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set a human-readable description of the agent's purpose.""" self._values["description"] = text return self def trigger( self, event: str, filter: Mapping[str, JSONValue] | None = None ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add an event trigger; the agent fires when the event occurs. + + Multiple triggers may be added; ``filter`` optionally restricts + the event payload fields that activate the agent. + """ triggers = cast(list[JSONValue], self._values["triggers"]) trigger: dict[str, JSONValue] = {"event": event} if filter is not None: @@ -387,6 +399,7 @@ def trigger( def scope( self, value: AgentScope | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the agent's permission scope from a model or mapping.""" self._scope = ( value if isinstance(value, AgentScope) else AgentScope.model_validate(value) ) @@ -395,30 +408,35 @@ def scope( def can_read( self, *entity_types: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Grant the agent read access to the given entity types.""" self._scope.read.extend(entity_types) return self def can_write( self, *entity_types: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Grant the agent write access to the given entity types.""" self._scope.write.extend(entity_types) return self def can_invoke( self, *actions: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Grant the agent permission to invoke the given actions.""" self._scope.actions.extend(actions) return self def escalates_on( self, *conditions: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add escalation conditions that route the run to a human.""" self._scope.escalate.extend(conditions) return self def confidence( self, value: ConfidenceConfig | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set confidence thresholds from a model or mapping.""" self._confidence = ( value if isinstance(value, ConfidenceConfig) @@ -429,18 +447,21 @@ def confidence( def auto_execute_above( self, threshold: float ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Auto-execute without approval when confidence is above this.""" self._confidence.auto_execute_above = threshold return self def escalate_below( self, threshold: float ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Escalate to a human when confidence falls below this.""" self._confidence.escalate_below = threshold return self def memory( self, value: MemoryConfig | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the agent's memory configuration from a model or mapping.""" self._memory = ( value if isinstance(value, MemoryConfig) @@ -451,6 +472,7 @@ def memory( def retry( self, value: RetryConfig | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the agent's retry policy from a model or mapping.""" self._retry = ( value if isinstance(value, RetryConfig) @@ -461,12 +483,14 @@ def retry( def timeout( self, duration: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the maximum run duration (e.g. ``"5m"``, ``"30s"``).""" self._values["timeout"] = duration return self def rate_limit( self, value: AgentRateLimit | Mapping[str, JSONValue] ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the agent's rate limit from a model or mapping.""" model = ( value if isinstance(value, AgentRateLimit) @@ -480,6 +504,7 @@ def rate_limit( def tags( self, *tags: str ) -> AgentBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Attach free-text tags used for filtering in list calls.""" self._values["tags"] = [*cast(list[str], self._values["tags"]), *tags] return self @@ -526,15 +551,19 @@ def requires_approval(self, state: JSONValue) -> bool: return self._approve_when(state) if self._approve_when is not None else False def get(self) -> JSONResultT: + """Get this agent's current definition from the API.""" return self._agents.get(id=self.id) def update(self, definition: Mapping[str, JSONValue]) -> JSONResultT: + """Replace this agent's definition with the given fields.""" return self._agents.update(id=self.id, body=dict(definition)) def delete(self) -> JSONResultT: + """Delete this agent.""" return self._agents.delete(id=self.id) def rollback(self, *, to_version: int | None = None) -> JSONResultT: + """Roll back this agent, optionally to a specific version.""" body: dict[str, JSONValue] = {} if to_version is not None: body["toVersion"] = to_version @@ -543,6 +572,7 @@ def rollback(self, *, to_version: int | None = None) -> JSONResultT: def versions( self, *, limit: int | None = None, cursor: str | None = None ) -> JSONResultT: + """List this agent's versions with optional pagination.""" query: dict[str, str | int] = {} if limit is not None: query["limit"] = limit @@ -559,6 +589,7 @@ def runs( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List this agent's runs, optionally filtered by status or time.""" query: dict[str, str | int] = {} for key, value in ( ("status", status), @@ -572,6 +603,7 @@ def runs( return self._agents.runs.list(agent_id=self.id, query=query) def run(self, run_id: str) -> JSONResultT: + """Get a single run of this agent by id.""" return self._agents.runs.get(run_id) def wait_for_completion( @@ -615,9 +647,11 @@ async def wait_async(initial: Awaitable[JSONValue]) -> JSONValue: return cast(JSONResultT, current) def conversation(self, run_id: str) -> JSONResultT: + """Get the conversation transcript for one run by id.""" return self._agents.runs.conversation(run_id) def message(self, event: str, payload: Mapping[str, JSONValue]) -> JSONResultT: + """Send an event message to this agent and start a run.""" body: dict[str, JSONValue] = {"event": event, "payload": dict(payload)} return self._agents.runs.create(agent_id=self.id, body=body) diff --git a/frontal_sdk/resources/ai.py b/frontal_sdk/resources/ai.py index 85d3a59..facedee 100644 --- a/frontal_sdk/resources/ai.py +++ b/frontal_sdk/resources/ai.py @@ -245,6 +245,7 @@ def get_prompt(self, name: str, version: str | None = None) -> Prompt: return prompt def update_prompt(self, name: str, updates: Mapping[str, JSONValue]) -> Prompt: + """Apply partial field updates to a stored prompt template.""" prompt = self.get_prompt(name) values = prompt.model_dump(mode="python", by_alias=False) values.update(updates) @@ -253,17 +254,21 @@ def update_prompt(self, name: str, updates: Mapping[str, JSONValue]) -> Prompt: return updated def chain_prompts(self, *prompts: Prompt) -> PromptChain: + """Combine prompts into an ordered chain executed in sequence.""" return PromptChain(prompts=list(prompts)) def step_count_is(self, count: int) -> None: + """Set the current step counter used for progress tracking.""" if count < 0: raise ValueError("step count must be non-negative") self._current_step = count def get_current_step(self) -> int: + """Return the current step counter value.""" return self._current_step def reset_steps(self) -> None: + """Reset the step counter back to zero.""" self._current_step = 0 @@ -931,6 +936,7 @@ def generate_speech( def generate_image( self, options: GenerateImageOptions | Mapping[str, JSONValue] ) -> GenerateImageResult: + """Generate images from a text prompt and return their URLs.""" values = ( options if isinstance(options, GenerateImageOptions) @@ -952,6 +958,7 @@ def generate_image( def generate_video( self, options: GenerateVideoOptions | Mapping[str, JSONValue] ) -> GenerateVideoResult: + """Generate a video from a text prompt and return its location.""" values = ( options if isinstance(options, GenerateVideoOptions) @@ -966,6 +973,7 @@ def generate_video( def transcribe( self, options: TranscriptionOptions | Mapping[str, object] ) -> TranscriptionResult: + """Transcribe uploaded audio and return the recognized text.""" values = ( options if isinstance(options, TranscriptionOptions) @@ -990,6 +998,7 @@ def transcribe( def moderate( self, options: ModerationOptions | Mapping[str, JSONValue] ) -> ModerationResult: + """Score input text for policy violations and return flags.""" values = ( options if isinstance(options, ModerationOptions) @@ -1002,6 +1011,8 @@ def moderate( return ModerationResult.model_validate(self.create_prediction(body=body)) def rerank(self, options: RerankOptions | Mapping[str, JSONValue]) -> RerankResult: + """Score candidate documents against the query; apply the scores to + reorder the documents yourself.""" values = ( options if isinstance(options, RerankOptions) @@ -1219,6 +1230,7 @@ async def generate_object( async def generate_speech( self, options: GenerateSpeechOptions | Mapping[str, JSONValue] ) -> bytes: + """Generate speech and return the raw audio bytes.""" values = ( options if isinstance(options, GenerateSpeechOptions) @@ -1240,6 +1252,7 @@ async def generate_speech( async def generate_image( self, options: GenerateImageOptions | Mapping[str, JSONValue] ) -> GenerateImageResult: + """Generate images from a text prompt and return their URLs.""" values = ( options if isinstance(options, GenerateImageOptions) @@ -1261,6 +1274,7 @@ async def generate_image( async def generate_video( self, options: GenerateVideoOptions | Mapping[str, JSONValue] ) -> GenerateVideoResult: + """Generate a video from a text prompt and return its location.""" values = ( options if isinstance(options, GenerateVideoOptions) @@ -1276,6 +1290,7 @@ async def generate_video( async def transcribe( self, options: TranscriptionOptions | Mapping[str, object] ) -> TranscriptionResult: + """Transcribe uploaded audio and return the recognized text.""" values = ( options if isinstance(options, TranscriptionOptions) @@ -1300,6 +1315,7 @@ async def transcribe( async def moderate( self, options: ModerationOptions | Mapping[str, JSONValue] ) -> ModerationResult: + """Score input text for policy violations and return flags.""" values = ( options if isinstance(options, ModerationOptions) @@ -1315,6 +1331,8 @@ async def moderate( async def rerank( self, options: RerankOptions | Mapping[str, JSONValue] ) -> RerankResult: + """Score candidate documents against the query; apply the scores to + reorder the documents yourself.""" values = ( options if isinstance(options, RerankOptions) diff --git a/frontal_sdk/resources/functions.py b/frontal_sdk/resources/functions.py index f884fbf..d07589a 100644 --- a/frontal_sdk/resources/functions.py +++ b/frontal_sdk/resources/functions.py @@ -424,72 +424,84 @@ def configure( def description( self, text: str ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set a human-readable description of the function.""" self._values["description"] = text return self def runtime( self, runtime: FunctionRuntime ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the execution runtime (e.g. ``FunctionRuntime.PYTHON_3_12``).""" self._values["runtime"] = runtime return self def entrypoint( self, entrypoint: str ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the handler entrypoint (e.g. ``"handler.main"``).""" self._values["entrypoint"] = entrypoint return self def source( self, source: str ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the function source code.""" self._values["source"] = source return self def input_schema( self, schema: Mapping[str, JSONValue] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the JSON Schema describing the function input.""" self._values["inputSchema"] = dict(schema) return self def output_schema( self, schema: Mapping[str, JSONValue] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the JSON Schema describing the function output.""" self._values["outputSchema"] = dict(schema) return self def dependencies( self, dependencies: list[str] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set third-party package dependencies for the function.""" self._values["dependencies"] = cast(JSONValue, dependencies) return self def env_vars( self, env_vars: Mapping[str, str] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set non-secret environment variables for the function.""" self._values["envVars"] = dict(env_vars) return self def secrets( self, secrets: list[str] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the names of secrets injected at runtime.""" self._values["secrets"] = cast(JSONValue, secrets) return self def memory( self, memory: int ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the memory limit in megabytes.""" self._values["memory"] = memory return self def timeout( self, timeout: int ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the execution timeout in seconds.""" self._values["timeout"] = timeout return self def permissions( self, permissions: FunctionPermission | Mapping[str, JSONValue] ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set resource permissions from a model or mapping.""" self._values["permissions"] = ( cast( JSONValue, @@ -521,14 +533,18 @@ def __init__( self.id = function_id def get(self) -> JSONResultT: + """Get this function's current definition from the API.""" return self._functions.get(self.id) def update( self, definition: FunctionDefinition | Mapping[str, JSONValue] ) -> JSONResultT: + """Update this function's definition, sending a PATCH so omitted + fields are left unchanged.""" return self._functions.update(self.id, definition) def delete(self) -> JSONResultT: + """Delete this function.""" return self._functions.delete(self.id) diff --git a/frontal_sdk/resources/workflows.py b/frontal_sdk/resources/workflows.py index bd78b57..e308012 100644 --- a/frontal_sdk/resources/workflows.py +++ b/frontal_sdk/resources/workflows.py @@ -526,24 +526,28 @@ def __init__( def description( self, text: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set a human-readable description of the workflow.""" self._values["description"] = text return self def version( self, version: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set the workflow definition version string.""" self._values["version"] = version return self def variables( self, variables: Mapping[str, JSONValue] ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Set default variables available to every step at runtime.""" self._values["variables"] = dict(variables) return self def tags( self, *tags: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Attach free-text tags used for filtering in list calls.""" current = cast(list[JSONValue], self._values["tags"]) self._values["tags"] = [*current, *tags] return self @@ -551,21 +555,25 @@ def tags( def trigger( self, trigger: WorkflowTrigger | Mapping[str, JSONValue] ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a trigger from a model or mapping.""" value = _model_value(trigger) cast(list[JSONValue], self._values["triggers"]).append(value) return self def manual(self) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add a manual trigger started explicitly via the API.""" return self.trigger({"type": "manual"}) def schedule( self, cron: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add a cron schedule trigger (e.g. ``"0 9 * * *"``).""" return self.trigger({"type": "schedule", "schedule": cron}) def event( self, event_type: str, config: Mapping[str, JSONValue] | None = None ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add an event trigger that fires on the given event type.""" value: dict[str, JSONValue] = {"type": "event", "eventType": event_type} if config is not None: value["config"] = dict(config) @@ -574,11 +582,13 @@ def event( def webhook( self, url: str ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Add a webhook trigger invoked by POSTing to the given URL.""" return self.trigger({"type": "webhook", "webhookUrl": url}) def step( self, step: WorkflowStep | Mapping[str, JSONValue] ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a raw step from a model or mapping.""" cast(list[JSONValue], self._values["steps"]).append(_model_value(step)) return self @@ -592,6 +602,7 @@ def task( depends_on: list[str] | None = None, timeout: str | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a task step that executes code with the given config.""" return self._append_step( { "id": step_id, @@ -611,6 +622,7 @@ def approval( depends_on: list[str] | None = None, timeout: str | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append an approval step pausing until an approver acts.""" return self._append_step( { "id": step_id, @@ -629,6 +641,7 @@ def condition( description: str | None = None, depends_on: list[str] | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a condition step branching on the given expression.""" return self._append_step( { "id": step_id, @@ -647,6 +660,7 @@ def parallel( description: str | None = None, depends_on: list[str] | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a parallel step running the given step ids together.""" return self._append_step( { "id": step_id, @@ -665,6 +679,7 @@ def delay( description: str | None = None, depends_on: list[str] | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a delay step pausing for the given duration.""" return self._append_step( { "id": step_id, @@ -684,6 +699,7 @@ def notification( description: str | None = None, depends_on: list[str] | None = None, ) -> WorkflowBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Append a notification step sending a message to channels.""" return self._append_step( { "id": step_id, @@ -727,21 +743,28 @@ def __init__( self.id = workflow_id def get(self) -> JSONResultT: + """Get this workflow's current definition from the API.""" return self._workflows.get_workflow(self.id) def update(self, definition: Mapping[str, JSONValue]) -> JSONResultT: + """Update this workflow's definition, sending a PATCH so omitted + fields are left unchanged.""" return self._workflows.update_workflow(self.id, body=dict(definition)) def delete(self) -> JSONResultT: + """Delete this workflow.""" return self._workflows.delete_workflow(self.id) def activate(self) -> JSONResultT: + """Publish this workflow so it can accept executions.""" return self._workflows.publish_workflow(self.id, body={}) def archive(self) -> JSONResultT: + """Archive this workflow, stopping new executions.""" return self._workflows.archive_workflow(self.id, body={}) def restore(self) -> JSONResultT: + """Restore a previously archived workflow.""" return self._workflows.restore_workflow(self.id, body={}) def executions( @@ -751,14 +774,17 @@ def executions( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List this workflow's executions with optional pagination.""" query = _page_query(status=status, limit=limit, cursor=cursor) query["workflowId"] = self.id return self._workflows.list_executions(query=query) def execution(self, execution_id: str) -> JSONResultT: + """Get a single execution of this workflow by id.""" return self._workflows.get_execution(execution_id) def trigger(self, input: Mapping[str, JSONValue] | None = None) -> JSONResultT: + """Start a new execution of this workflow with the given input.""" return self._workflows.create_execution( body={"workflowId": self.id, "input": dict(input or {})} ) @@ -843,18 +869,23 @@ def list( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List approvals, optionally filtered by status, with optional + status pagination.""" return self._workflows.list_approvals( query=_page_query(status=status, limit=limit, cursor=cursor) ) def get(self, approval_id: str) -> JSONResultT: + """Get a single approval request by id.""" return self._workflows.get_approval(approval_id) def approve(self, approval_id: str, comment: str | None = None) -> JSONResultT: + """Approve a pending step, optionally attaching a comment.""" body: dict[str, JSONValue] = {} if comment is None else {"comment": comment} return self._workflows.approve_approval(approval_id, body=body) def reject(self, approval_id: str, comment: str | None = None) -> JSONResultT: + """Reject a pending step, optionally attaching a comment.""" body: dict[str, JSONValue] = {} if comment is None else {"comment": comment} return self._workflows.reject_approval(approval_id, body=body) @@ -875,17 +906,21 @@ def list_tasks( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List tasks of one execution with optional status pagination.""" return self._workflows.list_execution_tasks( execution_id, query=_page_query(status=status, limit=limit, cursor=cursor) ) def get_task(self, task_id: str) -> JSONResultT: + """Get a single task by id.""" return self._workflows.get_task(task_id) def retry_task(self, task_id: str) -> JSONResultT: + """Retry a failed or cancelled task.""" return self._workflows.retry_task(task_id, body={}) def cancel_task(self, task_id: str, reason: str | None = None) -> JSONResultT: + """Cancel a running task, optionally recording a reason.""" body: dict[str, JSONValue] = {} if reason is None else {"reason": reason} return self._workflows.cancel_task(task_id, body=body) @@ -897,6 +932,7 @@ def list_run_steps( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List run steps of one run with optional status pagination.""" return self._workflows.list_run_steps( run_id, query=_page_query(status=status, limit=limit, cursor=cursor) ) @@ -917,12 +953,14 @@ def list( limit: int | None = None, cursor: str | None = None, ) -> JSONResultT: + """List templates, optionally filtered by category.""" query = _page_query(limit=limit, cursor=cursor) if category is not None: query["category"] = category return self._workflows.list_templates(query=query) def get(self, template_id: str) -> JSONResultT: + """Get a single template by id.""" return self._workflows.get_template(template_id) def create( @@ -933,6 +971,7 @@ def create( description: str | None = None, category: str | None = None, ) -> JSONResultT: + """Create a reusable template from a workflow definition.""" model = _workflow_definition(definition) body: dict[str, JSONValue] = { "name": name, @@ -948,6 +987,7 @@ def create( return self._workflows.create_template(body=body) def use(self, template_id: str, name: str) -> JSONResultT: + """Instantiate a template into a new workflow with the given name.""" return self._workflows.instantiate_template(template_id, body={"name": name}) diff --git a/mcp.json b/mcp.json index 302e8b0..20b70e2 100644 --- a/mcp.json +++ b/mcp.json @@ -4,6 +4,22 @@ "version": "1.0.0", "status": "stable", "documents": [ + { + "path": ".claude/rules/sdk-language.md", + "title": "sdk-language" + }, + { + "path": ".cursor/README.md", + "title": "README" + }, + { + "path": ".cursor/commands/review-sdk-contract.md", + "title": "review-sdk-contract" + }, + { + "path": ".cursor/commands/verify-sdk-change.md", + "title": "verify-sdk-change" + }, { "path": ".github/BRANCH_PROTECTION.md", "title": "BRANCH PROTECTION" @@ -12,6 +28,10 @@ "path": ".github/PULL_REQUEST_TEMPLATE.md", "title": "PULL REQUEST TEMPLATE" }, + { + "path": ".vscode/README.md", + "title": "README" + }, { "path": "AGENTS.md", "title": "AGENTS" @@ -20,6 +40,10 @@ "path": "CHANGELOG.md", "title": "CHANGELOG" }, + { + "path": "CLAUDE.md", + "title": "CLAUDE" + }, { "path": "CODE_OF_CONDUCT.md", "title": "CODE OF CONDUCT" @@ -64,6 +88,14 @@ "path": "docs/ENTERPRISE_READINESS.md", "title": "ENTERPRISE READINESS" }, + { + "path": "docs/FUNCTIONS.md", + "title": "FUNCTIONS" + }, + { + "path": "docs/GETTING_STARTED.md", + "title": "GETTING STARTED" + }, { "path": "docs/ONBOARDING.md", "title": "ONBOARDING" @@ -112,10 +144,86 @@ "path": "scripts/README.md", "title": "README" }, + { + "path": "skills/SOURCES.md", + "title": "SOURCES" + }, { "path": "skills/frontal-sdk/SKILL.md", "title": "SKILL" }, + { + "path": "skills/python-lint/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-lint/references/migration.md", + "title": "migration" + }, + { + "path": "skills/python-lint/references/rule-selection.md", + "title": "rule-selection" + }, + { + "path": "skills/python-packaging/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-packaging/references/build-backends.md", + "title": "build-backends" + }, + { + "path": "skills/python-pydantic-httpx-contracts/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-supply-chain/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-supply-chain/references/dependabot.md", + "title": "dependabot" + }, + { + "path": "skills/python-supply-chain/references/scanning-and-provenance.md", + "title": "scanning-and-provenance" + }, + { + "path": "skills/python-sync-async-clients/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-testing/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-testing/references/beyond-coverage.md", + "title": "beyond-coverage" + }, + { + "path": "skills/python-testing/references/configuration.md", + "title": "configuration" + }, + { + "path": "skills/python-testing/references/multi-version.md", + "title": "multi-version" + }, + { + "path": "skills/python-typing/SKILL.md", + "title": "SKILL" + }, + { + "path": "skills/python-typing/references/checker-selection.md", + "title": "checker-selection" + }, + { + "path": "skills/python-typing/references/py-typed-distribution.md", + "title": "py-typed-distribution" + }, + { + "path": "skills/python-typing/references/strictness-ratchet.md", + "title": "strictness-ratchet" + }, { "path": "templates/README.md", "title": "README" diff --git a/scripts/check_contracts.py b/scripts/check_contracts.py index 688c1cc..c253fc3 100644 --- a/scripts/check_contracts.py +++ b/scripts/check_contracts.py @@ -6,7 +6,7 @@ import ast import json import re -from collections import defaultdict +from collections import Counter, defaultdict from pathlib import Path from typing import Any @@ -204,6 +204,34 @@ def matches_operation( def main() -> None: + """Validate the committed contracts and report snapshot conformance.""" + inventory, coverage_floor = validate_inputs() + specs = load_specs() + + sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]] = defaultdict(list) + errors: list[str] = [] + missing_from_spec: list[tuple[str, str, str]] = [] + expected_count = 0 + + for domain, entries in inventory.items(): + domain_count, domain_drift = process_domain( + domain, entries, specs, sdk_by_surface, errors + ) + expected_count += domain_count + missing_from_spec.extend(domain_drift) + + collect_obsolete_package_errors(errors) + coverage, uncovered_spec = compute_coverage(specs, sdk_by_surface) + + if errors: + raise SystemExit("\n".join(errors)) + + print_summary(expected_count, coverage, missing_from_spec, uncovered_spec) + print_floor_drift(coverage, coverage_floor) + + +def validate_inputs() -> tuple[dict[str, Any], dict[str, Any]]: + """Load the shared contract inputs and check the coverage floor shape.""" for path in FILES: load_json(path) @@ -211,81 +239,39 @@ def main() -> None: coverage_floor = load_json(COVERAGE_FLOOR_PATH) if not isinstance(inventory, dict): raise SystemExit("endpoint inventory must be a JSON object") + if not isinstance(coverage_floor, dict): + raise SystemExit("coverage floor must be a JSON object") for surface in ("api", "ai"): value = coverage_floor.get(surface) if not isinstance(value, int) or value < 0: raise SystemExit( f"coverage floor must contain a non-negative {surface} count" ) + return inventory, coverage_floor + - specs = { +def load_specs() -> dict[str, set[tuple[str, str]]]: + """Parse the published OpenAPI snapshots for both surfaces.""" + return { "api": parse_openapi(API_OPENAPI_PATH), "ai": parse_openapi(AI_OPENAPI_PATH), } - sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]] = defaultdict(list) - errors: list[str] = [] - missing_from_spec: list[tuple[str, str, str]] = [] - expected_count = 0 - for domain, entries in inventory.items(): - if not isinstance(domain, str) or not isinstance(entries, list): - errors.append(f"invalid endpoint inventory entry: {domain!r}") - continue - resource_path = ROOT / "frontal_sdk/resources" / f"{domain}.py" - if not entries: - if resource_path.exists(): - errors.append(f"unexpected resource module for empty domain: {domain}") - continue - if not resource_path.is_file(): - errors.append(f"missing resource module: {resource_path.relative_to(ROOT)}") - continue - - implemented, resource_errors = resource_operations(resource_path) - errors.extend(resource_errors) - expected: set[tuple[str, str]] = set() - surface = "ai" if domain == "ai" else "api" - for entry in entries: - if not isinstance(entry, dict): - errors.append(f"invalid operation in {domain}: {entry!r}") - continue - method, path = entry.get("method"), entry.get("path") - if not isinstance(method, str) or not isinstance(path, str): - errors.append(f"invalid method/path in {domain}: {entry!r}") - continue - expected.add((method, path)) - resolved_method = resolve_method(method) - if not surface_allows(surface, path): - errors.append( - f"forbidden {surface} surface operation: {domain} {method} {path}" - ) - sdk_candidates = candidate_paths(surface, path) - sdk_by_surface[surface].append((resolved_method, sdk_candidates)) - if not any( - resolved_method == spec_method - and any( - path_matches(spec_path, candidate) for candidate in sdk_candidates - ) - for spec_method, spec_path in specs[surface] - ): - missing_from_spec.append((domain, method, path)) - - if len(expected) != len(entries): - errors.append(f"duplicate operations in {domain} endpoint inventory") - missing = expected - implemented - unexpected = implemented - expected - if missing or unexpected: - errors.append( - f"resource mismatch for {domain}: missing={sorted(missing)}, " - f"unexpected={sorted(unexpected)}" - ) - expected_count += len(expected) +def collect_obsolete_package_errors(errors: list[str]) -> None: + """Flag packages that were removed from the SDK but still exist.""" for obsolete_package in ("services", "utils"): if (ROOT / "frontal_sdk" / obsolete_package).exists(): errors.append( f"obsolete frontal_sdk/{obsolete_package} package still exists" ) + +def compute_coverage( + specs: dict[str, set[tuple[str, str]]], + sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]], +) -> tuple[dict[str, tuple[int, int]], dict[str, list[tuple[str, str]]]]: + """Match each snapshot operation against the endpoint inventory.""" coverage: dict[str, tuple[int, int]] = {} uncovered_spec: dict[str, list[tuple[str, str]]] = {} for surface, operations in specs.items(): @@ -296,10 +282,123 @@ def main() -> None: ] coverage[surface] = (len(operations) - len(uncovered), len(operations)) uncovered_spec[surface] = uncovered + return coverage, uncovered_spec + + +def process_domain( + domain: Any, + entries: Any, + specs: dict[str, set[tuple[str, str]]], + sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]], + errors: list[str], +) -> tuple[int, list[tuple[str, str, str]]]: + """Validate one inventory domain, returning its size and any snapshot drift.""" + if not isinstance(domain, str) or not isinstance(entries, list): + errors.append(f"invalid endpoint inventory entry: {domain!r}") + return 0, [] + + resource_path = ROOT / "frontal_sdk/resources" / f"{domain}.py" + if not entries: + if resource_path.exists(): + errors.append(f"unexpected resource module for empty domain: {domain}") + return 0, [] + + if not resource_path.is_file(): + errors.append(f"missing resource module: {resource_path.relative_to(ROOT)}") + return 0, [] + + implemented, resource_errors = resource_operations(resource_path) + errors.extend(resource_errors) + surface = "ai" if domain == "ai" else "api" + expected, missing_from_spec = domain_expected_operations( + domain, entries, surface, specs, sdk_by_surface, errors + ) - if errors: - raise SystemExit("\n".join(errors)) + if len(expected) != len(entries): + errors.append(f"duplicate operations in {domain} endpoint inventory") + report_resource_mismatch(domain, expected, implemented, errors) + return len(expected), missing_from_spec + + +def domain_expected_operations( + domain: str, + entries: list[Any], + surface: str, + specs: dict[str, set[tuple[str, str]]], + sdk_by_surface: dict[str, list[tuple[str, tuple[str, ...]]]], + errors: list[str], +) -> tuple[set[tuple[str, str]], list[tuple[str, str, str]]]: + """Expand inventory entries into the operations a resource must expose.""" + expected: set[tuple[str, str]] = set() + missing_from_spec: list[tuple[str, str, str]] = [] + for entry in entries: + if not isinstance(entry, dict): + errors.append(f"invalid operation in {domain}: {entry!r}") + continue + method, path = entry.get("method"), entry.get("path") + if not isinstance(method, str) or not isinstance(path, str): + errors.append(f"invalid method/path in {domain}: {entry!r}") + continue + expected.add((method, path)) + if not surface_allows(surface, path): + errors.append( + f"forbidden {surface} surface operation: {domain} {method} {path}" + ) + resolved_method = resolve_method(method) + sdk_candidates = candidate_paths(surface, path) + sdk_by_surface[surface].append((resolved_method, sdk_candidates)) + if not operation_in_spec(resolved_method, sdk_candidates, specs[surface]): + missing_from_spec.append((domain, method, path)) + return expected, missing_from_spec + + +def report_resource_mismatch( + domain: str, + expected: set[tuple[str, str]], + implemented: set[tuple[str, str]], + errors: list[str], +) -> None: + """Compare the operations a resource implements against the inventory.""" + missing = expected - implemented + unexpected = implemented - expected + if missing or unexpected: + errors.append( + f"resource mismatch for {domain}: missing={sorted(missing)}, " + f"unexpected={sorted(unexpected)}" + ) + +def operation_in_spec( + method: str, candidates: tuple[str, ...], spec: set[tuple[str, str]] +) -> bool: + """Report whether a resource operation appears in an OpenAPI snapshot.""" + return any( + spec_method == method + and any(path_matches(spec_path, candidate) for candidate in candidates) + for spec_method, spec_path in spec + ) + + +def print_floor_drift( + coverage: dict[str, tuple[int, int]], coverage_floor: dict[str, Any] +) -> None: + """Report parts of a surface whose matched baseline fell below its floor.""" + for surface, actual in coverage.items(): + floor = coverage_floor[surface] + if actual[0] < floor: + print( + f"OpenAPI coverage floor drift for {surface}: " + f"{actual[0]} matched, previous floor {floor}" + ) + + +def print_summary( + expected_count: int, + coverage: dict[str, tuple[int, int]], + missing_from_spec: list[tuple[str, str, str]], + uncovered_spec: dict[str, list[tuple[str, str]]], +) -> None: + """Print the contract summary followed by the informational drift reports.""" api_covered, api_total = coverage["api"] ai_covered, ai_total = coverage["ai"] print( @@ -310,20 +409,24 @@ def main() -> None: if missing_from_spec: print( f"OpenAPI baseline drift: {len(missing_from_spec)} inventory " - "operations are absent from the published snapshots" + "operations are absent from the published snapshots " + "(see contracts/README.md)" ) + drift_counts = Counter(domain for domain, _method, _path in missing_from_spec) + for domain in sorted(drift_counts): + print(f" {domain}: {drift_counts[domain]} operations") if uncovered_spec["api"] or uncovered_spec["ai"]: print( "OpenAPI operations not exposed by the endpoint inventory: " - f"api {len(uncovered_spec['api'])}, ai {len(uncovered_spec['ai'])}" + f"api {len(uncovered_spec['api'])}, ai {len(uncovered_spec['ai'])} " + "(see contracts/README.md)" ) - for surface, actual in coverage.items(): - floor = coverage_floor[surface] - if actual[0] < floor: - print( - f"OpenAPI coverage floor drift for {surface}: " - f"{actual[0]} matched, previous floor {floor}" - ) + uncovered_counts = Counter( + "/".join(path.strip("/").split("/")[:2]) + for _method, path in (*uncovered_spec["api"], *uncovered_spec["ai"]) + ) + for group in sorted(uncovered_counts): + print(f" {group}: {uncovered_counts[group]} operations") if __name__ == "__main__": diff --git a/scripts/generate_docs_manifest.py b/scripts/generate_docs_manifest.py index c75d458..0d139ea 100644 --- a/scripts/generate_docs_manifest.py +++ b/scripts/generate_docs_manifest.py @@ -1,24 +1,32 @@ #!/usr/bin/env python3 -"""Generate simple documentation indexes from Markdown files.""" +"""Generate simple documentation indexes from tracked Markdown files.""" + +from __future__ import annotations import json +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -paths = sorted( - p - for p in ROOT.rglob("*.md") - if ".git" not in p.parts - and ".venv" not in p.parts - and ".pytest_cache" not in p.parts - and ".mypy_cache" not in p.parts - and ".ruff_cache" not in p.parts - and "node_modules" not in p.parts - and "target" not in p.parts - and "build" not in p.parts - and "dist" not in p.parts - and p.name != "PLAN.md" -) + + +def tracked_markdown() -> list[Path]: + """List tracked Markdown files so local scratch files never leak in.""" + output = subprocess.run( + ["git", "ls-files", "--", "*.md"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + # ``git ls-files`` still lists tracked paths whose deletion has not been + # staged, so check the working tree to avoid linking to a missing file. + return [ + ROOT / line for line in output.splitlines() if line and (ROOT / line).is_file() + ] + + +paths = sorted(p for p in tracked_markdown() if p.name != "PLAN.md") documents = [ {"path": p.relative_to(ROOT).as_posix(), "title": p.stem.replace("_", " ")} for p in paths diff --git a/tests/test_circuit_breaker.py b/tests/test_circuit_breaker.py new file mode 100644 index 0000000..5fc86a0 --- /dev/null +++ b/tests/test_circuit_breaker.py @@ -0,0 +1,219 @@ +"""Circuit breaker tests covering state transitions and lock safety.""" + +from __future__ import annotations + +import asyncio +import threading +import time +from typing import Any, Callable + +import pytest +from frontal_sdk import core +from frontal_sdk.core.circuit_breaker import ( + CircuitBreaker, + CircuitBreakerConfig, + CircuitBreakerOpenError, + CircuitState, +) + + +def _boom() -> Any: + raise RuntimeError("upstream is down") + + +def _ok() -> str: + return "ok" + + +def _server_error() -> int: + return 500 + + +def _fail_once(breaker: CircuitBreaker) -> None: + """Drive one failure through the breaker on the calling thread.""" + with pytest.raises(RuntimeError): + breaker.execute(_boom) + + +def _succeed_once(breaker: CircuitBreaker) -> str: + return breaker.execute(_ok) + + +def _run_with_timeout(fn: Callable[[], Any], timeout: float = 5.0) -> Any: + """Run ``fn`` on a worker thread, failing instead of hanging on deadlock.""" + outcome: list[Any] = [] + failure: list[BaseException] = [] + + def runner() -> None: + try: + outcome.append(fn()) + except BaseException as exc: # noqa: BLE001 - re-raised below by the caller + failure.append(exc) + + thread = threading.Thread(target=runner, daemon=True) + thread.start() + thread.join(timeout) + if thread.is_alive(): + pytest.fail("circuit breaker deadlocked") + if failure: + raise failure[0] + return outcome[0] + + +def test_public_core_exports_expose_breaker_names() -> None: + assert core.CircuitBreaker is CircuitBreaker + assert core.CircuitBreakerOpenError is CircuitBreakerOpenError + assert "CircuitBreakerOpenError" in core.__all__ + assert "CircuitState" in core.__all__ + + +def test_open_callback_may_read_breaker_state() -> None: + observed: list[tuple[CircuitState, int]] = [] + + def on_open() -> None: + # Deadlocks if the callback fires while the breaker lock is held. + observed.append((breaker.state, breaker.failures)) + + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1, on_open=on_open)) + + with pytest.raises(RuntimeError): + _run_with_timeout(lambda: breaker.execute(_boom)) + + assert observed == [("open", 1)] + + +def test_close_callback_may_read_breaker_state() -> None: + observed: list[tuple[CircuitState, int]] = [] + + def on_close() -> None: + # Deadlocks if the callback fires while the breaker lock is held. + observed.append((breaker.state, breaker.failures)) + + breaker = CircuitBreaker( + CircuitBreakerConfig(failure_threshold=1, reset_timeout=0.01, on_close=on_close) + ) + + _run_with_timeout(lambda: _fail_once(breaker)) + time.sleep(0.02) + assert _run_with_timeout(lambda: _succeed_once(breaker)) == "ok" + + assert observed == [("closed", 0)] + + +def test_half_open_callback_may_read_breaker_state() -> None: + observed: list[CircuitState] = [] + + def on_half_open() -> None: + observed.append(breaker.state) + + breaker = CircuitBreaker( + CircuitBreakerConfig( + failure_threshold=1, reset_timeout=0.01, on_half_open=on_half_open + ) + ) + + _run_with_timeout(lambda: _fail_once(breaker)) + time.sleep(0.02) + assert _run_with_timeout(lambda: _succeed_once(breaker)) == "ok" + + assert observed == ["half_open"] + + +@pytest.mark.parametrize( + "cancelled", + [asyncio.CancelledError, KeyboardInterrupt, SystemExit], +) +def test_non_error_base_exceptions_do_not_open_the_circuit( + cancelled: type[BaseException], +) -> None: + def _cancelled() -> Any: + raise cancelled("stopped") + + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1)) + + with pytest.raises(cancelled): + breaker.execute(_cancelled) + + assert breaker.state == "closed" + assert breaker.failures == 0 + + +def test_failures_open_the_circuit_and_short_circuit_calls() -> None: + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=2)) + + for _ in range(2): + with pytest.raises(RuntimeError): + breaker.execute(_boom) + + assert breaker.state == "open" + assert breaker.failures == 2 + with pytest.raises(CircuitBreakerOpenError): + breaker.execute(_ok) + + +def test_successful_probe_closes_the_circuit() -> None: + breaker = CircuitBreaker( + CircuitBreakerConfig(failure_threshold=1, reset_timeout=0.01) + ) + + with pytest.raises(RuntimeError): + breaker.execute(_boom) + assert breaker.state == "open" + + time.sleep(0.02) + assert breaker.execute(_ok) == "ok" + assert breaker.state == "closed" + assert breaker.failures == 0 + + +def test_result_predicate_counts_non_raising_failures() -> None: + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1)) + + def is_failure(status: int) -> bool: + return status >= 500 + + assert breaker.execute(_server_error, is_failure=is_failure) == 500 + + assert breaker.state == "open" + with pytest.raises(CircuitBreakerOpenError): + breaker.execute(_ok) + + +def test_force_close_reopens_and_accepts_a_new_probe() -> None: + breaker = CircuitBreaker( + CircuitBreakerConfig(failure_threshold=1, reset_timeout=10) + ) + _fail_once(breaker) + _run_with_timeout(breaker.force_close) + assert breaker.state == "closed" + assert breaker.failures == 0 + assert _run_with_timeout(lambda: _succeed_once(breaker)) == "ok" + + +async def test_async_cancel_does_not_open_the_circuit() -> None: + async def _cancelled() -> Any: + raise asyncio.CancelledError("stopped") + + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1)) + + with pytest.raises(asyncio.CancelledError): + await breaker.execute_async(_cancelled) + + assert breaker.state == "closed" + assert breaker.failures == 0 + + +async def test_async_failures_open_the_circuit() -> None: + async def _boom_async() -> Any: + raise RuntimeError("upstream is down") + + async def _ok_async() -> str: + return "ok" + + breaker = CircuitBreaker(CircuitBreakerConfig(failure_threshold=1)) + with pytest.raises(RuntimeError): + await breaker.execute_async(_boom_async) + + assert breaker.state == "open" + with pytest.raises(CircuitBreakerOpenError): + await breaker.execute_async(_ok_async) diff --git a/tests/test_client.py b/tests/test_client.py index d993cb1..e457997 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -15,6 +15,7 @@ APIModel, AsyncFrontal, AuthenticationError, + ConflictError, Frontal, FrontalError, GenerateImageResult, @@ -22,6 +23,7 @@ GenerateTextResult, MultipartPart, NetworkError, + NotFoundError, PageResult, RateLimitError, ServerError, @@ -208,6 +210,128 @@ def test_nonstandard_error_json_is_preserved(respx_mock: respx.Router) -> None: client.close() +@pytest.mark.parametrize( + ("status_code", "code", "error_type", "expected_fix"), + [ + ( + 401, + "UNAUTHORIZED", + AuthenticationError, + "Check FRONTAL_API_KEY — it must be a valid key starting with frt_.", + ), + ( + 403, + "INVALID_API_KEY", + AuthenticationError, + "Check FRONTAL_API_KEY — it must be a valid key starting with frt_.", + ), + ( + 404, + "NOT_FOUND", + NotFoundError, + "Verify the resource id belongs to this workspace/environment.", + ), + ( + 409, + "VALIDATION_ERROR", + ConflictError, + "Fix the offending properties listed in `fields`.", + ), + ( + 429, + "RATE_LIMITED", + RateLimitError, + "Back off and retry after `retryAfter`, or lower concurrency.", + ), + ( + 400, + "BAD_REQUEST", + ValidationError, + "Fix the offending properties listed in `fields`.", + ), + ( + 422, + "VALIDATION_ERROR", + ValidationError, + "Fix the offending properties listed in `fields`.", + ), + ( + 500, + "INTERNAL_ERROR", + ServerError, + "Service is temporarily unavailable; retry with backoff.", + ), + ( + 503, + "SERVICE_UNAVAILABLE", + ServerError, + "Service is temporarily unavailable; retry with backoff.", + ), + ], +) +def test_error_fix_hints_are_reported_for_known_codes( + respx_mock: respx.Router, + status_code: int, + code: str, + error_type: type[FrontalError], + expected_fix: str, +) -> None: + respx_mock.get(f"{API_URL}/failure").mock( + return_value=httpx.Response( + status_code, json={"code": code, "message": "rejected"} + ) + ) + client = Frontal("frt_local_key", max_retries=0) + operation = Operation("GET", "/failure") + + with pytest.raises(error_type) as raised: + client._http.request(operation) + + assert raised.value.fix == expected_fix + client.close() + + +@pytest.mark.parametrize( + ("status_code", "code"), + [ + (404, "ARCHIVED"), + (409, "STATE_CONFLICT"), + (429, "QUOTA_EXCEEDED"), + (418, "TEAPOT"), + ], +) +def test_error_fix_hint_is_absent_for_unmapped_codes( + respx_mock: respx.Router, status_code: int, code: str +) -> None: + respx_mock.get(f"{API_URL}/failure").mock( + return_value=httpx.Response( + status_code, json={"code": code, "message": "rejected"} + ) + ) + client = Frontal("frt_local_key", max_retries=0) + operation = Operation("GET", "/failure") + + with pytest.raises(FrontalError) as raised: + client._http.request(operation) + + assert raised.value.fix is None + client.close() + + +def test_generic_error_carries_fallback_fix_hint(respx_mock: respx.Router) -> None: + respx_mock.get(f"{API_URL}/failure").mock( + return_value=httpx.Response(418, json={"message": "teapot"}) + ) + client = Frontal("frt_local_key", max_retries=0) + operation = Operation("GET", "/failure") + + with pytest.raises(FrontalError) as raised: + client._http.request(operation) + + assert raised.value.fix == "Check the API response for details." + client.close() + + def test_http_errors_distinguish_transient_from_safe_replay( respx_mock: respx.Router, ) -> None: diff --git a/tests/test_examples.py b/tests/test_examples.py index 8a39fde..6672d36 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -139,3 +139,55 @@ def test_workflow_example_with_mocked_api( assert create_route.called assert version_route.called assert trigger_route.called + + +def test_stream_text_example_with_mocked_sse( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_api_key(monkeypatch) + with respx.mock(assert_all_called=False) as router: + route = router.post(f"{API_URL}/ai/chat/completions").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=b"".join( + f"data: {chunk}\n\n".encode() + for chunk in ( + '{"choices": [{"delta": {"content": "one "}}]}', + '{"choices": [{"delta": {"content": "two"},' + ' "finishReason": "stop"}]}', + '"[DONE]"', + ) + ), + ) + ) + runpy.run_path("examples/stream_text.py", run_name="__main__") + assert route.called + + +def test_handle_errors_example_with_mocked_validation_error( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _set_api_key(monkeypatch) + with respx.mock(assert_all_called=False) as router: + route = router.get(f"{API_URL}/agents").mock( + return_value=httpx.Response( + 400, + json={ + "code": "VALIDATION_ERROR", + "message": "rejected", + "requestId": "req_example", + "fields": [ + { + "field": "limit", + "code": "REQUIRED", + "message": "must be positive", + } + ], + }, + ) + ) + runpy.run_path("examples/handle_errors.py", run_name="__main__") + assert route.called + assert "limit: must be positive" in capsys.readouterr().out