Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Tests
on:
workflow_dispatch:
pull_request:
push:
branches: [main]

jobs:
lint:
name: Lint and format check with ruff
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
# version-file so CI runs the ruff in uv.lock. Without it the action installs the latest
# release, so a ruff that nobody has locally can fail this job on a PR that did not touch
# any Python. ruff is a standalone binary, so no setup-python step is needed.
#
# Pinned to an exact version because ruff-action publishes immutable releases from v4 on:
# there is no moving `v4` tag to track, and `@v4` fails to resolve.
- uses: astral-sh/ruff-action@v4.1.0
with:
version-file: "uv.lock"
args: "check --output-format github"
- uses: astral-sh/ruff-action@v4.1.0
with:
version-file: "uv.lock"
args: "format --check"

unit-tests:
name: Offline unit tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.11"
- run: uv sync --frozen
# Collection is a test in its own right: the gsheet modules build their
# parametrization in pytest_generate_tests, so an import error or a bad
# parametrize there fails here rather than at the start of a live run.
# It needs the network, since collecting everything downloads the sheet.
- run: uv run pytest --collect-only -q
# The offline subset. Everything that talks to NodeNorm, NameRes, or the
# Google Sheet is unmarked and deselected here; see the `unit` marker in
# pyproject.toml.
- run: uv run pytest -m unit -q
22 changes: 19 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Babel Validation is a test suite and web tools for validating outputs from [Babe
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/). Run from repo root:

```bash
pytest -m unit # Offline unit tests only — no network, no live services (what CI runs)
pytest --target dev # Run all tests against dev environment (default if no --target)
pytest --target prod # Run against production
pytest --target dev --target prod # Run against multiple targets
Expand All @@ -23,12 +24,20 @@ pytest tests/nodenorm/test_nodenorm_from_gsheet.py # Run a specific test file
pytest tests/nodenorm/test_nodenorm_from_gsheet.py -k "row=42" # Run a specific test row
```

Note that `-m unit` and `--category "Unit Tests"` are unrelated despite the names. `-m unit`
selects the offline tests in `tests/unit/`; `--category "Unit Tests"` selects Google Sheet rows
whose Category column says "Unit Tests", and those still call a live NodeNorm or NameRes.

### Code Formatting

```bash
black tests/ # Format Python test code
uv run ruff check --fix . # Lint (import sorting, pyupgrade, pyflakes) and auto-fix
uv run ruff format . # Format Python code
```

Both run on every pull request; settings live in `pyproject.toml` and are kept in sync with
[Babel](https://github.com/NCATSTranslator/Babel).

### Vue Website (website-vue3-vite/)

```bash
Expand Down Expand Up @@ -69,6 +78,13 @@ The core of this project. Tests validate NodeNorm and NameRes services across mu
- `tests/nodenorm/` — NodeNorm tests (normalization accuracy, preferred IDs/labels, Biolink types, conflation, descriptions, OpenAPI spec, setid endpoint)
- `tests/nameres/` — NameRes tests (label lookup, autocomplete, Biolink type filtering, blocklist, taxon_specific flag)
- `tests/nodenorm/by_issue/` — Per-issue regression tests for NodeNorm (hand-written)
- `tests/unit/` — Offline tests for the library in `src/babel_validation/` (service caching, Google Sheet parsing). Marked `@pytest.mark.unit`; these are the only tests that run without network access, so they are the ones CI runs on every PR.

**Marking new tests:** a test belongs in `tests/unit/` with the `unit` marker only if it needs no
network at all. Anything that reaches NodeNorm, NameRes, or the Google Sheet stays unmarked, which
is what keeps `pytest -m unit` runnable offline. If a new module builds its parametrization from a
network source at collection time, guard it with `deselected_by_markexpr` (see
`tests/nodenorm/test_nodenorm_from_gsheet.py`) so `-m unit` does not pay for the fetch.

### Web Applications

Expand All @@ -78,7 +94,7 @@ The core of this project. Tests validate NodeNorm and NameRes services across mu

## Key Dependencies

- Python >=3.11, pytest, requests, deepdiff, openapi-spec-validator, black
- Python >=3.11, pytest, requests, deepdiff, openapi-spec-validator, ruff
- `uv` for Python dependency management (no requirements.txt — uses pyproject.toml)

## Testing Patterns
Expand All @@ -88,4 +104,4 @@ When writing new tests:
- For Google Sheet-based tests, parametrize with `gsheet.test_rows()` and use the `test_category` fixture for category filtering
- Use `pytest.mark.xfail(strict=True)` for known failures (strict=True means unexpected passes also fail)
- Hand-written per-issue regression tests go in `tests/nodenorm/by_issue/`
- Import shared classes from `src.babel_validation.*` (e.g. `from src.babel_validation.services.nodenorm import CachedNodeNorm`)
- Import shared classes from `babel_validation.*` (e.g. `from babel_validation.services.nodenorm import CachedNodeNorm`)
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ The best tests in this repository are Python tests stored in the [`./tests`](./t
This includes both unit tests as well as "Google Sheet"-based tests, which uses
a [shared Google Sheet](https://docs.google.com/spreadsheets/d/11zebx8Qs1Tc3ShQR9nh4HRW8QSoo8k65w_xIaftN0no/edit?gid=0#gid=0) containing facts that we can use to test a NodeNorm instance.

To run these tests, you need to [install `uv`](https://docs.astral.sh/uv/getting-started/installation/).
The offline subset needs no services at all and runs in well under a second:

```shell
$ uv run pytest -m unit
```

These are the tests in [`./tests/unit`](./tests/unit/), covering the library in `src/babel_validation/`.
They are what GitHub Actions runs on every pull request, alongside `ruff check` and `ruff format --check`.
Everything else needs a live NodeNorm or NameRes, and is run on demand against a chosen target.

To run those, you need to [install `uv`](https://docs.astral.sh/uv/getting-started/installation/).
You can then use `uv` to run the tests. The file [`tests/targets.ini`](./tests/targets.ini) allows you to
control which NodeNorm instance is tested. The `[DEFAULT]` section applies defaults for all the environments.
For example, to run all the tests on the `dev` instance, you can use `--target`:
Expand Down
34 changes: 30 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"black>=25.9.0",
"ruff>=0.14.14",
"requests>=2.32.5",
"filelock",
"deepdiff>=8.6.1",
Expand All @@ -23,12 +23,38 @@ requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
# Package the entire `src/` directory so existing imports
# (`from src.babel_validation.X import Y`) continue to work after install.
packages = ["src"]
packages = ["src/babel_validation"]

[tool.pytest.ini_options]
# Without testpaths, a bare `pytest` would also scan the website directories
# (including node_modules) during collection.
testpaths = ["tests"]
pythonpath = ["src"]
timeout = 300
# Canonical marker definitions. Every test that talks to a live service or downloads the
# Google Sheet is unmarked, so `pytest -m unit` is the offline subset CI can run on every PR.
markers = [
"unit: fast offline tests with no external dependencies",
]

# Linting/formatting configuration, kept in sync with NCATSTranslator/Babel.
[tool.ruff]
line-length = 120
# log-analysis/ holds exploratory notebooks, not maintained code: one cell does not even parse.
# Linting them would gate CI on scratch work nobody imports.
extend-exclude = ["*.ipynb"]

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort (import sorting)
"UP", # pyupgrade
]

ignore = [
"E501", # let Ruff handle wrapping consistently
]

fixable = ["ALL"]
unfixable = []
Empty file removed src/__init__.py
Empty file.
43 changes: 23 additions & 20 deletions src/babel_validation/core/testrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class TestRow:
"""
A TestRow models a single row from a GoogleSheet.
"""

Category: str
ExpectPassInNodeNorm: bool
ExpectPassInNameRes: bool
Expand All @@ -29,31 +30,33 @@ class TestRow:

# A string representation of this test row.
def __str__(self):
return f"TestRow of category {self.Category} for preferred {self.PreferredID} ({self.PreferredLabel}) with " + \
f"query {self.QueryID} ({self.QueryLabel}) from source {self.Source} ({self.SourceURL})"

return (
f"TestRow of category {self.Category} for preferred {self.PreferredID} ({self.PreferredLabel}) with "
+ f"query {self.QueryID} ({self.QueryLabel}) from source {self.Source} ({self.SourceURL})"
)

@staticmethod
def from_data_row(row):
return TestRow(
Category=row.get('Category', ''),
ExpectPassInNodeNorm=row.get('Passes in NodeNorm', '').strip().lower() == 'y',
ExpectPassInNameRes=row.get('Passes in NameRes', '').strip().lower() == 'y',
Flags=set(row.get('Flags', '').split('|')),
QueryLabel=row.get('Query Label', ''),
QueryID=row.get('Query ID', ''),
PreferredID=row.get('Preferred ID', ''),
AdditionalIDs=row.get('Additional IDs', '').split('|'),
PreferredLabel=row.get('Preferred Label', ''),
AdditionalLabels=row.get('Additional Labels', '').split('|'),
Conflations=set(row.get('Conflations', '').split('|')),
BiolinkClasses=set(row.get('Biolink Classes', '').split('|')),
Prefixes=set(row.get('Prefixes', '').split('|')),
Source=row.get('Source', ''),
SourceURL=row.get('Source URL', ''),
Notes=row.get('Notes', '')
Category=row.get("Category", ""),
ExpectPassInNodeNorm=row.get("Passes in NodeNorm", "").strip().lower() == "y",
ExpectPassInNameRes=row.get("Passes in NameRes", "").strip().lower() == "y",
Flags=set(row.get("Flags", "").split("|")),
QueryLabel=row.get("Query Label", ""),
QueryID=row.get("Query ID", ""),
PreferredID=row.get("Preferred ID", ""),
AdditionalIDs=row.get("Additional IDs", "").split("|"),
PreferredLabel=row.get("Preferred Label", ""),
AdditionalLabels=row.get("Additional Labels", "").split("|"),
Conflations=set(row.get("Conflations", "").split("|")),
BiolinkClasses=set(row.get("Biolink Classes", "").split("|")),
Prefixes=set(row.get("Prefixes", "").split("|")),
Source=row.get("Source", ""),
SourceURL=row.get("Source URL", ""),
Notes=row.get("Notes", ""),
)


class TestStatus(Enum):
Passed = "pass"
Failed = "fail"
Expand All @@ -62,11 +65,11 @@ class TestStatus(Enum):
# Mark as not a test despite starting with Test*.
__test__ = False


@dataclass
class TestResult:
status: TestStatus
message: str = ""

# Mark as not a test despite starting with Test*.
__test__ = False

44 changes: 32 additions & 12 deletions src/babel_validation/services/nameres.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
Entries are never evicted automatically; call ``invalidate_query()`` to force
a fresh lookup for a specific query string.

``lookup()`` and ``bulk_lookup()`` deliberately share this one keyspace, with
no record of which endpoint produced an entry. Given the same query and the
same parameters the two endpoints are interchangeable -- ``/lookup`` returns a
list of hits, and ``/bulk-lookup`` returns exactly that list under the string
it was asked about -- so a result cached by either is a valid answer for the
other, and one cache is fine. This is what lets a ``bulk_lookup()`` be served
with no HTTP call after a ``lookup()`` for the same string, and vice versa.

Cache-warming pattern
---------------------
When you need to look up many query strings for the same logical task, call
Expand All @@ -18,8 +26,10 @@
--------------------
``bulk_lookup()`` targets ``/bulk-lookup`` and sends the query list as a JSON
body. ``lookup()`` targets the separate ``/lookup`` endpoint and sends its
parameters as a URL query string. These are distinct API endpoints with
different response shapes; ``lookup()`` does NOT delegate to ``bulk_lookup()``.
parameters as a URL query string. Their return types differ accordingly --
a ``{query: hits}`` mapping against a bare list of hits -- so ``lookup()`` does
NOT delegate to ``bulk_lookup()``, even though, per the caching model above,
the hits themselves are the same and share a cache.
"""

import logging
Expand Down Expand Up @@ -54,7 +64,7 @@ def __str__(self):
return f"CachedNameRes({self.nameres_url})"

@staticmethod
def from_url(nameres_url: str) -> 'CachedNameRes':
def from_url(nameres_url: str) -> "CachedNameRes":
"""Return the singleton ``CachedNameRes`` for *nameres_url*.

The singleton ensures that cache entries accumulated during one part of
Expand Down Expand Up @@ -92,7 +102,7 @@ def bulk_lookup(self, queries: list[str], **params) -> dict[str, dict]:
result = {}
if queries_to_be_queried:
api_params = dict(params)
api_params['strings'] = list(queries_to_be_queried)
api_params["strings"] = list(queries_to_be_queried)

self.logger.debug("Called NameRes %s with params %s", self, api_params)
response = requests.post(self.nameres_url + "bulk-lookup", json=api_params, timeout=30)
Expand All @@ -105,9 +115,15 @@ def bulk_lookup(self, queries: list[str], **params) -> dict[str, dict]:
for query in cached_queries:
result[query] = self.cache[(query, params_key)]

time_taken_sec = (time.time_ns() - time_started) / 1E9
self.logger.info("Looked up %d queries (with %d cached) with params %s on %s in %.3fs",
len(queries_to_be_queried), len(cached_queries), params, self, time_taken_sec)
time_taken_sec = (time.time_ns() - time_started) / 1e9
self.logger.info(
"Looked up %d queries (with %d cached) with params %s on %s in %.3fs",
len(queries_to_be_queried),
len(cached_queries),
params,
self,
time_taken_sec,
)

return result

Expand All @@ -117,17 +133,21 @@ def lookup(self, query: str, **params) -> list[dict]:
This targets a different endpoint from ``bulk_lookup()`` — parameters
are sent as URL query string fields, and the response is a list of
result dicts rather than a mapping. Results are cached per
``(query, params)`` combination.

This method does NOT delegate to ``bulk_lookup()``. To cache-warm for
single lookups, call this method (or ``bulk_lookup()``) upfront.
``(query, params)`` combination, in the same keyspace ``bulk_lookup()``
uses: for a given query and parameters the two endpoints return the
same hits, so either may serve the other from cache.

This method does NOT delegate to ``bulk_lookup()`` — the two shape
their return values differently, and calling ``/bulk-lookup`` for a
single string would be the wrong request. To cache-warm for single
lookups, call this method (or ``bulk_lookup()``) upfront.
"""
cache_key = (query, frozenset(params.items()))
if cache_key in self.cache:
return self.cache[cache_key]

api_params = dict(params)
api_params['string'] = query
api_params["string"] = query
self.logger.debug("Querying NameRes with params %s", api_params)

response = requests.post(self.nameres_url + "lookup", params=api_params, timeout=30)
Expand Down
17 changes: 12 additions & 5 deletions src/babel_validation/services/nodenorm.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def __str__(self):
return f"CachedNodeNorm({self.nodenorm_url})"

@staticmethod
def from_url(nodenorm_url: str) -> 'CachedNodeNorm':
def from_url(nodenorm_url: str) -> "CachedNodeNorm":
"""Return the singleton ``CachedNodeNorm`` for *nodenorm_url*.

The singleton ensures that cache entries accumulated during one part of
Expand Down Expand Up @@ -88,7 +88,7 @@ def normalize_curies(self, curies: list[str], **params) -> dict[str, dict | None
result = {}
if curies_to_be_queried:
api_params = dict(params)
api_params['curies'] = list(curies_to_be_queried)
api_params["curies"] = list(curies_to_be_queried)

self.logger.debug("Called NodeNorm %s with params %s", self, api_params)
response = requests.post(self.nodenorm_url + "get_normalized_nodes", json=api_params, timeout=30)
Expand All @@ -101,9 +101,16 @@ def normalize_curies(self, curies: list[str], **params) -> dict[str, dict | None
for curie in cached_curies:
result[curie] = self.cache[(curie, params_key)]

time_taken_sec = (time.time_ns() - time_started) / 1E9
self.logger.info("Normalizing %d CURIEs %s (with %d CURIEs cached) with params %s on %s in %.3fs",
len(curies_to_be_queried), curies_to_be_queried, len(cached_curies), params, self, time_taken_sec)
time_taken_sec = (time.time_ns() - time_started) / 1e9
self.logger.info(
"Normalizing %d CURIEs %s (with %d CURIEs cached) with params %s on %s in %.3fs",
len(curies_to_be_queried),
curies_to_be_queried,
len(cached_curies),
params,
self,
time_taken_sec,
)

return result

Expand Down
Loading