No description
Find a file
2026-08-18 22:04:58 +08:00
odms Initial commit 2026-08-18 22:04:58 +08:00
tests Initial commit 2026-08-18 22:04:58 +08:00
.gitignore Initial commit 2026-08-18 22:04:58 +08:00
pyproject.toml Initial commit 2026-08-18 22:04:58 +08:00
README.md Initial commit 2026-08-18 22:04:58 +08:00

odms-client

Python client for the ODMS orbital-data API.

No dependencies. It is written against urllib because every service in the estate depends on ODMS, and a shared client that drags a dependency tree into each of their resolvers does not get shared — it gets copy-pasted, and then there are five subtly different clients. If httpx is already installed it is used instead, purely for connection pooling; nothing else changes and nothing requires it.

Install

pip install ./clients/odms            # or: pip install ./clients/odms[pooled]

Or vendor it: the odms/ package is four files and imports nothing outside the standard library.

Use

from odms import OdmsClient

with OdmsClient("https://odms.tmtc.yksa.space", token=TOKEN) as odms:
    element = odms.latest("iss")            # newest OMM, by epoch
    print(element["tle"])

    past = odms.at("iss", "2026-04-01T00:00:00Z")
    span = odms.history("iss", start, end, limit=500)

    xml  = odms.download(internal_id="iss", format="omm_xml")
    oem  = odms.oem("iss", start, stop, step_s=60)      # token

base_url is the site root, not the API prefix. A token is optional: without one you get the public catalogue and every read-only endpoint, which is what most consumers need. With one you also see non-public satellites and can reach the gated endpoints — OEM generation, message push, frame transforms, TLE fitting, bulk propagation.

Methods return parsed JSON (dict/list) or, for the rendered formats, the body as text. Deliberately not model classes: ODMS adds fields as the catalogue grows, and mapping them onto fixed classes turns each addition into a client release.

Endpoints

Method What
query(format=…, **filters) The one element-search endpoint. Everything else below is a shortcut over it.
latest(key) / at(key, when) / history(key, start, end) The three questions worth asking of an element history. None / [] when there is nothing.
download(format=…, **filters) TLE text, OMM XML, CSV, KVN, or a converted element set.
satellites() / satellite(key) / find(key) The catalogue. find returns None where satellite raises.
sources() Configured public sources with their last-run status.
opm(key) / oem(key, start, stop) Generate CCSDS messages. OEM needs a token.
element_sets(key) / state(key, frame) Converted element sets, and a state in a named frame.
messages(key) / message(id) / push_message(text) Stored external OPM/OEM. Push needs a token.
transform(frame, …) / tle_from_oem(…) / propagate(key, timestamps) The gated compute endpoints.
decay_runs(key) / decay_run(id) / decay_latest(key) Re-entry forecasts. decay_latest returns None when there is no forecast — a normal state, not an error.

key is resolved the way ODMS resolves it: internal id, then NORAD catalog number, then COSPAR designator.

Errors

Everything inherits OdmsError, so a caller that only cares whether ODMS answered can catch that one.

Exception Meaning
TransportError Never reached the server: DNS, TCP, TLS, read timeout.
NotFound No such object — or a non-public one seen anonymously. Indistinguishable on purpose.
Unauthorized The endpoint is token-gated and the token was missing or invalid.
RateLimited Per-IP hourly budget spent, after the automatic retries. Carries retry_after.
ServiceUnavailable ODMS or its propagation sidecar is busy or down.
HTTPError Anything else 4xx/5xx. Carries status, body, detail.

Retries

429, 502, 503 and 504 are retried with exponential backoff and jitter, honouring Retry-After. 500 is not: it means ODMS took the request and broke on it, and repeating it breaks it again.

Transport failures are retried for reads only. A POST whose outcome is unknown is not repeated — push_message that timed out may well have stored the message, and a retry would store a second copy. If you need to recover one, check messages() and push again yourself.

Configuration

OdmsClient(
    base_url,
    token=None,          # bearer token, from Core > API tokens in the admin
    timeout_s=30.0,
    max_retries=3,
    user_agent="odms-client/1.0",
    session=None,        # pass your own httpx.Client to share a pool
)

The API prefix and the element-query path are class attributes (api_prefix, query_path), so a deployment mounted elsewhere — or one still serving the older tle/query/ alias — is reachable without subclassing.

Thread-safe. Not fork-safe: build one client per process.