verdy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
verdy/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """VERDY: predict matches for your ENA IDs or free-text terms in the ontologies you choose.
2
+
3
+ Part of VERDANT: Vocabulary Enrichment & Resolution of Diversity Annotations via Neural Technology.
4
+
5
+ Input (a CSV / TSV / Excel file, a text file or a Python list) holds ENA IDs (samples,
6
+ runs, projects) and/or free text (species, habitats, parameters, measurements). ENA IDs
7
+ are looked up in ENA for their organism and environment fields; every name is searched in
8
+ each ontology you choose; out come its exact and most related terms.
9
+
10
+ import verdy
11
+
12
+ matches = verdy.match("my_input.csv", ontologies=["natura2000", "gold", "envo", "ncbitaxon"])
13
+ verdy.save(matches, "my_input.matches.csv") # or .tsv / .sssom.tsv / .json / .xlsx
14
+ verdy.match.unresolved # ENA IDs ENA did not know
15
+ verdy.unmatched("my_input.csv", matches, ["envo"]) # names an ontology found nothing for
16
+
17
+ verdy.ONTOLOGIES # the ready ones; any OLS4 ontology ID works too
18
+ verdy.search("air temperature", "cf") # one name in one ontology, its best terms
19
+ verdy.resolve("SAMD00132352") # what ENA says about an ID
20
+
21
+ From the shell: verdy my_input.csv -o natura2000,gold,envo --out my_input.matches.csv
22
+ """
23
+
24
+ from verdy.match import MATCH_TYPES, match, read_terms, resolve_terms, unmatched
25
+ from verdy.resolve import SAMPLE_FIELDS, resolve
26
+ from verdy.output import save, to_dataframe, to_sssom, to_table
27
+ from verdy.sources import ONTOLOGIES, ontology_info, search
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ predict = match # the same: IDs or names in, predicted matches out
32
+
33
+ __all__ = ["match", "predict", "resolve", "resolve_terms", "SAMPLE_FIELDS", "search", "read_terms", "unmatched", "save", "to_table", "to_sssom", "to_dataframe",
34
+ "ONTOLOGIES", "MATCH_TYPES", "ontology_info", "__version__"]
verdy/__main__.py ADDED
@@ -0,0 +1,60 @@
1
+ """Command line: verdy TERMS -o envo,pato,cf [--column NAME] [--out FILE]
2
+
3
+ verdy my_terms.csv -o envo,ncbitaxon,gbif --out my_terms.matches.csv
4
+ verdy my_terms.csv -o pato,cf,uo # print a table instead
5
+ verdy --list # the ready ontologies
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from verdy.match import match, read_terms, resolve_terms, unmatched
15
+ from verdy.output import save, to_table
16
+ from verdy.sources import ONTOLOGIES
17
+
18
+
19
+ def main(argv=None) -> int:
20
+ ap = argparse.ArgumentParser(prog="verdy", description="Match your terms to the ontologies you choose.")
21
+ ap.add_argument("terms", nargs="?", help="CSV / TSV / Excel with an ID or name column, or a text file with one per line")
22
+ ap.add_argument("-o", "--ontologies", help="comma-separated IDs: see --list; any OLS4 ontology ID works too")
23
+ ap.add_argument("--column", help="the column holding the terms (found by its header otherwise)")
24
+ ap.add_argument("--out", type=Path, help="write here: .csv, .tsv, .sssom.tsv, .json or .xlsx (default: print)")
25
+ ap.add_argument("--per-ontology", type=int, default=3, help="at most this many matches per term and ontology (3)")
26
+ ap.add_argument("--min-score", type=float, default=0.34, help="least word overlap for a related match (0.34)")
27
+ ap.add_argument("--list", action="store_true", help="list the ready ontologies")
28
+ args = ap.parse_args(argv)
29
+ if args.list:
30
+ for k, v in ONTOLOGIES.items():
31
+ print(f"{k:11} {v}")
32
+ return 0
33
+ if not args.terms or not args.ontologies:
34
+ ap.error("give a TERMS file and --ontologies (or --list)")
35
+ try:
36
+ terms = read_terms(args.terms, args.column)
37
+ matches = match(terms, args.ontologies, per_ontology=args.per_ontology,
38
+ min_score=args.min_score,
39
+ progress=lambda d, t: print(f"\r{d} of {t} lookups", end="", file=sys.stderr))
40
+ except ValueError as exc:
41
+ ap.error(str(exc))
42
+ print(file=sys.stderr)
43
+ unresolved = match.unresolved
44
+ if unresolved:
45
+ print(f"nothing found for {len(unresolved)} IDs: {', '.join(unresolved[:20])}"
46
+ + (" ..." if len(unresolved) > 20 else ""), file=sys.stderr)
47
+ if args.out:
48
+ save(matches, args.out)
49
+ print(f"{len(matches)} matches for {len({m['term_id'] for m in matches})} of {len(terms)} inputs -> {args.out}",
50
+ file=sys.stderr)
51
+ else:
52
+ sys.stdout.write(to_table(matches, "\t"))
53
+ for o, missing in unmatched(resolve_terms(terms)[0], matches, args.ontologies).items():
54
+ if missing:
55
+ print(f"nothing in {o} for: {', '.join(missing)}", file=sys.stderr)
56
+ return 0
57
+
58
+
59
+ if __name__ == "__main__":
60
+ sys.exit(main())
verdy/match.py ADDED
@@ -0,0 +1,197 @@
1
+ """Predict matches for your IDs or terms in the ontologies you choose.
2
+
3
+ import verdy
4
+ matches = verdy.match("my_samples.csv", ontologies=["natura2000", "gold", "envo"])
5
+ verdy.save(matches, "my_samples.matches.csv")
6
+
7
+ 1. An ENA ID (sample, run or project) is looked up in ENA for its sample's organism
8
+ (scientific_name), broad_scale_environmental_context, local_environmental_context,
9
+ environmental_medium, ...; any other value is free text and is searched as it is.
10
+ 2. Each name is searched in each chosen ontology on its own:
11
+
12
+ exactMatch a term's label or synonym is the name (plurals and 'sp.' aside)
13
+ closeMatch most of their words are shared (overlap >= 0.6)
14
+ relatedMatch some words are shared (overlap >= ``min_score``)
15
+
16
+ Up to ``per_ontology`` matches per term and ontology; when there is an exact one, only it.
17
+ In the taxonomies (NCBITaxon, GBIF) only exact names count: a near name is another taxon.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import csv
23
+ import io
24
+ import re
25
+ from concurrent.futures import ThreadPoolExecutor
26
+ from pathlib import Path
27
+
28
+ from verdy.resolve import SAMPLE_FIELDS, kind, resolve
29
+ from verdy.sources import TAXONOMIES, ontology_info, query_text, search
30
+
31
+ NAME_COLUMNS = ("name", "label", "term", "parameter", "measurement", "variable", "scientific_name", "scientificname",
32
+ "species", "taxon", "habitat", "biome", "text", "value", "description")
33
+ ID_COLUMNS = ("id", "subject_id", "identifier", "accession", "sample_accession", "run_accession", "study_accession",
34
+ "sample_id", "sample", "key", "source", "code")
35
+ CATEGORY_COLUMNS = ("category", "group", "type", "kind", "field")
36
+ MATCH_TYPES = ("skos:exactMatch", "skos:closeMatch", "skos:relatedMatch")
37
+
38
+
39
+ # --- your terms ---------------------------------------------------------------------
40
+
41
+ def _slug(text: str) -> str:
42
+ return re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_")[:60] or "term"
43
+
44
+
45
+ def _pick(columns, wanted) -> str | None:
46
+ low = {str(c).strip().lower(): c for c in columns}
47
+ return next((low[w] for w in wanted if w in low), None)
48
+
49
+
50
+ def read_terms(data, column: str | None = None) -> list[dict]:
51
+ """``[{"id", "label", "category"}]`` from a CSV / TSV / Excel file, a text file with one
52
+ value per line, a list of strings or dicts, or a pandas DataFrame. The column read is
53
+ given with ``column=``, or found by its header: a name column (name, label, term,
54
+ parameter, measurement, species, ...) if there is one, else an ID column (id, accession,
55
+ sample_accession, run_accession, ...). Repeats once."""
56
+ if hasattr(data, "to_dict") and hasattr(data, "columns"): # pandas DataFrame
57
+ rows = data.fillna("").astype(str).to_dict("records")
58
+ elif isinstance(data, (list, tuple)):
59
+ rows = [r if isinstance(r, dict) else {"name": r} for r in data]
60
+ else:
61
+ path = Path(data)
62
+ if path.suffix.lower() in (".xlsx", ".xls"):
63
+ import pandas as pd
64
+ rows = pd.read_excel(path, dtype=str).fillna("").to_dict("records")
65
+ else:
66
+ text = path.read_text(encoding="utf-8-sig")
67
+ lines = [ln for ln in text.splitlines() if ln.strip() and not ln.startswith("#")]
68
+ delim = "\t" if path.suffix.lower() in (".tsv", ".txt") or text.count("\t") > text.count(",") else ","
69
+ first = lines[0] if lines else ""
70
+ header = _pick(next(csv.reader([first], delimiter=delim)), NAME_COLUMNS + ID_COLUMNS) if lines else None
71
+ if column is None and header is None and (delim not in first or path.suffix.lower() == ".txt"):
72
+ rows = [{"name": ln.strip()} for ln in lines] # one term per line, no header
73
+ else:
74
+ rows = list(csv.DictReader(io.StringIO("\n".join(lines)), delimiter=delim))
75
+ if not rows:
76
+ return []
77
+ cols = list(rows[0])
78
+ id_col, cat_col = _pick(cols, ID_COLUMNS), _pick(cols, CATEGORY_COLUMNS)
79
+ name_col = column or _pick(cols, NAME_COLUMNS) or id_col
80
+ if name_col is None: # the first column with words in it
81
+ name_col = next((c for c in cols if c != id_col and any(re.search(r"[A-Za-z]{3}", str(r.get(c) or "")) for r in rows[:20])), None)
82
+ if name_col is None or name_col not in cols:
83
+ raise ValueError(f"No name column found; give one with column= (columns: {', '.join(map(str, cols))}).")
84
+ out, seen = [], set()
85
+ for r in rows:
86
+ label = str(r.get(name_col) or "").strip()
87
+ if not label or label.lower() == "nan":
88
+ continue
89
+ tid = (str(r.get(id_col) or "").strip() if id_col and id_col != name_col else "") or _slug(label)
90
+ if tid in seen:
91
+ continue
92
+ seen.add(tid)
93
+ out.append({"id": tid, "label": label, "category": (str(r.get(cat_col) or "").strip() or None) if cat_col else None})
94
+ return out
95
+
96
+
97
+ # --- IDs to names --------------------------------------------------------------------
98
+
99
+ def resolve_terms(terms: list[dict], fields=SAMPLE_FIELDS, workers: int = 6) -> tuple[list[dict], list[str]]:
100
+ """Terms whose value is an ID become one term per name found for it (``id`` the ID,
101
+ ``category`` the field(s) it came from); the IDs nothing was found for come second."""
102
+ ids = [t for t in terms if kind(t["label"]) != "name"]
103
+ with ThreadPoolExecutor(max_workers=workers) as pool:
104
+ found = dict(zip((t["label"] for t in ids), pool.map(lambda t: resolve(t["label"], fields), ids)))
105
+ out, unresolved = [], []
106
+ for t in terms:
107
+ if t["label"] not in found:
108
+ out.append(t)
109
+ continue
110
+ names = found[t["label"]]
111
+ if not names:
112
+ unresolved.append(t["label"] + (" (no answer from its source)" if names is None else ""))
113
+ continue
114
+ merged: dict[tuple[str, str], list[str]] = {}
115
+ for field, value in names:
116
+ sample, _, field = field.rpartition(":") # a project's values come per sample
117
+ merged.setdefault((sample or t["label"], value), []).append(field)
118
+ for (tid, value), fs in merged.items():
119
+ out.append({"id": tid, "label": value, "category": "|".join(dict.fromkeys(fs)),
120
+ **({"input": t["label"]} if tid != t["label"] else {})})
121
+ return out, unresolved
122
+
123
+
124
+ # --- matching ---------------------------------------------------------------------------
125
+
126
+ def _ontologies(ontologies) -> list[str]:
127
+ if isinstance(ontologies, str):
128
+ ontologies = re.split(r"[,\s]+", ontologies)
129
+ return list(dict.fromkeys(o.strip().lower() for o in ontologies or [] if o and o.strip()))
130
+
131
+
132
+ def _matches(term: dict, ontology: str, short: str, hits: list[dict], per_ontology: int, min_score: float) -> list[dict]:
133
+ exact = [h for h in hits if h["exact"]]
134
+ if ontology in TAXONOMIES or exact:
135
+ chosen = exact[:1]
136
+ else:
137
+ chosen = [h for h in hits if h["score"] >= min_score][:per_ontology]
138
+ out = []
139
+ for rank, h in enumerate(chosen, 1):
140
+ if h["exact"]:
141
+ kind, conf = "skos:exactMatch", 1.0 if h["same"] else 0.95
142
+ elif h["score"] >= 0.6:
143
+ kind, conf = "skos:closeMatch", round(0.5 + 0.4 * h["score"], 3)
144
+ else:
145
+ kind, conf = "skos:relatedMatch", round(0.3 + 0.4 * h["score"], 3)
146
+ out.append({"term_id": term["id"], "term": term["label"], "category": term.get("category"),
147
+ "match_type": kind, "ontology": short, "match_id": h["id"], "match_curie": h["curie"],
148
+ "match_label": h["label"], "confidence": conf, "word_overlap": h["score"], "rank": rank,
149
+ "definition": h.get("definition"), "searched_for": query_text(term["label"])})
150
+ return out
151
+
152
+
153
+ def match(data, ontologies, *, column: str | None = None, per_ontology: int = 3, min_score: float = 0.34,
154
+ fields=SAMPLE_FIELDS, progress=None, workers: int = 6) -> list[dict]:
155
+ """Matches for every ID or term in ``data`` in each of ``ontologies``: one dict per match
156
+ with term_id (your ID, or the term), term (the name searched), category (the field it
157
+ came from), match_type, ontology, match_id, match_curie, match_label, confidence,
158
+ word_overlap, rank, definition, searched_for.
159
+
160
+ ``ontologies``: a list or a comma-separated string of IDs (``verdy.ONTOLOGIES`` or any
161
+ OLS4 ontology). ``fields``: which ENA sample fields to search for a sample, run or project.
162
+ ``progress(done, total)`` is called as names are looked up. IDs nothing was found for
163
+ are in ``match.unresolved`` after the call."""
164
+ chosen = _ontologies(ontologies)
165
+ if not chosen:
166
+ raise ValueError("Choose at least one ontology, e.g. ontologies=['envo', 'ncbitaxon'].")
167
+ terms = data if isinstance(data, list) and data and isinstance(data[0], dict) and {"id", "label"} <= set(data[0]) \
168
+ else read_terms(data, column)
169
+ terms, match.unresolved = resolve_terms(terms, fields, workers)
170
+ shorts = {}
171
+ for o in chosen:
172
+ info = ontology_info(o)
173
+ if info is None:
174
+ raise ValueError(f"Unknown ontology “{o}”: use one of verdy.ONTOLOGIES or an OLS4 ontology ID.")
175
+ shorts[o] = info["short"]
176
+
177
+ tasks = [(t, o) for o in chosen for t in terms]
178
+ out, done = [], 0
179
+ with ThreadPoolExecutor(max_workers=2 if "wikidata" in chosen else workers) as pool:
180
+ for t, o, hits in pool.map(lambda task: (*task, search(task[0]["label"], task[1])), tasks):
181
+ done += 1
182
+ if progress:
183
+ progress(done, len(tasks))
184
+ if hits:
185
+ out.extend(_matches(t, o, shorts[o], hits, per_ontology, min_score))
186
+ return out
187
+
188
+
189
+ def unmatched(data, matches: list[dict], ontologies) -> dict[str, list[str]]:
190
+ """Per ontology, the terms that got no match in it."""
191
+ terms = data if isinstance(data, list) and data and isinstance(data[0], dict) and "label" in data[0] else read_terms(data)
192
+ got = {(m["term_id"], m["ontology"].lower()) for m in matches}
193
+ out = {}
194
+ for o in _ontologies(ontologies):
195
+ short = (ontology_info(o) or {}).get("short", o).lower()
196
+ out[o] = [t["label"] for t in terms if (t["id"], short) not in got]
197
+ return out
verdy/output.py ADDED
@@ -0,0 +1,87 @@
1
+ """Matches out: a table (CSV / TSV), SSSOM, JSON, or a pandas DataFrame."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+
11
+ COLUMNS = ["term_id", "term", "category", "match_type", "ontology", "match_curie", "match_label", "confidence",
12
+ "word_overlap", "rank", "match_id", "definition", "searched_for"]
13
+ PREFIXES = {"ENVO": "http://purl.obolibrary.org/obo/ENVO_", "NCBITaxon": "http://purl.obolibrary.org/obo/NCBITaxon_",
14
+ "PATO": "http://purl.obolibrary.org/obo/PATO_", "OBA": "http://purl.obolibrary.org/obo/OBA_",
15
+ "UO": "http://purl.obolibrary.org/obo/UO_", "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_",
16
+ "OBI": "http://purl.obolibrary.org/obo/OBI_", "UBERON": "http://purl.obolibrary.org/obo/UBERON_",
17
+ "PO": "http://purl.obolibrary.org/obo/PO_", "GO": "http://purl.obolibrary.org/obo/GO_",
18
+ "GBIF": "https://www.gbif.org/species/", "wikidata": "http://www.wikidata.org/entity/",
19
+ "CF": "http://vocab.nerc.ac.uk/standard_name/", "GOLD": "https://w3id.org/gold.vocab/",
20
+ "GOLD.path": "https://w3id.org/gold.path/", "n2k": "https://w3id.org/verdy/natura2000/"} # local: Natura 2000 has no URIs of its own
21
+
22
+
23
+ def to_table(matches: list[dict], sep: str = ",") -> str:
24
+ buf = io.StringIO()
25
+ w = csv.DictWriter(buf, fieldnames=COLUMNS, delimiter=sep, extrasaction="ignore", lineterminator="\n")
26
+ w.writeheader()
27
+ w.writerows(matches)
28
+ return buf.getvalue()
29
+
30
+
31
+ def _subject(term_id: str) -> str:
32
+ """Your ID as a CURIE: ENA accessions with their registry prefix, other IDs as they are,
33
+ free text under term:."""
34
+ t = str(term_id)
35
+ if re.match(r"^SAM[NED][A-Z]?\d+$", t):
36
+ return "biosample:" + t
37
+ if re.match(r"^([EDS]R[RXSP]|PRJ[EDN][A-Z])\d+$", t):
38
+ return "ena.embl:" + t
39
+ if re.match(r"^[A-Za-z][\w.]*:\S+$", t) or t.startswith("http"):
40
+ return t
41
+ if re.match(r"^Q\d+$", t):
42
+ return "wikidata:" + t
43
+ return "term:" + re.sub(r"\s+", "_", t)
44
+
45
+
46
+ def to_sssom(matches: list[dict], name: str = "matches") -> str:
47
+ """SSSOM TSV: lexical matches with their confidence, subjects as ``term:<term_id>``."""
48
+ used = {m["match_curie"].split(":", 1)[0] for m in matches if ":" in m["match_curie"]}
49
+ header = ["# curie_map:", "# term: https://w3id.org/verdy/term/",
50
+ "# biosample: https://www.ebi.ac.uk/biosamples/samples/", "# ena.embl: https://www.ebi.ac.uk/ena/browser/view/"] + \
51
+ [f"# {p}: {u}" for p, u in PREFIXES.items() if p in used] + [
52
+ "# skos: http://www.w3.org/2004/02/skos/core#", "# semapv: https://w3id.org/semapv/vocab/",
53
+ f"# mapping_set_id: https://w3id.org/verdy/{re.sub(r'[^A-Za-z0-9_.-]+', '-', name)}.sssom.tsv",
54
+ "# mapping_set_description: Lexical matches found by VERDY; not reviewed."]
55
+ fields = ["subject_id", "subject_label", "subject_category", "predicate_id", "object_id", "object_label",
56
+ "mapping_justification", "confidence", "object_source", "mapping_tool", "comment"]
57
+ buf = io.StringIO()
58
+ buf.write("\n".join(header) + "\n")
59
+ w = csv.DictWriter(buf, fieldnames=fields, delimiter="\t", lineterminator="\n")
60
+ w.writeheader()
61
+ for m in matches:
62
+ w.writerow({"subject_id": _subject(m["term_id"]), "subject_label": m["term"],
63
+ "subject_category": m.get("category") or "", "predicate_id": m["match_type"],
64
+ "object_id": m["match_curie"], "object_label": m["match_label"] or "",
65
+ "mapping_justification": "semapv:LexicalMatching", "confidence": m["confidence"],
66
+ "object_source": m["ontology"], "mapping_tool": "verdy",
67
+ "comment": f"word overlap {m['word_overlap']}; rank {m['rank']}"})
68
+ return buf.getvalue()
69
+
70
+
71
+ def to_dataframe(matches: list[dict]):
72
+ import pandas as pd
73
+ return pd.DataFrame(matches, columns=COLUMNS)
74
+
75
+
76
+ def save(matches: list[dict], path) -> Path:
77
+ """By the extension: .csv, .tsv, .sssom.tsv, .json (or .xlsx with pandas)."""
78
+ path = Path(path)
79
+ if path.name.endswith(".sssom.tsv"):
80
+ path.write_text(to_sssom(matches, path.name[: -len(".sssom.tsv")]), encoding="utf-8")
81
+ elif path.suffix.lower() == ".json":
82
+ path.write_text(json.dumps(matches, indent=1, ensure_ascii=False), encoding="utf-8")
83
+ elif path.suffix.lower() == ".xlsx":
84
+ to_dataframe(matches).to_excel(path, index=False)
85
+ else:
86
+ path.write_text(to_table(matches, "\t" if path.suffix.lower() == ".tsv" else ","), encoding="utf-8")
87
+ return path
verdy/resolve.py ADDED
@@ -0,0 +1,85 @@
1
+ """ENA IDs to the names worth searching for (ENA Portal API).
2
+
3
+ resolve("SAMD00132352") -> [("scientific_name", "air metagenome"),
4
+ ("broad_scale_environmental_context", "urban biome"), ...]
5
+ resolve("ERR10002200") -> the run's sample, as above
6
+ resolve("PRJEB54740") -> every sample of the project, as "<sample>:<field>"
7
+
8
+ Samples (SAMN/SAMEA/SAMD..., ERS/SRS/DRS...), runs and experiments (ERR/SRR/DRR...,
9
+ ERX/SRX/DRX...) and projects (PRJEB/PRJNA/PRJDB..., ERP/SRP/DRP...). Anything else is
10
+ free text and is searched as it is.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from verdy import sources
18
+ from verdy.sources import Cache
19
+
20
+ ENA_API = "https://www.ebi.ac.uk/ena/portal/api"
21
+ # ENA sample fields that say what a sample is; ``fields=`` chooses others (see ENA's returnFields).
22
+ SAMPLE_FIELDS = ("scientific_name", "broad_scale_environmental_context", "local_environmental_context",
23
+ "environmental_medium", "environment_biome", "environment_feature", "environment_material",
24
+ "isolation_source", "host_scientific_name", "host_body_site", "tissue_type")
25
+ EMPTY = {"", "missing", "not applicable", "not collected", "not provided", "na", "n/a", "unknown", "none"}
26
+
27
+ SAMPLE = re.compile(r"^(SAM[NED][A-Z]?\d+|[EDS]RS\d+)$", re.I)
28
+ RUN = re.compile(r"^[EDS]R[RX]\d+$", re.I)
29
+ PROJECT = re.compile(r"^(PRJ[EDN][A-Z]\d+|[EDS]RP\d+)$", re.I)
30
+
31
+
32
+ def kind(identifier: str) -> str:
33
+ """What sort of ID this is, or 'name' when it is not one."""
34
+ s = str(identifier or "").strip()
35
+ for name, pattern in (("sample", SAMPLE), ("run", RUN), ("project", PROJECT)):
36
+ if pattern.match(s):
37
+ return name
38
+ return "name"
39
+
40
+
41
+ def ena_sample(accession: str, fields=SAMPLE_FIELDS) -> list[tuple[str, str]] | None:
42
+ """The sample's (field, value) pairs from ENA; [] when ENA does not know it."""
43
+ acc = accession.upper()
44
+ fields = tuple(fields)
45
+
46
+ def fetch():
47
+ d = sources._json(f"{ENA_API}/search", result="sample", format="json", fields=",".join(fields),
48
+ query=f'sample_accession="{acc}" OR secondary_sample_accession="{acc}"')
49
+ return d if d is None else (d[0] if isinstance(d, list) and d else {})
50
+ row = Cache("resolve_ena_sample").get(f"{acc}|{','.join(fields)}", fetch)
51
+ if row is None:
52
+ return None
53
+ return [(f, str(row.get(f)).strip()) for f in fields if str(row.get(f) or "").strip().lower() not in EMPTY]
54
+
55
+
56
+ def ena_samples_of(accession: str) -> list[str] | None:
57
+ """The sample accessions of an ENA run, experiment or project."""
58
+ acc = accession.upper()
59
+
60
+ def fetch():
61
+ d = sources._json(f"{ENA_API}/filereport", accession=acc, result="read_run", fields="sample_accession",
62
+ format="json", limit=0)
63
+ return d if d is None else list(dict.fromkeys(r["sample_accession"] for r in d or [] if r.get("sample_accession")))
64
+ return Cache("resolve_ena_runs").get(acc, fetch)
65
+
66
+
67
+ def resolve(identifier, fields=SAMPLE_FIELDS) -> list[tuple[str, str]] | None:
68
+ """``[(field, name), ...]`` to search for: an ENA ID's sample fields (None when ENA did not
69
+ answer, [] when it does not know the ID), or free text as it is."""
70
+ s = str(identifier or "").strip()
71
+ k = kind(s)
72
+ if k == "name":
73
+ return [("name", s)] if s else []
74
+ if k == "sample":
75
+ return ena_sample(s, fields)
76
+ if k in ("run", "project"):
77
+ samples = ena_samples_of(s)
78
+ if samples is None:
79
+ return None
80
+ out = []
81
+ for acc in samples:
82
+ got = ena_sample(acc, fields)
83
+ if got:
84
+ out += [(f"{acc}:{f}" if k == "project" else f, v) for f, v in got]
85
+ return out
verdy/sources.py ADDED
@@ -0,0 +1,403 @@
1
+ """Where the terms come from, and how a name is searched in each.
2
+
3
+ Ontologies are chosen by ID. The ready ones are in ``ONTOLOGIES``; any other OLS4
4
+ ontology ID works too (https://www.ebi.ac.uk/ols4/ontologies).
5
+
6
+ * OLS4 ontologies (ENVO, NCBITaxon, PATO, OBA, UO, ...): OLS4's exact search on the name as
7
+ written, then its word searches on the name, its singular form and its key words (OLS4
8
+ only returns terms holding every word of a query).
9
+ * ``gbif``: the GBIF Backbone's name matcher.
10
+ * ``wikidata``: Wikidata's item search.
11
+ * ``natura2000``: the Natura 2000 habitat types and species of the EEA's Article 17 report
12
+ (2019-2024), fetched from its public service once and searched locally; by name, or by
13
+ code ('1330 Atlantic salt meadows').
14
+ * ``cf``: the CF Standard Names table (climate and forecast variables), read once and
15
+ searched locally.
16
+
17
+ Every answer is cached on disk (``VERDY_CACHE``, default ``~/.cache/verdy``), so a
18
+ second run on the same names makes no requests.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import re
27
+ import threading
28
+ import time
29
+ import xml.etree.ElementTree as ET
30
+ from pathlib import Path
31
+
32
+ import requests
33
+
34
+ logger = logging.getLogger("verdy")
35
+
36
+ CACHE = Path(os.environ.get("VERDY_CACHE", Path.home() / ".cache" / "verdy"))
37
+ UA = {"User-Agent": "verdy/0.1 (ontology matcher)"}
38
+ OLS = "https://www.ebi.ac.uk/ols4/api"
39
+ CF_TABLE = "https://cfconventions.org/Data/cf-standard-names/current/src/cf-standard-name-table.xml"
40
+
41
+ ONTOLOGIES = {
42
+ # environments, habitats, samples
43
+ "envo": "ENVO: environments, habitats, environmental materials",
44
+ "natura2000": "Natura 2000 habitat types and species (EEA Article 17 lists)",
45
+ "gold": "GOLD ecosystem classification",
46
+ # organisms
47
+ "ncbitaxon": "NCBI Taxonomy",
48
+ "gbif": "GBIF Backbone Taxonomy",
49
+ # parameters and measurements
50
+ "pato": "PATO: qualities (temperature, salinity, colour, ...)",
51
+ "oba": "OBA: biological attributes and traits",
52
+ "cf": "CF Standard Names: climate and forecast variables",
53
+ "uo": "UO: units of measurement",
54
+ "chebi": "ChEBI: chemical entities",
55
+ "obi": "OBI: investigations, assays, instruments",
56
+ # anatomy, plants, processes
57
+ "uberon": "Uberon: anatomy",
58
+ "po": "Plant Ontology",
59
+ "go": "Gene Ontology",
60
+ # general
61
+ "wikidata": "Wikidata",
62
+ }
63
+ NATURA = "natura2000"
64
+ ART17 = "https://bio.discomap.eea.europa.eu/arcgis/rest/services/Article17/ART17_2019_2024_public/MapServer"
65
+ SHORT = {NATURA: "Natura2000", "gbif": "GBIF", "wikidata": "Wikidata", "ncbitaxon": "NCBITaxon", "cf": "CF"}
66
+ TAXONOMIES = {"ncbitaxon", "gbif"} # a near name there is another taxon: exact matches only
67
+ LOCAL = {"gbif", "wikidata", "cf", NATURA}
68
+
69
+ _STOP = {"the", "and", "with", "of", "in", "on", "or", "by", "for", "to", "a", "an", "which", "are", "is", "sp", "spp",
70
+ "cf", "aff", "type", "types", "other", "including", "mainly", "where", "at", "per", "from"}
71
+
72
+
73
+ # --- cache and requests ----------------------------------------------------------
74
+
75
+ class Cache:
76
+ """One JSON file per source; a failed lookup (None) is not kept, so it is tried again."""
77
+ _locks: dict[str, threading.Lock] = {}
78
+
79
+ def __init__(self, name: str):
80
+ self.path = CACHE / f"{name}.json"
81
+ self.lock = Cache._locks.setdefault(str(self.path), threading.Lock())
82
+ try:
83
+ self.data = json.loads(self.path.read_text())
84
+ except (OSError, ValueError):
85
+ self.data = {}
86
+
87
+ def get(self, key, fetch):
88
+ if key in self.data:
89
+ return self.data[key]
90
+ value = fetch()
91
+ if value is None:
92
+ return None
93
+ with self.lock:
94
+ self.data[key] = value
95
+ self.path.parent.mkdir(parents=True, exist_ok=True)
96
+ tmp = self.path.with_name(self.path.name + f".{threading.get_ident()}.tmp")
97
+ tmp.write_text(json.dumps(self.data))
98
+ tmp.replace(self.path)
99
+ return value
100
+
101
+
102
+ def _json(url, **params):
103
+ """GET JSON; {} for 404, None when the service did not answer (after two retries)."""
104
+ for attempt in range(3):
105
+ try:
106
+ r = requests.get(url, params=params, headers=UA, timeout=30)
107
+ if r.status_code == 404:
108
+ return {}
109
+ if r.status_code in (429, 500, 502, 503, 504) and attempt < 2:
110
+ wait = r.headers.get("Retry-After") or ""
111
+ time.sleep(min(10.0, float(wait)) if wait.isdigit() else 2 * (attempt + 1))
112
+ continue
113
+ r.raise_for_status()
114
+ return r.json()
115
+ except requests.Timeout:
116
+ if attempt < 2:
117
+ continue
118
+ logger.warning("No answer from %s", url)
119
+ except (requests.RequestException, ValueError) as exc:
120
+ logger.warning("Lookup failed for %s: %s", url, exc)
121
+ return None
122
+ return None
123
+
124
+
125
+ # --- names and identifiers --------------------------------------------------------
126
+
127
+ def canon(identifier) -> str:
128
+ """One spelling per term, so a CURIE and a URI of the same term compare equal."""
129
+ o = str(identifier or "").strip()
130
+ if m := re.search(r"/obo/([A-Za-z][A-Za-z0-9]*)_(\w+)$", o):
131
+ return f"{m.group(1)}:{m.group(2)}"
132
+ if m := re.search(r"gbif\.org/species/(\d+)", o):
133
+ return f"GBIF:{m.group(1)}"
134
+ if m := re.search(r"wikidata\.org/(?:entity|wiki)/(Q\d+)", o):
135
+ return f"wikidata:{m.group(1)}"
136
+ if m := re.search(r"w3id\.org/gold\.(path|vocab)/([\w.-]+)", o):
137
+ return f"{'GOLD.path' if m.group(1) == 'path' else 'GOLD'}:{m.group(2)}"
138
+ if m := re.search(r"vocab\.nerc\.ac\.uk/standard_name/([\w-]+)", o):
139
+ return f"CF:{m.group(1)}"
140
+ return o
141
+
142
+
143
+ def query_text(label: str) -> str:
144
+ """The name as a search: without a leading code ('1330 Atlantic salt meadows'), a bracketed
145
+ note such as a unit ('air temperature [K]') or underscores ('air_temperature')."""
146
+ t = re.sub(r"^\d{3,}[0-9A-Z]*\s+\*?\s*(?=[A-Za-z])", "", str(label or "")).strip()
147
+ t = re.sub(r"\s*[\(\[][^)\]]*[\)\]]", "", t)
148
+ t = t.replace("_", " ")
149
+ return re.sub(r"\s+", " ", t).strip(" ,;")
150
+
151
+
152
+ def singular(word: str) -> str:
153
+ w = word.lower()
154
+ if len(w) > 4 and w.endswith("ies"):
155
+ return w[:-3] + "y"
156
+ if len(w) > 4 and w.endswith(("sses", "ches", "shes", "xes")):
157
+ return w[:-2]
158
+ if len(w) > 3 and w.endswith("s") and not w.endswith(("ss", "us", "is")):
159
+ return w[:-1]
160
+ return w
161
+
162
+
163
+ def words(text) -> set[str]:
164
+ return {singular(w) for w in re.findall(r"[a-z0-9]+", str(text or "").lower()) if w not in _STOP and len(w) > 1}
165
+
166
+
167
+ def _norm(text) -> str:
168
+ return re.sub(r"[\s_]+", " ", str(text or "").lower()).strip(" .")
169
+
170
+
171
+ def score(name: str, labels: list[str]) -> tuple[float, bool, bool]:
172
+ """How well the best of a term's labels fits the name (word overlap, 0..1); whether one
173
+ has the same words (plurals and 'sp.' aside); whether one is the name as written."""
174
+ nw = words(name)
175
+ # an abbreviation ('AIR' for a chemical) counts only when the name is written the same way
176
+ labels = [lab for lab in labels if lab and not (lab.isupper() and len(lab) <= 8 and lab != name.strip())]
177
+ # each label also without a bracketed part, and a path's last step ('Environmental > Air > Outdoor Air')
178
+ labels = list(dict.fromkeys(v for lab in labels if lab for v in (lab, query_text(lab), lab.split(">")[-1].strip()) if v))
179
+ best, exact, same = 0.0, False, any(_norm(lab) == _norm(name) for lab in labels)
180
+ for lab in labels:
181
+ lw = words(lab)
182
+ if not lw or not nw:
183
+ continue
184
+ if lw == nw:
185
+ best, exact = 1.0, True
186
+ best = max(best, len(nw & lw) / len(nw | lw))
187
+ return best, exact or same, same
188
+
189
+
190
+ def _queries(name: str, taxon: bool) -> list[str]:
191
+ out = [name]
192
+ bare = re.sub(r"\b(spp?|cf|aff)\.?(\s|$)", " ", name).strip()
193
+ if bare and bare != name:
194
+ out.append(bare)
195
+ sing = " ".join(singular(w) for w in name.split())
196
+ if sing.lower() != name.lower():
197
+ out.append(sing)
198
+ if not taxon:
199
+ keys = [singular(w) for w in re.findall(r"[A-Za-z][A-Za-z-]+", name) if w.lower() not in _STOP and len(w) > 2]
200
+ if len(keys) > 2:
201
+ out += [f"{a} {b}" for a, b in zip(keys, keys[1:])][:4]
202
+ if len(keys) > 1:
203
+ out += sorted(set(keys), key=len, reverse=True)[:4]
204
+ seen, uniq = set(), []
205
+ for q in out:
206
+ if q.lower() not in seen:
207
+ seen.add(q.lower())
208
+ uniq.append(q)
209
+ return uniq
210
+
211
+
212
+ # --- the sources ------------------------------------------------------------------
213
+
214
+ def ontology_info(ontology: str) -> dict | None:
215
+ """Title and short name of a source; None when it is neither ready nor in OLS4."""
216
+ o = ontology.strip().lower()
217
+ if o in LOCAL:
218
+ return {"id": o, "title": ONTOLOGIES[o], "short": SHORT[o]}
219
+ if not re.fullmatch(r"[a-z0-9_.-]{1,40}", o):
220
+ return None
221
+
222
+ def fetch():
223
+ d = _json(f"{OLS}/ontologies/{o}")
224
+ return d if not d else {"title": (d.get("config") or {}).get("title"),
225
+ "prefix": (d.get("config") or {}).get("preferredPrefix")}
226
+
227
+ d = Cache("ols_ontologies").get(o, fetch)
228
+ if not d:
229
+ return None
230
+ return {"id": o, "title": d.get("title") or ONTOLOGIES.get(o) or o, "short": SHORT.get(o) or d.get("prefix") or o.upper()}
231
+
232
+
233
+ def _ols(name: str, ontology: str, cache: Cache) -> list[dict] | None:
234
+ taxon = ontology == "ncbitaxon"
235
+ docs, seen, failed = [], set(), False
236
+ for exact, q in [(True, name)] + [(False, q) for q in _queries(name, taxon)]:
237
+ res = cache.get(f"{ontology}|{'=' if exact else ''}{q.lower()}", lambda q=q, exact=exact: _json(
238
+ f"{OLS}/search", q=q, ontology=ontology, type="class", rows=10, exact=str(exact).lower(),
239
+ fieldList="iri,label,obo_id,synonym,description,is_defining_ontology,is_obsolete"))
240
+ if res is None:
241
+ failed = True
242
+ continue
243
+ for d in (res.get("response") or {}).get("docs") or []:
244
+ if d.get("iri") in seen or d.get("is_obsolete"):
245
+ continue
246
+ seen.add(d["iri"])
247
+ docs.append(d)
248
+ if taxon and docs:
249
+ break
250
+ if failed and not docs:
251
+ return None
252
+ if any(d.get("is_defining_ontology") for d in docs): # its own terms, not imported ones;
253
+ docs = [d for d in docs if d.get("is_defining_ontology")] # (some, like GOLD, mark none as its own)
254
+ hits = []
255
+ for d in docs:
256
+ s, exact, same = score(name, [d.get("label") or "", *(d.get("synonym") or [])])
257
+ if s > 0:
258
+ desc = d.get("description")
259
+ hits.append({"id": d["iri"], "curie": canon(d["iri"]) if "gold" in d["iri"] else d.get("obo_id") or canon(d["iri"]),
260
+ "label": d.get("label"),
261
+ "score": round(s, 3), "exact": exact, "same": same,
262
+ "definition": (desc[0] if isinstance(desc, list) and desc else desc) or None})
263
+ return hits
264
+
265
+
266
+ def _gbif(name: str, cache: Cache) -> list[dict] | None:
267
+ name = re.sub(r"\b(spp?|cf|aff)\.?(\s|$)", " ", name).strip() # GBIF reads 'Elphidium sp.' as the genus
268
+ d = cache.get(name.lower(), lambda: _json("https://api.gbif.org/v1/species/match", name=name, verbose="true"))
269
+ if d is None:
270
+ return None
271
+ out = []
272
+ for c in [d, *(d.get("alternatives") or [])]:
273
+ if c.get("usageKey") and c.get("matchType") not in (None, "NONE", "HIGHERRANK"):
274
+ label = c.get("canonicalName") or c.get("scientificName")
275
+ s, exact, same = score(name, [label])
276
+ if not s and c.get("matchType") != "EXACT":
277
+ continue
278
+ out.append({"id": f"https://www.gbif.org/species/{c['usageKey']}", "curie": f"GBIF:{c['usageKey']}",
279
+ "label": label, "score": round(s, 3), "exact": exact or c.get("matchType") == "EXACT",
280
+ "same": same, "definition": " · ".join(x for x in (c.get("rank"), c.get("status")) if x) or None})
281
+ return out
282
+
283
+
284
+ def _wikidata(name: str, cache: Cache) -> list[dict] | None:
285
+ d = cache.get(name.lower(), lambda: _json(
286
+ "https://www.wikidata.org/w/api.php", action="wbsearchentities", search=name, language="en", limit=7,
287
+ format="json", type="item"))
288
+ if d is None:
289
+ return None
290
+ out = []
291
+ for h in d.get("search") or []:
292
+ _, exact, same = score(name, [h.get("label") or "", *(h.get("aliases") or [])]) # an alias can make it exact,
293
+ s = 1.0 if exact else score(name, [h.get("label") or ""])[0] # only the label otherwise
294
+ if s > 0:
295
+ out.append({"id": f"http://www.wikidata.org/entity/{h['id']}", "curie": f"wikidata:{h['id']}",
296
+ "label": h.get("label"), "score": round(s, 3), "exact": exact, "same": same,
297
+ "definition": h.get("description")})
298
+ return out
299
+
300
+
301
+ _cf_terms: list[dict] | None = None
302
+
303
+
304
+ def cf_terms() -> list[dict]:
305
+ """The CF Standard Names table: name, its words as a label, units, description; aliases included."""
306
+ global _cf_terms
307
+ if _cf_terms is None:
308
+ path = CACHE / "cf-standard-name-table.xml"
309
+ if not path.exists():
310
+ r = requests.get(CF_TABLE, headers=UA, timeout=60)
311
+ r.raise_for_status()
312
+ path.parent.mkdir(parents=True, exist_ok=True)
313
+ path.write_bytes(r.content)
314
+ root = ET.fromstring(path.read_bytes())
315
+ terms = {}
316
+ for e in root.iter("entry"):
317
+ sid = e.get("id")
318
+ terms[sid] = {"id": sid, "labels": [sid.replace("_", " ")], "units": (e.findtext("canonical_units") or "").strip(),
319
+ "description": re.sub(r"\s+", " ", e.findtext("description") or "").strip()}
320
+ for a in root.iter("alias"):
321
+ target = (a.findtext("entry_id") or "").strip()
322
+ if target in terms:
323
+ terms[target]["labels"].append(a.get("id").replace("_", " "))
324
+ _cf_terms = list(terms.values())
325
+ return _cf_terms
326
+
327
+
328
+ def _cf(name: str) -> list[dict]:
329
+ out = []
330
+ for t in cf_terms():
331
+ s, exact, same = score(name, t["labels"])
332
+ if s >= 0.25:
333
+ out.append({"id": f"http://vocab.nerc.ac.uk/standard_name/{t['id']}/", "curie": f"CF:{t['id']}",
334
+ "label": t["id"], "score": round(s, 3), "exact": exact, "same": same,
335
+ "definition": (f"[{t['units']}] " if t["units"] else "") + t["description"][:200]})
336
+ return out
337
+
338
+
339
+ def natura_terms() -> list[dict]:
340
+ """Natura 2000 habitat types and species, with codes, from the Article 17 layers (cached)."""
341
+ def fetch(layer, fields):
342
+ d = _json(f"{ART17}/{layer}/query", where="1=1", outFields=",".join(fields), returnDistinctValues="true",
343
+ returnGeometry="false", f="json")
344
+ return None if d is None or "features" not in d else [f["attributes"] for f in d["features"]]
345
+
346
+ def build():
347
+ hab = fetch(5, ["reference_habitatlist.habitat_code", "reference_habitatlist.label"])
348
+ spe = fetch(6, ["reference_specieslist.species_code", "reference_specieslist.species_name",
349
+ "reference_specieslist.taxonomical_group"])
350
+ if hab is None or spe is None:
351
+ return None
352
+ out = [{"code": a["reference_habitatlist.habitat_code"], "label": a["reference_habitatlist.label"],
353
+ "category": "habitat"} for a in hab if a.get("reference_habitatlist.habitat_code")]
354
+ out += [{"code": a["reference_specieslist.species_code"], "label": a["reference_specieslist.species_name"],
355
+ "category": "species", "group": a.get("reference_specieslist.taxonomical_group")}
356
+ for a in spe if a.get("reference_specieslist.species_name")]
357
+ return out
358
+ terms = Cache("natura2000").get("art17_2019_2024", build)
359
+ if terms is None:
360
+ raise RuntimeError("The EEA Article 17 service did not answer; try again later.")
361
+ return terms
362
+
363
+
364
+ _CODE = re.compile(r"^(?:n2k:|natura2000:)?(\d[0-9A-Z]{3})\b", re.I)
365
+
366
+
367
+ def _natura(name: str, raw: str) -> list[dict]:
368
+ terms = natura_terms()
369
+ hit = lambda t, s, exact, same, note=None: {
370
+ "id": f"n2k:{t['code']}", "curie": f"n2k:{t['code']}", "label": f"{t['code']} {t['label']}" if t["category"] == "habitat" else t["label"],
371
+ "score": round(s, 3), "exact": exact, "same": same,
372
+ "definition": note or ("Annex I habitat type" if t["category"] == "habitat" else f"species · {t.get('group') or ''}")}
373
+ if (m := _CODE.match(raw.strip())) and (t := next((t for t in terms if t["code"] == m.group(1).upper()
374
+ and t["category"] == "habitat"), None)):
375
+ return [hit(t, 1.0, True, True, "matched by its Annex I code")]
376
+ out = []
377
+ for t in terms:
378
+ s, exact, same = score(name, [t["label"]])
379
+ if s >= 0.25:
380
+ out.append(hit(t, s, exact, same))
381
+ return out
382
+
383
+
384
+ def search(name: str, ontology: str, top: int = 5) -> list[dict] | None:
385
+ """The ontology's best terms for ``name``, best first (exact names first, then by word
386
+ overlap, then in the source's own order); None if the service did not answer."""
387
+ q = query_text(name)
388
+ if not q:
389
+ return []
390
+ if ontology == "gbif":
391
+ hits = _gbif(q, Cache("search_gbif"))
392
+ elif ontology == "wikidata":
393
+ hits = _wikidata(q, Cache("search_wikidata"))
394
+ elif ontology == "cf":
395
+ hits = _cf(q)
396
+ elif ontology == NATURA:
397
+ hits = _natura(q, name)
398
+ else:
399
+ hits = _ols(q, ontology, Cache(f"search_{ontology}"))
400
+ if hits is None:
401
+ return None
402
+ hits.sort(key=lambda h: (-h["same"], -h["exact"], -h["score"]))
403
+ return hits[:top]
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: verdy
3
+ Version: 0.1.0
4
+ Summary: VERDY: match your own terms (species, habitats, parameters, measurements) to the ontologies you choose: ENVO, NCBITaxon, GBIF, PATO, CF, Wikidata, any OLS4 ontology.
5
+ Author: Jovana Dobreva
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dobrevajovana/verdy
8
+ Project-URL: Source, https://github.com/dobrevajovana/verdy
9
+ Project-URL: Issues, https://github.com/dobrevajovana/verdy/issues
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: requests>=2.31
14
+ Provides-Extra: excel
15
+ Requires-Dist: pandas>=2.0; extra == "excel"
16
+ Requires-Dist: openpyxl>=3.1; extra == "excel"
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7; extra == "test"
19
+ Dynamic: license-file
20
+
21
+ <p align="center"><img src="https://raw.githubusercontent.com/dobrevajovana/verdy/main/logo.png" width="200" alt="VERDY logo: a green forest spirit with a flower crown, braided leafy hair and berry nodes linked by vines"></p>
22
+
23
+ # VERDY
24
+
25
+ [![Python package](https://github.com/dobrevajovana/verdy/actions/workflows/python-package.yml/badge.svg)](https://github.com/dobrevajovana/verdy/actions/workflows/python-package.yml)
26
+
27
+ *Part of **VERDANT**: **V**ocabulary **E**nrichment & **R**esolution of **D**iversity **A**nnotations via **N**eural **T**echnology
28
+ (BioHackathon Europe 2026).*
29
+
30
+ **Meet VERDY, your warrior for ontology matching.** Give it your ENA IDs or free-text terms, choose the ontologies, and it predicts the matching terms in each.
31
+
32
+ 1. **Input:** a file (or Python list) of ENA IDs and/or free text. The IDs can be samples, runs or projects. The free text can be species, habitats, parameters or measurement names.
33
+ 2. **ENA lookup:** each ENA ID is looked up in [ENA](https://www.ebi.ac.uk/ena/portal/api) for its sample's organism and environment fields (`scientific_name`, `broad_scale_environmental_context`, `local_environmental_context`, `environmental_medium`, ...). Free text is used as it is.
34
+ 3. **Search:** every name is searched in each ontology you choose (for example `natura2000`, `gold`, `envo`).
35
+ 4. **Output:** a file with the exact match, or the most closely related terms, per name and ontology.
36
+
37
+ ```python
38
+ import verdy
39
+
40
+ matches = verdy.match("my_input.csv", ontologies=["natura2000", "gold", "envo", "ncbitaxon"])
41
+ verdy.save(matches, "my_input.matches.csv") # .csv .tsv .sssom.tsv .json .xlsx
42
+ ```
43
+
44
+ ```
45
+ verdy my_input.csv -o natura2000,gold,envo --out my_input.matches.csv
46
+ ```
47
+
48
+ ## Install
49
+
50
+ ```
51
+ pip install git+https://github.com/dobrevajovana/verdy.git
52
+ pip install "verdy[excel] @ git+https://github.com/dobrevajovana/verdy.git" # to read and write Excel
53
+ ```
54
+
55
+ Python 3.10 or newer; the only dependency is `requests`.
56
+
57
+ ## Input
58
+
59
+ `verdy.match` takes a CSV / TSV / Excel file, a text file with one value per line, a Python list or a pandas DataFrame.
60
+ The column is found by its header (`name`, `term`, `parameter`, `species`, ..., or `id`, `accession`, `sample_accession`, `run_accession`)
61
+ or given with `column="..."`. One file can mix both kinds of value:
62
+
63
+ | value | what happens |
64
+ |---|---|
65
+ | `SAMD00132352`, `SAMEA...`, `SAMN...`, `ERS...` | an ENA sample: its fields are looked up in ENA and each value is searched |
66
+ | `ERR9966617`, `SRR...`, `ERX...` | a run or experiment: its sample, as above |
67
+ | `PRJEB54740`, `PRJNA...` | a project: all its samples, each row under the sample's own accession |
68
+ | anything else | free text, searched as it is (units in brackets, underscores and a leading code are set aside) |
69
+
70
+ `fields=` chooses other ENA sample fields to search (see ENA's `returnFields?result=sample`).
71
+ After a run, `verdy.match.unresolved` lists the IDs ENA did not know.
72
+
73
+ ## The ontologies
74
+
75
+ `verdy.ONTOLOGIES` (or `verdy --list`):
76
+
77
+ | ID | What | For |
78
+ |---|---|---|
79
+ | `natura2000` | Natura 2000 habitat types and species (EEA Article 17 lists, fetched live) | habitats by name or code, protected species |
80
+ | `envo` | Environment Ontology | habitats, environments, materials, environmental measurements |
81
+ | `gold` | GOLD ecosystem classification | sample ecosystems |
82
+ | `ncbitaxon` | NCBI Taxonomy | organisms |
83
+ | `gbif` | GBIF Backbone Taxonomy | organisms |
84
+ | `pato` | Phenotype And Trait Ontology | qualities: temperature, salinity, humidity, ... |
85
+ | `oba` | Ontology of Biological Attributes | traits and measurable attributes |
86
+ | `cf` | CF Standard Names | climate, weather and ocean variables |
87
+ | `uo` | Units of measurement | units |
88
+ | `chebi` | Chemical Entities of Biological Interest | chemicals |
89
+ | `obi` | Ontology for Biomedical Investigations | assays, instruments, protocols |
90
+ | `uberon`, `po`, `go` | anatomy, plants, biological processes | |
91
+ | `wikidata` | Wikidata | anything with a name |
92
+
93
+ **Any other ontology in [OLS4](https://www.ebi.ac.uk/ols4/ontologies) works as well:** pass its ID
94
+ (for example `foodon`, `pco` or `ms`).
95
+
96
+ ## What comes out
97
+
98
+ One row per match:
99
+
100
+ | column | |
101
+ |---|---|
102
+ | `term_id`, `term`, `category` | your term |
103
+ | `match_type` | `skos:exactMatch`: a label or synonym is the term (plurals and `sp.` aside) · `skos:closeMatch`: most words shared · `skos:relatedMatch`: some words shared |
104
+ | `ontology`, `match_curie`, `match_id`, `match_label` | the matched term |
105
+ | `confidence` | 1.0 exact as written, 0.95 exact up to plurals, then by word overlap |
106
+ | `word_overlap`, `rank` | how many words they share (0..1), and the place among this term's matches |
107
+ | `definition` | the term's definition (units first, for CF) |
108
+ | `searched_for` | the term after cleaning |
109
+
110
+ How the search works:
111
+ * At most `per_ontology` matches (default 3) per term and ontology. When there is an exact match, only that one is given.
112
+ * In the taxonomies (NCBITaxon, GBIF), only exact names count, because a near name is another taxon.
113
+ * `verdy.unmatched(terms, matches, ontologies)` lists, per ontology, the terms it found nothing for.
114
+
115
+ Every answer (ENA, OLS4, GBIF, Wikidata, the CF table, the Natura 2000 lists) is cached in `~/.cache/verdy` (or `VERDY_CACHE`), so a second run makes no requests.
116
+
117
+ ## Example
118
+
119
+ `python examples/predict.py` runs [examples/input.csv](examples/input.csv), which has three ENA accessions and four free-text terms, against
120
+ Natura 2000, GOLD, ENVO, NCBITaxon, PATO and CF. It writes [examples/input.matches.csv](examples/input.matches.csv). Part of the result:
121
+
122
+ | input | name searched (from) | ontology | match |
123
+ |---|---|---|---|
124
+ | SAMD00132352 | urban biome (broad_scale_environmental_context) | ENVO | exactMatch ENVO:01000249 urban biome |
125
+ | SAMD00132352 | air (local_environmental_context, environmental_medium) | ENVO · GOLD | exactMatch ENVO:00002005 air · GOLD:Air |
126
+ | SAMD00132352 | air metagenome (scientific_name) | NCBITaxon | exactMatch NCBITaxon:655179 |
127
+ | ERR9966617 | temperate forest | ENVO | exactMatch ENVO:01001805 temperate forest |
128
+ | ERR9966617 | mountain | ENVO | exactMatch ENVO:00000081 mountain |
129
+ | SAMEA110429224 | Human (host_scientific_name) | NCBITaxon | exactMatch NCBITaxon:9606 Homo sapiens |
130
+ | Lynx lynx | | Natura 2000 · NCBITaxon | exactMatch n2k:1361 · NCBITaxon:13125 |
131
+ | Atlantic salt meadows | | Natura 2000 | exactMatch n2k:1330 |
132
+ | air_temperature | | CF · ENVO | exactMatch CF:air_temperature · ENVO:09200001 temperature of air |
133
+ | salinity | | PATO | exactMatch PATO:0085001 |
134
+
135
+ ## One term at a time
136
+
137
+ ```python
138
+ verdy.search("cloud liquid water content", "cf") # the source's best terms, best first
139
+ verdy.resolve("SAMD00132352") # what ENA says about an ID
140
+ verdy.read_terms("my_input.csv") # what VERDY read from your file
141
+ verdy.to_dataframe(matches) # pandas
142
+ ```
143
+
144
+ ## Tests
145
+
146
+ ```
147
+ pip install -e ".[test]" && python -m pytest -q
148
+ ```
149
+
150
+ The tests stub every web service, so they run offline.
151
+
152
+ ## Licence
153
+
154
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ verdy/__init__.py,sha256=r1pHqjoFqODwZQ_DbRQWJQ-UIC7MVqY3llqz9VVCK0g,1845
2
+ verdy/__main__.py,sha256=l6FmcAIP0h3bmuxSlTmaKCe-hIE17s_cW72e-tprQQ4,2829
3
+ verdy/match.py,sha256=8vl5fn6qBnF1CgKjk8LQPCw23idj20AxwnxCqEK9tcc,10199
4
+ verdy/output.py,sha256=tu2GYfzHDSk4Vwzj1WZ6Q7jqlIK3IlqX7ZkgQYkrM-Y,4612
5
+ verdy/resolve.py,sha256=vZUStrmmlHXH5TSvOlet5eBKCEY7uKMfq4Hnszpe51E,3733
6
+ verdy/sources.py,sha256=sNG5WiF_fRQeZEV_jjW8YC3CTjH86_fB_2vpGfZV1DQ,17818
7
+ verdy-0.1.0.dist-info/licenses/LICENSE,sha256=OuM4HpQk9KXk2ynEb-poNG1CsbY-bLTJEjXVv0JwCsk,1071
8
+ verdy-0.1.0.dist-info/METADATA,sha256=qJup4i4DOzi95RhUGt1q0i_z70ntgRTNY_U6sId7wXM,7820
9
+ verdy-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ verdy-0.1.0.dist-info/entry_points.txt,sha256=rXCx9wGEBZ1zxF9f4J0DCnvPgLLhzKA9lXe1H_aUf1c,46
11
+ verdy-0.1.0.dist-info/top_level.txt,sha256=7oY2YvwyxHSA8l8KA1ULphZXZnYQ6wi3ls2z6zm3w7Q,6
12
+ verdy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ verdy = verdy.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jovana Dobreva
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 @@
1
+ verdy