commit d71d560d29290b29bf26addfeb29cc5b092f6bdf Author: ThePetrovich Date: Tue Aug 18 22:04:58 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e70718 --- /dev/null +++ b/.gitignore @@ -0,0 +1,155 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +pytestdebug.log + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +doc/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +pythonenv* + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# profiling data +.prof + +# Db and static files +*.sqlite3 +/media +/static +/postgres +/EXAMPLE_* + +# Docker +docker-compose.override.yml +docker-compose.override + +# End of https://www.toptal.com/developers/gitignore/api/python diff --git a/README.md b/README.md new file mode 100644 index 0000000..2400ce4 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# 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. diff --git a/odms/__init__.py b/odms/__init__.py new file mode 100644 index 0000000..efa9ee7 --- /dev/null +++ b/odms/__init__.py @@ -0,0 +1,46 @@ +"""Client for the ODMS orbital-data API (``odms.tmtc.yksa.space``). + +Standard library only. Uses ``httpx`` for connection pooling if it happens to be +installed, and works identically without it. + +:: + + from odms import OdmsClient + + with OdmsClient("https://odms.tmtc.yksa.space") as odms: + element = odms.latest("iss") + print(element["tle"]) + +See :class:`~odms.client.OdmsClient` for the endpoint methods and +:mod:`odms.errors` for what they raise. +""" + +from ._http import Response, Transport +from .client import FILTERS, FORMATS, MODES, OdmsClient +from .errors import ( + HTTPError, + NotFound, + OdmsError, + RateLimited, + ServiceUnavailable, + TransportError, + Unauthorized, +) + +__all__ = [ + "FILTERS", + "FORMATS", + "MODES", + "HTTPError", + "NotFound", + "OdmsClient", + "OdmsError", + "RateLimited", + "Response", + "ServiceUnavailable", + "Transport", + "TransportError", + "Unauthorized", +] + +__version__ = "1.0.0" diff --git a/odms/_http.py b/odms/_http.py new file mode 100644 index 0000000..b8ad0fd --- /dev/null +++ b/odms/_http.py @@ -0,0 +1,252 @@ +"""The transport underneath the client: one request, retried sensibly. + +Standard library only. "No dependencies" is the difference between a service +adopting this client and copy-pasting a ``requests`` call it then maintains +badly. If ``httpx`` is installed it is used instead, purely for connection +pooling -- a caller pulling a thousand elements pays a TLS handshake per request +on urllib. Nothing else differs and nothing requires it. +""" + +from __future__ import annotations + +import json as _json +import random +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +from .errors import ( + HTTPError, + NotFound, + RateLimited, + ServiceUnavailable, + TransportError, + Unauthorized, +) + +#: 429 and 503 are "not now"; 502/504 are a proxy in front. Not 500: that means +#: ODMS took the request and broke on it, and repeating it breaks it again. +RETRY_STATUSES = frozenset({429, 502, 503, 504}) + +DEFAULT_TIMEOUT_S = 30.0 +DEFAULT_MAX_RETRIES = 3 +#: First backoff step, doubled per attempt and jittered. +DEFAULT_BACKOFF_S = 0.5 +#: Past this, fail fast and let the caller's scheduler decide rather than +#: holding a worker for an unbounded stretch. +MAX_RETRY_AFTER_S = 120.0 + + +class Response: + """What came back: status, headers, and the body as bytes.""" + + __slots__ = ("status", "headers", "body", "url") + + def __init__(self, status: int, headers: dict, body: bytes, url: str): + self.status = status + # Header lookup is case-insensitive on the wire; normalise once. + self.headers = {k.lower(): v for k, v in headers.items()} + self.body = body + self.url = url + + @property + def text(self) -> str: + charset = "utf-8" + content_type = self.headers.get("content-type", "") + if "charset=" in content_type: + charset = content_type.split("charset=", 1)[1].split(";")[0].strip() + return self.body.decode(charset, "replace") + + def json(self) -> Any: + try: + return _json.loads(self.text) + except ValueError as exc: + raise HTTPError( + self.status, self.url, self.text, + f"expected JSON, got {self.headers.get('content-type', 'no content type')}", + ) from exc + + +def _httpx_session(): + """A pooled ``httpx.Client``, or ``None`` when httpx is not installed.""" + try: + import httpx + except ImportError: + return None + return httpx.Client(follow_redirects=True) + + +def _retry_after(headers: dict) -> float | None: + """Retry-After in seconds, delta-seconds form only. + + The HTTP-date form is legal but rare here, and mis-parsing it into a very + long sleep is worse than falling back to our own backoff. + """ + raw = headers.get("retry-after") + if not raw: + return None + try: + return max(0.0, float(raw)) + except (TypeError, ValueError): + return None + + +def _raise_for_status(response: Response) -> None: + if response.status < 400: + return + args = (response.status, response.url, response.text) + if response.status == 404: + raise NotFound(*args) + if response.status in (401, 403): + raise Unauthorized(*args) + if response.status == 429: + raise RateLimited(*args, retry_after=_retry_after(response.headers)) + if response.status in (502, 503, 504): + raise ServiceUnavailable(*args) + raise HTTPError(*args) + + +class Transport: + """Issues requests, retries the ones worth retrying, and raises the rest. + + The policy is asymmetric on purpose. A GET that died mid-flight can be + repeated; a POST cannot -- ``messages/push/`` that timed out may already have + stored the message. So writes retry only on statuses that *prove* the request + was not acted on, never on a transport failure of unknown outcome. + """ + + def __init__(self, base_url: str, *, token: str | None = None, + timeout_s: float = DEFAULT_TIMEOUT_S, + max_retries: int = DEFAULT_MAX_RETRIES, + backoff_s: float = DEFAULT_BACKOFF_S, + user_agent: str = "odms-client/1.0", + session: Any = None): + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout_s = timeout_s + self.max_retries = max_retries + self.backoff_s = backoff_s + self.user_agent = user_agent + self._session = session if session is not None else _httpx_session() + + # -- lifecycle ---------------------------------------------------------- + + def close(self) -> None: + if self._session is not None and hasattr(self._session, "close"): + self._session.close() + + def __enter__(self): + return self + + def __exit__(self, *_exc): + self.close() + + # -- request ------------------------------------------------------------ + + def request(self, method: str, path: str, *, params: dict | None = None, + json: Any = None, data: bytes | None = None, + content_type: str | None = None, + headers: dict | None = None, + timeout_s: float | None = None) -> Response: + url = self._url(path, params) + body, request_headers = self._prepare(json, data, content_type, headers) + idempotent = method.upper() in ("GET", "HEAD") + timeout = self.timeout_s if timeout_s is None else timeout_s + + attempt = 0 + while True: + try: + response = self._send(method, url, body, request_headers, timeout) + except TransportError: + # Unknown outcome. Safe to repeat only if the call has no effect. + if idempotent and attempt < self.max_retries: + self._sleep(attempt, None) + attempt += 1 + continue + raise + if response.status in RETRY_STATUSES and attempt < self.max_retries: + self._sleep(attempt, _retry_after(response.headers)) + attempt += 1 + continue + _raise_for_status(response) + return response + + def _url(self, path: str, params: dict | None) -> str: + url = f"{self.base_url}/{path.lstrip('/')}" + if not params: + return url + # Drop unset filters rather than sending `?source=None`, and expand a + # list into repeated keys, which is how ODMS reads multi-valued filters. + pairs: list[tuple[str, str]] = [] + for key, value in params.items(): + if value is None: + continue + if isinstance(value, (list, tuple)): + pairs.extend((key, _stringify(v)) for v in value if v is not None) + else: + pairs.append((key, _stringify(value))) + return f"{url}?{urllib.parse.urlencode(pairs)}" if pairs else url + + def _prepare(self, json_body, data, content_type, extra_headers): + headers = { + "User-Agent": self.user_agent, + "Accept": "application/json, text/plain, */*", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + body = data + if json_body is not None: + body = _json.dumps(json_body).encode("utf-8") + headers["Content-Type"] = "application/json" + if content_type: + headers["Content-Type"] = content_type + if extra_headers: + headers.update(extra_headers) + return body, headers + + def _send(self, method, url, body, headers, timeout) -> Response: + if self._session is not None: + return self._send_httpx(method, url, body, headers, timeout) + return self._send_urllib(method, url, body, headers, timeout) + + def _send_httpx(self, method, url, body, headers, timeout) -> Response: + import httpx + + try: + reply = self._session.request( + method, url, content=body, headers=headers, timeout=timeout, + ) + except httpx.HTTPError as exc: + raise TransportError(f"{method} {url} failed: {exc}") from exc + return Response(reply.status_code, dict(reply.headers), reply.content, url) + + def _send_urllib(self, method, url, body, headers, timeout) -> Response: + request = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=timeout) as reply: + return Response(reply.status, dict(reply.headers), reply.read(), url) + except urllib.error.HTTPError as exc: + # urllib raises on 4xx/5xx. That is a real response with a real body, + # so hand it back and let the retry/raise logic classify it. + return Response(exc.code, dict(exc.headers or {}), exc.read(), url) + except (urllib.error.URLError, OSError) as exc: + raise TransportError(f"{method} {url} failed: {exc}") from exc + + def _sleep(self, attempt: int, retry_after: float | None) -> None: + if retry_after is not None: + time.sleep(min(retry_after, MAX_RETRY_AFTER_S)) + return + # Jittered: a fleet that hits the limit together must not wake together. + delay = self.backoff_s * (2 ** attempt) + time.sleep(delay * (0.5 + random.random())) + + +def _stringify(value) -> str: + if isinstance(value, bool): + # Django reads "True"/"False", not Python's lowercased str(bool). + return "True" if value else "False" + if hasattr(value, "isoformat"): + return value.isoformat() + return str(value) diff --git a/odms/client.py b/odms/client.py new file mode 100644 index 0000000..81c3872 --- /dev/null +++ b/odms/client.py @@ -0,0 +1,356 @@ +"""The ODMS API client. + +Methods return parsed JSON, or the body as text for the rendered formats -- +deliberately not model classes. ODMS adds fields as the catalogue grows, and a +client that maps them onto fixed classes turns each addition into a release. + +Every method is one request. No caching, no implicit pagination, no background +refresh: those are policy, they differ per service, and hiding them here is how +a shared client becomes a shared problem. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from ._http import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_S, Transport +from .errors import NotFound + +#: Rendering formats ``query()``/``download()`` accept. ``json`` is the parsed +#: default; the rest come back as text. +FORMATS = ( + "json", "tle", "tle_alpha5", "omm_xml", "csv", "kvn", + # CCSDS SANA element sets, propagated then converted, served as CSV. + "cartpv", "keplerian", "keplerianmean", "equinoctial", "geodetic", "adbarv", +) + +#: How a query selects elements in time. +MODES = ("latest", "at", "range") + +#: Filters ``query()`` forwards. Anything else raises rather than being silently +#: dropped -- a typo'd filter that quietly returns the whole catalogue is a much +#: worse failure than an exception. +FILTERS = ( + "q", "norad", "cospar", "internal_id", "name", "source", "limit", + "mode", "datetime", "start", "end", +) + + +class OdmsClient: + """Talks to an ODMS deployment. + + ``base_url`` is the site root (``https://odms.tmtc.yksa.space``), not the API + prefix -- the paths are this client's business. + + ``token`` is optional. Without one you see the public catalogue and the + read-only endpoints, which is all most consumers need. With one you also see + satellites flagged non-public, and you can reach the gated endpoints: OEM + generation, message push, frame transforms, TLE fitting and bulk propagation. + + Safe to share between threads when httpx is installed (its client is + thread-safe) and when it is not (urllib opens a connection per call). Not + safe to share across a fork -- build one per process. + + :: + + with OdmsClient("https://odms.tmtc.yksa.space", token=TOKEN) as odms: + iss = odms.latest("iss") + oem = odms.oem("iss", start, stop, step_s=60) + """ + + #: API prefix, and the element-query path under it. Both are attributes so a + #: deployment mounted elsewhere, or one still serving the older + #: ``tle/query/`` alias, can be reached without subclassing anything. + api_prefix = "api/v1" + query_path = "omm/query/" + + def __init__(self, base_url: str, *, token: str | None = None, + timeout_s: float = DEFAULT_TIMEOUT_S, + max_retries: int = DEFAULT_MAX_RETRIES, + user_agent: str = "odms-client/1.0", + session: Any = None): + self._transport = Transport( + base_url, token=token, timeout_s=timeout_s, max_retries=max_retries, + user_agent=user_agent, session=session, + ) + + # -- lifecycle ---------------------------------------------------------- + + def close(self) -> None: + self._transport.close() + + def __enter__(self): + return self + + def __exit__(self, *_exc): + self.close() + + # -- elements ----------------------------------------------------------- + + def query(self, *, format: str = "json", **filters) -> Any: + """Search the element catalogue. The one endpoint behind every lookup. + + Filters select *which* elements; ``format`` selects how they come back. + ``json`` (the default) returns ``{"count": n, "results": [...]}``; + anything else returns the rendered body as text. + + ``mode`` is ``latest`` (newest per satellite, by epoch not by download + time), ``at`` (with ``datetime=``: the newest element at or before it), + or ``range`` (with ``start=``/``end=``). + """ + params = self._filters(filters) + params["format"] = _one_of("format", format, FORMATS) + response = self._transport.request( + "GET", f"{self.api_prefix}/{self.query_path}", params=params, + ) + return response.json() if format == "json" else response.text + + def latest(self, key: str, **filters) -> dict | None: + """The newest element for one satellite, or ``None`` if it has none. + + ``key`` is whatever identifies the object: internal id, NORAD number or + COSPAR designator. It is matched the same way ``satellite()`` matches. + """ + results = self.query(q=key, mode="latest", limit=1, **filters)["results"] + return results[0] if results else None + + def at(self, key: str, when, **filters) -> dict | None: + """The element in force at ``when``: newest with ``epoch <= when``.""" + results = self.query( + q=key, mode="at", datetime=when, limit=1, **filters, + )["results"] + return results[0] if results else None + + def history(self, key: str, start, end, *, limit: int = 100, **filters) -> list[dict]: + """Every element for ``key`` between ``start`` and ``end``, oldest first. + + ``limit`` is capped at 500 server-side. This does not paginate for you: + a caller pulling a long history should walk it in windows, so that the + memory and the request count stay its own decision. + """ + return self.query( + q=key, mode="range", start=start, end=end, limit=limit, **filters, + )["results"] + + def download(self, *, format: str, filename: str | None = None, **filters) -> str: + """A rendered element document -- TLE text, OMM XML, CSV, KVN. + + ``filename`` asks ODMS to serve it as an attachment. It changes only the + Content-Disposition, so it matters when you are proxying the response to + a browser and not otherwise. + """ + params = self._filters(filters) + params["format"] = _one_of("format", format, FORMATS) + params["filename"] = filename + return self._transport.request( + "GET", f"{self.api_prefix}/{self.query_path}", params=params, + ).text + + # -- catalogue ---------------------------------------------------------- + + def satellites(self) -> list[dict]: + """Tracked satellites. Anonymous sees public ones; a token sees all.""" + return self._get("satellites/")["results"] + + def satellite(self, key: str) -> dict: + """One satellite, by internal id, NORAD number or COSPAR designator. + + Raises :class:`~odms.errors.NotFound` for an unknown object *and* for a + non-public one seen anonymously -- indistinguishable on purpose. + """ + return self._get(f"satellites/{key}/") + + def find(self, key: str) -> dict | None: + """:meth:`satellite`, but ``None`` instead of raising when absent.""" + try: + return self.satellite(key) + except NotFound: + return None + + def sources(self) -> list[dict]: + """Configured public data sources, with last-run status and counters.""" + return self._get("sources/")["results"] + + # -- CCSDS messages ----------------------------------------------------- + + def opm(self, key: str, *, at=None, format: str = "kvn", + ref_frame: str | None = None) -> str: + """Generate an OPM: single-epoch state plus osculating Keplerian. + + ``ref_frame`` defaults to TEME. A non-inertial frame (ITRF) yields a + Cartesian-only OPM, because osculating elements are inertial-only. + Frames beyond TEME/ITRF need ODMS on its Orekit backend. + """ + return self._get_text("orbital/opm/", { + "key": key, "at": at, "format": format, "ref_frame": ref_frame, + }) + + def oem(self, key: str, start, stop, *, step_s: int = 60, + format: str = "kvn", ref_frame: str | None = None) -> str: + """Generate an OEM over a span. **Token required.** + + Gated because it is the expensive one: the span is capped server-side + (7 days, 10 000 points by default) and exceeding either is a 400, not a + truncation. + """ + return self._get_text("orbital/oem/", { + "key": key, "start": start, "stop": stop, "step": step_s, + "format": format, "ref_frame": ref_frame, + }) + + def element_sets(self, key: str, *, at=None) -> dict: + """Every CCSDS element set for one satellite at one instant.""" + return self._get("orbital/elements/", {"key": key, "at": at}) + + def state(self, key: str, frame: str, *, at=None) -> dict: + """Cartesian state in ``frame``. Non-TEME/ITRF needs the Orekit backend.""" + return self._get("orbital/frames/", {"key": key, "frame": frame, "at": at}) + + def messages(self, key: str, *, limit: int = 50, offset: int = 0) -> dict: + """Stored OPM/OEM for a satellite, newest first. Returns the page dict, + with its ``count``, so a caller can decide whether to walk further.""" + return self._get("orbital/messages/", { + "key": key, "limit": limit, "offset": offset, + }) + + def message(self, message_id: int) -> dict: + """One stored message: metadata, parsed states and the raw text.""" + return self._get(f"orbital/messages/{message_id}/") + + def push_message(self, text: str, *, key: str | None = None, + is_public: bool | None = None) -> dict: + """Store an externally produced OPM/OEM. **Token required.** + + With ``key`` omitted the satellite is resolved from the message's own + ``OBJECT_ID``. + + Not idempotent, and treated as such: a push that fails in transport is + never retried automatically, because it may well have been stored. Retry + it yourself only once you have checked :meth:`messages`. + """ + payload: dict = {"text": text} + if key is not None: + payload["key"] = key + if is_public is not None: + payload["is_public"] = is_public + return self._post("orbital/messages/push/", payload) + + def transform(self, target_frame: str, *, text: str | None = None, + message_id: int | None = None, key: str | None = None, + format: str = "kvn") -> str: + """Re-express an OPM/OEM in another frame. **Token required.** + + The source is exactly one of: inline ``text``, a stored ``message_id``, + or ``key`` for that satellite's latest stored message. + """ + payload = _exactly_one( + {"text": text, "message_id": message_id, "key": key}, + "transform() needs one of text=, message_id= or key=", + ) + payload.update({"target_frame": target_frame, "format": format}) + return self._transport.request( + "POST", f"{self.api_prefix}/orbital/transform/", json=payload, + ).text + + def tle_from_oem(self, *, message_id: int | None = None, + key: str | None = None, text: str | None = None) -> dict: + """Least-squares fit a TLE to an OEM's states. **Token required.** + + Returns the fitted lines with the fit's ``rms`` and ``sample_count`` -- + check them. A fit that converged on a bad ephemeris still returns lines. + """ + payload = _exactly_one( + {"text": text, "message_id": message_id, "key": key}, + "tle_from_oem() needs one of text=, message_id= or key=", + ) + return self._post("orbital/tle-from-oem/", payload) + + def propagate(self, key: str, timestamps: Iterable, *, + frame: str = "TEME") -> dict: + """Propagate one satellite to many instants. **Token required.** + + Each point uses the element whose epoch is *closest* to it, so this is + the right call for a long historical span and the wrong one if you need + every point to come from a single element set. + + Capped server-side (5000 timestamps by default). A point that fails + carries an ``error`` instead of vectors rather than failing the batch. + """ + return self._post("orbital/propagate/", { + "key": key, + "timestamps": [_iso(t) for t in timestamps], + "frame": frame, + }) + + # -- decay forecasts ---------------------------------------------------- + + def decay_runs(self, key: str, *, kind: str | None = None) -> list[dict]: + """Forecast summaries for a satellite, without their trajectories. + + ``kind`` is ``latest`` (the rolling forecast, one per satellite) or + ``monthly`` (frozen snapshots anchored to the 1st, kept so past + forecasts can be read against what the elements actually did). + """ + return self._get("decay/runs/", {"key": key, "kind": kind})["results"] + + def decay_run(self, run_id: int) -> dict: + """One run with its full-resolution trajectory under ``series.runs[]``.""" + return self._get(f"decay/runs/{run_id}/") + + def decay_latest(self, key: str) -> dict | None: + """The current rolling forecast, or ``None`` if there is not one yet. + + No forecast is a normal state, not an error: an object with neither + recorded physical properties nor a fittable element history is skipped + deliberately rather than predicted from a coefficient nobody can justify. + """ + try: + return self._get("decay/latest/", {"key": key}) + except NotFound: + return None + + # -- internals ---------------------------------------------------------- + + def _get(self, path: str, params: dict | None = None) -> Any: + return self._transport.request( + "GET", f"{self.api_prefix}/{path}", params=params, + ).json() + + def _get_text(self, path: str, params: dict | None = None) -> str: + return self._transport.request( + "GET", f"{self.api_prefix}/{path}", params=params, + ).text + + def _post(self, path: str, payload: Any) -> Any: + return self._transport.request( + "POST", f"{self.api_prefix}/{path}", json=payload, + ).json() + + @staticmethod + def _filters(filters: dict) -> dict: + unknown = sorted(set(filters) - set(FILTERS)) + if unknown: + raise TypeError( + f"unknown filter(s) {', '.join(unknown)}; " + f"expected any of {', '.join(FILTERS)}" + ) + if "mode" in filters: + _one_of("mode", filters["mode"], MODES) + return dict(filters) + + +def _one_of(name: str, value: str, allowed) -> str: + if value not in allowed: + raise ValueError(f"{name}={value!r}; expected one of {', '.join(allowed)}") + return value + + +def _exactly_one(candidates: dict, message: str) -> dict: + given = {k: v for k, v in candidates.items() if v is not None} + if len(given) != 1: + raise TypeError(message) + return given + + +def _iso(value) -> str: + return value.isoformat() if hasattr(value, "isoformat") else str(value) diff --git a/odms/errors.py b/odms/errors.py new file mode 100644 index 0000000..f5ec659 --- /dev/null +++ b/odms/errors.py @@ -0,0 +1,89 @@ +"""Exceptions raised by the ODMS client. + +The split is by *what the caller should do*, not by HTTP status. Everything +inherits :class:`OdmsError`, so a service that only wants "ODMS did not answer" +can catch that one and be done. +""" + +from __future__ import annotations + + +class OdmsError(Exception): + """Base class for every failure this client raises.""" + + +class TransportError(OdmsError): + """The request never got an answer: DNS, TCP, TLS, or a read timeout. + + Distinguished from an HTTP error because a request that never arrived can be + retried safely even when it is not idempotent -- and one that may have + arrived cannot. The client only auto-retries these for reads. + """ + + +class HTTPError(OdmsError): + """ODMS answered with a status the caller did not ask for.""" + + def __init__(self, status: int, url: str, body: str = "", detail: str = ""): + self.status = status + self.url = url + self.body = body + self.detail = detail or _detail_from(body) or body[:200] + super().__init__(f"HTTP {status} from {url}" + (f": {self.detail}" if self.detail else "")) + + +class NotFound(HTTPError): + """No such satellite, run or message. + + ODMS returns this for a satellite that exists but is not public, too -- an + anonymous caller cannot tell the two apart, which is the intent. If you + expected a private object, check the token. + """ + + +class Unauthorized(HTTPError): + """The endpoint needs a token, and the one presented was missing or invalid. + + ODMS's read surface is anonymous; only the expensive and the writing + endpoints (OEM generation, message push, transform, TLE fitting, bulk + propagation) are gated. + """ + + +class RateLimited(HTTPError): + """The per-IP hourly budget is spent. + + ``retry_after`` is the server's own advice in seconds when it gave one. The + client retries these automatically up to ``max_retries``; seeing this + exception means the budget is still spent after those attempts, so the fix + is to slow down or hold a token, not to retry harder. + """ + + def __init__(self, status: int, url: str, body: str = "", + detail: str = "", retry_after: float | None = None): + self.retry_after = retry_after + super().__init__(status, url, body, detail) + + +class ServiceUnavailable(HTTPError): + """ODMS (or something it depends on) is busy or down. + + Retried automatically. A persistent one usually means the propagation + sidecar behind ODMS is saturated rather than ODMS itself being unwell. + """ + + +def _detail_from(body: str) -> str: + """The error message out of a JSON body, if that is what this is.""" + import json + + try: + parsed = json.loads(body) + except (ValueError, TypeError): + return "" + if isinstance(parsed, dict): + for key in ("detail", "error", "message"): + value = parsed.get(key) + if isinstance(value, str) and value: + return value + return "" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a927205 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "odms-client" +version = "1.0.0" +description = "Client for the ODMS orbital-data API" +readme = "README.md" +requires-python = ">=3.10" +# Intentionally empty. Every service in the estate depends on ODMS, so this +# client has to be installable next to any of them without dragging a +# dependency tree into the resolver. httpx is used when present; see _http.py. +dependencies = [] + +[project.optional-dependencies] +# Connection pooling only. Nothing here is required for correctness. +pooled = ["httpx>=0.24"] + +[tool.setuptools.packages.find] +include = ["odms*"] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..facec15 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,298 @@ +"""Tests for the ODMS client, against a real HTTP server on localhost. + +No mocking library and no monkeypatched sockets: the client's whole job is to +speak HTTP correctly, and a fake that intercepts above the socket cannot catch a +malformed query string, a mishandled 429, or a retry that silently repeats a +POST. ``http.server`` is in the standard library and costs milliseconds. +""" + +from __future__ import annotations + +import json +import sys +import threading +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from odms import NotFound, OdmsClient, RateLimited, Unauthorized # noqa: E402 +from odms.errors import HTTPError, ServiceUnavailable # noqa: E402 + + +class Recorder: + """The scripted responses, and what the client actually asked for.""" + + def __init__(self): + self.requests: list[dict] = [] + self.responses: list[tuple] = [] + + def reply(self, status: int, body="", headers=None): + """Queue one response. The last queued one repeats once used up.""" + if isinstance(body, (dict, list)): + body = json.dumps(body) + headers = {"Content-Type": "application/json", **(headers or {})} + self.responses.append((status, body, headers or {})) + return self + + def next_response(self): + if len(self.responses) > 1: + return self.responses.pop(0) + return self.responses[0] if self.responses else (200, "{}", {}) + + +@pytest.fixture(scope="module") +def _server(): + """One server for the module. Binding a socket and starting a thread costs + most of a second here, and 22 of them is a suite nobody runs.""" + recorder = Recorder() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass # the test output is not a web server log + + def _handle(self): + parsed = urlparse(self.path) + length = int(self.headers.get("Content-Length") or 0) + recorder.requests.append({ + "method": self.command, + "path": parsed.path, + "query": parse_qs(parsed.query), + "body": self.rfile.read(length).decode() if length else "", + "headers": dict(self.headers), + }) + status, body, headers = recorder.next_response() + payload = body.encode() + self.send_response(status) + for key, value in headers.items(): + self.send_header(key, value) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + do_GET = do_POST = _handle + + class FastServer(HTTPServer): + def server_bind(self): + # HTTPServer.server_bind resolves its own FQDN for the Server header, + # which costs ~half a second per bind on Windows. Nothing under test + # reads it. + super(HTTPServer, self).server_bind() + self.server_name = "127.0.0.1" + self.server_port = self.socket.getsockname()[1] + + httpd = FastServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=httpd.serve_forever, kwargs={"poll_interval": 0.02}) + thread.daemon = True + thread.start() + recorder.url = f"http://127.0.0.1:{httpd.server_port}" + try: + yield recorder + finally: + httpd.shutdown() + httpd.server_close() + + +@pytest.fixture +def server(_server): + _server.requests.clear() + _server.responses.clear() + return _server + + +@pytest.fixture +def client(server): + # Retries with no backoff: the retry *policy* is what is under test, and + # sleeping through it would only make the suite slow. + with OdmsClient(server.url, token="t0ken", max_retries=2) as odms: + odms._transport.backoff_s = 0.0 + # urllib is the floor every consumer gets; httpx, if installed here, is + # an optimisation the tests should not accidentally become dependent on. + odms._transport._session = None + yield odms + + +# --- request shape ---------------------------------------------------------- + + +def test_the_token_is_sent_as_a_bearer_header(server, client): + server.reply(200, {"results": []}) + client.satellites() + assert server.requests[0]["headers"]["Authorization"] == "Bearer t0ken" + + +def test_unset_filters_are_omitted_rather_than_sent_as_none(server, client): + """A `?source=None` filter matches no source and returns nothing, silently.""" + server.reply(200, {"count": 0, "results": []}) + client.query(internal_id="iss", source=None, name=None) + + query = server.requests[0]["query"] + assert query == {"internal_id": ["iss"], "format": ["json"]} + + +def test_datetimes_are_sent_as_iso_8601(server, client): + server.reply(200, {"count": 0, "results": []}) + client.at("iss", datetime(2026, 4, 1, tzinfo=timezone.utc)) + + assert server.requests[0]["query"]["datetime"] == ["2026-04-01T00:00:00+00:00"] + + +def test_booleans_are_sent_the_way_django_reads_them(server, client): + """Python's str(True) is "True"; str(False) is "False" -- but a lowercased + "false" is truthy to a naive parser, which is the bug this pins.""" + server.reply(201, {"id": 1}) + client.push_message("CCSDS_OPM_VERS = 3.0", key="iss", is_public=False) + + assert json.loads(server.requests[0]["body"])["is_public"] is False + + +def test_an_unknown_filter_raises_instead_of_being_dropped(client): + """A typo'd filter that quietly returns the whole catalogue is worse than + an exception -- the caller gets plausible, wrong data.""" + with pytest.raises(TypeError, match="norrad"): + client.query(norrad="25544") + + +def test_an_unknown_format_raises_before_the_request(server, client): + with pytest.raises(ValueError, match="format"): + client.query(format="parquet") + assert server.requests == [] + + +def test_transform_requires_exactly_one_source(client): + with pytest.raises(TypeError): + client.transform("ITRF") + with pytest.raises(TypeError): + client.transform("ITRF", key="iss", message_id=4) + + +# --- responses -------------------------------------------------------------- + + +def test_rendered_formats_come_back_as_text_not_json(server, client): + lines = "ISS (ZARYA)\n1 25544U ...\n2 25544 ..." + server.reply(200, lines, {"Content-Type": "text/plain"}) + + assert client.download(internal_id="iss", format="tle") == lines + + +def test_latest_returns_none_when_the_satellite_has_no_elements(server, client): + server.reply(200, {"count": 0, "results": []}) + assert client.latest("iss") is None + + +def test_decay_latest_returns_none_when_there_is_no_forecast(server, client): + """No forecast is a normal state: an object with no fittable history is + skipped deliberately rather than predicted from a coefficient nobody can + justify. Callers must not have to catch an exception for the normal case.""" + server.reply(404, {"detail": "no forecast"}) + assert client.decay_latest("iss") is None + + +def test_find_returns_none_where_satellite_raises(server, client): + server.reply(404, {"detail": "not found"}) + assert client.find("nope") is None + with pytest.raises(NotFound): + client.satellite("nope") + + +# --- errors ----------------------------------------------------------------- + + +@pytest.mark.parametrize("status,expected", [ + (401, Unauthorized), + (403, Unauthorized), + (404, NotFound), + (400, HTTPError), +]) +def test_statuses_map_onto_actionable_exceptions(server, client, status, expected): + server.reply(status, {"detail": "nope"}) + with pytest.raises(expected): + client.satellite("iss") + + +def test_the_error_detail_is_lifted_out_of_a_json_body(server, client): + server.reply(400, {"detail": "end must be >= start"}) + with pytest.raises(HTTPError) as caught: + client.satellite("iss") + assert caught.value.detail == "end must be >= start" + assert caught.value.status == 400 + + +def test_a_non_json_error_body_still_produces_a_usable_message(server, client): + server.reply(502, "Bad Gateway", {"Content-Type": "text/html"}) + with pytest.raises(ServiceUnavailable) as caught: + client.satellite("iss") + assert "Bad Gateway" in str(caught.value) + + +# --- retries ---------------------------------------------------------------- + + +def test_a_429_is_retried_and_then_succeeds(server, client): + server.reply(429, "slow down", {"Retry-After": "0"}) + server.reply(200, {"results": [{"internal_id": "iss"}]}) + + assert client.satellites() == [{"internal_id": "iss"}] + assert len(server.requests) == 2 + + +def test_a_persistent_429_raises_with_the_servers_advice(server, client): + server.reply(429, "slow down", {"Retry-After": "0"}) + + with pytest.raises(RateLimited) as caught: + client.satellites() + assert caught.value.retry_after == 0 + assert len(server.requests) == 3 # the original plus max_retries + + +def test_a_500_is_not_retried(server, client): + """It means ODMS took the request and broke on it. Repeating it breaks it + again, and turns one alert into three.""" + server.reply(500, "boom") + + with pytest.raises(HTTPError): + client.satellites() + assert len(server.requests) == 1 + + +def test_a_503_is_retried_even_for_a_post(server, client): + """503 proves the request was not acted on, so repeating it cannot + duplicate anything -- unlike a transport failure.""" + server.reply(503, "busy") + server.reply(201, {"id": 7}) + + assert client.push_message("CCSDS_OPM_VERS = 3.0", key="iss") == {"id": 7} + assert len(server.requests) == 2 + + +def test_a_transport_failure_never_repeats_a_post(server): + """A push that died in flight may already have stored the message. Retrying + would store a second copy, and nothing downstream would notice.""" + from odms.errors import TransportError + + # Nothing is listening on this port, so every attempt fails in transport. + with OdmsClient("http://127.0.0.1:9", max_retries=3, timeout_s=0.5) as odms: + odms._transport.backoff_s = 0.0 + odms._transport._session = None + attempts = [] + original = odms._transport._send_urllib + + def counting(*args, **kwargs): + attempts.append(1) + return original(*args, **kwargs) + + odms._transport._send_urllib = counting + + with pytest.raises(TransportError): + odms.push_message("CCSDS_OPM_VERS = 3.0", key="iss") + assert len(attempts) == 1 + + attempts.clear() + with pytest.raises(TransportError): + odms.satellites() + assert len(attempts) == 4 # a read may be repeated: 1 + max_retries