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
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.git
.github
.venv
tests
__pycache__
*.pyc
.ruff_cache
.pytest_cache
.pre-commit-config.yaml
Dockerfile
.dockerignore
77 changes: 77 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Publish container image
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7

- name: Resolve the image name
# The owner is mixed case (UMCUGenetics) but Docker repository names must be lowercase.
# metadata-action lowercases its own output, plain `docker` commands do not -- so lowercase
# it once here and let every step share it.
run: |
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${owner}/prs-utils" >> "$GITHUB_ENV"

- name: Set up QEMU
# Needed to build the linux/arm64 image on an amd64 runner.
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# The Nextflow modules pin the full version, e.g. 1.0.0. The major/minor aliases and
# latest exist for convenience only -- do not pin a module against them.
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}

- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Verify the published image reports the release version
# The Nextflow modules emit `prs-utils --version` into their versions topic, so a tag that
# disagrees with the packaged version must not survive a release.
if: github.event_name == 'release'
run: |
set -euo pipefail
image="${REGISTRY}/${IMAGE_NAME}:${GITHUB_REF_NAME#v}"
reported=$(docker run --rm "$image" prs-utils --version)
echo "Image $image reports version: $reported"
if [ "$reported" != "${GITHUB_REF_NAME#v}" ]; then
echo "::error::Image reports '$reported' but the release tag is '${GITHUB_REF_NAME#v}'."
echo "::error::Bump 'version' in pyproject.toml to match the tag."
exit 1
fi
35 changes: 35 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Build the virtualenv from uv.lock so the image ships exactly the dependency set the tests ran
# against, rather than re-resolving at runtime the way `uv run --script` used to.
# The uv tag pins uv itself (pyproject requires >= 0.11.28) and the Python minor; the runtime stage
# must stay on the same Python minor and Debian release, because the venv is copied, not rebuilt.
FROM ghcr.io/astral-sh/uv:0.12.1-python3.13-trixie-slim AS builder

ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy

WORKDIR /app

# Dependencies first, so this layer caches independently of source changes.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev

# README.md is required by the build backend -- pyproject sets `readme = "README.md"`.
COPY README.md ./
COPY src ./src
# --no-editable copies the package into the venv instead of linking back to /app/src, so the
# runtime stage below only needs the venv.
RUN uv sync --frozen --no-dev --no-editable


FROM python:3.13-slim-trixie

# Nextflow's .command.run polls `ps` to collect per-task CPU and memory metrics; the slim base
# image does not ship it. Installed before the venv copy so this layer caches across code changes.
RUN apt-get update \
&& apt-get install -y --no-install-recommends procps \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"

CMD ["prs-utils", "--help"]
109 changes: 86 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,109 @@
# python_template
# prs_utils

![test](https://github.com/UMCUGenetics/python_template/actions/workflows/test.yml/badge.svg)
![lint](https://github.com/UMCUGenetics/python_template/actions/workflows/lint.yml/badge.svg)
![test](https://github.com/UMCUGenetics/prs_utils/actions/workflows/test.yml/badge.svg)
![lint](https://github.com/UMCUGenetics/prs_utils/actions/workflows/lint.yml/badge.svg)

Python template project - This repository can be used as a starting point / guide on how to setup a python repository, including automated tests and code formatting.
Utility commands for the PRS pipeline, bundled as a single installable package.

## GitHub repository creation
These utilities previously lived as standalone [PEP 723](https://peps.python.org/pep-0723/)
scripts inside the `prsutils` Nextflow modules
(`NF-Modules/modules/UMCUGenetics/prsutils/*/resources/usr/bin/`), each with its own
`uv run --script` header and lock file. They are now one package with shared dependency
resolution, linting and tests. The behaviour of every command is unchanged.

- Repository name: `Modules should have short, all-lowercase names. Underscores can be used in the module name if it improves readability. Python packages should also have short, all-lowercase names, although the use of underscores is discouraged.`
- Add README
- Add .gitignore: `python`
- Add License: `MIT`
## Commands

Make sure to perform the following actions, after creating a new github repo:
Everything is reachable through the `prs-utils` entry point:

- Create a develop branch.
- Configure branch protection rules.
| Subcommand | Original script | NF module |
| --- | --- | --- |
| `prs-utils pgs-to-vcf` | `pgs_to_vcf.py` | `prsutils/getvcf` |
| `prs-utils merge-prs-mqc` | `merge_prs_mqc.py` | `prsutils/mergeprsmqc` |
| `prs-utils normalise-counts` | `normalise_counts.py` | `prsutils/norm` |
| `prs-utils sample-qc` | `sample_qc.py` | `prsutils/sampleqc` |
| `prs-utils get-snp-list` | `get_snp_list.py` | `prsutils/snplist` |

## UV
Each subcommand keeps the argument parser it was written with, so the flags are identical to
the standalone scripts. Use `prs-utils <subcommand> --help` for the per-command options.

UV supports packaged and unpackaged applications, this repository show cases a packaged application initiated with the command: `uv init --package .`. To create a simpler unpackaged application use: `uv init .`. Unpackaged applications can be used for single file scripts/tools, while packaged applications are used for (larger) tools requiring multiple files, distribution (pip) and tests separation.
```sh
uv run prs-utils --help
uv run prs-utils normalise-counts --PRS scores.tsv --mu 0.5 --SD 0.1 -o normalised.tsv
uv run prs-utils get-snp-list --scoring_file PGS000004.txt --prefix PGS000004 --flank 100
```

Note that `merge-prs-mqc` takes its inputs as bare positional paths and always writes
`prs_scores_mqc.tsv` in the working directory.

Setup uv package and development dependencies:
## Container image

Each release publishes a multi-arch image to
`ghcr.io/umcugenetics/prs-utils:<version>`, built from `uv.lock` so the dependency set is exactly
the one the tests ran against. This is what the `prsutils` Nextflow modules pin their `container`
directive to.

```sh
uv init --package .
uv add --dev ruff
uv add --dev pytest
docker run --rm -v "$PWD:/data" -w /data ghcr.io/umcugenetics/prs-utils:1.0.0 \
prs-utils normalise-counts --PRS scores.tsv --mu 0.5 --SD 0.1 -o normalised.tsv
```

Run pytest and the python-template tool:
Singularity pulls the same image directly:

```sh
singularity exec docker://ghcr.io/umcugenetics/prs-utils:1.0.0 prs-utils --version
```

Build it locally without publishing:

```sh
docker build -t prs-utils:dev .
```

## Releasing

`prs-utils --version` prints the bare version from `pyproject.toml`, and the Nextflow modules
capture that output into their `versions` topic and snapshot it. Keep the two repositories in step:

1. Bump `version` in `pyproject.toml`.
2. Run `uv lock` and commit the updated `uv.lock`.
3. Tag and publish a GitHub release. The `publish.yml` workflow builds and pushes the image, then
fails the release if the image does not report the tagged version.
4. Confirm the new tag appears under the repository's Packages.
5. Bump the `container` tag in the five `prsutils` modules in
[UMCUGenetics/NF-Modules](https://github.com/UMCUGenetics/NF-Modules) and re-run their nf-tests.

The first published version needs one manual step: set the GHCR package visibility to **public**
(Package settings → Change visibility). Otherwise every `singularity pull` in CI and on the HPC
needs registry credentials.

## Development

Setup and run the test suite:

```sh
uv sync
uv run pytest tests
uv run python-template World
uv run python-template --help
```

Linting and formatting use [Ruff](https://docs.astral.sh/ruff/):

```sh
uv run ruff check src tests
uv run ruff format src tests
```

## GitHub Actions

This template project contains two GitHub actions workflows (`.github/workflos/`): `lint.yml` and `test.yml`. The lint workflow uses the [ruff-action](https://github.com/astral-sh/ruff-action) action to run ruff. The test workflow uses the [setup-uv action](https://github.com/astral-sh/setup-uv) to setup uv, install dependencies and run tests. Both actions are configured to run on each pull request and push to main and develop.
This repository contains two GitHub actions workflows (`.github/workflows/`): `lint.yml` and
`test.yml`. The lint workflow uses the [ruff-action](https://github.com/astral-sh/ruff-action)
action to run ruff. The test workflow uses the
[setup-uv action](https://github.com/astral-sh/setup-uv) to setup uv, install dependencies and
run tests. Both actions are configured to run on each pull request and push to main and develop.

## pre-commit

Git pre-commit hooks enable you to run certain commands before each commit and can be used to check code style before committing. The file `.pre-commit-config.yaml` contains the [Ruff](https://docs.astral.sh/ruff/) [pre-commit](https://pre-commit.com) hook, which will automatically run Ruff before each commit. Run the following command to install the git commit hook: `uvx pre-commit install`.
Git pre-commit hooks enable you to run certain commands before each commit and can be used to
check code style before committing. The file `.pre-commit-config.yaml` contains the
[Ruff](https://docs.astral.sh/ruff/) [pre-commit](https://pre-commit.com) hook, which will
automatically run Ruff before each commit. Run the following command to install the git commit
hook: `uvx pre-commit install`.
11 changes: 6 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
[project]
name = "python-template"
version = "0.1.0"
description = "Python template project."
name = "prs-utils"
version = "1.0.0"
description = "Utility commands used by the UMCUGenetics prsutils Nextflow modules."
readme = "README.md"
authors = [
{ name = "Bioinformatica Genetica", email = "bioinformatica-genetica@umcutrecht.nl" }
]
requires-python = "~=3.14.0"
requires-python = ">=3.12"
dependencies = [
"pandas>=2.2",
"typer>=0.26.8",
]

Expand All @@ -19,7 +20,7 @@ dev = [
]

[project.scripts]
python-template = "python_template.cli:cli"
prs-utils = "prs_utils.cli:cli"

[build-system]
requires = ["uv_build>=0.11.27,<0.12"]
Expand Down
File renamed without changes.
88 changes: 88 additions & 0 deletions src/prs_utils/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Single entry point bundling the PRS pipeline utility scripts as subcommands."""

from importlib.metadata import version as package_version
from typing import Annotated

import typer

from prs_utils import get_snp_list, merge_prs_mqc, normalise_counts, pgs_to_vcf, sample_qc

cli = typer.Typer(add_completion=False, help="Utilities for the PRS pipeline.")

# These subcommands own their own argument parsing (argparse / sys.argv), so Click must not
# touch anything after the subcommand name -- not even --help, which is forwarded verbatim.
_PASSTHROUGH = {
"allow_extra_args": True,
"ignore_unknown_options": True,
"help_option_names": [],
}


def _version_callback(show_version: bool) -> None:
# The Nextflow modules capture this output into their `versions` topic, so it must be the
# bare version and nothing else.
if show_version:
typer.echo(package_version("prs-utils"))
raise typer.Exit()


@cli.callback()
def _cli_options(
version: Annotated[
bool,
typer.Option(
"--version",
callback=_version_callback,
is_eager=True,
help="Show the package version and exit.",
),
] = False,
) -> None:
"""Utilities for the PRS pipeline."""


@cli.command(
"pgs-to-vcf",
context_settings=_PASSTHROUGH,
help="Convert a PGS Catalog scoring file to a VCF (REF=other_allele, ALT=effect_allele).",
)
def _pgs_to_vcf(ctx: typer.Context) -> None:
pgs_to_vcf.main(ctx.args)


@cli.command(
"merge-prs-mqc",
context_settings=_PASSTHROUGH,
help="Merge per-sample QC tables into a MultiQC table (writes prs_scores_mqc.tsv).",
)
def _merge_prs_mqc(ctx: typer.Context) -> None:
merge_prs_mqc.main(ctx.args)


@cli.command(
"normalise-counts",
context_settings=_PASSTHROUGH,
help="Add a SUM_Z Z-score column to a PRS score table.",
)
def _normalise_counts(ctx: typer.Context) -> None:
normalise_counts.main(ctx.args)


@cli.command(
"get-snp-list",
context_settings=_PASSTHROUGH,
help="Write a flanked region list from the positions in a scoring file.",
)
def _get_snp_list(ctx: typer.Context) -> None:
get_snp_list.main(ctx.args)


# sample_qc is already a Typer command function -- reuse it as-is so its options are unchanged.
cli.command(
"sample-qc",
help="Flag samples based on PRS Z-score and ancestry thresholds.",
)(sample_qc.main)


if __name__ == "__main__":
cli()
Loading