diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3dac982 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,32 @@ +{ + "env": { + "AWS_PROFILE": "mavedb-agent-readonly" + }, + "permissions": { + "deny": ["Bash(* --no-verify*)", "Read(~/.aws/**)", "Read(~/.pgpass)", "Read(~/.ssh/**)"], + "ask": [ + "Edit(**/.claude/settings*.json)", + "Bash(git push *--force*)", + "Bash(git push * -f*)", + "Bash(git push * main*)", + "Bash(git push * mavedb-main*)", + "Bash(git push * mavedb-dev*)", + "Bash(git push * release-*)", + "Bash(git -C * push *--force*)", + "Bash(git -C * push * -f*)", + "Bash(git -C * push * main*)", + "Bash(git -C * push * mavedb-main*)", + "Bash(git -C * push * mavedb-dev*)", + "Bash(git -C * push * release-*)", + "Bash(aws *--profile*)", + "Bash(*AWS_PROFILE=*)", + "Bash(eb *)", + "Bash(*MODE=prodapi*)", + "Bash(poetry add *)", + "Bash(poetry -C * add *)", + "Bash(npm install *)", + "Bash(npm --prefix * install *)", + "Bash(*pip install *)" + ] + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..69b04ed --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Summary + + + +Closes # + + + +## Tests + + + +## Checklist + +- [ ] Targets `mavedb-dev`, not `mavedb-main` +- [ ] Docs and instruction files touched by this change are updated diff --git a/.github/scripts/issue-status.js b/.github/scripts/issue-status.js new file mode 100644 index 0000000..1c8afdc --- /dev/null +++ b/.github/scripts/issue-status.js @@ -0,0 +1,282 @@ +/** + * Moves issues across the MaveDB Backlog project as the pull requests linked to them progress. + * + * - A PR is opened, pushed to or marked ready: its linked issues go to In review (In progress while it's a draft). + * - A PR merges into a release branch: its linked issues go to Pending release, once no other open PR still links them. + * - A release branch's PR into the default branch is opened or pushed to: its description gets a "Closes" line for every + * open issue linked from a PR merged into that branch since its last release, so GitHub closes them when the release + * merges. The board's "Item closed" workflow then sets Done. + * + * Release branches are release-* in mavedb-api and mavedb-ui, and the long-lived mavedb-dev in dcd_mapping2. The + * pattern covers every repo because issues are linked across repos, and each repo carries an identical copy of this + * file. + * + * A PR's issues are the ones linked from its Development sidebar plus those named on a "Closes #N" line in its + * description. GitHub turns that line into a link only on PRs into the default branch; on a PR into a release branch it + * is a plain mention, so this script reads the description itself. + * + * Issues only move forward, so the release PR linking an issue that is already Pending release leaves it there. Issues + * that aren't on the board are skipped, not added: adding one fires the board's "Item added to project" workflow, which + * would race this script's status change. + * + * Called from .github/workflows/issue-status.yml through actions/github-script, with a GitHub App token that can write + * to the org's projects. + */ + +const PROJECT_OWNER = 'VariantEffect' +const PROJECT_NUMBER = 4 + +// Board order, left to right. Matched by suffix so the option emoji can change without breaking this. +const ORDER = ['New', 'Backlog', 'Ready', 'In progress', 'Waiting/Blocked', 'In review', 'Pending release', 'Done'] + +const RELEASE_SECTION_START = '' +const RELEASE_SECTION_END = '' + +const rank = (statusName) => (statusName ? ORDER.findIndex((label) => statusName.endsWith(label)) : -1) +const isReleaseBranch = (ref) => /^(release-.+|mavedb-dev)$/.test(ref) + +async function loadProject(github) { + const {organization} = await github.graphql( + `query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } + } + } + }`, + {owner: PROJECT_OWNER, number: PROJECT_NUMBER} + ) + const project = organization.projectV2 + const options = Object.fromEntries( + ORDER.map((label) => { + const option = project.field.options.find((o) => o.name.endsWith(label)) + if (!option) throw new Error(`The project's Status field has no "${label}" option.`) + return [label, option.id] + }) + ) + return {id: project.id, fieldId: project.field.id, options} +} + +const ISSUE_FIELDS = ` + id number state + repository { nameWithOwner } + projectItems(first: 20) { + nodes { + id + project { id } + status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } + } + } + closedByPullRequestsReferences(first: 20, includeClosedPrs: false) { + nodes { number headRefName repository { nameWithOwner } } + } +` + +const CLOSING_REF = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+(?:([\w.-]+)\/([\w.-]+))?#(\d+)\b/gi + +// HTML comments are dropped first so the template's own "Closes #123" example doesn't count. +function closingRefs(body, owner, repo) { + const text = (body ?? '').replace(//g, '') + return [...text.matchAll(CLOSING_REF)].map(([, o, r, n]) => ({owner: o ?? owner, repo: r ?? repo, number: Number(n)})) +} + +const linkRefs = (links) => + links.nodes.map((i) => { + const [owner, repo] = i.repository.nameWithOwner.split('/') + return {owner, repo, number: i.number} + }) + +// Fetches the issues behind a set of refs in one query. Refs that turn out to be PRs, or that don't resolve, are dropped. +async function fetchIssues(github, refs) { + const unique = [...new Map(refs.map((r) => [`${r.owner}/${r.repo}#${r.number}`.toLowerCase(), r])).values()] + if (!unique.length) return [] + const query = `query { + ${unique + .map( + (r, i) => `i${i}: repository(owner: ${JSON.stringify(r.owner)}, name: ${JSON.stringify(r.repo)}) { + issueOrPullRequest(number: ${r.number}) { ... on Issue { ${ISSUE_FIELDS} } } + }` + ) + .join('\n')} + }` + let data + try { + data = await github.graphql(query) + } catch (error) { + if (!error.data) throw error + data = error.data // A ref to a missing repo fails only its own alias. + } + return Object.values(data) + .map((r) => r?.issueOrPullRequest) + .filter((issue) => issue?.id && issue.state === 'OPEN') +} + +async function linkedIssues(github, owner, repo, pr) { + const {repository} = await github.graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 50) { nodes { number repository { nameWithOwner } } } + } + } + }`, + {owner, repo, number: pr.number} + ) + const links = repository.pullRequest.closingIssuesReferences + return fetchIssues(github, [...linkRefs(links), ...closingRefs(pr.body, owner, repo)]) +} + +async function moveIssue(github, core, project, issue, target) { + const label = `${issue.repository.nameWithOwner}#${issue.number}` + const item = issue.projectItems.nodes.find((i) => i.project.id === project.id) + if (!item) { + core.notice(`${label} isn't on the project; skipping.`) + return + } + const current = item.status?.name + if (rank(current) >= ORDER.indexOf(target)) { + core.info(`${label} is already at "${current}"; leaving it.`) + return + } + await github.graphql( + `mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $field, value: { singleSelectOptionId: $option } + }) { projectV2Item { id } } + }`, + {project: project.id, item: item.id, field: project.fieldId, option: project.options[target]} + ) + core.info(`${label}: "${current ?? 'no status'}" -> "${target}".`) +} + +async function markInReview(github, core, project, owner, repo, pr) { + const target = pr.draft ? 'In progress' : 'In review' + for (const issue of await linkedIssues(github, owner, repo, pr)) { + await moveIssue(github, core, project, issue, target) + } +} + +// An issue split across several PRs, possibly in different repos, waits for the last of them. Release PRs link every +// issue in the release, so they don't count. Only sidebar links are visible here, not "Closes" lines in other PRs. +function stillOpenElsewhere(core, issue) { + const open = issue.closedByPullRequestsReferences.nodes.filter((p) => !isReleaseBranch(p.headRefName)) + if (open.length) { + const refs = open.map((p) => `${p.repository.nameWithOwner}#${p.number}`).join(', ') + core.info(`${issue.repository.nameWithOwner}#${issue.number} is still linked from open ${refs}; leaving it.`) + } + return open.length > 0 +} + +async function markPendingRelease(github, core, project, owner, repo, pr) { + // By the time "closed" fires, the merged PR no longer counts as open. + for (const issue of await linkedIssues(github, owner, repo, pr)) { + if (!stillOpenElsewhere(core, issue)) await moveIssue(github, core, project, issue, 'Pending release') + } +} + +// Long-lived release branches (mavedb-dev) keep their history, so only PRs merged since the branch last shipped count. +async function lastReleasedAt(github, owner, repo, pr) { + const {search} = await github.graphql( + `query($query: String!) { + search(type: ISSUE, query: $query, first: 1) { nodes { ... on PullRequest { mergedAt } } } + }`, + {query: `repo:${owner}/${repo} is:pr is:merged head:${pr.head.ref} base:${pr.base.ref} sort:created-desc`} + ) + return search.nodes[0]?.mergedAt ?? null +} + +async function updateReleaseDescription(github, core, owner, repo, pr) { + const since = await lastReleasedAt(github, owner, repo, pr) + const query = `repo:${owner}/${repo} is:pr is:merged base:${pr.head.ref}` + (since ? ` merged:>${since}` : '') + const refs = [] + let cursor = null + do { + const {search} = await github.graphql( + `query($query: String!, $cursor: String) { + search(type: ISSUE, query: $query, first: 50, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequest { + body + closingIssuesReferences(first: 50) { nodes { number repository { nameWithOwner } } } + } + } + } + }`, + {query, cursor} + ) + for (const merged of search.nodes) { + refs.push(...linkRefs(merged.closingIssuesReferences), ...closingRefs(merged.body, owner, repo)) + } + cursor = search.pageInfo.hasNextPage ? search.pageInfo.endCursor : null + } while (cursor) + + const issues = new Map() + for (const issue of await fetchIssues(github, refs)) { + if (stillOpenElsewhere(core, issue)) continue + const sameRepo = issue.repository.nameWithOwner === `${owner}/${repo}` + issues.set(sameRepo ? `#${issue.number}` : `${issue.repository.nameWithOwner}#${issue.number}`, issue) + } + + const listed = [...issues.keys()].sort((a, b) => a.localeCompare(b, 'en', {numeric: true})) + const section = [ + RELEASE_SECTION_START, + '### Issues closed by this release', + '', + `_Generated from the issues linked to PRs merged into \`${pr.head.ref}\` for this release, and rewritten on every push. Issues still linked from an open PR are left out. To change the list, change those PRs' linked issues or Closes lines._`, + '', + ...(listed.length ? listed.map((ref) => `- Closes ${ref}`) : ['None.']), + RELEASE_SECTION_END + ].join('\n') + + const body = pr.body ?? '' + const start = body.indexOf(RELEASE_SECTION_START) + const end = body.indexOf(RELEASE_SECTION_END) + const updated = + start !== -1 && end > start + ? body.slice(0, start) + section + body.slice(end + RELEASE_SECTION_END.length) + : `${body.trimEnd()}\n\n${section}\n` + if (updated === body) { + core.info('Release description is up to date.') + return + } + await github.rest.pulls.update({owner, repo, pull_number: pr.number, body: updated}) + core.info(`Release description lists ${listed.length} issue(s).`) +} + +module.exports = async ({github, context, core}) => { + const {owner, repo} = context.repo + const project = await loadProject(github) + + // Linking an issue from the PR's Development sidebar fires no event, so a periodic sweep catches those. + if (context.eventName === 'schedule' || context.eventName === 'workflow_dispatch') { + const prs = await github.paginate(github.rest.pulls.list, {owner, repo, state: 'open', per_page: 100}) + for (const pr of prs.filter((p) => !isReleaseBranch(p.head.ref))) { + await markInReview(github, core, project, owner, repo, pr) + } + return + } + + const pr = context.payload.pull_request + const action = context.payload.action + + if (isReleaseBranch(pr.head.ref) && pr.base.ref === context.payload.repository.default_branch) { + if (['opened', 'reopened', 'synchronize'].includes(action)) { + await updateReleaseDescription(github, core, owner, repo, pr) + } + return + } + + if (action === 'closed') { + if (pr.merged && isReleaseBranch(pr.base.ref)) { + await markPendingRelease(github, core, project, owner, repo, pr) + } + return + } + + // Hotfixes merged straight into the default branch close their issues natively. + if (pr.base.ref !== context.payload.repository.default_branch) { + await markInReview(github, core, project, owner, repo, pr) + } +} diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index bacb99e..23d7600 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -27,7 +27,7 @@ jobs: # only run tests known to work in CI run: | mkdir -p coverage - python3 -m pytest tests/test_mavedb_data.py --cov-report=lcov:coverage/lcov.info + bin/t tests/test_mavedb_data.py --cov-report=lcov:coverage/lcov.info - name: Upload coverage to Coveralls uses: coverallsapp/github-action@v2 @@ -46,10 +46,5 @@ jobs: - name: Install dependencies run: python3 -m pip install ".[dev]" - - name: Check style - run: python3 -m ruff check . && ruff format --check . - - - name: Verify schema.json is up to date - run: | - python scripts/generate_schema.py - git diff --exit-code schema.json + - name: Lint, format and schema.json checks + run: bin/check diff --git a/.github/workflows/issue-status.yaml b/.github/workflows/issue-status.yaml new file mode 100644 index 0000000..8d5d305 --- /dev/null +++ b/.github/workflows/issue-status.yaml @@ -0,0 +1,41 @@ +name: Issue status + +# Moves linked issues across the MaveDB Backlog project; the logic and its rules are in .github/scripts/issue-status.js. +# pull_request_target runs this file from the base branch with the org secrets, so it works for fork PRs too. It never +# checks out or runs the PR's code. +on: + pull_request_target: + types: [opened, reopened, edited, synchronize, ready_for_review, converted_to_draft, closed] + schedule: + - cron: '17 * * * *' + workflow_dispatch: + +concurrency: + group: issue-status-${{ github.event.pull_request.number || github.run_id }} + +permissions: + contents: read + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/scripts + + # The default GITHUB_TOKEN can't write to org projects. The app needs org Projects read/write and repo Issues + # read and Pull requests read/write, installed on mavedb-api, mavedb-ui and dcd_mapping2. + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.PROJECT_AUTOMATION_APP_ID }} + private-key: ${{ secrets.PROJECT_AUTOMATION_PRIVATE_KEY }} + owner: VariantEffect + + - uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const run = require('./.github/scripts/issue-status.js'); + await run({ github, context, core }); diff --git a/.gitignore b/.gitignore index 439b17c..1aec9a5 100644 --- a/.gitignore +++ b/.gitignore @@ -173,5 +173,6 @@ urn:*.json tmp:*.json *_mapping_*.json -# Agent settings -.claude/ +# Agent settings: personal files stay local; the shared permission rules are committed +.claude/* +!.claude/settings.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4df7852 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# AGENTS.md — dcd_mapping + +Facts about this repo that you can't work out from the code. Keep this file short. Rules shared with the other MaveDB repos are in the workspace `AGENTS.md` one folder up. Update both in the same change that makes any of it untrue. + +The mapper turns a MaveDB score set's variants into GA4GH VRS objects. MaveDB's worker calls it over HTTP (`POST /api/v1/map/{urn}`, served from `src/api/`); the `dcd-map` CLI does the same job locally. This repo is MaveDB's fork of `ave-dcd/dcd_mapping`: work goes to `VariantEffect/dcd_mapping2`, branched from and merged into `mavedb-dev`, which is released to `mavedb-main`. + +## Setup + +- `python3 -m venv .venv && .venv/bin/pip install -e '.[dev,tests]'`, on Python 3.11. The `bin/` scripts use `.venv` when it exists. +- A real mapping run needs UTA, SeqRepo, the Gene Normalizer database and BLAT. The README's Prerequisites section covers each. + +## Commands + +| Task | Command | +|---|---| +| Lint, format and `schema.json` checks | `bin/check` | +| What CI tests | `bin/t tests/test_mavedb_data.py` | +| Other tests | `bin/t tests/test_variant_projection.py` | +| Regenerate `schema.json` | `.venv/bin/python scripts/generate_schema.py` | + +- Run `bin/check` after every edit, and the tests for what you touched before handing work back. +- Only `tests/test_mavedb_data.py` runs in CI. The align and `vrs_map` tests need BLAT, and others need SeqRepo or UTA, so some fail on a machine without them. Check a failure against `mavedb-dev` before blaming your change, and report it rather than skipping or loosening the test. + +## Rules + +- Open pull requests with `gh pr create -R VariantEffect/dcd_mapping2 --base mavedb-dev`. Without `-R`, `gh` may target the upstream `ave-dcd` repo. +- `schema.json` is the output contract the MaveDB api consumes. A change to `schemas.py` that alters it ships with the regenerated file in the same commit; `bin/check` fails otherwise. +- `ga4gh.vrs` is pinned to 2.0.0a6 and `cool-seq-tool` to 0.4.0.dev3, because upgrading either means moving the whole GA4GH stack (gene-normalizer, seqrepo, cdot) at once. Don't bump any of them as part of other work. +- Never store a RefSeq sequence in SeqRepo from another source, such as cdot's genome-assembled transcript. It changes the refget, and with it every allele digest MaveDB has published. +- Docstrings use the reST `:param:` style the code already has, not Google style. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile index 5f92714..f7c9634 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,22 @@ RUN wget -O - https://ftp.ncbi.nlm.nih.gov/genomes/refseq/vertebrate_mammalian/H RUN samtools faidx GCF_000001405.25_GRCh37.p13_genomic.fna.gz RUN samtools faidx GCF_000001405.39_GRCh38.p13_genomic.fna.gz +# Bake in the liftover chain files agct would otherwise download on first use. +# That download goes through certifi, which can drop roots UCSC's TLS chain +# relies on (VariantEffect/dcd_mapping2#117); curl uses the system trust store. +# The paths are what agct's wags-tails lookup expects for unversioned files. +# Checksums from each directory's md5sum.txt. +ENV WAGS_TAILS_DIR=/home/.local/share/wags_tails +RUN mkdir -p $WAGS_TAILS_DIR/ucsc-chainfile && cd $WAGS_TAILS_DIR/ucsc-chainfile && \ + curl -fLO https://hgdownload.soe.ucsc.edu/goldenPath/hg19/liftOver/hg19ToHg38.over.chain.gz && \ + curl -fLO https://hgdownload.soe.ucsc.edu/goldenPath/hg38/liftOver/hg38ToHg19.over.chain.gz && \ + printf '%s\n' \ + "35887f73fe5e2231656504d1f6430900 hg19ToHg38.over.chain.gz" \ + "ff3031d93792f4cbb86af44055efd903 hg38ToHg19.over.chain.gz" | md5sum -c - && \ + gunzip -c hg19ToHg38.over.chain.gz > chainfile_hg19_to_hg38_.chain && \ + gunzip -c hg38ToHg19.over.chain.gz > chainfile_hg38_to_hg19_.chain && \ + rm hg19ToHg38.over.chain.gz hg38ToHg19.over.chain.gz + RUN mkdir /usr/src/app WORKDIR /usr/src/app COPY . . @@ -53,6 +69,7 @@ COPY . . RUN pip install -e '.[dev,tests]' # use polars-lts-cpu to avoid issues with x86 emulation on arm machine RUN pip install -U polars-lts-cpu +RUN python -m pytest -p no:cacheprovider --no-cov tests/test_liftover_chainfiles.py # install gene normalizer with pg dependencies. TODO: can the pg dependencies be specified in pyproject.toml? #RUN pip install 'gene-normalizer[pg]' diff --git a/README.md b/README.md index 84c81f0..69adf0b 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,11 @@ Add pre-commit hooks pre-commit install ``` -Run tests with `pytest` +Run the lint, format and `schema.json` checks with `bin/check`, and tests with `bin/t`, which wraps `pytest`: ``` -pytest +bin/check +bin/t tests/test_mavedb_data.py ``` + +Only `tests/test_mavedb_data.py` runs in CI; most other test modules need BLAT, SeqRepo or UTA. Conventions for contributors and coding agents are in [AGENTS.md](AGENTS.md). diff --git a/bin/check b/bin/check new file mode 100755 index 0000000..1f07999 --- /dev/null +++ b/bin/check @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Fast static checks: lint, format and the schema.json contract. Tests run through bin/t. +# Runs every check before exiting so one invocation reports all failures. +# Uses .venv when present (local development), otherwise the python3 on PATH (CI). +set -uo pipefail +cd "$(dirname "$0")/.." +PY="${PYTHON:-$([ -x .venv/bin/python ] && echo .venv/bin/python || echo python3)}" + +status=0 +"$PY" -m ruff check . || status=1 +"$PY" -m ruff format --check . || status=1 +"$PY" scripts/generate_schema.py --check || status=1 +exit $status diff --git a/bin/t b/bin/t new file mode 100755 index 0000000..0f83da8 --- /dev/null +++ b/bin/t @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Tests: bin/t [test path or node id] [pytest args]. Only tests/test_mavedb_data.py runs in CI; +# most other modules need BLAT, SeqRepo or UTA. MAVEDB_BASE_URL matches CI; requests are mocked. +set -euo pipefail +cd "$(dirname "$0")/.." +PY="${PYTHON:-$([ -x .venv/bin/python ] && echo .venv/bin/python || echo python3)}" + +export MAVEDB_BASE_URL="${MAVEDB_BASE_URL:-https://api.mavedb.org}" +exec "$PY" -m pytest "$@" diff --git a/schema.json b/schema.json index ee11565..33b5bfb 100644 --- a/schema.json +++ b/schema.json @@ -752,7 +752,7 @@ "title": "Tool Parameters" }, "tool_version": { - "default": "2026.3.1", + "default": "2026.3.2", "title": "Tool Version", "type": "string" }, diff --git a/scripts/generate_schema.py b/scripts/generate_schema.py index 8e9b8b0..ae75725 100644 --- a/scripts/generate_schema.py +++ b/scripts/generate_schema.py @@ -7,13 +7,15 @@ python scripts/generate_schema.py The script writes the JSON Schema for :class:`~dcd_mapping.schemas.ScoresetMapping` -to ``schema.json`` at the repository root, overwriting the previous file. +to ``schema.json`` at the repository root, overwriting the previous file. With +``--check`` it writes nothing and exits 1 if ``schema.json`` is out of date. If you change schemas.py, re-run this script and commit the result. """ import json import pathlib +import sys from dcd_mapping.schemas import ScoresetMapping @@ -22,9 +24,17 @@ def main() -> None: - """Generate JSON Schema for ScoresetMapping and write to file.""" - schema = ScoresetMapping.model_json_schema() - SCHEMA_PATH.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n") + """Generate JSON Schema for ScoresetMapping and write it, or check it with --check.""" + schema = ( + json.dumps(ScoresetMapping.model_json_schema(), indent=2, sort_keys=True) + "\n" + ) + if "--check" in sys.argv[1:]: + if SCHEMA_PATH.read_text() != schema: + msg = f"{SCHEMA_PATH} is out of date; run python scripts/generate_schema.py" + print(msg) # noqa: T201 + sys.exit(1) + return + SCHEMA_PATH.write_text(schema) print(f"Written: {SCHEMA_PATH}") # noqa: T201 diff --git a/src/dcd_mapping/version.py b/src/dcd_mapping/version.py index f94f38e..f96c878 100644 --- a/src/dcd_mapping/version.py +++ b/src/dcd_mapping/version.py @@ -1,3 +1,3 @@ """Provide dcd mapping version""" -dcd_mapping_version = "2026.3.1" +dcd_mapping_version = "2026.3.2" diff --git a/tests/test_liftover_chainfiles.py b/tests/test_liftover_chainfiles.py new file mode 100644 index 0000000..2d1a65c --- /dev/null +++ b/tests/test_liftover_chainfiles.py @@ -0,0 +1,30 @@ +"""Check that the mapper Docker image ships the UCSC liftover chain files. + +This is an image check, not a unit test: the Dockerfile runs it during +``docker build``, and it skips everywhere else. Outside the image agct is free +to download the chain files on demand. Inside it, that download goes through +certifi, which fails whenever certifi drops a root UCSC's TLS chain relies on +(VariantEffect/dcd_mapping2#117), so the Dockerfile bakes the files in and this +check fails the build if agct can't find them. +""" + +import os +from unittest import mock + +import pytest +from agct import Converter + +# Only the Dockerfile sets DCD_MAPPING_RESOURCES_DIR, so it marks the image. +pytestmark = pytest.mark.skipif( + "DCD_MAPPING_RESOURCES_DIR" not in os.environ, + reason="image check; runs during docker build", +) + + +@pytest.mark.parametrize(("from_db", "to_db"), [("hg19", "hg38"), ("hg38", "hg19")]) +def test_converter_loads_baked_chainfile(from_db: str, to_db: str): + with mock.patch( + "agct.converter.download_http", + side_effect=AssertionError("agct tried to download a chain file"), + ): + Converter(from_db, to_db)