alphavx 0.0.5__py3-none-any.whl

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.
alphavx/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AlphaVX — AlphaGenome Variant Effect Interpreter."""
2
+
3
+ __version__ = "0.0.5"
alphavx/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m alphavx`."""
2
+
3
+ from alphavx.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
alphavx/cache.py ADDED
@@ -0,0 +1,170 @@
1
+ """SQLite-based result caching for AlphaVX.
2
+
3
+ Caches variant scoring results so interrupted batch runs can resume
4
+ without re-querying the AlphaGenome API for already-scored variants.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import sqlite3
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _SCHEMA = """
19
+ CREATE TABLE IF NOT EXISTS variant_scores (
20
+ variant_key TEXT NOT NULL,
21
+ scores_json TEXT NOT NULL,
22
+ scored_at TEXT NOT NULL,
23
+ config_hash TEXT
24
+ );
25
+ """
26
+
27
+ # Index for fast lookups on the composite key used by has() / get().
28
+ _INDEX = """
29
+ CREATE INDEX IF NOT EXISTS idx_variant_config
30
+ ON variant_scores (variant_key, config_hash);
31
+ """
32
+
33
+
34
+ class ResultCache:
35
+ """SQLite cache for variant scoring results.
36
+
37
+ Results are keyed by both the variant identifier **and** an optional
38
+ config hash so that different scoring configurations (modalities,
39
+ sequence_length, etc.) are cached independently.
40
+
41
+ Args:
42
+ cache_dir: Directory where the cache database will be stored.
43
+ config_hash: Optional hash string identifying the scoring
44
+ configuration. When *None*, every ``has``/``get`` call will
45
+ behave as a cache miss (safe default for backward compat).
46
+ """
47
+
48
+ def __init__(self, cache_dir: Path, config_hash: str | None = None) -> None:
49
+ import threading
50
+ self.cache_dir = Path(cache_dir)
51
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
52
+ self.db_path = self.cache_dir / "alphavx_cache.db"
53
+ self.config_hash = config_hash
54
+ self._lock = threading.Lock()
55
+ self._init_db()
56
+
57
+ def _init_db(self) -> None:
58
+ """Create the cache table if it doesn't exist and migrate old schemas."""
59
+ with sqlite3.connect(self.db_path) as conn:
60
+ conn.execute(_SCHEMA)
61
+ # Migrate legacy databases that lack the config_hash column.
62
+ self._migrate(conn)
63
+ conn.execute(_INDEX)
64
+ conn.commit()
65
+ logger.debug("Cache initialized at %s", self.db_path)
66
+
67
+ @staticmethod
68
+ def _migrate(conn: sqlite3.Connection) -> None:
69
+ """Add the config_hash column if it is missing (legacy DB migration)."""
70
+ cursor = conn.execute("PRAGMA table_info(variant_scores)")
71
+ columns = {row[1] for row in cursor.fetchall()}
72
+ if "config_hash" not in columns:
73
+ # Recreate table to remove the PRIMARY KEY constraint on variant_key
74
+ conn.execute("ALTER TABLE variant_scores RENAME TO variant_scores_old")
75
+ conn.execute(_SCHEMA)
76
+ conn.execute(
77
+ "INSERT INTO variant_scores (variant_key, scores_json, scored_at, config_hash) "
78
+ "SELECT variant_key, scores_json, scored_at, NULL FROM variant_scores_old"
79
+ )
80
+ conn.execute("DROP TABLE variant_scores_old")
81
+ logger.info("Migrated cache DB: added config_hash column and removed old primary key")
82
+
83
+ def has(self, variant_key: str) -> bool:
84
+ """Check if a variant has cached results for the current config.
85
+
86
+ Args:
87
+ variant_key: Variant identifier (chr:pos:ref>alt).
88
+
89
+ Returns:
90
+ True if the variant has cached scores **and** the cache was
91
+ created with a non-None ``config_hash``.
92
+ """
93
+ if self.config_hash is None:
94
+ return False
95
+ with sqlite3.connect(self.db_path) as conn:
96
+ cursor = conn.execute(
97
+ "SELECT 1 FROM variant_scores "
98
+ "WHERE variant_key = ? AND config_hash = ?",
99
+ (variant_key, self.config_hash),
100
+ )
101
+ return cursor.fetchone() is not None
102
+
103
+ def get(self, variant_key: str) -> dict | None:
104
+ """Retrieve cached scores for a variant under the current config.
105
+
106
+ Args:
107
+ variant_key: Variant identifier (chr:pos:ref>alt).
108
+
109
+ Returns:
110
+ Deserialized scores dict, or None if not cached.
111
+ """
112
+ if self.config_hash is None:
113
+ return None
114
+ with sqlite3.connect(self.db_path) as conn:
115
+ cursor = conn.execute(
116
+ "SELECT scores_json FROM variant_scores "
117
+ "WHERE variant_key = ? AND config_hash = ?",
118
+ (variant_key, self.config_hash),
119
+ )
120
+ row = cursor.fetchone()
121
+ if row is None:
122
+ return None
123
+ return json.loads(row[0])
124
+
125
+ def put(self, variant_key: str, scores: dict) -> None:
126
+ """Store scores for a variant in the cache.
127
+
128
+ Args:
129
+ variant_key: Variant identifier (chr:pos:ref>alt).
130
+ scores: Scoring results to cache (must be JSON-serializable).
131
+ """
132
+ now = datetime.now(timezone.utc).isoformat()
133
+ scores_json = json.dumps(scores, default=str)
134
+ with self._lock:
135
+ with sqlite3.connect(self.db_path) as conn:
136
+ # Remove any previous entry for the same (variant, config) pair.
137
+ conn.execute(
138
+ "DELETE FROM variant_scores "
139
+ "WHERE variant_key = ? AND config_hash IS ?",
140
+ (variant_key, self.config_hash),
141
+ )
142
+ conn.execute(
143
+ "INSERT INTO variant_scores "
144
+ "(variant_key, scores_json, scored_at, config_hash) "
145
+ "VALUES (?, ?, ?, ?)",
146
+ (variant_key, scores_json, now, self.config_hash),
147
+ )
148
+ conn.commit()
149
+ logger.debug("Cached scores for %s", variant_key)
150
+
151
+ def clear(self) -> None:
152
+ """Delete all cached results."""
153
+ with sqlite3.connect(self.db_path) as conn:
154
+ conn.execute("DELETE FROM variant_scores")
155
+ conn.commit()
156
+ logger.info("Cache cleared")
157
+
158
+ def stats(self) -> dict:
159
+ """Get cache statistics.
160
+
161
+ Returns:
162
+ Dict with 'count' (number of cached variants) and
163
+ 'cache_size_bytes' (database file size).
164
+ """
165
+ with sqlite3.connect(self.db_path) as conn:
166
+ cursor = conn.execute("SELECT COUNT(*) FROM variant_scores")
167
+ count = cursor.fetchone()[0]
168
+
169
+ size = os.path.getsize(self.db_path) if self.db_path.exists() else 0
170
+ return {"count": count, "cache_size_bytes": size}
alphavx/cli.py ADDED
@@ -0,0 +1,296 @@
1
+ """AlphaVX command-line interface.
2
+
3
+ Usage:
4
+ alphavx score input.vcf -o results/
5
+ alphavx query chr17:7674220:G>A
6
+ alphavx report results/
7
+ alphavx cache stats
8
+ alphavx cache clear
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import typer
19
+
20
+ from . import __version__
21
+ from .config import load_config
22
+ from .vcf_parser import parse_vcf, parse_variant_string
23
+ from .cache import ResultCache
24
+ from .scorer import VariantScorer
25
+ from .reporter import generate_csv_report, generate_html_report, generate_report, generate_vcf_report
26
+
27
+ app = typer.Typer(
28
+ name="alphavx",
29
+ help="AlphaVX — AlphaGenome Variant Effect Interpreter.\n\n"
30
+ "Batch-score genetic variants against AlphaGenome and generate "
31
+ "interpretable multi-modal effect reports.",
32
+ add_completion=False,
33
+ )
34
+
35
+ cache_app = typer.Typer(help="Manage the variant scoring cache.")
36
+ app.add_typer(cache_app, name="cache")
37
+
38
+ # Configure logging
39
+ logging.basicConfig(
40
+ level=logging.INFO,
41
+ format="%(asctime)s | %(levelname)-7s | %(message)s",
42
+ datefmt="%H:%M:%S",
43
+ )
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ def _version_callback(value: bool) -> None:
48
+ if value:
49
+ typer.echo(f"alphavx {__version__}")
50
+ raise typer.Exit()
51
+
52
+
53
+ def _get_console():
54
+ """Get a Rich console for pretty output, or fall back to plain print."""
55
+ try:
56
+ from rich.console import Console
57
+ return Console()
58
+ except ImportError:
59
+ return None
60
+
61
+
62
+ @app.callback()
63
+ def main(
64
+ version: Optional[bool] = typer.Option(
65
+ None, "--version", "-v", callback=_version_callback,
66
+ is_eager=True, help="Show version and exit.",
67
+ ),
68
+ ) -> None:
69
+ """AlphaVX — AlphaGenome Variant Effect Interpreter."""
70
+
71
+
72
+ @app.command()
73
+ def score(
74
+ vcf_path: Path = typer.Argument(..., help="Path to VCF file containing variants to score."),
75
+ output: Path = typer.Option("results", "--output", "-o", help="Output directory for results."),
76
+ genes: Optional[str] = typer.Option(
77
+ None, "--genes", "-g",
78
+ help="Comma-separated gene list to filter results (e.g., BRCA1,TP53,CFTR).",
79
+ ),
80
+ config_path: Optional[Path] = typer.Option(
81
+ None, "--config", "-c", help="Path to alphavx.yaml configuration file.",
82
+ ),
83
+ no_cache: bool = typer.Option(False, "--no-cache", help="Disable result caching."),
84
+ ) -> None:
85
+ """Score all variants in a VCF file against AlphaGenome."""
86
+ console = _get_console()
87
+
88
+ # Load config
89
+ try:
90
+ config = load_config(config_path)
91
+ except ValueError as e:
92
+ typer.echo(f"Error: {e}", err=True)
93
+ raise typer.Exit(1)
94
+
95
+ config.output_dir = output
96
+ if no_cache:
97
+ config.cache_enabled = False
98
+
99
+ # Parse VCF
100
+ try:
101
+ records = parse_vcf(vcf_path)
102
+ except (FileNotFoundError, ValueError) as e:
103
+ typer.echo(f"Error: {e}", err=True)
104
+ raise typer.Exit(1)
105
+
106
+ typer.echo(f"Parsed {len(records)} variants from {vcf_path}")
107
+
108
+ # Setup cache
109
+ cache = None
110
+ if config.cache_enabled:
111
+ cache = ResultCache(output / "cache", config_hash=config.scoring_fingerprint)
112
+ stats = cache.stats()
113
+ if stats["count"] > 0:
114
+ typer.echo(f"Cache: {stats['count']} variants already cached")
115
+
116
+ # Score
117
+ scorer = VariantScorer(config)
118
+
119
+ def progress(i: int, total: int, record) -> None:
120
+ typer.echo(f"[{i + 1}/{total}] Scoring {record.key}...")
121
+
122
+ df = scorer.score_batch(records, cache=cache, progress_callback=progress)
123
+
124
+ if df.empty:
125
+ typer.echo("No results — all variants failed or returned empty scores.")
126
+ raise typer.Exit(1)
127
+
128
+ # Filter by genes if specified
129
+ if genes:
130
+ gene_list = [g.strip().upper() for g in genes.split(",")]
131
+ gene_col = "gene_name" if "gene_name" in df.columns else None
132
+ if gene_col:
133
+ before = len(df)
134
+ df = df[df[gene_col].str.upper().isin(gene_list)]
135
+ typer.echo(f"Gene filter: {before} → {len(df)} rows (genes: {', '.join(gene_list)})")
136
+
137
+ # Generate reports
138
+ generate_csv_report(df, output, quantile_threshold=config.quantile_threshold)
139
+ generate_html_report(df, output, quantile_threshold=config.quantile_threshold)
140
+
141
+ # Generate annotated VCF
142
+ try:
143
+ generate_vcf_report(vcf_path, df, output, quantile_threshold=config.quantile_threshold)
144
+ except Exception as e:
145
+ logger.warning("Annotated VCF generation failed: %s", e)
146
+
147
+ # Generate plots
148
+ try:
149
+ from .plots import plot_summary_heatmap, plot_variant_detail
150
+ plots_dir = output / "plots"
151
+ plot_summary_heatmap(df, plots_dir / "summary_heatmap.png", config.quantile_threshold)
152
+
153
+ # Generate per-variant detail plots
154
+ variant_keys = df["variant_key"].unique() if "variant_key" in df.columns else []
155
+ for vk in variant_keys:
156
+ safe_name = str(vk).replace(":", "_").replace(">", "_")
157
+ plot_variant_detail(
158
+ df, vk, plots_dir / "per_variant" / f"{safe_name}.png",
159
+ config.quantile_threshold,
160
+ )
161
+ if len(variant_keys):
162
+ logger.info("Generated %d per-variant plots", len(variant_keys))
163
+ except Exception as e:
164
+ logger.warning("Plot generation failed: %s", e)
165
+
166
+ # Print summary
167
+ sig_count = 0
168
+ if "quantile_score" in df.columns:
169
+ sig_count = (df["quantile_score"].abs() > config.quantile_threshold).sum()
170
+
171
+ typer.echo("")
172
+ typer.echo("═" * 50)
173
+ typer.echo(f" Variants scored: {df['variant_key'].nunique() if 'variant_key' in df.columns else '?'}")
174
+ typer.echo(f" Significant hits: {sig_count}")
175
+ typer.echo(f" Results: {output / 'scores.csv'}")
176
+ typer.echo(f" Report: {output / 'report.html'}")
177
+ typer.echo("═" * 50)
178
+
179
+
180
+ @app.command()
181
+ def query(
182
+ variant: str = typer.Argument(
183
+ ..., help="Variant to score (e.g., chr17:7674220:G>A or chr17:7674220:G:A).",
184
+ ),
185
+ output: Optional[Path] = typer.Option(
186
+ None, "--output", "-o", help="Optional directory to save full CSV results.",
187
+ ),
188
+ config_path: Optional[Path] = typer.Option(
189
+ None, "--config", "-c", help="Path to alphavx.yaml configuration file.",
190
+ ),
191
+ ) -> None:
192
+ """Score a single variant and display results."""
193
+ # Parse variant
194
+ try:
195
+ record = parse_variant_string(variant)
196
+ except ValueError as e:
197
+ typer.echo(f"Error: {e}", err=True)
198
+ raise typer.Exit(1)
199
+
200
+ # Load config
201
+ try:
202
+ config = load_config(config_path)
203
+ except ValueError as e:
204
+ typer.echo(f"Error: {e}", err=True)
205
+ raise typer.Exit(1)
206
+
207
+ typer.echo(f"Scoring {record.key}...")
208
+
209
+ # Score
210
+ scorer = VariantScorer(config)
211
+ df = scorer.score_variant(record)
212
+
213
+ if df.empty:
214
+ typer.echo("No results returned for this variant.")
215
+ raise typer.Exit(1)
216
+
217
+ # Display significant results
218
+ if "quantile_score" in df.columns:
219
+ sig = df[df["quantile_score"].abs() > config.quantile_threshold]
220
+ else:
221
+ sig = df.head(0)
222
+
223
+ typer.echo(f"\nTotal scores: {len(df)}")
224
+ typer.echo(f"Significant: {len(sig)}")
225
+
226
+ if not sig.empty:
227
+ typer.echo(f"\n{'Variant':<25} {'Gene':<12} {'Modality':<20} {'Tissue':<25} {'Raw':<12} {'Quantile':<10}")
228
+ typer.echo("─" * 104)
229
+ display_cols = ["variant_key", "gene_name", "output_type", "biosample_name", "raw_score", "quantile_score"]
230
+ for _, row in sig.sort_values("quantile_score", key=abs, ascending=False).head(30).iterrows():
231
+ vk = str(row.get("variant_key", ""))[:24]
232
+ gene = str(row.get("gene_name", ""))[:11]
233
+ mod = str(row.get("output_type", ""))[:19]
234
+ tissue = str(row.get("biosample_name", ""))[:24]
235
+ raw = f"{row.get('raw_score', 0):.6f}"
236
+ quant = f"{row.get('quantile_score', 0):.6f}"
237
+ typer.echo(f"{vk:<25} {gene:<12} {mod:<20} {tissue:<25} {raw:<12} {quant:<10}")
238
+
239
+ # Save if output specified
240
+ if output:
241
+ generate_csv_report(df, output, quantile_threshold=config.quantile_threshold)
242
+ typer.echo(f"\nFull results saved to {output / 'scores.csv'}")
243
+
244
+
245
+ @app.command()
246
+ def report(
247
+ results_dir: Path = typer.Argument(
248
+ ..., help="Directory containing scores.csv to generate report from.",
249
+ ),
250
+ ) -> None:
251
+ """Generate an HTML report from existing scoring results."""
252
+ try:
253
+ generate_report(results_dir)
254
+ typer.echo(f"Report generated: {results_dir / 'report.html'}")
255
+ except FileNotFoundError as e:
256
+ typer.echo(f"Error: {e}", err=True)
257
+ raise typer.Exit(1)
258
+
259
+
260
+ @cache_app.command("stats")
261
+ def cache_stats(
262
+ cache_dir: Path = typer.Option(
263
+ "results/cache", "--dir", "-d", help="Cache directory.",
264
+ ),
265
+ ) -> None:
266
+ """Show cache statistics."""
267
+ cache = ResultCache(cache_dir)
268
+ stats = cache.stats()
269
+ typer.echo(f"Cached variants: {stats['count']}")
270
+ typer.echo(f"Cache size: {stats['cache_size_bytes'] / 1024:.1f} KB")
271
+ typer.echo(f"Cache location: {cache.db_path}")
272
+
273
+
274
+ @cache_app.command("clear")
275
+ def cache_clear(
276
+ cache_dir: Path = typer.Option(
277
+ "results/cache", "--dir", "-d", help="Cache directory.",
278
+ ),
279
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
280
+ ) -> None:
281
+ """Clear all cached results."""
282
+ cache = ResultCache(cache_dir)
283
+ stats = cache.stats()
284
+
285
+ if stats["count"] == 0:
286
+ typer.echo("Cache is already empty.")
287
+ return
288
+
289
+ if not force:
290
+ confirm = typer.confirm(f"Delete {stats['count']} cached variant scores?")
291
+ if not confirm:
292
+ typer.echo("Cancelled.")
293
+ return
294
+
295
+ cache.clear()
296
+ typer.echo(f"Cleared {stats['count']} cached entries.")
alphavx/config.py ADDED
@@ -0,0 +1,142 @@
1
+ """Configuration loading and validation for AlphaVX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import os
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ # Load .env.alphavx file if present (before any os.environ reads)
13
+ # NOTE: We use ".env.alphavx" instead of ".env" because anndata (an
14
+ # alphagenome dependency) uses pydantic-settings which auto-reads ".env"
15
+ # and rejects unknown keys like ALPHAVX_API_KEY.
16
+ try:
17
+ from dotenv import load_dotenv
18
+
19
+ _ENV_FILENAME = ".env.alphavx"
20
+
21
+ # Search current dir and project root
22
+ _candidates = [
23
+ Path.cwd() / _ENV_FILENAME,
24
+ Path(__file__).resolve().parent.parent.parent / _ENV_FILENAME,
25
+ ]
26
+ for _candidate in _candidates:
27
+ if _candidate.exists():
28
+ load_dotenv(_candidate)
29
+ break
30
+ except ImportError:
31
+ pass
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ DEFAULT_MODALITIES = [
36
+ "RNA_SEQ",
37
+ "SPLICE_SITES",
38
+ "SPLICE_SITE_USAGE",
39
+ "SPLICE_JUNCTIONS",
40
+ "DNASE",
41
+ "ATAC",
42
+ "CHIP_HISTONE",
43
+ "CHIP_TF",
44
+ ]
45
+
46
+
47
+ @dataclass
48
+ class Config:
49
+ """AlphaVX runtime configuration."""
50
+
51
+ api_key: str = ""
52
+ max_retries: int = 3
53
+ retry_delay: float = 5.0
54
+ sequence_length: int = 2**20 # 1,048,576 — AlphaGenome optimal
55
+ quantile_threshold: float = 0.995
56
+ output_dir: Path = field(default_factory=lambda: Path("results"))
57
+ cache_enabled: bool = True
58
+ modalities: list[str] = field(default_factory=lambda: list(DEFAULT_MODALITIES))
59
+
60
+ @property
61
+ def scoring_fingerprint(self) -> str:
62
+ """Return a short hash of config fields that affect API results.
63
+
64
+ Covers ``modalities`` (sorted) and ``sequence_length`` so that
65
+ different scoring configurations produce distinct cache entries.
66
+ """
67
+ canonical = json.dumps(
68
+ {"modalities": sorted(self.modalities),
69
+ "sequence_length": self.sequence_length},
70
+ sort_keys=True,
71
+ )
72
+ return hashlib.sha256(canonical.encode()).hexdigest()[:12]
73
+
74
+
75
+ def load_config(config_path: Path | None = None) -> Config:
76
+ """Load configuration from YAML file and/or environment variables.
77
+
78
+ Priority: YAML file values > environment variables > defaults.
79
+
80
+ Args:
81
+ config_path: Optional path to an alphavx.yaml configuration file.
82
+
83
+ Returns:
84
+ Populated Config instance.
85
+
86
+ Raises:
87
+ ValueError: If no API key is found in config or environment.
88
+ """
89
+ config = Config()
90
+ key_env_name: str | None = None
91
+
92
+ # Load from YAML if provided
93
+ if config_path is not None:
94
+ try:
95
+ import yaml
96
+ except ImportError:
97
+ logger.warning("pyyaml not installed — ignoring config file %s", config_path)
98
+ else:
99
+ with open(config_path) as f:
100
+ raw = yaml.safe_load(f) or {}
101
+
102
+ api_section = raw.get("api", {})
103
+ scoring_section = raw.get("scoring", {})
104
+ output_section = raw.get("output", {})
105
+
106
+ key_env_name = api_section.get("key_env")
107
+
108
+ if "max_retries" in api_section:
109
+ config.max_retries = int(api_section["max_retries"])
110
+ if "retry_delay" in api_section:
111
+ config.retry_delay = float(api_section["retry_delay"])
112
+ if "modalities" in scoring_section:
113
+ config.modalities = scoring_section["modalities"]
114
+ if "quantile_threshold" in scoring_section:
115
+ config.quantile_threshold = float(scoring_section["quantile_threshold"])
116
+ if "sequence_length" in scoring_section:
117
+ config.sequence_length = int(scoring_section["sequence_length"])
118
+ if "cache" in output_section:
119
+ config.cache_enabled = bool(output_section["cache"])
120
+
121
+ logger.info("Loaded config from %s", config_path)
122
+
123
+ # Resolve API key: env var takes precedence if config doesn't set it
124
+ if not config.api_key:
125
+ # If YAML specified a custom env var name via key_env, try it first
126
+ if key_env_name:
127
+ config.api_key = os.environ.get(key_env_name, "")
128
+ # Fall back to the hardcoded env var names
129
+ if not config.api_key:
130
+ config.api_key = os.environ.get(
131
+ "ALPHAVX_API_KEY",
132
+ os.environ.get("ALPHAGENOME_API_KEY", ""),
133
+ )
134
+
135
+ if not config.api_key:
136
+ raise ValueError(
137
+ "No API key found. Set ALPHAVX_API_KEY or ALPHAGENOME_API_KEY environment "
138
+ "variable, or provide it in your alphavx.yaml config file.\n"
139
+ "Sign up at: https://deepmind.google.com/science/alphagenome/"
140
+ )
141
+
142
+ return config