diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..22fd8eef --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,17 @@ +name: Tests + +on: + pull_request: + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + + - name: Run unit tests + env: + GITHUB_TOKEN: ${{ github.token }} + run: uv run pytest -m unit -v diff --git a/.gitignore b/.gitignore index a1480ec8..4bb67d43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Ignore the root .env file. +/.env + # Ignore all data files. data/ diff --git a/CLAUDE.md b/CLAUDE.md index 5b8c06a4..c9f814bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,14 @@ pytest --target all # Run against all targets in targets.ini pytest --category "Unit Tests" # Filter by Google Sheet category pytest --category-exclude "Slow" # Exclude a category 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 +pytest -m unit # Offline unit suite (no network/token) — what CI runs on PRs +``` + +To run a single Google Sheet row, pass its full node ID (NOT `-k "row=42"` — pytest's +`-k` expression parser rejects the `=`): + +```bash +pytest "tests/nodenorm/test_nodenorm_from_gsheet.py::test_normalization[dev-gsheet:row=42]" ``` ### Code Formatting @@ -46,21 +53,31 @@ 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 +- `sources/github/github_issues_test_cases.py` — `GitHubIssueTest` and `GitHubIssuesTestCases`: pull test cases embedded in GitHub issues (wiki or YAML syntax) and evaluate them against NodeNorm/NameRes + ### 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) +- `tests/github_issues/` — Dynamically-generated tests pulled from GitHub issue bodies via `GitHubIssuesTestCases` ### Web Applications @@ -79,4 +96,22 @@ 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/` +- GitHub-issue-driven tests are picked up automatically by `tests/github_issues/test_github_issues.py` via `GitHubIssuesTestCases` +- Import shared classes from `src.babel_validation.*` (e.g. `from src.babel_validation.services.nodenorm import CachedNodeNorm`) + +## Gotchas (learned the hard way) + +- **Offline vs. networked tests.** `pytest -m unit` is the offline suite CI runs on PRs (no + network, no `GITHUB_TOKEN`). Network-backed modules (Google Sheet, GitHub issues) defer all + fetching to `pytest_generate_tests` and call `tests/_pytest_helpers.deselected_by_markexpr` + so a marker-deselected run never hits the network. If you add a module that fetches at + import/collection time, replicate that pattern or you'll break `-m unit`. +- **`src/babel_validation` is importable only because** `[tool.hatch.build.targets.wheel]` in + `pyproject.toml` packages `src/`. Imports use the full `src.babel_validation.*` path. +- **Black is not strictly enforced** — `black --check` flags much of the existing tree. Match + the surrounding style of the file you're editing; don't mass-reformat (it creates huge, + review-hostile diffs). +- **Commits are signed via 1Password's SSH agent** (`op-ssh-sign`). It can lock mid-session; + a `failed to fill whole buffer` / `failed to write commit object` error means 1Password + needs unlocking, not a git problem. diff --git a/pyproject.toml b/pyproject.toml index 176b30cf..0d8c88a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,41 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "black>=25.9.0", + "click>=8.1", + "pyyaml>=6.0", "requests>=2.32.5", + "tqdm>=4.67.1", + "filelock", "deepdiff>=8.6.1", + "python-dotenv>=0.9.9", "openapi-spec-validator>=0.7.2", - "pytest>=8.4.2", + "pygithub>=2.8.1", + "pytest>=9.0.2", "pytest-timeout>=2.4.0", + "pytest-xdist[psutil]", + "pytest-subtests", ] [project.urls] Repository = "https://github.com/TranslatorSRI/babel-validation" + +[project.scripts] +csv-to-babeltests = "src.babel_validation.tools.csv_to_babeltests:main" + +[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 +markers = [ + "unit: unit tests that do not require network access", +] diff --git a/tests/common/__init__.py b/src/__init__.py similarity index 100% rename from tests/common/__init__.py rename to src/__init__.py diff --git a/src/babel_validation/__init__.py b/src/babel_validation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/babel_validation/assertions/README.md b/src/babel_validation/assertions/README.md new file mode 100644 index 00000000..9cc4ef7d --- /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 at least 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 (drug/chemical conflation enabled) 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..4ac70edc --- /dev/null +++ b/src/babel_validation/assertions/__init__.py @@ -0,0 +1,187 @@ +""" +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 [] if not applicable. + + :param param_sets: list[list[str]] — see github_issues_test_cases.py module + docstring for the full definition of param_set / param_sets. + """ + return [] + + 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 [] if not applicable. + + :param param_sets: list[list[str]] — see github_issues_test_cases.py module + docstring for the full definition of param_set / param_sets. + """ + return [] + + +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. + + :param params: A single param_set — one element of the outer param_sets list. + See github_issues_test_cases.py for the full terminology. + """ + 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. + + :param params: A single param_set — one element of the outer param_sets list. + See github_issues_test_cases.py for the full terminology. + """ + 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..68c5db22 --- /dev/null +++ b/src/babel_validation/assertions/common.py @@ -0,0 +1,13 @@ +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") 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..da4cb390 --- /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 at least 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 (drug/chemical conflation enabled) 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, drug_chemical_conflate='true') + 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']['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..bcd010a3 --- /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['type'] + 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/src/babel_validation/core/__init__.py b/src/babel_validation/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/babel_validation/core/testrow.py b/src/babel_validation/core/testrow.py new file mode 100644 index 00000000..b03a55ee --- /dev/null +++ b/src/babel_validation/core/testrow.py @@ -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 + diff --git a/src/babel_validation/services/__init__.py b/src/babel_validation/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/babel_validation/services/nameres.py b/src/babel_validation/services/nameres.py new file mode 100644 index 00000000..01d36bb1 --- /dev/null +++ b/src/babel_validation/services/nameres.py @@ -0,0 +1,76 @@ +import logging +import time + +import requests + +cached_nameres_by_url = {} + +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': + 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]: + 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, **params): + 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 delete_query(self, query): + keys_to_delete = [k for k in self.cache if k[0] == query] + for k in keys_to_delete: + del self.cache[k] diff --git a/src/babel_validation/services/nodenorm.py b/src/babel_validation/services/nodenorm.py new file mode 100644 index 00000000..633f7a4b --- /dev/null +++ b/src/babel_validation/services/nodenorm.py @@ -0,0 +1,69 @@ +import logging +import time + +import requests + +cached_node_norms_by_url = {} + +class CachedNodeNorm: + def __init__(self, nodenorm_url: str): + self.nodenorm_url = nodenorm_url + self.logger = logging.getLogger(str(self)) + self.cache = {} + + def __str__(self): + return f"CachedNodeNorm({self.nodenorm_url})" + + @staticmethod + def from_url(nodenorm_url: str) -> 'CachedNodeNorm': + if nodenorm_url not in cached_node_norms_by_url: + cached_node_norms_by_url[nodenorm_url] = CachedNodeNorm(nodenorm_url) + return cached_node_norms_by_url[nodenorm_url] + + def normalize_curies(self, curies: list[str], **params) -> dict[str, dict]: + if not curies: + raise ValueError(f"curies must not be empty when calling normalize_curies({curies}, {params}) on {self}") + if not isinstance(curies, list): + raise ValueError(f"curies must be a list when calling normalize_curies({curies}, {params}) on {self}") + + time_started = time.time_ns() + params_key = frozenset(params.items()) + curies_set = set(curies) + cached_curies = {c for c in curies_set if (c, params_key) in self.cache} + curies_to_be_queried = curies_set - cached_curies + + # Make query. + result = {} + if curies_to_be_queried: + api_params = dict(params) + 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) + response.raise_for_status() + result = response.json() + + for curie in curies_to_be_queried: + self.cache[(curie, params_key)] = result.get(curie, 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) + + return result + + def normalize_curie(self, curie, **params): + cache_key = (curie, frozenset(params.items())) + if cache_key in self.cache: + return self.cache[cache_key] + # Use .get(): NodeNorm normally echoes every requested CURIE (null when + # unresolvable), but don't crash if it ever omits one. + return self.normalize_curies([curie], **params).get(curie) + + def clear_curie(self, curie): + keys_to_delete = [k for k in self.cache if k[0] == curie] + for k in keys_to_delete: + del self.cache[k] diff --git a/src/babel_validation/sources/__init__.py b/src/babel_validation/sources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/babel_validation/sources/github/__init__.py b/src/babel_validation/sources/github/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/babel_validation/sources/github/github_issues_test_cases.py b/src/babel_validation/sources/github/github_issues_test_cases.py new file mode 100644 index 00000000..fa33dab2 --- /dev/null +++ b/src/babel_validation/sources/github/github_issues_test_cases.py @@ -0,0 +1,310 @@ +""" +Parse and evaluate BabelTest assertions embedded in GitHub issue bodies. + +Terminology +----------- +assertion — The name of the test type, e.g. "Resolves" or "ResolvesWith". + Case-insensitive. Maps to a key in ASSERTION_HANDLERS. + +param_set — One set of parameters for a single invocation of an assertion. + Represented as a list of strings. Each Wiki-syntax line produces + exactly one param_set; each entry under a YAML assertion key + produces one param_set. + + Often the first element is "special" (e.g. an expected label or + Biolink type) and the remaining elements are CURIEs to test, + but the interpretation is assertion-specific. + Example: ["CHEBI:15365", "PUBCHEM.COMPOUND:1"] + +param_sets — The full list of param_sets for one assertion in one issue. + A list of lists (list[list[str]]). YAML syntax allows many + param_sets for one assertion type in a single block. + Example: [["CHEBI:15365", "PUBCHEM.COMPOUND:1"], + ["MONDO:0005015", "DOID:9351"]] +""" + +import json +import logging +import re +from typing import Iterator + +import yaml + +from github import Github, Auth, Issue +from tqdm import tqdm + +from src.babel_validation.assertions import ASSERTION_HANDLERS +from src.babel_validation.core.testrow import TestResult +from src.babel_validation.services.nameres import CachedNameRes +from src.babel_validation.services.nodenorm import CachedNodeNorm + +_logger = logging.getLogger(__name__) + + +def _to_list(value, context: str) -> list: + """Normalize a YAML value that may be a bare string or a list; raise on anything else.""" + if isinstance(value, str): + return [value] + if isinstance(value, list): + return value + raise ValueError(f"{context}: expected str or list, got {type(value).__name__}") + + +class GitHubIssueTest: + """Represents one assertion extracted from a GitHub issue body — an assertion name paired with a list of param_sets to evaluate.""" + + def __init__(self, github_issue_id: str, github_issue: Issue.Issue, assertion: str, param_sets: list[list[str]] = None): + """ + :param github_issue_id: Human-readable issue identifier, e.g. "org/repo#42". + :param github_issue: The PyGitHub Issue object this test was extracted from. + :param assertion: The assertion name (case-insensitive), e.g. "Resolves" or "HasLabel". + Must match a key in ASSERTION_HANDLERS. + :param param_sets: A list of param_sets (list[list[str]]) to evaluate for this assertion. + Each inner list is one param_set — see module docstring for details. + """ + if not isinstance(param_sets, list) and param_sets is not None: + raise ValueError(f"param_sets must be a list when creating a GitHubIssueTest({github_issue}, {assertion}, {param_sets})") + self.github_issue = github_issue + self.assertion = assertion + self.param_sets = param_sets if param_sets is not None else [] + self.github_issue_id = github_issue_id + + _logger.info("Creating GitHubIssueTest for %s %s(%s)", github_issue.html_url, assertion, param_sets) + + def __str__(self): + return f"{self.github_issue_id}: {self.assertion}({len(self.param_sets)} param sets: {json.dumps(self.param_sets)})" + + def _get_handler(self): + handler = ASSERTION_HANDLERS.get(self.assertion.lower()) + if handler is None: + raise ValueError(f"Unknown assertion type for {self}: {self.assertion}") + return handler + + def test_with_nodenorm(self, nodenorm: CachedNodeNorm) -> Iterator[TestResult]: + return self._get_handler().test_with_nodenorm(self.param_sets, nodenorm, label=str(self)) + + def test_with_nameres(self, nodenorm: CachedNodeNorm, nameres: CachedNameRes, pass_if_found_in_top=5) -> Iterator[TestResult]: + return self._get_handler().test_with_nameres(self.param_sets, nodenorm, nameres, pass_if_found_in_top, label=str(self)) + + +class GitHubIssuesTestCases: + """ + The idea here is to allow test cases to be efficiently embedded within GitHub issues, to test them + regularly, and to provide a list of cases where either: + - An open issue has test cases that are now passing (and so should be updated or maybe even closed). + - A closed issue has test cases that are now failing (and so should be reopened). + """ + + # Case-insensitive, matching the case-insensitivity of assertion names. + # Group 1 captures everything between '{{BabelTest|' and '}}'. + _BABELTEST_RE = re.compile(r'{{BabelTest\|(.*?)}}', re.IGNORECASE) + _BABELTEST_YAML_RE = re.compile(r'```yaml\s+babel_tests:\s+.*?\s+```', re.DOTALL) + + def __init__(self, github_token: str, github_repositories): + """ + Create a GitHubIssuesTestCase object. + + Requires a GitHub authentication token. You can generate a personal authentication token + at https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#about-personal-access-tokens, + or you can read the GITHUB_TOKEN during a GitHub Action (https://docs.github.com/en/actions/tutorials/authenticate-with-github_token). + + :param github_token: A GitHub authentication to use for making these queries. + :param github_repositories: A list of GitHub repositories to pull issues from, specified as 'organization/repo'. + """ + self.github_token = github_token + if not self.github_token or self.github_token.strip() == '': + raise ValueError("No GitHub authentication token provided.") + + self.github = Github(auth=Auth.Token(self.github_token)) + self.logger = logging.getLogger(self.__class__.__name__) + + if not github_repositories: + raise ValueError("No GitHub repositories specified in `github_repositories`.") + self.github_repositories = github_repositories + self.logger.info("Configured GitHub repositories: %s", self.github_repositories) + + def get_test_issues_from_issue(self, github_issue: Issue.Issue) -> list[GitHubIssueTest]: + """ + Extract test rows from a single GitHub issue. + + Two syntaxes are supported: + - Wiki syntax: {{BabelTest|AssertionType|param1|param2|...}} + - YAML syntax: + + ```yaml + babel_tests: + assertion: + - param1 + - ['param1', 'param2'] + ``` + + For the full list of supported assertion types and their parameters, see + src/babel_validation/assertions/README.md or inspect ASSERTION_HANDLERS.keys(). + + :param github_issue: A single GitHub issue to extract test cases from. + :return: A list of GitHubIssueTest objects found in the issue body. + """ + + github_issue_id = f"{github_issue.repository.full_name}#{github_issue.number}" + self.logger.debug("Looking for tests in issue %s: %s (%s, %s)", + github_issue_id, github_issue.title, github_issue.state, github_issue.html_url) + + # Is there an issue body at all? + if not github_issue.body or github_issue.body.strip() == '': + return [] + + # Look for BabelTest syntax. + testrows = [] + + for babeltest_match in self._BABELTEST_RE.finditer(github_issue.body): + match = babeltest_match.group(0) + self.logger.info("Found BabelTest in issue %s: %s", github_issue_id, match) + + # Figure out parameters. + test_string = babeltest_match.group(1) + params = test_string.split("|") + if not params or not params[0]: + raise ValueError(f"Missing assertion name in BabelTest in issue {github_issue_id}: {match}") + # Wiki syntax: params[0] is the assertion name; params[1:] form a single + # param_set (may be empty for assertions like Needed), so param_sets is a + # one-element list: [params[1:]]. + testrows.append(GitHubIssueTest(github_issue_id, github_issue, params[0], [params[1:]])) + + babeltest_yaml_matches = re.findall(self._BABELTEST_YAML_RE, github_issue.body) + if babeltest_yaml_matches: + for match in babeltest_yaml_matches: + self.logger.info("Found BabelTest YAML in issue %s: %s", github_issue_id, match) + + # Parse string as YAML. + yaml_dict = yaml.safe_load(match.removeprefix("```yaml").removesuffix("```")) + + babel_tests = yaml_dict.get('babel_tests') if isinstance(yaml_dict, dict) else None + if babel_tests is None: + raise ValueError( + f"YAML block in issue {github_issue_id} matched the detection pattern " + f"but contains no 'babel_tests' top-level key: {match!r}" + ) + if not isinstance(babel_tests, dict): + raise ValueError( + f"YAML block in issue {github_issue_id}: 'babel_tests' must be a mapping of " + f"assertion name to param sets, but got {type(babel_tests).__name__}: {babel_tests!r}" + ) + + for assertion, original_param_sets in babel_tests.items(): + # YAML syntax: each entry under an assertion key becomes one param_set. + # A bare string becomes a single-element param_set; a list is used as-is. + if not isinstance(assertion, str): + raise ValueError( + f"YAML block in issue {github_issue_id}: assertion name must be a string, " + f"but got {type(assertion).__name__}: {assertion!r}" + ) + if original_param_sets is None: + raise ValueError( + f"YAML block in issue {github_issue_id}: assertion '{assertion}' has a null " + f"param list — use an empty list [] or remove the entry" + ) + normalized = _to_list( + original_param_sets, + f"YAML block in issue {github_issue_id}: assertion '{assertion}'" + ) + param_sets = [ + _to_list(ps, f"YAML block in issue {github_issue_id}: assertion '{assertion}' param_set") + for ps in normalized + ] + testrows.append(GitHubIssueTest(github_issue_id, github_issue, assertion, param_sets)) + + return testrows + + def issue_has_tests(self, issue: Issue.Issue) -> bool: + """Quick regex check to see if an issue body contains any BabelTest syntax.""" + if not issue.body or issue.body.strip() == '': + return False + return bool(self._BABELTEST_RE.search(issue.body) or + self._BABELTEST_YAML_RE.search(issue.body)) + + def get_issues_by_ids(self, issue_ids: list[str]) -> list[Issue.Issue]: + """ + Fetch specific GitHub issues by their ID strings, supporting three formats: + - 'org/repo#N' → direct fetch from that repo + - 'repo#N' → search self.github_repositories for matching repo name + - 'N' → fetch #N from all configured repositories + """ + from github import UnknownObjectException + issues = [] + for issue_id in issue_ids: + found = False + if m := re.match(r'^([^/]+)/([^#]+)#(\d+)$', issue_id): + # org/repo#N + issue = self.github.get_repo(f"{m.group(1)}/{m.group(2)}").get_issue(int(m.group(3))) + issues.append(issue) + found = True + elif m := re.match(r'^([^/#]+)#(\d+)$', issue_id): + # repo#N — find repo in configured list + repo_name, num = m.group(1), int(m.group(2)) + for full_repo in self.github_repositories: + parts = full_repo.split('/') + if len(parts) >= 2 and parts[1] == repo_name: + issues.append(self.github.get_repo(full_repo).get_issue(num)) + found = True + break + elif m := re.match(r'^(\d+)$', issue_id): + # N — try all configured repos; skip repos that don't have this issue number. + num = int(m.group(1)) + for full_repo in self.github_repositories: + try: + issues.append(self.github.get_repo(full_repo).get_issue(num)) + found = True + except UnknownObjectException: + pass + if not found: + raise ValueError( + f"Could not resolve issue ID {issue_id!r} in configured repositories " + f"{self.github_repositories}. Use 'org/repo#N', 'repo#N', or 'N'." + ) + return issues + + def get_issues_with_tests(self, github_repositories=None) -> Iterator[Issue.Issue]: + """Use GitHub search API to find only issues containing BabelTest syntax. + + This is much faster than get_all_issues() + issue_has_tests() filtering because + it only fetches issues that match the search query rather than paginating through + every issue in each repository. + + Note: GitHub's search index has a ~60-second lag for very recent edits. For + immediate testing of freshly-edited issues, use --issue which calls + get_issues_by_ids() directly. + """ + if github_repositories is None: + github_repositories = self.github_repositories + for repo_id in github_repositories: + seen_numbers = set() + for keyword in ['{{BabelTest', 'babel_tests:']: + query = f'"{keyword}" is:issue in:body repo:{repo_id}' + self.logger.info("Searching GitHub issues with query: %s", query) + for issue in self.github.search_issues(query): + if issue.number not in seen_numbers: + seen_numbers.add(issue.number) + if self.issue_has_tests(issue): + yield issue + + def get_all_issues(self, github_repositories=None) -> Iterator[Issue.Issue]: + """ + Get a list of test rows from one or more repositories. + + :param github_repositories: A list of GitHub repositories to search for test cases. If none is provided, + we default to the list specified when creating this GitHubIssuesTestCases class. + :return: A list of TestRows to process. + """ + if github_repositories is None: + github_repositories = self.github_repositories + + for repo_id in github_repositories: + self.logger.info("Looking up issues in GitHub repository %s", repo_id) + repo = self.github.get_repo(repo_id, lazy=True) + + issue_count = 0 + for issue in tqdm(repo.get_issues(state='all', sort='updated'), desc=f"Processing issues in {repo_id}"): + issue_count += 1 + yield issue + + self.logger.info("Found %d issues in GitHub repository %s", issue_count, repo_id) diff --git a/src/babel_validation/sources/google_sheets/__init__.py b/src/babel_validation/sources/google_sheets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/common/blocklist.py b/src/babel_validation/sources/google_sheets/blocklist.py similarity index 100% rename from tests/common/blocklist.py rename to src/babel_validation/sources/google_sheets/blocklist.py diff --git a/tests/common/google_sheet_test_cases.py b/src/babel_validation/sources/google_sheets/google_sheet_test_cases.py similarity index 57% rename from tests/common/google_sheet_test_cases.py rename to src/babel_validation/sources/google_sheets/google_sheet_test_cases.py index aecdda91..935b019e 100644 --- a/tests/common/google_sheet_test_cases.py +++ b/src/babel_validation/sources/google_sheets/google_sheet_test_cases.py @@ -3,66 +3,19 @@ # # This library contains classes and methods for accessing those test cases. import csv +import hashlib import io -from dataclasses import dataclass +import tempfile +import time from collections import Counter +from pathlib import Path import pytest import requests from _pytest.mark import ParameterSet +from filelock import FileLock - -@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 TestRow. - __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', '') == 'y', - ExpectPassInNameRes=row.get('Passes in NameRes', '') == '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', '') - ) +from src.babel_validation.core.testrow import TestRow class GoogleSheetTestCases: @@ -73,6 +26,11 @@ class GoogleSheetTestCases: def __str__(self): return f"Google Sheet Test Cases ({len(self.rows)} test cases from {self.google_sheet_id})" + # How long a cached download stays valid. pytest deletes the cache at the + # start of every run (see tests/conftest.py), so this TTL mainly protects + # other consumers (e.g. csv-to-babeltests) from reading stale data forever. + CACHE_TTL_SECONDS = 3600 + def __init__(self, google_sheet_id="11zebx8Qs1Tc3ShQR9nh4HRW8QSoo8k65w_xIaftN0no"): """ Create a Google Sheet test case. @@ -80,9 +38,20 @@ def __init__(self, google_sheet_id="11zebx8Qs1Tc3ShQR9nh4HRW8QSoo8k65w_xIaftN0no """ self.google_sheet_id = google_sheet_id - csv_url = f"https://docs.google.com/spreadsheets/d/{google_sheet_id}/gviz/tq?tqx=out:csv&sheet=Tests" - response = requests.get(csv_url) - self.csv_content = response.text + + sheet_hash = hashlib.md5(google_sheet_id.encode()).hexdigest()[:8] + cache_file = Path(tempfile.gettempdir()) / f"babel_validation_gsheet_{sheet_hash}.csv" + lock_file = cache_file.with_suffix(".lock") + + with FileLock(lock_file): + if cache_file.exists() and time.time() - cache_file.stat().st_mtime < self.CACHE_TTL_SECONDS: + self.csv_content = cache_file.read_text(encoding="utf-8") + else: + csv_url = f"https://docs.google.com/spreadsheets/d/{google_sheet_id}/gviz/tq?tqx=out:csv&sheet=Tests" + response = requests.get(csv_url, timeout=30) + response.raise_for_status() + self.csv_content = response.text + cache_file.write_text(self.csv_content, encoding="utf-8") self.rows = [] with io.StringIO(self.csv_content) as f: @@ -106,7 +75,8 @@ def has_nonempty_value(d: dict): for count, row in enumerate(self.rows): # Note that count is off by two: presumably one for the header row and one because we count from zero # but Google Sheets counts from one. - row_id = f"{test_id_prefix}:row={count + 2}" + row_count = count + 2 + row_id = f"{test_id_prefix}:row={row_count}" if has_nonempty_value(row): tr = TestRow.from_data_row(row) @@ -118,7 +88,7 @@ def has_nonempty_value(d: dict): trows.append(pytest.param( tr, marks=pytest.mark.xfail( - reason=f"Test row {count + 2} is marked as not expected to pass NodeNorm in the " + reason=f"Test row {row_count} is marked as not expected to pass NodeNorm in the " f"Google Sheet: {tr}", strict=True), id=row_id @@ -131,7 +101,7 @@ def has_nonempty_value(d: dict): trows.append(pytest.param( tr, marks=pytest.mark.xfail( - reason=f"Test row {count + 2} is marked as not expected to pass NameRes in the " + reason=f"Test row {row_count} is marked as not expected to pass NameRes in the " f"Google Sheet: {tr}", strict=True), id=row_id @@ -141,4 +111,4 @@ def has_nonempty_value(d: dict): def categories(self): """ Return a dict of all the categories of tests available with their counts. """ - return Counter(map(lambda t: t.get('Category', ''), self.rows)) \ No newline at end of file + return Counter(map(lambda t: t.get('Category', ''), self.rows)) diff --git a/src/babel_validation/tools/__init__.py b/src/babel_validation/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/babel_validation/tools/csv_to_babeltests.py b/src/babel_validation/tools/csv_to_babeltests.py new file mode 100644 index 00000000..565f23f0 --- /dev/null +++ b/src/babel_validation/tools/csv_to_babeltests.py @@ -0,0 +1,434 @@ +"""csv-to-babeltests +==================== + +Convert a CSV of (CURIE, label/type/equivalent-CURIE) rows into a YAML +``babel_tests:`` block suitable for pasting into a GitHub issue, optionally +validating each row against a NodeNorm endpoint defined in +``tests/targets.ini``. + +The emitted YAML is the same format consumed by +``GitHubIssuesTestCases.get_test_issues_from_issue`` — assertion handlers and +YAML schema are reused, not duplicated. + +Run: + uv run csv-to-babeltests INPUT.csv --curie-column OutputID \\ + --label-column "Expected Result / Suggested Comparator" --target dev +""" + +from __future__ import annotations + +import configparser +import csv +import io +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +import click +import yaml + +from src.babel_validation.assertions import ASSERTION_HANDLERS +from src.babel_validation.core.testrow import TestStatus +from src.babel_validation.services.nodenorm import CachedNodeNorm + + +# --- YAML emission --------------------------------------------------------- + +class _FlowList(list): + """List subclass that _BabelTestDumper emits in inline flow style.""" + + +def _represent_flow_list(dumper, data): + return dumper.represent_sequence( + "tag:yaml.org,2002:seq", data, flow_style=True + ) + + +class _BabelTestDumper(yaml.SafeDumper): + """Module-local dumper so the _FlowList representer doesn't mutate the global + yaml.SafeDumper (which would change YAML output process-wide for other code).""" + + +_BabelTestDumper.add_representer(_FlowList, _represent_flow_list) + + +# --- Data structures ------------------------------------------------------- + +@dataclass +class BlockEntry: + """One assertion invocation: a param_set with provenance back to a CSV row.""" + row_idx: int # 1-based; header is row 1, first data row is row 2. + param_set: list[str] + + +@dataclass +class ValidationResult: + assertion: str + row_idx: int + param_set: list[str] + status: TestStatus + messages: list[str] = field(default_factory=list) + + +# --- Pure functions (testable without network) ----------------------------- + +def read_csv(path: Path | str, delimiter: str | None = None) -> list[dict[str, str]]: + """Read a CSV file (or '-' for stdin) into a list of dict rows. + + If ``delimiter`` is None we let csv.Sniffer guess from the first 4KiB, + falling back to ',' on failure. + """ + if str(path) == "-": + text = sys.stdin.read() + else: + text = Path(path).read_text(encoding="utf-8-sig") # strip any BOM + + if delimiter is None: + try: + delimiter = csv.Sniffer().sniff(text[:4096], delimiters=",\t;|").delimiter + except csv.Error: + delimiter = "," + + reader = csv.DictReader(io.StringIO(text), delimiter=delimiter) + return list(reader) + + +def build_blocks( + rows: list[dict[str, str]], + *, + curie_column: str, + label_column: str | None, + type_column: str | None, + equivalent_curie_column: str | None, + emit_resolves: bool, + dedupe: bool, + skip_empty: bool, +) -> tuple[dict[str, list[BlockEntry]], list[str]]: + """Turn CSV rows into ``{assertion_name: [BlockEntry, ...]}``. + + Returns ``(blocks, warnings)``. Warnings is a list of human-readable + strings the caller can dump to stderr (e.g. "row 17: empty OutputID"). + """ + blocks: dict[str, list[BlockEntry]] = defaultdict(list) + warnings: list[str] = [] + seen: dict[str, set[tuple[str, ...]]] = defaultdict(set) + + def add(assertion: str, param_set: list[str], row_idx: int) -> None: + key = tuple(param_set) + if dedupe and key in seen[assertion]: + return + seen[assertion].add(key) + blocks[assertion].append(BlockEntry(row_idx=row_idx, param_set=param_set)) + + for offset, row in enumerate(rows): + row_idx = offset + 2 # match what spreadsheet UIs show: header is row 1. + + curie = (row.get(curie_column) or "").strip() + if not curie: + warnings.append(f"row {row_idx}: empty {curie_column!r} — skipping") + continue + + if label_column is not None: + label = (row.get(label_column) or "").strip() + if not label and skip_empty: + warnings.append(f"row {row_idx}: empty {label_column!r} — skipping HasLabel") + else: + add("HasLabel", [curie, label], row_idx) + + if type_column is not None: + biolink_type = (row.get(type_column) or "").strip() + if not biolink_type and skip_empty: + warnings.append(f"row {row_idx}: empty {type_column!r} — skipping ResolvesWithType") + else: + add("ResolvesWithType", [biolink_type, curie], row_idx) + + if equivalent_curie_column is not None: + equiv = (row.get(equivalent_curie_column) or "").strip() + if not equiv and skip_empty: + warnings.append(f"row {row_idx}: empty {equivalent_curie_column!r} — skipping ResolvesWith") + else: + add("ResolvesWith", [curie, equiv], row_idx) + + if emit_resolves: + add("Resolves", [curie], row_idx) + + return dict(blocks), warnings + + +def emit_yaml( + blocks: dict[str, list[BlockEntry]], + *, + fence: bool = True, + header: str | None = None, +) -> str: + """Render a ``{assertion_name: [BlockEntry, ...]}`` map as a YAML block. + + Each param_set is emitted as an inline flow list so the output reads like + the existing examples in ``assertions/nodenorm.py``:: + + babel_tests: + HasLabel: + - [CHEBI:15365, aspirin] + + A round-trip self-check via ``yaml.safe_load`` ensures we never emit + something the GitHub-issue parser would reject. + """ + # Match the convention used in src/babel_validation/assertions/nodenorm.py: + # single-element param_sets are emitted as bare strings ("- CHEBI:15365"), + # multi-element ones as inline flow lists ("- [CHEBI:15365, aspirin]"). + # Both parse to the same param_set via the GitHub issue loader. + data = { + "babel_tests": { + assertion: [ + e.param_set[0] if len(e.param_set) == 1 else _FlowList(e.param_set) + for e in entries + ] + for assertion, entries in blocks.items() + } + } + body = yaml.dump( + data, + Dumper=_BabelTestDumper, + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + width=10_000, + ) + + # Round-trip self-check; raises if our output isn't loadable. + yaml.safe_load(body) + + parts: list[str] = [] + if header: + parts.append(f"# {header}") + if fence: + parts.append("```yaml") + parts.append(body.rstrip()) + if fence: + parts.append("```") + return "\n".join(parts) + "\n" + + +# --- Validation ------------------------------------------------------------ + +def validate_blocks( + blocks: dict[str, list[BlockEntry]], + nodenorm: CachedNodeNorm, +) -> list[ValidationResult]: + """Run each (assertion, param_set) through the matching ASSERTION_HANDLER. + + Param_sets are evaluated one at a time so each ``TestResult`` can be + attributed back to its source CSV row. ``CachedNodeNorm`` deduplicates + network calls per CURIE, so the per-row loop is no slower than batching. + """ + # Pre-warm the cache with every CURIE we'll need across all blocks. + all_curies: set[str] = set() + for assertion, entries in blocks.items(): + handler = ASSERTION_HANDLERS[assertion.lower()] + for entry in entries: + all_curies.update(handler.curie_params(entry.param_set)) + if all_curies: + nodenorm.normalize_curies(list(all_curies)) + + results: list[ValidationResult] = [] + for assertion, entries in blocks.items(): + handler = ASSERTION_HANDLERS[assertion.lower()] + for entry in entries: + test_results = list( + handler.test_with_nodenorm( + [entry.param_set], nodenorm, + label=f"row {entry.row_idx}", + ) + ) + if test_results and all(r.status == TestStatus.Passed for r in test_results): + status = TestStatus.Passed + messages: list[str] = [] + else: + status = TestStatus.Failed + messages = [r.message for r in test_results + if r.status != TestStatus.Passed] or ["no result"] + results.append(ValidationResult( + assertion=assertion, + row_idx=entry.row_idx, + param_set=entry.param_set, + status=status, + messages=messages, + )) + return results + + +def format_report( + results: list[ValidationResult], + target_name: str, + nodenorm_url: str, +) -> str: + """Human-readable validation summary, suitable for stderr.""" + by_assertion: dict[str, list[ValidationResult]] = defaultdict(list) + for r in results: + by_assertion[r.assertion].append(r) + + lines = [f"Validation against target {target_name!r} ({nodenorm_url}):"] + for assertion, rows in by_assertion.items(): + passed = sum(1 for r in rows if r.status == TestStatus.Passed) + failed = sum(1 for r in rows if r.status == TestStatus.Failed) + lines.append(f" {assertion}: {passed} passed, {failed} failed.") + for r in rows: + if r.status == TestStatus.Failed: + params_str = ", ".join(r.param_set) + lines.append( + f" FAIL row {r.row_idx} [{params_str}] → {r.messages[0]}" + ) + return "\n".join(lines) + "\n" + + +# --- targets.ini ----------------------------------------------------------- + +def _default_targets_ini() -> Path: + """Locate tests/targets.ini relative to this file's repo.""" + # src/babel_validation/tools/csv_to_babeltests.py → ../../../tests/targets.ini + return Path(__file__).resolve().parents[3] / "tests" / "targets.ini" + + +def load_nodenorm_url(target_name: str, targets_ini_path: Path) -> str: + """Look up ``NodeNormURL`` for ``target_name`` in ``targets_ini_path``.""" + if not targets_ini_path.is_file(): + raise click.ClickException(f"targets.ini not found at {targets_ini_path}") + cp = configparser.ConfigParser() + cp.read(targets_ini_path, encoding="utf-8") + if target_name not in cp: + raise click.ClickException( + f"target {target_name!r} not found in {targets_ini_path}; " + f"available: {', '.join(cp.sections())}" + ) + section = cp[target_name] + if "NodeNormURL" not in section: + raise click.ClickException( + f"target {target_name!r} in {targets_ini_path} has no NodeNormURL" + ) + return section["NodeNormURL"] + + +# --- CLI ------------------------------------------------------------------- + +@click.command(context_settings={"help_option_names": ["-h", "--help"]}) +@click.argument( + "input_csv", + type=click.Path(exists=True, dir_okay=False, allow_dash=True, path_type=Path), +) +@click.option( + "--curie-column", required=True, + help="Column name containing the primary CURIE.", +) +@click.option( + "--label-column", default=None, + help="Column with the expected label → emits a HasLabel block.", +) +@click.option( + "--type-column", default=None, + help="Column with a Biolink type (e.g. 'biolink:SmallMolecule') → emits a ResolvesWithType block.", +) +@click.option( + "--equivalent-curie-column", default=None, + help="Column with a CURIE that should merge to the same canonical id → emits a ResolvesWith block.", +) +@click.option( + "--resolves/--no-resolves", "emit_resolves_flag", default=None, + help=("Force-emit (or suppress) a Resolves block. Default: emit a Resolves " + "block only when no other assertion column was given."), +) +@click.option("--dedupe", is_flag=True, default=False, + help="Drop duplicate param_sets within each assertion block.") +@click.option("--skip-empty/--no-skip-empty", default=True, + help="Skip rows where the assertion column is blank (with a stderr warning).") +@click.option("--delimiter", default=None, + help="CSV delimiter (default: auto-detect via csv.Sniffer; falls back to comma).") +@click.option("--target", default=None, + help="Target name from targets.ini to validate against (e.g. dev, prod, ci).") +@click.option( + "--targets-ini", + type=click.Path(exists=False, dir_okay=False, path_type=Path), + default=None, + help="Override path to targets.ini (default: tests/targets.ini in the repo).", +) +@click.option("--fence/--no-fence", default=True, + help="Wrap output in ```yaml … ``` Markdown fences (default: on).") +@click.option("--header", default=None, + help="Optional comment line emitted above the YAML block " + "(useful for recording provenance).") +def main( + input_csv: Path, + curie_column: str, + label_column: str | None, + type_column: str | None, + equivalent_curie_column: str | None, + emit_resolves_flag: bool | None, + dedupe: bool, + skip_empty: bool, + delimiter: str | None, + target: str | None, + targets_ini: Path | None, + fence: bool, + header: str | None, +) -> None: + """Convert INPUT_CSV into a BabelTests YAML block on stdout.""" + + # Default behavior for --resolves: emit Resolves only when the user + # didn't ask for any of the other assertion blocks. + if emit_resolves_flag is None: + emit_resolves = not (label_column or type_column or equivalent_curie_column) + else: + emit_resolves = emit_resolves_flag + + rows = read_csv(input_csv, delimiter=delimiter) + if rows and curie_column not in rows[0]: + raise click.ClickException( + f"--curie-column {curie_column!r} not found in CSV header. " + f"Available columns: {list(rows[0].keys())}" + ) + for col_name, col_label in [ + (label_column, "--label-column"), + (type_column, "--type-column"), + (equivalent_curie_column, "--equivalent-curie-column"), + ]: + if col_name and rows and col_name not in rows[0]: + raise click.ClickException( + f"{col_label} {col_name!r} not found in CSV header. " + f"Available columns: {list(rows[0].keys())}" + ) + + blocks, warnings = build_blocks( + rows, + curie_column=curie_column, + label_column=label_column, + type_column=type_column, + equivalent_curie_column=equivalent_curie_column, + emit_resolves=emit_resolves, + dedupe=dedupe, + skip_empty=skip_empty, + ) + + if not blocks: + raise click.ClickException( + "No assertions to emit — every row was skipped or no assertion " + "columns were given. Pass --label-column / --type-column / " + "--equivalent-curie-column, or use --resolves to force a Resolves " + "block on the CURIE column." + ) + + for w in warnings: + click.echo(w, err=True) + + yaml_text = emit_yaml(blocks, fence=fence, header=header) + sys.stdout.write(yaml_text) + + if target: + ini_path = targets_ini or _default_targets_ini() + nodenorm_url = load_nodenorm_url(target, ini_path) + nodenorm = CachedNodeNorm.from_url(nodenorm_url) + results = validate_blocks(blocks, nodenorm) + click.echo(format_report(results, target, nodenorm_url), err=True) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/_pytest_helpers.py b/tests/_pytest_helpers.py new file mode 100644 index 00000000..fa84dca4 --- /dev/null +++ b/tests/_pytest_helpers.py @@ -0,0 +1,31 @@ +"""Shared pytest helpers for deferring network-backed parametrization. + +Several test modules build their parametrization from network sources (the +Google Sheet, GitHub issues) at collection time. pytest only applies ``-m`` +marker deselection *after* test generation, so a run like ``pytest -m unit`` +would still pay for those fetches before discarding the tests. Evaluating the +marker expression ourselves lets us skip the fetch when the test won't run. +""" + + +def deselected_by_markexpr(metafunc) -> bool: + """True if an active ``-m`` marker expression would deselect this test. + + Returns False (i.e. "keep it") whenever there is no ``-m`` filter, the + internal expression API is unavailable, or the expression can't be parsed — + so this never suppresses a test that pytest would otherwise run. + """ + markexpr = metafunc.config.getoption("markexpr") + if not markexpr: + return False + try: + from _pytest.mark.expression import Expression + except ImportError: + # Internal API moved; fall back to the (network-using) default. + return False + own_markers = {m.name for m in metafunc.definition.iter_markers()} + try: + return not Expression.compile(markexpr).evaluate(lambda name: name in own_markers) + except Exception: + # Unparseable expression — let pytest handle it; don't suppress tests. + return False diff --git a/tests/conftest.py b/tests/conftest.py index 2515e227..58d9925d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,10 @@ # # conftest.py - pytest configuration settings # +import glob +import os import os.path +import tempfile import pytest import configparser @@ -25,6 +28,26 @@ def get_targets_ini_path(config): return config_path +def _silent_unlink(path: str) -> None: + try: + os.unlink(path) + except FileNotFoundError: + pass + + +def pytest_configure(config): + # Delete the Google Sheet CSV cache at the start of each run so tests always + # use a fresh download. Only the controller does this — xdist workers skip it + # so they can share the cache file written by the controller. + if not os.environ.get('PYTEST_XDIST_WORKER'): + for f in glob.glob(os.path.join(tempfile.gettempdir(), 'babel_validation_gsheet_*.csv')): + _silent_unlink(f) + _silent_unlink(f.removesuffix('.csv') + '.lock') + tmpdir = tempfile.gettempdir() + for name in ('babel_validation_issues_cache.json', 'babel_validation_issues_cache.lock'): + _silent_unlink(os.path.join(tmpdir, name)) + + def pytest_addoption(parser): # The target environment(s) to target. parser.addoption( @@ -47,6 +70,14 @@ def pytest_addoption(parser): help="The categories of tests to exclude." ) + # Only test particular GitHub issues. + parser.addoption( + '--issue', + default=[], + action='append', + help="One or more GitHub issues to test. Should be specified as either 'organization/repo#110', 'repo#110' or '110'" + ) + def read_targets(config_path): cp = configparser.ConfigParser() @@ -119,4 +150,9 @@ def category_test(cat): return False return True - return category_test \ No newline at end of file + return category_test + +# --issue is consumed by tests/github_issues/conftest.py; this fixture exposes it to any test that wants it. +@pytest.fixture +def selected_github_issues(pytestconfig): + return pytestconfig.getoption('issue') diff --git a/tests/github_issues/__init__.py b/tests/github_issues/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/github_issues/conftest.py b/tests/github_issues/conftest.py new file mode 100644 index 00000000..ec1bdb21 --- /dev/null +++ b/tests/github_issues/conftest.py @@ -0,0 +1,145 @@ +import configparser +import json +import os +import tempfile +from pathlib import Path + +import dotenv +import pytest +from filelock import FileLock +from github import GithubException, Issue + +from src.babel_validation.sources.github.github_issues_test_cases import GitHubIssuesTestCases +from tests._pytest_helpers import deselected_by_markexpr + +_github_token = None +_github_auth_error: str | None = None + +_AUTH_ERROR_ID = "github-auth-error" +_AUTH_HELP = ( + "GitHub token is expired or invalid. Generate a new one at " + "https://github.com/settings/tokens and set it as the GITHUB_TOKEN " + "environment variable (or in a .env file), then delete the cache file " + f"at {Path(tempfile.gettempdir()) / 'babel_validation_issues_cache.json'} " + "if it exists." +) + +_targets_config = configparser.ConfigParser() +_targets_config.read(Path(__file__).parent.parent / 'targets.ini') +_repos = [ + r.strip() + for r in _targets_config['DEFAULT']['Repositories'].splitlines() + if r.strip() +] +_github_issues_test_cases = None + +_CACHE_FILE = Path(tempfile.gettempdir()) / "babel_validation_issues_cache.json" +_LOCK_FILE = _CACHE_FILE.with_suffix(".lock") + +# Module-level cache to avoid re-fetching issues already retrieved during collection. +_fetched_issues_cache: dict[str, Issue.Issue] = {} + + +def _get_github_issues_test_cases() -> GitHubIssuesTestCases: + """Lazily construct GitHubIssuesTestCases, skipping tests if no token is available.""" + global _github_issues_test_cases, _github_token + if _github_issues_test_cases is not None: + return _github_issues_test_cases + if _github_token is None: + dotenv.load_dotenv() + _github_token = os.getenv('GITHUB_TOKEN') or '' + if not _github_token: + pytest.skip( + "GITHUB_TOKEN environment variable not set; skipping GitHub issues tests.", + allow_module_level=True, + ) + _github_issues_test_cases = GitHubIssuesTestCases(_github_token, _repos) + return _github_issues_test_cases + + +def _issue_id(issue: Issue.Issue) -> str: + """Derive a test ID from an issue without making extra API calls.""" + return f"{issue.repository.full_name}#{issue.number}" + + +def _record_auth_error(e: GithubException) -> None: + global _github_auth_error + _github_auth_error = f"{_AUTH_HELP}\n\nOriginal error: {e}" + + +_cached_ids: list[str] | None = None + + +def _get_all_test_issue_ids() -> list[str]: + """Return IDs of all issues that contain tests, using a file-based cache.""" + global _cached_ids + if _cached_ids is not None: + return _cached_ids + with FileLock(_LOCK_FILE): + if _CACHE_FILE.exists(): + try: + _cached_ids = json.loads(_CACHE_FILE.read_text()) + return _cached_ids + except (json.JSONDecodeError, OSError): + _CACHE_FILE.unlink(missing_ok=True) + try: + issues = list(_get_github_issues_test_cases().get_issues_with_tests()) + except GithubException as e: + if e.status == 401: + _record_auth_error(e) + _cached_ids = [_AUTH_ERROR_ID] + return _cached_ids + raise + ids = [_issue_id(i) for i in issues] + for issue, id_ in zip(issues, ids): + _fetched_issues_cache[id_] = issue + _CACHE_FILE.write_text(json.dumps(ids)) + _cached_ids = ids + return ids + + +def pytest_generate_tests(metafunc): + if "github_issue_id" not in metafunc.fixturenames: + return + if deselected_by_markexpr(metafunc): + # Parametrizing fetches issues from GitHub at collection time. Skip the + # network round trip when a -m filter (e.g. `pytest -m unit`) would + # deselect this test anyway. + metafunc.parametrize("github_issue_id", [], ids=[]) + return + issue_id_filter = metafunc.config.getoption("issue", default=[]) + if issue_id_filter: + try: + issues = _get_github_issues_test_cases().get_issues_by_ids(issue_id_filter) + except GithubException as e: + if e.status == 401: + _record_auth_error(e) + metafunc.parametrize("github_issue_id", [_AUTH_ERROR_ID], ids=[_AUTH_ERROR_ID]) + return + raise + ids = [_issue_id(i) for i in issues] + for issue, id_ in zip(issues, ids): + _fetched_issues_cache[id_] = issue + else: + ids = _get_all_test_issue_ids() + metafunc.parametrize("github_issue_id", ids, ids=ids) + + +@pytest.fixture +def github_issue(github_issue_id): + """Hydrate a GitHub Issue object from its string ID.""" + if github_issue_id == _AUTH_ERROR_ID: + pytest.fail(_github_auth_error or _AUTH_HELP) + if github_issue_id in _fetched_issues_cache: + return _fetched_issues_cache[github_issue_id] + try: + return _get_github_issues_test_cases().get_issues_by_ids([github_issue_id])[0] + except GithubException as e: + if e.status == 401: + pytest.fail(f"{_AUTH_HELP}\n\nOriginal error: {e}") + raise + + +@pytest.fixture(scope="session") +def github_issues_test_cases(): + return _get_github_issues_test_cases() diff --git a/tests/github_issues/test_github_issues.py b/tests/github_issues/test_github_issues.py new file mode 100644 index 00000000..32aa5790 --- /dev/null +++ b/tests/github_issues/test_github_issues.py @@ -0,0 +1,90 @@ +import itertools +import json + +import pytest + +from src.babel_validation.assertions import ASSERTION_HANDLERS +from src.babel_validation.services.nameres import CachedNameRes +from src.babel_validation.services.nodenorm import CachedNodeNorm +from src.babel_validation.core.testrow import TestResult, TestStatus + + +def test_github_issue(request, target_info, github_issue_id, github_issue, github_issues_test_cases, subtests): + nodenorm = CachedNodeNorm.from_url(target_info['NodeNormURL']) + nameres = CachedNameRes.from_url(target_info['NameResURL']) + # NameRes assertions pass if the expected CURIE is in the top N results; N + # comes from targets.ini (NameResXFailIfInTop) rather than being hardcoded. + pass_if_found_in_top = int(target_info.get('NameResXFailIfInTop', 5)) + tests = github_issues_test_cases.get_test_issues_from_issue(github_issue) + if not tests: + pytest.skip(f"No tests found in issue {github_issue}") + return + + is_open = github_issue.state == "open" + + # Unknown assertion types must fail hard, not XFAIL. + unknown = [f"'{t.assertion}'" for t in tests + if t.assertion.lower() not in ASSERTION_HANDLERS] + if unknown: + pytest.fail( + f"Issue {github_issue_id} uses unknown assertion type(s): " + f"{', '.join(unknown)} with param sets {json.dumps([t.param_sets for t in tests])}. " + f"Valid types (case-insensitive): {sorted(ASSERTION_HANDLERS.keys())}" + ) + + # Open issues are assumed to fail, so we set an xfail marker (but we set it to strict so + # that XPASSes are reported loudly). + if is_open: + request.node.add_marker(pytest.mark.xfail( + reason=f"Issue {github_issue.html_url} is expected to fail because the issue is open.", + strict=True, + )) + + count_subtests = 0 + failed_messages = [] + for test_issue in tests: + results_nodenorm = test_issue.test_with_nodenorm(nodenorm) + results_nameres = test_issue.test_with_nameres(nodenorm, nameres, pass_if_found_in_top) + + for result in itertools.chain(results_nodenorm, results_nameres): + count_subtests += 1 + + if is_open: + # Don't assert inside subtests for open issues: subtest failures + # don't respect the xfail marker on the parent test, so they would + # be reported as real failures. Collect them and xfail below instead. + if result.status == TestStatus.Failed: + failed_messages.append(f"{github_issue_id} ({github_issue.state}): {result.message}") + continue + + with subtests.test(msg=github_issue_id): + match result: + case TestResult(status=TestStatus.Passed, message=message): + assert True, f"{github_issue_id} ({github_issue.state}): {message}" + + case TestResult(status=TestStatus.Failed, message=message): + assert False, f"{github_issue_id} ({github_issue.state}): {message}" + + case TestResult(status=TestStatus.Skipped, message=message): + pytest.skip(f"{github_issue_id} ({github_issue.state}): {message}") + + case _: + assert False, f"Unknown result from {github_issue_id}: {result}" + + # For open issues: xfail so the result stays in the xfail family. + # - Some assertions failed → xfail with a count summary (expected outcome). + # - All assertions passed → the xfail marker added above makes this XPASS, + # which (strict=True) reports as a failure and signals the issue is closeable. + # - No assertions ran → xfail as a configuration error. + if is_open: + if count_subtests == 0: + pytest.xfail(f"Open issue {github_issue_id} produced no test results — check assertion configuration") + elif failed_messages: + pct = len(failed_messages) / count_subtests + details = "\n".join(failed_messages[:5]) + if len(failed_messages) > 5: + details += f"\n... and {len(failed_messages) - 5} more" + pytest.xfail( + f"Open issue {github_issue_id} has {len(failed_messages):,} failing assertions " + f"out of {count_subtests:,} ({pct:.0%}):\n{details}" + ) diff --git a/tests/github_issues/test_system.py b/tests/github_issues/test_system.py new file mode 100644 index 00000000..dafc93ed --- /dev/null +++ b/tests/github_issues/test_system.py @@ -0,0 +1,314 @@ +"""System tests for BabelTest trigger detection in GitHub issue bodies.""" + +from unittest.mock import MagicMock, patch +import pytest +import yaml + +from src.babel_validation.core.testrow import TestStatus +from src.babel_validation.sources.github.github_issues_test_cases import GitHubIssuesTestCases + +pytestmark = pytest.mark.unit + +INVALID_NAME = "NotARealAssertion" + + +@pytest.fixture +def github_issues_test_cases(): + """Override the conftest fixture: these unit tests never hit the GitHub API, + so build the parser with a dummy token rather than requiring GITHUB_TOKEN.""" + return GitHubIssuesTestCases("unit-test-dummy-token", ["test-org/test-repo"]) + + +def _mock_issue(body: str, number: int = 999) -> MagicMock: + """Minimal mock GitHub Issue for get_test_issues_from_issue().""" + issue = MagicMock() + issue.body = body + issue.number = number + issue.repository.full_name = "test-org/test-repo" + return issue + + +class TestInvalidAssertionNameDetection: + """Invalid assertion names are parsed but raise ValueError at execution time.""" + + def _wiki_issue(self): + return _mock_issue(f"{{{{BabelTest|{INVALID_NAME}|CHEBI:90926}}}}") + + def _yaml_issue(self): + return _mock_issue( + f"```yaml\nbabel_tests:\n {INVALID_NAME}:\n - CHEBI:90926\n```" + ) + + # --- parsing: invalid names are extracted, not rejected --- + + def test_wiki_syntax_parses_invalid_name(self, github_issues_test_cases): + tests = github_issues_test_cases.get_test_issues_from_issue(self._wiki_issue()) + assert len(tests) == 1 + assert tests[0].assertion == INVALID_NAME + + def test_yaml_syntax_parses_invalid_name(self, github_issues_test_cases): + tests = github_issues_test_cases.get_test_issues_from_issue(self._yaml_issue()) + assert len(tests) == 1 + assert tests[0].assertion == INVALID_NAME + + # --- execution: invalid names raise ValueError before any service call --- + + def test_wiki_invalid_name_raises_on_nodenorm(self, github_issues_test_cases): + tests = github_issues_test_cases.get_test_issues_from_issue(self._wiki_issue()) + with pytest.raises(ValueError, match="Unknown assertion type"): + list(tests[0].test_with_nodenorm(None)) + + def test_wiki_invalid_name_raises_on_nameres(self, github_issues_test_cases): + tests = github_issues_test_cases.get_test_issues_from_issue(self._wiki_issue()) + with pytest.raises(ValueError, match="Unknown assertion type"): + list(tests[0].test_with_nameres(None, None)) + + def test_yaml_invalid_name_raises_on_nodenorm(self, github_issues_test_cases): + tests = github_issues_test_cases.get_test_issues_from_issue(self._yaml_issue()) + with pytest.raises(ValueError, match="Unknown assertion type"): + list(tests[0].test_with_nodenorm(None)) + + def test_yaml_invalid_name_raises_on_nameres(self, github_issues_test_cases): + tests = github_issues_test_cases.get_test_issues_from_issue(self._yaml_issue()) + with pytest.raises(ValueError, match="Unknown assertion type"): + list(tests[0].test_with_nameres(None, None)) + + +@pytest.mark.unit +class TestTooManyParams: + """Extra params for fixed-arity assertions should yield a failed result, not silently pass.""" + + def _results(self, fixture, wiki_syntax, service="nodenorm"): + mock = _mock_issue(wiki_syntax) + tests = fixture.get_test_issues_from_issue(mock) + assert len(tests) == 1 + if service == "nodenorm": + return list(tests[0].test_with_nodenorm(MagicMock())) + else: + return list(tests[0].test_with_nameres(None, None)) + + def test_haslabel_too_many_params(self, github_issues_test_cases): + results = self._results( + github_issues_test_cases, + "{{BabelTest|HasLabel|CHEBI:15365|aspirin|unexpected}}" + ) + assert len(results) == 1 + assert results[0].status == TestStatus.Failed + assert "exactly two" in results[0].message + + def test_searchbyname_too_many_params(self, github_issues_test_cases): + results = self._results( + github_issues_test_cases, + "{{BabelTest|SearchByName|water|CHEBI:15377|unexpected}}", + service="nameres" + ) + assert len(results) == 1 + assert results[0].status == TestStatus.Failed + assert "exactly two" in results[0].message + + +@pytest.mark.unit +class TestTooFewParams: + """Comparison assertions require 2+ CURIEs; a single CURIE must fail loudly, + not pass (ResolvesWith) or fail (DoesNotResolveWith) vacuously.""" + + def _results(self, fixture, wiki_syntax): + mock = _mock_issue(wiki_syntax) + tests = fixture.get_test_issues_from_issue(mock) + assert len(tests) == 1 + return list(tests[0].test_with_nodenorm(MagicMock())) + + def test_resolveswith_single_curie_fails(self, github_issues_test_cases): + results = self._results( + github_issues_test_cases, "{{BabelTest|ResolvesWith|CHEBI:15365}}" + ) + assert len(results) == 1 + assert results[0].status == TestStatus.Failed + assert "at least two" in results[0].message + + def test_doesnotresolvewith_single_curie_fails(self, github_issues_test_cases): + results = self._results( + github_issues_test_cases, "{{BabelTest|DoesNotResolveWith|CHEBI:15365}}" + ) + assert len(results) == 1 + assert results[0].status == TestStatus.Failed + assert "at least two" in results[0].message + + +@pytest.mark.unit +class TestMalformedYaml: + """A YAML block that matches the detection regex but is not valid YAML raises yaml.YAMLError.""" + + MALFORMED_BODY = "```yaml\nbabel_tests:\n Resolves:\n - [unclosed bracket\n```" + + def test_malformed_yaml_raises(self, github_issues_test_cases): + mock = _mock_issue(self.MALFORMED_BODY) + with pytest.raises(yaml.YAMLError): + github_issues_test_cases.get_test_issues_from_issue(mock) + + +@pytest.mark.unit +class TestEmptyOrNullBabelTests: + """Documents behaviour when issue bodies contain empty or null babel test content.""" + + # --- empty/null body (already handled gracefully) --- + + def test_none_body_returns_empty(self, github_issues_test_cases): + mock = _mock_issue(None) + assert github_issues_test_cases.get_test_issues_from_issue(mock) == [] + + def test_whitespace_body_returns_empty(self, github_issues_test_cases): + mock = _mock_issue(" ") + assert github_issues_test_cases.get_test_issues_from_issue(mock) == [] + + # --- wiki syntax: assertion name only, no curie params --- + + def test_wiki_no_curie_params_parsed(self, github_issues_test_cases): + # {{BabelTest|Resolves}} with no params parses to an empty param_set, not a parse error. + mock = _mock_issue("{{BabelTest|Resolves}}") + tests = github_issues_test_cases.get_test_issues_from_issue(mock) + assert len(tests) == 1 + assert tests[0].assertion == "Resolves" + assert tests[0].param_sets == [[]] + + def test_wiki_needed_no_params(self, github_issues_test_cases): + # {{BabelTest|Needed}} with no extra params is valid per the documented wiki syntax. + mock = _mock_issue("{{BabelTest|Needed}}") + tests = github_issues_test_cases.get_test_issues_from_issue(mock) + assert len(tests) == 1 + assert tests[0].assertion == "Needed" + assert tests[0].param_sets == [[]] + + # --- YAML syntax: null / empty values --- + + def test_yaml_null_babel_tests_raises(self, github_issues_test_cases): + # babel_tests: null → ValueError with a clear message (null yaml value has no .items()) + mock = _mock_issue("```yaml\nbabel_tests:\n\n```") + with pytest.raises(ValueError, match="no 'babel_tests' top-level key"): + github_issues_test_cases.get_test_issues_from_issue(mock) + + def test_yaml_null_assertion_params_raises(self, github_issues_test_cases): + # Resolves: null → ValueError with a clear message (null param list is a config error) + mock = _mock_issue("```yaml\nbabel_tests:\n Resolves:\n```") + with pytest.raises(ValueError, match="null param list"): + github_issues_test_cases.get_test_issues_from_issue(mock) + + def test_yaml_empty_assertion_params(self, github_issues_test_cases): + # Resolves: [] → GitHubIssueTest with empty param_sets (no crash) + mock = _mock_issue("```yaml\nbabel_tests:\n Resolves: []\n```") + tests = github_issues_test_cases.get_test_issues_from_issue(mock) + assert len(tests) == 1 + assert tests[0].param_sets == [] + + def test_yaml_scalar_assertion_params_treated_as_single_param_set(self, github_issues_test_cases): + # Resolves: CHEBI:15365 (bare scalar) → wrapped as a single one-element param_set + mock = _mock_issue("```yaml\nbabel_tests:\n Resolves: CHEBI:15365\n```") + tests = github_issues_test_cases.get_test_issues_from_issue(mock) + assert len(tests) == 1 + assert tests[0].param_sets == [["CHEBI:15365"]] + + def test_yaml_babel_tests_as_list_raises(self, github_issues_test_cases): + # babel_tests as a list (not a mapping) → clear ValueError, not an AttributeError on .items(). + mock = _mock_issue("```yaml\nbabel_tests:\n - Resolves\n```") + with pytest.raises(ValueError, match="must be a mapping"): + github_issues_test_cases.get_test_issues_from_issue(mock) + + def test_yaml_non_string_assertion_name_raises(self, github_issues_test_cases): + # A non-string assertion key (e.g. an integer) → clear ValueError, not a later + # AttributeError when .lower() is called on the assertion name. + mock = _mock_issue("```yaml\nbabel_tests:\n 123:\n - CHEBI:15365\n```") + with pytest.raises(ValueError, match="assertion name must be a string"): + github_issues_test_cases.get_test_issues_from_issue(mock) + + +@pytest.mark.unit +class TestWikiMarkerCaseInsensitivity: + """The {{BabelTest|...}} marker is case-insensitive, like assertion names.""" + + @pytest.mark.parametrize("marker", ["BabelTest", "babeltest", "BABELTEST", "Babeltest"]) + def test_wiki_marker_any_case_parsed(self, github_issues_test_cases, marker): + mock = _mock_issue(f"{{{{{marker}|Resolves|CHEBI:15365}}}}") + tests = github_issues_test_cases.get_test_issues_from_issue(mock) + assert len(tests) == 1 + assert tests[0].assertion == "Resolves" + assert tests[0].param_sets == [["CHEBI:15365"]] + + def test_wiki_marker_any_case_detected(self, github_issues_test_cases): + assert github_issues_test_cases.issue_has_tests( + _mock_issue("{{babeltest|Resolves|CHEBI:12345}}") + ) is True + + +@pytest.mark.unit +class TestIssueHasTests: + """Documents issue_has_tests() behaviour for various body contents.""" + + def test_none_body_returns_false(self, github_issues_test_cases): + assert github_issues_test_cases.issue_has_tests(_mock_issue(None)) is False + + def test_whitespace_body_returns_false(self, github_issues_test_cases): + assert github_issues_test_cases.issue_has_tests(_mock_issue(" ")) is False + + def test_wiki_syntax_detected(self, github_issues_test_cases): + assert github_issues_test_cases.issue_has_tests( + _mock_issue("{{BabelTest|Resolves|CHEBI:12345}}") + ) is True + + def test_yaml_syntax_detected(self, github_issues_test_cases): + assert github_issues_test_cases.issue_has_tests( + _mock_issue("```yaml\nbabel_tests:\n Resolves:\n - CHEBI:12345\n```") + ) is True + + def test_plain_text_not_detected(self, github_issues_test_cases): + assert github_issues_test_cases.issue_has_tests( + _mock_issue("Just some text without babel tests.") + ) is False + + +@pytest.mark.unit +class TestGetIssuesWithTests: + """Documents the search-API path of get_issues_with_tests().""" + + _REPOS = ["test-org/test-repo"] + + def test_no_results_yields_nothing(self, github_issues_test_cases): + with patch.object(github_issues_test_cases.github, "search_issues", + return_value=[]): + results = list( + github_issues_test_cases.get_issues_with_tests(self._REPOS) + ) + assert results == [] + + def test_matching_issue_is_yielded(self, github_issues_test_cases): + mock = _mock_issue("{{BabelTest|Resolves|CHEBI:12345}}", number=1) + # Two keyword searches per repo; first returns our issue, second returns nothing. + with patch.object(github_issues_test_cases.github, "search_issues", + side_effect=[[mock], []]): + results = list( + github_issues_test_cases.get_issues_with_tests(self._REPOS) + ) + assert results == [mock] + + def test_duplicate_across_keywords_deduplicated(self, github_issues_test_cases): + # Issue contains both syntaxes → appears in both keyword searches → yielded once. + body = ( + "{{BabelTest|Resolves|CHEBI:12345}}\n" + "```yaml\nbabel_tests:\n Resolves:\n - CHEBI:12345\n```" + ) + mock = _mock_issue(body, number=42) + with patch.object(github_issues_test_cases.github, "search_issues", + side_effect=[[mock], [mock]]): + results = list( + github_issues_test_cases.get_issues_with_tests(self._REPOS) + ) + assert results == [mock] + + def test_search_false_positive_filtered(self, github_issues_test_cases): + # GitHub returns an issue that mentions the keyword in prose (no real BabelTest block). + mock = _mock_issue("This issue discusses babel_tests: in passing.", number=99) + with patch.object(github_issues_test_cases.github, "search_issues", + side_effect=[[mock], []]): + results = list( + github_issues_test_cases.get_issues_with_tests(self._REPOS) + ) + assert results == [] diff --git a/tests/nameres/test_blocklist.py b/tests/nameres/test_blocklist.py index 1d6df4d5..c1d803d2 100644 --- a/tests/nameres/test_blocklist.py +++ b/tests/nameres/test_blocklist.py @@ -3,13 +3,31 @@ import requests import pytest -from tests.common.blocklist import load_blocklist_from_gsheet +from src.babel_validation.sources.google_sheets.blocklist import load_blocklist_from_gsheet +from tests._pytest_helpers import deselected_by_markexpr -# Parameterize blocklist entries. -blocklist_entries = load_blocklist_from_gsheet() +# The blocklist Google Sheet is downloaded lazily in pytest_generate_tests so +# that runs which deselect these tests (e.g. `pytest -m unit`) never hit the +# network. +_blocklist_entries = None + + +def _get_blocklist_entries(): + global _blocklist_entries + if _blocklist_entries is None: + _blocklist_entries = load_blocklist_from_gsheet() + return _blocklist_entries + + +def pytest_generate_tests(metafunc): + if "blocklist_entry" not in metafunc.fixturenames: + return + if deselected_by_markexpr(metafunc): + metafunc.parametrize("blocklist_entry", []) + return + metafunc.parametrize("blocklist_entry", _get_blocklist_entries()) -@pytest.mark.parametrize("blocklist_entry", blocklist_entries) def test_check_blocklist_entry(target_info, blocklist_entry, categories_include): """ Test whether a NameRes instance has blocked every item from a blocklist. diff --git a/tests/nameres/test_nameres_from_gsheet.py b/tests/nameres/test_nameres_from_gsheet.py index 199e074d..1b9403f6 100644 --- a/tests/nameres/test_nameres_from_gsheet.py +++ b/tests/nameres/test_nameres_from_gsheet.py @@ -1,16 +1,38 @@ import urllib.parse import requests import pytest -from common.google_sheet_test_cases import GoogleSheetTestCases, TestRow +from src.babel_validation.sources.google_sheets.google_sheet_test_cases import GoogleSheetTestCases +from tests._pytest_helpers import deselected_by_markexpr # Configuration options NAMERES_TIMEOUT = 10 # If we don't get a response in 10 seconds, that's a fail. -# We generate a set of tests from the GoogleSheetTestCases. -gsheet = GoogleSheetTestCases() +# The Google Sheet is downloaded lazily in pytest_generate_tests so that runs +# which deselect these tests (e.g. `pytest -m unit`) never hit the network. +_gsheet = None + + +def _get_gsheet() -> GoogleSheetTestCases: + global _gsheet + if _gsheet is None: + _gsheet = GoogleSheetTestCases() + return _gsheet + + +def pytest_generate_tests(metafunc): + if "test_row" not in metafunc.fixturenames: + return + if deselected_by_markexpr(metafunc): + metafunc.parametrize("test_row", []) + return + metafunc.parametrize( + "test_row", + _get_gsheet().test_rows( + 'test_nameres_from_gsheet.test_label', test_nodenorm=False, test_nameres=True + ), + ) -@pytest.mark.parametrize("test_row", gsheet.test_rows('test_nameres_from_gsheet.test_label', test_nodenorm=False, test_nameres=True)) def test_label(target_info, test_row, test_category): nameres_url = target_info['NameResURL'] limit = target_info['NameResLimit'] @@ -56,8 +78,8 @@ def test_label(target_info, test_row, test_category): request = { "string": label, "autocomplete": autocomplete_flag, - "biolink_type": biolink_class, - "limit": limit + "biolink_type": [biolink_class], + "limit": limit, } if test_row.Prefixes: only_prefixes = [] diff --git a/tests/nodenorm/test_nodenorm_descriptions.py b/tests/nodenorm/test_nodenorm_descriptions.py index 75831944..1b82c6ff 100644 --- a/tests/nodenorm/test_nodenorm_descriptions.py +++ b/tests/nodenorm/test_nodenorm_descriptions.py @@ -6,15 +6,15 @@ import pytest import requests -IDENTIFIERS_WITH_DESCRIPTIONS = { +IDENTIFIERS_WITH_DESCRIPTIONS = [ 'MESH:D014867', 'NCIT:C34373', 'NCBIGene:1756', -} +] -IDENTIFIERS_WITHOUT_DESCRIPTIONS = { +IDENTIFIERS_WITHOUT_DESCRIPTIONS = [ 'UMLS:C0665297', # natalizumab -} +] @pytest.mark.parametrize('curie', IDENTIFIERS_WITH_DESCRIPTIONS) def test_descriptions(target_info, curie): diff --git a/tests/nodenorm/test_nodenorm_from_gsheet.py b/tests/nodenorm/test_nodenorm_from_gsheet.py index 5dfe43fb..057e8a7a 100644 --- a/tests/nodenorm/test_nodenorm_from_gsheet.py +++ b/tests/nodenorm/test_nodenorm_from_gsheet.py @@ -1,14 +1,35 @@ -import itertools import urllib.parse import requests import pytest -from common.google_sheet_test_cases import GoogleSheetTestCases, TestRow +from src.babel_validation.sources.google_sheets.google_sheet_test_cases import GoogleSheetTestCases +from tests._pytest_helpers import deselected_by_markexpr -# We generate a set of tests from the GoogleSheetTestCases. -gsheet = GoogleSheetTestCases() +# The Google Sheet is downloaded lazily in pytest_generate_tests so that runs +# which deselect these tests (e.g. `pytest -m unit`) never hit the network. +_gsheet = None + + +def _get_gsheet() -> GoogleSheetTestCases: + global _gsheet + if _gsheet is None: + _gsheet = GoogleSheetTestCases() + return _gsheet + + +def pytest_generate_tests(metafunc): + if "test_row" not in metafunc.fixturenames: + return + if deselected_by_markexpr(metafunc): + metafunc.parametrize("test_row", []) + return + metafunc.parametrize( + "test_row", + _get_gsheet().test_rows( + 'test_nodenorm_from_gsheet.test_row', test_nodenorm=True, test_nameres=False + ), + ) -@pytest.mark.parametrize("test_row", gsheet.test_rows('test_nodenorm_from_gsheet.test_row', test_nodenorm=True, test_nameres=False)) def test_normalization(target_info, test_row, test_category): nodenorm_url = target_info['NodeNormURL'] @@ -89,4 +110,4 @@ def test_normalization(target_info, test_row, test_category): f"found in types: {biolink_types}") else: assert biolink_type in set(biolink_types), (f"{test_summary} biolink type {biolink_type} not found in " - f"types: {biolink_types}") \ No newline at end of file + f"types: {biolink_types}") diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index 209c8a5e..00000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -# pytest.ini settings -[pytest] -# Timeout of 5 minutes (300 seconds) -timeout = 300 diff --git a/tests/targets.ini b/tests/targets.ini index d942212e..1484ce09 100644 --- a/tests/targets.ini +++ b/tests/targets.ini @@ -14,10 +14,15 @@ [DEFAULT] NameResLimit = 20 NameResXFailIfInTop = 5 - -[ci-es] -NodeNormURL = https://biothings.ci.transltr.io/nodenorm/ -NameResURL = https://name-lookup.ci.transltr.io/ +# GitHub repositories to scan for embedded BabelTest assertions. +# One "org/repo" per line. Read from this DEFAULT section only — the GitHub +# issue tests do not support per-environment overrides. +Repositories = + NCATSTranslator/Babel + NCATSTranslator/NodeNormalization + NCATSTranslator/NameResolution + TranslatorSRI/babel-validation + TranslatorSRI/babel-explorer [prod] NodeNormURL = https://nodenorm.transltr.io/ @@ -33,6 +38,10 @@ NameResURL = https://name-lookup.ci.transltr.io/ [ci] NodeNormURL = https://nodenorm-es.ci.transltr.io/ +NameResURL = https://name-lookup.ci.transltr.io/ + +[ci-es] +NodeNormURL = https://nodenorm-es.ci.transltr.io/ NameResURL = https://namelookup-es.ci.transltr.io/ [dev] 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" + ) diff --git a/tests/test_env.py b/tests/test_environment/test_env.py similarity index 79% rename from tests/test_env.py rename to tests/test_environment/test_env.py index 87a4f6af..5302198d 100644 --- a/tests/test_env.py +++ b/tests/test_environment/test_env.py @@ -1,7 +1,7 @@ # Test whether the test environment is functional. import json -from common.google_sheet_test_cases import GoogleSheetTestCases +from src.babel_validation.sources.google_sheets.google_sheet_test_cases import GoogleSheetTestCases def test_google_sheet_has_test_cases(): @@ -13,4 +13,4 @@ def test_google_sheet_has_test_cases(): print(f"Found {len(gsheet.rows)} test cases in {gsheet}: {json.dumps(gsheet.rows[:10], indent=2)}") categories = gsheet.categories() - assert 'Unit Tests' in categories \ No newline at end of file + assert 'Unit Tests' in categories diff --git a/tests/tools/test_csv_to_babeltests.py b/tests/tools/test_csv_to_babeltests.py new file mode 100644 index 00000000..32aad267 --- /dev/null +++ b/tests/tools/test_csv_to_babeltests.py @@ -0,0 +1,226 @@ +"""Unit tests for the csv-to-babeltests CLI helpers. + +These exercise the pure-function parts of the tool — CSV parsing, block +construction, YAML emission, and round-trip parsing through the same regex ++ yaml.safe_load that the GitHub-issue test discovery uses. No network. +""" + +import re +from pathlib import Path + +import pytest +import yaml + +from src.babel_validation.tools.csv_to_babeltests import ( + BlockEntry, + build_blocks, + emit_yaml, + read_csv, +) + + +pytestmark = pytest.mark.unit + + +# Same regex as src/babel_validation/sources/github/github_issues_test_cases.py +GITHUB_YAML_PATTERN = re.compile(r"```yaml\s+babel_tests:\s+.*?\s+```", re.DOTALL) + + +def _parse_emitted(yaml_text: str) -> dict: + """Run emit_yaml() output through the GitHub-issue parser path.""" + match = GITHUB_YAML_PATTERN.search(yaml_text) + assert match is not None, f"emitted YAML didn't match the issue regex:\n{yaml_text}" + return yaml.safe_load( + match.group(0).removeprefix("```yaml").removesuffix("```") + ) + + +# --- read_csv -------------------------------------------------------------- + +def test_read_csv_basic(tmp_path: Path): + p = tmp_path / "in.csv" + p.write_text("CURIE,Label\nCHEBI:15365,aspirin\nMONDO:1,asthma\n", encoding="utf-8") + rows = read_csv(p) + assert rows == [ + {"CURIE": "CHEBI:15365", "Label": "aspirin"}, + {"CURIE": "MONDO:1", "Label": "asthma"}, + ] + + +def test_read_csv_strips_bom(tmp_path: Path): + p = tmp_path / "in.csv" + p.write_bytes("CURIE,Label\nCHEBI:1,foo\n".encode("utf-8")) + rows = read_csv(p) + assert rows[0]["CURIE"] == "CHEBI:1" + + +def test_read_csv_handles_quoted_field_with_comma(tmp_path: Path): + p = tmp_path / "in.csv" + p.write_text( + 'CURIE,"Long, Name"\nCHEBI:1,"foo, bar"\n', encoding="utf-8" + ) + rows = read_csv(p) + assert rows[0] == {"CURIE": "CHEBI:1", "Long, Name": "foo, bar"} + + +# --- build_blocks ---------------------------------------------------------- + +ROWS = [ + {"OutputID": "CHEBI:15365", "Label": "aspirin", "Type": "biolink:SmallMolecule", "Equiv": "PUBCHEM.COMPOUND:1"}, + {"OutputID": "MONDO:0005015", "Label": "diabetes", "Type": "biolink:Disease", "Equiv": ""}, + {"OutputID": "", "Label": "missing", "Type": "", "Equiv": ""}, + {"OutputID": "CHEBI:15365", "Label": "aspirin", "Type": "biolink:SmallMolecule", "Equiv": "PUBCHEM.COMPOUND:1"}, # dup +] + + +def test_build_blocks_haslabel_only(): + blocks, warnings = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column=None, equivalent_curie_column=None, + emit_resolves=False, dedupe=False, skip_empty=True, + ) + assert set(blocks) == {"HasLabel"} + assert [e.param_set for e in blocks["HasLabel"]] == [ + ["CHEBI:15365", "aspirin"], + ["MONDO:0005015", "diabetes"], + ["CHEBI:15365", "aspirin"], # dup kept (dedupe=False) + ] + # row 4 (offset 2 → row index 4) was empty CURIE. + assert any("row 4" in w and "OutputID" in w for w in warnings) + + +def test_build_blocks_dedupe_drops_duplicate_param_sets(): + blocks, _ = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column=None, equivalent_curie_column=None, + emit_resolves=False, dedupe=True, skip_empty=True, + ) + assert [e.param_set for e in blocks["HasLabel"]] == [ + ["CHEBI:15365", "aspirin"], + ["MONDO:0005015", "diabetes"], + ] + + +def test_build_blocks_multiple_assertions(): + blocks, _ = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column="Type", equivalent_curie_column="Equiv", + emit_resolves=True, dedupe=True, skip_empty=True, + ) + # All four assertion blocks should be present. + assert set(blocks) == {"HasLabel", "ResolvesWithType", "ResolvesWith", "Resolves"} + # ResolvesWithType places the Biolink type first. + rwt = [e.param_set for e in blocks["ResolvesWithType"]] + assert rwt == [ + ["biolink:SmallMolecule", "CHEBI:15365"], + ["biolink:Disease", "MONDO:0005015"], + ] + # ResolvesWith only fires when Equiv is non-empty. + rw = [e.param_set for e in blocks["ResolvesWith"]] + assert rw == [["CHEBI:15365", "PUBCHEM.COMPOUND:1"]] + + +def test_build_blocks_row_indices_match_spreadsheet_rows(): + """row_idx should be 1-based with the header at row 1 — matches spreadsheet UIs.""" + blocks, _ = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column=None, equivalent_curie_column=None, + emit_resolves=False, dedupe=False, skip_empty=True, + ) + indices = [e.row_idx for e in blocks["HasLabel"]] + # ROWS[0] → row 2, ROWS[1] → row 3, ROWS[3] → row 5 (ROWS[2] was empty). + assert indices == [2, 3, 5] + + +# --- emit_yaml ------------------------------------------------------------- + +def test_emit_yaml_round_trips_through_github_parser(): + blocks = { + "HasLabel": [ + BlockEntry(2, ["CHEBI:15365", "aspirin"]), + BlockEntry(3, ["MONDO:0005015", "type 2 diabetes mellitus"]), + ], + "ResolvesWithType": [ + BlockEntry(2, ["biolink:SmallMolecule", "CHEBI:15365"]), + ], + } + out = emit_yaml(blocks, fence=True, header=None) + parsed = _parse_emitted(out) + assert list(parsed["babel_tests"].keys()) == ["HasLabel", "ResolvesWithType"] + assert parsed["babel_tests"]["HasLabel"] == [ + ["CHEBI:15365", "aspirin"], + ["MONDO:0005015", "type 2 diabetes mellitus"], + ] + assert parsed["babel_tests"]["ResolvesWithType"] == [ + ["biolink:SmallMolecule", "CHEBI:15365"], + ] + + +def test_emit_yaml_with_special_characters(): + """Labels with apostrophes, commas, brackets must round-trip.""" + blocks = { + "HasLabel": [ + BlockEntry(2, ["DRUGBANK:DB00001", "Adenosine 5'-phosphosulfate"]), + BlockEntry(3, ["CHEBI:1", "foo, bar [baz]"]), + BlockEntry(4, ["CHEBI:2", ""]), # empty label edge case + ], + } + out = emit_yaml(blocks) + parsed = _parse_emitted(out) + assert parsed["babel_tests"]["HasLabel"] == [ + ["DRUGBANK:DB00001", "Adenosine 5'-phosphosulfate"], + ["CHEBI:1", "foo, bar [baz]"], + ["CHEBI:2", ""], + ] + + +def test_emit_yaml_no_fence(): + blocks = {"Resolves": [BlockEntry(2, ["CHEBI:15365"])]} + out = emit_yaml(blocks, fence=False) + assert "```" not in out + assert "babel_tests:" in out + # Without the fence the GitHub parser regex shouldn't find a match. + assert GITHUB_YAML_PATTERN.search(out) is None + + +def test_emit_yaml_with_header_comment(): + blocks = {"Resolves": [BlockEntry(2, ["CHEBI:15365"])]} + out = emit_yaml(blocks, header="from data/test-assets.csv") + assert out.startswith("# from data/test-assets.csv\n") + # Header doesn't break the round-trip. + parsed = _parse_emitted(out) + # Single-element param_sets are emitted as bare strings (matching the + # convention in assertions/nodenorm.py); the GitHub parser later wraps + # them back into [["CHEBI:15365"]]. + assert parsed["babel_tests"]["Resolves"] == ["CHEBI:15365"] + + +def test_emit_yaml_single_element_param_sets_emit_as_bare_strings(): + """Single-element param_sets should match the existing YAML examples in + assertions/nodenorm.py — bare strings, not single-item flow lists.""" + blocks = {"Resolves": [BlockEntry(2, ["CHEBI:15365"]), BlockEntry(3, ["MONDO:1"])]} + out = emit_yaml(blocks, fence=False) + # Bare-string form, not single-element flow lists. + assert "- CHEBI:15365" in out + assert "- [CHEBI:15365]" not in out + + +# --- assertion-name compatibility with ASSERTION_HANDLERS ------------------ + +def test_emitted_assertion_names_match_handler_registry(): + """The names build_blocks emits must each resolve in ASSERTION_HANDLERS.""" + from src.babel_validation.assertions import ASSERTION_HANDLERS + blocks, _ = build_blocks( + ROWS, + curie_column="OutputID", label_column="Label", + type_column="Type", equivalent_curie_column="Equiv", + emit_resolves=True, dedupe=True, skip_empty=True, + ) + for assertion in blocks: + assert assertion.lower() in ASSERTION_HANDLERS, ( + f"build_blocks emitted unknown assertion name {assertion!r}" + ) diff --git a/uv.lock b/uv.lock index 53443ce8..c428e43c 100644 --- a/uv.lock +++ b/uv.lock @@ -23,24 +23,40 @@ wheels = [ [[package]] name = "babel-validation" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "black" }, + { name = "click" }, { name = "deepdiff" }, + { name = "filelock" }, { name = "openapi-spec-validator" }, + { name = "pygithub" }, { name = "pytest" }, + { name = "pytest-subtests" }, { name = "pytest-timeout" }, + { name = "pytest-xdist", extra = ["psutil"] }, + { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "requests" }, + { name = "tqdm" }, ] [package.metadata] requires-dist = [ { name = "black", specifier = ">=25.9.0" }, + { name = "click", specifier = ">=8.1" }, { name = "deepdiff", specifier = ">=8.6.1" }, + { name = "filelock" }, { name = "openapi-spec-validator", specifier = ">=0.7.2" }, - { name = "pytest", specifier = ">=8.4.2" }, + { name = "pygithub", specifier = ">=2.8.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-subtests" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, + { name = "pytest-xdist", extras = ["psutil"] }, + { name = "python-dotenv", specifier = ">=0.9.9" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.5" }, + { name = "tqdm", specifier = ">=4.67.1" }, ] [[package]] @@ -89,6 +105,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -183,6 +269,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + [[package]] name = "deepdiff" version = "8.6.1" @@ -195,6 +340,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -390,6 +553,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -516,6 +716,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pygithub" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/74/e560bdeffea72ecb26cff27f0fad548bbff5ecc51d6a155311ea7f9e4c4c/pygithub-2.8.1.tar.gz", hash = "sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9", size = 2246994, upload-time = "2025-09-02T17:41:54.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ba/7049ce39f653f6140aac4beb53a5aaf08b4407b6a3019aae394c1c5244ff/pygithub-2.8.1-py3-none-any.whl", hash = "sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0", size = 432709, upload-time = "2025-09-02T17:41:52.947Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -525,6 +741,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -541,6 +806,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-subtests" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0" @@ -553,6 +831,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[package.optional-dependencies] +psutil = [ + { name = "psutil" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -809,6 +1105,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"