diff --git a/.github/workflows/upstream-compat.yml b/.github/workflows/upstream-compat.yml new file mode 100644 index 0000000..8af1236 --- /dev/null +++ b/.github/workflows/upstream-compat.yml @@ -0,0 +1,132 @@ +name: Upstream Compatibility + +# We sit downstream of four independently-released packages: toolregistry, +# toolregistry-server, mcp, and uxarray. A break in any of them shows up in +# our tools long before it shows up in our lockfile, so probe them on a +# schedule instead of waiting for a user to hit it. + +on: + schedule: + - cron: "23 5 * * 1" # Monday 05:23 UTC + + # Allow an upstream release to notify us immediately. + repository_dispatch: + types: [upstream-release] + + workflow_dispatch: + inputs: + package: + description: "Package to force-upgrade (blank = upgrade all to latest)" + required: false + default: "" + version: + description: "Version for that package (blank = latest)" + required: false + default: "" + +permissions: + contents: read + +jobs: + latest: + name: latest ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Probe each upstream on its own so a failure names the culprit + # directly instead of forcing a bisect across four packages. + target: [toolregistry, toolregistry-server, mcp, uxarray, all] + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pinned dependencies + run: uv sync --dev + + - name: Upgrade upstream to latest + id: upgrade + env: + TARGET: ${{ matrix.target }} + INPUT_PACKAGE: ${{ github.event.inputs.package || github.event.client_payload.package }} + INPUT_VERSION: ${{ github.event.inputs.version || github.event.client_payload.version }} + run: | + set -euo pipefail + if [ -n "$INPUT_PACKAGE" ]; then + SPEC="$INPUT_PACKAGE" + if [ -n "$INPUT_VERSION" ]; then + VERSION="${INPUT_VERSION#v}" + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+'; then + echo "::error::Invalid version format: '$INPUT_VERSION'" + exit 1 + fi + SPEC="$INPUT_PACKAGE==$VERSION" + fi + uv pip install --upgrade "$SPEC" + elif [ "$TARGET" = "all" ]; then + uv pip install --upgrade toolregistry toolregistry-server mcp uxarray + else + uv pip install --upgrade "$TARGET" + fi + { + echo "versions<> "$GITHUB_OUTPUT" + + - name: Show resolved versions + run: uv pip list | grep -E '^(toolregistry|toolregistry-server|mcp|uxarray) ' + + - name: Run test suite + run: uv run pytest tests/ --ignore=tests/test_remote_agent.py -v --tb=long + + - name: Report failure + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET: ${{ matrix.target }} + VERSIONS: ${{ steps.upgrade.outputs.versions }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + echo "::error::Upstream '$TARGET' at latest breaks the test suite" + echo "$VERSIONS" + echo "See $RUN_URL" + + floor: + name: declared floor versions + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install lowest-compatible dependency versions + # Our pyproject declares ranges (e.g. mcp>=1.24,<3) but CI otherwise + # only ever exercises the top of those ranges. Issue #54 upstream was + # exactly this class of bug, so test the bottom too. + run: uv sync --dev --resolution lowest-direct + + - name: Show resolved versions + run: uv pip list | grep -E '^(toolregistry|toolregistry-server|mcp|uxarray) ' + + - name: Run test suite + run: uv run pytest tests/ --ignore=tests/test_remote_agent.py -v --tb=long diff --git a/.github/zizmor.yml b/.github/zizmor.yml index af2d8e6..a5e8b69 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -9,6 +9,7 @@ rules: unpinned-uses: ignore: - ci.yml + - upstream-compat.yml - monthly-release.yml - release.yml - zizmor.yml diff --git a/docs/index.rst b/docs/index.rst index 8f0b28a..68e86e8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -45,3 +45,4 @@ HTTP clients) from a single install. changelog roadmap-and-open-questions mcp-2026-07-28-assessment + issues-from-escience-study/README diff --git a/docs/issues-from-escience-study/README.md b/docs/issues-from-escience-study/README.md index d76b537..2735663 100644 --- a/docs/issues-from-escience-study/README.md +++ b/docs/issues-from-escience-study/README.md @@ -58,3 +58,16 @@ through them: cacheable `tools/list` results weaken the argument for repeating catalog material inside results (#83), and Multi Round-Trip Requests give a server a way to halt a call pending acknowledgment rather than warning and computing anyway (#86). + +```{toctree} +:hidden: +:maxdepth: 1 + +01-run-analysis-is-too-general +02-capability-catalog-dominates-every-reply +03-results-cannot-say-whether-anything-was-checked +04-remap-extrapolates-silently +05-warnings-inform-but-never-block +06-scale-by-radius-default-disagrees-with-uxarray +07-no-result-size-budget +``` diff --git a/tests/test_domain_isolation.py b/tests/test_domain_isolation.py new file mode 100644 index 0000000..a38da78 --- /dev/null +++ b/tests/test_domain_isolation.py @@ -0,0 +1,74 @@ +"""Guard the ``domain/`` boundary. + +Pure computation in ``uxarray_mcp.domain`` must stay importable without any +server dependency installed. Two things depend on that property: + +* Remote execution — ``AllCodeStrategies`` ships these functions to a Globus + Compute worker that has ``uxarray`` but not ``toolregistry`` or ``mcp``. +* Portability — the server layer is a third-party dependency, so keeping the + science free of it means a protocol change never reaches ``domain/``. + +An accidental ``from toolregistry import ...`` in a domain module would only +surface as a worker-side ``ModuleNotFoundError`` at job runtime, which is an +expensive place to learn about it. +""" + +from __future__ import annotations + +import ast +import pkgutil +from pathlib import Path + +import pytest + +import uxarray_mcp.domain as domain_pkg + +FORBIDDEN_ROOTS = {"toolregistry", "toolregistry_server", "mcp", "fastapi"} + +DOMAIN_DIR = Path(domain_pkg.__file__).parent + + +def _domain_modules() -> list[Path]: + return sorted(p for p in DOMAIN_DIR.glob("*.py") if p.name != "__pycache__") + + +def _imported_roots(tree: ast.AST) -> set[str]: + """Collect top-level package names imported anywhere in the module.""" + roots: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + roots.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom): + # Relative imports have no module root to check. + if node.level == 0 and node.module: + roots.add(node.module.split(".")[0]) + return roots + + +@pytest.mark.parametrize("path", _domain_modules(), ids=lambda p: p.name) +def test_domain_module_has_no_server_imports(path: Path) -> None: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + offenders = _imported_roots(tree) & FORBIDDEN_ROOTS + assert not offenders, ( + f"{path.name} imports {sorted(offenders)}; domain/ must stay free of " + "server and protocol dependencies so it can run on an HPC worker." + ) + + +def test_domain_package_is_non_empty() -> None: + """Fail loudly if the glob above silently stops matching.""" + modules = [p for p in _domain_modules() if p.name != "__init__.py"] + assert len(modules) >= 5, ( + f"expected the domain package to be populated, saw {modules}" + ) + + +def test_every_domain_submodule_is_covered() -> None: + """Every importable submodule must be seen by the AST scan.""" + discovered = {name for _, name, _ in pkgutil.iter_modules([str(DOMAIN_DIR)])} + scanned = {p.stem for p in _domain_modules()} + missing = discovered - scanned + assert not missing, ( + f"domain submodules not covered by the import guard: {sorted(missing)}" + )