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.
@@ -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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ mondonorm = mondonorm.cli:main
@@ -0,0 +1,4 @@
1
+
2
+ [recommended]
3
+ rapidfuzz>=3.0
4
+ rich>=13.0
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+