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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<profile>/model.yaml`. |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ', '') }}"
Expand Down
9 changes: 9 additions & 0 deletions docs/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!-- algorithm-release-guide -->", guide)
if page.file.src_uri == "running.md":
return markdown.replace("<!-- orchestration-guide -->", orchestration_guide())
if page.file.src_uri == "reference/spec.md":
Expand Down
13 changes: 13 additions & 0 deletions docs/publishing-algorithms.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- algorithm-release-guide -->
3 changes: 3 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 2 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions template/.github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions template/.github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Publish

on:
workflow_dispatch:
inputs:
index:
description: Package index (dispatch from a reviewed v<version> 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/' }}
2 changes: 2 additions & 0 deletions template/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ __pycache__/
.pytest_cache/
.venv/
*.egg-info/
dist/
build/
runs/**/logs/
runs/**/artifacts/
runs/**/checkpoints/
Expand Down
5 changes: 5 additions & 0 deletions template/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changes

## 0.1.0 (unreleased)

Describe the implemented algorithm, supported profiles, and validation evidence before release.
6 changes: 6 additions & 0 deletions template/README.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 101 additions & 0 deletions template/RELEASING.md.jinja
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions template/docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
@@ -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"]

Expand All @@ -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 = [
Expand Down
Loading
Loading