Skip to content

Latest commit

 

History

History
204 lines (165 loc) · 9.81 KB

AGENTS.md

File metadata and controls

204 lines (165 loc) · 9.81 KB

Python SDK

Async Python client for the Styrmin GraphQL API. The SDK exposes one resource per backend area (auth, clusters, environments, deployments, drivers, tasks, backups, environment-backups, environment-clones, schedules, backup-storage-locations, users, groups) on a single StyrminClient. The users and groups namespaces are admin-only — non-admin sessions receive FORBIDDEN.

Tech Stack

  • Transport: httpx (AsyncClient)
  • Models: Pydantic v2 (regenerated from the GraphQL schema)
  • GraphQL operations: static .graphql files, loaded by name via load_query()
  • Tooling: uv, ruff, ty, pytest

File Structure

Path Role
src/styrmin_sdk/client.py StyrminClient — public entry point. Constructs the transport and instantiates one resource per area, exposed as attributes (client.clusters, client.deployments, …).
src/styrmin_sdk/_transport.py Internal HTTP + GraphQL transport over httpx.AsyncClient. Owns the connection lifetime; wraps HTTP errors into StyrminTransportError. The client timeout defaults to 30 seconds; execute(..., request_timeout=) overrides it for one call.
src/styrmin_sdk/_queries.py load_query(name) helper — reads graphql/<name>.graphql from disk and returns the query string.
src/styrmin_sdk/_input_checks.py Checks for config dicts sent through a generated input model: validate_input_strict and to_graphql_enum_names. See Updating a cluster config.
src/styrmin_sdk/exceptions.py StyrminError hierarchy: StyrminTransportError (HTTP failures), StyrminGraphQLError (raw GraphQL errors), and StyrminAPIError with the subclasses (ResourceNotFoundError, ResourceAlreadyExistsError, OperationFailedError, DeploymentRestoringError, StyrminAuthenticationError, StyrminAuthorizationError) that _check_graphql_errors raises based on extensions.code. Each exception gives the code as typename and all the extensions of the first error as extensions.
src/styrmin_sdk/resources/ One *Resource class per area; each method maps to one GraphQL operation.
src/styrmin_sdk/resources/_base.py BaseResource — holds the transport reference and provides _check_graphql_errors() and _parse() helpers.
src/styrmin_sdk/graphql/*.graphql One operation per file; file name is snake_case of the GraphQL operation name.
src/styrmin_sdk/graphql/_generated/ Pydantic models regenerated by uv run invoke generate. Never hand-edit.
tests/ Unit and contract tests for the SDK.

The repo-root AGENTS.md documents repo-wide GraphQL conventions (one operation per file, snake_case naming, named operations only). Those rules apply here verbatim and are not restated.

Commands

Run from the repository root unless noted otherwise.

uv sync                                      # Install dependencies
uv run pytest python_sdk/tests               # SDK tests
uv run invoke validate-graphql               # Validate every .graphql file
uv run styrmin export graphql-schema schema.graphql  # Re-export the schema
uv run invoke generate                       # Regenerate Pydantic types
uv run invoke format lint                    # Required before commit

Adding or changing a GraphQL operation

⚠️ Regenerating types is not enough. Adding a .graphql file and running uv run invoke generate updates the Pydantic models under _generated/, but the SDK's Python surface (client.py, resources/<area>.py) does not auto-wire new operations. You must update the Python code by hand, or the operation will not be reachable through the SDK. This is the single most common SDK mistake.

Step by step:

  1. Author the .graphql file. Place it under src/styrmin_sdk/graphql/, one operation per file, with a named operation (e.g., query GetCluster { … }). The file name is the snake_case of the operation name (GetCluster → get_cluster.graphql). See the repo-root AGENTS.md "GraphQL Queries" section for the full repo-wide rules.

  2. Regenerate the schema and types from the repo root:

    uv run styrmin export graphql-schema schema.graphql
    uv run invoke generate
  3. Validate the operation:

    uv run invoke validate-graphql
  4. Wire the Python surface.

    • Existing area (clusters, deployments, environments, …): add an async def <method>(...) to the matching *Resource class in src/styrmin_sdk/resources/<area>.py. Inside the method:
      • Build the variables dict.
      • result = await self._transport.execute(load_query("<file_name>"), variables)
      • self._check_graphql_errors(result)
      • Return either self._parse(Outer, result["data"], "<field>", Inner) for single-object responses or Outer.model_validate(result["data"]).<field> or [] for list-shaped responses (see existing methods for both shapes).
    • New area (no existing resource module): create a new src/styrmin_sdk/resources/<area>.py with a *Resource(BaseResource) subclass, then instantiate it inside StyrminClient.__init__ so it becomes reachable as client.<area>.
  5. Add tests under python_sdk/tests/. Cover the success path and at least one error code mapping (e.g., NOT_FOUND → ResourceNotFoundError).

  6. Format and lint before commit:

    uv run invoke format lint

Worked example

From src/styrmin_sdk/resources/clusters.py:

async def get(
    self,
    cluster_id: str | UUID | None = None,
    cluster_name: str | None = None,
) -> GetClusterCluster:
    variables: dict[str, Any] = {}
    if cluster_id is not None:
        variables["clusterId"] = str(cluster_id)
    if cluster_name is not None:
        variables["clusterName"] = cluster_name
    result = await self._transport.execute(load_query("get_cluster"), variables)
    self._check_graphql_errors(result)
    return self._parse(GetCluster, result["data"], "cluster", GetClusterCluster)

load_query("get_cluster") reads graphql/get_cluster.graphql. The response is sent through _transport.execute, structured GraphQL errors are mapped to typed exceptions by _check_graphql_errors, and the generated GetCluster outer model is validated and the inner cluster field is returned as the typed GetClusterCluster.

Looking up a deployment by name

DeploymentResource.get_by_name(name, *, environment_id) resolves a deployment by its natural identity (name, environment). Internal and soft-deleted rows are filtered out; a miss returns None. The return type, GetDeploymentByNameDeployment, is re-exported from the package root so callers can import it without reaching into _generated/.

from styrmin_sdk import GetDeploymentByNameDeployment, StyrminClient

async with StyrminClient(server_address="...") as client:
    deployment: GetDeploymentByNameDeployment | None = (
        await client.deployments.get_by_name("my-app", environment_id=env_id)
    )

See specs/archive/009-deployments-apply/contracts/sdk-apply.md for the full contract, and ADR-048 for why the upsert lives in the SDK.

Updating a cluster config

ClusterResource.update(config, cluster_id=None, cluster_name=None, *, dry_run=False) replaces a cluster's whole config through the updateCluster mutation. It takes a plain mapping, not the generated UpdateClusterInput, and returns the updateCluster result, which styrmin_sdk.models exports as ClusterUpdateResult. UpdateClusterInput is not exported.

The generated input models ignore keys they do not know. Under full replacement, a misspelled key would be dropped, and the stored value it was meant to keep would be removed. So before any request, update passes the config through cluster_update_input (resources/clusters.py), which:

  • refuses, with a ValueError that names each path, any key at any depth that is neither a field name nor an alias of UpdateClusterInput (_input_checks.validate_input_strict);
  • accepts snake_case or camelCase keys;
  • converts stored enum values, such as http or NoSchedule as get() returns them, to their GraphQL names (_input_checks.to_graphql_enum_names).

styrminctl clusters update calls cluster_update_input before it connects to the server. The server answers updateCluster only after it has started one redeploy per changed deployment, so update waits up to 600 seconds (UPDATE_CLUSTER_TIMEOUT_SECONDS) instead of the 30-second default. See ADR-066 §4 and §5.

Always Do / Ask First / Never Do

Always Do

  • Run uv run invoke validate-graphql after touching any .graphql file.
  • Regenerate types after any .graphql change: uv run invoke generate.
  • Update both the .graphql file and the Python wiring (resource method, and StyrminClient.__init__ if a new resource module is added) in the same change — neither is automatic.
  • Run uv run invoke format lint before commit.
  • Write tests for new resource methods (success path + at least one error code).

Ask First

  • Changes to _transport.py — affects every SDK operation.
  • Changes that break the public client API surface (StyrminClient or any *Resource public method signature).
  • New third-party dependencies in python_sdk/pyproject.toml.
  • Reorganizing resources/ (splitting or merging modules, renaming resources visible on StyrminClient).

Never Do

  • Hand-edit anything under src/styrmin_sdk/graphql/_generated/ — regenerate instead.
  • Inline GraphQL strings in Python; every operation lives in its own .graphql file and is loaded via load_query().
  • Add unnamed operations (query { … }).
  • Skip validate-graphql after .graphql changes.