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 +34 -0
- verdy/__main__.py +60 -0
- verdy/match.py +197 -0
- verdy/output.py +87 -0
- verdy/resolve.py +85 -0
- verdy/sources.py +403 -0
- verdy-0.1.0.dist-info/METADATA +154 -0
- verdy-0.1.0.dist-info/RECORD +12 -0
- verdy-0.1.0.dist-info/WHEEL +5 -0
- verdy-0.1.0.dist-info/entry_points.txt +2 -0
- verdy-0.1.0.dist-info/licenses/LICENSE +21 -0
- verdy-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
[](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,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
|