diff --git a/README.md b/README.md index 5ee85c9..313793b 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ so the command remains usable if formatting/bootstrap tasks are added later. |---|---| | `algorithm_name` | Human-readable algorithm name. | | `algorithm_slug` | Importable Python package and MEDS-DEV algorithm identifier. | +| `package_name` | PyPI distribution name; defaults to the slug with hyphens, independently of the import/CLI. | +| `repository_url`, `license_expression` | Canonical HTTPS URL and chosen SPDX license; may be blank initially but required for release, together with a LICENSE file. | | `algorithm_class` | Public class name; defaults to CamelCase from the slug. Set `RETAIN` explicitly to preserve that spelling. | | `profiles` | Nonempty list of supported profiles; each gets its own MEDS-DEV YAML. | | `algorithm_description` | Short description rendered into README and `algorithms//model.yaml`. | @@ -299,6 +301,11 @@ Always run: uv run pytest -rs +Also run `bash scripts/check_distribution.sh` to verify installed imports, CLI commands and bundled +defaults outside the checkout. Extend `tests/wheel_smoke.py` for required algorithm configuration or +resources. Follow RELEASING.md for publication readiness; package publication itself requires an explicit +user request. Do not choose a license on the user's behalf or bundle datasets, credentials or fitted weights. + If the selected scope includes MEDS-DEV integration or full-evaluation preparation, also run: uv run pytest -m meds_dev -rs @@ -410,6 +417,11 @@ and [shared-package assessment](https://github.com/McDermottHealthAI/MEDS_algori ## Changes +### Unreleased + +- Generate an algorithm release guide, Trusted Publishing workflow, and installed-distribution checks. +- Configure PyPI metadata separately from Python names and bundle portable run/profile YAMLs once at build time. + ### 0.4.0a2 (2026-11-03) - Generate projects using the published `meds-algorithm==0.4.0a5` dependency. diff --git a/TESTING.md b/TESTING.md index 8bb58d5..4c8d81e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -118,6 +118,17 @@ builder uses plain JavaScript without runtime dependencies, network calls or per ## Release checks outside pytest +### Generated algorithm release tests + +| Test | What it checks | Why it exists | +|---|---|---| +| `test_generated_distribution_installs_without_checkout` | Builds single- and multiple-profile projects through their generated distribution script, installs base and orchestration wheels, runs commands/dry-run outside the checkout, and inspects wheel/sdist resources. | Catch missing runtime files and dependencies, preserve distinct PyPI/import names, and ensure the source archive can build the shipped wheel. | +| `test_generated_release_gate_rejects_incomplete_projects_and_wrong_tags` | Rejects missing licenses/repository URLs, stubs, direct URL dependencies and incorrect tags; accepts a completed matching-tag fixture. | Keep incomplete or incorrectly versioned distributions out of publication. | +| `test_algorithm_release_guide_uses_generated_source` | Renders the generated release guide into the documentation site with example names. | Keep website and per-algorithm release instructions in sync. | + +The generated distribution smoke test checks packaging only; its synthetic empty-input dry-run never +trains an algorithm. Algorithm CI still runs the scientific acceptance tests separately. + `tests/wheel_smoke.py` installs/executes the generator against a local tagged template mirror. It checks single-profile and all-profile generation, compilation, canonical source URL and recorded release tag. It detects packaging and version/source mistakes without requiring a real release tag to exist yet. diff --git a/copier.yml b/copier.yml index 31798de..27e384a 100644 --- a/copier.yml +++ b/copier.yml @@ -14,6 +14,25 @@ algorithm_slug: validator: >- {% if not (algorithm_slug | regex_search('^[a-z][a-z0-9_]*$')) or algorithm_slug in ['config', 'meds_algorithm', 'meds_algorithm_template', 'keyword', 'pathlib', 'dataclasses', 'argparse', 'json', 'typing', 'abc', 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield'] %}Choose a non-reserved lowercase Python identifier.{% endif %} +package_name: + type: str + default: "{{ algorithm_slug | replace('_', '-') }}" + help: PyPI distribution name; the Python import and CLI still use algorithm_slug. + validator: >- + {% if not (package_name | regex_search('^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$')) %}Use a lowercase Python distribution name.{% endif %} + +repository_url: + type: str + default: "" + help: Canonical HTTPS repository URL. May be left blank during implementation; required before publishing. + validator: >- + {% if repository_url and not (repository_url | regex_search('^https://[^\\s]+$')) %}Use an HTTPS URL.{% endif %} + +license_expression: + type: str + default: "" + help: SPDX license expression, e.g. MIT, only after choosing a compatible license. Add its LICENSE file before publishing. + algorithm_class: type: str default: "{{ algorithm_slug | replace('_', ' ') | title | replace(' ', '') }}" diff --git a/docs/hooks.py b/docs/hooks.py index d703f88..b3029d0 100644 --- a/docs/hooks.py +++ b/docs/hooks.py @@ -4,10 +4,19 @@ import re from pathlib import Path +from jinja2 import Environment, StrictUndefined from meds_algorithm.spec import architecture, orchestration_guide, specification, validation_guide def on_page_markdown(markdown, page, **kwargs): + if page.file.src_uri == "publishing-algorithms.md": + source = Path(__file__).resolve().parents[1] / "template" / "RELEASING.md.jinja" + guide = ( + Environment(undefined=StrictUndefined) + .from_string(source.read_text(encoding="utf-8")) + .render(algorithm_name="My Algorithm", algorithm_slug="my_algorithm", package_name="my-algorithm") + ) + return markdown.replace("", guide) if page.file.src_uri == "running.md": return markdown.replace("", orchestration_guide()) if page.file.src_uri == "reference/spec.md": diff --git a/docs/publishing-algorithms.md b/docs/publishing-algorithms.md new file mode 100644 index 0000000..84e488a --- /dev/null +++ b/docs/publishing-algorithms.md @@ -0,0 +1,13 @@ +# Publishing an implemented algorithm + +Newly generated repositories include package metadata, a distribution check, a manual publishing +workflow, and their own `RELEASING.md`. Copier asks for `package_name`, `repository_url`, and +`license_expression`; the latter two may remain blank during implementation, but must be completed +before publishing. Licensing remains the algorithm author's choice. + +The guide below is rendered directly from the generated guide with example names, so these instructions +are maintained in one place. For an existing generated repository, use `copier update` and review its +changes, especially existing metadata and CI customizations. The current published generator 0.4.0a2 +predates this support; use the template revision containing this change until the next generator release. + + diff --git a/docs/releasing.md b/docs/releasing.md index 0d50606..9d1ac7e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1 +1,4 @@ +For an implemented algorithm repository, follow [Publishing an algorithm](publishing-algorithms.md). +This page is for maintainers releasing the shared core and template generator themselves. + --8<-- "RELEASING.md" diff --git a/mkdocs.yml b/mkdocs.yml index 417632c..664581e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,7 +79,8 @@ nav: - Core package layout: core-package.md - Template and shared package: shared-package-assessment.md - Migrating to version 4: migration-v4.md - - Publishing to PyPI: releasing.md + - Publishing an algorithm: publishing-algorithms.md + - Releasing core and template: releasing.md - Zero-shot algorithms: - Zero-shot in practice: zero-shot.md - Reference: diff --git a/template/.github/workflows/ci.yml b/template/.github/workflows/ci.yml index 398b1f6..52d447c 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -13,3 +13,4 @@ jobs: - uses: astral-sh/setup-uv@v6 - run: uv sync --extra test - run: uv run pytest -rs + - run: bash scripts/check_distribution.sh diff --git a/template/.github/workflows/publish.yml b/template/.github/workflows/publish.yml new file mode 100644 index 0000000..ce50c11 --- /dev/null +++ b/template/.github/workflows/publish.yml @@ -0,0 +1,59 @@ +name: Publish + +on: + workflow_dispatch: + inputs: + index: + description: Package index (dispatch from a reviewed v tag) + type: choice + options: [testpypi, pypi] + default: testpypi + required: true + +permissions: + contents: read + +concurrency: + group: publish-${{ github.ref }}-${{ inputs.index }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.11" + - name: Require a version tag on reviewed main history + run: | + test "$GITHUB_REF_TYPE" = tag + git merge-base --is-ancestor HEAD origin/main + - run: uv sync --locked --extra test + - run: uv run --no-sync python scripts/check_release.py + - run: uv run --no-sync pytest -rs + - run: bash scripts/check_distribution.sh + - uses: actions/upload-artifact@v4 + with: + name: distributions + path: dist/* + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: ${{ inputs.index }} + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: ${{ inputs.index == 'testpypi' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} diff --git a/template/.gitignore b/template/.gitignore index 444c623..cdabe7e 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -2,6 +2,8 @@ __pycache__/ .pytest_cache/ .venv/ *.egg-info/ +dist/ +build/ runs/**/logs/ runs/**/artifacts/ runs/**/checkpoints/ diff --git a/template/CHANGELOG.md b/template/CHANGELOG.md new file mode 100644 index 0000000..80c7bfc --- /dev/null +++ b/template/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changes + +## 0.1.0 (unreleased) + +Describe the implemented algorithm, supported profiles, and validation evidence before release. diff --git a/template/README.md.jinja b/template/README.md.jinja index 3fece2a..03c4ec1 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -69,6 +69,12 @@ for stage subsets, artifact reuse, resource configuration and prototype limitati per-step CLI and shell launcher remain available for comparison. This prototype does not implement Hydra multirun, multi-node training or checkpoint/resume. +## Publishing this algorithm + +Follow [the release guide](RELEASING.md) for package metadata, one-time PyPI Trusted Publisher setup, +installed-wheel checks, and version-tag publication. All profiles ship in the `{{ package_name }}` +distribution. Tests run in CI; publishing is an explicit workflow dispatch after implementation. + ## Inputs and predicates - `external_meds_dir` is the standard MEDS dataset boundary. diff --git a/template/RELEASING.md.jinja b/template/RELEASING.md.jinja new file mode 100644 index 0000000..4928db8 --- /dev/null +++ b/template/RELEASING.md.jinja @@ -0,0 +1,101 @@ +# Publishing {{ algorithm_name }} + +One PyPI distribution, `{{ package_name }}`, contains all declared profiles. Its Python import is +`{{ algorithm_slug }}` and its executable is `{{ algorithm_slug | replace('_', '-') }}`. Publishing the +algorithm does not require another release of the shared core or template. + +## Prepare once + +1. Choose an available PyPI name; edit `[project].name` in `pyproject.toml` if needed. The import and CLI + names need not match it. Update this guide and the CLI installation hint if changing the name later. +2. Set `project.urls.Repository`, choose an SPDX `project.license` expression compatible with your + implementation and upstream sources, and add the actual `LICENSE` file. Review the authors, + description and README. The template deliberately does not choose a license for you. +3. Declare every runtime dependency in `pyproject.toml`; release dependencies must be installable + from the package index, without Git/direct URL requirements. Commit `uv.lock` after `uv lock`. +4. Complete every declared profile and its scientific tests; remove completed profiles from + `STUB_PROFILES`. Publication rejects remaining stubs. Passing packaging checks does not establish + scientific correctness. Extend `tests/wheel_smoke.py` for required algorithm resources/configuration. +5. Merge `.github/workflows/publish.yml` into `main`. Create GitHub environments `testpypi` and `pypi`; + configure release reviewers/tag restrictions as appropriate for your repository. +6. On [PyPI](https://pypi.org/manage/account/publishing/) and separately on + [TestPyPI](https://test.pypi.org/manage/account/publishing/), register a pending GitHub publisher: + + | Field | Value | + |---|---| + | PyPI project name | `{{ package_name }}` | + | Owner | Your GitHub user or organization | + | Repository | This algorithm's GitHub repository name | + | Workflow filename | `publish.yml` | + | Environment | `pypi` on PyPI; `testpypi` on TestPyPI | + + For an existing PyPI project, add the publisher in its Publishing settings instead. No API token + is needed. See the [official setup guide](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/). + +## Check each release + +Update `[project].version` and `CHANGELOG.md`, then run these commands from the repository root +(Bash, with Git and uv installed): + +```bash +uv lock +uv sync --locked --extra test +uv run --no-sync pytest -rs +uv run --no-sync python scripts/check_release.py +bash scripts/check_distribution.sh +``` + +The distribution check builds a wheel from the source archive, validates metadata, installs the base +wheel in a fresh environment, checks imports/commands/resources from a temporary working directory, +then installs orchestration and checks a preprocessing dry-run. It runs no training or SLURM jobs. +It leaves the checked files in `dist/`; move old `dist/` contents elsewhere before repeating it. +Commit the version, lockfile, changelog and implementation changes; merge passing CI into `main`. + +## Tag, rehearse, publish + +Replace `OWNER/REPOSITORY` below. Set `version` to the exact version in `pyproject.toml`: + +```bash +repository=OWNER/REPOSITORY +version=0.1.0 +git switch main +git pull --ff-only +git tag -a "v$version" -m "Release $version" +git push origin "v$version" +gh workflow run publish.yml --repo "$repository" --ref "v$version" -f index=testpypi +gh run list --repo "$repository" --workflow publish.yml +``` + +Wait for success and verify installation from TestPyPI. Install dependencies from PyPI first, then +replace only this package from TestPyPI, avoiding mixed-index dependency resolution: + +```bash +uv venv /tmp/algorithm-release-check +uv pip install --python /tmp/algorithm-release-check/bin/python dist/*.whl +uv pip install --python /tmp/algorithm-release-check/bin/python --reinstall --no-deps \ + --index-url https://test.pypi.org/simple/ "{{ package_name }}==$version" +/tmp/algorithm-release-check/bin/python -I tests/wheel_smoke.py +gh workflow run publish.yml --repo "$repository" --ref "v$version" -f index=pypi +gh run list --repo "$repository" --workflow publish.yml +``` + +The workflow requires the matching version tag on `main` history, a committed lockfile, complete +profiles, passing tests, and valid installed distributions. A separate job uploads those exact checked +files using Trusted Publishing. Rehearsal and production runs each build and check their own artifacts +from the same tag. After success, install `{{ package_name }}==$version` in a fresh environment and +repeat the smoke check. Never move a published tag or reuse a published version; increment it for fixes. + +## What installation includes + +`configs/runs/` and `algorithms/` are the single editable source for portable run defaults and MEDS-DEV +YAMLs. The wheel copies them to `{{ algorithm_slug }}/resources/` during its build; the source archive +includes the originals. No second copy is maintained in Git. + +The runner uses `./configs/runs` when present, otherwise its installed defaults. `--config-dir` explicitly +selects user configuration. Supply dataset paths and run settings as overrides or external configuration; +do not put dataset-specific paths, credentials, labels, datasets or fitted weights in bundled defaults. +The source archive uses an explicit file allowlist; review its contents before publishing. + +Access installed profile YAMLs with `importlib.resources.files("{{ algorithm_slug }}") / "resources" / +"algorithms"`. SLURM shell scripts and implementation reports remain checkout tooling. Large pretrained +weights should be retrieved as separately versioned artifacts, not silently embedded in a wheel. diff --git a/template/docs/TESTING.md b/template/docs/TESTING.md index d4e3e79..bdf1d02 100644 --- a/template/docs/TESTING.md +++ b/template/docs/TESTING.md @@ -43,6 +43,19 @@ Replace it with positive-signal and shuffled-label control runs of the real algo the signal reaches its actual features. It runs for every registered profile, including stubs. Structural conformance cannot establish scientific behavior or fidelity to a paper. +## Distribution checks + +`bash scripts/check_distribution.sh` builds a wheel from the source archive, checks its metadata and +installs it in an isolated environment. `tests/wheel_smoke.py` verifies public imports, CLI profile/command +discovery and packaged run/profile YAMLs outside the checkout. With `--orchestration`, it also verifies +the installed runner resolves a preprocessing plan without creating outputs. Extend it with representative +config overrides when the concrete algorithm has required configuration fields. These checks catch missing +files/dependencies; they do not execute or validate the scientific algorithm. + +`scripts/check_release.py` additionally rejects missing license/repository metadata, direct URL +dependencies, unfinished declared profiles and mismatched GitHub release tags. The publication workflow +also requires a committed lockfile and a tag on main history. See the generated `RELEASING.md` guide. + ## Algorithm-specific tests to add Add source-backed tests for numerical behavior, temporal cutoffs, feature selection, frozen pretrained diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index fe1a0c2..f648258 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -1,11 +1,16 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [project] -name = "{{ algorithm_slug | replace('_', '-') }}" +name = {{ package_name | tojson }} version = "0.1.0" -description = "{{ algorithm_description }}" +description = {{ algorithm_description | tojson }} +readme = "README.md" +authors = [{name = {{ author_name | tojson }}, email = {{ author_email | tojson }}}] +{% if license_expression %}license = {{ license_expression | tojson }} +license-files = ["LICENSE*"] +{% endif %} requires-python = ">=3.11" dependencies = ["pyyaml>=6", "meds-algorithm[data]==0.4.0a5"] @@ -14,12 +19,23 @@ orchestration = ["meds-algorithm[orchestration]==0.4.0a5"] # meds-evaluation is pinned to the version MEDS-DEV evaluates predictions with. test = ["meds-algorithm[conformance]==0.4.0a5"] -[project.scripts] +{% if repository_url %}[project.urls] +Repository = {{ repository_url | tojson }} + +{% endif %}[project.scripts] {{ algorithm_slug | replace('_', '-') }} = "{{ algorithm_slug }}.__main__:main" [tool.hatch.build.targets.wheel] packages = ["src/{{ algorithm_slug }}"] +# Keep one editable source; copy only portable defaults into the installed wheel. +[tool.hatch.build.targets.wheel.force-include] +"configs/runs" = "{{ algorithm_slug }}/resources/runs" +"algorithms" = "{{ algorithm_slug }}/resources/algorithms" + +[tool.hatch.build.targets.sdist] +include = ["/src", "/configs/runs", "/algorithms", "/tests", "/scripts", "/docs", "/README.md", "/RELEASING.md", "/CHANGELOG.md", "/LICENSE*", "/pyproject.toml", "/uv.lock"] + [tool.pytest.ini_options] testpaths = ["tests"] markers = [ diff --git a/template/scripts/check_distribution.sh b/template/scripts/check_distribution.sh new file mode 100644 index 0000000..be55e41 --- /dev/null +++ b/template/scripts/check_distribution.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build wheel from the sdist, then exercise installed artifacts outside the checkout. +set -euo pipefail +if compgen -G 'dist/*' > /dev/null; then + echo 'dist/ must be empty before checking a release; move previous builds elsewhere.' >&2 + exit 1 +fi +verification=$(mktemp -d) +uv build --out-dir "$verification/dist" +uvx --from 'twine>=6' twine check --strict "$verification"/dist/* +uv venv "$verification/env" +wheels=("$verification"/dist/*.whl) +uv pip install --python "$verification/env/bin/python" "${wheels[0]}" +"$verification/env/bin/python" -I tests/wheel_smoke.py +uv pip install --python "$verification/env/bin/python" "${wheels[0]}[orchestration]" +uv pip check --python "$verification/env/bin/python" +"$verification/env/bin/python" -I tests/wheel_smoke.py --orchestration +# Only these checked distributions are uploaded by the publishing job. +mkdir -p dist +cp "$verification"/dist/* dist/ diff --git a/template/scripts/check_release.py.jinja b/template/scripts/check_release.py.jinja new file mode 100644 index 0000000..d3ccc60 --- /dev/null +++ b/template/scripts/check_release.py.jinja @@ -0,0 +1,32 @@ +"""Reject incomplete metadata, unfinished profiles, and mismatched release tags.""" + +import os +import tomllib +from pathlib import Path + +from {{ algorithm_slug }}.{{ algorithm_slug }} import STUB_PROFILES + + +def main(): + root = Path(__file__).resolve().parents[1] + project = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))["project"] + if not project.get("license") or not any(root.glob("LICENSE*")): + raise SystemExit("Choose an SPDX license expression and add its LICENSE file before publishing.") + repository = project.get("urls", {}).get("Repository", "") + if not repository.startswith("https://"): + raise SystemExit("Set project.urls.Repository to the canonical HTTPS repository URL.") + if STUB_PROFILES: + raise SystemExit(f"Finish declared profiles before publishing: {sorted(STUB_PROFILES)}") + dependencies = [*project.get("dependencies", [])] + for extra in project.get("optional-dependencies", {}).values(): + dependencies.extend(extra) + if any("@" in dependency for dependency in dependencies): + raise SystemExit("Use index-installable dependencies rather than direct URL or Git requirements.") + ref = os.environ.get("GITHUB_REF") + if ref is not None and ref != f"refs/tags/v{project['version']}": + raise SystemExit("Dispatch publication from the matching v tag.") + print(f"Release metadata and profile completion verified: {project['name']} {project['version']}") + + +if __name__ == "__main__": + main() diff --git a/template/src/{{ algorithm_slug }}/__main__.py.jinja b/template/src/{{ algorithm_slug }}/__main__.py.jinja index 0a315ef..8bb7f72 100644 --- a/template/src/{{ algorithm_slug }}/__main__.py.jinja +++ b/template/src/{{ algorithm_slug }}/__main__.py.jinja @@ -3,6 +3,7 @@ import argparse import json import sys +from pathlib import Path from .{{ algorithm_slug }} import create_repository @@ -23,8 +24,12 @@ def main() -> None: try: from meds_algorithm.orchestration.cli import main as run_profile except ImportError as error: - raise SystemExit("Install the prototype runner with: uv sync --extra orchestration") from error - run_profile(repository, module="{{ algorithm_slug }}", argv=sys.argv[2:]) + raise SystemExit('Install the runner with: pip install "{{ package_name }}[orchestration]" (or uv sync --extra orchestration in a checkout)') from error + arguments = sys.argv[2:] + explicit_directory = any(arg == "--config-dir" or arg.startswith("--config-dir=") for arg in arguments) + if not explicit_directory and not (Path.cwd() / "configs" / "runs").is_dir(): + arguments = ["--config-dir", str(Path(__file__).parent / "resources" / "runs"), *arguments] + run_profile(repository, module="{{ algorithm_slug }}", argv=arguments) return parser = argparse.ArgumentParser(description="Run one declared MEDS algorithm profile") parser.add_argument("command", help="run (prototype), profiles, commands, or a step name") diff --git a/template/tests/wheel_smoke.py.jinja b/template/tests/wheel_smoke.py.jinja new file mode 100644 index 0000000..cf49aee --- /dev/null +++ b/template/tests/wheel_smoke.py.jinja @@ -0,0 +1,55 @@ +"""Run with the installed wheel's Python using -I; no source checkout on sys.path. + +This checks packaging, not scientific correctness. Extend for required algorithm resources. +""" + +import argparse +import json +import subprocess +import sys +import tempfile +from importlib.resources import files +from pathlib import Path + +from {{ algorithm_slug }} import {{ algorithm_class }}, {{ algorithm_class }}Config +from {{ algorithm_slug }}.{{ algorithm_slug }} import create_repository + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--orchestration", action="store_true") + args = parser.parse_args() + assert {{ algorithm_class }}.__module__.startswith("{{ algorithm_slug }}.") + assert {{ algorithm_class }}Config.__module__.startswith("{{ algorithm_slug }}.") + repository = create_repository() + resources = files("{{ algorithm_slug }}") / "resources" + assert (resources / "runs" / "run.yaml").is_file() + for profile in repository.profiles: + assert (resources / "algorithms" / profile / "model.yaml").is_file() + executable = Path(sys.executable).parent / ("{{ algorithm_slug | replace('_', '-') }}" + (".exe" if sys.platform == "win32" else "")) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + result = subprocess.run([str(executable), "profiles"], cwd=root, check=True, text=True, capture_output=True) + assert result.stdout.splitlines() == list(repository.profiles) + for profile in repository.profiles: + result = subprocess.run([str(executable), "commands", "--profile", profile], cwd=root, check=True, text=True, capture_output=True) + assert result.stdout.splitlines() == list(repository.profile_commands(profile)) + if args.orchestration: + # A preprocessing-only dry run requires no fitted artifacts or task-specific labels. + # Algorithms with mandatory configuration fields should add representative overrides here. + (root / "inputs").mkdir() + (root / "predicates.yaml").write_text("predicates: {}\n", encoding="utf-8") + result = subprocess.run( + [str(executable), "run", "--dry-run", f"profile={next(iter(repository.profiles))}", + "stages=[preprocess_data]", f"output_dir={root / 'planned'}", + f"inputs.meds_dir={root / 'inputs'}", f"inputs.predicates_file={root / 'predicates.yaml'}"], + cwd=root, check=True, text=True, capture_output=True, + ) + plan = json.loads(result.stdout) + assert [stage["name"] for stage in plan["stages"]] == ["preprocess_data"] + assert not (root / "planned").exists() + print("Installed imports, commands, packaged resources and requested dry-run checks passed.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_algorithm_release.py b/tests/test_algorithm_release.py new file mode 100644 index 0000000..2e80c09 --- /dev/null +++ b/tests/test_algorithm_release.py @@ -0,0 +1,104 @@ +"""Generated releases must work without the checkout and reject incomplete publication.""" + +import os +import subprocess +import sys +import tarfile +import tomllib +import zipfile + +import pytest +import yaml +from copier import run_copy +from test_render import REPO + + +def release_project(tmp_path, profiles): + destination = tmp_path / "algorithm" + run_copy( + str(REPO), + destination, + data={ + "algorithm_slug": "release_example", + "algorithm_class": "ReleaseExample", + "package_name": "different-pypi-name", + "algorithm_description": 'An "auditable" algorithm', + "repository_url": "https://github.com/example/algorithm", + "license_expression": "MIT", + "profiles": profiles, + }, + defaults=True, + unsafe=True, + quiet=True, + ) + return destination + + +@pytest.mark.parametrize("profiles", [["supervised"], ["finetune", "probe"]]) +def test_generated_distribution_installs_without_checkout(tmp_path, profiles): + destination = release_project(tmp_path, profiles) + (destination / "LICENSE").write_text("MIT License\nCopyright Test fixture\n") + for name in ("datasets", "runs"): + (destination / name).mkdir() + (destination / name / "private-fixture.txt").write_text("Must not be distributed") + # Run the exact gate used by generated CI/publishing, including sdist -> wheel and both installs. + result = subprocess.run( + ["bash", "scripts/check_distribution.sh"], + cwd=destination, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + project = tomllib.loads((destination / "pyproject.toml").read_text())["project"] + assert project["name"] == "different-pypi-name" + assert project["description"] == 'An "auditable" algorithm' + with zipfile.ZipFile(next((destination / "dist").glob("*.whl"))) as wheel: + assert "release_example/resources/runs/run.yaml" in wheel.namelist() + for profile in profiles: + assert f"release_example/resources/algorithms/{profile}/model.yaml" in wheel.namelist() + assert not any(name.startswith(("tests/", "runs/", "slurm/")) for name in wheel.namelist()) + with tarfile.open(next((destination / "dist").glob("*.tar.gz"))) as archive: + assert any(name.endswith("/configs/runs/run.yaml") for name in archive.getnames()) + assert not any("private-fixture.txt" in name for name in archive.getnames()) + workflow = yaml.safe_load((destination / ".github/workflows/publish.yml").read_text()) + assert workflow["jobs"]["publish"]["needs"] == "build" + assert workflow["jobs"]["publish"]["permissions"] == {"id-token": "write"} + assert workflow["jobs"]["publish"]["environment"]["name"] == "${{ inputs.index }}" + + +def test_generated_release_gate_rejects_incomplete_projects_and_wrong_tags(tmp_path): + destination = release_project(tmp_path, ["supervised"]) + environment = {**os.environ, "PYTHONPATH": str(destination / "src")} + environment.pop("GITHUB_REF", None) + + def check(): + return subprocess.run( + [sys.executable, "scripts/check_release.py"], + cwd=destination, + env=environment, + text=True, + capture_output=True, + ) + + assert "LICENSE" in check().stderr + (destination / "LICENSE").write_text("MIT License\n") + assert "Finish declared profiles" in check().stderr + implementation = destination / "src/release_example/release_example.py" + implementation.write_text( + implementation.read_text().replace("STUB_PROFILES = set(PROFILES)", "STUB_PROFILES = set()") + ) + assert check().returncode == 0 + environment["GITHUB_REF"] = "refs/heads/main" + assert "matching" in check().stderr + environment["GITHUB_REF"] = "refs/tags/v9.9.9" + assert "matching" in check().stderr + environment["GITHUB_REF"] = "refs/tags/v0.1.0" + assert check().returncode == 0 + metadata = destination / "pyproject.toml" + original = metadata.read_text() + metadata.write_text( + original.replace('Repository = "https://github.com/example/algorithm"', 'Repository = ""') + ) + assert "Repository" in check().stderr + metadata.write_text(original.replace('"pyyaml>=6"', '"example @ https://example.org/example.whl"')) + assert "index-installable" in check().stderr diff --git a/tests/test_docs.py b/tests/test_docs.py index a62cea1..1d841d2 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -30,6 +30,16 @@ def test_prompt_builder_embeds_the_canonical_readme_prompt(): assert "" not in result +def test_algorithm_release_guide_uses_generated_source(): + hooks = load_hooks() + page = SimpleNamespace(file=SimpleNamespace(src_uri="publishing-algorithms.md")) + result = hooks.on_page_markdown((ROOT / "docs/publishing-algorithms.md").read_text(), page) + assert "" not in result + assert "{{" not in result + assert "scripts/check_distribution.sh" in result + assert "my-algorithm==$version" in result + + def test_prompt_source_cannot_close_its_json_script_element(tmp_path, monkeypatch): hooks = load_hooks() monkeypatch.setattr(hooks, "__file__", str(tmp_path / "docs/hooks.py"))