genetinav 0.1.0__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.
- genetinav/__init__.py +1 -0
- genetinav/api_client.py +124 -0
- genetinav/cache.py +71 -0
- genetinav/chunk_cache.py +148 -0
- genetinav/cli.py +239 -0
- genetinav/command_parser.py +32 -0
- genetinav/command_router.py +33 -0
- genetinav/db.py +77 -0
- genetinav/favorites.py +79 -0
- genetinav/gene_service.py +265 -0
- genetinav/history.py +94 -0
- genetinav/models.py +80 -0
- genetinav/navigation_history.py +61 -0
- genetinav/sequence.py +63 -0
- genetinav/settings.py +69 -0
- genetinav/textual_app.py +168 -0
- genetinav/themes.py +206 -0
- genetinav/ui_textual/__init__.py +1 -0
- genetinav/ui_textual/about_modal.py +339 -0
- genetinav/ui_textual/base_screen.py +16 -0
- genetinav/ui_textual/command_palette.py +137 -0
- genetinav/ui_textual/favorites_modal.py +79 -0
- genetinav/ui_textual/help_modal.py +191 -0
- genetinav/ui_textual/history_modal.py +69 -0
- genetinav/ui_textual/home_screen.py +255 -0
- genetinav/ui_textual/loading_screen.py +36 -0
- genetinav/ui_textual/menu_modal.py +210 -0
- genetinav/ui_textual/result_screen.py +152 -0
- genetinav/ui_textual/sequence_viewer.tcss +142 -0
- genetinav/ui_textual/sequence_viewer_controller.py +156 -0
- genetinav/ui_textual/sequence_viewer_screen.py +765 -0
- genetinav/ui_textual/settings_modal.py +177 -0
- genetinav/ui_textual/theme.py +387 -0
- genetinav/ui_textual/widgets/__init__.py +85 -0
- genetinav/ui_textual/widgets/gc_track_widget.py +75 -0
- genetinav/ui_textual/widgets/legend_widget.py +58 -0
- genetinav/ui_textual/widgets/minimap_widget.py +74 -0
- genetinav/ui_textual/widgets/ruler_widget.py +110 -0
- genetinav/ui_textual/widgets/sequence_track_widget.py +121 -0
- genetinav/ui_textual/widgets/stats_footer_widget.py +117 -0
- genetinav/utils/__init__.py +1 -0
- genetinav/utils/errors.py +36 -0
- genetinav/utils/export.py +17 -0
- genetinav/utils/validation.py +23 -0
- genetinav-0.1.0.dist-info/METADATA +311 -0
- genetinav-0.1.0.dist-info/RECORD +49 -0
- genetinav-0.1.0.dist-info/WHEEL +4 -0
- genetinav-0.1.0.dist-info/entry_points.txt +2 -0
- genetinav-0.1.0.dist-info/licenses/LICENSE +65 -0
genetinav/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
genetinav/api_client.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Optional, Dict
|
|
3
|
+
|
|
4
|
+
from genetinav.utils.errors import (
|
|
5
|
+
GeneNotFoundError,
|
|
6
|
+
NetworkUnavailableError,
|
|
7
|
+
ApiRateLimitError,
|
|
8
|
+
SequenceFetchError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
class EnsemblClient:
|
|
12
|
+
def __init__(self, base_url: str = "https://rest.ensembl.org", timeout: float = 10.0):
|
|
13
|
+
self.base_url = base_url.rstrip("/")
|
|
14
|
+
self.timeout = timeout
|
|
15
|
+
self._client: Optional[httpx.Client] = None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def client(self) -> httpx.Client:
|
|
19
|
+
if self._client is None:
|
|
20
|
+
self._client = httpx.Client(timeout=self.timeout)
|
|
21
|
+
return self._client
|
|
22
|
+
|
|
23
|
+
def close(self) -> None:
|
|
24
|
+
if self._client is not None:
|
|
25
|
+
self._client.close()
|
|
26
|
+
self._client = None
|
|
27
|
+
|
|
28
|
+
def __enter__(self):
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
32
|
+
self.close()
|
|
33
|
+
|
|
34
|
+
def _handle_exceptions(self, response: httpx.Response, symbol_or_region: str):
|
|
35
|
+
if response.status_code == 404:
|
|
36
|
+
raise GeneNotFoundError(f"No gene found for '{symbol_or_region}'.")
|
|
37
|
+
elif response.status_code == 400:
|
|
38
|
+
raise GeneNotFoundError(f"Bad request or out of bounds for '{symbol_or_region}'.")
|
|
39
|
+
elif response.status_code == 429:
|
|
40
|
+
raise ApiRateLimitError("API rate limit exceeded.")
|
|
41
|
+
elif not response.is_success:
|
|
42
|
+
raise SequenceFetchError(f"API request failed with status code {response.status_code}.")
|
|
43
|
+
|
|
44
|
+
def lookup_gene(self, symbol: str, species: str = "human") -> dict:
|
|
45
|
+
url = f"{self.base_url}/lookup/symbol/{species}/{symbol}?content-type=application/json"
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
response = self.client.get(url)
|
|
49
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
50
|
+
raise NetworkUnavailableError(f"Network error: {e}") from e
|
|
51
|
+
|
|
52
|
+
self._handle_exceptions(response, symbol)
|
|
53
|
+
|
|
54
|
+
return response.json()
|
|
55
|
+
|
|
56
|
+
def fetch_sequence(self, region: str, species: str = "human") -> str:
|
|
57
|
+
url = f"{self.base_url}/sequence/region/{species}/{region}?content-type=text/plain"
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
response = self.client.get(url)
|
|
61
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
62
|
+
raise NetworkUnavailableError(f"Network error: {e}") from e
|
|
63
|
+
|
|
64
|
+
self._handle_exceptions(response, region)
|
|
65
|
+
|
|
66
|
+
# Strip all whitespace/newlines from the sequence
|
|
67
|
+
return "".join(response.text.split())
|
|
68
|
+
|
|
69
|
+
def lookup_gene_by_id(self, gene_id: str) -> dict:
|
|
70
|
+
url = f"{self.base_url}/lookup/id/{gene_id}?expand=1&content-type=application/json"
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
response = self.client.get(url)
|
|
74
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
75
|
+
raise NetworkUnavailableError(f"Network error: {e}") from e
|
|
76
|
+
|
|
77
|
+
self._handle_exceptions(response, gene_id)
|
|
78
|
+
|
|
79
|
+
return response.json()
|
|
80
|
+
|
|
81
|
+
def overlap_genes(self, chromosome: str, start: int, end: int, species: str = "human") -> list[dict]:
|
|
82
|
+
"""Return a list of genes overlapping the given genomic range.
|
|
83
|
+
|
|
84
|
+
Queries ``GET /overlap/region/{species}/{chromosome}:{start}-{end}?feature=gene``
|
|
85
|
+
and returns the raw JSON list (each element has at least ``external_name``,
|
|
86
|
+
``start``, ``end``, ``strand``, and ``id`` fields).
|
|
87
|
+
|
|
88
|
+
Returns an empty list on 404 / out-of-bounds rather than raising.
|
|
89
|
+
"""
|
|
90
|
+
url = (
|
|
91
|
+
f"{self.base_url}/overlap/region/{species}/{chromosome}:{start}-{end}"
|
|
92
|
+
f"?feature=gene&content-type=application/json"
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
response = self.client.get(url)
|
|
96
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
97
|
+
raise NetworkUnavailableError(f"Network error: {e}") from e
|
|
98
|
+
|
|
99
|
+
if response.status_code == 404 or response.status_code == 400:
|
|
100
|
+
return []
|
|
101
|
+
elif response.status_code == 429:
|
|
102
|
+
raise ApiRateLimitError("API rate limit exceeded.")
|
|
103
|
+
elif not response.is_success:
|
|
104
|
+
raise SequenceFetchError(f"API request failed with status code {response.status_code}.")
|
|
105
|
+
|
|
106
|
+
data = response.json()
|
|
107
|
+
# The endpoint returns either a list or an error dict
|
|
108
|
+
if isinstance(data, list):
|
|
109
|
+
return data
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
def fetch_transcript_sequence(self, transcript_id: str, is_cds: bool = False) -> str:
|
|
113
|
+
seq_type = "cds" if is_cds else "cdna"
|
|
114
|
+
url = f"{self.base_url}/sequence/id/{transcript_id}?type={seq_type}&content-type=text/plain"
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
response = self.client.get(url)
|
|
118
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
119
|
+
raise NetworkUnavailableError(f"Network error: {e}") from e
|
|
120
|
+
|
|
121
|
+
self._handle_exceptions(response, transcript_id)
|
|
122
|
+
|
|
123
|
+
# Strip all whitespace/newlines from the sequence
|
|
124
|
+
return "".join(response.text.split())
|
genetinav/cache.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
from genetinav.db import get_connection, initialize_schema
|
|
5
|
+
from genetinav.utils.errors import CacheError
|
|
6
|
+
|
|
7
|
+
class CacheManager:
|
|
8
|
+
def __init__(self, conn: sqlite3.Connection):
|
|
9
|
+
self.conn = conn
|
|
10
|
+
initialize_schema(self.conn)
|
|
11
|
+
|
|
12
|
+
def set(self, key: str, response_data: str, ttl_seconds: int | None = None) -> None:
|
|
13
|
+
try:
|
|
14
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
15
|
+
created_at = now.isoformat()
|
|
16
|
+
|
|
17
|
+
if ttl_seconds is not None:
|
|
18
|
+
expires_at = (now + datetime.timedelta(seconds=ttl_seconds)).isoformat()
|
|
19
|
+
else:
|
|
20
|
+
expires_at = None
|
|
21
|
+
|
|
22
|
+
self.conn.execute(
|
|
23
|
+
"""
|
|
24
|
+
INSERT INTO cache (key, response_data, created_at, expires_at)
|
|
25
|
+
VALUES (?, ?, ?, ?)
|
|
26
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
27
|
+
response_data=excluded.response_data,
|
|
28
|
+
created_at=excluded.created_at,
|
|
29
|
+
expires_at=excluded.expires_at
|
|
30
|
+
""",
|
|
31
|
+
(key, response_data, created_at, expires_at)
|
|
32
|
+
)
|
|
33
|
+
self.conn.commit()
|
|
34
|
+
except sqlite3.Error as e:
|
|
35
|
+
raise CacheError(f"Cache read/write failed: {e}") from e
|
|
36
|
+
|
|
37
|
+
def get(self, key: str) -> str | None:
|
|
38
|
+
try:
|
|
39
|
+
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
40
|
+
|
|
41
|
+
cursor = self.conn.execute(
|
|
42
|
+
"SELECT response_data, expires_at FROM cache WHERE key = ?",
|
|
43
|
+
(key,)
|
|
44
|
+
)
|
|
45
|
+
row = cursor.fetchone()
|
|
46
|
+
|
|
47
|
+
if row is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
expires_at = row["expires_at"]
|
|
51
|
+
if expires_at is not None and expires_at <= now_iso:
|
|
52
|
+
self.delete(key)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
return row["response_data"]
|
|
56
|
+
except sqlite3.Error as e:
|
|
57
|
+
raise CacheError(f"Cache read/write failed: {e}") from e
|
|
58
|
+
|
|
59
|
+
def clear(self) -> None:
|
|
60
|
+
try:
|
|
61
|
+
self.conn.execute("DELETE FROM cache")
|
|
62
|
+
self.conn.commit()
|
|
63
|
+
except sqlite3.Error as e:
|
|
64
|
+
raise CacheError(f"Cache read/write failed: {e}") from e
|
|
65
|
+
|
|
66
|
+
def delete(self, key: str) -> None:
|
|
67
|
+
try:
|
|
68
|
+
self.conn.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
69
|
+
self.conn.commit()
|
|
70
|
+
except sqlite3.Error as e:
|
|
71
|
+
raise CacheError(f"Cache read/write failed: {e}") from e
|
genetinav/chunk_cache.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from collections import OrderedDict
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from genetinav.api_client import EnsemblClient
|
|
7
|
+
from genetinav.utils.errors import GeneNotFoundError, NetworkUnavailableError, SequenceFetchError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChunkCache:
|
|
11
|
+
def __init__(self, api_client: EnsemblClient, chunk_size: int = 5000, max_chunks: int = 100):
|
|
12
|
+
self.api_client = api_client
|
|
13
|
+
self.chunk_size = chunk_size
|
|
14
|
+
self.max_chunks = max_chunks
|
|
15
|
+
|
|
16
|
+
self._cache: OrderedDict[tuple[str, str, int], str] = OrderedDict()
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
self._executor = ThreadPoolExecutor(max_workers=3)
|
|
19
|
+
self._fetching: set[tuple[str, str, int]] = set()
|
|
20
|
+
self._errors: dict[tuple[str, str], Exception] = {}
|
|
21
|
+
|
|
22
|
+
def get_chunk_start(self, coord: int) -> int:
|
|
23
|
+
"""Ensembl coordinates are 1-indexed. We align to chunk_size boundaries.
|
|
24
|
+
Example for chunk_size=5000:
|
|
25
|
+
coord=1 -> 1
|
|
26
|
+
coord=5000 -> 1
|
|
27
|
+
coord=5001 -> 5001
|
|
28
|
+
"""
|
|
29
|
+
return ((coord - 1) // self.chunk_size) * self.chunk_size + 1
|
|
30
|
+
|
|
31
|
+
def get_sequence_slice(self, species: str, chromosome: str, start_coord: int, end_coord: int) -> Optional[str]:
|
|
32
|
+
"""
|
|
33
|
+
Returns the sequence string if fully cached, else None.
|
|
34
|
+
start_coord and end_coord are absolute 1-indexed coordinates.
|
|
35
|
+
end_coord is inclusive.
|
|
36
|
+
"""
|
|
37
|
+
req_chunks = []
|
|
38
|
+
c_start = self.get_chunk_start(start_coord)
|
|
39
|
+
while c_start <= end_coord:
|
|
40
|
+
req_chunks.append(c_start)
|
|
41
|
+
c_start += self.chunk_size
|
|
42
|
+
|
|
43
|
+
with self._lock:
|
|
44
|
+
for c in req_chunks:
|
|
45
|
+
key = (species, chromosome, c)
|
|
46
|
+
if key not in self._cache:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
pieces = []
|
|
50
|
+
for c in req_chunks:
|
|
51
|
+
key = (species, chromosome, c)
|
|
52
|
+
self._cache.move_to_end(key)
|
|
53
|
+
pieces.append(self._cache[key])
|
|
54
|
+
|
|
55
|
+
full_str = "".join(pieces)
|
|
56
|
+
|
|
57
|
+
# Calculate offsets into the joined string
|
|
58
|
+
first_chunk_start = req_chunks[0]
|
|
59
|
+
offset_start = start_coord - first_chunk_start
|
|
60
|
+
length = end_coord - start_coord + 1
|
|
61
|
+
|
|
62
|
+
return full_str[offset_start : offset_start + length]
|
|
63
|
+
|
|
64
|
+
def fetch_blocking(self, species: str, chromosome: str, start_coord: int, end_coord: int) -> str:
|
|
65
|
+
"""Synchronously fetches missing chunks for the requested range, returns the slice."""
|
|
66
|
+
req_chunks = []
|
|
67
|
+
c_start = self.get_chunk_start(start_coord)
|
|
68
|
+
while c_start <= end_coord:
|
|
69
|
+
req_chunks.append(c_start)
|
|
70
|
+
c_start += self.chunk_size
|
|
71
|
+
|
|
72
|
+
for c in req_chunks:
|
|
73
|
+
key = (species, chromosome, c)
|
|
74
|
+
with self._lock:
|
|
75
|
+
if key in self._cache:
|
|
76
|
+
continue
|
|
77
|
+
self._fetch_chunk(species, chromosome, c)
|
|
78
|
+
|
|
79
|
+
# Clear any recent errors for this species/chrom since we succeeded
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._errors.pop((species, chromosome), None)
|
|
82
|
+
|
|
83
|
+
res = self.get_sequence_slice(species, chromosome, start_coord, end_coord)
|
|
84
|
+
if res is None:
|
|
85
|
+
# Fallback if something went extremely wrong, shouldn't happen.
|
|
86
|
+
return "N" * (end_coord - start_coord + 1)
|
|
87
|
+
return res
|
|
88
|
+
|
|
89
|
+
def prefetch(self, species: str, chromosome: str, start_coord: int, end_coord: int) -> None:
|
|
90
|
+
"""Kicks off background fetch for the view and its neighbors."""
|
|
91
|
+
current_c = self.get_chunk_start(start_coord)
|
|
92
|
+
|
|
93
|
+
chunks_to_fetch = [
|
|
94
|
+
current_c - self.chunk_size,
|
|
95
|
+
current_c,
|
|
96
|
+
current_c + self.chunk_size,
|
|
97
|
+
current_c + 2 * self.chunk_size,
|
|
98
|
+
current_c + 3 * self.chunk_size
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
for c in chunks_to_fetch:
|
|
102
|
+
if c < 1:
|
|
103
|
+
continue
|
|
104
|
+
key = (species, chromosome, c)
|
|
105
|
+
with self._lock:
|
|
106
|
+
if key in self._cache or key in self._fetching:
|
|
107
|
+
continue
|
|
108
|
+
self._fetching.add(key)
|
|
109
|
+
|
|
110
|
+
self._executor.submit(self._fetch_chunk_bg, species, chromosome, c)
|
|
111
|
+
|
|
112
|
+
def _fetch_chunk_bg(self, species: str, chromosome: str, chunk_start: int) -> None:
|
|
113
|
+
try:
|
|
114
|
+
self._fetch_chunk(species, chromosome, chunk_start)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
# Store error so it can be surfaced non-fatally
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._errors[(species, chromosome)] = e
|
|
119
|
+
finally:
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._fetching.discard((species, chromosome, chunk_start))
|
|
122
|
+
|
|
123
|
+
def _fetch_chunk(self, species: str, chromosome: str, chunk_start: int) -> None:
|
|
124
|
+
chunk_end = chunk_start + self.chunk_size - 1
|
|
125
|
+
region = f"{chromosome}:{chunk_start}..{chunk_end}"
|
|
126
|
+
try:
|
|
127
|
+
seq = self.api_client.fetch_sequence(region, species=species)
|
|
128
|
+
except GeneNotFoundError:
|
|
129
|
+
# Region is completely out of bounds or missing. Pad entirely with Ns.
|
|
130
|
+
seq = ""
|
|
131
|
+
|
|
132
|
+
expected_len = self.chunk_size
|
|
133
|
+
if len(seq) < expected_len:
|
|
134
|
+
seq += "N" * (expected_len - len(seq))
|
|
135
|
+
|
|
136
|
+
with self._lock:
|
|
137
|
+
key = (species, chromosome, chunk_start)
|
|
138
|
+
self._cache[key] = seq
|
|
139
|
+
if len(self._cache) > self.max_chunks:
|
|
140
|
+
self._cache.popitem(last=False)
|
|
141
|
+
|
|
142
|
+
def get_latest_error(self, species: str, chromosome: str) -> Optional[Exception]:
|
|
143
|
+
with self._lock:
|
|
144
|
+
return self._errors.get((species, chromosome))
|
|
145
|
+
|
|
146
|
+
def get_cached_bytes(self) -> int:
|
|
147
|
+
with self._lock:
|
|
148
|
+
return len(self._cache) * self.chunk_size
|
genetinav/cli.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Command-line interface for GenetiNav.
|
|
2
|
+
|
|
3
|
+
Quick-command flags (spec Section 25):
|
|
4
|
+
genetinav # launch interactive TUI
|
|
5
|
+
genetinav <GENE> # quick gene lookup, then exit
|
|
6
|
+
genetinav search <GENE> # launch TUI pre-filled with GENE
|
|
7
|
+
genetinav settings # open settings screen then exit
|
|
8
|
+
genetinav --no-animation # disable animations for this session
|
|
9
|
+
genetinav --theme <NAME> # override active palette for this session
|
|
10
|
+
genetinav --species <NAME> # override default species for this session
|
|
11
|
+
genetinav --clear-cache # clear local gene cache then exit
|
|
12
|
+
genetinav --clear-history # clear search history then exit
|
|
13
|
+
genetinav --version # print version and exit
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from genetinav import __version__
|
|
20
|
+
from genetinav.cache import CacheManager
|
|
21
|
+
from genetinav.history import HistoryManager
|
|
22
|
+
from genetinav.db import get_connection, initialize_schema
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DefaultGroup(typer.core.TyperGroup):
|
|
26
|
+
"""Allow a bare positional argument to be handled as the 'default' sub-command."""
|
|
27
|
+
|
|
28
|
+
def resolve_command(self, ctx, args):
|
|
29
|
+
if args and args[0] not in self.commands and not args[0].startswith("-"):
|
|
30
|
+
args.insert(0, "default")
|
|
31
|
+
return super().resolve_command(ctx, args)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(cls=DefaultGroup)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def version_callback(value: bool):
|
|
38
|
+
if value:
|
|
39
|
+
typer.echo(f"genetinav v{__version__}")
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_overrides(
|
|
44
|
+
no_animation: bool,
|
|
45
|
+
theme: Optional[str],
|
|
46
|
+
species: Optional[str],
|
|
47
|
+
) -> dict:
|
|
48
|
+
"""Return a partial settings dict reflecting CLI flag overrides."""
|
|
49
|
+
overrides: dict = {}
|
|
50
|
+
if no_animation:
|
|
51
|
+
overrides["performance_mode"] = True
|
|
52
|
+
overrides["animations_enabled"] = False
|
|
53
|
+
overrides["splash_animation_enabled"] = False
|
|
54
|
+
if theme:
|
|
55
|
+
overrides["theme"] = theme
|
|
56
|
+
if species:
|
|
57
|
+
overrides["default_species"] = species
|
|
58
|
+
return overrides
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate_cli_overrides(theme: Optional[str], species: Optional[str]) -> None:
|
|
65
|
+
"""Validate --theme and --species values at the CLI boundary.
|
|
66
|
+
|
|
67
|
+
Prints a user-friendly error and raises ``typer.Exit(code=1)`` when an
|
|
68
|
+
invalid value is supplied, so the error surfaces before GenetinavApp is
|
|
69
|
+
ever constructed or the database is touched.
|
|
70
|
+
"""
|
|
71
|
+
if theme is not None:
|
|
72
|
+
from genetinav.themes import list_ui_theme_names
|
|
73
|
+
valid_themes = list_ui_theme_names()
|
|
74
|
+
if theme not in valid_themes:
|
|
75
|
+
typer.echo(
|
|
76
|
+
f"Error: '{theme}' is not a valid theme. "
|
|
77
|
+
f"Valid themes: {', '.join(valid_themes)}"
|
|
78
|
+
)
|
|
79
|
+
raise typer.Exit(code=1)
|
|
80
|
+
|
|
81
|
+
if species is not None:
|
|
82
|
+
from genetinav.utils.validation import DEFAULT_ALLOWED_SPECIES
|
|
83
|
+
valid_species = DEFAULT_ALLOWED_SPECIES
|
|
84
|
+
normalized = species.strip().lower()
|
|
85
|
+
if normalized not in valid_species:
|
|
86
|
+
typer.echo(
|
|
87
|
+
f"Error: '{species}' is not a valid species. "
|
|
88
|
+
f"Valid species: {', '.join(valid_species)}"
|
|
89
|
+
)
|
|
90
|
+
raise typer.Exit(code=1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run_gene_cmd(
|
|
94
|
+
gene: str,
|
|
95
|
+
no_animation: bool,
|
|
96
|
+
theme: Optional[str],
|
|
97
|
+
species: Optional[str],
|
|
98
|
+
echo_message: str,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Shared implementation for default_cmd and search_cmd.
|
|
101
|
+
|
|
102
|
+
Validates overrides, emits the command-specific banner, constructs the
|
|
103
|
+
app, and runs the quick-lookup flow. Each caller supplies its own
|
|
104
|
+
*echo_message* ("Quick lookup: {gene}" vs "Searching for: {gene}").
|
|
105
|
+
"""
|
|
106
|
+
_validate_cli_overrides(theme, species)
|
|
107
|
+
typer.echo(echo_message)
|
|
108
|
+
overrides = _build_overrides(no_animation, theme, species)
|
|
109
|
+
from genetinav.settings import load_settings
|
|
110
|
+
from genetinav.textual_app import GenetinavTUI
|
|
111
|
+
|
|
112
|
+
settings = load_settings()
|
|
113
|
+
settings.update(overrides)
|
|
114
|
+
tui = GenetinavTUI(settings=settings, initial_query=gene)
|
|
115
|
+
tui.run()
|
|
116
|
+
typer.echo("Session terminated gracefully. Goodbye!")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.callback(invoke_without_command=True)
|
|
120
|
+
def cli(
|
|
121
|
+
ctx: typer.Context,
|
|
122
|
+
version: Optional[bool] = typer.Option(
|
|
123
|
+
None,
|
|
124
|
+
"--version",
|
|
125
|
+
callback=version_callback,
|
|
126
|
+
is_eager=True,
|
|
127
|
+
help="Show the version and exit.",
|
|
128
|
+
),
|
|
129
|
+
no_animation: bool = typer.Option(False, "--no-animation", help="Disable animations for this session"),
|
|
130
|
+
theme: Optional[str] = typer.Option(None, "--theme", help="Override active colour palette"),
|
|
131
|
+
species: Optional[str] = typer.Option(None, "--species", help="Override default species"),
|
|
132
|
+
clear_cache: bool = typer.Option(False, "--clear-cache", help="Clear the local gene cache then exit"),
|
|
133
|
+
clear_history: bool = typer.Option(False, "--clear-history", help="Clear the search history then exit"),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""GenetiNav — navigational genomics toolkit."""
|
|
136
|
+
|
|
137
|
+
# --clear-cache / --clear-history are handled first so they can be combined
|
|
138
|
+
# with each other (and still print their confirmation messages) before any
|
|
139
|
+
# interactive UI is launched.
|
|
140
|
+
if clear_cache:
|
|
141
|
+
conn = get_connection()
|
|
142
|
+
initialize_schema(conn)
|
|
143
|
+
cache_mgr = CacheManager(conn)
|
|
144
|
+
cache_mgr.clear()
|
|
145
|
+
conn.close()
|
|
146
|
+
typer.echo("Cache cleared")
|
|
147
|
+
|
|
148
|
+
if clear_history:
|
|
149
|
+
conn = get_connection()
|
|
150
|
+
initialize_schema(conn)
|
|
151
|
+
history_mgr = HistoryManager(conn)
|
|
152
|
+
history_mgr.clear()
|
|
153
|
+
conn.close()
|
|
154
|
+
typer.echo("History cleared")
|
|
155
|
+
|
|
156
|
+
# If a data-clearing flag was used without a subcommand, exit cleanly.
|
|
157
|
+
if clear_cache or clear_history:
|
|
158
|
+
if ctx.invoked_subcommand is None:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Launch the interactive TUI when no subcommand was given.
|
|
162
|
+
# Informational flag summaries are printed *after* the session ends so
|
|
163
|
+
# they are not immediately scrolled away by the splash screen.
|
|
164
|
+
if ctx.invoked_subcommand is None:
|
|
165
|
+
overrides = _build_overrides(no_animation, theme, species)
|
|
166
|
+
from genetinav.settings import load_settings
|
|
167
|
+
from genetinav.textual_app import GenetinavTUI
|
|
168
|
+
settings = load_settings()
|
|
169
|
+
settings.update(overrides)
|
|
170
|
+
tui = GenetinavTUI(settings=settings)
|
|
171
|
+
tui.run()
|
|
172
|
+
typer.echo("Session terminated gracefully. Goodbye!")
|
|
173
|
+
|
|
174
|
+
# Print flag confirmations after the TUI exits so they remain visible.
|
|
175
|
+
if no_animation:
|
|
176
|
+
typer.echo("Performance mode enabled")
|
|
177
|
+
if theme:
|
|
178
|
+
typer.echo(f"Theme set to: {theme}")
|
|
179
|
+
if species:
|
|
180
|
+
typer.echo(f"Species set to: {species}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("default", hidden=True)
|
|
184
|
+
def default_cmd(
|
|
185
|
+
gene: str,
|
|
186
|
+
no_animation: bool = typer.Option(False, "--no-animation"),
|
|
187
|
+
theme: Optional[str] = typer.Option(None, "--theme"),
|
|
188
|
+
species: Optional[str] = typer.Option(None, "--species"),
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Quick lookup: launch TUI pre-filled with GENE."""
|
|
191
|
+
_run_gene_cmd(
|
|
192
|
+
gene=gene,
|
|
193
|
+
no_animation=no_animation,
|
|
194
|
+
theme=theme,
|
|
195
|
+
species=species,
|
|
196
|
+
echo_message=f"Quick lookup: {gene.upper()}",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("search")
|
|
201
|
+
def search_cmd(
|
|
202
|
+
gene: str,
|
|
203
|
+
no_animation: bool = typer.Option(False, "--no-animation"),
|
|
204
|
+
theme: Optional[str] = typer.Option(None, "--theme"),
|
|
205
|
+
species: Optional[str] = typer.Option(None, "--species"),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Search for a GENE in the interactive TUI."""
|
|
208
|
+
_run_gene_cmd(
|
|
209
|
+
gene=gene,
|
|
210
|
+
no_animation=no_animation,
|
|
211
|
+
theme=theme,
|
|
212
|
+
species=species,
|
|
213
|
+
echo_message=f"Searching for: {gene.upper()}",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command("settings")
|
|
218
|
+
def settings_cmd(
|
|
219
|
+
no_animation: bool = typer.Option(False, "--no-animation"),
|
|
220
|
+
theme: Optional[str] = typer.Option(None, "--theme"),
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Open the settings screen."""
|
|
223
|
+
overrides = _build_overrides(no_animation, theme, None)
|
|
224
|
+
from genetinav.settings import load_settings
|
|
225
|
+
from genetinav.textual_app import GenetinavTUI
|
|
226
|
+
|
|
227
|
+
settings = load_settings()
|
|
228
|
+
settings.update(overrides)
|
|
229
|
+
tui = GenetinavTUI(settings=settings, initial_screen="settings")
|
|
230
|
+
tui.run()
|
|
231
|
+
typer.echo("Session terminated gracefully. Goodbye!")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main() -> None:
|
|
235
|
+
app()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
def parse_command(input_str: str, valid_commands: list[str] | None = None) -> tuple[str | None, str, str | None]:
|
|
2
|
+
"""Parse a slash command or return as a raw query.
|
|
3
|
+
|
|
4
|
+
Returns:
|
|
5
|
+
(command_name, arguments, error_message)
|
|
6
|
+
"""
|
|
7
|
+
s = input_str.strip()
|
|
8
|
+
if not s:
|
|
9
|
+
return None, "", None
|
|
10
|
+
|
|
11
|
+
if s.startswith("/"):
|
|
12
|
+
parts = s[1:].split(" ", 1)
|
|
13
|
+
raw_cmd = parts[0].strip().lower()
|
|
14
|
+
args = parts[1].strip() if len(parts) > 1 else ""
|
|
15
|
+
|
|
16
|
+
if valid_commands is not None and raw_cmd:
|
|
17
|
+
if raw_cmd in valid_commands:
|
|
18
|
+
return raw_cmd, args, None
|
|
19
|
+
|
|
20
|
+
prefix_matches = [cmd for cmd in valid_commands if cmd.startswith(raw_cmd)]
|
|
21
|
+
|
|
22
|
+
if len(prefix_matches) == 1:
|
|
23
|
+
return prefix_matches[0], args, None
|
|
24
|
+
elif len(prefix_matches) > 1:
|
|
25
|
+
pick_list = " ".join(f"{i+1}) /{cmd}" for i, cmd in enumerate(prefix_matches))
|
|
26
|
+
return None, "", f"Ambiguous command. Did you mean: {pick_list}"
|
|
27
|
+
else:
|
|
28
|
+
return None, "", f"Unknown command: /{raw_cmd}"
|
|
29
|
+
|
|
30
|
+
return raw_cmd, args, None
|
|
31
|
+
|
|
32
|
+
return None, s, None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Command routing and registry for GenetiNav."""
|
|
2
|
+
from typing import Callable, Dict, Tuple
|
|
3
|
+
|
|
4
|
+
# Registry entry: (handler, description, keybinding_shortcut)
|
|
5
|
+
CommandRegistry = Dict[str, Tuple[Callable[[], None], str, str]]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CommandRouter:
|
|
9
|
+
"""Dispatches commands to their appropriate handlers.
|
|
10
|
+
|
|
11
|
+
The *live* argument accepts any object with ``stop()`` and ``start()``
|
|
12
|
+
methods — used by the Textual app with a ``DummyLive`` stub that is a
|
|
13
|
+
no-op, since Textual manages its own render loop.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, registry: CommandRegistry, live) -> None:
|
|
17
|
+
self._registry = registry
|
|
18
|
+
self._live = live
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def registry(self) -> CommandRegistry:
|
|
22
|
+
"""Access the command registry."""
|
|
23
|
+
return self._registry
|
|
24
|
+
|
|
25
|
+
def dispatch(self, command: str) -> None:
|
|
26
|
+
handler_info = self._registry.get(command)
|
|
27
|
+
if handler_info:
|
|
28
|
+
handler = handler_info[0]
|
|
29
|
+
self._live.stop()
|
|
30
|
+
try:
|
|
31
|
+
handler()
|
|
32
|
+
finally:
|
|
33
|
+
self._live.start()
|