Skip to main content

temper-py

The Python client for the Temper knowledge-base API. Sibling of temper-rb and temper-ts — all three are pinned to the same OpenAPI contract. Workspace-isolated: this package is not a cargo member and not a bun workspace member, so nothing in the repo builds it implicitly.

import temper
from temper.generated.api.resources_api import ResourcesApi

temper.configure(base_url="https://temperkb.io")

client = temper.Client(
    temper.ClientCredentials(
        token_url="https://temperkb.io/oauth/token",
        client_id="tmpr_...",
        client_secret="...",
    )
)

client.whoami()
client.call(lambda api: ResourcesApi(api).get_resource(resource_id), idempotent=True)

What is generated and what is not

temper/generated/** is emitted from the repo-root openapi.json — itself a product of the Axum router — by a pinned openapi-generator. Never hand-edit it. A new field on a response DTO restales it exactly as it restales openapi.json itself, the temper-rb gem, and temper-ts's schema.ts:

cargo make openapi      # regenerates the spec and all three SDKs
cargo make openapi-py   # just this package

cargo make check runs openapi-py-drift, which regenerates and diffs. The test-python CI job runs the same check and never skips, so a contract change that leaves this package behind cannot merge.

Everything outside temper/generated/ is hand-written, and .openapi-generator-ignore keeps the generator off it.

Module What it is
temper.connection One ApiClient (one urllib3 pool) per process; the token is call-scoped via a ContextVar
temper.credentials BearerToken and ClientCredentials, pinned to tests/contracts/m2m-token-request.json
temper.errors The transient/permanent split, and map_error
temper.client Client.call() — the one seam carrying retry policy and 401 repair
temper.act ActInput's seven wire keys, with the confidence invariant enforced at construction
temper.refs parse_ref — a port of temper_workflow::operations::parse_ref
temper._validate The admission checks the seams share: endpoints, and values that become headers

There are no per-endpoint wrapper methods, deliberately

The gem hand-writes Resources, Contexts, CognitiveMaps because a Ruby caller otherwise passes an untyped hash. The generated Python core already answers that: every operation is a typed method over pydantic models. A hand-written resources.create(...) would be a second, worse spelling of something already correct — and a place for the two to drift. temper-ts declines the same wrappers for the same reason.

What the generated core does not answer is which failures are worth retrying and who repairs a dead token. That is what Client.call() is:

client.call(fn, idempotent=True)  # a safe method: 5xx and transport failures retry
client.call(fn)  # a write: NEVER auto-retried

A 401 is repaired once either way — re-authenticating is not re-submitting. A BearerToken cannot mint, so its 401 comes back untouched rather than being replaced by a message about the client's own plumbing.

What configure() will pass through

Keyword arguments beyond base_url and device_id reach the generated Configuration, and only the ones on an allowlist do:

temper.configure(
    base_url="https://temper.internal",
    ssl_ca_cert="/etc/ssl/private-ca.pem",  # trust a private CA
    tls_server_name="temper.internal",  # the SNI name the cert carries
    connection_pool_maxsize=32,
    proxy="http://proxy.internal:3128",
)

ssl_ca_cert, ca_cert_data, cert_file, key_file, tls_server_name, connection_pool_maxsize, proxy, proxy_headers, socket_options, datetime_format, date_format — that is the whole list, and an unrecognised name is a TypeError rather than a silent passthrough. Three of the arguments it refuses are why the list is an allowlist:

Refused What it would have done
debug=True Sets httplib.HTTPConnection.debuglevel = 1, a class attribute — every HTTP request in the process starts printing its request headers to stdout, Authorization: Bearer … included. Raise the level on the temper.generated or urllib3 logger instead; neither touches httplib.
verify_ssl=False ssl.CERT_NONE on the pool: any certificate from anything that answers, and the bearer token goes to whoever intercepted the connection. Use ssl_ca_cert / ca_cert_data.
assert_hostname=False Keeps verification on but stops checking the certificate is for the host you dialled. Use tls_server_name.

The rest are refused because this module owns them (host, retries, the server_* family) or because credentials are call-scoped, not connection-scoped (access_token, api_key, username/password). Every refusal names its reason, and all of them fire at configure() — not at the first API call, which is when a lazily-built Configuration would have raised.

Endpoints, and plaintext http

base_url and ClientCredentials(token_url=...) must be absolute https URLs, with no userinfo (https://id:secret@host puts the secret in every error message that names the URL) and no query or fragment. Plaintext http is accepted for the loopback interface — a test server, a temper serve on your laptop — and refused anywhere else, because a bearer token and a client_secret both travel in the clear over it. Where TLS genuinely terminates elsewhere, say so:

temper.configure(base_url="http://temper.internal", allow_insecure_http=True)

Connections and forking

temper.configure() installs a process-global connection: one ApiClient, one urllib3 pool, one TLS handshake amortized across every call. The access token is not on it — it is bound per call from the Client's credential, through a ContextVar, so one connection serves every concurrent caller with their own identity.

urllib3 has no fork hook (the gem gets one free from connection_pool >= 2.4), so a forking server must drop the inherited sockets itself:

import os, temper

os.register_at_fork(after_in_child=temper.reset_connection)

Errors

map_error translates the generated core's ApiException — and the raw urllib3 errors a transport failure raises — into a tree whose top-level split is the one that matters operationally:

  • TransientError → ServerError, RateLimited (with retry_after), TransportError. Let these escape a job; a retry is what fixes them.
  • PermanentError → Unauthorized, Forbidden, SystemAccessRequired, NotFound, Conflict, BadRequest. Catch these and dead-letter them.

SystemAccessRequired.refusal returns the typed refusal the server sent (Denied, Revoked, IllegalTransition, …) so a worker can tell "never granted" from "granted and then revoked" without matching on a message string. refusal_kind gives the raw discriminator when this build predates the kind the server named.

The gem calls the transport failure Temper::ConnectionError. Here it is TransportError, because ConnectionError is a Python builtin and shadowing it would make except ConnectionError silently catch the wrong thing.

Composition bounds raise BEFORE the request [2026-08-28]

/api/query's contract publishes ceilings on what one composition may declare, and the generated pydantic models enforce them locally — so these surface as a ValidationError at construction (and, since validate_assignment is on, at mutation) rather than as a BadRequest carrying a typed refusal:

field ceiling
Composition.stages 64
Intention.query 4096
IdSet.ids 256
ResourceFilter.doc_type / .tags, EdgeFilter.labels 256

This is a behaviour change for code that already builds large plans: a 300-id IdSet used to construct fine and reach the server. It now raises before any HTTP call.

The client counts characters; the server counts bytes. A 4096-character CJK question is 8192 bytes — it constructs cleanly here and is refused server-side as intention_too_long. The skew is one-directional by construction (a UTF-8 string is never fewer bytes than characters), so the client can only ever under-enforce, never refuse something the server would have run.

Two ceilings are deliberately NOT enforced here, because neither is a contract fact: the per-stage predicate and probe caps, and the aggregate embed budget (intention_budget_exceeded) — what a deployment can embed in one request is a property of that deployment. Those arrive as refusals.

Credentials

BearerToken(token) and ClientCredentials(...) check their inputs at construction, because the two ways a credential arrives wrong both surface as an unexplained invalid_client or 401 hours later:

  • Whitespace. TEMPER_M2M_CLIENT_SECRET=$(cat secret.txt) keeps the trailing newline. Rejected rather than stripped — a stripped value is a guess, and the same guess is wrong for a space in the middle of a secret.
  • Swapped fields. A client_secret beginning tmpr_ is a temper client id in the secret's slot; temper mints secrets as bare base64url.

The mint itself is deliberately unadventurous. It does not follow redirects (that would re-POST the client_secret to whatever origin the Location names), it reads at most 64 KiB of response, it applies a connect/read timeout — the mint runs under a lock, so an issuer that accepts the connection and never answers would otherwise block every thread in the process, not one — and it treats a 200 that carries no usable access_token, token_type or expires_in as a credential failure rather than letting a KeyError out of token(). A mint that fails drops the cached token first, so a caller never goes on presenting one the server has already rejected.

Development

uv sync --group dev                              # honours .python-version
uv run ruff check . && uv run ruff format --check .
uv run mypy
uv run pytest
uv build                                          # the gemspec-equivalent smoke test

There is no committed lockfile — a library states what it works with rather than freezing what its consumers resolve, the same call temper-rb makes by gitignoring Gemfile.lock. What a lockfile would have bought is bought instead by pinning ruff and mypy EXACTLY in pyproject.toml: those two can redden CI on their own release schedule, and nothing else in the dev group can.

Supported interpreters are 3.10+. That is above the generated package's own >= 3.9 on purpose: 3.9 is end-of-life, and mypy 2.x already requires 3.10, so a 3.9 floor would promise a version whose type-check we could not run.

Regenerating needs no Python at all — a Rust developer who changed a DTO can run cargo make openapi-py with either Docker or a JVM. The generator pin and its parameters live in one place, .github/scripts/generate-temper-py.sh, shared by cargo-make and the drift gate.

Release files for temperkb-py 0.5.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for temperkb-py 0.5.2
File Size Uploaded
temperkb_py-0.5.2.tar.gz 298.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for temperkb-py 0.5.2
File Interpreter ABI Platform
temperkb_py-0.5.2-py3-none-any.whl Python 3 none any Details

Total release size: 1.0 MB

Release files / temperkb_py-0.5.2.tar.gz

Download URL temperkb_py-0.5.2.tar.gz
Size 298.2 kB
Tags Source
SHA-256 checksum
How to use checksums
ed6d75f535506293f0114f5116edba22a755f8e5eebe75258c132e1af85e8959
BLAKE2b-256 checksum
How to use checksums
287621d688f91787f4ec89c2235047e3b9260aadece1ad706610e7449ff6caa1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.15 {"installer":{"name":"uv","version":"0.12.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / temperkb_py-0.5.2-py3-none-any.whl

Download URL temperkb_py-0.5.2-py3-none-any.whl
Size 726.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
369ab517ff95a12414bb4376f58ee636709f1db3748689b2e542c701c7e61371
BLAKE2b-256 checksum
How to use checksums
08c48d9d85571e1a33ea5be021bbb16afa0cd473eb64214a001ed5fdc6c6cdc1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.15 {"installer":{"name":"uv","version":"0.12.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.5.4

2 release files

0.5.3

2 release files

This release

0.5.2 This release

2 release files

0.5.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page