# 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 ```bash 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 ```python 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 ```python 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.