Skip to content

Commit 6bf5621

Browse files
feat(dispatcher): catalog-driven MEOS-function dispatcher (step 4 foundation)
Step 4 of docs/MEOS_API_INGESTION_PLAN.md — the catalog-driven dispatcher that the 5 'REPLACE' resource modules will delegate to in follow-up PRs. mobilityapi/dispatcher.py: - Loads vendor/meos-api/meos-idl.json at construction; honours an explicit catalog_path= for tests. - Filters to network.exposable functions only when enrichment fields are present; otherwise treats every function as exposable. - FunctionSignature dataclass: name / category / params / return_type / decode_per_param / encode_return / description, all populated from the catalog (enriched or bare). - dispatch(function_name, params) -> Any: * validates the parameter set against the catalog signature (missing or unexpected names raise TypeError) * resolves the MEOS function via an injected resolver callable (production: getattr(pymeos.functions, name); tests: stub registry) * invokes it with the validated keyword args * returns the result; the caller owns encoding to JSON / WKB tests/test_dispatcher.py (12 tests, all passing locally): - catalog load (default path, explicit path, FileNotFoundError) - FunctionSignature.from_catalog_entry (basic fields, enriched wire metadata, fallback when wire absent, non-exposable filtering) - dispatch contract (resolver invocation, unknown function, missing param, unexpected param, default stub resolver) - integration sanity (the 5 MovFeat dispatch candidates named in the ingestion plan are present in the vendored catalog) What this PR does NOT change: - Existing hand-written endpoint modules in resource/* remain unchanged. The plan's 5 REPLACE candidates migrate to the dispatcher module-by-module in follow-up PRs: temporal_geom_seq/, temporal_geom_query/{velocity,acceleration, distance}, temporal_properties/. - PyMEOS is not yet a dependency (the dispatcher is resolver- agnostic; the production resolver lands when the first endpoint migrates). Stacks on #4 (vendor MEOS-API artefacts) so vendor/meos-api/meos-idl.json is in the tree.
1 parent 4d5098f commit 6bf5621

4 files changed

Lines changed: 418 additions & 0 deletions

File tree

.github/workflows/python.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,16 @@ jobs:
3939
python -c "from resource.collections import Create, Retrieve"
4040
python -c "from resource.moving_features import Create, Retrieve"
4141
python -c "from resource.temporal_geom_query import distance, velocity, acceleration"
42+
43+
pytest-dispatcher:
44+
name: Dispatcher unit tests
45+
runs-on: ubuntu-latest
46+
steps:
47+
- uses: actions/checkout@v4
48+
- uses: actions/setup-python@v5
49+
with:
50+
python-version: "3.11"
51+
- name: Install pytest
52+
run: pip install --upgrade pip pytest
53+
- name: Run dispatcher tests
54+
run: python -m pytest tests/test_dispatcher.py -v

mobilityapi/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""MobilityAPI catalog-driven dispatcher package.
2+
3+
The MobilityAPI ingestion plan (docs/MEOS_API_INGESTION_PLAN.md) calls for
4+
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.
10+
"""
11+
12+
from .dispatcher import Dispatcher, FunctionSignature
13+
14+
__all__ = ["Dispatcher", "FunctionSignature"]

mobilityapi/dispatcher.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Catalog-driven dispatcher for MEOS functions.
2+
3+
Reads the vendored MEOS-API catalog (``vendor/meos-api/meos-idl.json``,
4+
produced by the MEOS-API ``run.py`` against MobilityDB master headers) and
5+
exposes a single ``dispatch(function_name, params) -> Any`` entry point.
6+
7+
When a MEOS-API enriched catalog (with ``network``/``wire``/``api`` fields,
8+
authored by ``parser/enrich.py`` on MEOS-API PR #4) is the source, the
9+
dispatcher uses the richer per-parameter decode/encode metadata. When only
10+
the bare catalog is available, it falls back to the function signature
11+
itself.
12+
13+
The dispatcher does NOT invoke PyMEOS directly inside its core logic —
14+
PyMEOS is injected as a *resolver* callable so the same dispatcher can be
15+
unit-tested with stubs. In production, the resolver is
16+
``getattr(pymeos.functions, name)`` (PyMEOS's flat function module mirrors
17+
the MEOS C API one-for-one).
18+
19+
Foundation only: this PR ships the loader, the signature model, and the
20+
dispatch entry point with stub-resolver unit tests. The follow-up PRs swap
21+
each of the 5 hand-written ``resource/*`` modules listed in
22+
``docs/MEOS_API_INGESTION_PLAN.md`` (§\"Replace candidates\") to call
23+
``Dispatcher.dispatch`` instead of psycopg2 SQL.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import json
29+
from dataclasses import dataclass, field
30+
from pathlib import Path
31+
from typing import Any, Callable, Iterable
32+
33+
34+
# Default vendored catalog path, resolved relative to the repository root.
35+
_DEFAULT_CATALOG = (
36+
Path(__file__).resolve().parent.parent
37+
/ "vendor" / "meos-api" / "meos-idl.json"
38+
)
39+
40+
41+
@dataclass(frozen=True)
42+
class FunctionSignature:
43+
"""One MEOS function from the catalog, normalised for dispatch."""
44+
45+
name: str
46+
category: str
47+
params: list[dict] = field(default_factory=list)
48+
return_type: str = ""
49+
# Network / wire enrichment (optional; only present on enriched catalog).
50+
exposable: bool = True
51+
decode_per_param: dict[str, str] = field(default_factory=dict)
52+
encode_return: str | None = None
53+
description: str = ""
54+
55+
@classmethod
56+
def from_catalog_entry(cls, entry: dict) -> "FunctionSignature":
57+
network = entry.get("network", {})
58+
wire = entry.get("wire", {})
59+
60+
decode_per_param: dict[str, str] = {}
61+
if wire.get("params"):
62+
for p in wire["params"]:
63+
if p.get("kind") == "serialized" and p.get("decode"):
64+
decode_per_param[p["name"]] = p["decode"]
65+
elif p.get("kind") == "array" and p.get("element", {}).get("decode"):
66+
decode_per_param[p["name"]] = p["element"]["decode"]
67+
68+
encode_return: str | None = None
69+
if wire.get("result", {}).get("kind") == "serialized":
70+
encode_return = wire["result"].get("encode")
71+
72+
return cls(
73+
name=entry["name"],
74+
category=entry.get("category", "uncategorised"),
75+
params=entry.get("params", []),
76+
return_type=entry.get("return_type", ""),
77+
exposable=bool(network.get("exposable", True)),
78+
decode_per_param=decode_per_param,
79+
encode_return=encode_return,
80+
description=entry.get("doc", "") or entry.get("description", ""),
81+
)
82+
83+
84+
class Dispatcher:
85+
"""Catalog-driven MEOS function dispatcher."""
86+
87+
def __init__(
88+
self,
89+
catalog_path: Path | str | None = None,
90+
resolver: Callable[[str], Callable[..., Any]] | None = None,
91+
) -> None:
92+
"""Construct a dispatcher.
93+
94+
:param catalog_path: Path to ``meos-idl.json``; defaults to the
95+
vendored copy at ``vendor/meos-api/meos-idl.json``.
96+
:param resolver: Callable mapping a MEOS function name to the
97+
Python callable that implements it. In production this is
98+
``lambda n: getattr(pymeos.functions, n)``. In unit tests it
99+
can be a stub registry. Defaults to a stub that raises
100+
``NotImplementedError`` — the caller must supply a real
101+
resolver before ``dispatch`` is called.
102+
"""
103+
path = Path(catalog_path) if catalog_path else _DEFAULT_CATALOG
104+
self._catalog_path = path
105+
self._signatures: dict[str, FunctionSignature] = {}
106+
self._load(path)
107+
self._resolver = resolver or self._stub_resolver
108+
109+
# -- catalog ----------------------------------------------------------------
110+
111+
def _load(self, path: Path) -> None:
112+
if not path.exists():
113+
raise FileNotFoundError(
114+
f"MEOS-API catalog not found at {path}. Run "
115+
f"`make vendor-meos-api` to (re-)populate vendor/meos-api/."
116+
)
117+
with path.open() as f:
118+
catalog = json.load(f)
119+
120+
for entry in catalog.get("functions", []):
121+
sig = FunctionSignature.from_catalog_entry(entry)
122+
if sig.exposable:
123+
self._signatures[sig.name] = sig
124+
125+
def signature(self, name: str) -> FunctionSignature:
126+
try:
127+
return self._signatures[name]
128+
except KeyError:
129+
raise KeyError(
130+
f"Unknown MEOS function `{name}` — either it does not exist "
131+
f"in the vendored catalog or it is not exposable."
132+
)
133+
134+
def signatures(self) -> Iterable[FunctionSignature]:
135+
return self._signatures.values()
136+
137+
def has(self, name: str) -> bool:
138+
return name in self._signatures
139+
140+
def __len__(self) -> int:
141+
return len(self._signatures)
142+
143+
# -- dispatch ---------------------------------------------------------------
144+
145+
@staticmethod
146+
def _stub_resolver(name: str) -> Callable[..., Any]:
147+
def _raise(*_a, **_kw): # pragma: no cover - intentional stub
148+
raise NotImplementedError(
149+
f"Dispatcher has no resolver wired in for `{name}`. Pass a "
150+
f"resolver= argument to Dispatcher(...)."
151+
)
152+
return _raise
153+
154+
def dispatch(self, function_name: str, params: dict) -> Any:
155+
"""Invoke the MEOS function named ``function_name`` with ``params``.
156+
157+
``params`` is a JSON-like dict whose keys match the function's
158+
parameter names (per the catalog). Each parameter is passed through
159+
unchanged to the resolver-returned callable; the caller is
160+
responsible for decoding opaque types (e.g. constructing
161+
``pymeos.TGeomPoint`` from MF-JSON) before calling ``dispatch``.
162+
163+
Encoding the return value is also left to the caller — the
164+
dispatcher returns whatever the resolver-returned callable returns.
165+
166+
The catalog signature is used only for validation:
167+
168+
* unknown function name → ``KeyError``
169+
* mismatched parameter set → ``TypeError`` with a helpful message
170+
"""
171+
sig = self.signature(function_name)
172+
self._validate_params(sig, params)
173+
fn = self._resolver(function_name)
174+
return fn(**params)
175+
176+
@staticmethod
177+
def _validate_params(sig: FunctionSignature, params: dict) -> None:
178+
expected = {p["name"] for p in sig.params}
179+
provided = set(params.keys())
180+
missing = expected - provided
181+
unexpected = provided - expected
182+
if missing or unexpected:
183+
details = []
184+
if missing:
185+
details.append(f"missing: {sorted(missing)}")
186+
if unexpected:
187+
details.append(f"unexpected: {sorted(unexpected)}")
188+
raise TypeError(
189+
f"`{sig.name}` parameter set mismatch — "
190+
+ "; ".join(details)
191+
+ f". Expected: {sorted(expected)}"
192+
)

0 commit comments

Comments
 (0)