Skip to content
Merged
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
132 changes: 132 additions & 0 deletions .github/workflows/upstream-compat.yml
Original file line number Diff line number Diff line change
@@ -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<<EOF"
uv pip list | grep -E '^(toolregistry|toolregistry-server|mcp|uxarray) ' || true
echo "EOF"
} >> "$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
1 change: 1 addition & 0 deletions .github/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ rules:
unpinned-uses:
ignore:
- ci.yml
- upstream-compat.yml
- monthly-release.yml
- release.yml
- zizmor.yml
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions docs/issues-from-escience-study/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
74 changes: 74 additions & 0 deletions tests/test_domain_isolation.py
Original file line number Diff line number Diff line change
@@ -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)}"
)
Loading