allelix 2.2.2__tar.gz → 2.3.0__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.3.0}/PKG-INFO +12 -5
- {allelix-2.2.2 → allelix-2.3.0}/README.md +11 -4
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/gnomad.py +21 -26
- {allelix-2.2.2 → allelix-2.3.0}/allelix/cli/_helpers.py +38 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/cli/_options.py +14 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/cli/analyze.py +4 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/twentythreeandme.py +27 -5
- {allelix-2.2.2 → allelix-2.3.0}/allelix/reports/_pipeline.py +53 -1
- allelix-2.3.0/allelix/reports/vcf.py +579 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix.egg-info/PKG-INFO +12 -5
- {allelix-2.2.2 → allelix-2.3.0}/allelix.egg-info/SOURCES.txt +2 -0
- {allelix-2.2.2 → allelix-2.3.0}/pyproject.toml +1 -1
- allelix-2.3.0/tests/test_changelog_sanitize.py +179 -0
- allelix-2.3.0/tests/test_cli_vcf_out.py +369 -0
- allelix-2.2.2/tests/test_changelog_sanitize.py +0 -83
- {allelix-2.2.2 → allelix-2.3.0}/LICENSE +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/alphamissense.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/base.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/cadd.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/clinvar.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/gwas.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/pharmgkb.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/annotators/snpedia.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/cli/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/cli/config.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/cli/db.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/cli/focused.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/cli/utility.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/compare.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/config.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/data/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/data/high_value_snps.yaml +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/_versions.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/alphamissense_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/cadd_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/cpic_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/gnomad_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/gwas_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/loader_utils.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/manager.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/pharmgkb_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/schema.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/snpedia_loader.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/databases/snpedia_parser.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/exporters/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/exporters/plink.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/models.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/_helpers.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/ancestrydna.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/base.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/ftdna.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/ftdna_famfinder.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/ftdna_illumina.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/livingdna.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/myhappygenes.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/myheritage.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/parsers/vcf.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/py.typed +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/reports/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/reports/diff.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/reports/high_value.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/reports/html.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/reports/json_report.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/reports/methylation.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/reports/terminal.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/utils/__init__.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/utils/allele.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix/utils/build_detect.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix.egg-info/dependency_links.txt +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix.egg-info/entry_points.txt +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix.egg-info/requires.txt +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/allelix.egg-info/top_level.txt +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/setup.cfg +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_cli.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_cli_helpers.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_compare.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_config.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_end_to_end.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_extract_rsidless_gh128.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_mock_data_invariants.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_models.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_original_rsid_never_serializes.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_registry.py +0 -0
- {allelix-2.2.2 → allelix-2.3.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allelix
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
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>
|
|
@@ -43,7 +43,7 @@ Dynamic: license-file
|
|
|
43
43
|
|
|
44
44
|
**Open-source genotype analysis. Format-agnostic. Offline-first.**
|
|
45
45
|
|
|
46
|
-
[Website](https://allelix.io) · [Quickstart](#quickstart) · [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md)
|
|
46
|
+
[Website](https://allelix.io) · [Demo](https://analyze.allelix.io) · [Quickstart](#quickstart) · [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md)
|
|
47
47
|
|
|
48
48
|
[](https://www.python.org/downloads/)
|
|
49
49
|
[](https://pypi.org/project/allelix/)
|
|
@@ -55,7 +55,7 @@ Dynamic: license-file
|
|
|
55
55
|
|
|
56
56
|
# Allelix
|
|
57
57
|
|
|
58
|
-
> **Status:** Production. Eight parser formats (including VCF + gVCF), four annotators (ClinVar + ClinPGx + GWAS Catalog + SNPedia), three enrichment sources (gnomAD + AlphaMissense + CADD), licensable-source gating for commercial users, dual-build ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports, methylation + pharmacogenomics focused commands, report diffing, persistent config with commercial-mode safety switch. Build auto-detection from position data (ADR-0021). No regex on prose anywhere in production. The [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md) tracks every release.
|
|
58
|
+
> **Status:** Production. Eight parser formats (including VCF + gVCF), four annotators (ClinVar + ClinPGx + GWAS Catalog + SNPedia), three enrichment sources (gnomAD + AlphaMissense + CADD), licensable-source gating for commercial users, dual-build ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports and annotated VCF output for pipeline integration (v2.3.0+), methylation + pharmacogenomics focused commands, report diffing, persistent config with commercial-mode safety switch. Build auto-detection from position data (ADR-0021). No regex on prose anywhere in production. The [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md) tracks every release.
|
|
59
59
|
|
|
60
60
|
## Quickstart
|
|
61
61
|
|
|
@@ -80,6 +80,11 @@ allelix analyze trio.vcf.gz --sample HG002 --output report.html
|
|
|
80
80
|
|
|
81
81
|
# Filter to a custom panel (rsIDs + gene names, one per line; '#' comments and blank lines ignored)
|
|
82
82
|
allelix analyze your_genotype_file.txt --filter-file my_panel.txt --output report.html
|
|
83
|
+
|
|
84
|
+
# Annotated VCF output — VCF-in / VCF-out for pipeline integration
|
|
85
|
+
# Writes a valid VCF with annotations as INFO fields + full provenance in the header.
|
|
86
|
+
# Refuses to overwrite; requires VCF or gVCF input.
|
|
87
|
+
allelix analyze your_wgs.vcf.gz --vcf-out annotated.vcf
|
|
83
88
|
```
|
|
84
89
|
|
|
85
90
|
See [Development](#development) for source installs and running tests, [Managing your data](#managing-your-data) for cache locations and cleanup, and [Troubleshooting](#troubleshooting) for common failure states.
|
|
@@ -190,8 +195,10 @@ disease findings. Wherever you write them via `--output <path>`,
|
|
|
190
195
|
that's where they sit until you delete them. Allelix doesn't
|
|
191
196
|
auto-clean and won't warn you when you write to `/tmp/` or any
|
|
192
197
|
other shared location. Treat the files as personal data: read them,
|
|
193
|
-
move them somewhere you control, or delete when you're done.
|
|
194
|
-
|
|
198
|
+
move them somewhere you control, or delete when you're done.
|
|
199
|
+
`allelix db clean` (shipped in v2.2.0) handles the cache side of
|
|
200
|
+
the lifecycle; output reports are still the user's responsibility
|
|
201
|
+
to manage.
|
|
195
202
|
|
|
196
203
|
## Configuration
|
|
197
204
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**Open-source genotype analysis. Format-agnostic. Offline-first.**
|
|
6
6
|
|
|
7
|
-
[Website](https://allelix.io) · [Quickstart](#quickstart) · [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md)
|
|
7
|
+
[Website](https://allelix.io) · [Demo](https://analyze.allelix.io) · [Quickstart](#quickstart) · [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md)
|
|
8
8
|
|
|
9
9
|
[](https://www.python.org/downloads/)
|
|
10
10
|
[](https://pypi.org/project/allelix/)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
# Allelix
|
|
18
18
|
|
|
19
|
-
> **Status:** Production. Eight parser formats (including VCF + gVCF), four annotators (ClinVar + ClinPGx + GWAS Catalog + SNPedia), three enrichment sources (gnomAD + AlphaMissense + CADD), licensable-source gating for commercial users, dual-build ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports, methylation + pharmacogenomics focused commands, report diffing, persistent config with commercial-mode safety switch. Build auto-detection from position data (ADR-0021). No regex on prose anywhere in production. The [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md) tracks every release.
|
|
19
|
+
> **Status:** Production. Eight parser formats (including VCF + gVCF), four annotators (ClinVar + ClinPGx + GWAS Catalog + SNPedia), three enrichment sources (gnomAD + AlphaMissense + CADD), licensable-source gating for commercial users, dual-build ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports and annotated VCF output for pipeline integration (v2.3.0+), methylation + pharmacogenomics focused commands, report diffing, persistent config with commercial-mode safety switch. Build auto-detection from position data (ADR-0021). No regex on prose anywhere in production. The [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md) tracks every release.
|
|
20
20
|
|
|
21
21
|
## Quickstart
|
|
22
22
|
|
|
@@ -41,6 +41,11 @@ allelix analyze trio.vcf.gz --sample HG002 --output report.html
|
|
|
41
41
|
|
|
42
42
|
# Filter to a custom panel (rsIDs + gene names, one per line; '#' comments and blank lines ignored)
|
|
43
43
|
allelix analyze your_genotype_file.txt --filter-file my_panel.txt --output report.html
|
|
44
|
+
|
|
45
|
+
# Annotated VCF output — VCF-in / VCF-out for pipeline integration
|
|
46
|
+
# Writes a valid VCF with annotations as INFO fields + full provenance in the header.
|
|
47
|
+
# Refuses to overwrite; requires VCF or gVCF input.
|
|
48
|
+
allelix analyze your_wgs.vcf.gz --vcf-out annotated.vcf
|
|
44
49
|
```
|
|
45
50
|
|
|
46
51
|
See [Development](#development) for source installs and running tests, [Managing your data](#managing-your-data) for cache locations and cleanup, and [Troubleshooting](#troubleshooting) for common failure states.
|
|
@@ -151,8 +156,10 @@ disease findings. Wherever you write them via `--output <path>`,
|
|
|
151
156
|
that's where they sit until you delete them. Allelix doesn't
|
|
152
157
|
auto-clean and won't warn you when you write to `/tmp/` or any
|
|
153
158
|
other shared location. Treat the files as personal data: read them,
|
|
154
|
-
move them somewhere you control, or delete when you're done.
|
|
155
|
-
|
|
159
|
+
move them somewhere you control, or delete when you're done.
|
|
160
|
+
`allelix db clean` (shipped in v2.2.0) handles the cache side of
|
|
161
|
+
the lifecycle; output reports are still the user's responsibility
|
|
162
|
+
to manage.
|
|
156
163
|
|
|
157
164
|
## Configuration
|
|
158
165
|
|
|
@@ -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:
|
|
@@ -496,12 +496,29 @@ def _run_analysis_command(
|
|
|
496
496
|
no_alphamissense: bool = False,
|
|
497
497
|
no_cadd: bool = False,
|
|
498
498
|
sample: str | None = None,
|
|
499
|
+
vcf_out: Path | None = None,
|
|
499
500
|
) -> None:
|
|
500
501
|
resolved = resolve_data_dir(data_dir)
|
|
501
502
|
if not no_update:
|
|
502
503
|
_maybe_refresh_databases(resolved)
|
|
503
504
|
parser = _resolve_parser(file_path, fmt, sample=sample)
|
|
504
505
|
|
|
506
|
+
# #150: --vcf-out preflight. Bail before any expensive setup so a
|
|
507
|
+
# user who mistyped the flag or aimed it at a 23andMe file finds
|
|
508
|
+
# out immediately, not after the analyze pipeline runs.
|
|
509
|
+
if vcf_out is not None:
|
|
510
|
+
if parser.name != "vcf":
|
|
511
|
+
raise click.ClickException(
|
|
512
|
+
f"--vcf-out requires VCF or gVCF input; got {parser.display_name} "
|
|
513
|
+
f"({parser.name!r}). Convert to VCF first, or drop --vcf-out to "
|
|
514
|
+
"produce the default report."
|
|
515
|
+
)
|
|
516
|
+
if vcf_out.exists():
|
|
517
|
+
raise click.ClickException(
|
|
518
|
+
f"--vcf-out refuses to overwrite an existing file: {vcf_out}. "
|
|
519
|
+
"Move or delete the existing file, or choose a different path."
|
|
520
|
+
)
|
|
521
|
+
|
|
505
522
|
from allelix.config import load_config
|
|
506
523
|
|
|
507
524
|
cfg = load_config(resolved)
|
|
@@ -554,6 +571,7 @@ def _run_analysis_command(
|
|
|
554
571
|
cadd=cast("CaddAnnotator | None", cadd_annotator),
|
|
555
572
|
high_value_rsids=hv_rsids,
|
|
556
573
|
panel_rsids=rsids,
|
|
574
|
+
collect_vcf_write_inputs=vcf_out is not None,
|
|
557
575
|
)
|
|
558
576
|
finally:
|
|
559
577
|
_unwire_parser_logging(counter, stderr_handler, snapshot)
|
|
@@ -650,6 +668,26 @@ def _run_analysis_command(
|
|
|
650
668
|
)
|
|
651
669
|
console.print(f"[green]Wrote {rendered:,} annotation(s) to {output}[/green]")
|
|
652
670
|
|
|
671
|
+
if vcf_out is not None:
|
|
672
|
+
from allelix.reports.vcf import render_vcf
|
|
673
|
+
|
|
674
|
+
# Pipeline populates result.vcf_write_inputs whenever the
|
|
675
|
+
# ``collect_vcf_write_inputs`` flag is set — which the preflight
|
|
676
|
+
# above guarantees for this branch — so the None case here is
|
|
677
|
+
# a defensive assert, not a runtime path.
|
|
678
|
+
assert result.vcf_write_inputs is not None
|
|
679
|
+
stamped = render_vcf(
|
|
680
|
+
input_path=file_path,
|
|
681
|
+
output_path=vcf_out,
|
|
682
|
+
build=result.build,
|
|
683
|
+
annotators_used=result.annotators_used,
|
|
684
|
+
annotations_by_key=result.vcf_write_inputs.annotations_by_key,
|
|
685
|
+
recovered_rsids=result.vcf_write_inputs.recovered_rsids,
|
|
686
|
+
)
|
|
687
|
+
console.print(
|
|
688
|
+
f"[green]Wrote annotated VCF with {stamped:,} stamped row(s) to {vcf_out}[/green]"
|
|
689
|
+
)
|
|
690
|
+
|
|
653
691
|
console.print(
|
|
654
692
|
f"[dim]{len(result.annotations):,} total annotation(s) from {len(ready)} "
|
|
655
693
|
f"database(s) across {result.total_variants:,} variant(s).[/dim]"
|
|
@@ -169,3 +169,17 @@ _SAMPLE_OPT = click.option(
|
|
|
169
169
|
"or any plain VCF tool to list available samples."
|
|
170
170
|
),
|
|
171
171
|
)
|
|
172
|
+
# #150: annotated VCF output. Requires VCF/gVCF input (any other format
|
|
173
|
+
# errors out early). Existing output paths are refused (no clobber, no
|
|
174
|
+
# --force in v2.3.0). See ADR-0036 for the output contract.
|
|
175
|
+
_VCF_OUT_OPT = click.option(
|
|
176
|
+
"--vcf-out",
|
|
177
|
+
"vcf_out",
|
|
178
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
179
|
+
default=None,
|
|
180
|
+
help=(
|
|
181
|
+
"Write an annotated VCF at this path (v2.3.0+; experimental output "
|
|
182
|
+
"contract, ADR-0036). Requires VCF or gVCF input. Refuses to "
|
|
183
|
+
"overwrite an existing file."
|
|
184
|
+
),
|
|
185
|
+
)
|
|
@@ -29,6 +29,7 @@ from allelix.cli._options import (
|
|
|
29
29
|
_REPORT_FORMAT_OPT,
|
|
30
30
|
_SAMPLE_OPT,
|
|
31
31
|
_SNPEDIA_MIN_MAG_OPT,
|
|
32
|
+
_VCF_OUT_OPT,
|
|
32
33
|
)
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
@@ -61,6 +62,7 @@ if TYPE_CHECKING:
|
|
|
61
62
|
@_NO_ALPHAMISSENSE_OPT
|
|
62
63
|
@_NO_CADD_OPT
|
|
63
64
|
@_SAMPLE_OPT
|
|
65
|
+
@_VCF_OUT_OPT
|
|
64
66
|
def analyze(
|
|
65
67
|
file_path: Path,
|
|
66
68
|
fmt: str | None,
|
|
@@ -82,6 +84,7 @@ def analyze(
|
|
|
82
84
|
no_alphamissense: bool,
|
|
83
85
|
no_cadd: bool,
|
|
84
86
|
sample: str | None,
|
|
87
|
+
vcf_out: Path | None,
|
|
85
88
|
) -> None:
|
|
86
89
|
"""Annotate a genotype file against all ready reference databases."""
|
|
87
90
|
filter_genes: frozenset[str] | None = None
|
|
@@ -113,4 +116,5 @@ def analyze(
|
|
|
113
116
|
no_alphamissense=no_alphamissense,
|
|
114
117
|
no_cadd=no_cadd,
|
|
115
118
|
sample=sample,
|
|
119
|
+
vcf_out=vcf_out,
|
|
116
120
|
)
|
|
@@ -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
|
|
@@ -39,6 +39,7 @@ if TYPE_CHECKING:
|
|
|
39
39
|
from allelix.annotators.gnomad import GnomadAnnotator
|
|
40
40
|
from allelix.models import Annotation, Variant
|
|
41
41
|
from allelix.parsers.base import GenotypeParser
|
|
42
|
+
from allelix.reports.vcf import VcfWriteInputs
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
# How many input variants to buffer while waiting for detection to
|
|
@@ -155,6 +156,12 @@ class AnalysisResult:
|
|
|
155
156
|
# Used by the CLI to surface the GRCh38 rsID-less undercount
|
|
156
157
|
# warning until #62's dbSNP resolution lands.
|
|
157
158
|
rsidless_variant_count: int = 0
|
|
159
|
+
# #150: pipeline-produced inputs for the annotated VCF writer.
|
|
160
|
+
# Populated only when ``run_analysis`` is called with
|
|
161
|
+
# ``collect_vcf_write_inputs=True`` — the ``--vcf-out`` code path.
|
|
162
|
+
# None on every other analyze run (zero memory / CPU overhead on the
|
|
163
|
+
# default path). Consumed by ``allelix.reports.vcf.render_vcf``.
|
|
164
|
+
vcf_write_inputs: VcfWriteInputs | None = None
|
|
158
165
|
|
|
159
166
|
def panel_coverage(
|
|
160
167
|
self, filtered_annotations: list[Annotation] | None = None
|
|
@@ -439,6 +446,7 @@ def run_analysis(
|
|
|
439
446
|
cadd: CaddAnnotator | None = None,
|
|
440
447
|
high_value_rsids: set[str] | None = None,
|
|
441
448
|
panel_rsids: frozenset[str] | None = None,
|
|
449
|
+
collect_vcf_write_inputs: bool = False,
|
|
442
450
|
) -> AnalysisResult:
|
|
443
451
|
"""Stream the file once; batch-annotate; return a fully-populated result.
|
|
444
452
|
|
|
@@ -515,6 +523,13 @@ def run_analysis(
|
|
|
515
523
|
# back to position-keyed gnomAD / AlphaMissense lookups for resolved
|
|
516
524
|
# rsIDs that don't appear in those caches' rsid index.
|
|
517
525
|
resolved_coords: dict[str, tuple[str, int, str, str]] = {}
|
|
526
|
+
# #150: annotation dict keyed by (chrom, pos, ref, alt) for the
|
|
527
|
+
# annotated-VCF writer. Populated per batch inside ``_flush`` when
|
|
528
|
+
# ``collect_vcf_write_inputs`` is True. Values are references to the
|
|
529
|
+
# Annotation objects in ``annotations`` — enrichment mutations
|
|
530
|
+
# (gnomAD AF, AlphaMissense, CADD) that happen after streaming are
|
|
531
|
+
# visible through these refs without a per-annotation copy.
|
|
532
|
+
vcf_annotations_by_key: dict[tuple[str, int, str, str], list[Annotation]] = {}
|
|
518
533
|
|
|
519
534
|
with contextlib.ExitStack() as stack:
|
|
520
535
|
bound = [stack.enter_context(a) for a in annotators]
|
|
@@ -617,8 +632,29 @@ def run_analysis(
|
|
|
617
632
|
nonlocal no_ref_total, rsidless_total
|
|
618
633
|
no_ref_total += sum(1 for v in batch_buf if v.ref is None)
|
|
619
634
|
rsidless_total += sum(1 for v in batch_buf if not v.rsid.startswith("rs"))
|
|
635
|
+
batch_new: list[Annotation] = []
|
|
620
636
|
for annotator in bound:
|
|
621
|
-
|
|
637
|
+
batch_new.extend(annotator.batch_annotate(batch_buf))
|
|
638
|
+
annotations.extend(batch_new)
|
|
639
|
+
# #150: collect writer inputs for this batch. Only touches
|
|
640
|
+
# variants whose ``ref`` is populated (VCF inputs; array
|
|
641
|
+
# data with ref=None can't produce a well-formed VCF key).
|
|
642
|
+
# Skips annotations with empty ``alt`` — GWAS rows keyed by
|
|
643
|
+
# rsID alone can't be VCF-stamped without allele context.
|
|
644
|
+
if collect_vcf_write_inputs:
|
|
645
|
+
rsid_to_coords: dict[str, tuple[str, int, str]] = {
|
|
646
|
+
v.rsid: (v.chromosome, v.position, v.ref)
|
|
647
|
+
for v in batch_buf
|
|
648
|
+
if v.ref is not None
|
|
649
|
+
}
|
|
650
|
+
for a in batch_new:
|
|
651
|
+
if not a.alt:
|
|
652
|
+
continue
|
|
653
|
+
coords = rsid_to_coords.get(a.rsid)
|
|
654
|
+
if coords is None:
|
|
655
|
+
continue
|
|
656
|
+
chrom, pos, ref = coords
|
|
657
|
+
vcf_annotations_by_key.setdefault((chrom, pos, ref, a.alt), []).append(a)
|
|
622
658
|
batch_buf.clear()
|
|
623
659
|
|
|
624
660
|
def _accept(v: Variant) -> None:
|
|
@@ -733,6 +769,21 @@ def run_analysis(
|
|
|
733
769
|
result_annotated = None
|
|
734
770
|
result_genotypes = None
|
|
735
771
|
|
|
772
|
+
vcf_write_inputs: VcfWriteInputs | None = None
|
|
773
|
+
if collect_vcf_write_inputs:
|
|
774
|
+
# Import here to avoid a top-level cycle with allelix.reports.vcf
|
|
775
|
+
# (which reaches into the annotator registry for attribution).
|
|
776
|
+
from allelix.reports.vcf import VcfWriteInputs as _VcfWriteInputs
|
|
777
|
+
|
|
778
|
+
vcf_recovered_rsids = {
|
|
779
|
+
(chrom, pos, ref, alt): rsid
|
|
780
|
+
for rsid, (chrom, pos, ref, alt) in resolved_coords.items()
|
|
781
|
+
}
|
|
782
|
+
vcf_write_inputs = _VcfWriteInputs(
|
|
783
|
+
annotations_by_key=vcf_annotations_by_key,
|
|
784
|
+
recovered_rsids=vcf_recovered_rsids,
|
|
785
|
+
)
|
|
786
|
+
|
|
736
787
|
return AnalysisResult(
|
|
737
788
|
file_path=file_path,
|
|
738
789
|
parser_name=parser.name,
|
|
@@ -751,6 +802,7 @@ def run_analysis(
|
|
|
751
802
|
panel_genotypes=result_genotypes,
|
|
752
803
|
no_ref_variant_count=no_ref_total,
|
|
753
804
|
rsidless_variant_count=rsidless_total,
|
|
805
|
+
vcf_write_inputs=vcf_write_inputs,
|
|
754
806
|
)
|
|
755
807
|
|
|
756
808
|
|