Initial commit
This commit is contained in:
commit
9c0bd4bf2c
8 changed files with 1325 additions and 0 deletions
155
.gitignore
vendored
Normal file
155
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
108
README.md
Normal file
108
README.md
Normal file
|
|
@ -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.
|
||||||
46
odms/__init__.py
Normal file
46
odms/__init__.py
Normal file
|
|
@ -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"
|
||||||
252
odms/_http.py
Normal file
252
odms/_http.py
Normal file
|
|
@ -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)
|
||||||
356
odms/client.py
Normal file
356
odms/client.py
Normal file
|
|
@ -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)
|
||||||
89
odms/errors.py
Normal file
89
odms/errors.py
Normal file
|
|
@ -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 ""
|
||||||
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
|
|
@ -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*"]
|
||||||
298
tests/test_client.py
Normal file
298
tests/test_client.py
Normal file
|
|
@ -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, "<html>Bad Gateway</html>", {"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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue