Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,16 @@ jobs:
python -c "from resource.collections import Create, Retrieve"
python -c "from resource.moving_features import Create, Retrieve"
python -c "from resource.temporal_geom_query import distance, velocity, acceleration"

pytest-dispatcher:
name: Dispatcher unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install pytest
run: pip install --upgrade pip pytest
- name: Run dispatcher tests
run: python -m pytest tests/test_dispatcher.py -v
62 changes: 62 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# MobilityAPI build & vendoring targets

MEOS_API_REPO ?= https://github.com/MobilityDB/MEOS-API
MEOS_API_REF ?= master
MEOS_API_PR5 ?= refs/pull/5/head # MEOS-API PR #5 (OpenAPI projection)
MEOS_API_PR4 ?= refs/pull/4/head # MEOS-API PR #4 (enrichment)
MOBILITYDB_REPO ?= https://github.com/MobilityDB/MobilityDB
MOBILITYDB_REF ?= master
VENDOR_DIR := vendor/meos-api

.PHONY: vendor-meos-api vendor-meos-api-from-prs

# Regenerate vendored MEOS-API artefacts from MEOS-API + MobilityDB headers.
#
# `output/*.json` is .gitignore'd in MEOS-API (generated by `python3 run.py
# <path-to-meos-include>`), so we have to:
# 1. clone MEOS-API at the requested ref,
# 2. clone MobilityDB at the requested ref so MEOS-API's parser can read its
# headers (`meos/include/`),
# 3. install libclang,
# 4. run `python3 run.py <MobilityDB-headers-path>` to produce output/*.json,
# 5. copy the JSON artefacts into $(VENDOR_DIR).
vendor-meos-api:
@echo "[vendor] regenerating meos-api artefacts from"
@echo " MEOS-API: $(MEOS_API_REPO)@$(MEOS_API_REF)"
@echo " MobilityDB: $(MOBILITYDB_REPO)@$(MOBILITYDB_REF) (headers source)"
@mkdir -p $(VENDOR_DIR)
@tmpdir=$$(mktemp -d) && \
git clone --depth 1 --branch $(MEOS_API_REF) $(MEOS_API_REPO) $$tmpdir/meos-api && \
git clone --depth 1 --branch $(MOBILITYDB_REF) $(MOBILITYDB_REPO) $$tmpdir/mobilitydb && \
cd $$tmpdir/meos-api && \
pip install --quiet --user -r requirements.txt && \
python3 run.py $$tmpdir/mobilitydb/meos/include && \
if [ -f report.py ]; then python3 report.py $$tmpdir/mobilitydb/meos/include || true; fi && \
if [ -f object_model_parity.py ]; then python3 object_model_parity.py || true; fi && \
cp -v output/meos-idl.json $(CURDIR)/$(VENDOR_DIR)/ && \
( [ -f output/meos-coverage.json ] && cp -v output/meos-coverage.json $(CURDIR)/$(VENDOR_DIR)/ || true ) && \
( [ -f output/meos-object-model-parity.json ] && cp -v output/meos-object-model-parity.json $(CURDIR)/$(VENDOR_DIR)/ || true ) && \
cd $(CURDIR) && rm -rf $$tmpdir
@echo "[vendor] done — $(VENDOR_DIR) refreshed"

# Fetch the enriched catalog + OpenAPI projection from the open PR branches
# (PR #4 ships parser/enrich.py, PR #5 ships generate_openapi.py).
vendor-meos-api-from-prs:
@echo "[vendor] fetching from open PR branches (#4 enrichment + #5 OpenAPI)"
@mkdir -p $(VENDOR_DIR)
@tmpdir=$$(mktemp -d) && \
git clone $(MEOS_API_REPO) $$tmpdir/meos-api && \
git clone --depth 1 --branch $(MOBILITYDB_REF) $(MOBILITYDB_REPO) $$tmpdir/mobilitydb && \
cd $$tmpdir/meos-api && \
git fetch origin $(MEOS_API_PR4):pr4 $(MEOS_API_PR5):pr5 && \
git checkout pr5 && \
git merge --no-edit pr4 || true && \
pip install --quiet --user -r requirements.txt && \
python3 run.py $$tmpdir/mobilitydb/meos/include && \
python3 generate_openapi.py && \
cp -v output/meos-idl.json $(CURDIR)/$(VENDOR_DIR)/ && \
( [ -f output/meos-coverage.json ] && cp -v output/meos-coverage.json $(CURDIR)/$(VENDOR_DIR)/ || true ) && \
( [ -f output/meos-object-model-parity.json ] && cp -v output/meos-object-model-parity.json $(CURDIR)/$(VENDOR_DIR)/ || true ) && \
( [ -f output/meos-openapi.json ] && cp -v output/meos-openapi.json $(CURDIR)/$(VENDOR_DIR)/ || true ) && \
cd $(CURDIR) && rm -rf $$tmpdir
@echo "[vendor] done — $(VENDOR_DIR) refreshed from PRs #4 + #5"
14 changes: 14 additions & 0 deletions mobilityapi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""MobilityAPI catalog-driven dispatcher package.

The MobilityAPI ingestion plan (docs/MEOS_API_INGESTION_PLAN.md) calls for
replacing the hand-written MEOS-dispatching endpoint modules with thin
dispatchers driven by the vendored MEOS-API catalog. This package is the
foundation: a `Dispatcher` class that loads the vendored catalog and exposes
``dispatch(function_name, params) -> Any`` for every stateless-exposable
MEOS function. Existing hand-written endpoints remain unchanged until they
are migrated module-by-module in follow-up PRs.
"""

from .dispatcher import Dispatcher, FunctionSignature

__all__ = ["Dispatcher", "FunctionSignature"]
192 changes: 192 additions & 0 deletions mobilityapi/dispatcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""Catalog-driven dispatcher for MEOS functions.

Reads the vendored MEOS-API catalog (``vendor/meos-api/meos-idl.json``,
produced by the MEOS-API ``run.py`` against MobilityDB master headers) and
exposes a single ``dispatch(function_name, params) -> Any`` entry point.

When a MEOS-API enriched catalog (with ``network``/``wire``/``api`` fields,
authored by ``parser/enrich.py`` on MEOS-API PR #4) is the source, the
dispatcher uses the richer per-parameter decode/encode metadata. When only
the bare catalog is available, it falls back to the function signature
itself.

The dispatcher does NOT invoke PyMEOS directly inside its core logic —
PyMEOS is injected as a *resolver* callable so the same dispatcher can be
unit-tested with stubs. In production, the resolver is
``getattr(pymeos.functions, name)`` (PyMEOS's flat function module mirrors
the MEOS C API one-for-one).

Foundation only: this PR ships the loader, the signature model, and the
dispatch entry point with stub-resolver unit tests. The follow-up PRs swap
each of the 5 hand-written ``resource/*`` modules listed in
``docs/MEOS_API_INGESTION_PLAN.md`` (§\"Replace candidates\") to call
``Dispatcher.dispatch`` instead of psycopg2 SQL.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Iterable


# Default vendored catalog path, resolved relative to the repository root.
_DEFAULT_CATALOG = (
Path(__file__).resolve().parent.parent
/ "vendor" / "meos-api" / "meos-idl.json"
)


@dataclass(frozen=True)
class FunctionSignature:
"""One MEOS function from the catalog, normalised for dispatch."""

name: str
category: str
params: list[dict] = field(default_factory=list)
return_type: str = ""
# Network / wire enrichment (optional; only present on enriched catalog).
exposable: bool = True
decode_per_param: dict[str, str] = field(default_factory=dict)
encode_return: str | None = None
description: str = ""

@classmethod
def from_catalog_entry(cls, entry: dict) -> "FunctionSignature":
network = entry.get("network", {})
wire = entry.get("wire", {})

decode_per_param: dict[str, str] = {}
if wire.get("params"):
for p in wire["params"]:
if p.get("kind") == "serialized" and p.get("decode"):
decode_per_param[p["name"]] = p["decode"]
elif p.get("kind") == "array" and p.get("element", {}).get("decode"):
decode_per_param[p["name"]] = p["element"]["decode"]

encode_return: str | None = None
if wire.get("result", {}).get("kind") == "serialized":
encode_return = wire["result"].get("encode")

return cls(
name=entry["name"],
category=entry.get("category", "uncategorised"),
params=entry.get("params", []),
return_type=entry.get("return_type", ""),
exposable=bool(network.get("exposable", True)),
decode_per_param=decode_per_param,
encode_return=encode_return,
description=entry.get("doc", "") or entry.get("description", ""),
)


class Dispatcher:
"""Catalog-driven MEOS function dispatcher."""

def __init__(
self,
catalog_path: Path | str | None = None,
resolver: Callable[[str], Callable[..., Any]] | None = None,
) -> None:
"""Construct a dispatcher.

:param catalog_path: Path to ``meos-idl.json``; defaults to the
vendored copy at ``vendor/meos-api/meos-idl.json``.
:param resolver: Callable mapping a MEOS function name to the
Python callable that implements it. In production this is
``lambda n: getattr(pymeos.functions, n)``. In unit tests it
can be a stub registry. Defaults to a stub that raises
``NotImplementedError`` — the caller must supply a real
resolver before ``dispatch`` is called.
"""
path = Path(catalog_path) if catalog_path else _DEFAULT_CATALOG
self._catalog_path = path
self._signatures: dict[str, FunctionSignature] = {}
self._load(path)
self._resolver = resolver or self._stub_resolver

# -- catalog ----------------------------------------------------------------

def _load(self, path: Path) -> None:
if not path.exists():
raise FileNotFoundError(
f"MEOS-API catalog not found at {path}. Run "
f"`make vendor-meos-api` to (re-)populate vendor/meos-api/."
)
with path.open() as f:
catalog = json.load(f)

for entry in catalog.get("functions", []):
sig = FunctionSignature.from_catalog_entry(entry)
if sig.exposable:
self._signatures[sig.name] = sig

def signature(self, name: str) -> FunctionSignature:
try:
return self._signatures[name]
except KeyError:
raise KeyError(
f"Unknown MEOS function `{name}` — either it does not exist "
f"in the vendored catalog or it is not exposable."
)

def signatures(self) -> Iterable[FunctionSignature]:
return self._signatures.values()

def has(self, name: str) -> bool:
return name in self._signatures

def __len__(self) -> int:
return len(self._signatures)

# -- dispatch ---------------------------------------------------------------

@staticmethod
def _stub_resolver(name: str) -> Callable[..., Any]:
def _raise(*_a, **_kw): # pragma: no cover - intentional stub
raise NotImplementedError(
f"Dispatcher has no resolver wired in for `{name}`. Pass a "
f"resolver= argument to Dispatcher(...)."
)
return _raise

def dispatch(self, function_name: str, params: dict) -> Any:
"""Invoke the MEOS function named ``function_name`` with ``params``.

``params`` is a JSON-like dict whose keys match the function's
parameter names (per the catalog). Each parameter is passed through
unchanged to the resolver-returned callable; the caller is
responsible for decoding opaque types (e.g. constructing
``pymeos.TGeomPoint`` from MF-JSON) before calling ``dispatch``.

Encoding the return value is also left to the caller — the
dispatcher returns whatever the resolver-returned callable returns.

The catalog signature is used only for validation:

* unknown function name → ``KeyError``
* mismatched parameter set → ``TypeError`` with a helpful message
"""
sig = self.signature(function_name)
self._validate_params(sig, params)
fn = self._resolver(function_name)
return fn(**params)

@staticmethod
def _validate_params(sig: FunctionSignature, params: dict) -> None:
expected = {p["name"] for p in sig.params}
provided = set(params.keys())
missing = expected - provided
unexpected = provided - expected
if missing or unexpected:
details = []
if missing:
details.append(f"missing: {sorted(missing)}")
if unexpected:
details.append(f"unexpected: {sorted(unexpected)}")
raise TypeError(
f"`{sig.name}` parameter set mismatch — "
+ "; ".join(details)
+ f". Expected: {sorted(expected)}"
)
Loading
Loading