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
16 changes: 16 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,19 @@ 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 / resolvers / wire / app unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
# `fastapi` + `httpx` are required by test_app.py; the other test
# modules run with pytest alone since the package init lazy-loads
# FastAPI via PEP 562.
run: pip install --upgrade pip pytest fastapi 'httpx>=0.24'
- name: Run framework + app tests
run: python -m pytest tests/test_dispatcher.py tests/test_resolvers.py tests/test_wire.py tests/test_app.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"
43 changes: 43 additions & 0 deletions mobilityapi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""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 provides
the three foundation pieces the migrating endpoints share:

- ``Dispatcher`` — catalog-driven function lookup + invocation.
- ``resolvers`` — pick the MEOS function implementation
(production: PyMEOS; tests: explicit stubs).
- ``wire`` — decode HTTP wire values to PyMEOS objects;
encode PyMEOS results back to wire values.

Existing hand-written endpoints remain unchanged until they are migrated
module-by-module in follow-up PRs.
"""

from .dispatcher import Dispatcher, FunctionSignature
from .resolvers import stub_resolver, pymeos_resolver, default_resolver
from .wire import (
WireCodec, stub_codec, pymeos_codec,
ENCODING_MFJSON, ENCODING_TEXT, ENCODING_WKB, ENCODING_HEXWKB,
)

__all__ = [
"create_app",
"Dispatcher", "FunctionSignature",
"stub_resolver", "pymeos_resolver", "default_resolver",
"WireCodec", "stub_codec", "pymeos_codec",
"ENCODING_MFJSON", "ENCODING_TEXT", "ENCODING_WKB", "ENCODING_HEXWKB",
]


# Lazy-load `create_app` (PEP 562) so importing `mobilityapi` does NOT pull
# in FastAPI/Starlette/Pydantic. Callers that need the HTTP routes import
# `from mobilityapi import create_app` and pay the FastAPI dep then; tests
# that only exercise Dispatcher/Resolvers/WireCodec do not need it on the
# import path.
def __getattr__(name): # noqa: D401 - module-level descriptor
if name == "create_app":
from .app import create_app as _create_app
return _create_app
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
59 changes: 59 additions & 0 deletions mobilityapi/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""FastAPI application factory for the MobilityAPI Dispatcher framework.

Exposes the catalog-driven Dispatcher + WireCodec foundation (steps 3 / 4
of the ingestion plan) as HTTP routes. Two router groups:

- ``/catalog/*`` — read-only introspection (list functions, fetch one).
- ``/functions/*`` — invoke a MEOS function from a JSON request body.

The app is built by :func:`create_app`, which takes injected
``Dispatcher`` and ``WireCodec`` instances. Production wires both to
PyMEOS; tests pass stubs. No global singletons; every dependency is
explicit, which keeps the request-time hot path resolver-agnostic and
the test surface fast.
"""

from __future__ import annotations

from fastapi import FastAPI

from .dispatcher import Dispatcher
from .routers import catalog, functions
from .wire import WireCodec


def create_app(
dispatcher: Dispatcher,
codec: WireCodec,
*,
title: str = "MobilityAPI",
version: str = "0.1.0",
) -> FastAPI:
"""Build the FastAPI app from the injected dispatcher + codec.

:param dispatcher: ``Dispatcher`` instance bound to a resolver
(``stub_resolver`` for tests, ``pymeos_resolver`` for prod).
:param codec: ``WireCodec`` mapping the catalog's per-parameter
encoding labels (``mfjson`` / ``text`` / ``wkb`` / ``hexwkb``)
to Python factory + serialiser callables.

The dispatcher and codec are exposed to routers via
``app.state.dispatcher`` and ``app.state.codec`` so router
dependencies can read them without a global.
"""
app = FastAPI(
title=title,
version=version,
description=(
"Catalog-driven dispatcher for MEOS functions, exposed over "
"HTTP. Every route delegates to the MobilityAPI Dispatcher; "
"no MEOS C code is invoked outside the dispatcher resolver."
),
)
app.state.dispatcher = dispatcher
app.state.codec = codec

app.include_router(catalog.router, prefix="/catalog", tags=["catalog"])
app.include_router(functions.router, prefix="/functions", tags=["functions"])

return app
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