Skip to content

Commit a16d253

Browse files
cailmdaleyclaude
andcommitted
test: add Tier 0-2 scaffolding — shell syntax, INI parse, imports, entrypoints, runner metadata
- tests/unit/test_shell_syntax.py: bash -n over every scripts/sh/*.{sh,bash} - tests/unit/test_config_parse.py: configparser over example/**/*.ini - tests/unit/test_imports.py: import every shapepipe.* submodule - tests/unit/test_entrypoints.py: invoke -h on every [project.scripts] entry - tests/unit/test_runner_metadata.py: every *_runner.py exports a @module_runner Pre-existing failures tracked as xfail so the suite lands green and the issues remain discoverable (strict=True auto-notifies once fixed): - stile v0.1 imports removed treecorr.corr2 → breaks mccd_plots_runner, random_cat_runner, plus their packages - summary_run -h treats '-h' as the 'patch' positional and mkdirs it Also: - Fix stale testpaths (pyproject.toml) — was "shapepipe" from the old pre-src-layout era, so pytest never discovered anything new - New CI workflow ci-dev.yml gates PRs to develop using the published ghcr.io/cosmostat/shapepipe:develop image (ci-release.yml stays as-is for main/master) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b59e95 commit a16d253

9 files changed

Lines changed: 345 additions & 1 deletion

File tree

.github/workflows/ci-dev.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI (develop)
2+
3+
# Lightweight PR-time test suite that actually gates merges into develop.
4+
# Runs the unit tests under tests/ inside the published shapepipe container
5+
# — no conda bootstrap, no multi-OS matrix. Complements ci-release.yml
6+
# (which is a heavier conda-based suite gating main/master).
7+
8+
on:
9+
pull_request:
10+
branches:
11+
- develop
12+
push:
13+
branches:
14+
- develop
15+
16+
jobs:
17+
unit:
18+
name: Unit tests
19+
runs-on: ubuntu-latest
20+
container:
21+
image: ghcr.io/cosmostat/shapepipe:develop
22+
options: --user root
23+
24+
steps:
25+
- uses: actions/checkout@v4
26+
27+
- name: Reinstall shapepipe from the checkout
28+
# The container ships a snapshot of the repo under /app. Replace it
29+
# with the PR's checkout so tests run against the proposed code.
30+
run: |
31+
pip install --no-deps --force-reinstall -e .
32+
33+
- name: Run unit tests
34+
run: |
35+
pytest tests/unit/ -v --no-cov -p no:warnings

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,4 @@ canfar_monitor_log = "shapepipe.canfar_run:run_monitor_log"
5555

5656
[tool.pytest.ini_options]
5757
addopts = "--verbose --cov=shapepipe"
58-
testpaths = ["shapepipe"]
58+
testpaths = ["tests", "src/shapepipe/tests"]

tests/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Shared pytest fixtures for the repository-level test suite."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
8+
@pytest.fixture(scope="session")
9+
def repo_root() -> Path:
10+
"""Absolute path to the repository root.
11+
12+
Tests live at ``<root>/tests/``, so the root is two parents up
13+
from this conftest file.
14+
"""
15+
return Path(__file__).resolve().parent.parent

tests/unit/__init__.py

Whitespace-only changes.

tests/unit/test_config_parse.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Every example INI config must parse as a valid configparser file.
2+
3+
Uses ``RawConfigParser`` so ``$SP_RUN``-style variable references don't
4+
trigger interpolation errors. This catches malformed section headers,
5+
duplicate keys within a section, and missing ``=``/``:`` separators.
6+
"""
7+
8+
import configparser
9+
from pathlib import Path
10+
11+
import pytest
12+
13+
14+
def _config_files(root: Path) -> list[Path]:
15+
return sorted((root / "example").rglob("*.ini"))
16+
17+
18+
def pytest_generate_tests(metafunc):
19+
if "config_path" in metafunc.fixturenames:
20+
root = Path(__file__).resolve().parents[2]
21+
configs = _config_files(root)
22+
metafunc.parametrize(
23+
"config_path",
24+
configs,
25+
ids=[str(p.relative_to(root)) for p in configs],
26+
)
27+
28+
29+
def test_config_parses(config_path: Path) -> None:
30+
parser = configparser.RawConfigParser(strict=True)
31+
parser.read(config_path, encoding="utf-8")
32+
assert parser.sections(), (
33+
f"{config_path} parsed but has no sections — likely malformed"
34+
)

tests/unit/test_entrypoints.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Every ``[project.scripts]`` entry must resolve and handle ``-h`` cleanly.
2+
3+
The test skips entries whose command isn't on PATH — that's an install
4+
concern, not a code concern. When a command *is* installed, ``-h``
5+
must exit 0 or 2 (argparse's help exit code) with non-empty output.
6+
This catches entry points whose target function treats ``-h`` as a
7+
positional argument, or whose argparser is initialised so late the
8+
help flag never runs.
9+
"""
10+
11+
import shutil
12+
import subprocess
13+
import tomllib
14+
from pathlib import Path
15+
16+
import pytest
17+
18+
# Entry points known to mishandle ``-h`` — tracked via ``xfail`` so the
19+
# suite stays green and the issue is discoverable once fixed.
20+
KNOWN_XFAIL = {
21+
"summary_run":
22+
"treats '-h' as the 'patch' positional arg, tries to mkdir '-h'",
23+
}
24+
25+
26+
def _scripts_from_pyproject() -> dict[str, str]:
27+
root = Path(__file__).resolve().parents[2]
28+
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
29+
return pyproject.get("project", {}).get("scripts", {})
30+
31+
32+
def _params():
33+
for name in sorted(_scripts_from_pyproject().keys()):
34+
if name in KNOWN_XFAIL:
35+
yield pytest.param(
36+
name,
37+
marks=pytest.mark.xfail(
38+
reason=KNOWN_XFAIL[name], strict=True, raises=Exception
39+
),
40+
)
41+
else:
42+
yield name
43+
44+
45+
def pytest_generate_tests(metafunc):
46+
if "script_name" in metafunc.fixturenames:
47+
metafunc.parametrize("script_name", list(_params()))
48+
49+
50+
def test_entrypoint_help(script_name: str) -> None:
51+
if shutil.which(script_name) is None:
52+
pytest.skip(
53+
f"{script_name} not on PATH — not installed in this env"
54+
)
55+
result = subprocess.run(
56+
[script_name, "-h"],
57+
capture_output=True,
58+
text=True,
59+
timeout=30,
60+
)
61+
# argparse help exits 0; some parsers use 2. Either is fine so
62+
# long as *something* was printed, meaning the help path ran.
63+
assert result.returncode in (0, 2), (
64+
f"{script_name} -h exited {result.returncode}\n"
65+
f"stdout: {result.stdout}\nstderr: {result.stderr}"
66+
)
67+
assert result.stdout or result.stderr, (
68+
f"{script_name} -h produced no output"
69+
)

tests/unit/test_imports.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Every public ``shapepipe.*`` submodule must import cleanly.
2+
3+
Catches module-level syntax errors, typos in import paths, circular
4+
imports, and (crucially) the pattern where a module unconditionally
5+
imports an optional dependency — a ``try/except ImportError`` guard
6+
placed *after* a failing top-level import is unreachable.
7+
8+
Assumes the shapepipe package and its declared dependencies are
9+
installed (the official container, or ``pip install -e .``).
10+
"""
11+
12+
import importlib
13+
import pkgutil
14+
15+
import pytest
16+
17+
import shapepipe
18+
19+
# Modules with known-broken transitive imports — tracked so the suite
20+
# lands green but the failure is discoverable and auto-notifies (via
21+
# ``strict=True``) once the upstream fix lands.
22+
KNOWN_XFAIL = {
23+
"shapepipe.modules.mccd_package.mccd_plot_utilities":
24+
"stile v0.1 imports removed treecorr.corr2",
25+
"shapepipe.modules.mccd_plots_runner":
26+
"stile v0.1 imports removed treecorr.corr2",
27+
"shapepipe.modules.random_cat_package.random_cat":
28+
"stile v0.1 imports removed treecorr.corr2",
29+
"shapepipe.modules.random_cat_runner":
30+
"stile v0.1 imports removed treecorr.corr2",
31+
}
32+
33+
34+
def _iter_shapepipe_modules() -> list[str]:
35+
return sorted(
36+
m.name
37+
for m in pkgutil.walk_packages(
38+
shapepipe.__path__, prefix="shapepipe."
39+
)
40+
if "tests" not in m.name.split(".")
41+
)
42+
43+
44+
def _params():
45+
for name in _iter_shapepipe_modules():
46+
if name in KNOWN_XFAIL:
47+
yield pytest.param(
48+
name,
49+
marks=pytest.mark.xfail(
50+
reason=KNOWN_XFAIL[name], strict=True, raises=Exception
51+
),
52+
)
53+
else:
54+
yield name
55+
56+
57+
def pytest_generate_tests(metafunc):
58+
if "module_name" in metafunc.fixturenames:
59+
metafunc.parametrize("module_name", list(_params()))
60+
61+
62+
def test_module_imports(module_name: str) -> None:
63+
importlib.import_module(module_name)

tests/unit/test_runner_metadata.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Every ``*_runner.py`` module must export a function decorated with
2+
``@module_runner``.
3+
4+
The decorator attaches introspection metadata the pipeline needs
5+
(``version``, ``input_module``, ``file_pattern``, ``file_ext``,
6+
``depends``, ``executes``, ``numbering_scheme``, ``run_method``). A new
7+
runner that forgets the decorator loads silently but fails at dispatch
8+
time. This test catches the oversight at import time.
9+
10+
We locate the runner by looking for any top-level function that carries
11+
the metadata, since the function name doesn't always match the file
12+
name (e.g. ``pastecat_runner.py`` exports ``paste_cat_runner``).
13+
"""
14+
15+
import importlib
16+
import inspect
17+
import pkgutil
18+
19+
import pytest
20+
21+
import shapepipe.modules
22+
23+
REQUIRED_ATTRS = (
24+
"version",
25+
"input_module",
26+
"file_pattern",
27+
"file_ext",
28+
"depends",
29+
"executes",
30+
"numbering_scheme",
31+
"run_method",
32+
)
33+
34+
# Modules imported here but broken upstream — tracked separately.
35+
# Mapping: module name → xfail reason.
36+
KNOWN_XFAIL = {
37+
# Both reach stile, which imports treecorr.corr2 (removed in treecorr 5.x).
38+
"shapepipe.modules.mccd_plots_runner":
39+
"stile v0.1 imports removed treecorr.corr2",
40+
"shapepipe.modules.random_cat_runner":
41+
"stile v0.1 imports removed treecorr.corr2",
42+
}
43+
44+
45+
def _runner_modules() -> list[str]:
46+
return sorted(
47+
m.name
48+
for m in pkgutil.iter_modules(
49+
shapepipe.modules.__path__, prefix="shapepipe.modules."
50+
)
51+
if m.name.endswith("_runner")
52+
)
53+
54+
55+
def _params():
56+
for name in _runner_modules():
57+
if name in KNOWN_XFAIL:
58+
yield pytest.param(
59+
name,
60+
marks=pytest.mark.xfail(
61+
reason=KNOWN_XFAIL[name], strict=True, raises=Exception
62+
),
63+
)
64+
else:
65+
yield name
66+
67+
68+
def pytest_generate_tests(metafunc):
69+
if "runner_module" in metafunc.fixturenames:
70+
metafunc.parametrize("runner_module", list(_params()))
71+
72+
73+
def test_runner_has_metadata(runner_module: str) -> None:
74+
mod = importlib.import_module(runner_module)
75+
decorated = [
76+
obj
77+
for _, obj in inspect.getmembers(mod, inspect.isfunction)
78+
if obj.__module__ == runner_module
79+
and all(hasattr(obj, a) for a in REQUIRED_ATTRS)
80+
]
81+
assert decorated, (
82+
f"{runner_module} exports no function decorated with @module_runner "
83+
f"(required attrs: {REQUIRED_ATTRS})"
84+
)

tests/unit/test_shell_syntax.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Every shell script under scripts/ must parse under ``bash -n``.
2+
3+
Catches unclosed quotes, unbalanced brackets, stray parentheses, and the
4+
whole family of errors that only surface the moment someone runs the
5+
script. Parametrized one-test-per-file so CI output names the offender.
6+
"""
7+
8+
import subprocess
9+
from pathlib import Path
10+
11+
import pytest
12+
13+
14+
def _shell_scripts(root: Path) -> list[Path]:
15+
"""Return every ``.sh``/``.bash`` under ``scripts/``."""
16+
scripts_dir = root / "scripts"
17+
return sorted(
18+
p for p in scripts_dir.rglob("*")
19+
if p.is_file() and p.suffix in {".sh", ".bash"}
20+
)
21+
22+
23+
def pytest_generate_tests(metafunc):
24+
"""Parametrize per-script at collection time so we can name each file."""
25+
if "script_path" in metafunc.fixturenames:
26+
root = Path(__file__).resolve().parents[2]
27+
scripts = _shell_scripts(root)
28+
metafunc.parametrize(
29+
"script_path",
30+
scripts,
31+
ids=[str(p.relative_to(root)) for p in scripts],
32+
)
33+
34+
35+
def test_shell_script_parses(script_path: Path) -> None:
36+
"""bash -n must succeed."""
37+
result = subprocess.run(
38+
["bash", "-n", str(script_path)],
39+
capture_output=True,
40+
text=True,
41+
)
42+
assert result.returncode == 0, (
43+
f"bash -n failed on {script_path}:\n{result.stderr}"
44+
)

0 commit comments

Comments
 (0)