allelix 2.2.2__tar.gz → 2.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {allelix-2.2.2 → allelix-2.2.3}/PKG-INFO +5 -3
- {allelix-2.2.2 → allelix-2.2.3}/README.md +4 -2
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/gnomad.py +21 -26
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/twentythreeandme.py +27 -5
- {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/PKG-INFO +5 -3
- {allelix-2.2.2 → allelix-2.2.3}/pyproject.toml +1 -1
- allelix-2.2.3/tests/test_changelog_sanitize.py +179 -0
- allelix-2.2.2/tests/test_changelog_sanitize.py +0 -83
- {allelix-2.2.2 → allelix-2.2.3}/LICENSE +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/alphamissense.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/base.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/cadd.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/clinvar.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/gwas.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/pharmgkb.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/snpedia.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/_helpers.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/_options.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/analyze.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/config.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/db.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/focused.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/utility.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/compare.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/config.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/data/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/data/high_value_snps.yaml +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/_versions.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/alphamissense_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/cadd_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/cpic_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/gnomad_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/gwas_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/loader_utils.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/manager.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/pharmgkb_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/schema.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/snpedia_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/snpedia_parser.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/exporters/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/exporters/plink.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/models.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/_helpers.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/ancestrydna.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/base.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/ftdna.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/ftdna_famfinder.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/ftdna_illumina.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/livingdna.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/myhappygenes.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/myheritage.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/vcf.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/py.typed +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/_pipeline.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/diff.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/high_value.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/html.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/json_report.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/methylation.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/terminal.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/utils/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/utils/allele.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix/utils/build_detect.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/SOURCES.txt +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/dependency_links.txt +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/entry_points.txt +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/requires.txt +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/top_level.txt +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/setup.cfg +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_cli.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_cli_helpers.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_compare.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_config.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_end_to_end.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_extract_rsidless_gh128.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_mock_data_invariants.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_models.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_original_rsid_never_serializes.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_registry.py +0 -0
- {allelix-2.2.2 → allelix-2.2.3}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allelix
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first.
|
|
5
5
|
Author: Allelix
|
|
6
6
|
Maintainer-email: dial481 <dial481@users.noreply.github.com>
|
|
@@ -190,8 +190,10 @@ disease findings. Wherever you write them via `--output <path>`,
|
|
|
190
190
|
that's where they sit until you delete them. Allelix doesn't
|
|
191
191
|
auto-clean and won't warn you when you write to `/tmp/` or any
|
|
192
192
|
other shared location. Treat the files as personal data: read them,
|
|
193
|
-
move them somewhere you control, or delete when you're done.
|
|
194
|
-
|
|
193
|
+
move them somewhere you control, or delete when you're done.
|
|
194
|
+
`allelix db clean` (shipped in v2.2.0) handles the cache side of
|
|
195
|
+
the lifecycle; output reports are still the user's responsibility
|
|
196
|
+
to manage.
|
|
195
197
|
|
|
196
198
|
## Configuration
|
|
197
199
|
|
|
@@ -151,8 +151,10 @@ disease findings. Wherever you write them via `--output <path>`,
|
|
|
151
151
|
that's where they sit until you delete them. Allelix doesn't
|
|
152
152
|
auto-clean and won't warn you when you write to `/tmp/` or any
|
|
153
153
|
other shared location. Treat the files as personal data: read them,
|
|
154
|
-
move them somewhere you control, or delete when you're done.
|
|
155
|
-
|
|
154
|
+
move them somewhere you control, or delete when you're done.
|
|
155
|
+
`allelix db clean` (shipped in v2.2.0) handles the cache side of
|
|
156
|
+
the lifecycle; output reports are still the user's responsibility
|
|
157
|
+
to manage.
|
|
156
158
|
|
|
157
159
|
## Configuration
|
|
158
160
|
|
|
@@ -247,34 +247,29 @@ class GnomadAnnotator(Annotator):
|
|
|
247
247
|
return {}
|
|
248
248
|
conn = self._connection()
|
|
249
249
|
result: dict[tuple[str, int], list[tuple[str, str, str]]] = {}
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
# ``
|
|
260
|
-
# serves the lookup directly. ``_BULK_BATCH_SIZE`` here
|
|
261
|
-
# refers to (chrom, pos) PAIRS not single ints, so each
|
|
262
|
-
# chunk binds 2 * batch_size parameters.
|
|
250
|
+
# Issue a single combined SQL statement per chunk using SQLite's
|
|
251
|
+
# row-value ``(chrom, pos) IN ((?, ?), ...)`` form instead of
|
|
252
|
+
# grouping by chromosome and issuing one statement per
|
|
253
|
+
# (chrom, chunk) pair. On WGS input each batch touches all ~24
|
|
254
|
+
# chromosomes; the per-chrom shape was a 24x query multiplier
|
|
255
|
+
# the ``idx_gnomad_position`` index could not amortize away. The
|
|
256
|
+
# row-value IN form keeps the same carrier-rule contract — both
|
|
257
|
+
# ``chrom`` and ``pos`` must match the input pair simultaneously
|
|
258
|
+
# — and the ``idx_gnomad_position`` covering index on
|
|
259
|
+
# ``(chrom, pos)`` serves the lookup directly.
|
|
263
260
|
#
|
|
264
|
-
#
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
|
|
273
|
-
# the runtime ``sqlite3.sqlite_version`` before assuming
|
|
274
|
-
# capacity.
|
|
261
|
+
# Each input pair binds 2 SQLite parameters (chrom + pos), so
|
|
262
|
+
# the chunk size is ``_BULK_BATCH_SIZE // 2`` to stay inside the
|
|
263
|
+
# 999-param convention every other annotator and gnomAD's own
|
|
264
|
+
# multi-param queries respect (``bulk_lookup_by_alt`` does the
|
|
265
|
+
# same 2-param shape with the same ``// 2``;
|
|
266
|
+
# ``bulk_lookup_by_position`` uses ``// 4`` for its 4-param
|
|
267
|
+
# shape, same convention scaled). Net effective batch size:
|
|
268
|
+
# 450 pairs x 2 = 900 params per chunk.
|
|
269
|
+
chunk_pairs = _BULK_BATCH_SIZE // 2
|
|
275
270
|
position_list = list(positions)
|
|
276
|
-
for i in range(0, len(position_list),
|
|
277
|
-
batch = position_list[i : i +
|
|
271
|
+
for i in range(0, len(position_list), chunk_pairs):
|
|
272
|
+
batch = position_list[i : i + chunk_pairs]
|
|
278
273
|
placeholders = ",".join("(?, ?)" for _ in batch)
|
|
279
274
|
params: list[str | int] = []
|
|
280
275
|
for chrom, pos in batch:
|
|
@@ -42,7 +42,26 @@ if TYPE_CHECKING:
|
|
|
42
42
|
|
|
43
43
|
logger = logging.getLogger(__name__)
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
# Known canonical first-line banner variants 23andMe wraps raw exports in.
|
|
46
|
+
# The original v2/v3/v4-era exports used the verbatim ``generated by`` form;
|
|
47
|
+
# the June 2026 v5 export prose was edited to ``is generated by`` (with a
|
|
48
|
+
# trailing period in some samples). Match either; future vendor banner
|
|
49
|
+
# edits get a new entry here.
|
|
50
|
+
#
|
|
51
|
+
# Why a tuple of exact prefixes instead of a loose ``"23andMe" in line``
|
|
52
|
+
# substring match: a bare substring check false-positives on transcoded
|
|
53
|
+
# fixtures and third-party tool output. Files that strip the canonical
|
|
54
|
+
# banner entirely (WGSExtract output, Genetic Lifehacks converter output,
|
|
55
|
+
# privacy-conscious users who delete the comment block) are out of scope
|
|
56
|
+
# for this parser by design — they are handled by ``GenericTsvParser`` at
|
|
57
|
+
# the end of the dispatch chain (planned, see GH #146 / GH #127). That
|
|
58
|
+
# two-layer design keeps banner-bearing 23andMe files attributed to
|
|
59
|
+
# "23andMe" and banner-less 23andMe-shape files attributed to "generic-tsv"
|
|
60
|
+
# without guessing a vendor.
|
|
61
|
+
_CANONICAL_PREFIXES: tuple[str, ...] = (
|
|
62
|
+
"# This data file generated by 23andMe",
|
|
63
|
+
"# This data file is generated by 23andMe",
|
|
64
|
+
)
|
|
46
65
|
SNIFF_LINE_LIMIT = 50
|
|
47
66
|
EXPECTED_COLUMNS = 4
|
|
48
67
|
|
|
@@ -58,10 +77,13 @@ class TwentyThreeAndMeParser(GenotypeParser):
|
|
|
58
77
|
def can_parse(self, file_path: Path) -> bool:
|
|
59
78
|
"""Recognize a real 23andMe export by its canonical first-line header.
|
|
60
79
|
|
|
61
|
-
Real exports start with
|
|
80
|
+
Real exports start with one of the variants in ``_CANONICAL_PREFIXES``:
|
|
81
|
+
the historical ``# This data file generated by 23andMe at: ...`` form
|
|
82
|
+
or the June 2026 v5 ``# This data file is generated by 23andMe.`` form.
|
|
62
83
|
Some files have user-prepended comments before the canonical line; the
|
|
63
|
-
|
|
64
|
-
"23andMe" mention without the canonical
|
|
84
|
+
scan loop reads up to ``SNIFF_LINE_LIMIT`` comment lines. A bare
|
|
85
|
+
"23andMe" substring mention without one of the canonical prefixes is
|
|
86
|
+
rejected (see ``_CANONICAL_PREFIXES`` rationale).
|
|
65
87
|
"""
|
|
66
88
|
try:
|
|
67
89
|
with file_path.open("r", encoding="utf-8") as fh:
|
|
@@ -71,7 +93,7 @@ class TwentyThreeAndMeParser(GenotypeParser):
|
|
|
71
93
|
return False
|
|
72
94
|
if not line.startswith("#"):
|
|
73
95
|
return False
|
|
74
|
-
if line.startswith(
|
|
96
|
+
if any(line.startswith(p) for p in _CANONICAL_PREFIXES):
|
|
75
97
|
return True
|
|
76
98
|
except (OSError, UnicodeDecodeError):
|
|
77
99
|
return False
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allelix
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first.
|
|
5
5
|
Author: Allelix
|
|
6
6
|
Maintainer-email: dial481 <dial481@users.noreply.github.com>
|
|
@@ -190,8 +190,10 @@ disease findings. Wherever you write them via `--output <path>`,
|
|
|
190
190
|
that's where they sit until you delete them. Allelix doesn't
|
|
191
191
|
auto-clean and won't warn you when you write to `/tmp/` or any
|
|
192
192
|
other shared location. Treat the files as personal data: read them,
|
|
193
|
-
move them somewhere you control, or delete when you're done.
|
|
194
|
-
|
|
193
|
+
move them somewhere you control, or delete when you're done.
|
|
194
|
+
`allelix db clean` (shipped in v2.2.0) handles the cache side of
|
|
195
|
+
the lifecycle; output reports are still the user's responsibility
|
|
196
|
+
to manage.
|
|
195
197
|
|
|
196
198
|
## Configuration
|
|
197
199
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "allelix"
|
|
7
|
-
version = "2.2.
|
|
7
|
+
version = "2.2.3"
|
|
8
8
|
description = "Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Allelix
|
|
3
|
+
"""Release-gate sanitize guard for the public CHANGELOG.
|
|
4
|
+
|
|
5
|
+
GH #118: CHANGELOG.md ships verbatim from the private allelix-dev
|
|
6
|
+
repo to the public allelix repo and PyPI. Bare `#NN` references
|
|
7
|
+
auto-link on GitHub against the surrounding repo's numbering — on
|
|
8
|
+
the public repo they ALL 404, because the issues/PRs only exist on
|
|
9
|
+
the private tracker. Same hazard for an `### Internal` section,
|
|
10
|
+
which is forensic provenance no public user needs.
|
|
11
|
+
|
|
12
|
+
These two assertions are the structural guard the issue requires:
|
|
13
|
+
- any bare issue/PR reference (#NN) fails the gate
|
|
14
|
+
- any `### Internal` heading fails the gate
|
|
15
|
+
|
|
16
|
+
The guard runs on every fast-tier pytest pass, so a PR that
|
|
17
|
+
re-introduces either form gets caught at PR time, not at tag time.
|
|
18
|
+
|
|
19
|
+
If you genuinely need internal traceability for an entry, put the
|
|
20
|
+
issue number in the git commit body (the private dev branch keeps
|
|
21
|
+
the squash commit + the private-only release commit body — both
|
|
22
|
+
retain forensic context for anyone tracing history). Public users
|
|
23
|
+
read the CHANGELOG; everything in there must stand on its own.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
_CHANGELOG = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
34
|
+
_ISSUE_REF_PATTERN = re.compile(r"#\d+\b")
|
|
35
|
+
_INTERNAL_HEADING_PATTERN = re.compile(r"^###\s+Internal\b", re.MULTILINE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(scope="module")
|
|
39
|
+
def changelog_text() -> str:
|
|
40
|
+
return _CHANGELOG.read_text(encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_changelog_has_no_issue_or_pr_references(changelog_text: str) -> None:
|
|
44
|
+
"""GH #118: every `#NN` token in CHANGELOG.md auto-links to the
|
|
45
|
+
surrounding repo on GitHub. Private-repo issue/PR numbers don't
|
|
46
|
+
exist on the public repo, so every shipped `#NN` is a dead link.
|
|
47
|
+
|
|
48
|
+
If you need to credit an issue, restructure the prose so it
|
|
49
|
+
stands without the tracker number, and put the reference in the
|
|
50
|
+
git commit body (forensic context the squash commit retains).
|
|
51
|
+
"""
|
|
52
|
+
matches = _ISSUE_REF_PATTERN.findall(changelog_text)
|
|
53
|
+
assert not matches, (
|
|
54
|
+
f"CHANGELOG.md contains {len(matches)} bare issue/PR reference(s) "
|
|
55
|
+
f"that would become dead links on the public repo: "
|
|
56
|
+
f"{sorted(set(matches))[:10]}{'…' if len(set(matches)) > 10 else ''}. "
|
|
57
|
+
f"Strip them (forensic context lives in the git commit body, not "
|
|
58
|
+
f"in the user-facing CHANGELOG)."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_changelog_has_no_internal_section(changelog_text: str) -> None:
|
|
63
|
+
"""GH #118: an `### Internal` heading carries forensic provenance
|
|
64
|
+
(architectural notes, plumbing details, fixture changes) that no
|
|
65
|
+
public user needs. The squash commit on main + the private
|
|
66
|
+
release commit body both retain this context for anyone tracing
|
|
67
|
+
history.
|
|
68
|
+
"""
|
|
69
|
+
matches = _INTERNAL_HEADING_PATTERN.findall(changelog_text)
|
|
70
|
+
assert not matches, (
|
|
71
|
+
"CHANGELOG.md contains an '### Internal' heading. Move that "
|
|
72
|
+
"content into the squash commit body on main (the load-bearing "
|
|
73
|
+
"forensic surface) and drop the heading from the CHANGELOG."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_changelog_keep_a_changelog_header_intact(changelog_text: str) -> None:
|
|
78
|
+
"""Light sanity check that the file structure isn't accidentally
|
|
79
|
+
truncated by an over-aggressive sanitize pass — the Keep a
|
|
80
|
+
Changelog header should always be present at the top."""
|
|
81
|
+
first_lines = "\n".join(changelog_text.splitlines()[:3])
|
|
82
|
+
assert "# Changelog" in first_lines
|
|
83
|
+
assert "Keep a Changelog" in changelog_text
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Drift-vulnerable count patterns. These match the three shapes that have
|
|
87
|
+
# historically shipped in release content and silently rotted across DB
|
|
88
|
+
# refreshes: panel-coverage ratios ("3/20 found"), timing arrows
|
|
89
|
+
# ("~165s → ~133s"), and explicit before/after pairs ("went from X to Y").
|
|
90
|
+
# Each is an absolute measurement against a specific DB snapshot — the same
|
|
91
|
+
# code on a refreshed DB will produce different numbers, so any reader
|
|
92
|
+
# (or reviewer) re-running the analysis later sees what looks like a
|
|
93
|
+
# regression.
|
|
94
|
+
#
|
|
95
|
+
# The patterns are deliberately narrow: they flag comparison constructs
|
|
96
|
+
# specifically, not bare numbers in infrastructure descriptions ("10
|
|
97
|
+
# non-blank lines", "57M-row cache", "999-param convention"). The
|
|
98
|
+
# harness output format (``✓ <key>: N annotations, M unique keys, all
|
|
99
|
+
# invariants hold``) deliberately does not match, because that line IS
|
|
100
|
+
# the drift-tolerant signal — see ``test_data/check_ground_truth.py``.
|
|
101
|
+
_DRIFT_PATTERNS = (
|
|
102
|
+
re.compile(r"\d+/\d+ found"), # "3/20 found"
|
|
103
|
+
re.compile(r"~\d+\s*s\b.*→.*~\d+\s*s\b"), # "~165s → ~133s"
|
|
104
|
+
re.compile(r"went from \d", re.IGNORECASE), # "went from X to Y"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Caveat keywords that opt a drift-pattern line back in: if a paragraph
|
|
108
|
+
# contains a comparison count AND one of these keywords nearby, the
|
|
109
|
+
# author has explicitly tagged the measurement as a snapshot. The check
|
|
110
|
+
# is per-section (not per-line) so a single calibration paragraph at
|
|
111
|
+
# the top of an [Unreleased] block covers all measurements inside.
|
|
112
|
+
_SNAPSHOT_CAVEAT_KEYWORDS = (
|
|
113
|
+
"snapshot",
|
|
114
|
+
"drift",
|
|
115
|
+
"point-in-time",
|
|
116
|
+
"harness floor",
|
|
117
|
+
"harness invariant",
|
|
118
|
+
"ground-truth harness",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _extract_unreleased_section(text: str) -> str:
|
|
123
|
+
"""Return the ``[Unreleased]`` block (without surrounding sections).
|
|
124
|
+
|
|
125
|
+
The guard scopes drift-pattern enforcement to ``[Unreleased]`` only.
|
|
126
|
+
Already-shipped release sections are immutable history; rewriting
|
|
127
|
+
them would create churn for no reader benefit (the corresponding
|
|
128
|
+
PyPI sdist and squash commit body are forever pinned regardless).
|
|
129
|
+
The discipline is forward-looking — every release shipped from this
|
|
130
|
+
commit onward gets the check.
|
|
131
|
+
"""
|
|
132
|
+
marker_open = "\n## [Unreleased]"
|
|
133
|
+
if marker_open not in text:
|
|
134
|
+
return ""
|
|
135
|
+
after = text.split(marker_open, 1)[1]
|
|
136
|
+
# The block runs until the next ``## [`` heading or EOF.
|
|
137
|
+
next_section = re.search(r"\n## \[", after)
|
|
138
|
+
if next_section:
|
|
139
|
+
return after[: next_section.start()]
|
|
140
|
+
return after
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_changelog_unreleased_has_no_drift_vulnerable_counts(
|
|
144
|
+
changelog_text: str,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""GH #145-era discipline: raw drift-vulnerable counts in
|
|
147
|
+
``[Unreleased]`` must carry an explicit snapshot caveat, or the
|
|
148
|
+
measurement must be quoted via the §19 ground-truth harness output
|
|
149
|
+
format (which is drift-tolerant by design).
|
|
150
|
+
|
|
151
|
+
See ``CONTRIBUTING.md`` § Release-content discipline for the
|
|
152
|
+
rationale and the two acceptable shapes.
|
|
153
|
+
"""
|
|
154
|
+
unreleased = _extract_unreleased_section(changelog_text)
|
|
155
|
+
if not unreleased.strip():
|
|
156
|
+
# Empty [Unreleased] section is fine — nothing to validate.
|
|
157
|
+
return
|
|
158
|
+
drift_hits: list[str] = []
|
|
159
|
+
for pattern in _DRIFT_PATTERNS:
|
|
160
|
+
for match in pattern.finditer(unreleased):
|
|
161
|
+
drift_hits.append(match.group(0))
|
|
162
|
+
if not drift_hits:
|
|
163
|
+
return
|
|
164
|
+
# Any drift hit requires a snapshot caveat keyword anywhere in the
|
|
165
|
+
# [Unreleased] block. One calibration paragraph at the top of the
|
|
166
|
+
# block covers all measurements inside.
|
|
167
|
+
lowered = unreleased.lower()
|
|
168
|
+
if any(kw in lowered for kw in _SNAPSHOT_CAVEAT_KEYWORDS):
|
|
169
|
+
return
|
|
170
|
+
raise AssertionError(
|
|
171
|
+
f"CHANGELOG.md [Unreleased] contains {len(drift_hits)} drift-vulnerable "
|
|
172
|
+
f"count(s) without a snapshot caveat: {drift_hits[:5]}"
|
|
173
|
+
f"{'…' if len(drift_hits) > 5 else ''}. "
|
|
174
|
+
f"Either remove the raw counts, wrap them with an explicit snapshot "
|
|
175
|
+
f"caveat (any of: {', '.join(_SNAPSHOT_CAVEAT_KEYWORDS)}), or quote "
|
|
176
|
+
f"the §19 ground-truth harness output format instead "
|
|
177
|
+
f"(`✓ <key>: N annotations, M unique keys, all invariants hold`). "
|
|
178
|
+
f"See CONTRIBUTING.md § Release-content discipline."
|
|
179
|
+
)
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
# Copyright (C) 2026 Allelix
|
|
3
|
-
"""Release-gate sanitize guard for the public CHANGELOG.
|
|
4
|
-
|
|
5
|
-
GH #118: CHANGELOG.md ships verbatim from the private allelix-dev
|
|
6
|
-
repo to the public allelix repo and PyPI. Bare `#NN` references
|
|
7
|
-
auto-link on GitHub against the surrounding repo's numbering — on
|
|
8
|
-
the public repo they ALL 404, because the issues/PRs only exist on
|
|
9
|
-
the private tracker. Same hazard for an `### Internal` section,
|
|
10
|
-
which is forensic provenance no public user needs.
|
|
11
|
-
|
|
12
|
-
These two assertions are the structural guard the issue requires:
|
|
13
|
-
- any bare issue/PR reference (#NN) fails the gate
|
|
14
|
-
- any `### Internal` heading fails the gate
|
|
15
|
-
|
|
16
|
-
The guard runs on every fast-tier pytest pass, so a PR that
|
|
17
|
-
re-introduces either form gets caught at PR time, not at tag time.
|
|
18
|
-
|
|
19
|
-
If you genuinely need internal traceability for an entry, put the
|
|
20
|
-
issue number in the git commit body (the private dev branch keeps
|
|
21
|
-
the squash commit + the private-only release commit body — both
|
|
22
|
-
retain forensic context for anyone tracing history). Public users
|
|
23
|
-
read the CHANGELOG; everything in there must stand on its own.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
from __future__ import annotations
|
|
27
|
-
|
|
28
|
-
import re
|
|
29
|
-
from pathlib import Path
|
|
30
|
-
|
|
31
|
-
import pytest
|
|
32
|
-
|
|
33
|
-
_CHANGELOG = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
34
|
-
_ISSUE_REF_PATTERN = re.compile(r"#\d+\b")
|
|
35
|
-
_INTERNAL_HEADING_PATTERN = re.compile(r"^###\s+Internal\b", re.MULTILINE)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@pytest.fixture(scope="module")
|
|
39
|
-
def changelog_text() -> str:
|
|
40
|
-
return _CHANGELOG.read_text(encoding="utf-8")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_changelog_has_no_issue_or_pr_references(changelog_text: str) -> None:
|
|
44
|
-
"""GH #118: every `#NN` token in CHANGELOG.md auto-links to the
|
|
45
|
-
surrounding repo on GitHub. Private-repo issue/PR numbers don't
|
|
46
|
-
exist on the public repo, so every shipped `#NN` is a dead link.
|
|
47
|
-
|
|
48
|
-
If you need to credit an issue, restructure the prose so it
|
|
49
|
-
stands without the tracker number, and put the reference in the
|
|
50
|
-
git commit body (forensic context the squash commit retains).
|
|
51
|
-
"""
|
|
52
|
-
matches = _ISSUE_REF_PATTERN.findall(changelog_text)
|
|
53
|
-
assert not matches, (
|
|
54
|
-
f"CHANGELOG.md contains {len(matches)} bare issue/PR reference(s) "
|
|
55
|
-
f"that would become dead links on the public repo: "
|
|
56
|
-
f"{sorted(set(matches))[:10]}{'…' if len(set(matches)) > 10 else ''}. "
|
|
57
|
-
f"Strip them (forensic context lives in the git commit body, not "
|
|
58
|
-
f"in the user-facing CHANGELOG)."
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_changelog_has_no_internal_section(changelog_text: str) -> None:
|
|
63
|
-
"""GH #118: an `### Internal` heading carries forensic provenance
|
|
64
|
-
(architectural notes, plumbing details, fixture changes) that no
|
|
65
|
-
public user needs. The squash commit on main + the private
|
|
66
|
-
release commit body both retain this context for anyone tracing
|
|
67
|
-
history.
|
|
68
|
-
"""
|
|
69
|
-
matches = _INTERNAL_HEADING_PATTERN.findall(changelog_text)
|
|
70
|
-
assert not matches, (
|
|
71
|
-
"CHANGELOG.md contains an '### Internal' heading. Move that "
|
|
72
|
-
"content into the squash commit body on main (the load-bearing "
|
|
73
|
-
"forensic surface) and drop the heading from the CHANGELOG."
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def test_changelog_keep_a_changelog_header_intact(changelog_text: str) -> None:
|
|
78
|
-
"""Light sanity check that the file structure isn't accidentally
|
|
79
|
-
truncated by an over-aggressive sanitize pass — the Keep a
|
|
80
|
-
Changelog header should always be present at the top."""
|
|
81
|
-
first_lines = "\n".join(changelog_text.splitlines()[:3])
|
|
82
|
-
assert "# Changelog" in first_lines
|
|
83
|
-
assert "Keep a Changelog" in changelog_text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|