Skip to content
Merged
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
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,29 @@ cd website && npm install && npm run dev # Dev server at localhost:4321

## Architecture

### Library (`src/babel_validation/`)

Shared library code used by the tests and potentially other consumers.

- `core/testrow.py` — `TestRow` dataclass (models a single Google Sheet test row), `TestStatus` enum, `TestResult` dataclass
- `services/nodenorm.py` — `CachedNodeNorm`: wraps the NodeNorm `get_normalized_nodes` API with per-instance caching
- `services/nameres.py` — `CachedNameRes`: wraps the NameRes `lookup`/`bulk-lookup` APIs with per-instance caching
- `sources/google_sheets/google_sheet_test_cases.py` — `GoogleSheetTestCases`: downloads and parses the shared Google Sheet into `TestRow` instances and pytest `ParameterSet` lists

### Test Framework (`tests/`)

The core of this project. Tests validate NodeNorm and NameRes services across multiple deployment environments.

**Target system:** `tests/targets.ini` defines endpoints for each environment (dev, prod, test, ci, exp, localhost). Tests use `target_info` fixture to get URLs. The `conftest.py` parametrizes tests across targets via `--target` CLI option; default is `dev`.

**Google Sheet integration:** ~2000+ test cases are pulled from a [shared Google Sheet](https://docs.google.com/spreadsheets/d/11zebx8Qs1Tc3ShQR9nh4HRW8QSoo8k65w_xIaftN0no/). `tests/common/google_sheet_test_cases.py` fetches and parses these into `TestRow` dataclasses. Rows marked as not expected to pass are wrapped with `pytest.mark.xfail(strict=True)`. Tests are parametrized by row, with IDs like `gsheet:row=42`.
**Google Sheet integration:** ~2000+ test cases are pulled from a [shared Google Sheet](https://docs.google.com/spreadsheets/d/11zebx8Qs1Tc3ShQR9nh4HRW8QSoo8k65w_xIaftN0no/). `src/babel_validation/sources/google_sheets/google_sheet_test_cases.py` fetches and parses these into `TestRow` dataclasses. Rows marked as not expected to pass are wrapped with `pytest.mark.xfail(strict=True)`. Tests are parametrized by row, with IDs like `gsheet:row=42`.

**Category filtering:** Google Sheet rows have a Category column. The `test_category` fixture (from conftest.py) returns a callable that tests use to `pytest.skip()` rows not matching `--category`/`--category-exclude` filters.

**Test modules:**
- `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/` — Tests tied to specific GitHub issues
- `tests/common/` — Shared utilities (`GoogleSheetTestCases`, `TestRow`)
- `tests/nodenorm/by_issue/` — Per-issue regression tests for NodeNorm (hand-written)

### Web Applications

Expand All @@ -79,4 +87,5 @@ When writing new tests:
- Use the `target_info` fixture to get NodeNorm/NameRes URLs from targets.ini
- 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)
- Issue-specific tests go in `tests/nodenorm/by_issue/` or `tests/github_issues/`
- 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`)
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ requires-python = ">=3.11"
dependencies = [
"black>=25.9.0",
"requests>=2.32.5",
"filelock",
"deepdiff>=8.6.1",
"openapi-spec-validator>=0.7.2",
"pytest>=8.4.2",
Expand All @@ -16,3 +17,18 @@ dependencies = [

[project.urls]
Repository = "https://github.com/TranslatorSRI/babel-validation"

[build-system]
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"]

[tool.pytest.ini_options]
# Without testpaths, a bare `pytest` would also scan the website directories
# (including node_modules) during collection.
testpaths = ["tests"]
timeout = 300
File renamed without changes.
Empty file.
Empty file.
72 changes: 72 additions & 0 deletions src/babel_validation/core/testrow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from dataclasses import dataclass
from enum import Enum


@dataclass(frozen=True)
class TestRow:
"""
A TestRow models a single row from a GoogleSheet.
"""
Category: str
ExpectPassInNodeNorm: bool
ExpectPassInNameRes: bool
Flags: set[str]
QueryLabel: str
PreferredLabel: str
AdditionalLabels: list[str]
QueryID: str
PreferredID: str
AdditionalIDs: list[str]
Conflations: set[str]
BiolinkClasses: set[str]
Prefixes: set[str]
Source: str
SourceURL: str
Notes: str

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

# 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})"


@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', '')
)

class TestStatus(Enum):
Passed = "pass"
Failed = "fail"
Skipped = "skip"

# 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

Empty file.
148 changes: 148 additions & 0 deletions src/babel_validation/services/nameres.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""
Cached client for the NameRes ``bulk-lookup`` and ``lookup`` APIs.

Caching model
-------------
Each response is stored under the key ``(query, frozenset(params.items()))``.
Entries are never evicted automatically; call ``invalidate_query()`` to force
a fresh lookup for a specific query string.

Cache-warming pattern
---------------------
When you need to look up many query strings for the same logical task, call
``bulk_lookup()`` once with the full list. That issues a single HTTP POST to
the ``bulk-lookup`` endpoint and populates the cache. Subsequent
``bulk_lookup()`` calls for any subset of those queries are served from cache.

Endpoint differences
--------------------
``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()``.
"""

import logging
import time
from typing import Protocol

import requests

cached_nameres_by_url = {}


class NameResService(Protocol):
"""Interface that callers should depend on.

Type parameters against this Protocol rather than ``CachedNameRes``
directly so that a future drop-in library replacement requires no caller
changes.
"""

def bulk_lookup(self, queries: list[str], **params) -> dict[str, dict]: ...
def lookup(self, query: str, **params) -> list[dict]: ...
def invalidate_query(self, query: str) -> None: ...


class CachedNameRes:
def __init__(self, nameres_url: str):
self.nameres_url = nameres_url
self.logger = logging.getLogger(str(self))
self.cache = {}

def __str__(self):
return f"CachedNameRes({self.nameres_url})"

@staticmethod
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
a test run are reused by later parts that share the same URL. Prefer
this over direct construction unless you explicitly want a fresh cache.
"""
if nameres_url not in cached_nameres_by_url:
cached_nameres_by_url[nameres_url] = CachedNameRes(nameres_url)
return cached_nameres_by_url[nameres_url]

def bulk_lookup(self, queries: list[str], **params) -> dict[str, dict]:
"""Look up *queries* in bulk, returning a ``{query: result}`` mapping.

Already-cached queries are served from the cache; the remainder are
fetched from NameRes in a single HTTP POST to ``bulk-lookup``.
The response is merged with the cached results before returning.

*queries* must be a non-empty list — the NameRes API rejects empty
requests, so this method raises ``ValueError`` immediately.

Use this as the cache-warming call; subsequent ``bulk_lookup()`` calls
for any subset of these queries will be free.
"""
if not queries:
raise ValueError(f"queries must not be empty when calling bulk_lookup({queries}, {params}) on {self}")
if not isinstance(queries, list):
raise ValueError(f"queries must be a list when calling bulk_lookup({queries}, {params}) on {self}")

time_started = time.time_ns()
params_key = frozenset(params.items())
queries_set = set(queries)
cached_queries = {q for q in queries_set if (q, params_key) in self.cache}
queries_to_be_queried = queries_set - cached_queries

result = {}
if queries_to_be_queried:
api_params = dict(params)
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)
response.raise_for_status()
result = response.json()

for query in queries_to_be_queried:
self.cache[(query, params_key)] = result.get(query, None)

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)

return result

def lookup(self, query: str, **params) -> list[dict]:
"""Look up a single *query* string via the NameRes ``/lookup`` endpoint.

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.
"""
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
self.logger.debug("Querying NameRes with params %s", api_params)

response = requests.post(self.nameres_url + "lookup", params=api_params, timeout=30)
response.raise_for_status()
result = response.json()

self.cache[cache_key] = result
return result

def invalidate_query(self, query: str) -> None:
"""Remove all cached results for *query* (across every param variant).

The next call to ``lookup()`` or ``bulk_lookup()`` for this query will
issue a fresh HTTP request.
"""
keys_to_delete = [k for k in self.cache if k[0] == query]
for k in keys_to_delete:
del self.cache[k]
Loading