mondonorm 1.0.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.
- mondonorm-1.0.0/LICENSE +21 -0
- mondonorm-1.0.0/PKG-INFO +10 -0
- mondonorm-1.0.0/README.md +141 -0
- mondonorm-1.0.0/mondonorm/cli.py +391 -0
- mondonorm-1.0.0/mondonorm/mondo.py +318 -0
- mondonorm-1.0.0/mondonorm/ols.py +112 -0
- mondonorm-1.0.0/mondonorm/phenopackets.py +80 -0
- mondonorm-1.0.0/mondonorm/suggest.py +75 -0
- mondonorm-1.0.0/mondonorm.egg-info/PKG-INFO +10 -0
- mondonorm-1.0.0/mondonorm.egg-info/SOURCES.txt +14 -0
- mondonorm-1.0.0/mondonorm.egg-info/dependency_links.txt +1 -0
- mondonorm-1.0.0/mondonorm.egg-info/entry_points.txt +2 -0
- mondonorm-1.0.0/mondonorm.egg-info/requires.txt +4 -0
- mondonorm-1.0.0/mondonorm.egg-info/top_level.txt +1 -0
- mondonorm-1.0.0/pyproject.toml +22 -0
- mondonorm-1.0.0/setup.cfg +4 -0
mondonorm-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Tim Hearn 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mondonorm-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mondonorm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Interactively validate and normalise GA4GH phenopacket disease terms against the MONDO ontology
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Provides-Extra: recommended
|
|
8
|
+
Requires-Dist: rapidfuzz>=3.0; extra == "recommended"
|
|
9
|
+
Requires-Dist: rich>=13.0; extra == "recommended"
|
|
10
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# mondonorm
|
|
2
|
+
|
|
3
|
+
Interactively **validate and normalise the disease terms** in GA4GH
|
|
4
|
+
phenopacket / family JSON files against the **MONDO** disease ontology.
|
|
5
|
+
|
|
6
|
+
If your tool currently emits disease terms with an empty `id` and a free-text
|
|
7
|
+
label:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
"diseases": \[ { "term": { "id": "", "label": "sickle cell" } } ]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`mondonorm` walks the file, finds every disease term, suggests the most likely
|
|
14
|
+
MONDO terms for each label, lets you pick one (or type a MONDO id yourself), and
|
|
15
|
+
writes a normalised copy:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
"diseases": \[ { "term": { "id": "MONDO:0011382", "label": "sickle cell disease" } } ]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## How suggestions are sourced
|
|
22
|
+
|
|
23
|
+
Two interchangeable backends; pick with `--backend`:
|
|
24
|
+
|
|
25
|
+
|Backend|What it uses|Network|
|
|
26
|
+
|-|-|-|
|
|
27
|
+
|`ols`|EBI Ontology Lookup Service v4 (live MONDO)|required|
|
|
28
|
+
|`local`|A local `mondo.obo` / `mondo.json`, downloaded + cached on first use|only for the one-time download|
|
|
29
|
+
|`auto`|OLS if reachable, otherwise local (the default)|preferred|
|
|
30
|
+
|
|
31
|
+
The official ontology file can be fetched automatically (from
|
|
32
|
+
`purl.obolibrary.org/obo/mondo.obo`) or supplied with `--mondo-file`.
|
|
33
|
+
A tiny **illustrative** offline sample (`mondonorm/data/mondo-sample.obo`,
|
|
34
|
+
9 terms) ships with the package so you can try the tool with no network — it is
|
|
35
|
+
**not** a substitute for the real ontology.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e . # installs the `mondonorm` command
|
|
41
|
+
pip install -e ".\[recommended]" # + rapidfuzz (better matching) and rich (nicer UI)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Both extras are optional: without `rapidfuzz` it falls back to stdlib `difflib`;
|
|
45
|
+
without `rich` it prints plain text.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Online (auto-detects OLS):
|
|
51
|
+
mondonorm autosomal-dominant-colours\_phenopackets.json
|
|
52
|
+
|
|
53
|
+
# Fully offline against a downloaded ontology:
|
|
54
|
+
mondonorm myfile.json --backend local --mondo-file /path/to/mondo.obo
|
|
55
|
+
|
|
56
|
+
# Try it offline with the bundled sample (diabetes / sickle cell only):
|
|
57
|
+
mondonorm myfile.json --backend local --mondo-file mondonorm/data/mondo-sample.obo
|
|
58
|
+
|
|
59
|
+
# Equivalent without installing:
|
|
60
|
+
python -m mondonorm myfile.json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## The interactive review
|
|
64
|
+
|
|
65
|
+
Each **distinct** label is reviewed once and the decision is applied to every
|
|
66
|
+
occurrence (so "diabetes" appearing 8 times is one question, not eight). For
|
|
67
|
+
each label you see a ranked table and a prompt:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Select \[#, s words, m MONDO:id, r, k, x, ?, q]:
|
|
71
|
+
<number> select that suggestion
|
|
72
|
+
s <words> search again with different words
|
|
73
|
+
m MONDO:id enter a MONDO id manually (validated, canonical label fetched)
|
|
74
|
+
r reuse a decision remembered from earlier in this session
|
|
75
|
+
k keep the current term unchanged
|
|
76
|
+
x skip this label (leave it unmapped)
|
|
77
|
+
? help
|
|
78
|
+
q finish now: apply decisions made so far and save
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If a term already has an `id`, it is validated: the tool reports whether the id
|
|
82
|
+
resolves in MONDO and whether its canonical label matches the existing label.
|
|
83
|
+
|
|
84
|
+
## Output
|
|
85
|
+
|
|
86
|
+
For `input.json` (unless you pass `--out` or `--in-place`):
|
|
87
|
+
|
|
88
|
+
* `input.normalized.json` — the normalised phenopacket (only `term.id` /
|
|
89
|
+
`term.label` are changed; everything else, including the pedigree, is left
|
|
90
|
+
exactly as-is).
|
|
91
|
+
* `input.normalized.json.mapping.json` — a decision log (label → MONDO id, count).
|
|
92
|
+
|
|
93
|
+
## Useful options
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
--out PATH output path (single input file only)
|
|
97
|
+
--in-place overwrite the input file(s)
|
|
98
|
+
--backend {auto,ols,local}
|
|
99
|
+
--mondo-file PATH local mondo.obo or mondo.json
|
|
100
|
+
--cache-dir DIR where the downloaded MONDO + parsed index are cached
|
|
101
|
+
--update re-download / re-parse MONDO
|
|
102
|
+
--no-download never download (local backend must find a file)
|
|
103
|
+
--no-online never use OLS, even in auto mode
|
|
104
|
+
--limit N suggestions shown per label (default 8)
|
|
105
|
+
--mapping FILE load/save remembered label->term decisions across runs
|
|
106
|
+
--auto-remembered auto-apply remembered labels without prompting
|
|
107
|
+
--no-color plain text output
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Process several files in one go; remembered decisions carry across them:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
mondonorm \*.json --mapping team-mondo-map.json
|
|
114
|
+
# next time, reuse without re-typing:
|
|
115
|
+
mondonorm new/\*.json --mapping team-mondo-map.json --auto-remembered
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Use as a library
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from mondonorm import MondoIndex, OlsClient, Suggester
|
|
122
|
+
from mondonorm import phenopackets as pp
|
|
123
|
+
|
|
124
|
+
sug = Suggester(OlsClient(), name="ols") # or Suggester(MondoIndex.load(...))
|
|
125
|
+
for c in sug.suggest("sickle cell", limit=5):
|
|
126
|
+
print(c.id, c.label, c.score)
|
|
127
|
+
|
|
128
|
+
data = pp.load("myfile.json")
|
|
129
|
+
for ref in pp.find\_disease\_terms(data):
|
|
130
|
+
print(ref.path, ref.label, ref.id)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Getting the full MONDO ontology
|
|
134
|
+
|
|
135
|
+
* Browser / search: https://mondo.monarchinitiative.org
|
|
136
|
+
* Direct files: `http://purl.obolibrary.org/obo/mondo.obo` or `.../mondo.json`
|
|
137
|
+
* Releases: https://github.com/monarch-initiative/mondo/releases
|
|
138
|
+
|
|
139
|
+
Phenopackets reference: https://phenopacket-schema.readthedocs.io
|
|
140
|
+
(disease terms use MONDO `OntologyClass` values).
|
|
141
|
+
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Interactive command-line tool to validate and normalise the disease terms
|
|
2
|
+
in GA4GH phenopacket / family JSON files against the MONDO ontology.
|
|
3
|
+
|
|
4
|
+
Run: python -m mondonorm FILE.json [FILE2.json ...]
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import phenopackets as pp
|
|
14
|
+
from .mondo import MondoIndex, normalise_curie
|
|
15
|
+
from .ols import OlsClient
|
|
16
|
+
from .suggest import Candidate, Suggester
|
|
17
|
+
|
|
18
|
+
# --------------------------------------------------------------------- pretty
|
|
19
|
+
try:
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
_console = Console()
|
|
24
|
+
_HAVE_RICH = True
|
|
25
|
+
except Exception: # pragma: no cover
|
|
26
|
+
_console = None
|
|
27
|
+
_HAVE_RICH = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UI:
|
|
31
|
+
def __init__(self, color: bool = True):
|
|
32
|
+
self.rich = _HAVE_RICH and color
|
|
33
|
+
|
|
34
|
+
def rule(self, text: str = "") -> None:
|
|
35
|
+
if self.rich:
|
|
36
|
+
_console.rule(f"[bold]{text}")
|
|
37
|
+
else:
|
|
38
|
+
print("\n" + "=" * 70)
|
|
39
|
+
if text:
|
|
40
|
+
print(text)
|
|
41
|
+
print("-" * 70)
|
|
42
|
+
|
|
43
|
+
def say(self, text: str = "") -> None:
|
|
44
|
+
(_console.print if self.rich else print)(text)
|
|
45
|
+
|
|
46
|
+
def warn(self, text: str) -> None:
|
|
47
|
+
self.say(f"[yellow]! {text}[/yellow]" if self.rich else f"! {text}")
|
|
48
|
+
|
|
49
|
+
def ok(self, text: str) -> None:
|
|
50
|
+
self.say(f"[green]\u2713 {text}[/green]" if self.rich else f"\u2713 {text}")
|
|
51
|
+
|
|
52
|
+
def err(self, text: str) -> None:
|
|
53
|
+
self.say(f"[red]\u2717 {text}[/red]" if self.rich else f"\u2717 {text}")
|
|
54
|
+
|
|
55
|
+
def candidates(self, cands: list[Candidate]) -> None:
|
|
56
|
+
if not cands:
|
|
57
|
+
self.warn("No suggestions found. Try 's <better search words>' or 'm <MONDO:id>'.")
|
|
58
|
+
return
|
|
59
|
+
if self.rich:
|
|
60
|
+
t = Table(show_header=True, header_style="bold cyan", box=None, pad_edge=False)
|
|
61
|
+
t.add_column("#", justify="right")
|
|
62
|
+
t.add_column("MONDO id", style="magenta", no_wrap=True)
|
|
63
|
+
t.add_column("Label", style="white")
|
|
64
|
+
t.add_column("Score", justify="right")
|
|
65
|
+
t.add_column("Matched on", style="dim")
|
|
66
|
+
t.add_column("Definition", style="dim")
|
|
67
|
+
for i, c in enumerate(cands, 1):
|
|
68
|
+
t.add_row(str(i), c.id, c.label, f"{c.score:g}", c.matched_on, c.short_def(70))
|
|
69
|
+
_console.print(t)
|
|
70
|
+
else:
|
|
71
|
+
for i, c in enumerate(cands, 1):
|
|
72
|
+
print(f" {i:>2}. {c.id} {c.label} (score {c.score:g}, {c.matched_on})")
|
|
73
|
+
if c.short_def(80):
|
|
74
|
+
print(f" {c.short_def(80)}")
|
|
75
|
+
|
|
76
|
+
def prompt(self, text: str) -> str:
|
|
77
|
+
try:
|
|
78
|
+
return input(text)
|
|
79
|
+
except EOFError:
|
|
80
|
+
return "q"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
HELP = """\
|
|
84
|
+
Choices:
|
|
85
|
+
<number> select that suggestion
|
|
86
|
+
s <words> search again with different words
|
|
87
|
+
m <MONDO:id> enter a MONDO id manually (e.g. m MONDO:0005015) - it is validated
|
|
88
|
+
k keep the current term unchanged
|
|
89
|
+
x skip this label (leave it unmapped)
|
|
90
|
+
? show this help
|
|
91
|
+
q finish now: apply decisions made so far and save
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_suggester(args, ui: UI) -> Suggester:
|
|
96
|
+
backend = args.backend
|
|
97
|
+
if backend == "ols":
|
|
98
|
+
ui.say("Backend: EBI OLS (online).")
|
|
99
|
+
return Suggester(OlsClient(), name="ols")
|
|
100
|
+
if backend == "local":
|
|
101
|
+
idx = MondoIndex.load(
|
|
102
|
+
args.mondo_file,
|
|
103
|
+
allow_download=not args.no_download,
|
|
104
|
+
update=args.update,
|
|
105
|
+
cache_dir=args.cache_dir,
|
|
106
|
+
log=ui.say,
|
|
107
|
+
)
|
|
108
|
+
ui.say(f"Backend: local MONDO ({len(idx)} terms from {idx.source}).")
|
|
109
|
+
return Suggester(idx, name="mondo-local")
|
|
110
|
+
# auto
|
|
111
|
+
ols = OlsClient()
|
|
112
|
+
if not args.no_online and ols.available():
|
|
113
|
+
ui.say("Backend: EBI OLS (online).")
|
|
114
|
+
# Local is used as a silent fallback only if it is already cheap to load.
|
|
115
|
+
fb = None
|
|
116
|
+
try:
|
|
117
|
+
if args.mondo_file or (Path(args.cache_dir or _default_cache()) / "mondo.obo").exists():
|
|
118
|
+
fb = MondoIndex.load(
|
|
119
|
+
args.mondo_file, allow_download=False,
|
|
120
|
+
cache_dir=args.cache_dir, log=lambda *a, **k: None,
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
fb = None
|
|
124
|
+
return Suggester(ols, fallback=fb, name="ols")
|
|
125
|
+
ui.warn("OLS not reachable; using local MONDO.")
|
|
126
|
+
idx = MondoIndex.load(
|
|
127
|
+
args.mondo_file,
|
|
128
|
+
allow_download=not args.no_download,
|
|
129
|
+
update=args.update,
|
|
130
|
+
cache_dir=args.cache_dir,
|
|
131
|
+
log=ui.say,
|
|
132
|
+
)
|
|
133
|
+
ui.say(f"Backend: local MONDO ({len(idx)} terms from {idx.source}).")
|
|
134
|
+
return Suggester(idx, name="mondo-local")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _default_cache():
|
|
138
|
+
from .mondo import default_cache_dir
|
|
139
|
+
|
|
140
|
+
return default_cache_dir()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def review_group(
|
|
144
|
+
ui: UI,
|
|
145
|
+
suggester: Suggester,
|
|
146
|
+
label: str,
|
|
147
|
+
existing_id: str,
|
|
148
|
+
count: int,
|
|
149
|
+
index: int,
|
|
150
|
+
total: int,
|
|
151
|
+
remembered: dict[str, dict],
|
|
152
|
+
limit: int,
|
|
153
|
+
) -> dict | None:
|
|
154
|
+
"""Drive the interactive review of one label group.
|
|
155
|
+
|
|
156
|
+
Returns a decision dict {id,label,action} or None to abort/quit.
|
|
157
|
+
The special action 'quit' tells the caller to stop and save.
|
|
158
|
+
"""
|
|
159
|
+
ui.rule(f"[{index}/{total}] disease label: {label!r} ({count} occurrence(s))")
|
|
160
|
+
|
|
161
|
+
if existing_id:
|
|
162
|
+
existing = suggester.resolve(existing_id)
|
|
163
|
+
if existing and existing.label.lower() == label.lower():
|
|
164
|
+
ui.ok(f"Already a valid MONDO term: {existing.id} {existing.label}")
|
|
165
|
+
elif existing:
|
|
166
|
+
ui.warn(
|
|
167
|
+
f"Existing id {existing_id} resolves to {existing.id} '{existing.label}', "
|
|
168
|
+
f"which differs from the label '{label}'."
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
ui.warn(f"Existing id {existing_id!r} could not be validated against MONDO.")
|
|
172
|
+
|
|
173
|
+
query = label
|
|
174
|
+
cands = suggester.suggest(query, limit=limit)
|
|
175
|
+
|
|
176
|
+
# Float a remembered decision for this label to the very top.
|
|
177
|
+
mem = remembered.get(label.lower())
|
|
178
|
+
if mem:
|
|
179
|
+
ui.ok(f"Remembered from earlier: {mem['id']} {mem['label']} (enter 'r' to reuse)")
|
|
180
|
+
|
|
181
|
+
while True:
|
|
182
|
+
ui.candidates(cands)
|
|
183
|
+
raw = ui.prompt("Select [#, s words, m MONDO:id, r, k, x, ?, q]: ").strip()
|
|
184
|
+
|
|
185
|
+
if raw == "":
|
|
186
|
+
continue
|
|
187
|
+
if raw in ("?", "help"):
|
|
188
|
+
ui.say(HELP)
|
|
189
|
+
continue
|
|
190
|
+
if raw in ("q", "quit"):
|
|
191
|
+
return {"action": "quit"}
|
|
192
|
+
if raw in ("k", "keep"):
|
|
193
|
+
return {"action": "keep"}
|
|
194
|
+
if raw in ("x", "skip"):
|
|
195
|
+
return {"action": "skip"}
|
|
196
|
+
if raw in ("r", "reuse") and mem:
|
|
197
|
+
return {"action": "map", "id": mem["id"], "label": mem["label"]}
|
|
198
|
+
|
|
199
|
+
# search again
|
|
200
|
+
if raw.startswith("s ") or raw == "s":
|
|
201
|
+
query = raw[2:].strip() or ui.prompt("Search words: ").strip()
|
|
202
|
+
if query:
|
|
203
|
+
cands = suggester.suggest(query, limit=limit)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# explicit manual id (m MONDO:0005015) or a bare curie
|
|
207
|
+
manual = None
|
|
208
|
+
if raw.startswith("m "):
|
|
209
|
+
manual = raw[2:].strip()
|
|
210
|
+
elif normalise_curie(raw):
|
|
211
|
+
# Only treat as a curie if it is NOT a small selection index.
|
|
212
|
+
if not (raw.isdigit() and 1 <= int(raw) <= len(cands)):
|
|
213
|
+
manual = raw
|
|
214
|
+
if manual is not None:
|
|
215
|
+
c = normalise_curie(manual)
|
|
216
|
+
if not c:
|
|
217
|
+
ui.err(f"{manual!r} is not a valid MONDO id (expected MONDO:0000000).")
|
|
218
|
+
continue
|
|
219
|
+
resolved = suggester.resolve(c)
|
|
220
|
+
if resolved:
|
|
221
|
+
ui.ok(f"{resolved.id} {resolved.label}")
|
|
222
|
+
if _confirm(ui, "Use this term?"):
|
|
223
|
+
return {"action": "map", "id": resolved.id, "label": resolved.label}
|
|
224
|
+
continue
|
|
225
|
+
ui.warn(f"Could not validate {c} against MONDO.")
|
|
226
|
+
if _confirm(ui, f"Use {c} anyway with label {label!r}?"):
|
|
227
|
+
return {"action": "map", "id": c, "label": label}
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
# numeric selection
|
|
231
|
+
if raw.isdigit():
|
|
232
|
+
i = int(raw)
|
|
233
|
+
if 1 <= i <= len(cands):
|
|
234
|
+
chosen = cands[i - 1]
|
|
235
|
+
return {"action": "map", "id": chosen.id, "label": chosen.label}
|
|
236
|
+
ui.err(f"Pick a number between 1 and {len(cands)}.")
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
ui.err("Unrecognised input. Type ? for help.")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _confirm(ui: UI, text: str) -> bool:
|
|
243
|
+
ans = ui.prompt(f"{text} [Y/n]: ").strip().lower()
|
|
244
|
+
return ans in ("", "y", "yes")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def process_file(path: Path, args, ui: UI, suggester: Suggester, remembered: dict) -> dict:
|
|
248
|
+
ui.rule(f"FILE: {path.name}")
|
|
249
|
+
data = pp.load(path)
|
|
250
|
+
refs = pp.find_disease_terms(data)
|
|
251
|
+
if not refs:
|
|
252
|
+
ui.warn("No disease terms found in this file.")
|
|
253
|
+
return {"file": str(path), "mapped": 0, "kept": 0, "skipped": 0, "total": 0}
|
|
254
|
+
|
|
255
|
+
groups = pp.group_by_label(refs)
|
|
256
|
+
ui.say(f"Found {len(refs)} disease term(s) across {len(groups)} distinct label(s).")
|
|
257
|
+
|
|
258
|
+
decisions: list[dict] = []
|
|
259
|
+
stop = False
|
|
260
|
+
items = list(groups.items())
|
|
261
|
+
for index, ((label, existing_id), group_refs) in enumerate(items, 1):
|
|
262
|
+
if stop:
|
|
263
|
+
break
|
|
264
|
+
if args.auto_remembered and label.lower() in remembered:
|
|
265
|
+
mem = remembered[label.lower()]
|
|
266
|
+
for r in group_refs:
|
|
267
|
+
r.apply(mem["id"], mem["label"])
|
|
268
|
+
ui.ok(f"[auto] {label!r} -> {mem['id']} {mem['label']}")
|
|
269
|
+
decisions.append({"label": label, **mem, "action": "map", "count": len(group_refs)})
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
decision = review_group(
|
|
273
|
+
ui, suggester, label, existing_id, len(group_refs),
|
|
274
|
+
index, len(items), remembered, args.limit,
|
|
275
|
+
)
|
|
276
|
+
action = decision.get("action")
|
|
277
|
+
if action == "quit":
|
|
278
|
+
stop = True
|
|
279
|
+
ui.warn("Finishing early - remaining labels left unchanged.")
|
|
280
|
+
break
|
|
281
|
+
if action == "keep":
|
|
282
|
+
ui.say(f"Kept {label!r} unchanged.")
|
|
283
|
+
decisions.append({"label": label, "action": "keep", "count": len(group_refs)})
|
|
284
|
+
continue
|
|
285
|
+
if action == "skip":
|
|
286
|
+
ui.say(f"Skipped {label!r}.")
|
|
287
|
+
decisions.append({"label": label, "action": "skip", "count": len(group_refs)})
|
|
288
|
+
continue
|
|
289
|
+
# map
|
|
290
|
+
cid, clabel = decision["id"], decision["label"]
|
|
291
|
+
for r in group_refs:
|
|
292
|
+
r.apply(cid, clabel)
|
|
293
|
+
remembered[label.lower()] = {"id": cid, "label": clabel}
|
|
294
|
+
ui.ok(f"Mapped {label!r} -> {cid} {clabel} ({len(group_refs)} occurrence(s)).")
|
|
295
|
+
decisions.append({"label": label, "id": cid, "label_mondo": clabel,
|
|
296
|
+
"action": "map", "count": len(group_refs)})
|
|
297
|
+
|
|
298
|
+
# ---- write output
|
|
299
|
+
if args.out and len(args._files) == 1:
|
|
300
|
+
out_path = Path(args.out)
|
|
301
|
+
elif args.in_place:
|
|
302
|
+
out_path = path
|
|
303
|
+
else:
|
|
304
|
+
out_path = path.with_name(path.stem + ".normalized.json")
|
|
305
|
+
pp.save(data, out_path)
|
|
306
|
+
ui.ok(f"Wrote normalised file: {out_path}")
|
|
307
|
+
|
|
308
|
+
# ---- write a mapping sidecar
|
|
309
|
+
sidecar = out_path.with_suffix(out_path.suffix + ".mapping.json")
|
|
310
|
+
pp.save({"file": str(path), "decisions": decisions}, sidecar)
|
|
311
|
+
ui.say(f"Wrote decision log: {sidecar}")
|
|
312
|
+
|
|
313
|
+
mapped = sum(d["count"] for d in decisions if d["action"] == "map")
|
|
314
|
+
kept = sum(d["count"] for d in decisions if d["action"] == "keep")
|
|
315
|
+
skipped = sum(d["count"] for d in decisions if d["action"] == "skip")
|
|
316
|
+
return {"file": str(path), "mapped": mapped, "kept": kept,
|
|
317
|
+
"skipped": skipped, "total": len(refs)}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def main(argv: list[str] | None = None) -> int:
|
|
321
|
+
p = argparse.ArgumentParser(
|
|
322
|
+
prog="mondonorm",
|
|
323
|
+
description="Interactively map free-text phenopacket disease labels to MONDO terms.",
|
|
324
|
+
)
|
|
325
|
+
p.add_argument("files", nargs="+", help="Phenopacket / family JSON file(s).")
|
|
326
|
+
p.add_argument("-o", "--out", help="Output path (only valid with a single input file).")
|
|
327
|
+
p.add_argument("--in-place", action="store_true", help="Overwrite the input file(s).")
|
|
328
|
+
p.add_argument("--backend", choices=["auto", "ols", "local"], default="auto",
|
|
329
|
+
help="Suggestion source (default: auto -> OLS online, else local).")
|
|
330
|
+
p.add_argument("--mondo-file", help="Path to a local mondo.obo or mondo.json.")
|
|
331
|
+
p.add_argument("--cache-dir", help="Where to cache the downloaded MONDO file.")
|
|
332
|
+
p.add_argument("--update", action="store_true", help="Re-download / re-parse MONDO.")
|
|
333
|
+
p.add_argument("--no-download", action="store_true",
|
|
334
|
+
help="Never download MONDO (local backend must find a file).")
|
|
335
|
+
p.add_argument("--no-online", action="store_true",
|
|
336
|
+
help="Do not use OLS even in auto mode.")
|
|
337
|
+
p.add_argument("--limit", type=int, default=8, help="Suggestions shown per label.")
|
|
338
|
+
p.add_argument("--mapping", help="JSON file of remembered label->term decisions to "
|
|
339
|
+
"load and update across runs.")
|
|
340
|
+
p.add_argument("--auto-remembered", action="store_true",
|
|
341
|
+
help="Auto-apply remembered labels without prompting.")
|
|
342
|
+
p.add_argument("--no-color", action="store_true", help="Disable rich/colour output.")
|
|
343
|
+
args = p.parse_args(argv)
|
|
344
|
+
args._files = args.files
|
|
345
|
+
|
|
346
|
+
ui = UI(color=not args.no_color)
|
|
347
|
+
|
|
348
|
+
if args.out and len(args.files) > 1:
|
|
349
|
+
ui.err("--out can only be used with a single input file.")
|
|
350
|
+
return 2
|
|
351
|
+
|
|
352
|
+
# Load remembered decisions, if any.
|
|
353
|
+
remembered: dict[str, dict] = {}
|
|
354
|
+
if args.mapping and Path(args.mapping).exists():
|
|
355
|
+
try:
|
|
356
|
+
remembered = json.loads(Path(args.mapping).read_text())
|
|
357
|
+
ui.say(f"Loaded {len(remembered)} remembered mapping(s) from {args.mapping}.")
|
|
358
|
+
except Exception:
|
|
359
|
+
ui.warn(f"Could not read mapping file {args.mapping}; starting fresh.")
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
suggester = build_suggester(args, ui)
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
ui.err(f"Could not initialise a MONDO backend: {exc}")
|
|
365
|
+
return 1
|
|
366
|
+
|
|
367
|
+
summaries = []
|
|
368
|
+
for f in args.files:
|
|
369
|
+
path = Path(f)
|
|
370
|
+
if not path.exists():
|
|
371
|
+
ui.err(f"File not found: {path}")
|
|
372
|
+
continue
|
|
373
|
+
summaries.append(process_file(path, args, ui, suggester, remembered))
|
|
374
|
+
|
|
375
|
+
# Persist remembered decisions for next time.
|
|
376
|
+
if args.mapping:
|
|
377
|
+
try:
|
|
378
|
+
Path(args.mapping).write_text(json.dumps(remembered, indent=2, ensure_ascii=False))
|
|
379
|
+
ui.say(f"Saved {len(remembered)} remembered mapping(s) to {args.mapping}.")
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
ui.warn(f"Could not write mapping file: {exc}")
|
|
382
|
+
|
|
383
|
+
ui.rule("Summary")
|
|
384
|
+
for s in summaries:
|
|
385
|
+
ui.say(f"{Path(s['file']).name}: {s['mapped']} mapped, {s['kept']} kept, "
|
|
386
|
+
f"{s['skipped']} skipped (of {s['total']} term(s)).")
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Local MONDO ontology index.
|
|
2
|
+
|
|
3
|
+
Loads MONDO from a local ``.obo`` or obographs ``.json`` file (downloading and
|
|
4
|
+
caching the official release on first use if permitted), builds an in-memory
|
|
5
|
+
search index over term labels + synonyms, and answers two questions:
|
|
6
|
+
|
|
7
|
+
* ``search(query)`` -> ranked candidate terms for a free-text label
|
|
8
|
+
* ``get(curie)`` -> the canonical term for a specific ``MONDO:xxxxxxx`` id
|
|
9
|
+
|
|
10
|
+
Fuzzy matching uses ``rapidfuzz`` when available and falls back to the stdlib
|
|
11
|
+
``difflib`` otherwise, so the tool runs even in a minimal environment.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import pickle
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.request
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterable
|
|
25
|
+
|
|
26
|
+
from .suggest import Candidate
|
|
27
|
+
|
|
28
|
+
# Official, version-pinned-to-"latest" PURLs maintained by the Monarch Initiative.
|
|
29
|
+
MONDO_OBO_URL = "http://purl.obolibrary.org/obo/mondo.obo"
|
|
30
|
+
MONDO_JSON_URL = "http://purl.obolibrary.org/obo/mondo.json"
|
|
31
|
+
|
|
32
|
+
_CURIE_RE = re.compile(r"^MONDO:\d{7}$")
|
|
33
|
+
_IRI_RE = re.compile(r"MONDO_(\d{7})")
|
|
34
|
+
|
|
35
|
+
try: # Optional, much faster + better quality.
|
|
36
|
+
from rapidfuzz import fuzz, process
|
|
37
|
+
|
|
38
|
+
_HAVE_RAPIDFUZZ = True
|
|
39
|
+
except Exception: # pragma: no cover - exercised only when dep is absent
|
|
40
|
+
import difflib
|
|
41
|
+
|
|
42
|
+
_HAVE_RAPIDFUZZ = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class MondoTerm:
|
|
47
|
+
id: str
|
|
48
|
+
label: str
|
|
49
|
+
synonyms: list[str] = field(default_factory=list)
|
|
50
|
+
definition: str | None = None
|
|
51
|
+
obsolete: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_cache_dir() -> Path:
|
|
55
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
56
|
+
return Path(base) / "mondonorm"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalise_curie(raw: str) -> str | None:
|
|
60
|
+
"""Coerce user input / IRIs to a canonical ``MONDO:xxxxxxx`` curie, or None."""
|
|
61
|
+
s = raw.strip()
|
|
62
|
+
if not s:
|
|
63
|
+
return None
|
|
64
|
+
m = _IRI_RE.search(s)
|
|
65
|
+
if m:
|
|
66
|
+
return f"MONDO:{m.group(1)}"
|
|
67
|
+
s = s.replace("_", ":")
|
|
68
|
+
if not s.upper().startswith("MONDO"):
|
|
69
|
+
# Allow bare 7-digit ids like "0005015".
|
|
70
|
+
digits = re.sub(r"\D", "", s)
|
|
71
|
+
if len(digits) == 7:
|
|
72
|
+
return f"MONDO:{digits}"
|
|
73
|
+
return None
|
|
74
|
+
digits = re.sub(r"\D", "", s)
|
|
75
|
+
if len(digits) != 7:
|
|
76
|
+
return None
|
|
77
|
+
return f"MONDO:{digits}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MondoIndex:
|
|
81
|
+
"""An in-memory, searchable index of MONDO disease terms."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, terms: dict[str, MondoTerm], source: str):
|
|
84
|
+
self.source = source
|
|
85
|
+
self.terms: dict[str, MondoTerm] = terms
|
|
86
|
+
# Parallel arrays for fast fuzzy scanning: searchable string -> term id.
|
|
87
|
+
self._names: list[str] = []
|
|
88
|
+
self._owner: list[str] = [] # term id for _names[i]
|
|
89
|
+
self._kind: list[str] = [] # "label" or "synonym"
|
|
90
|
+
self._exact: dict[str, list[tuple[str, str]]] = {} # lower text -> [(id, kind)]
|
|
91
|
+
for t in terms.values():
|
|
92
|
+
if t.obsolete:
|
|
93
|
+
continue
|
|
94
|
+
self._add_name(t.id, t.label, "label")
|
|
95
|
+
for syn in t.synonyms:
|
|
96
|
+
self._add_name(t.id, syn, "synonym")
|
|
97
|
+
|
|
98
|
+
def _add_name(self, term_id: str, text: str, kind: str) -> None:
|
|
99
|
+
text = (text or "").strip()
|
|
100
|
+
if not text:
|
|
101
|
+
return
|
|
102
|
+
self._names.append(text)
|
|
103
|
+
self._owner.append(term_id)
|
|
104
|
+
self._kind.append(kind)
|
|
105
|
+
self._exact.setdefault(text.lower(), []).append((term_id, kind))
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------ lookup
|
|
108
|
+
def get(self, curie: str) -> MondoTerm | None:
|
|
109
|
+
c = normalise_curie(curie)
|
|
110
|
+
return self.terms.get(c) if c else None
|
|
111
|
+
|
|
112
|
+
def __len__(self) -> int: # number of (non-obsolete-aware) terms loaded
|
|
113
|
+
return len(self.terms)
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------ search
|
|
116
|
+
def search(self, query: str, limit: int = 8) -> list[Candidate]:
|
|
117
|
+
query = (query or "").strip()
|
|
118
|
+
if not query:
|
|
119
|
+
return []
|
|
120
|
+
scored: dict[str, Candidate] = {}
|
|
121
|
+
|
|
122
|
+
def consider(term_id: str, score: float, matched_on: str, kind: str) -> None:
|
|
123
|
+
t = self.terms.get(term_id)
|
|
124
|
+
if t is None or t.obsolete:
|
|
125
|
+
return
|
|
126
|
+
tag = "label" if kind == "label" else f"synonym: {matched_on}"
|
|
127
|
+
prev = scored.get(term_id)
|
|
128
|
+
if prev is None or score > prev.score:
|
|
129
|
+
scored[term_id] = Candidate(
|
|
130
|
+
id=t.id,
|
|
131
|
+
label=t.label,
|
|
132
|
+
score=round(float(score), 1),
|
|
133
|
+
matched_on=tag,
|
|
134
|
+
definition=t.definition,
|
|
135
|
+
source="mondo-local",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# 1) Exact (case-insensitive) label / synonym hits get a strong score.
|
|
139
|
+
for term_id, kind in self._exact.get(query.lower(), []):
|
|
140
|
+
consider(term_id, 100.0 if kind == "label" else 98.0, query, kind)
|
|
141
|
+
|
|
142
|
+
# 2) Fuzzy scan across all label + synonym strings.
|
|
143
|
+
if _HAVE_RAPIDFUZZ:
|
|
144
|
+
matches = process.extract(
|
|
145
|
+
query, self._names, scorer=fuzz.WRatio, limit=limit * 6
|
|
146
|
+
)
|
|
147
|
+
for matched_text, score, idx in matches:
|
|
148
|
+
consider(self._owner[idx], score, matched_text, self._kind[idx])
|
|
149
|
+
else: # difflib fallback
|
|
150
|
+
ratios = (
|
|
151
|
+
(difflib.SequenceMatcher(None, query.lower(), n.lower()).ratio(), i)
|
|
152
|
+
for i, n in enumerate(self._names)
|
|
153
|
+
)
|
|
154
|
+
top = sorted(ratios, reverse=True)[: limit * 6]
|
|
155
|
+
for ratio, idx in top:
|
|
156
|
+
consider(
|
|
157
|
+
self._owner[idx], ratio * 100.0, self._names[idx], self._kind[idx]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
ranked = sorted(scored.values(), key=lambda c: c.score, reverse=True)
|
|
161
|
+
return ranked[:limit]
|
|
162
|
+
|
|
163
|
+
# ----------------------------------------------------------- construction
|
|
164
|
+
@classmethod
|
|
165
|
+
def load(
|
|
166
|
+
cls,
|
|
167
|
+
path: str | os.PathLike | None = None,
|
|
168
|
+
*,
|
|
169
|
+
allow_download: bool = True,
|
|
170
|
+
update: bool = False,
|
|
171
|
+
cache_dir: str | os.PathLike | None = None,
|
|
172
|
+
log=lambda *a, **k: None,
|
|
173
|
+
) -> "MondoIndex":
|
|
174
|
+
"""Load MONDO from *path*, the cache, or by downloading the release."""
|
|
175
|
+
cache = Path(cache_dir) if cache_dir else default_cache_dir()
|
|
176
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
|
|
178
|
+
src_path: Path
|
|
179
|
+
if path is not None:
|
|
180
|
+
src_path = Path(path)
|
|
181
|
+
if not src_path.exists():
|
|
182
|
+
raise FileNotFoundError(f"MONDO file not found: {src_path}")
|
|
183
|
+
else:
|
|
184
|
+
src_path = cache / "mondo.obo"
|
|
185
|
+
if update or not src_path.exists():
|
|
186
|
+
if not allow_download:
|
|
187
|
+
raise FileNotFoundError(
|
|
188
|
+
"No local MONDO file and downloading is disabled. "
|
|
189
|
+
"Pass --mondo-file or allow downloading."
|
|
190
|
+
)
|
|
191
|
+
log(f"Downloading MONDO from {MONDO_OBO_URL} (one-time) ...")
|
|
192
|
+
_download(MONDO_OBO_URL, src_path)
|
|
193
|
+
log(f"Saved MONDO to {src_path}")
|
|
194
|
+
|
|
195
|
+
# Use a parsed-index pickle cache keyed on source size+mtime.
|
|
196
|
+
stat = src_path.stat()
|
|
197
|
+
key = f"{src_path.name}-{stat.st_size}-{int(stat.st_mtime)}.idx.pkl"
|
|
198
|
+
idx_cache = cache / key
|
|
199
|
+
if idx_cache.exists() and not update:
|
|
200
|
+
try:
|
|
201
|
+
with idx_cache.open("rb") as fh:
|
|
202
|
+
terms = pickle.load(fh)
|
|
203
|
+
log(f"Loaded {len(terms)} MONDO terms from cache.")
|
|
204
|
+
return cls(terms, source=str(src_path))
|
|
205
|
+
except Exception:
|
|
206
|
+
pass # fall through and re-parse
|
|
207
|
+
|
|
208
|
+
log(f"Parsing MONDO from {src_path} ...")
|
|
209
|
+
terms = _parse_file(src_path)
|
|
210
|
+
log(f"Indexed {len(terms)} MONDO terms.")
|
|
211
|
+
try:
|
|
212
|
+
with idx_cache.open("wb") as fh:
|
|
213
|
+
pickle.dump(terms, fh, protocol=pickle.HIGHEST_PROTOCOL)
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
return cls(terms, source=str(src_path))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --------------------------------------------------------------------- parsing
|
|
220
|
+
def _download(url: str, dest: Path, timeout: int = 120) -> None:
|
|
221
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
223
|
+
req = urllib.request.Request(url, headers={"User-Agent": "mondonorm/1.0"})
|
|
224
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp, tmp.open("wb") as out:
|
|
225
|
+
while True:
|
|
226
|
+
chunk = resp.read(1 << 16)
|
|
227
|
+
if not chunk:
|
|
228
|
+
break
|
|
229
|
+
out.write(chunk)
|
|
230
|
+
tmp.replace(dest)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_file(path: Path) -> dict[str, MondoTerm]:
|
|
234
|
+
head = path.open("rb").read(256).lstrip()
|
|
235
|
+
if head[:1] == b"{":
|
|
236
|
+
return _parse_obographs(path)
|
|
237
|
+
return _parse_obo(path)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _parse_obo(path: Path) -> dict[str, MondoTerm]:
|
|
241
|
+
terms: dict[str, MondoTerm] = {}
|
|
242
|
+
cur: dict | None = None
|
|
243
|
+
in_term = False
|
|
244
|
+
|
|
245
|
+
def flush(block: dict | None) -> None:
|
|
246
|
+
if not block:
|
|
247
|
+
return
|
|
248
|
+
tid = block.get("id")
|
|
249
|
+
if not tid or not _CURIE_RE.match(tid):
|
|
250
|
+
return
|
|
251
|
+
terms[tid] = MondoTerm(
|
|
252
|
+
id=tid,
|
|
253
|
+
label=block.get("name", tid),
|
|
254
|
+
synonyms=block.get("synonyms", []),
|
|
255
|
+
definition=block.get("def"),
|
|
256
|
+
obsolete=block.get("obsolete", False),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
syn_re = re.compile(r'synonym:\s*"((?:[^"\\]|\\.)*)"')
|
|
260
|
+
def_re = re.compile(r'def:\s*"((?:[^"\\]|\\.)*)"')
|
|
261
|
+
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
262
|
+
for line in fh:
|
|
263
|
+
line = line.rstrip("\n")
|
|
264
|
+
if line.startswith("[Term]"):
|
|
265
|
+
flush(cur)
|
|
266
|
+
cur = {"synonyms": []}
|
|
267
|
+
in_term = True
|
|
268
|
+
continue
|
|
269
|
+
if line.startswith("[") and line.endswith("]"):
|
|
270
|
+
flush(cur)
|
|
271
|
+
cur = None
|
|
272
|
+
in_term = False
|
|
273
|
+
continue
|
|
274
|
+
if not in_term or cur is None:
|
|
275
|
+
continue
|
|
276
|
+
if line.startswith("id:"):
|
|
277
|
+
cur["id"] = line[3:].strip()
|
|
278
|
+
elif line.startswith("name:"):
|
|
279
|
+
cur["name"] = line[5:].strip()
|
|
280
|
+
elif line.startswith("def:"):
|
|
281
|
+
m = def_re.match(line)
|
|
282
|
+
if m:
|
|
283
|
+
cur["def"] = _unescape(m.group(1))
|
|
284
|
+
elif line.startswith("synonym:"):
|
|
285
|
+
m = syn_re.match(line)
|
|
286
|
+
if m:
|
|
287
|
+
cur["synonyms"].append(_unescape(m.group(1)))
|
|
288
|
+
elif line.startswith("is_obsolete:") and line.split(":", 1)[1].strip() == "true":
|
|
289
|
+
cur["obsolete"] = True
|
|
290
|
+
flush(cur)
|
|
291
|
+
return terms
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _parse_obographs(path: Path) -> dict[str, MondoTerm]:
|
|
295
|
+
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
296
|
+
data = json.load(fh)
|
|
297
|
+
terms: dict[str, MondoTerm] = {}
|
|
298
|
+
for graph in data.get("graphs", []):
|
|
299
|
+
for node in graph.get("nodes", []):
|
|
300
|
+
m = _IRI_RE.search(node.get("id", ""))
|
|
301
|
+
if not m:
|
|
302
|
+
continue
|
|
303
|
+
tid = f"MONDO:{m.group(1)}"
|
|
304
|
+
meta = node.get("meta", {}) or {}
|
|
305
|
+
synonyms = [s.get("val", "") for s in meta.get("synonyms", []) if s.get("val")]
|
|
306
|
+
definition = (meta.get("definition") or {}).get("val")
|
|
307
|
+
terms[tid] = MondoTerm(
|
|
308
|
+
id=tid,
|
|
309
|
+
label=node.get("lbl") or tid,
|
|
310
|
+
synonyms=synonyms,
|
|
311
|
+
definition=definition,
|
|
312
|
+
obsolete=bool(meta.get("deprecated", False)),
|
|
313
|
+
)
|
|
314
|
+
return terms
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _unescape(s: str) -> str:
|
|
318
|
+
return s.replace('\\"', '"').replace("\\n", " ").replace("\\\\", "\\").strip()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""EBI Ontology Lookup Service (OLS4) backend for MONDO.
|
|
2
|
+
|
|
3
|
+
Provides high-quality ranked search over the live MONDO ontology without any
|
|
4
|
+
local download. Used when the machine is online; otherwise the CLI falls back
|
|
5
|
+
to the local :class:`~mondonorm.mondo.MondoIndex`.
|
|
6
|
+
|
|
7
|
+
OLS4 API docs: https://www.ebi.ac.uk/ols4/help
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from .suggest import Candidate
|
|
16
|
+
|
|
17
|
+
OLS_BASE = "https://www.ebi.ac.uk/ols4/api"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OlsClient:
|
|
21
|
+
name = "ols"
|
|
22
|
+
|
|
23
|
+
def __init__(self, base: str = OLS_BASE, timeout: int = 15):
|
|
24
|
+
self.base = base.rstrip("/")
|
|
25
|
+
self.timeout = timeout
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------ utils
|
|
28
|
+
def _get_json(self, url: str) -> dict:
|
|
29
|
+
req = urllib.request.Request(url, headers={"User-Agent": "mondonorm/1.0"})
|
|
30
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
31
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
32
|
+
|
|
33
|
+
def available(self) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
self._get_json(f"{self.base}/ontologies/mondo")
|
|
36
|
+
return True
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
# ----------------------------------------------------------------- search
|
|
41
|
+
def search(self, query: str, limit: int = 8) -> list[Candidate]:
|
|
42
|
+
query = (query or "").strip()
|
|
43
|
+
if not query:
|
|
44
|
+
return []
|
|
45
|
+
params = urllib.parse.urlencode(
|
|
46
|
+
{
|
|
47
|
+
"q": query,
|
|
48
|
+
"ontology": "mondo",
|
|
49
|
+
"type": "class",
|
|
50
|
+
"rows": max(limit, 10),
|
|
51
|
+
"fieldList": "obo_id,label,description,synonym,is_obsolete",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
data = self._get_json(f"{self.base}/search?{params}")
|
|
55
|
+
docs = (data.get("response") or {}).get("docs", [])
|
|
56
|
+
out: list[Candidate] = []
|
|
57
|
+
seen: set[str] = set()
|
|
58
|
+
# OLS returns docs in relevance order; map that to a descending score.
|
|
59
|
+
for rank, doc in enumerate(docs):
|
|
60
|
+
obo_id = doc.get("obo_id")
|
|
61
|
+
if not obo_id or not obo_id.startswith("MONDO:"):
|
|
62
|
+
continue
|
|
63
|
+
if doc.get("is_obsolete"):
|
|
64
|
+
continue
|
|
65
|
+
if obo_id in seen:
|
|
66
|
+
continue
|
|
67
|
+
seen.add(obo_id)
|
|
68
|
+
desc = doc.get("description")
|
|
69
|
+
if isinstance(desc, list):
|
|
70
|
+
desc = desc[0] if desc else None
|
|
71
|
+
out.append(
|
|
72
|
+
Candidate(
|
|
73
|
+
id=obo_id,
|
|
74
|
+
label=doc.get("label") or obo_id,
|
|
75
|
+
score=round(max(1.0, 100.0 - rank * 4), 1),
|
|
76
|
+
matched_on="OLS relevance",
|
|
77
|
+
definition=desc,
|
|
78
|
+
source="ols",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
if len(out) >= limit:
|
|
82
|
+
break
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------- term
|
|
86
|
+
def get(self, curie: str) -> Candidate | None:
|
|
87
|
+
from .mondo import normalise_curie
|
|
88
|
+
|
|
89
|
+
c = normalise_curie(curie)
|
|
90
|
+
if not c:
|
|
91
|
+
return None
|
|
92
|
+
iri = f"http://purl.obolibrary.org/obo/{c.replace(':', '_')}"
|
|
93
|
+
params = urllib.parse.urlencode({"iri": iri})
|
|
94
|
+
try:
|
|
95
|
+
data = self._get_json(f"{self.base}/ontologies/mondo/terms?{params}")
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
terms = (data.get("_embedded") or {}).get("terms", [])
|
|
99
|
+
if not terms:
|
|
100
|
+
return None
|
|
101
|
+
t = terms[0]
|
|
102
|
+
desc = t.get("description")
|
|
103
|
+
if isinstance(desc, list):
|
|
104
|
+
desc = desc[0] if desc else None
|
|
105
|
+
return Candidate(
|
|
106
|
+
id=c,
|
|
107
|
+
label=t.get("label") or c,
|
|
108
|
+
score=100.0,
|
|
109
|
+
matched_on="exact id",
|
|
110
|
+
definition=desc,
|
|
111
|
+
source="ols",
|
|
112
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Read a phenopacket / family JSON, locate every disease ``term``, apply
|
|
2
|
+
chosen MONDO mappings in place, and write the normalised file back out.
|
|
3
|
+
|
|
4
|
+
The disease term finder is deliberately schema-tolerant: it recursively walks
|
|
5
|
+
the document and collects the ``term`` object of every entry under any
|
|
6
|
+
``diseases`` array. That covers the GA4GH ``Phenopacket.diseases`` and
|
|
7
|
+
``Family.proband`` / ``Family.relatives`` layouts (and any nesting of them)
|
|
8
|
+
without hard-coding paths.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TermRef:
|
|
19
|
+
"""A live, mutable reference to one disease ``term`` object in the document."""
|
|
20
|
+
|
|
21
|
+
path: str
|
|
22
|
+
term: dict # the actual {"id": ..., "label": ...} dict inside the loaded JSON
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def label(self) -> str:
|
|
26
|
+
return (self.term.get("label") or "").strip()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def id(self) -> str:
|
|
30
|
+
return (self.term.get("id") or "").strip()
|
|
31
|
+
|
|
32
|
+
def apply(self, curie: str, label: str) -> None:
|
|
33
|
+
self.term["id"] = curie
|
|
34
|
+
self.term["label"] = label
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load(path: str | Path) -> dict:
|
|
38
|
+
with Path(path).open("r", encoding="utf-8") as fh:
|
|
39
|
+
return json.load(fh)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_disease_terms(obj, path: str = "$") -> list[TermRef]:
|
|
43
|
+
"""Recursively collect every disease ``term`` dict in *obj*."""
|
|
44
|
+
found: list[TermRef] = []
|
|
45
|
+
|
|
46
|
+
def walk(node, p):
|
|
47
|
+
if isinstance(node, dict):
|
|
48
|
+
for key, value in node.items():
|
|
49
|
+
if key == "diseases" and isinstance(value, list):
|
|
50
|
+
for i, entry in enumerate(value):
|
|
51
|
+
if isinstance(entry, dict) and isinstance(
|
|
52
|
+
entry.get("term"), dict
|
|
53
|
+
):
|
|
54
|
+
found.append(
|
|
55
|
+
TermRef(f"{p}.diseases[{i}].term", entry["term"])
|
|
56
|
+
)
|
|
57
|
+
walk(entry, f"{p}.diseases[{i}]")
|
|
58
|
+
else:
|
|
59
|
+
walk(value, f"{p}.{key}")
|
|
60
|
+
elif isinstance(node, list):
|
|
61
|
+
for i, item in enumerate(node):
|
|
62
|
+
walk(item, f"{p}[{i}]")
|
|
63
|
+
|
|
64
|
+
walk(obj, path)
|
|
65
|
+
return found
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def group_by_label(refs: list[TermRef]) -> "dict[tuple[str, str], list[TermRef]]":
|
|
69
|
+
"""Group term references by their current (label, id) so each distinct
|
|
70
|
+
label is reviewed once and the decision applied to every occurrence."""
|
|
71
|
+
groups: dict[tuple[str, str], list[TermRef]] = {}
|
|
72
|
+
for ref in refs:
|
|
73
|
+
groups.setdefault((ref.label, ref.id), []).append(ref)
|
|
74
|
+
return groups
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save(data: dict, path: str | Path) -> None:
|
|
78
|
+
with Path(path).open("w", encoding="utf-8") as fh:
|
|
79
|
+
json.dump(data, fh, indent=2, ensure_ascii=False)
|
|
80
|
+
fh.write("\n")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Unified suggestion layer.
|
|
2
|
+
|
|
3
|
+
A ``Suggester`` wraps one of two interchangeable backends:
|
|
4
|
+
|
|
5
|
+
* ``mondo`` - a local :class:`~mondonorm.mondo.MondoIndex` (offline capable)
|
|
6
|
+
* ``ols`` - the EBI Ontology Lookup Service v4 (online, best ranking)
|
|
7
|
+
|
|
8
|
+
and exposes a backend-agnostic API used by the CLI:
|
|
9
|
+
|
|
10
|
+
* ``suggest(label)`` -> ranked list of :class:`Candidate`
|
|
11
|
+
* ``resolve(curie)`` -> validate a specific MONDO id and return its canonical term
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Candidate:
|
|
20
|
+
"""A single suggested MONDO term."""
|
|
21
|
+
|
|
22
|
+
id: str # e.g. "MONDO:0005015"
|
|
23
|
+
label: str # canonical MONDO label
|
|
24
|
+
score: float # 0-100 match score (higher is better)
|
|
25
|
+
matched_on: str # "label" or 'synonym: "..."'
|
|
26
|
+
definition: str | None = None
|
|
27
|
+
source: str = "" # which backend produced it
|
|
28
|
+
|
|
29
|
+
def short_def(self, n: int = 90) -> str:
|
|
30
|
+
if not self.definition:
|
|
31
|
+
return ""
|
|
32
|
+
d = " ".join(self.definition.split())
|
|
33
|
+
return d if len(d) <= n else d[: n - 1] + "\u2026"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Suggester:
|
|
37
|
+
def __init__(self, backend, *, fallback=None, name: str = ""):
|
|
38
|
+
self.backend = backend
|
|
39
|
+
self.fallback = fallback
|
|
40
|
+
self.name = name or getattr(backend, "name", "backend")
|
|
41
|
+
|
|
42
|
+
def suggest(self, label: str, limit: int = 8) -> list[Candidate]:
|
|
43
|
+
try:
|
|
44
|
+
results = self.backend.search(label, limit=limit)
|
|
45
|
+
except Exception:
|
|
46
|
+
results = []
|
|
47
|
+
if not results and self.fallback is not None:
|
|
48
|
+
try:
|
|
49
|
+
results = self.fallback.search(label, limit=limit)
|
|
50
|
+
except Exception:
|
|
51
|
+
results = []
|
|
52
|
+
return results
|
|
53
|
+
|
|
54
|
+
def resolve(self, curie: str) -> Candidate | None:
|
|
55
|
+
"""Look up a specific MONDO id to validate it and fetch its canonical label."""
|
|
56
|
+
for be in (self.backend, self.fallback):
|
|
57
|
+
if be is None:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
term = be.get(curie)
|
|
61
|
+
except Exception:
|
|
62
|
+
term = None
|
|
63
|
+
if term is not None:
|
|
64
|
+
# ``MondoIndex.get`` returns a MondoTerm; OLS returns a Candidate.
|
|
65
|
+
if isinstance(term, Candidate):
|
|
66
|
+
return term
|
|
67
|
+
return Candidate(
|
|
68
|
+
id=term.id,
|
|
69
|
+
label=term.label,
|
|
70
|
+
score=100.0,
|
|
71
|
+
matched_on="exact id",
|
|
72
|
+
definition=getattr(term, "definition", None),
|
|
73
|
+
source=getattr(be, "name", self.name),
|
|
74
|
+
)
|
|
75
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mondonorm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Interactively validate and normalise GA4GH phenopacket disease terms against the MONDO ontology
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Provides-Extra: recommended
|
|
8
|
+
Requires-Dist: rapidfuzz>=3.0; extra == "recommended"
|
|
9
|
+
Requires-Dist: rich>=13.0; extra == "recommended"
|
|
10
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mondonorm/cli.py
|
|
5
|
+
mondonorm/mondo.py
|
|
6
|
+
mondonorm/ols.py
|
|
7
|
+
mondonorm/phenopackets.py
|
|
8
|
+
mondonorm/suggest.py
|
|
9
|
+
mondonorm.egg-info/PKG-INFO
|
|
10
|
+
mondonorm.egg-info/SOURCES.txt
|
|
11
|
+
mondonorm.egg-info/dependency_links.txt
|
|
12
|
+
mondonorm.egg-info/entry_points.txt
|
|
13
|
+
mondonorm.egg-info/requires.txt
|
|
14
|
+
mondonorm.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mondonorm
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mondonorm"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Interactively validate and normalise GA4GH phenopacket disease terms against the MONDO ontology"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [] # stdlib-only by default
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
recommended = ["rapidfuzz>=3.0", "rich>=13.0"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
mondonorm = "mondonorm.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["mondonorm"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.package-data]
|
|
22
|
+
mondonorm = ["data/*.obo"]
|