diff --git a/pyproject.toml b/pyproject.toml index c5fbd4ea..80f4ba02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,3 +32,6 @@ packages = ["src"] # (including node_modules) during collection. testpaths = ["tests"] timeout = 300 +markers = [ + "unit: unit tests that do not require network access", +] diff --git a/src/babel_validation/assertions/README.md b/src/babel_validation/assertions/README.md new file mode 100644 index 00000000..65929d1e --- /dev/null +++ b/src/babel_validation/assertions/README.md @@ -0,0 +1,243 @@ + + +# BabelTest Assertion Types + +This package defines the assertion types that can be embedded in GitHub issue bodies and evaluated against the NodeNorm and NameRes services. + +## Embedding Tests in Issues + +Two syntaxes are supported: + +**Wiki syntax** (one assertion per line): +``` +{{BabelTest|AssertionType|param1|param2|...}} +``` + +**YAML syntax** (multiple assertions, multiple param sets): +```` +```yaml +babel_tests: + AssertionType: + - param1 + - [param1, param2] +``` +```` + +Assertion names are case-insensitive, as is the `{{BabelTest|...}}` marker itself. + +## Param Sets + +Each assertion can be invoked with one or more **param sets** — independent groups of +parameters that are each evaluated separately. + +- **Wiki syntax** — each `{{BabelTest|...}}` line is one param set. +- **YAML syntax** — each list entry under an assertion key is one param set; a bare string + is a single-element param set, a YAML list is a multi-element param set. + +The meaning of each element in a param set depends on the assertion type (see below). +For most assertions the elements are CURIEs; for `HasLabel` the second element is a +label string; for `ResolvesWithType` the first element is a Biolink type. + +--- + +## NodeNorm Assertions + +These assertions test the [NodeNorm](https://nodenorm.transltr.io/docs) service. + +### Resolves + +**Applies to:** NodeNorm + +Each CURIE in each param_set must resolve to a non-null result in NodeNorm. + +**Parameters:** One or more CURIEs per param_set. + +**Wiki syntax:** +``` +{{BabelTest|Resolves|CHEBI:15365}} +{{BabelTest|Resolves|MONDO:0005015|DOID:9351}} +``` + +**YAML syntax:** +```yaml +babel_tests: + Resolves: + - CHEBI:15365 + - [MONDO:0005015, DOID:9351] +``` + +--- + +### DoesNotResolve + +**Applies to:** NodeNorm + +Each CURIE in each param_set must fail to resolve (return null) in NodeNorm. Use this to confirm that an identifier is intentionally not normalizable. + +**Parameters:** One or more CURIEs per param_set. + +**Wiki syntax:** +``` +{{BabelTest|DoesNotResolve|FAKENS:99999}} +``` + +**YAML syntax:** +```yaml +babel_tests: + DoesNotResolve: + - FAKENS:99999 +``` + +--- + +### ResolvesWith + +**Applies to:** NodeNorm + +All CURIEs within each param_set must resolve to the identical normalized result. Use this to assert that two identifiers are equivalent. + +**Parameters:** Two or more CURIEs per param_set. All must resolve to the same result. + +**Wiki syntax:** +``` +{{BabelTest|ResolvesWith|CHEBI:15365|PUBCHEM.COMPOUND:1}} +``` + +**YAML syntax:** +```yaml +babel_tests: + ResolvesWith: + - [CHEBI:15365, PUBCHEM.COMPOUND:1] + - [MONDO:0005015, DOID:9351] +``` + +--- + +### DoesNotResolveWith + +**Applies to:** NodeNorm + +The CURIEs within each param_set must NOT all resolve to the same normalized result. Use this to assert that two identifiers are intentionally distinct entities. + +**Parameters:** Two or more CURIEs per param_set. They must not all resolve to the same result. + +**Wiki syntax:** +``` +{{BabelTest|DoesNotResolveWith|CHEBI:15365|CHEBI:16856}} +``` + +**YAML syntax:** +```yaml +babel_tests: + DoesNotResolveWith: + - [CHEBI:15365, CHEBI:16856] +``` + +--- + +### HasLabel + +**Applies to:** NodeNorm + +The CURIE must resolve in NodeNorm and its primary label (id.label) must match the expected label exactly (case-sensitive). + +**Parameters:** Exactly two elements per param_set: a CURIE, then the expected label string. + +**Wiki syntax:** +``` +{{BabelTest|HasLabel|CHEBI:15365|aspirin}} +``` + +**YAML syntax:** +```yaml +babel_tests: + HasLabel: + - [CHEBI:15365, aspirin] +``` + +--- + +### ResolvesWithType + +**Applies to:** NodeNorm + +Each param_set must have at least two elements: the first is the expected Biolink type (e.g. 'biolink:Gene'), and the remainder are CURIEs that must resolve with that type. + +**Parameters:** Each param_set: first element is the expected Biolink type (e.g. `biolink:Gene`), remaining elements are CURIEs. + +**Wiki syntax:** +``` +{{BabelTest|ResolvesWithType|biolink:Gene|NCBIGene:1}} +``` + +**YAML syntax:** +```yaml +babel_tests: + ResolvesWithType: + - [biolink:Gene, NCBIGene:1, HGNC:5] +``` + +--- + +## NameRes Assertions + +These assertions test the [NameRes](https://name-lookup.transltr.io/docs) service. + +### SearchByName + +**Applies to:** NameRes + +Each param_set must have exactly two elements: a search query string and an expected CURIE. The test passes if the CURIE's normalized identifier appears within the top N results (default N=5) when NameRes looks up the search query. + +**Parameters:** Each param_set: the **search query string** and the **expected CURIE**. The CURIE is normalized via NodeNorm before matching. + +**Wiki syntax:** +``` +{{BabelTest|SearchByName|water|CHEBI:15377}} +``` + +**YAML syntax:** +```yaml +babel_tests: + SearchByName: + - [water, CHEBI:15377] + - [diabetes, MONDO:0005015] +``` + +--- + +## Special Assertions + +### Needed + +**Applies to:** NodeNorm and NameRes + +Marks an issue as needing a test — always fails as a reminder to add real assertions. + +**Wiki syntax:** +``` +{{BabelTest|Needed}} +``` + +**YAML syntax:** +```yaml +babel_tests: + Needed: + - placeholder +``` + +--- + +## Adding a New Assertion Type + +1. Choose the right module: + - `nodenorm.py` — for NodeNorm-only assertions (subclass `NodeNormTest`, override `test_param_set`) + - `nameres.py` — for NameRes-only assertions (subclass `NameResTest`, override `test_param_set`) + - `common.py` — for assertions that apply to both services (subclass `AssertionHandler`, override `test_with_nodenorm` and/or `test_with_nameres`) + +2. Define the class with `NAME`, `DESCRIPTION`, `PARAMETERS`, `WIKI_EXAMPLES`, `YAML_PARAMS`, and `test_param_set()` (or both `test_with_*` methods for `AssertionHandler` subclasses). + +3. Import it in `__init__.py` and add an instance to `ASSERTION_HANDLERS`. + +4. Run `uv run python -m src.babel_validation.assertions.gen_docs` to regenerate `README.md`. diff --git a/src/babel_validation/assertions/__init__.py b/src/babel_validation/assertions/__init__.py new file mode 100644 index 00000000..ae82b4c1 --- /dev/null +++ b/src/babel_validation/assertions/__init__.py @@ -0,0 +1,171 @@ +""" +babel_validation.assertions +=========================== + +This package defines the assertion types that can be embedded in GitHub issue bodies +and evaluated against the NodeNorm and NameRes services. + +Supported assertion types are registered in ASSERTION_HANDLERS. To see everything +that is currently supported, scan that dict or read assertions/README.md (auto-generated). + +Adding a new assertion type +--------------------------- +1. Create a subclass of NodeNormTest or NameResTest (or AssertionHandler for both) + in the appropriate module (nodenorm.py, nameres.py, or common.py). +2. Set NAME and DESCRIPTION class attributes. +3. Set PARAMETERS, WIKI_EXAMPLES, and YAML_PARAMS class attributes for documentation. +4. Override test_param_set(). +5. Import it here and add an instance to ASSERTION_HANDLERS. +6. Run `uv run python -m src.babel_validation.assertions.gen_docs` to regenerate README.md. +""" + +import re +from typing import Iterator + +from src.babel_validation.core.testrow import TestResult, TestStatus + + +class AssertionHandler: + """Base class for all BabelTest assertion handlers.""" + NAME: str # lowercase assertion name as used in issue bodies + DESCRIPTION: str # one-line human-readable description + + def passed(self, message: str) -> TestResult: + return TestResult(status=TestStatus.Passed, message=message) + + def failed(self, message: str) -> TestResult: + return TestResult(status=TestStatus.Failed, message=message) + + def test_with_nodenorm(self, param_sets: list[list[str]], nodenorm, + label: str = "") -> Iterator[TestResult]: + """Evaluate this assertion against NodeNorm. Returns nothing if not applicable.""" + return iter([]) + + def test_with_nameres(self, param_sets: list[list[str]], nodenorm, nameres, + pass_if_found_in_top: int = 5, + label: str = "") -> Iterator[TestResult]: + """Evaluate this assertion against NameRes. Returns nothing if not applicable.""" + return iter([]) + + +class NodeNormTest(AssertionHandler): + """Base class for assertions that test NodeNorm. + + Subclasses implement test_param_set() instead of test_with_nodenorm(). + """ + + _CURIE_RE = re.compile(r'^[A-Za-z][A-Za-z0-9._-]*:[^\s]+$') + + def curie_params(self, params: list[str]) -> list[str]: + """Return the subset of params that are CURIEs (for prewarming and validation). + Default: all params are CURIEs. Subclasses override when some params are non-CURIEs.""" + return params + + def test_with_nodenorm(self, param_sets: list[list[str]], nodenorm, + label: str = "") -> Iterator[TestResult]: + if not param_sets: + yield self.failed(f"No parameters provided in {label}") + return + # Validate each param_set up front so malformed CURIEs are never sent to + # NodeNorm — not even in the cache-warming call below. + failures: dict[int, TestResult] = {} + for index, params in enumerate(param_sets): + if not params: + failures[index] = self.failed(f"No parameters in param_set {index} in {label}") + continue + invalid = [c for c in self.curie_params(params) if not self._CURIE_RE.match(c)] + if invalid: + failures[index] = self.failed( + f"Malformed CURIE(s) {invalid} in param_set {index} in {label}: " + f"expected format PREFIX:LOCAL_ID (e.g. CHEBI:15365)" + ) + # warm the cache only for params that are CURIEs (deduplicated); skip if empty + # (normalize_curies raises ValueError on an empty list) + curies_to_warm = list({ + p + for index, params in enumerate(param_sets) + if index not in failures + for p in self.curie_params(params) + }) + if curies_to_warm: + nodenorm.normalize_curies(curies_to_warm) + results = [] + for index, params in enumerate(param_sets): + if index in failures: + results.append(failures[index]) + continue + results.extend(self.test_param_set(params, nodenorm, label)) + if not results: + yield self.failed(f"No test results returned in {label}") + return + yield from results + + def test_param_set(self, params: list[str], nodenorm, label: str = "") -> Iterator[TestResult]: + """Override this to implement the assertion. Called once per param_set.""" + raise NotImplementedError + + @staticmethod + def first_type(result: dict) -> str: + """First Biolink type of a resolved node, or a placeholder if the node has none. + + NodeNorm normally returns a non-empty `type` list, but guard against an empty + (or missing) one so message formatting never raises IndexError/KeyError.""" + types = result.get('type') or [] + return types[0] if types else 'unknown type' + + def resolved_message(self, curie: str, result: dict, nodenorm) -> str: + """Standard pass-message when a CURIE resolves.""" + return (f"Resolved {curie} to {result['id']['identifier']} " + f"({self.first_type(result)}, \"{result['id'].get('label', '')}\") " + f"with NodeNormalization service {nodenorm}") + + +class NameResTest(AssertionHandler): + """Base class for assertions that test NameRes. + + Subclasses implement test_param_set() instead of test_with_nameres(). + """ + + def test_with_nameres(self, param_sets: list[list[str]], nodenorm, nameres, + pass_if_found_in_top: int = 5, + label: str = "") -> Iterator[TestResult]: + if not param_sets: + yield self.failed(f"No parameters provided in {label}") + return + results = [] + for index, params in enumerate(param_sets): + if not params: + results.append(self.failed(f"No parameters in param_set {index} in {label}")) + continue + results.extend(self.test_param_set(params, nodenorm, nameres, pass_if_found_in_top, label)) + if not results: + yield self.failed(f"No test results returned in {label}") + return + yield from results + + def test_param_set(self, params: list[str], nodenorm, nameres, + pass_if_found_in_top: int, label: str = "") -> Iterator[TestResult]: + """Override this to implement the assertion. Called once per param_set.""" + raise NotImplementedError + + +# Registry — import submodules after base classes are defined to avoid circular imports. +from src.babel_validation.assertions.nodenorm import ( # noqa: E402 + ResolvesHandler, DoesNotResolveHandler, ResolvesWithHandler, + ResolvesWithTypeHandler, DoesNotResolveWithHandler, HasLabelHandler, +) +from src.babel_validation.assertions.nameres import SearchByNameHandler # noqa: E402 +from src.babel_validation.assertions.common import NeededHandler # noqa: E402 + +ASSERTION_HANDLERS: dict[str, AssertionHandler] = { + h.NAME: h for h in [ + ResolvesHandler(), + DoesNotResolveHandler(), + ResolvesWithHandler(), + DoesNotResolveWithHandler(), + HasLabelHandler(), + ResolvesWithTypeHandler(), + SearchByNameHandler(), + NeededHandler(), + ] +} diff --git a/src/babel_validation/assertions/common.py b/src/babel_validation/assertions/common.py new file mode 100644 index 00000000..ac12daca --- /dev/null +++ b/src/babel_validation/assertions/common.py @@ -0,0 +1,16 @@ +from src.babel_validation.assertions import AssertionHandler + + +class NeededHandler(AssertionHandler): + """Placeholder assertion indicating that a test still needs to be written for this issue.""" + NAME = "needed" + DESCRIPTION = "Marks an issue as needing a test — always fails as a reminder to add real assertions." + PARAMETERS = "" + WIKI_EXAMPLES = ["{{BabelTest|Needed}}"] + YAML_PARAMS = " - placeholder" + + def test_with_nodenorm(self, param_sets, nodenorm, label=""): + yield self.failed("Test needed for issue") + + def test_with_nameres(self, param_sets, nodenorm, nameres, pass_if_found_in_top=5, label=""): + yield self.failed("Test needed for issue") diff --git a/src/babel_validation/assertions/gen_docs.py b/src/babel_validation/assertions/gen_docs.py new file mode 100644 index 00000000..c5a77a43 --- /dev/null +++ b/src/babel_validation/assertions/gen_docs.py @@ -0,0 +1,146 @@ +"""Generate assertions/README.md from handler class attributes. + +Run: + uv run python -m src.babel_validation.assertions.gen_docs +""" + +from pathlib import Path + +from src.babel_validation.assertions import ( + ASSERTION_HANDLERS, AssertionHandler, NodeNormTest, NameResTest, +) + +README_PATH = Path(__file__).parent / "README.md" + +INTRO = """\ + + +# BabelTest Assertion Types + +This package defines the assertion types that can be embedded in GitHub issue bodies and evaluated against the NodeNorm and NameRes services. + +## Embedding Tests in Issues + +Two syntaxes are supported: + +**Wiki syntax** (one assertion per line): +``` +{{BabelTest|AssertionType|param1|param2|...}} +``` + +**YAML syntax** (multiple assertions, multiple param sets): +```` +```yaml +babel_tests: + AssertionType: + - param1 + - [param1, param2] +``` +```` + +Assertion names are case-insensitive, as is the `{{BabelTest|...}}` marker itself. + +## Param Sets + +Each assertion can be invoked with one or more **param sets** — independent groups of +parameters that are each evaluated separately. + +- **Wiki syntax** — each `{{BabelTest|...}}` line is one param set. +- **YAML syntax** — each list entry under an assertion key is one param set; a bare string + is a single-element param set, a YAML list is a multi-element param set. + +The meaning of each element in a param set depends on the assertion type (see below). +For most assertions the elements are CURIEs; for `HasLabel` the second element is a +label string; for `ResolvesWithType` the first element is a Biolink type. + +--- +""" + +ADDING_NEW = """\ +## Adding a New Assertion Type + +1. Choose the right module: + - `nodenorm.py` — for NodeNorm-only assertions (subclass `NodeNormTest`, override `test_param_set`) + - `nameres.py` — for NameRes-only assertions (subclass `NameResTest`, override `test_param_set`) + - `common.py` — for assertions that apply to both services (subclass `AssertionHandler`, override `test_with_nodenorm` and/or `test_with_nameres`) + +2. Define the class with `NAME`, `DESCRIPTION`, `PARAMETERS`, `WIKI_EXAMPLES`, `YAML_PARAMS`, and `test_param_set()` (or both `test_with_*` methods for `AssertionHandler` subclasses). + +3. Import it in `__init__.py` and add an instance to `ASSERTION_HANDLERS`. + +4. Run `uv run python -m src.babel_validation.assertions.gen_docs` to regenerate `README.md`. +""" + +_GROUP_HEADERS: dict[str, str] = { + "NodeNorm": ( + "## NodeNorm Assertions\n\n" + "These assertions test the [NodeNorm](https://nodenorm.transltr.io/docs) service." + ), + "NameRes": ( + "## NameRes Assertions\n\n" + "These assertions test the [NameRes](https://name-lookup.transltr.io/docs) service." + ), + "NodeNorm and NameRes": "## Special Assertions", +} + + +def _display_name(h: AssertionHandler) -> str: + return type(h).__name__.removesuffix("Handler") + + +def _applies_to(h: AssertionHandler) -> str: + if isinstance(h, NodeNormTest): + return "NodeNorm" + if isinstance(h, NameResTest): + return "NameRes" + return "NodeNorm and NameRes" + + +def _render_handler(h: AssertionHandler) -> str: + name = _display_name(h) + service = _applies_to(h) + description = getattr(h, "DESCRIPTION", "") + parameters = getattr(h, "PARAMETERS", "") + wiki_examples = getattr(h, "WIKI_EXAMPLES", []) + yaml_params = getattr(h, "YAML_PARAMS", "") + + parts = [] + parts.append(f"### {name}\n") + parts.append(f"**Applies to:** {service}\n") + parts.append(f"{description}\n") + + if parameters: + parts.append(f"**Parameters:** {parameters}\n") + + wiki_block = "\n".join(wiki_examples) + parts.append(f"**Wiki syntax:**\n```\n{wiki_block}\n```\n") + + parts.append( + f"**YAML syntax:**\n```yaml\nbabel_tests:\n {name}:\n{yaml_params}\n```\n" + ) + + parts.append("---\n") + + return "\n".join(parts) + + +def generate_readme() -> str: + sections = [INTRO] + seen_groups: set[str] = set() + + for h in ASSERTION_HANDLERS.values(): + service = _applies_to(h) + if service not in seen_groups: + seen_groups.add(service) + sections.append(_GROUP_HEADERS[service] + "\n") + sections.append(_render_handler(h)) + + sections.append(ADDING_NEW) + return "\n".join(sections) + + +if __name__ == "__main__": + content = generate_readme() + README_PATH.write_text(content, encoding="utf-8") + print(f"Written to {README_PATH}") diff --git a/src/babel_validation/assertions/nameres.py b/src/babel_validation/assertions/nameres.py new file mode 100644 index 00000000..a0b493f0 --- /dev/null +++ b/src/babel_validation/assertions/nameres.py @@ -0,0 +1,61 @@ +import json +import logging +from typing import Iterator + +from src.babel_validation.assertions import NameResTest +from src.babel_validation.core.testrow import TestResult +from src.babel_validation.services.nameres import CachedNameRes +from src.babel_validation.services.nodenorm import CachedNodeNorm + + +class SearchByNameHandler(NameResTest): + """Test that a name search returns an expected CURIE in the top-N results in NameRes.""" + NAME = "searchbyname" + DESCRIPTION = ( + "Each param_set must have exactly two elements: a search query string and an expected CURIE. " + "The test passes if the CURIE's normalized identifier appears within the top N results " + "(default N=5) when NameRes looks up the search query." + ) + PARAMETERS = ( + "Each param_set: the **search query string** and the **expected CURIE**. " + "The CURIE is normalized via NodeNorm before matching." + ) + WIKI_EXAMPLES = ["{{BabelTest|SearchByName|water|CHEBI:15377}}"] + YAML_PARAMS = " - [water, CHEBI:15377]\n - [diabetes, MONDO:0005015]" + + def test_param_set(self, params: list[str], nodenorm: CachedNodeNorm, + nameres: CachedNameRes, pass_if_found_in_top: int = 5, + label: str = "") -> Iterator[TestResult]: + if len(params) != 2: + yield self.failed( + f"SearchByName requires exactly two parameters (search query, expected CURIE) in {label}, " + f"but got {len(params)}: {params}" + ) + return + + [search_query, expected_curie_from_test] = params + expected_curie_result = nodenorm.normalize_curie(expected_curie_from_test) + if not expected_curie_result: + yield self.failed(f"Unable to normalize CURIE {expected_curie_from_test} in {label}") + return + + expected_curie = expected_curie_result['id']['identifier'] + expected_curie_label = expected_curie_result['id'].get('label', '') + expected_curie_string = f"Expected CURIE {expected_curie_from_test}, normalized to {expected_curie} '{expected_curie_label}'" + + results = nameres.lookup(search_query, autocomplete='false', limit=pass_if_found_in_top) + if not results: + yield self.failed(f"No results found for '{search_query}' on NameRes {nameres} ({expected_curie_string})") + return + + curies = [result['curie'] for result in results] + if expected_curie not in curies: + logging.getLogger(__name__).debug( + "%s not found in top %d results for '%s' in NameRes %s: %s", + expected_curie_string, pass_if_found_in_top, search_query, nameres, + json.dumps(results, indent=2, sort_keys=True) + ) + yield self.failed(f"{expected_curie_string} not found in top {pass_if_found_in_top} results for '{search_query}' in NameRes {nameres}") + return + + yield self.passed(f"{expected_curie_string} found at index {curies.index(expected_curie) + 1} on NameRes {nameres}") diff --git a/src/babel_validation/assertions/nodenorm.py b/src/babel_validation/assertions/nodenorm.py new file mode 100644 index 00000000..92f477f5 --- /dev/null +++ b/src/babel_validation/assertions/nodenorm.py @@ -0,0 +1,249 @@ +from typing import Iterator + +from src.babel_validation.assertions import NodeNormTest +from src.babel_validation.core.testrow import TestResult +from src.babel_validation.services.nodenorm import CachedNodeNorm + + +class ResolvesHandler(NodeNormTest): + """Test that every CURIE in every param_set resolves in NodeNorm.""" + NAME = "resolves" + DESCRIPTION = "Each CURIE in each param_set must resolve to a non-null result in NodeNorm." + PARAMETERS = "One or more CURIEs per param_set." + WIKI_EXAMPLES = [ + "{{BabelTest|Resolves|CHEBI:15365}}", + "{{BabelTest|Resolves|MONDO:0005015|DOID:9351}}", + ] + YAML_PARAMS = " - CHEBI:15365\n - [MONDO:0005015, DOID:9351]" + + def test_param_set(self, params: list[str], nodenorm: CachedNodeNorm, + label: str = "") -> Iterator[TestResult]: + for curie in params: + result = nodenorm.normalize_curie(curie) + if not result: + yield self.failed(f"Could not resolve {curie} with NodeNormalization service {nodenorm}") + else: + yield self.passed(self.resolved_message(curie, result, nodenorm)) + + +class DoesNotResolveHandler(NodeNormTest): + """Test that every CURIE in every param_set does NOT resolve in NodeNorm.""" + NAME = "doesnotresolve" + DESCRIPTION = ( + "Each CURIE in each param_set must fail to resolve (return null) in NodeNorm. " + "Use this to confirm that an identifier is intentionally not normalizable." + ) + PARAMETERS = "One or more CURIEs per param_set." + WIKI_EXAMPLES = ["{{BabelTest|DoesNotResolve|FAKENS:99999}}"] + YAML_PARAMS = " - FAKENS:99999" + + def test_param_set(self, params: list[str], nodenorm: CachedNodeNorm, + label: str = "") -> Iterator[TestResult]: + for curie in params: + result = nodenorm.normalize_curie(curie) + if not result: + yield self.passed(f"Could not resolve {curie} with NodeNormalization service {nodenorm} as expected") + else: + yield self.failed(f"Resolved {curie} to {result['id']['identifier']} ({self.first_type(result)}, \"{result['id'].get('label', '')}\") with NodeNormalization service {nodenorm}, but expected not to resolve") + + +def _compare_resolutions( + params: list[str], nodenorm: CachedNodeNorm +) -> tuple[dict | None, dict[str, dict | None]]: + """Resolve all params; return (first_good_result, per_curie_results). + + first_good_result is None if every CURIE failed to resolve. + per_curie_results maps each CURIE to its result (None if unresolvable). + """ + per_curie = nodenorm.normalize_curies(params) + first_good = next((r for r in per_curie.values() if r is not None), None) + return first_good, per_curie + + +class ResolvesWithHandler(NodeNormTest): + """Test that all CURIEs in a param_set resolve to the same normalized result in NodeNorm.""" + NAME = "resolveswith" + DESCRIPTION = ( + "All CURIEs within each param_set must resolve to the identical normalized result. " + "Use this to assert that two identifiers are equivalent." + ) + PARAMETERS = "Two or more CURIEs per param_set. All must resolve to the same result." + WIKI_EXAMPLES = ["{{BabelTest|ResolvesWith|CHEBI:15365|PUBCHEM.COMPOUND:1}}"] + YAML_PARAMS = " - [CHEBI:15365, PUBCHEM.COMPOUND:1]\n - [MONDO:0005015, DOID:9351]" + + def test_param_set(self, params: list[str], nodenorm: CachedNodeNorm, + label: str = "") -> Iterator[TestResult]: + if len(params) < 2: + yield self.failed( + f"ResolvesWith requires at least two CURIEs per param_set in {label}, " + f"but got {len(params)}: {params}" + ) + return + + first_good, results = _compare_resolutions(params, nodenorm) + + if first_good is None: + yield self.failed(f"None of the CURIEs {params} could be resolved on {nodenorm}") + return + + canonical_id = first_good['id']['identifier'] + + for curie, result in results.items(): + if result is None: + yield self.failed( + f"CURIE {curie} could not be resolved on {nodenorm}" + ) + elif result['id']['identifier'] == canonical_id: + yield self.passed( + f"Resolved {curie} to the expected canonical identifier {canonical_id}" + ) + else: + yield self.failed( + f"Resolved {curie} to {result['id']['identifier']} " + f"({self.first_type(result)}, \"{result['id'].get('label', '')}\"), but expected " + f"{canonical_id} " + f"({self.first_type(first_good)}, \"{first_good['id'].get('label', '')}\") on {nodenorm}" + ) + + +class DoesNotResolveWithHandler(NodeNormTest): + """Test that not all CURIEs in a param_set resolve to the same result in NodeNorm.""" + NAME = "doesnotresolvewith" + DESCRIPTION = ( + "The CURIEs within each param_set must NOT all resolve to the same normalized " + "result. Use this to assert that two identifiers are intentionally distinct entities." + ) + PARAMETERS = "Two or more CURIEs per param_set. They must not all resolve to the same result." + WIKI_EXAMPLES = ["{{BabelTest|DoesNotResolveWith|CHEBI:15365|CHEBI:16856}}"] + YAML_PARAMS = " - [CHEBI:15365, CHEBI:16856]" + + def test_param_set(self, params: list[str], nodenorm: CachedNodeNorm, + label: str = "") -> Iterator[TestResult]: + if len(params) < 2: + yield self.failed( + f"DoesNotResolveWith requires at least two CURIEs per param_set in {label}, " + f"but got {len(params)}: {params}" + ) + return + + first_good, results = _compare_resolutions(params, nodenorm) + + # Every CURIE must resolve — an unresolved CURIE is a configuration error. + unresolved = [curie for curie, result in results.items() if result is None] + if unresolved: + yield self.failed( + f"CURIEs {unresolved} could not be resolved on {nodenorm}; " + f"all CURIEs in a DoesNotResolveWith param_set must resolve" + ) + return + + # All resolved — check that they don't all map to the same canonical identifier. + canonical_ids = {result['id']['identifier'] for result in results.values()} + + if len(canonical_ids) == 1: + # Every CURIE maps to the same result — assertion fails. + shared = first_good + yield self.failed( + f"All CURIEs {params} resolved to the same result " + f"{shared['id']['identifier']} " + f"({self.first_type(shared)}, \"{shared['id'].get('label', '')}\") on {nodenorm}, " + f"but expected them to resolve differently" + ) + else: + summary = ", ".join( + f"{curie} → {result['id']['identifier']}" + for curie, result in results.items() + ) + yield self.passed( + f"CURIEs resolve to different results as expected: {summary} on {nodenorm}" + ) + + +class HasLabelHandler(NodeNormTest): + """Test that a CURIE resolves to a specific primary label in NodeNorm.""" + NAME = "haslabel" + DESCRIPTION = ( + "The CURIE must resolve in NodeNorm and its primary label (id.label) must " + "match the expected label exactly (case-sensitive)." + ) + PARAMETERS = "Exactly two elements per param_set: a CURIE, then the expected label string." + WIKI_EXAMPLES = ["{{BabelTest|HasLabel|CHEBI:15365|aspirin}}"] + YAML_PARAMS = " - [CHEBI:15365, aspirin]" + + def curie_params(self, params: list[str]) -> list[str]: + return params[:1] + + def test_param_set(self, params: list[str], nodenorm: CachedNodeNorm, + label: str = "") -> Iterator[TestResult]: + if len(params) != 2: + yield self.failed( + f"HasLabel requires exactly two parameters (CURIE, expected label) in {label}, " + f"but got {len(params)}: {params}" + ) + return + + curie = params[0] + expected_label = params[1].strip() + + result = nodenorm.normalize_curie(curie) + if not result: + yield self.failed( + f"Could not resolve {curie} on {nodenorm}" + ) + return + + if 'label' not in result['id']: + yield self.failed( + f"CURIE {curie} has no label but expected '{expected_label}' on {nodenorm}" + ) + return + + actual_label = result['id']['label'] + if actual_label == expected_label: + yield self.passed( + f"CURIE {curie} has expected label '{actual_label}' on {nodenorm}" + ) + else: + yield self.failed( + f"CURIE {curie} has label '{actual_label}', " + f"but expected '{expected_label}' on {nodenorm}" + ) + + +class ResolvesWithTypeHandler(NodeNormTest): + """Test that CURIEs resolve with a specific Biolink type in NodeNorm.""" + NAME = "resolveswithtype" + DESCRIPTION = ( + "Each param_set must have at least two elements: the first is the expected Biolink type " + "(e.g. 'biolink:Gene'), and the remainder are CURIEs that must resolve with that type." + ) + PARAMETERS = ( + "Each param_set: first element is the expected Biolink type (e.g. `biolink:Gene`), " + "remaining elements are CURIEs." + ) + WIKI_EXAMPLES = ["{{BabelTest|ResolvesWithType|biolink:Gene|NCBIGene:1}}"] + YAML_PARAMS = " - [biolink:Gene, NCBIGene:1, HGNC:5]" + + def curie_params(self, params: list[str]) -> list[str]: + return params[1:] + + def test_param_set(self, params: list[str], nodenorm: CachedNodeNorm, + label: str = "") -> Iterator[TestResult]: + if len(params) < 2: + yield self.failed(f"Too few parameters provided in param_set in {label}: {params}") + return + + expected_biolink_type = params[0] + curies = params[1:] + + results = nodenorm.normalize_curies(curies) + for curie in curies: + node = results.get(curie) + if not node: + yield self.failed(f"Could not resolve {curie} with NodeNormalization service {nodenorm}") + continue + biolink_types = node.get('type') or [] + if expected_biolink_type in biolink_types: + yield self.passed(f"Biolink types {biolink_types} for CURIE {curie} includes expected Biolink type {expected_biolink_type}") + else: + yield self.failed(f"Biolink types {biolink_types} for CURIE {curie} does not include expected Biolink type {expected_biolink_type}") diff --git a/tests/test_environment/test_assertions_docs.py b/tests/test_environment/test_assertions_docs.py new file mode 100644 index 00000000..03e5b908 --- /dev/null +++ b/tests/test_environment/test_assertions_docs.py @@ -0,0 +1,14 @@ +import pytest + +from src.babel_validation.assertions.gen_docs import generate_readme, README_PATH + + +@pytest.mark.unit +def test_assertions_readme_is_up_to_date(): + expected = generate_readme() + actual = README_PATH.read_text(encoding="utf-8").replace("\r\n", "\n") + assert actual == expected, ( + "assertions/README.md is out of date.\n" + "Regenerate it with:\n" + " uv run python -m src.babel_validation.assertions.gen_docs" + )