From 71d7e8901f543ad5ffa38c3f85eee349d33af0dd Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Fri, 9 Oct 2026 14:50:04 +0100 Subject: [PATCH 1/4] refactor(sdk): remove sandbox resource --- contracts/sdk-endpoints.json | 18 ++--------- docs/OVERVIEW.md | 2 +- docs/RESOURCES.md | 2 +- examples/SDKS_GUIDE.md | 2 +- frontal_sdk/client.py | 5 --- frontal_sdk/resources/__init__.py | 2 -- frontal_sdk/resources/sandbox.py | 52 ------------------------------- 7 files changed, 5 insertions(+), 78 deletions(-) delete mode 100644 frontal_sdk/resources/sandbox.py diff --git a/contracts/sdk-endpoints.json b/contracts/sdk-endpoints.json index 011d26b..498b6cd 100644 --- a/contracts/sdk-endpoints.json +++ b/contracts/sdk-endpoints.json @@ -1313,22 +1313,8 @@ "path": "/data/pipelines/pipeline-runs/{param}" } ], - "react": [], - "sandbox": [ - { - "method": "GET", - "path": "/sandbox/languages" - }, - { - "method": "POST", - "path": "/sandbox/self-test" - }, - { - "method": "POST", - "path": "/sandbox/submit" - } - ], - "schedules": [ + "react": [], + "schedules": [ { "method": "DELETE", "path": "/workflows/schedules/{param}" diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 3cae049..a78ed56 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -51,7 +51,7 @@ Each client exposes the same service attributes: `ai`, `agents`, `workflows`, `audit`, `auth`, `billing`, `blob`, `connectors`, `data`, `governance`, `lineage`, `observability`, `ontology`, `pipelines`, -`sandbox`, `schedules`, and `webhooks`. +`schedules`, and `webhooks`. The AI service adds normalized helpers such as `generate_text`, `stream_text`, `embed`, `generate_object`, `generate_speech`, `generate_image`, diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md index ff3f152..c6aceb3 100644 --- a/docs/RESOURCES.md +++ b/docs/RESOURCES.md @@ -10,7 +10,7 @@ | `audit`, `auth`, `billing` | Audit events, authentication, and billing | | `blob`, `connectors`, `data` | Files, integrations, and data operations | | `governance`, `lineage`, `observability` | Governance, lineage, and telemetry | -| `ontology`, `pipelines`, `sandbox` | Ontology, pipelines, and sandbox operations | +| `ontology`, `pipelines` | Ontology and pipelines operations | | `schedules`, `webhooks` | Schedules and webhook operations | ## AI helpers diff --git a/examples/SDKS_GUIDE.md b/examples/SDKS_GUIDE.md index a143d51..ef16dc3 100644 --- a/examples/SDKS_GUIDE.md +++ b/examples/SDKS_GUIDE.md @@ -46,7 +46,7 @@ asyncio.run(main()) Clients expose `ai`, `agents`, `workflows`, `audit`, `auth`, `billing`, `blob`, `connectors`, `data`, `governance`, `lineage`, `observability`, `ontology`, -`pipelines`, `sandbox`, `schedules`, and `webhooks`. The first three also +`pipelines`, `schedules`, and `webhooks`. The first three also provide high-level helpers and validated builders. For example, create an agent with `client.agents.define(name).trigger(event).can_read(entity).create()` or define a workflow with `client.workflows.define(name).manual().task(...).create()`. diff --git a/frontal_sdk/client.py b/frontal_sdk/client.py index ea5ca8c..07213ec 100644 --- a/frontal_sdk/client.py +++ b/frontal_sdk/client.py @@ -25,7 +25,6 @@ Observability, Ontology, Pipelines, - Sandbox, Schedules, SyncAI, Webhooks, @@ -126,7 +125,6 @@ def __init__( self.pipelines: Pipelines[JSONValue, bytes, Iterator[ServerEvent]] = Pipelines( http ) - self.sandbox: Sandbox[JSONValue, bytes, Iterator[ServerEvent]] = Sandbox(http) self.schedules: Schedules[JSONValue, bytes, Iterator[ServerEvent]] = Schedules( http ) @@ -214,9 +212,6 @@ def __init__( self.pipelines: Pipelines[ _AsyncJSONResult, _AsyncBytesResult, _AsyncStreamResult ] = Pipelines(http) - self.sandbox: Sandbox[ - _AsyncJSONResult, _AsyncBytesResult, _AsyncStreamResult - ] = Sandbox(http) self.schedules: Schedules[ _AsyncJSONResult, _AsyncBytesResult, _AsyncStreamResult ] = Schedules(http) diff --git a/frontal_sdk/resources/__init__.py b/frontal_sdk/resources/__init__.py index 3966054..c32445f 100644 --- a/frontal_sdk/resources/__init__.py +++ b/frontal_sdk/resources/__init__.py @@ -13,7 +13,6 @@ from frontal_sdk.resources.observability import Observability from frontal_sdk.resources.ontology import Ontology from frontal_sdk.resources.pipelines import Pipelines -from frontal_sdk.resources.sandbox import Sandbox from frontal_sdk.resources.schedules import Schedules from frontal_sdk.resources.webhooks import Webhooks from frontal_sdk.resources.workflows import ( @@ -42,7 +41,6 @@ "Observability", "Ontology", "Pipelines", - "Sandbox", "Schedules", "Webhooks", "WorkflowAccessor", diff --git a/frontal_sdk/resources/sandbox.py b/frontal_sdk/resources/sandbox.py deleted file mode 100644 index 63e7de3..0000000 --- a/frontal_sdk/resources/sandbox.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Typed API resource for the sandbox endpoints.""" - -from __future__ import annotations - -from typing import Generic - -from frontal_sdk.core.operation import Operation -from frontal_sdk.models import QueryParams -from frontal_sdk.models.requests import UNSET, RequestBodyInput -from frontal_sdk.resources._base import ( - APIResource, - BytesResultT, - JSONResultT, - StreamResultT, -) - - -class Sandbox( - APIResource[JSONResultT, BytesResultT, StreamResultT], - Generic[JSONResultT, BytesResultT, StreamResultT], -): - """Methods for the sandbox API endpoints.""" - - def list_languages(self, *, query: QueryParams | None = None) -> JSONResultT: - """Call GET /sandbox/languages.""" - return self._request( - Operation("GET", "/sandbox/languages"), - path_params=(), - query=query, - ) - - def post_sandbox_self_test( - self, *, query: QueryParams | None = None, body: RequestBodyInput = UNSET - ) -> JSONResultT: - """Call POST /sandbox/self-test.""" - return self._request( - Operation("POST", "/sandbox/self-test"), - path_params=(), - query=query, - body=body, - ) - - def post_sandbox_submit( - self, *, query: QueryParams | None = None, body: RequestBodyInput = UNSET - ) -> JSONResultT: - """Call POST /sandbox/submit.""" - return self._request( - Operation("POST", "/sandbox/submit"), - path_params=(), - query=query, - body=body, - ) From aec607535286856ee89a9e4985e08bedef1e403f Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Fri, 9 Oct 2026 15:03:59 +0100 Subject: [PATCH 2/4] style(ci): format commitlint scope list --- commitlint.config.cjs | 55 +++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 64e5492..6ee97b3 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -21,34 +21,33 @@ module.exports = { "scope-enum": [ 2, "always", - [ - "sdk", - "core", - "ai", - "agents", - "audit", - "auth", - "billing", - "blob", - "connectors", - "data", - "governance", - "lineage", - "observability", - "ontology", - "pipelines", - "sandbox", - "schedules", - "webhooks", - "workflows", - "repo", - "deps", - "ci", - "release", - "docs", - "scripts", - "templates", - ], + [ + "sdk", + "core", + "ai", + "agents", + "audit", + "auth", + "billing", + "blob", + "connectors", + "data", + "governance", + "lineage", + "observability", + "ontology", + "pipelines", + "schedules", + "webhooks", + "workflows", + "repo", + "deps", + "ci", + "release", + "docs", + "scripts", + "templates", + ], ], "header-max-length": [2, "always", 100], }, From 740f63ff3cefab7bce2337e7c724d97c846f036a Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Fri, 9 Oct 2026 15:12:45 +0100 Subject: [PATCH 3/4] ci: rerun pull request title check From 17b56b748607ea0b2771f1ab75e46e5c573eb4de Mon Sep 17 00:00:00 2001 From: Gabriel Fonseca Date: Fri, 9 Oct 2026 15:39:25 +0100 Subject: [PATCH 4/4] feat(functions): add functions resource Add the functions resource to the Frontal Python SDK, including: - New model (frontal_sdk/models/functions.py) - New resource (frontal_sdk/resources/functions.py) - Unit tests (tests/test_functions.py) - Example usage (examples/create_function.py) - Documentation (docs/FUNCTIONS.md) - Updates to package exports (__init__.py files) - Client integration (frontal_sdk/client.py) - Test suite updates (tests/test_examples.py) - Endpoint inventory update (contracts/sdk-endpoints.json) - Documentation updates (README.md, docs/OVERVIEW.md, etc.) --- README.md | 16 +- contracts/sdk-endpoints.json | 66 ++++ docs/FUNCTIONS.md | 69 ++++ docs/OVERVIEW.md | 14 +- docs/README.md | 1 + docs/RESOURCES.md | 12 + examples/README.md | 2 + examples/create_function.py | 35 ++ frontal_sdk/__init__.py | 34 ++ frontal_sdk/client.py | 7 + frontal_sdk/models/__init__.py | 32 ++ frontal_sdk/models/functions.py | 167 +++++++++ frontal_sdk/resources/__init__.py | 4 + frontal_sdk/resources/functions.py | 542 +++++++++++++++++++++++++++++ tests/test_examples.py | 35 ++ tests/test_functions.py | 291 ++++++++++++++++ 16 files changed, 1314 insertions(+), 13 deletions(-) create mode 100644 docs/FUNCTIONS.md create mode 100644 examples/create_function.py create mode 100644 frontal_sdk/models/functions.py create mode 100644 frontal_sdk/resources/functions.py create mode 100644 tests/test_functions.py diff --git a/README.md b/README.md index 472c3fc..efe96d2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The `frontal` package provides synchronous and asynchronous Python clients for the Frontal API. Services are available on the client as typed attributes such -as `client.ai`, `client.agents`, and `client.workflows`. +as `client.ai`, `client.agents`, `client.functions`, and `client.workflows`. ## Install @@ -54,7 +54,7 @@ environment header, and debug logging. The SDK does not load `.env` files. ## What is included -- All 370 operations in `contracts/sdk-endpoints.json`, grouped by service. +- All 383 operations in `contracts/sdk-endpoints.json`, grouped by service. - HTTPX sync and async transports with bounded GET retries, request IDs, pagination helpers, polling, raw bytes, multipart uploads, and SSE streams. - Pydantic v2 request models and validation for JSON-compatible payloads. @@ -69,10 +69,14 @@ only when replaying that operation is safe. If you provide an `httpx.Client` or For JSON writes, omitting `body` sends no request body; pass `body=None` to send the JSON value `null`, and `body={}` to send an empty JSON object. -The OpenAPI snapshots define the operation inventory but do not include -service-specific request and response schemas for most endpoints. The SDK -does not guess those payload shapes; define request models with `APIModel` and -use `PageResult[T]` for paginated responses where applicable. +The endpoint inventory records the SDK's hand-written routes. OpenAPI snapshots +provide an additional contract baseline, but they omit the Functions routes +and most endpoint-specific request and response schemas. The Functions models +follow the TypeScript Functions package; other unmodeled operation payloads +use the recursive `JSONValue` type. + +The [Functions API guide](docs/FUNCTIONS.md) covers function definitions, +versions, deployment, invocation, execution results, and cancellation. The SDK uses resource-oriented names and semantic path arguments. diff --git a/contracts/sdk-endpoints.json b/contracts/sdk-endpoints.json index 498b6cd..32f6ae8 100644 --- a/contracts/sdk-endpoints.json +++ b/contracts/sdk-endpoints.json @@ -707,6 +707,72 @@ "path": "/data/transformations/transformations/{param}/executions" } ], + "functions": [ + { + "method": "DELETE", + "path": "/functions/{param}" + }, + { + "method": "GET", + "path": "/functions" + }, + { + "method": "GET", + "path": "/functions/{param}" + }, + { + "method": "GET", + "path": "/functions/executions" + }, + { + "method": "GET", + "path": "/functions/executions/{param}" + }, + { + "method": "GET", + "path": "/functions/executions/{param}/result" + }, + { + "method": "GET", + "path": "/functions/{param}/versions" + }, + { + "method": "GET", + "path": "/functions/{param}/versions/{param}" + }, + { + "method": "GET", + "path": "/functions/{param}/versions/{param}/deployment/status" + }, + { + "method": "PATCH", + "path": "/functions/{param}" + }, + { + "method": "POST", + "path": "/functions" + }, + { + "method": "POST", + "path": "/functions/executions/{param}/cancel" + }, + { + "method": "POST", + "path": "/functions/invoke" + }, + { + "method": "POST", + "path": "/functions/invoke-async" + }, + { + "method": "POST", + "path": "/functions/{param}/versions/{param}/deploy" + }, + { + "method": "POST", + "path": "/functions/{param}/versions/{param}/publish" + } + ], "governance": [ { "method": "DELETE", diff --git a/docs/FUNCTIONS.md b/docs/FUNCTIONS.md new file mode 100644 index 0000000..8d1d3f7 --- /dev/null +++ b/docs/FUNCTIONS.md @@ -0,0 +1,69 @@ +# Functions API + +`Frontal` and `AsyncFrontal` expose the Functions service as +`client.functions`. It uses the shared client configuration and HTTPX transport: +the default API URL is `https://api.frontal.dev/v1`, the default timeout is 30 +seconds, and the default retry count is three. Pass `api_key`, `base_url`, +`timeout`, and `max_retries` to either client to override those values. The API +key is sent as Bearer authentication. + +## Create and invoke + +Function definitions require `name`, `runtime` (`nodejs20`, `nodejs22`, or +`python311`), and `entrypoint`. Optional fields include `description`, `source`, +`input_schema`, `output_schema`, `dependencies`, `env_vars`, `secrets`, +`memory` in MB, `timeout` in seconds, and `permissions` with `ontology` and +`actions` string arrays. Schema and invocation data accept arbitrary nested JSON +objects. + +```python +from frontal_sdk import ( + Frontal, + FunctionInvocationInput, + FunctionInvocationResult, + FunctionResource, +) + +with Frontal(api_key="frt_your_api_key") as client: + created = ( + client.functions.define("hello-world") + .runtime("nodejs22") + .entrypoint("index.handler") + .input_schema({"type": "object", "properties": {"name": {"type": "string"}}}) + .create() + ) + function = FunctionResource.model_validate(created) + raw_result = client.functions.executions.invoke( + FunctionInvocationInput( + function_id=function.id, + input={"name": "World", "metadata": {"requestTag": "sample"}}, + ) + ) + result = FunctionInvocationResult.model_validate(raw_result) + print(result.result) +``` + +The same resources work with `AsyncFrontal`; await calls such as +`client.functions.executions.invoke(...)`. `client.functions.versions` provides +`list`, `get`, and `publish`; `client.functions.deployments` provides `deploy` +and `status`; `client.functions.executions` provides `invoke`, `invoke_async`, +`get`, `get_result`, `list`, and `cancel`. + +List methods accept the API's cursor names through `query=`, for example: +`client.functions.list(query={"cursor": "next", "limit": 25})`. Execution +filters use `functionId` and `status` in the query mapping. + +## Errors + +Functions requests use the shared typed SDK errors. HTTP 400 and 422 responses +raise `ValidationError`; 401 and 403 raise `AuthenticationError`; 404 raises +`NotFoundError`; 409 raises `ConflictError`; 429 raises `RateLimitError`; and +5xx responses raise `ServerError`. Connection and timeout failures raise +`NetworkError` and `TimeoutError`. Errors expose `status_code`, `code`, +`request_id`, `details`, `transient`, and `safe_to_retry`; rate limit errors +also expose `retry_after`. The SDK does not automatically retry function write +requests when replay could repeat an accepted operation. + +The Functions routes are recorded in `contracts/sdk-endpoints.json` from the +TypeScript Functions client and its package tests. The currently checked-in +OpenAPI snapshots do not contain these routes. diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index a78ed56..0d60671 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -49,9 +49,9 @@ client to tune transport behavior. See [architecture](ARCHITECTURE.md) and Each client exposes the same service attributes: -`ai`, `agents`, `workflows`, `audit`, `auth`, `billing`, `blob`, `connectors`, -`data`, `governance`, `lineage`, `observability`, `ontology`, `pipelines`, -`schedules`, and `webhooks`. +`ai`, `agents`, `functions`, `workflows`, `audit`, `auth`, `billing`, `blob`, +`connectors`, `data`, `governance`, `lineage`, `observability`, `ontology`, +`pipelines`, `schedules`, and `webhooks`. The AI service adds normalized helpers such as `generate_text`, `stream_text`, `embed`, `generate_object`, `generate_speech`, `generate_image`, @@ -63,8 +63,8 @@ endpoint methods from the SDK inventory. ## Types and API boundaries Public models are exported from `frontal_sdk` and `frontal_sdk.models`. The -client validates typed AI, agent, workflow, pagination, and error boundaries -with Pydantic. Many raw API operations have no operation-specific schema in the -OpenAPI snapshots, so their request and response values use the recursive -`JSONValue` type rather than guessed models. See [API resources](RESOURCES.md) +client validates typed AI, agent, Functions, workflow, pagination, and error +boundaries with Pydantic. Many raw API operations have no operation-specific +schema in the OpenAPI snapshots, so their request and response values use the +recursive `JSONValue` type rather than guessed models. See [API resources](RESOURCES.md) for route naming and payload conventions. diff --git a/docs/README.md b/docs/README.md index 7c4fe4a..8e92103 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,7 @@ - [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. +- [Functions API](FUNCTIONS.md) — function definitions, invocations, and executions. - [Testing](TESTING.md) — offline RESPX coverage and runnable documentation. - [Developer guide](DEVELOPERS.md) and [onboarding](ONBOARDING.md) — repository workflow. - [Security](SECURITY.md), [publishing](PUBLISHING.md), and [releasing](RELEASING.md). diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md index c6aceb3..0cea980 100644 --- a/docs/RESOURCES.md +++ b/docs/RESOURCES.md @@ -6,6 +6,7 @@ | --- | --- | | `ai` | AI generation and gateway operations | | `agents` | Agent definitions and runs | +| `functions` | Function definitions, versions, deployments, and executions | | `workflows` | Workflow definitions and executions | | `audit`, `auth`, `billing` | Audit events, authentication, and billing | | `blob`, `connectors`, `data` | Files, integrations, and data operations | @@ -59,6 +60,17 @@ an execution, list executions, and poll one to a terminal state. The same accessors work with `AsyncFrontal`; await their results and use `async for` for streams. +## Functions + +Use `client.functions.define(name)` to build and create a validated function, +or pass a `FunctionDefinition` to `client.functions.create()`. Invoke a +function with `client.functions.executions.invoke()` or start an asynchronous +execution with `invoke_async()`. Versions, deployment status, results, and +cancellation are grouped under `client.functions.versions`, +`client.functions.deployments`, and `client.functions.executions`. The sync and +async clients expose the same methods. See the [Functions API guide](FUNCTIONS.md) +for fields, configuration, errors, and an end-to-end example. + ## Endpoint methods Each service also exposes methods corresponding to the committed inventory. diff --git a/examples/README.md b/examples/README.md index aaa27eb..15e3ccf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,6 +9,7 @@ Each script closes its HTTPX client when finished. | [`quickstart.py`](./quickstart.py) | Synchronous text generation | | [`async_quickstart.py`](./async_quickstart.py) | Async text generation with `AsyncFrontal` | | [`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 | Run scripts from the repository root after installing the development extra: @@ -18,6 +19,7 @@ uv sync --extra dev uv run python examples/quickstart.py 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 ``` diff --git a/examples/create_function.py b/examples/create_function.py new file mode 100644 index 0000000..376f227 --- /dev/null +++ b/examples/create_function.py @@ -0,0 +1,35 @@ +"""Create a Frontal function and invoke it with JSON input.""" + +from frontal_sdk import ( + Frontal, + FunctionInvocationInput, + FunctionInvocationResult, + FunctionResource, +) + + +def main() -> None: + with Frontal() as client: + created = ( + client.functions.define("hello-world") + .runtime("nodejs22") + .entrypoint("index.handler") + .description("Return a greeting") + .input_schema( + {"type": "object", "properties": {"name": {"type": "string"}}} + ) + .create() + ) + function = FunctionResource.model_validate(created) + raw_result = client.functions.executions.invoke( + FunctionInvocationInput( + function_id=function.id, + input={"name": "World", "metadata": {"source": "example"}}, + ) + ) + result = FunctionInvocationResult.model_validate(raw_result) + print(result.result) + + +if __name__ == "__main__": + main() diff --git a/frontal_sdk/__init__.py b/frontal_sdk/__init__.py index 6f0a600..f8518e6 100644 --- a/frontal_sdk/__init__.py +++ b/frontal_sdk/__init__.py @@ -26,6 +26,21 @@ EmbedResult, ErrorField, ErrorResponse, + FunctionDefinition, + FunctionDeploymentStatus, + FunctionExecution, + FunctionExecutionListResponse, + FunctionInvocationAccepted, + FunctionInvocationInput, + FunctionInvocationResult, + FunctionListResponse, + FunctionPagination, + FunctionPermission, + FunctionResource, + FunctionRuntime, + FunctionStatus, + FunctionVersion, + FunctionVersionListResponse, GenerateImageOptions, GenerateImageResult, GenerateObjectResult, @@ -63,6 +78,8 @@ from frontal_sdk.resources import ( AgentAccessor, AgentBuilder, + FunctionAccessor, + FunctionBuilder, WorkflowAccessor, WorkflowBuilder, tool, @@ -90,6 +107,23 @@ "EmbedResult", "Frontal", "FrontalError", + "FunctionAccessor", + "FunctionBuilder", + "FunctionDefinition", + "FunctionDeploymentStatus", + "FunctionExecution", + "FunctionExecutionListResponse", + "FunctionInvocationAccepted", + "FunctionInvocationInput", + "FunctionInvocationResult", + "FunctionListResponse", + "FunctionPagination", + "FunctionPermission", + "FunctionResource", + "FunctionRuntime", + "FunctionStatus", + "FunctionVersion", + "FunctionVersionListResponse", "GenerateImageOptions", "GenerateImageResult", "GenerateObjectResult", diff --git a/frontal_sdk/client.py b/frontal_sdk/client.py index 07213ec..416a161 100644 --- a/frontal_sdk/client.py +++ b/frontal_sdk/client.py @@ -20,6 +20,7 @@ Blob, Connectors, Data, + Functions, Governance, Lineage, Observability, @@ -112,6 +113,9 @@ def __init__( Connectors(http) ) self.data: Data[JSONValue, bytes, Iterator[ServerEvent]] = Data(http) + self.functions: Functions[JSONValue, bytes, Iterator[ServerEvent]] = Functions( + http + ) self.governance: Governance[JSONValue, bytes, Iterator[ServerEvent]] = ( Governance(http) ) @@ -197,6 +201,9 @@ def __init__( self.data: Data[_AsyncJSONResult, _AsyncBytesResult, _AsyncStreamResult] = Data( http ) + self.functions: Functions[ + _AsyncJSONResult, _AsyncBytesResult, _AsyncStreamResult + ] = Functions(http) self.governance: Governance[ _AsyncJSONResult, _AsyncBytesResult, _AsyncStreamResult ] = Governance(http) diff --git a/frontal_sdk/models/__init__.py b/frontal_sdk/models/__init__.py index c1ee5a4..45ed7ce 100644 --- a/frontal_sdk/models/__init__.py +++ b/frontal_sdk/models/__init__.py @@ -58,6 +58,23 @@ TranscriptionResult, VariableDefinition, ) +from frontal_sdk.models.functions import ( + FunctionDefinition, + FunctionDeploymentStatus, + FunctionExecution, + FunctionExecutionListResponse, + FunctionInvocationAccepted, + FunctionInvocationInput, + FunctionInvocationResult, + FunctionListResponse, + FunctionPagination, + FunctionPermission, + FunctionResource, + FunctionRuntime, + FunctionStatus, + FunctionVersion, + FunctionVersionListResponse, +) from frontal_sdk.models.http import MultipartPart, ServerEvent from frontal_sdk.models.pagination import PageResult, PaginationMeta from frontal_sdk.models.requests import ( @@ -150,6 +167,21 @@ "EventTrigger", "ExecutionStatus", "FinishPart", + "FunctionDefinition", + "FunctionDeploymentStatus", + "FunctionExecution", + "FunctionExecutionListResponse", + "FunctionInvocationAccepted", + "FunctionInvocationInput", + "FunctionInvocationResult", + "FunctionListResponse", + "FunctionPagination", + "FunctionPermission", + "FunctionResource", + "FunctionRuntime", + "FunctionStatus", + "FunctionVersion", + "FunctionVersionListResponse", "JSONDocument", "JSONPrimitive", "JSONObject", diff --git a/frontal_sdk/models/functions.py b/frontal_sdk/models/functions.py new file mode 100644 index 0000000..8e5a6a4 --- /dev/null +++ b/frontal_sdk/models/functions.py @@ -0,0 +1,167 @@ +"""Pydantic models for the Frontal Functions service.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import ConfigDict, Field + +from frontal_sdk.models.types import APIModel, JSONObject, JSONValue + +FunctionRuntime = Literal["nodejs20", "nodejs22", "python311"] +FunctionStatus = Literal["draft", "active", "deprecated", "failed"] + + +class FunctionPermission(APIModel): + """Ontology and action permissions assigned to a function.""" + + ontology: list[str] | None = None + actions: list[str] | None = None + + +class FunctionDefinition(APIModel): + """Validated payload used to create or update a function.""" + + name: str = Field(min_length=1) + runtime: FunctionRuntime + entrypoint: str + description: str | None = None + source: str | None = None + input_schema: JSONObject | None = Field(default=None, alias="inputSchema") + output_schema: JSONObject | None = Field(default=None, alias="outputSchema") + dependencies: list[str] | None = None + env_vars: dict[str, str] | None = Field(default=None, alias="envVars") + secrets: list[str] | None = None + memory: int | None = Field(default=None, gt=0) + timeout: int | None = Field(default=None, gt=0) + permissions: FunctionPermission | None = None + + +class FunctionResource(APIModel): + """Function resource and its current lifecycle state.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + name: str + runtime: FunctionRuntime + entrypoint: str + status: FunctionStatus + version: int = Field(gt=0) + description: str | None = None + latest_version: int | None = Field(default=None, alias="latestVersion", gt=0) + memory: int | None = Field(default=None, gt=0) + timeout: int | None = Field(default=None, gt=0) + permissions: FunctionPermission | None = None + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + + +class FunctionVersion(APIModel): + """Published or draft source version of a function.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + version: int = Field(gt=0) + source: str + created_at: datetime = Field(alias="createdAt") + published_by: str | None = Field(default=None, alias="publishedBy") + + +class FunctionExecution(APIModel): + """Status and JSON input/output for one function execution.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + function_id: str = Field(alias="functionId") + version: int = Field(gt=0) + status: FunctionStatus + input: JSONObject | None = None + output: JSONObject | None = None + error: str | None = None + started_at: datetime | None = Field(default=None, alias="startedAt") + completed_at: datetime | None = Field(default=None, alias="completedAt") + duration_ms: int | None = Field(default=None, alias="durationMs", ge=0) + + +class FunctionInvocationInput(APIModel): + """Request to invoke a function with an arbitrary JSON object.""" + + function_id: str = Field(alias="functionId") + version: int | None = Field(default=None, gt=0) + input: JSONObject | None = None + + +class FunctionInvocationResult(APIModel): + """Result of a synchronous or completed asynchronous invocation.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + execution_id: str = Field(alias="executionId") + result: JSONObject | None = None + error: str | None = None + status: FunctionStatus | None = None + + +class FunctionInvocationAccepted(APIModel): + """Execution identifier returned after an asynchronous invocation.""" + + execution_id: str = Field(alias="executionId") + + +class FunctionDeploymentStatus(APIModel): + """Deployment state and optional JSON details for a function version.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: str + details: JSONValue = None + + +class FunctionPagination(APIModel): + """Cursor metadata returned with function service list responses.""" + + cursor: str | None = None + has_more: bool = Field(alias="hasMore") + + +class FunctionListResponse(APIModel): + """A page of functions.""" + + functions: list[FunctionResource] + pagination: FunctionPagination + + +class FunctionVersionListResponse(APIModel): + """A page of function versions.""" + + versions: list[FunctionVersion] + pagination: FunctionPagination + + +class FunctionExecutionListResponse(APIModel): + """A page of function executions.""" + + executions: list[FunctionExecution] + pagination: FunctionPagination + + +__all__ = [ + "FunctionDefinition", + "FunctionDeploymentStatus", + "FunctionExecution", + "FunctionExecutionListResponse", + "FunctionInvocationAccepted", + "FunctionInvocationInput", + "FunctionInvocationResult", + "FunctionListResponse", + "FunctionPagination", + "FunctionPermission", + "FunctionResource", + "FunctionRuntime", + "FunctionStatus", + "FunctionVersion", + "FunctionVersionListResponse", +] diff --git a/frontal_sdk/resources/__init__.py b/frontal_sdk/resources/__init__.py index c32445f..19a7fa6 100644 --- a/frontal_sdk/resources/__init__.py +++ b/frontal_sdk/resources/__init__.py @@ -8,6 +8,7 @@ from frontal_sdk.resources.blob import Blob from frontal_sdk.resources.connectors import Connectors from frontal_sdk.resources.data import Data +from frontal_sdk.resources.functions import FunctionAccessor, FunctionBuilder, Functions from frontal_sdk.resources.governance import Governance from frontal_sdk.resources.lineage import Lineage from frontal_sdk.resources.observability import Observability @@ -36,6 +37,9 @@ "Blob", "Connectors", "Data", + "FunctionAccessor", + "FunctionBuilder", + "Functions", "Governance", "Lineage", "Observability", diff --git a/frontal_sdk/resources/functions.py b/frontal_sdk/resources/functions.py new file mode 100644 index 0000000..f884fbf --- /dev/null +++ b/frontal_sdk/resources/functions.py @@ -0,0 +1,542 @@ +"""Typed API resources for the Frontal Functions service.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Generic, cast + +from frontal_sdk.core.operation import Operation +from frontal_sdk.models import JSONValue, QueryParams +from frontal_sdk.models.functions import ( + FunctionDefinition, + FunctionInvocationInput, + FunctionPermission, + FunctionRuntime, +) +from frontal_sdk.models.requests import RequestBodyInput +from frontal_sdk.resources._base import ( + APIResource, + BytesResultT, + HTTPTransport, + JSONResultT, + StreamResultT, +) + + +def _definition_model( + definition: FunctionDefinition | Mapping[str, JSONValue], +) -> FunctionDefinition: + if isinstance(definition, FunctionDefinition): + return definition + return FunctionDefinition.model_validate(definition) + + +def _invocation_model( + value: FunctionInvocationInput | Mapping[str, JSONValue], +) -> FunctionInvocationInput: + if isinstance(value, FunctionInvocationInput): + return value + return FunctionInvocationInput.model_validate(value) + + +def _model_payload(model: FunctionDefinition | FunctionInvocationInput) -> JSONValue: + return cast( + JSONValue, + model.model_dump(mode="json", by_alias=True, exclude_none=True), + ) + + +class Functions( + APIResource[JSONResultT, BytesResultT, StreamResultT], + Generic[JSONResultT, BytesResultT, StreamResultT], +): + """Function definitions, versions, deployments, and executions.""" + + def __init__( + self, http: HTTPTransport[JSONResultT, BytesResultT, StreamResultT] + ) -> None: + super().__init__(http) + self.versions = FunctionVersions(self) + self.deployments = FunctionDeployments(self) + self.executions = FunctionExecutions(self) + + def _create_function( + self, *, query: QueryParams | None = None, body: RequestBodyInput + ) -> JSONResultT: + """Call POST /functions.""" + return self._request( + Operation("POST", "/functions"), path_params=(), query=query, body=body + ) + + def _list_functions(self, *, query: QueryParams | None = None) -> JSONResultT: + """Call GET /functions.""" + return self._request(Operation("GET", "/functions"), query=query) + + def _get_function( + self, function_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Call GET /functions/{param}.""" + return self._request( + Operation("GET", "/functions/{param}"), + path_params=(function_id,), + query=query, + ) + + def _update_function( + self, + function_id: str, + *, + query: QueryParams | None = None, + body: RequestBodyInput, + ) -> JSONResultT: + """Call PATCH /functions/{param}.""" + return self._request( + Operation("PATCH", "/functions/{param}"), + path_params=(function_id,), + query=query, + body=body, + ) + + def _delete_function( + self, function_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Call DELETE /functions/{param}.""" + return self._request( + Operation("DELETE", "/functions/{param}"), + path_params=(function_id,), + query=query, + ) + + def _list_function_versions( + self, function_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Call GET /functions/{param}/versions.""" + return self._request( + Operation("GET", "/functions/{param}/versions"), + path_params=(function_id,), + query=query, + ) + + def _get_function_version( + self, + function_id: str, + version: int, + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Call GET /functions/{param}/versions/{param}.""" + return self._request( + Operation("GET", "/functions/{param}/versions/{param}"), + path_params=(function_id, str(version)), + query=query, + ) + + def _publish_function_version( + self, + function_id: str, + version: int, + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Call POST /functions/{param}/versions/{param}/publish.""" + return self._request( + Operation("POST", "/functions/{param}/versions/{param}/publish"), + path_params=(function_id, str(version)), + query=query, + body={}, + ) + + def _deploy_function_version( + self, + function_id: str, + version: int, + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Call POST /functions/{param}/versions/{param}/deploy.""" + return self._request( + Operation("POST", "/functions/{param}/versions/{param}/deploy"), + path_params=(function_id, str(version)), + query=query, + body={}, + ) + + def _get_function_deployment_status( + self, + function_id: str, + version: int, + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Call GET /functions/{param}/versions/{param}/deployment/status.""" + return self._request( + Operation("GET", "/functions/{param}/versions/{param}/deployment/status"), + path_params=(function_id, str(version)), + query=query, + ) + + def _invoke_function( + self, *, query: QueryParams | None = None, body: RequestBodyInput + ) -> JSONResultT: + """Call POST /functions/invoke.""" + return self._request( + Operation("POST", "/functions/invoke"), query=query, body=body + ) + + def _invoke_function_async( + self, *, query: QueryParams | None = None, body: RequestBodyInput + ) -> JSONResultT: + """Call POST /functions/invoke-async.""" + return self._request( + Operation("POST", "/functions/invoke-async"), query=query, body=body + ) + + def _get_function_execution( + self, execution_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Call GET /functions/executions/{param}.""" + return self._request( + Operation("GET", "/functions/executions/{param}"), + path_params=(execution_id,), + query=query, + ) + + def _get_function_execution_result( + self, execution_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Call GET /functions/executions/{param}/result.""" + return self._request( + Operation("GET", "/functions/executions/{param}/result"), + path_params=(execution_id,), + query=query, + ) + + def _list_function_executions( + self, *, query: QueryParams | None = None + ) -> JSONResultT: + """Call GET /functions/executions.""" + return self._request(Operation("GET", "/functions/executions"), query=query) + + def _cancel_function_execution( + self, execution_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Call POST /functions/executions/{param}/cancel.""" + return self._request( + Operation("POST", "/functions/executions/{param}/cancel"), + path_params=(execution_id,), + query=query, + body={}, + ) + + def create( + self, + definition: FunctionDefinition | Mapping[str, JSONValue], + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Validate and create a function definition.""" + return self._create_function( + body=_model_payload(_definition_model(definition)), query=query + ) + + def list(self, *, query: QueryParams | None = None) -> JSONResultT: + """List functions with optional ``cursor`` and ``limit`` query values.""" + return self._list_functions(query=query) + + def get(self, id: str, *, query: QueryParams | None = None) -> JSONResultT: + """Retrieve one function by ID.""" + return self._get_function(id, query=query) + + def update( + self, + id: str, + definition: FunctionDefinition | Mapping[str, JSONValue], + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Validate and patch a function definition, creating a version.""" + return self._update_function( + id, body=_model_payload(_definition_model(definition)), query=query + ) + + def delete(self, id: str, *, query: QueryParams | None = None) -> JSONResultT: + """Delete a function.""" + return self._delete_function(id, query=query) + + def define( + self, + name: str, + options: FunctionDefinition | Mapping[str, JSONValue] | None = None, + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Start a fluent definition; call ``create()`` to send it.""" + builder = FunctionBuilder(self, name) + if options is not None: + builder.configure(options) + return builder + + def use( + self, function_id: str + ) -> FunctionAccessor[JSONResultT, BytesResultT, StreamResultT]: + """Create an accessor for one function.""" + return FunctionAccessor(self, function_id) + + +class FunctionVersions(Generic[JSONResultT, BytesResultT, StreamResultT]): + """Version operations available under ``client.functions.versions``.""" + + def __init__(self, functions: Functions[JSONResultT, BytesResultT, StreamResultT]): + self._functions = functions + + def list( + self, *, function_id: str, query: QueryParams | None = None + ) -> JSONResultT: + """List versions for one function.""" + return self._functions._list_function_versions(function_id, query=query) + + def get( + self, + function_id: str, + version: int, + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Retrieve one version of a function.""" + return self._functions._get_function_version(function_id, version, query=query) + + def publish( + self, + function_id: str, + version: int, + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Publish one version of a function.""" + return self._functions._publish_function_version( + function_id, version, query=query + ) + + +class FunctionDeployments(Generic[JSONResultT, BytesResultT, StreamResultT]): + """Deployment operations available under ``client.functions.deployments``.""" + + def __init__(self, functions: Functions[JSONResultT, BytesResultT, StreamResultT]): + self._functions = functions + + def deploy( + self, + function_id: str, + version: int, + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Deploy one version of a function.""" + return self._functions._deploy_function_version( + function_id, version, query=query + ) + + def status( + self, + function_id: str, + version: int, + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Retrieve the deployment status of a function version.""" + return self._functions._get_function_deployment_status( + function_id, version, query=query + ) + + +class FunctionExecutions(Generic[JSONResultT, BytesResultT, StreamResultT]): + """Invocation and execution operations under ``client.functions.executions``.""" + + def __init__(self, functions: Functions[JSONResultT, BytesResultT, StreamResultT]): + self._functions = functions + + def invoke( + self, + input: FunctionInvocationInput | Mapping[str, JSONValue], + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Invoke a function and wait for its result.""" + payload = _model_payload(_invocation_model(input)) + return self._functions._invoke_function(body=payload, query=query) + + def invoke_async( + self, + input: FunctionInvocationInput | Mapping[str, JSONValue], + *, + query: QueryParams | None = None, + ) -> JSONResultT: + """Start an asynchronous function invocation.""" + payload = _model_payload(_invocation_model(input)) + return self._functions._invoke_function_async(body=payload, query=query) + + def get( + self, execution_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Retrieve one function execution.""" + return self._functions._get_function_execution(execution_id, query=query) + + def get_result( + self, execution_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Retrieve a function execution result.""" + return self._functions._get_function_execution_result(execution_id, query=query) + + def list(self, *, query: QueryParams | None = None) -> JSONResultT: + """List executions, optionally filtering by cursor, function ID, or status.""" + return self._functions._list_function_executions(query=query) + + def cancel( + self, execution_id: str, *, query: QueryParams | None = None + ) -> JSONResultT: + """Cancel one function execution.""" + return self._functions._cancel_function_execution(execution_id, query=query) + + +class FunctionBuilder(Generic[JSONResultT, BytesResultT, StreamResultT]): + """Fluent, Pydantic-validated builder for a function definition.""" + + def __init__( + self, + functions: Functions[JSONResultT, BytesResultT, StreamResultT], + name: str, + ) -> None: + self._functions = functions + self._values: dict[str, JSONValue] = {"name": name} + + def configure( + self, definition: FunctionDefinition | Mapping[str, JSONValue] + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + """Apply fields from a model or a mapping to this builder.""" + if isinstance(definition, FunctionDefinition): + values = cast( + dict[str, JSONValue], + definition.model_dump(mode="json", by_alias=True, exclude_none=True), + ) + else: + values = dict(definition) + self._values.update(values) + return self + + def description( + self, text: str + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["description"] = text + return self + + def runtime( + self, runtime: FunctionRuntime + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["runtime"] = runtime + return self + + def entrypoint( + self, entrypoint: str + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["entrypoint"] = entrypoint + return self + + def source( + self, source: str + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["source"] = source + return self + + def input_schema( + self, schema: Mapping[str, JSONValue] + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["inputSchema"] = dict(schema) + return self + + def output_schema( + self, schema: Mapping[str, JSONValue] + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["outputSchema"] = dict(schema) + return self + + def dependencies( + self, dependencies: list[str] + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["dependencies"] = cast(JSONValue, dependencies) + return self + + def env_vars( + self, env_vars: Mapping[str, str] + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["envVars"] = dict(env_vars) + return self + + def secrets( + self, secrets: list[str] + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["secrets"] = cast(JSONValue, secrets) + return self + + def memory( + self, memory: int + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["memory"] = memory + return self + + def timeout( + self, timeout: int + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["timeout"] = timeout + return self + + def permissions( + self, permissions: FunctionPermission | Mapping[str, JSONValue] + ) -> FunctionBuilder[JSONResultT, BytesResultT, StreamResultT]: + self._values["permissions"] = ( + cast( + JSONValue, + permissions.model_dump(mode="json", by_alias=True, exclude_none=True), + ) + if isinstance(permissions, FunctionPermission) + else dict(permissions) + ) + return self + + def to_definition(self) -> FunctionDefinition: + """Validate and return the function definition.""" + return FunctionDefinition.model_validate(self._values) + + def create(self) -> JSONResultT: + """Validate and create the function on the configured client.""" + return self._functions.create(self.to_definition()) + + +class FunctionAccessor(Generic[JSONResultT, BytesResultT, StreamResultT]): + """Convenience methods for a function selected by ID.""" + + def __init__( + self, + functions: Functions[JSONResultT, BytesResultT, StreamResultT], + function_id: str, + ) -> None: + self._functions = functions + self.id = function_id + + def get(self) -> JSONResultT: + return self._functions.get(self.id) + + def update( + self, definition: FunctionDefinition | Mapping[str, JSONValue] + ) -> JSONResultT: + return self._functions.update(self.id, definition) + + def delete(self) -> JSONResultT: + return self._functions.delete(self.id) + + +__all__ = [ + "FunctionAccessor", + "FunctionBuilder", + "FunctionDeployments", + "FunctionExecutions", + "FunctionVersions", + "Functions", +] diff --git a/tests/test_examples.py b/tests/test_examples.py index bb78d5b..8a39fde 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -75,6 +75,41 @@ def test_agent_definition_example_with_mocked_api( assert route.called +def test_function_example_with_mocked_api( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_api_key(monkeypatch) + with respx.mock(assert_all_called=False) as router: + create_route = router.post(f"{API_URL}/functions").mock( + return_value=httpx.Response( + 201, + json={ + "id": "fn_1", + "name": "hello-world", + "runtime": "nodejs22", + "entrypoint": "index.handler", + "status": "draft", + "version": 1, + "createdAt": "2026-10-09T12:00:00Z", + "updatedAt": "2026-10-09T12:00:00Z", + }, + ) + ) + invoke_route = router.post(f"{API_URL}/functions/invoke").mock( + return_value=httpx.Response( + 200, + json={ + "executionId": "exec_1", + "result": {"message": "Hello, World!"}, + "status": "active", + }, + ) + ) + runpy.run_path("examples/create_function.py", run_name="__main__") + assert create_route.called + assert invoke_route.called + + def test_workflow_example_with_mocked_api( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 0000000..97b2924 --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,291 @@ +"""Offline tests for the Frontal Functions resources and models.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator + +import httpx +import pytest +import respx +from frontal_sdk import ( + AsyncFrontal, + Frontal, + FunctionDefinition, + FunctionExecutionListResponse, + FunctionInvocationInput, + FunctionInvocationResult, + FunctionListResponse, + NotFoundError, + ValidationError, +) + +API_URL = "https://api.frontal.dev/v1" + + +@pytest.fixture +def respx_mock() -> Iterator[respx.Router]: + with respx.mock(assert_all_called=False) as router: + yield router + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def _mock_operation( + router: respx.Router, method: str, path: str, *, status_code: int = 200 +) -> respx.Route: + return getattr(router, method.lower())(f"{API_URL}{path}").mock( + return_value=( + httpx.Response(status_code) + if status_code == 204 + else httpx.Response(status_code, json={"ok": True}) + ) + ) + + +def _definition() -> FunctionDefinition: + return FunctionDefinition( + name="nested-json", + runtime="python311", + entrypoint="handler.main", + input_schema={ + "type": "object", + "properties": { + "payload": {"type": "object", "x-schema-meta": {"custom": True}} + }, + "x-vendor-field": {"keep": [1, {"mixedCase": "preserved"}]}, + }, + ) + + +def test_functions_operations_paths_queries_and_bodies( + respx_mock: respx.Router, +) -> None: + expected = [ + ("POST", "/functions"), + ("GET", "/functions"), + ("GET", "/functions/fn%2Fone"), + ("PATCH", "/functions/fn%2Fone"), + ("DELETE", "/functions/fn%2Fone"), + ("GET", "/functions/fn%2Fone/versions"), + ("GET", "/functions/fn%2Fone/versions/3"), + ("POST", "/functions/fn%2Fone/versions/3/publish"), + ("POST", "/functions/fn%2Fone/versions/3/deploy"), + ("GET", "/functions/fn%2Fone/versions/3/deployment/status"), + ("POST", "/functions/invoke"), + ("POST", "/functions/invoke-async"), + ("GET", "/functions/executions/exec%2Fone"), + ("GET", "/functions/executions/exec%2Fone/result"), + ("GET", "/functions/executions"), + ("POST", "/functions/executions/exec%2Fone/cancel"), + ] + routes = { + (method, path): _mock_operation( + respx_mock, + method, + path, + status_code=204 if method == "DELETE" else 200, + ) + for method, path in expected + } + client = Frontal("frt_local_key", max_retries=0) + definition = _definition() + invocation = FunctionInvocationInput( + function_id="fn/one", + version=3, + input={"query": {"deepKey": ["value", {"custom_key": 17}]}}, + ) + + client.functions.create(definition) + client.functions.list(query={"cursor": "page-2", "limit": 12}) + client.functions.get(id="fn/one") + client.functions.update(id="fn/one", definition=definition) + client.functions.delete(id="fn/one") + client.functions.versions.list( + function_id="fn/one", query={"cursor": "versions-2", "limit": 8} + ) + client.functions.versions.get("fn/one", 3) + client.functions.versions.publish("fn/one", 3) + client.functions.deployments.deploy("fn/one", 3) + client.functions.deployments.status("fn/one", 3) + client.functions.executions.invoke(invocation) + client.functions.executions.invoke_async(invocation) + client.functions.executions.get("exec/one") + client.functions.executions.get_result("exec/one") + client.functions.executions.list( + query={ + "cursor": "exec-2", + "limit": 5, + "functionId": "fn/one", + "status": "failed", + } + ) + client.functions.executions.cancel("exec/one") + + for (method, path), route in routes.items(): + assert route.called, (method, path) + request = route.calls.last.request + assert request.method == method + assert request.url.raw_path.split(b"?", 1)[0] == ("/v1" + path).encode() + assert request.headers["authorization"] == "Bearer frt_local_key" + + assert dict(routes[("GET", "/functions")].calls.last.request.url.params) == { + "cursor": "page-2", + "limit": "12", + } + assert dict( + routes[("GET", "/functions/fn%2Fone/versions")].calls.last.request.url.params + ) == { + "cursor": "versions-2", + "limit": "8", + } + assert dict( + routes[("GET", "/functions/executions")].calls.last.request.url.params + ) == { + "cursor": "exec-2", + "limit": "5", + "functionId": "fn/one", + "status": "failed", + } + + definition_body = json.loads( + routes[("POST", "/functions")].calls.last.request.content + ) + assert definition_body["inputSchema"] == definition.input_schema + assert "input_schema" not in definition_body + assert "description" not in definition_body + assert definition_body["inputSchema"]["x-vendor-field"]["keep"][1] == { + "mixedCase": "preserved" + } + assert json.loads( + routes[("POST", "/functions/invoke")].calls.last.request.content + ) == { + "functionId": "fn/one", + "version": 3, + "input": {"query": {"deepKey": ["value", {"custom_key": 17}]}}, + } + assert json.loads( + routes[("POST", "/functions/invoke-async")].calls.last.request.content + ) == json.loads(routes[("POST", "/functions/invoke")].calls.last.request.content) + for method_path in ( + ("POST", "/functions/fn%2Fone/versions/3/publish"), + ("POST", "/functions/fn%2Fone/versions/3/deploy"), + ("POST", "/functions/executions/exec%2Fone/cancel"), + ): + assert json.loads(routes[method_path].calls.last.request.content) == {} + client.close() + + +def test_functions_models_validate_pages_and_keep_nested_json() -> None: + function_page = FunctionListResponse.model_validate( + { + "functions": [ + { + "id": "fn_1", + "name": "echo", + "runtime": "nodejs22", + "entrypoint": "index.handler", + "status": "active", + "version": 2, + "latestVersion": 2, + "createdAt": "2026-10-09T12:00:00Z", + "updatedAt": "2026-10-09T12:01:00Z", + } + ], + "pagination": {"cursor": "next", "hasMore": True}, + } + ) + execution_page = FunctionExecutionListResponse.model_validate( + { + "executions": [ + { + "id": "exec_1", + "functionId": "fn_1", + "version": 2, + "status": "active", + "input": {"arbitrary": {"deep": [1, {"CamelKey": True}]}}, + } + ], + "pagination": {"hasMore": False}, + } + ) + result = FunctionInvocationResult.model_validate( + { + "executionId": "exec_1", + "result": {"output": {"strange_JSON_key": {"values": [1, 2]}}}, + "status": "active", + } + ) + + assert function_page.functions[0].latest_version == 2 + assert function_page.pagination.has_more is True + assert execution_page.executions[0].input == { + "arbitrary": {"deep": [1, {"CamelKey": True}]} + } + assert result.model_dump(mode="json", by_alias=True)["result"] == { + "output": {"strange_JSON_key": {"values": [1, 2]}} + } + + +def test_function_definition_requires_runtime_and_entrypoint() -> None: + with pytest.raises(ValueError): + FunctionDefinition.model_validate({"name": "incomplete"}) + + +def test_functions_use_shared_base_url_configuration(respx_mock: respx.Router) -> None: + route = respx_mock.post("https://functions.example.test/api/functions/invoke").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + client = Frontal( + "frt_local_key", + base_url="https://functions.example.test/api", + timeout=12.0, + max_retries=0, + ) + client.functions.executions.invoke({"functionId": "fn_1", "input": {}}) + assert route.called + assert route.calls.last.request.url.host == "functions.example.test" + assert route.calls.last.request.url.path == "/api/functions/invoke" + client.close() + + +def test_functions_api_errors_use_shared_error_mapping( + respx_mock: respx.Router, +) -> None: + respx_mock.get(f"{API_URL}/functions/missing").mock( + return_value=httpx.Response(404, json={"message": "function not found"}) + ) + respx_mock.post(f"{API_URL}/functions").mock( + return_value=httpx.Response(422, json={"message": "invalid function"}) + ) + client = Frontal("frt_local_key", max_retries=0) + + with pytest.raises(NotFoundError, match="function not found"): + client.functions.get("missing") + with pytest.raises(ValidationError, match="invalid function"): + client.functions.create(_definition()) + client.close() + + +@pytest.mark.anyio +async def test_async_functions_share_routes_and_json_serialization( + respx_mock: respx.Router, +) -> None: + route = respx_mock.post(f"{API_URL}/functions/invoke-async").mock( + return_value=httpx.Response(202, json={"executionId": "exec_1"}) + ) + async with AsyncFrontal("frt_local_key", max_retries=0) as client: + response = await client.functions.executions.invoke_async( + FunctionInvocationInput( + function_id="fn_1", input={"nested": {"PascalKey": [1, 2]}} + ) + ) + + assert response == {"executionId": "exec_1"} + assert json.loads(route.calls.last.request.content) == { + "functionId": "fn_1", + "input": {"nested": {"PascalKey": [1, 2]}}, + }