Skip to content

Commit 43841a0

Browse files
feat(resolvers,wire): pluggable resolvers + wire-layer codec (step 4)
Stacks on the #6 dispatcher foundation. Adds the two layers a production endpoint migration needs alongside the dispatcher: mobilityapi/resolvers.py: - stub_resolver(registry) — explicit name->callable map for tests - pymeos_resolver() — production resolver that looks up functions in pymeos.functions; lazy- imports PyMEOS, raises ImportError with an actionable message when it's absent - default_resolver(prefer_pymeos=True) — production-first probe with a stub fallback that raises NotImplementedError on first call mobilityapi/wire.py: - WireCodec — keyed by encoding name (mfjson, text, wkb, hexwkb); decode wire-value to PyMEOS obj; encode PyMEOS obj back - stub_codec(decoders, encoders) — explicit-map constructor for tests - pymeos_codec() — production codec bridging to PyMEOS factory entry points - ENCODING_{MFJSON,TEXT,WKB,HEXWKB} — canonical encoding-name constants matching the catalog's x-meos-{decode,encode} fields mobilityapi/__init__.py re-exports all three names so endpoint migrations import from one module. 15 new unit tests (tests/test_resolvers.py + tests/test_wire.py), all passing locally. CI workflow's pytest job is extended to cover all three test files together. Production wiring lands when the first endpoint migrates: install pymeos, replace getattr(pymeos.functions, name) plumbing with default_resolver(), point WireCodec at pymeos_codec(). The intervening migration PRs are bounded ~50-100 LoC each.
1 parent 6bf5621 commit 43841a0

6 files changed

Lines changed: 416 additions & 8 deletions

File tree

.github/workflows/python.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
python -c "from resource.temporal_geom_query import distance, velocity, acceleration"
4242
4343
pytest-dispatcher:
44-
name: Dispatcher unit tests
44+
name: Dispatcher / resolvers / wire unit tests
4545
runs-on: ubuntu-latest
4646
steps:
4747
- uses: actions/checkout@v4
@@ -51,4 +51,4 @@ jobs:
5151
- name: Install pytest
5252
run: pip install --upgrade pip pytest
5353
- name: Run dispatcher tests
54-
run: python -m pytest tests/test_dispatcher.py -v
54+
run: python -m pytest tests/test_dispatcher.py tests/test_resolvers.py tests/test_wire.py -v

mobilityapi/__init__.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,29 @@
22
33
The MobilityAPI ingestion plan (docs/MEOS_API_INGESTION_PLAN.md) calls for
44
replacing the hand-written MEOS-dispatching endpoint modules with thin
5-
dispatchers driven by the vendored MEOS-API catalog. This package is the
6-
foundation: a `Dispatcher` class that loads the vendored catalog and exposes
7-
``dispatch(function_name, params) -> Any`` for every stateless-exposable
8-
MEOS function. Existing hand-written endpoints remain unchanged until they
9-
are migrated module-by-module in follow-up PRs.
5+
dispatchers driven by the vendored MEOS-API catalog. This package provides
6+
the three foundation pieces the migrating endpoints share:
7+
8+
- ``Dispatcher`` — catalog-driven function lookup + invocation.
9+
- ``resolvers`` — pick the MEOS function implementation
10+
(production: PyMEOS; tests: explicit stubs).
11+
- ``wire`` — decode HTTP wire values to PyMEOS objects;
12+
encode PyMEOS results back to wire values.
13+
14+
Existing hand-written endpoints remain unchanged until they are migrated
15+
module-by-module in follow-up PRs.
1016
"""
1117

1218
from .dispatcher import Dispatcher, FunctionSignature
19+
from .resolvers import stub_resolver, pymeos_resolver, default_resolver
20+
from .wire import (
21+
WireCodec, stub_codec, pymeos_codec,
22+
ENCODING_MFJSON, ENCODING_TEXT, ENCODING_WKB, ENCODING_HEXWKB,
23+
)
1324

14-
__all__ = ["Dispatcher", "FunctionSignature"]
25+
__all__ = [
26+
"Dispatcher", "FunctionSignature",
27+
"stub_resolver", "pymeos_resolver", "default_resolver",
28+
"WireCodec", "stub_codec", "pymeos_codec",
29+
"ENCODING_MFJSON", "ENCODING_TEXT", "ENCODING_WKB", "ENCODING_HEXWKB",
30+
]

mobilityapi/resolvers.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Resolvers — bridge ``Dispatcher`` to a concrete MEOS-function implementation.
2+
3+
A *resolver* is a callable ``name -> Python callable`` that the
4+
``Dispatcher`` uses to look up the MEOS function to invoke for a given
5+
catalog name. The resolver is the only place that knows *how* the
6+
binding actually calls into MEOS:
7+
8+
- **production** — ``pymeos_resolver()`` returns a resolver that looks
9+
up the function in ``pymeos.functions``; each call decodes the
10+
request's serialised parameters into PyMEOS objects, invokes the
11+
function, and returns the result.
12+
- **stub / test** — ``stub_resolver(registry)`` builds a resolver from
13+
an explicit ``{name: callable}`` mapping, so unit tests can verify
14+
the dispatch contract without a PyMEOS runtime.
15+
16+
The resolver layer is deliberately thin: every decode / encode decision
17+
that depends on the catalog's ``x-meos-decode`` / ``x-meos-encode``
18+
metadata lives in ``mobilityapi.wire``, not here. Resolvers just hand
19+
off the function pointer; ``wire`` does the actual byte-shuffling.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import Any, Callable
25+
26+
27+
def stub_resolver(registry: dict[str, Callable[..., Any]]) -> Callable[[str], Callable[..., Any]]:
28+
"""Build a resolver from an explicit name→callable registry.
29+
30+
Intended for unit tests:
31+
32+
resolver = stub_resolver({"tpoint_speed": lambda *, temp: ...})
33+
dispatcher = Dispatcher(resolver=resolver)
34+
"""
35+
def _resolve(name: str) -> Callable[..., Any]:
36+
if name not in registry:
37+
raise NotImplementedError(
38+
f"stub_resolver has no entry for `{name}`. "
39+
f"Available: {sorted(registry)}"
40+
)
41+
return registry[name]
42+
return _resolve
43+
44+
45+
def pymeos_resolver() -> Callable[[str], Callable[..., Any]]:
46+
"""Build a resolver that dispatches to ``pymeos.functions``.
47+
48+
Lazy-imports PyMEOS so MobilityAPI can be installed without it
49+
available (the import only fires the first time the resolver is
50+
actually called). Raises ``ImportError`` at call time if PyMEOS is
51+
not installed.
52+
53+
PyMEOS exposes the MEOS C API as a flat module of Python functions
54+
one-for-one with the C names — ``pymeos.functions.tpoint_speed``
55+
maps to ``tpoint_speed(temp: Temporal) -> Temporal`` in MEOS.
56+
"""
57+
def _resolve(name: str) -> Callable[..., Any]:
58+
try:
59+
import pymeos.functions as pmf # noqa: WPS433 - lazy import on purpose
60+
except ImportError as e:
61+
raise ImportError(
62+
"pymeos is not installed. MobilityAPI's pymeos_resolver "
63+
"requires the `pymeos` package on the Python path. "
64+
"Either add `pymeos>=1.4` to requirements.txt and pip "
65+
"install it, or use stub_resolver() for development."
66+
) from e
67+
try:
68+
return getattr(pmf, name)
69+
except AttributeError as e:
70+
raise AttributeError(
71+
f"pymeos.functions has no attribute `{name}`. "
72+
f"Either the catalog references a function not exposed "
73+
f"by the installed PyMEOS version, or PyMEOS is out of "
74+
f"sync with the vendored catalog (run `make vendor-meos-api` "
75+
f"and check requirements.txt's pymeos version)."
76+
) from e
77+
return _resolve
78+
79+
80+
def default_resolver(prefer_pymeos: bool = True) -> Callable[[str], Callable[..., Any]]:
81+
"""Pick the appropriate resolver based on what's actually available.
82+
83+
Tries PyMEOS first (production), then falls back to a stub resolver
84+
that explicitly raises ``NotImplementedError`` for every name. Useful
85+
as a Dispatcher default in production code that wants to fail fast
86+
if PyMEOS is missing, but doesn't want to import-error at startup.
87+
"""
88+
if prefer_pymeos:
89+
try:
90+
import pymeos.functions # noqa: F401 - probe
91+
return pymeos_resolver()
92+
except ImportError:
93+
pass
94+
95+
def _stub(name: str) -> Callable[..., Any]:
96+
def _raise(*_a, **_kw):
97+
raise NotImplementedError(
98+
f"No production resolver is wired in for `{name}`. "
99+
f"Either install pymeos, or supply an explicit "
100+
f"resolver=... to Dispatcher(...)."
101+
)
102+
return _raise
103+
return _stub

mobilityapi/wire.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Wire-layer codec — decode HTTP request bodies to PyMEOS objects, encode
2+
PyMEOS results back to HTTP response payloads.
3+
4+
The catalog (``vendor/meos-api/meos-idl.json``, after the enrichment pass)
5+
labels each parameter and the result with one of the supported encodings:
6+
7+
- ``mfjson`` — Moving Features JSON (the OGC API – Moving Features
8+
standard wire format for temporal trajectories).
9+
- ``text`` — EWKT (Extended Well-Known Text), the human-readable form.
10+
- ``wkb`` — EWKB (Extended Well-Known Binary), the wire-compact form.
11+
12+
``Wire`` resolves a per-parameter or per-result *encoding name* to the
13+
right PyMEOS factory / serialiser. It does not pick the encoding: that
14+
decision is made by the catalog at generation time and surfaced via the
15+
``x-meos-{decode,encode}`` extensions on the OpenAPI spec.
16+
17+
The module is split so that the encoding/decoding logic is *resolver-
18+
agnostic*. Production runs against PyMEOS; the tests use a stub
19+
``WireCodec`` whose factory map is explicit.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import Any, Callable
25+
26+
27+
# Canonical encoding names appearing on catalog `wire.params[].decode`
28+
# and `wire.result.encode` fields.
29+
ENCODING_MFJSON = "mfjson"
30+
ENCODING_TEXT = "text"
31+
ENCODING_WKB = "wkb"
32+
ENCODING_HEXWKB = "hexwkb"
33+
34+
35+
class WireCodec:
36+
"""Codec keyed by encoding name (``mfjson``, ``text``, ``wkb``, …).
37+
38+
The decode map turns an inbound wire value (str / bytes) into a
39+
PyMEOS object. The encode map turns a PyMEOS object back into a
40+
wire value. Each map is keyed by the *encoding name* the catalog
41+
labels the parameter / result with.
42+
43+
Stub-mode construction supplies the maps explicitly. Production
44+
construction asks PyMEOS for the factories.
45+
"""
46+
47+
def __init__(
48+
self,
49+
decoders: dict[str, Callable[[Any], Any]],
50+
encoders: dict[str, Callable[[Any], Any]],
51+
) -> None:
52+
self._decoders = decoders
53+
self._encoders = encoders
54+
55+
def decode(self, encoding: str, wire_value: Any) -> Any:
56+
"""Apply the decoder for ``encoding`` to ``wire_value``."""
57+
try:
58+
return self._decoders[encoding](wire_value)
59+
except KeyError:
60+
raise KeyError(
61+
f"WireCodec has no decoder for encoding `{encoding}`. "
62+
f"Known: {sorted(self._decoders)}"
63+
)
64+
65+
def encode(self, encoding: str, value: Any) -> Any:
66+
"""Apply the encoder for ``encoding`` to ``value``."""
67+
try:
68+
return self._encoders[encoding](value)
69+
except KeyError:
70+
raise KeyError(
71+
f"WireCodec has no encoder for encoding `{encoding}`. "
72+
f"Known: {sorted(self._encoders)}"
73+
)
74+
75+
def has_decoder(self, encoding: str) -> bool:
76+
return encoding in self._decoders
77+
78+
def has_encoder(self, encoding: str) -> bool:
79+
return encoding in self._encoders
80+
81+
82+
def stub_codec(
83+
decoders: dict[str, Callable[[Any], Any]] | None = None,
84+
encoders: dict[str, Callable[[Any], Any]] | None = None,
85+
) -> WireCodec:
86+
"""Build a stub WireCodec from explicit maps (for tests)."""
87+
return WireCodec(
88+
decoders=decoders or {},
89+
encoders=encoders or {},
90+
)
91+
92+
93+
def pymeos_codec() -> WireCodec:
94+
"""Build the production WireCodec that bridges to PyMEOS.
95+
96+
Lazy-imports PyMEOS the first time the codec is used. The decoders
97+
accept the PyMEOS factory entry points, and the encoders use the
98+
PyMEOS object's own ``__str__`` / serialisation methods.
99+
100+
The factory entry points are deliberately not type-specific (e.g.,
101+
the catalog says ``decode = "temporal_in"`` and that's a single
102+
PyMEOS factory that dispatches on the input). When PyMEOS exposes
103+
more granular factories per temporal subtype, this map grows.
104+
"""
105+
try:
106+
import pymeos # noqa: WPS433 - lazy import on purpose
107+
except ImportError as e:
108+
raise ImportError(
109+
"pymeos is not installed. WireCodec.pymeos_codec() requires "
110+
"the `pymeos` package on the Python path."
111+
) from e
112+
113+
def _from_mfjson(s):
114+
# PyMEOS exposes Temporal.from_mfjson on the family root class.
115+
# The dispatch by base type happens inside PyMEOS.
116+
return pymeos.TPoint.from_mfjson(s) if isinstance(s, str) else s
117+
118+
def _from_wkb(b):
119+
return pymeos.TPoint.from_wkb(b)
120+
121+
def _from_hexwkb(s):
122+
return pymeos.TPoint.from_hexwkb(s)
123+
124+
def _from_text(s):
125+
return pymeos.TPoint(s) # PyMEOS constructor accepts EWKT
126+
127+
return WireCodec(
128+
decoders={
129+
ENCODING_MFJSON: _from_mfjson,
130+
ENCODING_WKB: _from_wkb,
131+
ENCODING_HEXWKB: _from_hexwkb,
132+
ENCODING_TEXT: _from_text,
133+
},
134+
encoders={
135+
ENCODING_MFJSON: lambda obj: obj.as_mfjson() if hasattr(obj, "as_mfjson") else str(obj),
136+
ENCODING_WKB: lambda obj: obj.as_wkb() if hasattr(obj, "as_wkb") else bytes(str(obj), "utf-8"),
137+
ENCODING_HEXWKB: lambda obj: obj.as_hexwkb() if hasattr(obj, "as_hexwkb") else str(obj),
138+
ENCODING_TEXT: str,
139+
},
140+
)

tests/test_resolvers.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Unit tests for mobilityapi.resolvers."""
2+
3+
import pytest
4+
5+
from mobilityapi.resolvers import (
6+
stub_resolver, pymeos_resolver, default_resolver,
7+
)
8+
9+
10+
def test_stub_resolver_returns_registered_callable():
11+
fn = lambda **kw: "ok"
12+
r = stub_resolver({"my_fn": fn})
13+
assert r("my_fn") is fn
14+
15+
16+
def test_stub_resolver_raises_on_unknown_name():
17+
r = stub_resolver({"my_fn": lambda: None})
18+
with pytest.raises(NotImplementedError, match="has no entry for `other`"):
19+
r("other")
20+
21+
22+
def test_stub_resolver_lists_known_names_in_error():
23+
r = stub_resolver({"a": lambda: None, "b": lambda: None})
24+
with pytest.raises(NotImplementedError, match="\\['a', 'b'\\]"):
25+
r("c")
26+
27+
28+
def test_pymeos_resolver_returns_callable_factory():
29+
"""The factory itself constructs without PyMEOS installed; the import
30+
fires only when the returned resolver is *called*."""
31+
r = pymeos_resolver()
32+
assert callable(r)
33+
34+
35+
def test_pymeos_resolver_raises_importerror_on_call_when_pymeos_missing(monkeypatch):
36+
"""Simulate PyMEOS being absent: the resolver call raises ImportError
37+
with an actionable message."""
38+
import sys
39+
# Force the lazy import to fail by hiding the module.
40+
monkeypatch.setitem(sys.modules, "pymeos", None)
41+
monkeypatch.setitem(sys.modules, "pymeos.functions", None)
42+
r = pymeos_resolver()
43+
with pytest.raises(ImportError, match="pymeos is not installed"):
44+
r("any_fn")
45+
46+
47+
def test_default_resolver_falls_back_to_stub_when_pymeos_missing(monkeypatch):
48+
import sys
49+
monkeypatch.setitem(sys.modules, "pymeos", None)
50+
monkeypatch.setitem(sys.modules, "pymeos.functions", None)
51+
r = default_resolver(prefer_pymeos=True)
52+
# The fallback raises NotImplementedError when called, not at construction.
53+
callable_for_fn = r("some_fn")
54+
with pytest.raises(NotImplementedError, match="No production resolver"):
55+
callable_for_fn(temp="x")
56+
57+
58+
def test_default_resolver_with_prefer_pymeos_false_skips_probe(monkeypatch):
59+
"""If prefer_pymeos=False, default_resolver does not attempt PyMEOS
60+
even if it's available, and uses the stub path immediately."""
61+
r = default_resolver(prefer_pymeos=False)
62+
callable_for_fn = r("some_fn")
63+
with pytest.raises(NotImplementedError, match="No production resolver"):
64+
callable_for_fn()

0 commit comments

Comments
 (0)