From d5a007f365cd8bd3e59dec70ecb942fb71f88bef Mon Sep 17 00:00:00 2001 From: Jorisvansteenbrugge <7196110+Jorisvansteenbrugge@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:28:12 +0200 Subject: [PATCH 1/3] prs utils scripts --- .dockerignore | 11 + .github/workflows/publish.yml | 70 ++++++ Dockerfile | 29 +++ README.md | 109 +++++++-- pyproject.toml | 11 +- .../__init__.py | 0 src/prs_utils/cli.py | 88 +++++++ src/prs_utils/get_snp_list.py | 50 ++++ src/prs_utils/merge_prs_mqc.py | 75 ++++++ src/prs_utils/normalise_counts.py | 33 +++ src/prs_utils/pgs_to_vcf.py | 134 +++++++++++ src/prs_utils/sample_qc.py | 157 +++++++++++++ src/python_template/cli.py | 30 --- tests/test_cli.py | 49 +++- tests/test_get_snp_list.py | 44 ++++ tests/test_merge_prs_mqc.py | 65 ++++++ tests/test_normalise_counts.py | 23 ++ tests/test_pgs_to_vcf.py | 75 ++++++ tests/test_sample_qc.py | 82 +++++++ uv.lock | 220 ++++++++++++++++-- 20 files changed, 1272 insertions(+), 83 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/publish.yml create mode 100644 Dockerfile rename src/{python_template => prs_utils}/__init__.py (100%) create mode 100644 src/prs_utils/cli.py create mode 100644 src/prs_utils/get_snp_list.py create mode 100644 src/prs_utils/merge_prs_mqc.py create mode 100644 src/prs_utils/normalise_counts.py create mode 100644 src/prs_utils/pgs_to_vcf.py create mode 100644 src/prs_utils/sample_qc.py delete mode 100644 src/python_template/cli.py create mode 100644 tests/test_get_snp_list.py create mode 100644 tests/test_merge_prs_mqc.py create mode 100644 tests/test_normalise_counts.py create mode 100644 tests/test_pgs_to_vcf.py create mode 100644 tests/test_sample_qc.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..186739f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +.venv +tests +__pycache__ +*.pyc +.ruff_cache +.pytest_cache +.pre-commit-config.yaml +Dockerfile +.dockerignore diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0899366 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,70 @@ +name: Publish container image +on: + release: + types: [published] + workflow_dispatch: +permissions: + contents: read + packages: write +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/prs-utils +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc552da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# 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 + +COPY --from=builder /app/.venv /app/.venv +ENV PATH="/app/.venv/bin:$PATH" + +CMD ["prs-utils", "--help"] diff --git a/README.md b/README.md index e53864c..c1a0763 100644 --- a/README.md +++ b/README.md @@ -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 --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:`, 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`. diff --git a/pyproject.toml b/pyproject.toml index 6f9cf08..1c9e6de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -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"] diff --git a/src/python_template/__init__.py b/src/prs_utils/__init__.py similarity index 100% rename from src/python_template/__init__.py rename to src/prs_utils/__init__.py diff --git a/src/prs_utils/cli.py b/src/prs_utils/cli.py new file mode 100644 index 0000000..8a03314 --- /dev/null +++ b/src/prs_utils/cli.py @@ -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() diff --git a/src/prs_utils/get_snp_list.py b/src/prs_utils/get_snp_list.py new file mode 100644 index 0000000..bf7a64d --- /dev/null +++ b/src/prs_utils/get_snp_list.py @@ -0,0 +1,50 @@ +from argparse import ArgumentParser + +import pandas as pd + + +def positions_to_tsv(scoring_file, chr_colname, pos_colname, prefix, flank=100): + df = pd.read_csv(scoring_file, sep="\t", comment="#") + + with open(f"{prefix}_snplist.list", "w") as outfile: + for index, row in df.iterrows(): + chrom = row[chr_colname] + pos = int(row[pos_colname]) + + start = pos - flank + end = pos + flank + + # Skip rows with missing chromosome or position + if not chrom or not pos: + continue + + # Ensure chromosome has 'chr' prefix + chr_prefix = "chr" + if "chr" in str(chrom): + chr_prefix = "" + + tsv_line = f"{chr_prefix}{chrom}:{str(start)}-{str(end)}\n" + + outfile.write(tsv_line) + + +def get_opts(argv=None): + p = ArgumentParser() + p.add_argument("--scoring_file", help="Custom scoring file, alternative for PGSID", required=True) + p.add_argument("--flank", help="Flanking region for each position to call variants in.", default=100, type=int) + p.add_argument("--prefix", help="Prefix for output files", required=True) + + return p.parse_args(argv) + + +def main(argv=None): + args = get_opts(argv) + + chr_colname = "hm_chr" + pos_colname = "hm_pos" + + positions_to_tsv(args.scoring_file, chr_colname, pos_colname, args.prefix, int(args.flank)) + + +if __name__ == "__main__": + main() diff --git a/src/prs_utils/merge_prs_mqc.py b/src/prs_utils/merge_prs_mqc.py new file mode 100644 index 0000000..af256cc --- /dev/null +++ b/src/prs_utils/merge_prs_mqc.py @@ -0,0 +1,75 @@ +import sys + +import pandas as pd + +sample_palette = ["#e8f0fe", "#ffffff"] # blue / white — sample stripes + +# Pastel palette — distinct color per model so the same model is recognisable +# across all samples. Wraps after 8 models (rare in practice). +model_palette = [ + "#ffd9b3", # peach + "#b3d9f0", # sky blue + "#fff2a8", # pale yellow + "#d4b8e8", # lavender + "#a8d8c8", # mint teal + "#f5b8c8", # rose pink + "#d9c4a3", # warm sand + "#e0e0e0", # soft grey +] + + +def _emit_bgcols(out, column, mapping): + out.write(f"# {column}:\n") + out.write("# bgcols:\n") + for key, color in mapping.items(): + out.write(f"# '{key}': '{color}'\n") + + +def main(argv=None): + files = list(sys.argv[1:]) if argv is None else list(argv) + + dfs = [] + for f in files: + df = pd.read_csv(f, sep="\t") + dfs.append(df) + + combined = pd.concat(dfs, ignore_index=True) + combined = combined.rename( + columns={ + "SUM_Z": "Z-score", + "QC status": "QC Status", + } + ) + combined = combined[["Sample", "Model", "Z-score", "QC Status", "QC Comment", "Model Alpha"]] + combined.insert(0, "Sample_Model", combined["Sample"] + "__" + combined["Model"]) + combined = combined.sort_values(["Sample", "Model"]).reset_index(drop=True) + + unique_samples = combined["Sample"].drop_duplicates().tolist() + sample_bgcols = {s: sample_palette[i % len(sample_palette)] for i, s in enumerate(unique_samples)} + + # Sample_Model rows align 1:1 with Sample rows — reuse the sample palette, + # keyed on the composite "${Sample}__${Model}" value. + sample_model_bgcols = {sm: sample_bgcols[s] for sm, s in zip(combined["Sample_Model"], combined["Sample"])} + + unique_models = combined["Model"].drop_duplicates().tolist() + model_bgcols = {m: model_palette[i % len(model_palette)] for i, m in enumerate(unique_models)} + + with open("prs_scores_mqc.tsv", "w") as out: + out.write("# id: 'prs-scores'\n") + out.write("# pconfig:\n") + out.write("# only_defined_headers: false\n") + out.write("# headers:\n") + _emit_bgcols(out, "Sample_Model", sample_model_bgcols) + _emit_bgcols(out, "Sample", sample_bgcols) + _emit_bgcols(out, "Model", model_bgcols) + out.write("# Z-score:\n") + out.write("# title: 'Z-score'\n") + out.write("# format: '{:,.3f}'\n") + out.write("# Model Alpha:\n") + out.write("# title: 'Model Alpha'\n") + out.write("# format: '{:,.3f}'\n") + combined.to_csv(out, sep="\t", index=False) + + +if __name__ == "__main__": + main() diff --git a/src/prs_utils/normalise_counts.py b/src/prs_utils/normalise_counts.py new file mode 100644 index 0000000..26a13f8 --- /dev/null +++ b/src/prs_utils/normalise_counts.py @@ -0,0 +1,33 @@ +import argparse + +import pandas as pd + + +def get_args(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--PRS", help="Table with PRS scores") + parser.add_argument("--mu", help="Population average for calculating Z-scores", required=True, type=float) + parser.add_argument("--SD", help="Population standard deviation for calculating Z-scores", required=True, type=float) + parser.add_argument("-o", "--output", help="Output file", required=True) + + return parser.parse_args(argv) + + +def score_to_z_score(scores, mu, SD): + z_scores = (scores - mu) / SD + + return z_scores + + +def main(argv=None): + args = get_args(argv) + + df = pd.read_csv(args.PRS, sep="\t") + + df["SUM_Z"] = score_to_z_score(df["SCORE1_SUM"], mu=args.mu, SD=args.SD) + + df.to_csv(args.output, sep="\t", index=False) + + +if __name__ == "__main__": + main() diff --git a/src/prs_utils/pgs_to_vcf.py b/src/prs_utils/pgs_to_vcf.py new file mode 100644 index 0000000..a96d782 --- /dev/null +++ b/src/prs_utils/pgs_to_vcf.py @@ -0,0 +1,134 @@ +import argparse +import csv + + +def parse_args(argv=None): + p = argparse.ArgumentParser( + description="Convert a PGS Catalog scoring CSV/TSV to a VCF (REF=other_allele, ALT=effect_allele)." + ) + p.add_argument("input", help="Input PGS scoring file (tab-delimited)", type=argparse.FileType("r")) + p.add_argument("output", help="Output VCF path ('-' for stdout)", type=argparse.FileType("w", encoding="UTF-8")) + p.add_argument("--pgsid", help="Override PGS ID for header/INFO (falls back to value parsed from metadata)") + p.add_argument("--genome-build", help="Override genome build for header (e.g. GRCh37/GRCh38)") + return p.parse_args(argv) + + +def read_meta(fh): + meta = {} + header = None + body_lines = [] + + for line in fh: + if not line.startswith("#"): # stop at first non-# (the header line) + header = line.rstrip("\n") + break + if line.startswith("###"): # human-readable banner -> ignore + continue + if "=" in line: + key, val = line.strip()[1:].split("=", 1) + meta[key.strip()] = val.strip() + # store the remaining lines (the body) + body_lines = fh.readlines() + + return meta, header, body_lines + + +def build_info_fields(row, pgsid): + # Build INFO + weight = (row.get("effect_weight") or "").strip() + eaf = (row.get("allelefrequency_effect") or "").strip() + hm_source = (row.get("hm_source") or "").replace(" ", "_") + hm_match_chr = (row.get("hm_match_chr") or "").strip() + hm_match_pos = (row.get("hm_match_pos") or "").strip() + info_parts = [f"PGSID={pgsid}"] + if weight: + try: + info_parts.append(f"EFFECT_WEIGHT={float(weight)}") + except ValueError: + pass + if eaf: + try: + info_parts.append(f"EAF={float(eaf)}") + except ValueError: + pass + if hm_source: + info_parts.append(f"HM_SOURCE={hm_source}") + if hm_match_chr and hm_match_pos: + info_parts.append(f"HM_MATCH=chr:{hm_match_chr}|pos:{hm_match_pos}") + + return ";".join(info_parts) + + +def main(argv=None): + args = parse_args(argv) + meta, header, body = read_meta(args.input) + pgsid = args.pgsid or meta.get("pgs_id", "PGS_UNKNOWN") + build = args.genome_build or meta.get("genome_build", "unknown") + + # Open IO + with args.output as oh: + # TSV reader + reader = csv.DictReader([header] + body, delimiter="\t") + + # collect contigs to emit header contig lines later + contigs = set() + chrom_prefix = "" + + # Write VCF header + oh.write("##fileformat=VCFv4.2\n") + oh.write("##source=PGS_Catalog_to_VCF\n") + oh.write(f"##pgs_id={pgsid}\n") + oh.write(f"##reference={build}\n") + oh.write('##INFO=\n') + oh.write('##INFO=\n') + oh.write('##INFO=\n') + oh.write('##INFO=\n') + oh.write('##INFO=\n') + + # Peek through to gather contigs (we need to iterate twice or buffer) + rows = list(reader) + for row in rows: + chrom = (row.get("hm_chr") or "").strip() + if chrom: + if build.lower() == "grch38" and "chr" not in chrom: + chrom_prefix = "chr" + chrom = f"{chrom_prefix}{chrom}" + contigs.add(str(chrom)) + + for contig in sorted(contigs, key=lambda x: x.lstrip("chr") if isinstance(x, str) else x): + oh.write(f"##contig=\n") + + oh.write("#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n") + + # Emit records + for i, row in enumerate(rows, start=1): + # Choose harmonized coords if available, else original + chrom = (row.get("hm_chr") or "").strip() + pos = (row.get("hm_pos") or "").strip() + if build.lower() == "grch38" and chrom and "chr" not in chrom: + chrom = f"chr{chrom}" + + try: + pos_int = int(pos) + except ValueError: + continue + + ref = (row.get("other_allele") or "").upper().replace(" ", "") + alt = (row.get("effect_allele") or "").upper().replace(" ", "") + + # Basic sanity: skip if missing alleles or REF == ALT + if not ref or not alt or ref == alt: + continue + + rsid = (row.get("hm_rsID") or row.get("rsID") or ".").strip() or "." + qual = "." + filt = "PASS" + + info = build_info_fields(row, pgsid) + + # Write VCF line + oh.write(f"{chrom}\t{pos_int}\t{rsid}\t{ref}\t{alt}\t{qual}\t{filt}\t{info}\n") + + +if __name__ == "__main__": + main() diff --git a/src/prs_utils/sample_qc.py b/src/prs_utils/sample_qc.py new file mode 100644 index 0000000..7ba58ad --- /dev/null +++ b/src/prs_utils/sample_qc.py @@ -0,0 +1,157 @@ +"""Sample QC: flag samples based on PRS Z-score and ancestry thresholds.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, List + +import pandas as pd +import typer + +app = typer.Typer(add_completion=False, help="Sample QC for PRS pipeline.") + + +def QC_check( + row: pd.Series, expected_ancestry: list[str], conf_threshold: float, model_matchrate: float, matchrate_threshold: float +) -> pd.Series: + """Assign QC status and comments to a single sample row. + + Args: + row: A single row from the merged scores/ancestry DataFrame. + expected_ancestry: Accepted predicted ancestry group labels. + conf_threshold: Minimum KNN confidence score to pass ancestry QC. + model_matchrate: Fraction of model variants found in the sample (0–1). + matchrate_threshold: Minimum required model matchrate to pass QC. + + Returns: + The input row with ``QC status`` and ``QC Comment`` columns added. + """ + ancestry_group_pass = row["pred_group"] in expected_ancestry + ancestry_conf_pass = row["knn_conf"] >= conf_threshold + matchrate_pass = model_matchrate >= matchrate_threshold + + qc_comment = [] + if not ancestry_group_pass: + qc_comment.append("REFERENCE_PANEL_MISMATCH") + if not ancestry_conf_pass: + qc_comment.append("LOW_REFERENCE_PANEL_CONFIDENCE") + if not matchrate_pass: + qc_comment.append("INSUFFICIENT_VARIANT_COVERAGE") + + row["Model matchrate"] = model_matchrate + row["QC status"] = "PASS" if ancestry_group_pass and ancestry_conf_pass and matchrate_pass else "FAIL" + row["QC Comment"] = ";".join(qc_comment) + return row + + +def calc_model_matchrate(model_df: pd.DataFrame) -> float: + """Calculate the fraction of model variants matched in the sample. + + Args: + model_df: Model summary DataFrame with ``match_status`` and ``percent`` columns. + + Returns: + Matchrate as a fraction between 0 and 1. + """ + matched_vars = model_df[model_df["match_status"] == "matched"] + match_rate = matched_vars["percent"].sum() / 100 + return match_rate + + +@app.command() +def main( + scores: Annotated[ + Path, + typer.Option( + "--scores", + "-s", + exists=True, + dir_okay=False, + readable=True, + help="Normalised PRS scores TSV with SUM_Z column.", + ), + ], + ancestry: Annotated[ + Path, + typer.Option( + "--ancestry", + "-a", + exists=True, + dir_okay=False, + readable=True, + help="KNN ancestry TSV with pred_group and knn_conf columns.", + ), + ], + model_summary: Annotated[ + Path, typer.Option("--model-summary", "-m", exists=True, dir_okay=False, readable=True, help="Model summary file") + ], + output: Annotated[ + Path, + typer.Option("--output", "-o", help="Output QC TSV path."), + ], + sample: Annotated[ + str, + typer.Option("--sample", help="Sample ID to tag onto every row."), + ], + model: Annotated[ + str, + typer.Option("--model", help="Model ID to tag onto every row."), + ], + alpha: Annotated[ + float, + typer.Option("--alpha", help="Model alpha value to tag onto every row."), + ], + conf_threshold: Annotated[ + float, + typer.Option("--conf-threshold", help="Minimum ancestry confidence to pass QC. (default: 0.6)"), + ] = 0.6, + expected_ancestry: Annotated[ + List[str], + typer.Option( + "--expected-ancestry", help="Expected superpopulation label(s). Can be passed multiple times. (default: EUR)" + ), + ] = ["EUR"], + matchrate_threshold: Annotated[ + float, + typer.Option( + "--matchrate-threshold", + help="Minimum model matchrate (i.e., percentage of variants in the model that are in the sample. (default: 0.75) )", + ), + ] = 0.75, +) -> None: + """Run sample QC checks and write a MultiQC-compatible table.""" + score_file = pd.read_csv(scores, sep="\t") + ancestry_file = pd.read_csv(ancestry, sep="\t") + model_df = pd.read_csv(model_summary, sep=",") + + combined_df = score_file.merge(ancestry_file, left_on="IID", right_on="#IID") + + model_matchrate = calc_model_matchrate(model_df) + + typer.echo(f"Model matchrate:{model_matchrate}") + qc_df = combined_df.apply(QC_check, args=(expected_ancestry, conf_threshold, model_matchrate, matchrate_threshold), axis=1) + + qc_df["Sample"] = sample + qc_df["Model"] = model + qc_df["Model Alpha"] = alpha + + qc_df = qc_df.filter( + items=[ + "Sample", + "Model", + "SCORE1_SUM", + "SUM_Z", + "Model Matchrate", + "QC status", + "QC Comment", + "Model Alpha", + ] + ) + + qc_df.to_csv(output, sep="\t", index=False) + + typer.echo(f"Wrote {output}") + + +if __name__ == "__main__": + app() diff --git a/src/python_template/cli.py b/src/python_template/cli.py deleted file mode 100644 index 1ecc320..0000000 --- a/src/python_template/cli.py +++ /dev/null @@ -1,30 +0,0 @@ -import typer - - -def get_hello_msg(name: str = "World"): - """ - Returns a greeting message. - - Args: - name (str): The name to greet. Defaults to "World". - - Returns: - str: A greeting message. - """ - msg = f"Hello {name}!" - - return msg - - -cli = typer.Typer() - - -@cli.command() -def hello(name: str): - """Prints a greeting message.""" - msg = get_hello_msg(name) - print(msg) - - -if __name__ == "__main__": - cli() diff --git a/tests/test_cli.py b/tests/test_cli.py index 314e76b..5f28bf1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,47 @@ -from python_template import cli +from importlib.metadata import version as package_version +from typer.testing import CliRunner -def test_get_hello_msg(): - assert cli.get_hello_msg() == "Hello World!" - assert cli.get_hello_msg("Bioinformagician") == "Hello Bioinformagician!" +from prs_utils.cli import cli + +runner = CliRunner() + + +def test_version_prints_the_bare_version_string(): + """The Nextflow modules snapshot this output, so it must be the version and nothing else.""" + result = runner.invoke(cli, ["--version"]) + + assert result.exit_code == 0 + assert result.output == f"{package_version('prs-utils')}\n" + + +def test_all_subcommands_are_registered(): + result = runner.invoke(cli, ["--help"]) + + assert result.exit_code == 0 + for subcommand in ("pgs-to-vcf", "merge-prs-mqc", "normalise-counts", "sample-qc", "get-snp-list"): + assert subcommand in result.output + + +def test_passthrough_forwards_help_to_the_original_parser(): + """--help after a passthrough subcommand must reach argparse, not Click.""" + result = runner.invoke(cli, ["normalise-counts", "--help"]) + + assert result.exit_code == 0 + # argparse spells these exactly as the standalone script did. + assert "--PRS" in result.output + assert "--SD" in result.output + + +def test_passthrough_forwards_arguments(tmp_path): + scores = tmp_path / "scores.tsv" + scores.write_text("IID\tSCORE1_SUM\nsampleA\t1.5\n") + output = tmp_path / "out.tsv" + + result = runner.invoke( + cli, + ["normalise-counts", "--PRS", str(scores), "--mu", "0.5", "--SD", "0.5", "-o", str(output)], + ) + + assert result.exit_code == 0 + assert output.read_text().splitlines()[1].endswith("2.0") diff --git a/tests/test_get_snp_list.py b/tests/test_get_snp_list.py new file mode 100644 index 0000000..a266cd9 --- /dev/null +++ b/tests/test_get_snp_list.py @@ -0,0 +1,44 @@ +from prs_utils import get_snp_list + +SCORING_FILE = """\ +#pgs_id=PGS000001 +hm_chr\thm_pos +1\t1000 +chr2\t2000 +""" + + +def write_scoring_file(tmp_path): + scoring_file = tmp_path / "scoring.txt" + scoring_file.write_text(SCORING_FILE) + return scoring_file + + +def test_positions_to_tsv_applies_flank_and_chr_prefix(tmp_path): + scoring_file = write_scoring_file(tmp_path) + prefix = tmp_path / "PGS000001" + + get_snp_list.positions_to_tsv(scoring_file, "hm_chr", "hm_pos", str(prefix), flank=100) + + lines = (tmp_path / "PGS000001_snplist.list").read_text().splitlines() + # Row 1 gains a "chr" prefix, row 2 already has one and is left alone. + assert lines == ["chr1:900-1100", "chr2:1900-2100"] + + +def test_positions_to_tsv_default_flank_is_100(tmp_path): + scoring_file = write_scoring_file(tmp_path) + prefix = tmp_path / "default" + + get_snp_list.positions_to_tsv(scoring_file, "hm_chr", "hm_pos", str(prefix)) + + assert (tmp_path / "default_snplist.list").read_text().splitlines()[0] == "chr1:900-1100" + + +def test_main_uses_the_harmonised_columns(tmp_path): + scoring_file = write_scoring_file(tmp_path) + prefix = tmp_path / "PGS000001" + + get_snp_list.main(["--scoring_file", str(scoring_file), "--prefix", str(prefix), "--flank", "10"]) + + lines = (tmp_path / "PGS000001_snplist.list").read_text().splitlines() + assert lines == ["chr1:990-1010", "chr2:1990-2010"] diff --git a/tests/test_merge_prs_mqc.py b/tests/test_merge_prs_mqc.py new file mode 100644 index 0000000..80ce770 --- /dev/null +++ b/tests/test_merge_prs_mqc.py @@ -0,0 +1,65 @@ +import pandas as pd + +from prs_utils import merge_prs_mqc + +HEADER = "Sample\tModel\tSCORE1_SUM\tSUM_Z\tQC status\tQC Comment\tModel Alpha\n" + + +def write_qc_tsv(tmp_path, name, rows): + path = tmp_path / name + path.write_text(HEADER + "".join(rows)) + return str(path) + + +def test_main_merges_sorts_and_writes_multiqc_table(tmp_path, monkeypatch): + files = [ + write_qc_tsv(tmp_path, "b.tsv", ["sampleB\tmodel1\t1.0\t0.5\tPASS\tok\t1.0\n"]), + write_qc_tsv(tmp_path, "a.tsv", ["sampleA\tmodel2\t2.0\t1.5\tFAIL\tbad\t0.5\n"]), + write_qc_tsv(tmp_path, "c.tsv", ["sampleA\tmodel1\t3.0\t2.5\tPASS\tok\t0.1\n"]), + ] + monkeypatch.chdir(tmp_path) + + merge_prs_mqc.main(files) + + output = tmp_path / "prs_scores_mqc.tsv" + text = output.read_text() + + # MultiQC configuration is emitted as a comment block ahead of the table. + assert text.startswith("# id: 'prs-scores'\n") + assert "# Z-score:\n" in text + assert "# 'sampleA__model1': '#e8f0fe'\n" in text + assert "# 'sampleB': '#ffffff'\n" in text + + df = pd.read_csv(output, sep="\t", comment="#") + assert list(df.columns) == [ + "Sample_Model", + "Sample", + "Model", + "Z-score", + "QC Status", + "QC Comment", + "Model Alpha", + ] + # Sorted by Sample then Model; SCORE1_SUM is dropped. + assert list(df["Sample_Model"]) == ["sampleA__model1", "sampleA__model2", "sampleB__model1"] + assert list(df["Z-score"]) == [2.5, 1.5, 0.5] + + +def test_main_assigns_a_distinct_colour_per_model(tmp_path, monkeypatch): + files = [ + write_qc_tsv( + tmp_path, + "all.tsv", + [ + "sampleA\tmodel1\t1.0\t0.5\tPASS\tok\t1.0\n", + "sampleA\tmodel2\t1.0\t0.5\tPASS\tok\t1.0\n", + ], + ) + ] + monkeypatch.chdir(tmp_path) + + merge_prs_mqc.main(files) + + text = (tmp_path / "prs_scores_mqc.tsv").read_text() + assert "# 'model1': '#ffd9b3'\n" in text + assert "# 'model2': '#b3d9f0'\n" in text diff --git a/tests/test_normalise_counts.py b/tests/test_normalise_counts.py new file mode 100644 index 0000000..20b21d6 --- /dev/null +++ b/tests/test_normalise_counts.py @@ -0,0 +1,23 @@ +import pandas as pd + +from prs_utils import normalise_counts + + +def test_score_to_z_score(): + scores = pd.Series([1.0, 2.0, 3.0]) + + z_scores = normalise_counts.score_to_z_score(scores, mu=2.0, SD=0.5) + + assert list(z_scores) == [-2.0, 0.0, 2.0] + + +def test_main_writes_sum_z_column(tmp_path): + prs = tmp_path / "scores.tsv" + prs.write_text("IID\tSCORE1_SUM\nsampleA\t1.0\nsampleB\t3.0\n") + output = tmp_path / "normalised.tsv" + + normalise_counts.main(["--PRS", str(prs), "--mu", "2.0", "--SD", "0.5", "-o", str(output)]) + + df = pd.read_csv(output, sep="\t") + assert list(df.columns) == ["IID", "SCORE1_SUM", "SUM_Z"] + assert list(df["SUM_Z"]) == [-2.0, 2.0] diff --git a/tests/test_pgs_to_vcf.py b/tests/test_pgs_to_vcf.py new file mode 100644 index 0000000..fb15bb2 --- /dev/null +++ b/tests/test_pgs_to_vcf.py @@ -0,0 +1,75 @@ +import io + +from prs_utils import pgs_to_vcf + +SCORING_FILE = """\ +### PGS CATALOG SCORING FILE +#pgs_id=PGS000001 +#genome_build=GRCh38 +rsID\teffect_allele\tother_allele\teffect_weight\thm_chr\thm_pos\thm_rsID +rs1\tA\tG\t0.1\t1\t1000\trs1 +rs2\tT\tC\t0.2\t2\t2000\trs2 +""" + + +def test_read_meta_splits_metadata_header_and_body(): + meta, header, body = pgs_to_vcf.read_meta(io.StringIO(SCORING_FILE)) + + assert meta == {"pgs_id": "PGS000001", "genome_build": "GRCh38"} + assert header.startswith("rsID\teffect_allele") + assert len(body) == 2 + + +def test_build_info_fields_includes_numeric_values(): + row = { + "effect_weight": "0.1", + "allelefrequency_effect": "0.25", + "hm_source": "ENSEMBL Variation", + "hm_match_chr": "True", + "hm_match_pos": "True", + } + + info = pgs_to_vcf.build_info_fields(row, "PGS000001") + + assert info == ("PGSID=PGS000001;EFFECT_WEIGHT=0.1;EAF=0.25;HM_SOURCE=ENSEMBL_Variation;HM_MATCH=chr:True|pos:True") + + +def test_build_info_fields_skips_non_numeric_and_missing_values(): + row = {"effect_weight": "NA", "allelefrequency_effect": "", "hm_source": ""} + + assert pgs_to_vcf.build_info_fields(row, "PGS000001") == "PGSID=PGS000001" + + +def test_main_writes_vcf_with_chr_prefixed_contigs(tmp_path): + scoring_file = tmp_path / "scoring.txt" + scoring_file.write_text(SCORING_FILE) + output = tmp_path / "out.vcf" + + pgs_to_vcf.main([str(scoring_file), str(output)]) + + lines = output.read_text().splitlines() + assert lines[0] == "##fileformat=VCFv4.2" + assert "##pgs_id=PGS000001" in lines + # genome_build GRCh38 comes from the metadata, so contigs gain a chr prefix. + assert "##contig=" in lines + assert lines[-2] == "chr1\t1000\trs1\tG\tA\t.\tPASS\tPGSID=PGS000001;EFFECT_WEIGHT=0.1" + assert lines[-1] == "chr2\t2000\trs2\tC\tT\t.\tPASS\tPGSID=PGS000001;EFFECT_WEIGHT=0.2" + + +def test_main_skips_rows_with_equal_or_missing_alleles(tmp_path): + scoring_file = tmp_path / "scoring.txt" + scoring_file.write_text( + "#pgs_id=PGS000002\n" + "rsID\teffect_allele\tother_allele\thm_chr\thm_pos\n" + "rs1\tA\tA\t1\t1000\n" # REF == ALT + "rs2\tA\t\t1\t2000\n" # missing other_allele + "rs3\tA\tG\t1\t\n" # missing position + "rs4\tA\tG\t1\t4000\n" + ) + output = tmp_path / "out.vcf" + + pgs_to_vcf.main([str(scoring_file), str(output)]) + + records = [line for line in output.read_text().splitlines() if not line.startswith("#")] + assert len(records) == 1 + assert records[0].startswith("1\t4000\trs4\tG\tA") diff --git a/tests/test_sample_qc.py b/tests/test_sample_qc.py new file mode 100644 index 0000000..5f7b00a --- /dev/null +++ b/tests/test_sample_qc.py @@ -0,0 +1,82 @@ +import pandas as pd +import pytest + +from prs_utils import sample_qc + + +def make_row(pred_group="EUR", knn_conf=0.9): + return pd.Series({"pred_group": pred_group, "knn_conf": knn_conf}) + + +def test_calc_model_matchrate_sums_only_matched_percentages(): + model_df = pd.DataFrame( + { + "match_status": ["matched", "matched", "unmatched"], + "percent": [60.0, 20.0, 20.0], + } + ) + + assert sample_qc.calc_model_matchrate(model_df) == pytest.approx(0.8) + + +def test_qc_check_passes_when_all_thresholds_are_met(): + row = sample_qc.QC_check(make_row(), ["EUR"], 0.6, 0.9, 0.75) + + assert row["QC status"] == "PASS" + assert row["QC Comment"] == "" + assert row["Model matchrate"] == 0.9 + + +@pytest.mark.parametrize( + ("row", "matchrate", "comment"), + [ + (make_row(pred_group="AFR"), 0.9, "REFERENCE_PANEL_MISMATCH"), + (make_row(knn_conf=0.1), 0.9, "LOW_REFERENCE_PANEL_CONFIDENCE"), + (make_row(), 0.1, "INSUFFICIENT_VARIANT_COVERAGE"), + ], +) +def test_qc_check_reports_each_failure_reason(row, matchrate, comment): + result = sample_qc.QC_check(row, ["EUR"], 0.6, matchrate, 0.75) + + assert result["QC status"] == "FAIL" + assert result["QC Comment"] == comment + + +def test_qc_check_joins_multiple_failure_reasons(): + row = sample_qc.QC_check(make_row(pred_group="AFR", knn_conf=0.1), ["EUR"], 0.6, 0.1, 0.75) + + assert row["QC Comment"] == ("REFERENCE_PANEL_MISMATCH;LOW_REFERENCE_PANEL_CONFIDENCE;INSUFFICIENT_VARIANT_COVERAGE") + + +def test_main_writes_qc_table(tmp_path): + scores = tmp_path / "scores.tsv" + scores.write_text("IID\tSCORE1_SUM\tSUM_Z\nsampleA\t1.5\t0.5\n") + ancestry = tmp_path / "ancestry.tsv" + ancestry.write_text("#IID\tpred_group\tknn_conf\nsampleA\tEUR\t0.9\n") + model_summary = tmp_path / "summary.csv" + model_summary.write_text("match_status,percent\nmatched,90.0\nunmatched,10.0\n") + output = tmp_path / "qc.tsv" + + sample_qc.main( + scores=scores, + ancestry=ancestry, + model_summary=model_summary, + output=output, + sample="sampleA", + model="BCAC_313_PRS", + alpha=1.0, + ) + + df = pd.read_csv(output, sep="\t") + # "Model Matchrate" is intentionally absent: QC_check writes "Model matchrate" (lowercase m) + # while the final filter asks for the capitalised name, so pandas drops it. Preserved as-is. + assert list(df.columns) == [ + "Sample", + "Model", + "SCORE1_SUM", + "SUM_Z", + "QC status", + "QC Comment", + "Model Alpha", + ] + assert df.loc[0, "QC status"] == "PASS" diff --git a/uv.lock b/uv.lock index 26d6fb9..a3119b1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,14 @@ version = 1 revision = 3 -requires-python = "==3.14.*" +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. @@ -30,6 +38,45 @@ version = "7.11.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload-time = "2025-11-10T00:13:17.18Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676, upload-time = "2025-11-10T00:11:11.566Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034, upload-time = "2025-11-10T00:11:13.12Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531, upload-time = "2025-11-10T00:11:15.023Z" }, + { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290, upload-time = "2025-11-10T00:11:16.628Z" }, + { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375, upload-time = "2025-11-10T00:11:18.249Z" }, + { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946, upload-time = "2025-11-10T00:11:20.202Z" }, + { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310, upload-time = "2025-11-10T00:11:21.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461, upload-time = "2025-11-10T00:11:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039, upload-time = "2025-11-10T00:11:25.07Z" }, + { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903, upload-time = "2025-11-10T00:11:26.992Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201, upload-time = "2025-11-10T00:11:28.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012, upload-time = "2025-11-10T00:11:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652, upload-time = "2025-11-10T00:11:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload-time = "2025-11-10T00:11:34.296Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload-time = "2025-11-10T00:11:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload-time = "2025-11-10T00:11:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload-time = "2025-11-10T00:11:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload-time = "2025-11-10T00:11:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload-time = "2025-11-10T00:11:43.27Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload-time = "2025-11-10T00:11:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload-time = "2025-11-10T00:11:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload-time = "2025-11-10T00:11:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload-time = "2025-11-10T00:11:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload-time = "2025-11-10T00:11:52.184Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload-time = "2025-11-10T00:11:53.871Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload-time = "2025-11-10T00:11:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload-time = "2025-11-10T00:11:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload-time = "2025-11-10T00:11:59.519Z" }, + { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload-time = "2025-11-10T00:12:01.592Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload-time = "2025-11-10T00:12:03.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload-time = "2025-11-10T00:12:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload-time = "2025-11-10T00:12:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload-time = "2025-11-10T00:12:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload-time = "2025-11-10T00:12:11.195Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload-time = "2025-11-10T00:12:12.843Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload-time = "2025-11-10T00:12:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload-time = "2025-11-10T00:12:17.274Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload-time = "2025-11-10T00:12:19.292Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload-time = "2025-11-10T00:12:21.251Z" }, { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload-time = "2025-11-10T00:12:23.089Z" }, { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload-time = "2025-11-10T00:12:24.863Z" }, { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload-time = "2025-11-10T00:12:26.553Z" }, @@ -89,6 +136,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -98,6 +196,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -107,6 +251,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prs-utils" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "pandas" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pandas", specifier = ">=2.2" }, + { name = "typer", specifier = ">=0.26.8" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.15.20" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -147,28 +320,15 @@ wheels = [ ] [[package]] -name = "python-template" -version = "0.1.0" -source = { editable = "." } +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typer" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, + { name = "six" }, ] - -[package.metadata] -requires-dist = [{ name = "typer", specifier = ">=0.26.8" }] - -[package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=9.1.1" }, - { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "ruff", specifier = ">=0.15.20" }, +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] @@ -218,6 +378,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "typer" version = "0.26.8" @@ -232,3 +401,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b87 wheels = [ { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] From 54da44af261e2e8bcb9c0589e06e65ed27d27919 Mon Sep 17 00:00:00 2001 From: Jorisvansteenbrugge <7196110+Jorisvansteenbrugge@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:39:48 +0200 Subject: [PATCH 2/3] fix error on publish check --- .github/workflows/publish.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0899366..727b97f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,7 +8,6 @@ permissions: packages: write env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository_owner }}/prs-utils jobs: build-and-push: runs-on: ubuntu-latest @@ -16,6 +15,14 @@ jobs: - 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 From 6fac3cb65bf5fb9cf10adf1f358074dd17cf0d2a Mon Sep 17 00:00:00 2001 From: Jorisvansteenbrugge <7196110+Jorisvansteenbrugge@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:57:55 +0200 Subject: [PATCH 3/3] add ps to container image --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index dc552da..55b9035 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,12 @@ 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"