Generic synchronous Python HTTP client for the TCKDB API.
tckdb-client is the transport layer for any TCKDB consumer — scripts,
notebooks, post-processing jobs, future producer-specific adapters (ARC,
RMG, …). It accepts already-formed JSON payloads and sends them; it does
not know how to construct chemistry payloads, and it has no
chemistry dependencies.
For hosted/public querying, see
docs/guides/public_hosted_querying.md.
Install directly from the TCKDB monorepo without cloning:
pip install "git+https://github.com/calvinp0/tckdbv2.git#subdirectory=clients/python"Editable install for local development (from the repo root):
pip install -e ./clients/python
# or with test extras:
pip install -e "./clients/python[test]"Runtime dependency: httpx.
Two values fully determine the target instance:
export TCKDB_BASE_URL="http://localhost:8010/api/v1"
export TCKDB_API_KEY="tck_replace_me" # optional for public scientific readsAPI keys are minted on the target instance. Pointing at a different
base_url does not migrate or sync data — see
docs/clients/generic-client-targeting.md.
| Operation | API key required client-side? |
|---|---|
Scientific reads (search_*, get_species_thermo, get_reaction_*, get_geometry) |
No — anonymous-friendly. Set a key only if the deployment requires authentication or you want authenticated quotas. |
health() |
No |
me(), uploads, bundle dry-run/submit, post_json, get_json |
Yes — the client raises TCKDBAuthenticationError before sending. |
When an API key is configured, every request — including scientific
reads — forwards it as the X-API-Key header. That lets authenticated
deployments bill reads against a user identity.
The client is not an abuse-control boundary. Hosted deployments should enforce abuse limits server-side through rate limits, pagination caps, query timeouts, and monitoring.
from tckdb_client import TCKDBClient
# Anonymous: scientific reads + health
with TCKDBClient(base_url) as client:
print(client.health())
print(client.search_species(smiles="O"))
# Authenticated: required for uploads / me / admin
with TCKDBClient(base_url, api_key=api_key) as client:
print(client.me())upload() accepts:
- a known short name from
UPLOAD_ENDPOINTS(conformer,reaction,kinetics,thermo,statmech,transport,transition_state,network,network_pdep,computed_reaction), - an explicit path beginning with
/(e.g./uploads/some-future-endpoint), - or an absolute URL for advanced use.
Unknown short names are rejected client-side — upload("thermos", ...)
fails fast rather than silently posting to /uploads/thermos.
from tckdb_client import TCKDBClient
with TCKDBClient(base_url, api_key=api_key) as client:
# preferred: known short name
client.upload("thermo", payload, idempotency_key="mytool:job-123:thermo:eth")
# forward-compatible: explicit path for future endpoints
client.upload("/uploads/some-future-endpoint", payload)Retry-safe writes use the conventional Idempotency-Key header. Build a
generic key with the helper, or supply your own opaque string (16-200
chars, [A-Za-z0-9._:-]):
from tckdb_client import make_idempotency_key
key = make_idempotency_key("mytool", "job-123", "thermo", "ethanol")
client.upload("thermo", payload, idempotency_key=key)make_idempotency_key(*parts) replaces any character outside
[A-Za-z0-9._:-] with - so callers don't have to pre-sanitize labels
like "n-butane (s)". This is lossy. Two distinct logical inputs
that differ only in disallowed characters collapse to the same key:
make_idempotency_key("foo bar", ...) # -> "foo-bar:..."
make_idempotency_key("foo-bar", ...) # -> "foo-bar:..." (collision!)For most v0 producers (a single tool naming jobs from its own ID space) that's fine. Producer adapters that need stronger uniqueness guarantees should either pass pre-normalized parts or append a stable payload-hash suffix:
key = make_idempotency_key("arc", job_id, output_kind, stable_payload_hash[:12])The server treats the key as opaque — it never parses structure — so adding a hash suffix is purely a producer-side strengthening.
To detect whether the server replayed a stored response (rather than re-executing the write), use the lower-level wrapper:
response = client.request_json(
"POST", "/uploads/thermo", json=payload, idempotency_key=key,
)
if response.idempotency_replayed:
print("server replayed a prior response")
print(response.data)preview = client.bundle_dry_run(bundle)
result = client.bundle_submit(bundle, idempotency_key=key)Every HTTP failure raises a structured exception that carries the parsed response body, status code, and headers:
| Status | Exception |
|---|---|
| 401 | TCKDBAuthenticationError |
| 403 | TCKDBForbiddenError |
| 422 | TCKDBValidationError |
409 (code=idempotency_conflict) |
TCKDBIdempotencyConflictError |
| 409 (other) | TCKDBConflictError |
| 4xx/5xx (other) | TCKDBHTTPError |
| 2xx whose body is not JSON (e.g. an HTML page) | TCKDBUnexpectedResponseError |
| network / timeout | TCKDBConnectionError |
TCKDBUnexpectedResponseError (a TCKDBHTTPError) almost always means
base_url names the web site rather than the API: use
https://host/api/v1, not https://host. The message names the URL that
was requested.
from tckdb_client import TCKDBValidationError
try:
client.upload("thermo", bad_payload)
except TCKDBValidationError as exc:
print(exc.status_code, exc.detail)The status tells you who is at fault; it does not tell you what to do.
A 422 covers every refusal the server can make, and those want opposite
responses — a geometry that disagrees with its declared SMILES is worth
repairing and resending, while a reaction that does not balance means the
deposit is wrong and should not be retried at all.
exc.code names which one it was. RejectionCode is generated from the
server's code catalogue, so a code that is renamed breaks the import rather
than turning a branch into one that silently never matches. Every member is
a refusal a caller can actually receive: codes the catalogue records as
guards no request can trip are catalogued on the server and deliberately not
exported, because a branch for a code that never arrives is the same silent
non-event as a typo.
from tckdb_client import RejectionCode, TCKDBHTTPError, rejection_code
try:
client.upload("reactions", payload)
except TCKDBHTTPError as exc:
match rejection_code(exc.code):
case RejectionCode.SPECIES_GEOMETRY_COMPOSITION_MISMATCH:
payload = repair_geometry(payload) # recoverable
case RejectionCode.REACTION_MASS_BALANCE_FAILED:
raise # the deposit is wrong
case None:
raise # not a code this client knows
print(exc.detail) # prose, for a human
print(exc.response_json["context"]) # structured facts, or {}context is not populated for every code, and {} is often the correct
answer rather than a gap. The server's rule: a code that names a
relationship — *_conflict, *_mismatch, anything asserting that
two things disagree — puts the which in context (a field path, a
public ref, a local key, a constraint name, a count; never a database row
id). A code that names a thing — unknown_release, missing_filter
— is already the whole message and has no second fact to report. Branch
on code first and enrich from context if it is there; never make a
non-empty context a precondition for handling a refusal.
Cap refusals (limit_too_large, offset_too_large, geometry_too_large,
smiles_too_long, query_too_expensive, the two *_all_cap_exceeded
codes and composed_search_candidate_limit_exceeded) are relationships
and carry the limit — read it and retry against it rather than
hardcoding a number, because these caps are deployment settings and a
self-hosted TCKDB may run different ones. They will never tell you how
much data is on the far side of the cap: no match count, no row count,
in context or detail. That is deliberate, not a gap to report — an
exact count off a cheap refusal would turn any filter into a census of
the server's holdings.
Use rejection_code(exc.code) rather than RejectionCode(exc.code): a
server is routinely newer than the client pinned against it, and a code
added since must return None rather than raise inside your own error
handler.
VALIDATION_REJECTION_CODES and CONFLICT_REJECTION_CODES say which
status carries each code — 409 means a database-held rule refused the
write, 422 means nothing was written at all. A code can be in both: the
same scientific claim is sometimes enforced at the wire boundary and
again in the schema.
RejectionCode covers 4xx only, because a 5xx is not a refusal of
anything the caller did and there is no payload to repair. But a handful
of TCKDB's 5xx conditions are deterministic, and a retry policy needs
to know which:
from tckdb_client import NON_RETRYABLE_CODES
# frozenset of plain strings, not RejectionCode membersGET /scientific/artifacts/{sha256}/download reports two different
storage failures. A 503 artifact_storage_unavailable means the object
store did not answer — wait and try again. A 502 artifact_object_missing
means the store answered and the bytes a still-published record points at
are gone; only an operator restoring the object changes that, so replaying
the request is a backoff schedule with no exit. Both statuses are in
DEFAULT_RETRY_STATUS_CODES, so the status alone cannot tell them apart.
Artifact uploads add a third: 507 artifact_storage_full means the
object store answered and refused the write because it has no room — out
of space, or over its quota. Nothing about the request is wrong and no
amount of waiting helps until an operator frees space, so do not retry it;
surface it to whoever runs the deployment. Unlike the pair above, this one
needs no deny list to get right — 507 is deliberately not in
DEFAULT_RETRY_STATUS_CODES, so any client, in any language, pinned to any
version, stops after one attempt on the status alone.
RetryPolicy consults this set automatically — if you pass a policy, you
get the behaviour for free. It is a deny list: a code the set does not
name is retried exactly as before, so a client pinned against an older
release never stops retrying a transient failure it has not heard of.
What each code asserts, why it refuses rather than warns, and how legitimate chemistry it would otherwise reject can still be deposited, is in the scientific check register.
The client exposes thin wrappers over the backend's /api/v1/scientific/*
read surface. These methods are generic TCKDB reads — they serialize
parameters, call the backend, and return parsed JSON. They contain no
ARC- or RMG-specific selection or reuse policy, no client-side ranking,
and no notion of a "best" record. Trust posture, sort order, evidence
completeness, and provenance shape are all decided by the backend per
docs/specs/browse_order.md.
Every scientific read takes an optional profile=. profile="curated"
asks for the results TCKDB stands behind; profile="exploratory" (the
default when you pass nothing) returns everything that matched. The
answering profile comes back in response["request"]["profile"] —
read that rather than assuming you got what you asked for, because a
deployment with no curated selection answers exploratorily.
kinetics = client.search_kinetics(reactants=["C[CH2]"], profile="curated")
# One-sided queries mean containment: "kinetics for reactions consuming
# the ethyl radical", products unconstrained. Pass match="exact" to ask
# for one specific equation instead (both sides, counts included).
kinetics["request"]["profile"] # what actually answered
kinetics["request"]["profile_recommendation"] # what TCKDB recommendsThere is deliberately no release= parameter on any method. Scoping
a read to a published dataset release is done through the release's own
path — GET /scientific/releases/{handle}/selections and its artifacts,
reachable via get_json() — not through a query parameter. Supplying
?release= to any other scientific endpoint is answered with a 422.
Recommended — chemistry-first search (use these for hosted workflow tools that know identifiers, not entry ids):
# Thermo by SMILES — one call, entry id and entry ref returned in the response
thermo = client.search_thermo(
smiles="C[CH2]",
temperature_min=300,
temperature_max=3000,
collapse="first",
include=["provenance", "review"],
)
# Kinetics by reactants/products
kinetics = client.search_kinetics(
reactants=["[CH3]", "c1ccccc1"],
products=["CH4", "[c]1ccccc1"],
direction="either",
temperature_min=300,
temperature_max=2000,
collapse="first",
include=["provenance", "review"],
)Searches still return ordinary JSON dictionaries. Exported TypedDict
aliases add editor/type-checker guidance without adding runtime validation:
from tckdb_client import NetworkKineticsSearchResponse
page: NetworkKineticsSearchResponse = client.search_network_kinetics(
source_species_entry_refs=["spe_reactant", "spe_reactant"],
sink_species_entry_refs=["spe_product"],
include=["coefficients"],
)Each supported paginated search also has a lazy iter_* form. It keeps
filters and include tokens unchanged, follows returned pagination metadata,
and raises TCKDBPaginationError on malformed, changing, or non-advancing
pages. collapse="first" yields the one collapsed record and stops because
hosted responses report both the pre-collapse pagination.total and the
post-collapse, pre-page pagination.post_collapse_total. The client retains
the legacy stop rule for older servers that omit the additive field.
for record in client.iter_network_kinetics(
source_smiles=["[CH3]", "[CH3]"],
sink_smiles=["CC"],
include=["coefficients", "source_calculations"],
):
print(record["network_kinetics"]["network_kinetics_ref"])Discovery-only (use only when you want the identity layer without records — e.g. listing matched candidates in a UI):
species = client.search_species(smiles="C[CH2]")
rxns = client.search_reactions(
reactants=["[CH3]", "c1ccccc1"],
products=["CH4", "[c]1ccccc1"],
direction="either",
)
# Force GET if you prefer query-string encoding:
rxns_get = client.search_reactions(
reactants=["A"], products=["B"], method="GET",
)Ref-first follow-up reads (preferred): each chemistry-first search
response includes *_ref public handles alongside the integer ids.
Use those refs when chaining into a detail endpoint — the path
parameter accepts either form.
# Thermo detail keyed off a ref returned by search_thermo.
thermo = client.search_thermo(smiles="CN", collapse="first")
species_entry_ref = thermo["records"][0]["species"]["species_entry_ref"]
detail = client.get_species_thermo(
species_entry_id=species_entry_ref, # public ref accepted here
temperature_min=300,
temperature_max=2000,
)
# Composite "/full" keyed off a ref returned by search_kinetics.
kinetics = client.search_kinetics(
reactants=["[CH3]", "[CH3]"],
products=["CC"],
direction="either",
collapse="first",
)
reaction_entry_ref = kinetics["records"][0]["reaction"]["reaction_entry_ref"]
full = client.get_reaction_full(
reaction_entry_id=reaction_entry_ref, # public ref accepted here
include=["species", "kinetics", "transition_states", "calculations", "review"],
)
# Level-of-theory ref works as a filter wherever level_of_theory_id does.
lot_ref = detail["records"][0]["provenance"]["level_of_theory"]["level_of_theory_ref"]
same_lot = client.search_species_calculations(
smiles="CN",
calculation_type="sp",
level_of_theory_ref=lot_ref,
ranking="lowest_energy",
collapse="first",
)Entry-id detail / follow-up (still supported for inspection, curation, or chaining off ids you already hold):
# Kinetics for a known reaction entry, listed in display order (approved first, then newest deposited; not a preference);
# temperature_min/max only fill each record's coverage field
kinetics = client.get_reaction_kinetics(
reaction_entry_id=51,
temperature_min=300,
temperature_max=2000,
collapse="first",
)
# Thermo for a known species entry
thermo = client.get_species_thermo(
species_entry_id=31,
temperature_min=300,
temperature_max=3000,
model_kind="nasa",
)
# Composite "everything supporting this reaction" document
full = client.get_reaction_full(
reaction_entry_id=51,
include=["kinetics", "transition_states", "calculations", "review"],
include_review="full",
)species-calculations/search returns geometry_ref handles, not
coordinate payloads, because most callers don't need the full atom
list. Use get_geometry(geometry_handle) to retrieve coordinates
when you do.
calcs = client.search_species_calculations(
smiles="O",
calculation_type="sp",
ranking="lowest_energy",
collapse="first",
include=["provenance", "review"],
)
# SP calculations consume an input geometry; opt produces an output.
geom_block = calcs["records"][0]["geometry"]
geometry_handle = (
geom_block["primary_output_geometry_ref"]
or geom_block["input_geometries"][0]["geometry_ref"]
)
geometry = client.get_geometry(geometry_handle)
# {
# "geometry_ref": "geom_…",
# "natoms": 3,
# "format": "cartesian",
# "coordinate_units": "angstrom",
# "symbols": ["O", "H", "H"],
# "coords": [[0, 0, 0], [0, 0.76, 0.58], [0, -0.76, 0.58]],
# "provenance": {
# "produced_by": [{"calculation_ref": "calc_…", "calculation_type": "opt", "role": "final"}],
# "used_as_input_by": [{"calculation_ref": "calc_…", "calculation_type": "sp"}]
# }
# }Notes:
- SP calculations usually have
input_geometriesbut no output geometry;primary_output_geometry_refisnullfor SP by design (the upload layer only auto-attaches an output geometry foropt). - Opt calculations should normally have
primary_output_geometry_refset when an optimized geometry was persisted. - The endpoint accepts an integer
geometry.idtoo, for compatibility; public docs and examples prefer the ref form.
Notes
-
Phase D (default): scientific read responses expose only public refs (
*_ref). Integer primary keys (*_id) and bare integer-id arrays (input_geometry_ids,supporting_calculation_ids, …) are hidden by default. Their ref-bearing object-array siblings (input_geometries,supporting_calculations, …) remain visible. -
Opt-in to integer IDs: request the
internal_idsinclude token. The opt-in is only effective when the deployment setsALLOW_PUBLIC_INTERNAL_IDS=true; in hosted production the token is silently dropped and the response stays refs-only.# Refs only (default): thermo = client.search_thermo(smiles="CN", collapse="first") # Compatibility / debugging — request internal ids if the server allows them: thermo = client.search_thermo( smiles="CN", collapse="first", include=["provenance", "review", "internal_ids"], )
-
include=alldoes not expand tointernal_ids; you must passinclude=["all", "internal_ids"]to combine them. -
Public refs are the preferred hosted handles; integer IDs are internal/debug compatibility fields.
-
Path parameters like
reaction_entry_id/species_entry_idaccept either an integer PK or a public ref of the matching prefix (rxe_.../spe_...). A wrong-prefix ref returns 422 (handle_type_mismatch); an unknown ref returns 404. The path parameter shape did not change in Phase D — only the response. -
Supplying both
*_idand*_reffor the same filter is allowed only when they resolve to the same row; otherwise the backend returns 422 with a stable<resource>_handle_conflictcode. -
reaction_entry_idis strictlyreaction_entry.id(notchem_reaction.id).species_entry_idis strictlyspecies_entry.id(notspecies.id). -
Client-supplied
sort=is not supported in v0; the backend returns 422 if asortquery parameter is sent. The methods deliberately omit asortargument for that reason. -
direction="exact"is rejected by the backend with 422. -
includeaccepts a Python list and is serialized as repeated query parameters (?include=a&include=b). -
Returned values are parsed JSON
dictenvelopes matching the response models inbackend/app/schemas/reads/scientific_*.py. PublicTypedDictaliases describe that wire shape but do not wrap or coerce it.
Raw artifact bytes are served only to authenticated callers -- always, on every deployment. Unredacted ESS logs can carry producer-side scratch paths, usernames and cluster hostnames, so the gate has no opt-out (ADR 0004). Set your key first:
export TCKDB_API_KEY=tck_...--api-key-env names a different variable to read the key from; it
never takes the key itself.
From the command line:
tckdb download artifact <sha256> # ./output_log_art_7k2p9x.log
tckdb download artifact <sha256> -o job.log # ./job.log
tckdb download artifact <sha256> -o ~/downloads # ~/downloads/<derived name>
tckdb download artifact <sha256> -o - | less # straight to stdoutAn existing file is never overwritten without --force. The bytes are
hashed and checked against the digest you asked for before anything
is written, so a mismatch -- a proxy or cache serving the wrong object --
fails loudly rather than landing on disk under a name that asserts a
digest it does not have.
The default filename is <kind>_<artifact_ref><ext> -- what the file is,
which record it belongs to, and its format. Deriving it costs one extra
metadata lookup, skipped entirely when --output is given, and falls
back to the digest if the archive knows neither.
The recorded filename is deliberately not used as the lead. On this
corpus it identifies nothing and reads as the opposite of the truth: 317
artifacts of kind output_log are all called input.log, because
g16 input.gjf writes input.log -- Gaussian names the output after the
job, not its role. The extension still comes from the recorded name,
since that is the part a reader needs.
The filename is stored data, so it is not trusted: only the basename survives, and only characters that cannot mean anything to a path.
From Python:
raw = client.download_artifact("<sha256>")Exit codes: 0 written, 4 no artifact with that digest is readable by
your credential (either it is not in the archive, or it is neither
approved nor one of your own deposits), 1 anything else.
examples/basic_usage.pyexamples/upload_json_file.pyexamples/submit_bundle.pyexamples/scientific_reads.py
examples/scientific_reads.py exercises every
chemistry-first scientific read method against a live TCKDB deployment.
Empty results are reported with a friendly message; client errors print
status_code, code, and detail rather than swallowing them.
# Default — run the species-side queries against a local instance
python examples/scientific_reads.py \
--base-url http://127.0.0.1:8010/api/v1 \
--smiles "C[CH2]"
# Add reactants/products to also run reaction discovery + kinetics +
# a follow-up to /scientific/reaction-entries/{id}/full
python examples/scientific_reads.py \
--base-url http://127.0.0.1:8010/api/v1 \
--reactant "[CH3]" --reactant "c1ccccc1" \
--product "CH4" --product "[c]1ccccc1"
# Filter species-calculations by LoT, dump raw JSON
python examples/scientific_reads.py \
--base-url http://127.0.0.1:8010/api/v1 \
--smiles "CCO" \
--level-of-theory-id 8 \
--jsonThe script never selects a "best" record. It uses the documented
collapse="first" semantics (first record under the backend's
deterministic ordering) and prints the underlying evidence — review
status, temperature coverage, evidence completeness, provenance — so the
caller can apply their own reuse policy.
By default the script exercises every section it knows about, which is useful as a demo but noisy when you're debugging one specific API. Three CLI knobs narrow the output:
--only <sections>— comma-separated list of sections to run.--skip <sections>— comma-separated list to remove (applied after--only).--no-followups— disable every follow-up call (thermo-detail,lot-followup,geometry,full), keeping only the primary searches.
Two additional flags re-target the species-calculations search itself
(it previously hard-coded sp + lowest_energy):
--calculation-type <type>— backendcalculation_typefilter (defaultsp; common values:sp,opt,freq,scan,irc,neb,path_search,conf).--ranking <ranking>— backendrankingknob (defaultlowest_energy). Unsupported combinations surface the backend's normal validation error.
Section names: species, thermo, thermo-detail, calculations,
lot-followup, geometry, reactions, kinetics, full, plus
all. Unknown names raise a clean CLI error.
# Species search only.
python examples/scientific_reads.py --smiles "O" --only species
# Species-calculations search + the geometry follow-up.
python examples/scientific_reads.py --smiles "O" --only calculations,geometry
# Primary searches only; skip every follow-up.
python examples/scientific_reads.py --smiles "O" --no-followups
# Pull opt records instead of the default sp.
python examples/scientific_reads.py --smiles "O" --only calculations --calculation-type optWhen a section's dependency is missing (e.g. geometry without
calculations, or kinetics without --reactant/--product), the
script prints a one-line skip notice and continues.
pytest
python -m py_compile examples/basic_usage.py examples/upload_json_file.py examples/submit_bundle.pyThe test suite uses httpx.MockTransport and never contacts a live TCKDB
instance.
The package ships a builder layer under tckdb_client.builders that
constructs upload payloads from scientific Python objects and sends
them through the existing thin client. The thin HTTP client is
production-stable; the builder layer is public beta / preview —
recommended for early adopters who want a typed, validated upload
surface, with a known set of forward-compat gaps documented below.
The supported flows are computed-species upload (one species + its opt/freq/sp + optional thermo / statmech / transport) and computed-reaction upload (reactant/product species + TS + kinetics + per-species thermo / statmech / transport).
See docs/builder_api_stability.md
for the public-beta API surface, deprecation policy, and the list of
things that may still change before v1.
The builder intentionally models one scientifically meaningful
conformer/geometry per species upload. It is not intended for
uploading every conformer candidate a workflow considered. See
docs/conformer_semantic_boundary.md.
SourceCalculations is an opt-in helper that lets producers tag
calcs by role once and reuse them across Thermo / Statmech /
Kinetics / Transport blocks:
from tckdb_client.builders import SourceCalculations, Thermo, Statmech
sources = SourceCalculations(opt=opt, freq=freq, sp=sp)
thermo = Thermo.nasa(..., source_calculations=sources.only("opt", "freq", "sp"))
statmech = Statmech(..., source_calculations=sources.only("opt", "freq"))For duplicate-role cases (e.g. bimolecular kinetics with two reactant SPs) pass a list value:
kin_sources = SourceCalculations(
reactant_energy=[ch3_sp, h_sp],
product_energy=ch4_sp,
ts_energy=ts_sp,
freq=ts_freq,
)
kinetics = Kinetics.modified_arrhenius(..., source_calculations=kin_sources.as_list()).only(*roles) emits entries in caller-requested role order and
raises on a misspelled role (typo guard). .as_list() emits all
entries in insertion order. Use .add("k-inf", calc) for role
tokens that aren't valid Python identifiers.
Full design rationale and rejected alternatives:
docs/source_calculation_ergonomics.md.
Explicit lists, dicts, and dict-of-list shapes keep working
unchanged — the helper is purely additive.
The base tckdb-client is and remains parser-free. It does not
read Gaussian, ORCA, Arkane, or RMG output files; it does not infer
charge / multiplicity from logs; it does not run RDKit identity
checks. ESS file parsing is an upper layer above the builders, and
is intentionally not part of the base install. See
docs/parser_validation_boundary.md
for the layering between builders, future parser helpers,
workflow-tool adapters, and the backend.
Calculation.opt/freq/sp(..., note="…") is a builder-local free-text
annotation for producer/adapter ergonomics. It is preserved on the
builder (visible via upload.iter_calculations()) but not emitted
on the wire today. Producer conventions for what belongs in a note
(short one-line why, never logs / candidate-conformer narratives /
machine paths / scratchpad text) live in
docs/calculation_note_conventions.md.
The
docs/adapter_authoring_quickstart.md
is the short producer-facing path. One document covers the four-layer
model (workflow → adapter → builders → client → backend), the three
boundary rules, the minimal six-step adapter flow, SourceCalculations
use, the two-phase artifact upload contract, the pre-upload inspection
surface (summary() + emission_diagnostics()), the raw-payload
escape hatch, and pointers to the worked example demos.
upload.summary() returns a small UploadSummary value with two
emission methods — .to_text() (human-readable; formatting may
change between minor versions) and .to_dict() (public-beta
stable keys, JSON-serialisable). Both
ComputedSpeciesUpload.summary() and
ComputedReactionUpload.summary() are supported:
summary = upload.summary()
print(summary.to_text()) # CLI / notebook / log preview
data = summary.to_dict() # structured for tests + observability
assert data["artifact_count"] == 2
assert data["diagnostic_codes"] # codes only — no long messagesThe summary is a builder-side viewer of upload state; it is
not a substitute for upload.to_payload(), which remains the
canonical wire representation. Stability layering and the full
field list (per upload kind) are documented in
docs/builder_summary_design.md.
No new dependencies, no impact on to_payload() — purely additive.
from tckdb_client import TCKDBClient
from tckdb_client.builders import (
Calculation,
ComputedSpeciesUpload,
Geometry,
LevelOfTheory,
Species,
SoftwareRelease,
)
sr = SoftwareRelease(software="Gaussian", version="16", revision="C.01")
lot = LevelOfTheory(method="B3LYP", basis="6-31G(d)")
opt = Calculation.opt(sr, lot, output_geometry=Geometry.from_xyz(out_xyz),
final_energy_hartree=-76.4, converged=True)
freq = Calculation.freq(sr, lot, input_geometry=Geometry.from_xyz(out_xyz),
n_imag=0, zpe_hartree=0.0214, depends_on=opt)
upload = ComputedSpeciesUpload(
species=Species(smiles="O", charge=0, multiplicity=1),
calculations=[opt, freq],
)
# Inspect what the bundle endpoint will / won't accept before sending.
for diag in upload.emission_diagnostics():
print(f"[{diag.level}] {diag.code} @ {diag.path}: {diag.message}")
with TCKDBClient(base_url, api_key=api_key) as client:
# warn_on_dropped_fields=True re-emits each warning diagnostic via
# warnings.warn(...) before the request is dispatched.
result = client.upload(upload, warn_on_dropped_fields=True)End-to-end demos:
examples/builder_computed_species_demo.py— start here. Simplest single-species upload: oneSpecies, oneopt + freq + sptriple, oneThermo, oneStatmech, oneTransport, two attached artifacts. ~250 lines of fully-typed example code with no domain modelling beyond the molecule itself.examples/builder_computed_reaction_demo.py— multi-species reaction upload: threeSpecies(CH3 + H → CH4), aTransitionState, modified-ArrheniusKineticswith duplicatereactant_energysource roles, per-species thermo / statmech / transport, and two TS / species-side artifacts. Use this once the species demo makes sense.examples/builder_computed_reaction_demo.ipynb— Jupyter notebook walk-through of the reaction demo, section headings included, ideal for interactive exploration.examples/builder_arc_style_dry_run.py— ARC-style dry-run example. Workflow-shaped data — four species (CH4 + OH → CH3 + H2O, H-abstraction), a TS, mixed Gaussian-opt / ORCA-SP releases, modified-Arrhenius kinetics withSourceCalculations, thermo + statmech on one product, and two attached artifacts — mapped into builders without requiring ARC itself. Demonstrates thenote=kwarg onCalculation.opt/freq/spfor workflow-side context (local-only; not emitted on the wire). Uses only public APIs and respects the one-conformer-per-species conformer boundary policy.examples/builder_arc_style_dry_run.ipynb— Jupyter notebook walk-through of the ARC-style dry-run, split into twelve named sections (imports → workflow mapping → artifact plan → optional live upload). Same flow as the.pysibling, same public-API contract, ideal for stepping through cell-by-cell.
All five demos:
- print the payload summary, emission diagnostics, an attached-artifact
summary (via
upload.iter_calculation_entries(with_artifacts_only=True)), and a mock-IDs plan preview (viaupload.artifact_plan_preview()); - short-circuit before any HTTP when
TCKDB_BASE_URL/TCKDB_API_KEYare not set; - with both env vars set, run the full two-phase flow —
client.upload(upload, warn_on_dropped_fields=True)followed byclient.upload_artifacts(upload.artifact_plan(result), idempotency_key_prefix=…).
The matrix below is the source of truth for which blocks make it onto
the wire on which endpoint. The same values appear in
emission_diagnostics() and stay aligned with the diagnostic codes.
| Block | ComputedSpeciesUpload |
ComputedReactionUpload |
|---|---|---|
Thermo |
emits + source_calculations |
emits, source_calculations not emitted |
Statmech |
emits + source_calculations |
emits + source_calculations |
Transport |
accepted, not emitted |
accepted, not emitted |
Kinetics |
n/a | emits + source_calculations |
| Artifacts | second-phase only |
second-phase only |
emission_diagnostics()
with a stable code (see tckdb_client.builders.DIAG_CODES). When the
backend bundle schemas grow the missing fields, the assemblers flip
emission on and those diagnostics simply stop appearing — no client
API change.
Scientific uploads create calculation rows; files (input decks,
output logs, checkpoints) ride on a separate POST per calculation
once the server has assigned calculation.id values. The bundle
endpoints do not accept inline artifact bytes — keeping the
scientific upload and the file transport split means a 503 on file
storage never leaves scientific rows half-written, and the
calculation payload stays small.
Attach files to any Calculation before upload:
opt = Calculation.opt(..., label="ethanol_opt")
opt.add_artifact("ethanol_opt.gjf", kind="input")
opt.add_artifact("ethanol_opt.log", kind="output_log")Then upload in two phases:
result = client.upload(upload) # phase 1: scientific bundle
plan = upload.artifact_plan(result) # resolve calc keys → ids
# Simple and easiest to debug: one POST per artifact.
client.upload_artifacts(plan, idempotency_key_prefix="my-run:2026-05-16")
# Or — fewer HTTP requests, one batch per calculation:
client.upload_artifacts(plan, idempotency_key_prefix="my-run:2026-05-16",
batch_by_calculation=True)batch_by_calculation=True groups planned items by
calculation_id and sends one POST per group to
/calculations/{id}/artifacts. The server endpoint is batch-atomic:
any per-artifact validation failure rejects the whole batch with 422
(no DB rows, no S3 writes), and a pass-2 storage failure compensates
earlier S3 writes before returning 503. Returns one
ArtifactUploadBatchResult(calculation_id, calculation_keys, artifact_count, response, calculation_ref, status_code, request_id, replayed, warnings) per group: response is the server's body, status_code
and request_id (X-Request-ID) are the HTTP answer's, replayed is True
when the server replayed a stored Idempotency-Key receipt, and warnings is
the body's warning list. Only batch_by_calculation=True returns these; the
default sequential mode still returns the bare response bodies. Idempotency keys in batch mode
are f"{prefix}:{first_calculation_key}:artifact-batch" (one per
batch, deterministic across runs).
The default remains sequential one-artifact-per-request — pick the batched form once you've confirmed the simple flow works.
Supported artifact kinds match the backend ArtifactKind enum:
input, output_log, checkpoint, formatted_checkpoint,
ancillary. The server's per-kind extension allow-list (e.g.
.gjf/.in for input, .out/.log/.orca for output_log)
still applies on upload — add_artifact(...) does not pre-check
extensions, but it does validate the kind, sha256 (64 lowercase
hex), and bytes (non-negative integer) up front so typos surface
deterministically.
emission_diagnostics() reports
artifact_upload_requires_second_phase per calculation that has
attached artifacts — it is intentionally a warning-level reminder,
not an error, so the scientific upload can still go through.
Both upload classes expose four public iteration / preview helpers so producer code never has to reach into private state to walk their calculations or stage offline plans:
# Walk every calc in the upload, in payload order.
for calc in upload.iter_calculations():
...
# Same walk, but skip calcs without attached artifacts.
for calc in upload.iter_calculations(with_artifacts_only=True):
...
# Walk with bucket / species context — TS-side vs species-side on
# the reaction path, identity-only on the species path.
for entry in upload.iter_calculation_entries(with_artifacts_only=True):
print(entry.bucket, entry.species, entry.calculation)
# Pair every attached artifact with its calculation.
for calc, artifact in upload.iter_artifacts():
...
# A real `artifact_plan(server_result)` against synthetic IDs —
# useful for offline demos, CI fixtures, and producer debugging.
# Same upload state, same preview, every time. The IDs are NOT
# real server-side primary keys.
plan_preview = upload.artifact_plan_preview()The builder layer accepts some fields for forward compatibility before the backend bundle schema carries them. Today's known gaps:
ComputedSpeciesUpload.transport— no bundle field yetComputedReactionUpload.species_transport— no bundle field yetComputedReactionUpload.species_thermo[…].source_calculations— the computed-reactionBundleThermoInlackssource_calculations(only the computed-speciesThermoInBundlecarries it)
Each upload object exposes emission_diagnostics() so producers can
see what data has been validated locally but will not travel on
the wire today:
upload = ComputedReactionUpload(
reaction=rxn,
calculations=[ts_opt, ts_freq],
species_calculations={ch4: [ch4_opt, ch4_freq, ch4_sp]},
species_transport={ch4: Transport(sigma_angstrom=3.8, epsilon_over_k_k=141.4)},
)
for diag in upload.emission_diagnostics():
print(f"[{diag.level}] {diag.code} @ {diag.path}: {diag.message}")Pass warn_on_dropped_fields=True to client.upload(...) to re-emit
each warning-level diagnostic through warnings.warn(...) before
dispatch — useful in producer pipelines that already filter / escalate
Python warnings:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("error") # promote diagnostics to errors
client.upload(upload, warn_on_dropped_fields=True)The diagnostic codes are stable strings — see
docs/builder_api_stability.md for
the policy.
from tckdb_client import TCKDBClient
from tckdb_client.builders import (
Calculation,
ChemReaction,
ComputedReactionUpload,
Geometry,
Kinetics,
LevelOfTheory,
Species,
SoftwareRelease,
TransitionState,
)
sr = SoftwareRelease(software="Gaussian", version="16")
lot = LevelOfTheory(method="wb97xd", basis="def2tzvp")
ts_geom = Geometry.from_xyz(ts_xyz)
ts_opt = Calculation.opt(sr, lot, output_geometry=ts_geom,
final_energy_hartree=-270.55, converged=True)
ts_freq = Calculation.freq(sr, lot, n_imag=1, imag_freq_cm1=-1200.0,
zpe_hartree=0.201, depends_on=ts_opt)
kin = Kinetics.modified_arrhenius(
A=1.2e13, A_units="cm3/mol/s", n=0.5,
Ea=10.0, Ea_units="kJ/mol", # also accepts kcal/mol
Tmin=300, Tmax=2500,
source_calculations={"ts_energy": ts_opt, "freq": ts_freq},
)
rxn = ChemReaction(
reactants=[Species(smiles="[CH3]", charge=0, multiplicity=2),
Species(smiles="[H]", charge=0, multiplicity=2)],
products =[Species(smiles="C", charge=0, multiplicity=1)],
family="H_Abstraction",
transition_state=TransitionState(charge=0, multiplicity=2, geometry=ts_geom),
kinetics=[kin],
)
upload = ComputedReactionUpload(reaction=rxn, calculations=[ts_opt, ts_freq])
with TCKDBClient(base_url, api_key=api_key) as client:
result = client.upload(upload)from tckdb_client.builders import Transport
transport = Transport(
sigma_angstrom=3.8,
epsilon_over_k_k=150.0,
dipole_debye=0.1,
polarizability_angstrom3=2.6,
rotational_relaxation=13.0,
source_calculations=[("supporting_geometry", opt)],
)
upload = ComputedSpeciesUpload(
species=ethanol,
calculations=[opt, freq, sp],
primary_calculation=opt,
thermo=thermo,
statmech=statmech,
transport=transport, # accepted, validated, NOT yet emitted
)Transport is also accepted on the computed-reaction path via
ComputedReactionUpload(..., species_transport={ch4: transport, ...}).
Forward-compat caveat. The current bundle schemas (
ComputedSpeciesUploadRequest,ComputedReactionUploadRequest) do not carry a transport field. The bundle assemblers accept theTransportbuilder, validate its source-calc references against the matching calculations bucket, and intentionally drop the data on the wire so producer code stays portable across the eventual schema change. Until then, the standalone/uploads/transportendpoint is the way to ship transport data.Transport.to_payload()produces a dict the primitiveTransportUploadPayloadschema accepts:client.upload("transport", transport.to_payload())
from tckdb_client.builders import Statmech
statmech = Statmech(
external_symmetry=2,
point_group="C2v",
is_linear=False,
rigid_rotor_kind="asymmetric_top", # see backend RigidRotorKind enum
statmech_treatment="rrho", # see backend StatmechTreatmentKind enum
source_calculations=[("opt", opt), ("freq", freq), ("sp", sp)],
)
upload = ComputedSpeciesUpload(
species=water_species,
calculations=[opt, freq, sp],
primary_calculation=opt,
thermo=thermo,
statmech=statmech,
)Statmech is also accepted on the computed-reaction path via
ComputedReactionUpload(..., species_statmech={ch4: statmech, ...}).
Unlike thermo on the reaction side, statmech source_calculations
are emitted on the wire for both endpoints — the backend's
StatmechInBundle and BundleStatmechIn both carry the field. The
builder validates that each referenced calc belongs to the same
species's bucket either way.
from tckdb_client.builders import (
Calculation, ComputedSpeciesUpload, Geometry, LevelOfTheory,
Species, SoftwareRelease, Thermo,
)
opt = Calculation.opt(sr, lot, output_geometry=geom,
final_energy_hartree=-76.4, converged=True, label="opt")
freq = Calculation.freq(sr, lot, n_imag=0, zpe_hartree=0.0214,
depends_on=opt, label="freq")
sp = Calculation.sp(sr, lot, electronic_energy_hartree=-76.45,
depends_on=opt, label="sp")
thermo = Thermo.nasa(
coeffs_low =[a1, a2, a3, a4, a5, a6, a7],
coeffs_high=[b1, b2, b3, b4, b5, b6, b7],
t_low=300.0, t_mid=1000.0, t_high=3000.0,
h298_kj_mol=-241.8, s298_j_mol_k=188.8,
source_calculations=[("opt", opt), ("freq", freq), ("sp", sp)],
)
upload = ComputedSpeciesUpload(
species=water_species,
calculations=[opt, freq, sp],
primary_calculation=opt,
thermo=thermo,
)The ComputedSpeciesUpload.thermo path emits source_calculations
on the wire — each entry's calculation_key is resolved to the
bundle-local key the builder minted for the corresponding
Calculation. The backend's ThermoInBundle enforces uniqueness on
(calculation_key, role) pairs.
Per-endpoint distinction. The same
Thermobuilder is used byComputedReactionUpload.species_thermo(Phase 3B), but the computed-reaction backend'sBundleThermoInschema does not carrysource_calculationstoday. The builder still validates source-calc references locally for the reaction path; they are simply not emitted on the wire there. Only the computed-species path emits them.
Pass species_calculations to ship per-species opt / freq / sp records
in the same upload. Each species gets one conformer anchored on its
single opt; non-opt calcs attach to that conformer.
ch3 = Species(smiles="[CH3]", charge=0, multiplicity=2)
h = Species(smiles="[H]", charge=0, multiplicity=2)
ch4 = Species(smiles="C", charge=0, multiplicity=1)
# (build species-side opt + sp builders per species, similar to TS side …)
kin = Kinetics.modified_arrhenius(
A=1.2e13, A_units="cm3/mol/s", n=0.5, Ea=10.0, Ea_units="kJ/mol",
source_calculations={
"reactant_energy": [ch3_sp, h_sp], # duplicate role: list value
"product_energy": ch4_sp,
"ts_energy": ts_opt,
"freq": ts_freq,
},
)
rxn = ChemReaction(
reactants=[ch3, h], products=[ch4],
family="H_Abstraction",
transition_state=TransitionState(charge=0, multiplicity=2, geometry=ts_geom),
kinetics=[kin],
)
upload = ComputedReactionUpload(
reaction=rxn,
calculations=[ts_opt, ts_freq],
species_calculations={
ch3: [ch3_opt, ch3_sp],
h: [h_opt, h_sp],
ch4: [ch4_opt, ch4_sp],
},
)source_calculations accepts three shapes — a dict[role, Calculation],
a dict[role, list[Calculation]] (used above for the duplicate
reactant_energy), or a list[(role, Calculation)] of explicit pairs.
The builder supports one opt per species, by design — see
docs/conformer_semantic_boundary.md.
Pass species_thermo to ship per-species thermo blocks. Three flavours
are supported and map to the backend's BundleThermoIn shape:
from tckdb_client.builders import Thermo
# NASA 7-coefficient polynomial.
ch4_thermo = Thermo.nasa(
coeffs_low =[a1, a2, a3, a4, a5, a6, a7],
coeffs_high=[b1, b2, b3, b4, b5, b6, b7],
t_low=200.0, t_mid=1000.0, t_high=5000.0,
h298_kj_mol=-74.6, s298_j_mol_k=186.3,
)
# Scalar h298 / s298 (at least one required).
ch4_scalar = Thermo.scalar(h298_kj_mol=-74.6, s298_j_mol_k=186.3,
tmin_k=200, tmax_k=2000)
# Tabulated points.
ch4_points = Thermo.points(
[{"temperature_k": 298.15, "cp_j_mol_k": 35.3, "h_kj_mol": 0.0,
"s_j_mol_k": 186.3}],
tmin_k=200, tmax_k=2000,
)
upload = ComputedReactionUpload(
reaction=rxn,
calculations=[ts_opt, ts_freq],
species_calculations={ch4: [ch4_opt, ch4_freq, ch4_sp]},
species_thermo={ch4: ch4_thermo},
)Thermo factories also accept a source_calculations= kwarg for
forward compatibility with future thermo endpoints (the role names
mirror the backend's ThermoCalculationRole enum:
opt / freq / sp / composite / imported). The
computed-reaction endpoint does not consume this field today, so it
is not emitted on the wire — but the builder still validates that
each referenced calculation belongs to the same species's bucket so
producers don't ship inconsistent data.
Kinetics.modified_arrhenius accepts user-friendly unit strings
("cm3/mol/s", "s^-1", "kcal/mol", …) and normalises them to the
backend's enum values before sending. Ea in kcal/mol is converted
to kJ/mol automatically.
client.upload(builder) and the long-standing
client.upload(endpoint, payload_dict) stay structurally distinct —
passing a raw dict into the single-argument form raises TypeError
rather than guessing an endpoint. Thermo, statmech, and artifact
helpers are deferred to Phase 3; see
docs/builder_api_mvp.md for the full
phased rollout and open design questions.
- no chemistry adapters / no ARC, RMG, RDKit, ASE, cclib, Arkane imports
- no async client (deferred)
- no automatic retries
- no payload sidecar / on-disk replay management
- no OpenAPI-generated client code
- no direct database access