From 4aead5e71acd92f3f353ea10961589118fc0c147 Mon Sep 17 00:00:00 2001 From: Philippe Saade Date: Tue, 7 Apr 2026 14:37:40 +0200 Subject: [PATCH] Create unit tests --- .gitignore | 3 + main.py | 2 - pyproject.toml | 7 +- tests/README.md | 68 ++++++++ tests/integration/test_live_routes.py | 219 ++++++++++++++++++++++++++ tests/unit/conftest.py | 48 ++++++ tests/unit/test_json_normalizer.py | 164 +++++++++++++++++++ tests/unit/test_routes.py | 84 ++++++++++ tests/unit/test_textifier.py | 78 +++++++++ tests/unit/test_utils.py | 101 ++++++++++++ tests/unit/test_wikidatalabel.py | 60 +++++++ uv.lock | 87 +++++++--- 12 files changed, 898 insertions(+), 23 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/integration/test_live_routes.py create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_json_normalizer.py create mode 100644 tests/unit/test_routes.py create mode 100644 tests/unit/test_textifier.py create mode 100644 tests/unit/test_utils.py create mode 100644 tests/unit/test_wikidatalabel.py diff --git a/.gitignore b/.gitignore index f9089cc..980c1f6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ *.db ./data/* +# Ruff Lint +.ruff_cache/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/main.py b/main.py index 23cffdc..9ffe3f2 100644 --- a/main.py +++ b/main.py @@ -91,8 +91,6 @@ async def get_textified_wd( - **all_ranks** (bool): If `true`, include preferred, normal, and deprecated statement ranks. - **qualifiers** (bool): If `true`, include qualifiers for claim values. - **fallback_lang** (str): Fallback language used when `lang` is unavailable. - - **request** (Request): FastAPI request context object. - - **background_tasks** (BackgroundTasks): Background task manager used for cache cleanup. **Returns:** diff --git a/pyproject.toml b/pyproject.toml index 6eedfea..7df145c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,13 +16,13 @@ dependencies = [ [dependency-groups] dev = [ + "pytest>=8.4.2", "ruff>=0.9.0" ] [tool.ruff] target-version = "py313" line-length = 120 - exclude = ["data/mysql"] [tool.ruff.lint] @@ -40,3 +40,8 @@ convention = "google" known-first-party = [ "wikidatasearch" ] + +[tool.pytest.ini_options] +testpaths = [ + "tests" +] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..963ec4d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,68 @@ +# Tests + +This folder contains automated tests split into two layers: + +- **Unit (`tests/unit`)**: Fast isolated tests with stubs/mocks and direct function/route calls. +- **Integration (`tests/integration`)**: Live HTTP tests against a running local API server. + +## What Is Covered + +### Unit (`tests/unit`) + +- Route wiring behavior for single-ID vs multi-ID normalization paths. +- JSON normalizer behavior (rank filtering, datatype conversion, external-id filtering). +- Textifier model behavior (serialization, triplet/text rendering, truthiness rules). +- Utility helpers (`src/utils.py`) with mocked HTTP calls. +- Label helper behavior (`src/WikidataLabel.py`) including language fallback and lazy resolution. + +### Integration (`tests/integration`) + +- Local API contracts for `GET /` and docs endpoint availability. +- Response shape checks for JSON and text output. +- Cache verification: ensure label rows are written and reused between repeated requests. + +## Setup + +From project root: + +```bash +uv sync --locked +``` + +For integration tests, start Docker services first: + +```bash +docker compose up --build +``` + +## Common Commands + +Run unit tests only: + +```bash +uv run pytest -q tests/unit +``` + +Run integration tests only: + +```bash +uv run pytest -q tests/integration -m integration +``` + +Run all tests: + +```bash +uv run pytest -q tests +``` + +Run lint: + +```bash +uv run ruff check . +``` + +## Notes + +- Integration tests assume the API is available at `http://127.0.0.1:5000`. +- The cache integration test reads DB credentials from environment variables or local `.env`. +- If DB credentials are not usable, the cache verification test is skipped with a clear message. diff --git a/tests/integration/test_live_routes.py b/tests/integration/test_live_routes.py new file mode 100644 index 0000000..912f774 --- /dev/null +++ b/tests/integration/test_live_routes.py @@ -0,0 +1,219 @@ +"""Live integration tests against the local FastAPI service.""" + +import json +import os +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +import pymysql +import pytest + +pytestmark = pytest.mark.integration +LOCAL_BASE_URL = "http://127.0.0.1:5000" + + +def _api_get(path: str, params: dict | None = None, expected_status: int | None = 200) -> dict: + """Submit a GET request to the local API and return parsed response data.""" + query = f"?{urlencode(params or {}, doseq=True)}" if params else "" + req = Request( + f"{LOCAL_BASE_URL}{path}{query}", + method="GET", + headers={ + "User-Agent": "Pytest Integration Suite/1.0 (integration-tests@example.org)", + "Accept": "application/json", + }, + ) + + try: + with urlopen(req, timeout=120) as res: + status = res.status + body_bytes = res.read() + headers = dict(res.headers.items()) + except HTTPError as e: + status = e.code + body_bytes = e.read() + headers = dict(e.headers.items()) if e.headers else {} + except URLError as e: + pytest.fail(f"Local API is unreachable at {LOCAL_BASE_URL}: {e}") + + body_text = body_bytes.decode("utf-8", errors="replace") + try: + payload = json.loads(body_text) + except json.JSONDecodeError: + payload = body_text + + if expected_status is not None: + assert status == expected_status, f"{path} expected {expected_status}, got {status}: {payload}" + + return {"status": status, "payload": payload, "headers": headers} + + +def _load_env_file() -> dict[str, str]: + """Load key-value pairs from local ``.env`` file if present.""" + env_path = Path(__file__).resolve().parents[2] / ".env" + out: dict[str, str] = {} + if not env_path.exists(): + return out + + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + normalized_value = value.strip() + if ( + len(normalized_value) >= 2 + and normalized_value[0] == normalized_value[-1] + and normalized_value[0] in {"'", '"'} + ): + normalized_value = normalized_value[1:-1] + out[key.strip()] = normalized_value + return out + + +def _db_config() -> dict[str, str | int]: + """Build DB connection config from environment with sensible defaults.""" + env_file = _load_env_file() + + user = os.environ.get("DB_USER") or env_file.get("DB_USER", "root") + password = os.environ.get("DB_PASS") + if password is None: + password = env_file.get("DB_PASS", "") + + db_name = os.environ.get("DB_NAME") + if db_name is None: + db_name = env_file.get("DB_NAME_LABEL") or env_file.get("DB_NAME", "label") + + return { + "host": os.environ.get("DB_HOST") or env_file.get("DB_HOST", "127.0.0.1"), + "port": int(os.environ.get("DB_PORT") or env_file.get("DB_PORT", "3306")), + "user": user, + "password": password, + "database": db_name, + } + + +def _db_connect(): + """Open a DB connection for cache verification queries.""" + cfg = _db_config() + return pymysql.connect( + host=cfg["host"], + port=cfg["port"], + user=cfg["user"], + password=cfg["password"], + database=cfg["database"], + charset="utf8mb4", + autocommit=True, + ) + + +def test_docs_route_is_reachable(): + """Validate docs route is reachable.""" + result = _api_get("/docs", expected_status=200) + content_type = result["headers"].get("Content-Type") or result["headers"].get("content-type", "") + assert "text/html" in content_type + + +def test_entity_query_json_contract_for_multi_ids(): + """Validate JSON contract for multi-ID query.""" + result = _api_get( + "/", + params={ + "id": "Q42,Q2", + "format": "json", + "lang": "en", + "pid": "P31", + }, + expected_status=200, + ) + payload = result["payload"] + + assert isinstance(payload, dict) + assert set(payload.keys()) == {"Q42", "Q2"} + assert isinstance(payload["Q42"], dict) + assert payload["Q42"]["QID"] == "Q42" + assert "claims" in payload["Q42"] + + +def test_entity_query_text_contract_for_single_id(): + """Validate text contract for single-ID query.""" + result = _api_get( + "/", + params={ + "id": "Q42", + "format": "text", + "lang": "en", + "pid": "P31", + }, + expected_status=200, + ) + payload = result["payload"] + + assert isinstance(payload, dict) + assert "Q42" in payload + assert isinstance(payload["Q42"], str) + assert payload["Q42"] + + +def test_cache_writes_and_reuses_label_entries(): + """Validate label cache rows are written and then reused across repeated requests.""" + tracked_ids = ["P31", "Q5"] + + try: + with _db_connect() as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM labels WHERE id IN (%s, %s)", + (tracked_ids[0], tracked_ids[1]), + ) + except pymysql.err.OperationalError as e: + pytest.skip(f"Cannot connect to MariaDB for cache verification: {e}") + + first = _api_get( + "/", + params={ + "id": "Q42,Q2", + "format": "json", + "lang": "en", + "pid": "P31", + }, + expected_status=200, + ) + assert isinstance(first["payload"], dict) + + with _db_connect() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, date_added FROM labels WHERE id IN (%s, %s)", + (tracked_ids[0], tracked_ids[1]), + ) + rows_first = cur.fetchall() + + assert rows_first, "Expected label cache entries to be created after first request." + first_dates = {row[0]: row[1] for row in rows_first} + assert "P31" in first_dates + + second = _api_get( + "/", + params={ + "id": "Q42,Q2", + "format": "json", + "lang": "en", + "pid": "P31", + }, + expected_status=200, + ) + assert isinstance(second["payload"], dict) + + with _db_connect() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, date_added FROM labels WHERE id IN (%s, %s)", + (tracked_ids[0], tracked_ids[1]), + ) + rows_second = cur.fetchall() + + second_dates = {row[0]: row[1] for row in rows_second} + assert second_dates["P31"] == first_dates["P31"] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..c23bc4d --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,48 @@ +"""Setup for unit tests: shared fixtures and import bootstrap.""" + +import asyncio +import sys +from pathlib import Path +from urllib.parse import urlencode + +import pytest +from starlette.requests import Request + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +@pytest.fixture +def run_async(): + """Run an async coroutine in unit tests.""" + + def _run(coro): + return asyncio.run(coro) + + return _run + + +@pytest.fixture +def make_request(): + """Create a minimal Starlette request object for route calls.""" + + def _make(path: str, method: str = "GET", params: dict | None = None) -> Request: + """Construct a request scope with query params and test headers.""" + query_string = urlencode(params or {}, doseq=True).encode() + scope = { + "type": "http", + "http_version": "1.1", + "method": method, + "path": path, + "query_string": query_string, + "headers": [ + (b"user-agent", b"Unit Test Client/1.0 (unit-tests@example.org)"), + ], + "client": ("127.0.0.1", 12345), + "scheme": "http", + "server": ("testserver", 80), + } + return Request(scope) + + return _make diff --git a/tests/unit/test_json_normalizer.py b/tests/unit/test_json_normalizer.py new file mode 100644 index 0000000..b1f7d5a --- /dev/null +++ b/tests/unit/test_json_normalizer.py @@ -0,0 +1,164 @@ +"""Unit tests for JSON normalizer behavior. + +Covers claim filtering and datavalue conversion behavior for key datatypes. +""" + +from importlib import import_module + +from src.Normalizer.JSONNormalizer import JSONNormalizer +from src.Textifier.WikidataTextifier import WikidataQuantity, WikidataText + +json_normalizer_module = import_module("src.Normalizer.JSONNormalizer") + + +class _DummyLabelFactory: + """Minimal label factory that records requested IDs.""" + + def __init__(self): + self.requested_ids = [] + + def create(self, qid): + """Return a stable synthetic label for an ID.""" + self.requested_ids.append(qid) + return f"label-{qid}" + + +def _base_entity_json(): + """Create a minimal entity payload with labels/descriptions/aliases.""" + return { + "labels": {"en": {"value": "Douglas Adams"}}, + "descriptions": {"en": {"value": "English writer"}}, + "aliases": {"en": [{"value": "DNA"}]}, + "claims": {}, + } + + +def test_normalize_filters_external_id_claims_when_disabled(): + """It should exclude ``external-id`` datatype claims when ``external_ids=False``.""" + data = _base_entity_json() + data["claims"] = { + "P31": [ + { + "rank": "normal", + "mainsnak": { + "snaktype": "value", + "datatype": "wikibase-item", + "datavalue": {"type": "wikibase-entityid", "value": {"id": "Q5"}}, + }, + } + ], + "P214": [ + { + "rank": "normal", + "mainsnak": { + "snaktype": "value", + "datatype": "external-id", + "datavalue": {"type": "string", "value": "113230702"}, + }, + } + ], + } + + normalizer = JSONNormalizer( + entity_id="Q42", + entity_json=data, + label_factory=_DummyLabelFactory(), + ) + entity = normalizer.normalize(external_ids=False) + + assert len(entity.claims) == 1 + assert entity.claims[0].property.id == "P31" + + +def test_filter_by_rank_prefers_preferred_statements(): + """It should keep preferred statements (plus rank-less ones) when present.""" + normalizer = JSONNormalizer( + entity_id="Q42", + entity_json=_base_entity_json(), + label_factory=_DummyLabelFactory(), + ) + statements = [ + {"rank": "normal"}, + {"rank": "preferred"}, + {"rank": None}, + ] + + filtered = normalizer._filter_by_rank(statements, all_ranks=False) + + assert len(filtered) == 2 + assert {"rank": "preferred"} in filtered + assert {"rank": None} in filtered + + +def test_to_value_object_quantity_resolves_unit_label(): + """It should map quantity unit URIs to unit IDs and lazy labels.""" + factory = _DummyLabelFactory() + normalizer = JSONNormalizer( + entity_id="Q42", + entity_json=_base_entity_json(), + label_factory=factory, + ) + + quantity = normalizer._to_value_object( + "quantity", + { + "type": "quantity", + "value": { + "amount": "+10", + "unit": "http://www.wikidata.org/entity/Q11573", + }, + }, + ) + + assert isinstance(quantity, WikidataQuantity) + assert quantity.unit_id == "Q11573" + assert quantity.unit == "label-Q11573" + assert "Q11573" in factory.requested_ids + + +def test_to_value_object_monolingual_text_ignores_other_languages(): + """It should return empty monolingual text when language does not match target ``lang``.""" + normalizer = JSONNormalizer( + entity_id="Q42", + entity_json=_base_entity_json(), + lang="en", + label_factory=_DummyLabelFactory(), + ) + + value = normalizer._to_value_object( + "monolingualtext", + {"type": "monolingualtext", "value": {"text": "Bonjour", "language": "fr"}}, + ) + + assert isinstance(value, WikidataText) + assert value.text is None + + +def test_to_value_object_time_returns_none_when_formatter_fails(monkeypatch): + """It should return ``None`` for time values when formatter call fails.""" + + def fake_time_formatter(value, lang): + raise ValueError("cannot format") + + monkeypatch.setattr(json_normalizer_module, "wikidata_time_to_text", fake_time_formatter) + + normalizer = JSONNormalizer( + entity_id="Q42", + entity_json=_base_entity_json(), + label_factory=_DummyLabelFactory(), + debug=False, + ) + + value = normalizer._to_value_object( + "time", + { + "type": "time", + "value": { + "time": "+2024-01-01T00:00:00Z", + "precision": 11, + "calendarmodel": "http://www.wikidata.org/entity/Q1985786", + }, + }, + ) + + assert value is None diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py new file mode 100644 index 0000000..3592f5c --- /dev/null +++ b/tests/unit/test_routes.py @@ -0,0 +1,84 @@ +"""Unit tests for API routes. + +Covers route wiring behavior for single and multi-entity requests. +""" + +from fastapi import BackgroundTasks + +import main +from src.Textifier.WikidataTextifier import WikidataEntity + + +def test_get_textified_wd_uses_ttl_normalizer_for_single_qid(monkeypatch, run_async, make_request): + """Validate ``TTLNormalizer`` is used when one QID is requested.""" + calls = {} + + def fake_get_ttl(qid, lang="en"): + calls["requested_qid"] = qid + return "ttl-data" + + class DummyTTLNormalizer: + """Minimal TTL normalizer stand-in for unit testing.""" + + def __init__(self, **kwargs): + self.entity_id = kwargs["entity_id"] + calls["normalizer_entity_id"] = self.entity_id + + def normalize(self, **kwargs): + return WikidataEntity(id=self.entity_id, label="Douglas Adams", claims=[]) + + monkeypatch.setattr(main.utils, "get_wikidata_ttl_by_id", fake_get_ttl) + monkeypatch.setattr(main, "TTLNormalizer", DummyTTLNormalizer) + + result = run_async( + main.get_textified_wd( + request=make_request("/"), + background_tasks=BackgroundTasks(), + id="Q42", + pid=None, + format="json", + ) + ) + + assert calls["requested_qid"] == "Q42" + assert calls["normalizer_entity_id"] == "Q42" + assert result["Q42"]["QID"] == "Q42" + assert result["Q42"]["label"] == "Douglas Adams" + + +def test_get_textified_wd_uses_json_normalizer_for_multiple_qids(monkeypatch, run_async, make_request): + """Validate ``JSONNormalizer`` is used for multi-QID requests.""" + init_calls = [] + + def fake_get_json(ids): + return { + "Q1": {"labels": {"en": {"value": "One"}}, "descriptions": {}, "aliases": {}, "claims": {}}, + "Q2": {"labels": {"en": {"value": "Two"}}, "descriptions": {}, "aliases": {}, "claims": {}}, + } + + class DummyJSONNormalizer: + """Minimal JSON normalizer stand-in for unit testing.""" + + def __init__(self, **kwargs): + self.entity_id = kwargs["entity_id"] + init_calls.append(self.entity_id) + + def normalize(self, **kwargs): + return WikidataEntity(id=self.entity_id, label=f"Label-{self.entity_id}", claims=[]) + + monkeypatch.setattr(main.utils, "get_wikidata_json_by_ids", fake_get_json) + monkeypatch.setattr(main, "JSONNormalizer", DummyJSONNormalizer) + + result = run_async( + main.get_textified_wd( + request=make_request("/"), + background_tasks=BackgroundTasks(), + id="Q1,Q2", + pid=None, + format="text", + ) + ) + + assert init_calls == ["Q1", "Q2"] + assert result["Q1"] == "Label-Q1" + assert result["Q2"] == "Label-Q2" diff --git a/tests/unit/test_textifier.py b/tests/unit/test_textifier.py new file mode 100644 index 0000000..e55e5ec --- /dev/null +++ b/tests/unit/test_textifier.py @@ -0,0 +1,78 @@ +"""Unit tests for textifier model behavior. + +Covers truthiness rules, serialization, and triplet/text rendering behavior. +""" + +from src.Textifier.WikidataTextifier import ( + WikidataClaim, + WikidataClaimValue, + WikidataCoordinates, + WikidataEntity, + WikidataQuantity, +) + + +def test_wikidata_coordinates_bool_requires_lat_and_lon(): + """It should only be truthy when both latitude and longitude are set.""" + assert not WikidataCoordinates(latitude=1.0, longitude=None) + assert bool(WikidataCoordinates(latitude=1.0, longitude=2.0)) + + +def test_wikidata_quantity_string_and_json_with_unit(): + """It should include the unit label/id in string and JSON output.""" + quantity = WikidataQuantity(amount="+10", unit="metre", unit_id="Q11573") + + assert str(quantity) == "+10 metre" + assert quantity.to_json() == { + "amount": "+10", + "unit": "metre", + "unit_QID": "Q11573", + } + + +def test_wikidata_entity_to_text_includes_description_and_aliases(): + """It should render label, description, and aliases in text format.""" + entity = WikidataEntity( + id="Q42", + label="Douglas Adams", + description="English writer", + aliases=["DNA"], + claims=[], + ) + + rendered = entity.to_text(lang="en") + + assert "Douglas Adams" in rendered + assert "English writer" in rendered + assert "DNA" in rendered + + +def test_claim_value_entity_serialization_uses_qid_for_wikibase_item(): + """It should serialize entity values with ``QID`` when claim datatype is ``wikibase-item``.""" + subject = WikidataEntity(id="Q42", label="Douglas Adams") + prop = WikidataEntity(id="P31", label="instance of") + claim = WikidataClaim(subject=subject, property=prop, datatype="wikibase-item") + + value_entity = WikidataEntity(id="Q5", label="human") + claim_value = WikidataClaimValue(claim=claim, value=value_entity) + claim.values = [claim_value] + + result = claim_value.to_json() + + assert result == {"value": {"QID": "Q5", "label": "human"}} + + +def test_claim_to_triplet_renders_one_line_per_value(): + """It should render one triplet line per claim value.""" + subject = WikidataEntity(id="Q42", label="Douglas Adams") + prop = WikidataEntity(id="P31", label="instance of") + claim = WikidataClaim(subject=subject, property=prop, datatype="wikibase-item") + claim.values = [ + WikidataClaimValue(claim=claim, value=WikidataEntity(id="Q5", label="human")), + WikidataClaimValue(claim=claim, value=WikidataEntity(id="Q215627", label="person")), + ] + + rendered = claim.to_triplet() + + assert "instance of (P31): human (Q5)" in rendered + assert "instance of (P31): person (Q215627)" in rendered diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..1f39ee9 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,101 @@ +"""Unit tests for utility helpers. + +Covers HTTP helper wiring, chunking behavior, and formatter error handling. +""" + +import json + +import pytest + +from src import utils + + +class _FakeResponse: + """Simple fake response object for mocked HTTP calls.""" + + def __init__(self, *, payload=None, text=""): + self._payload = payload or {} + self.text = text + + def raise_for_status(self): + """Mimic successful HTTP responses.""" + + def json(self): + """Return JSON payload.""" + return self._payload + + +def test_get_wikidata_ttl_by_id_returns_response_text(monkeypatch): + """It should return the raw TTL text for a requested entity.""" + + def fake_get(url, params, headers, timeout): + return _FakeResponse(text="ttl-content") + + monkeypatch.setattr(utils.SESSION, "get", fake_get) + + result = utils.get_wikidata_ttl_by_id("Q42", lang="en") + + assert result == "ttl-content" + + +def test_get_wikidata_json_by_ids_deduplicates_and_chunks(monkeypatch): + """It should deduplicate IDs and split requests in chunks of 50.""" + captured_chunks = [] + + def fake_get(url, params, headers, timeout): + chunk_ids = params["ids"].split("|") + captured_chunks.append(chunk_ids) + entities = {qid: {"id": qid} for qid in chunk_ids} + return _FakeResponse(payload={"entities": entities}) + + monkeypatch.setattr(utils.SESSION, "get", fake_get) + + ids = [f"Q{i}" for i in range(1, 55)] + ["Q1"] # 54 unique IDs + result = utils.get_wikidata_json_by_ids(ids) + + assert len(captured_chunks) == 2 + assert len(captured_chunks[0]) == 50 + assert len(captured_chunks[1]) == 4 + assert len(result) == 54 + assert "Q1" in result + assert "Q54" in result + + +def test_wikidata_time_to_text_normalizes_time_before_api_call(monkeypatch): + """It should normalize time payloads before posting to formatter API.""" + captured = {} + + def fake_post(url, data, timeout): + captured["datavalue"] = json.loads(data["datavalue"]) + return _FakeResponse(payload={"result": "1 January 2024"}) + + monkeypatch.setattr(utils.SESSION, "post", fake_post) + + result = utils.wikidata_time_to_text({"time": "2024-01-01T00:00:00+00:00"}, lang="en") + + assert result == "1 January 2024" + assert captured["datavalue"]["value"]["time"] == "+2024-01-01T00:00:00Z" + + +def test_wikidata_time_to_text_raises_for_missing_result(monkeypatch): + """It should raise ``ValueError`` when formatter response has no result field.""" + + def fake_post(url, data, timeout): + return _FakeResponse(payload={"error": "missing result"}) + + monkeypatch.setattr(utils.SESSION, "post", fake_post) + + with pytest.raises(ValueError): + utils.wikidata_time_to_text({"time": "+2024-01-01T00:00:00Z"}, lang="en") + + +def test_wikidata_geolocation_to_text_raises_for_missing_result(monkeypatch): + """It should raise ``ValueError`` when coordinate formatter response is malformed.""" + + def fake_post(url, data, timeout): + return _FakeResponse(payload={"error": "missing result"}) + + monkeypatch.setattr(utils.SESSION, "post", fake_post) + + with pytest.raises(ValueError): + utils.wikidata_geolocation_to_text({"latitude": 1.0, "longitude": 2.0}, lang="en") diff --git a/tests/unit/test_wikidatalabel.py b/tests/unit/test_wikidatalabel.py new file mode 100644 index 0000000..feefa94 --- /dev/null +++ b/tests/unit/test_wikidatalabel.py @@ -0,0 +1,60 @@ +"""Unit tests for label cache helpers. + +Covers language fallback behavior, nested ID extraction, and lazy label resolution. +""" + +from src.WikidataLabel import LazyLabelFactory, WikidataLabel + + +def test_get_lang_val_prefers_requested_language(): + """It should prefer the exact requested language when present.""" + labels = { + "en": {"value": "Douglas Adams"}, + "mul": {"value": "Douglas Adams (multilingual)"}, + } + + assert WikidataLabel.get_lang_val(labels, lang="en", fallback_lang="fr") == "Douglas Adams" + + +def test_get_lang_val_falls_back_to_mul_and_fallback_language(): + """It should use ``mul`` first, then explicit fallback when needed.""" + labels_with_mul = { + "mul": {"value": "Universal label"}, + "fr": {"value": "Etiquette"}, + } + labels_without_mul = { + "fr": {"value": "Etiquette"}, + } + + assert WikidataLabel.get_lang_val(labels_with_mul, lang="en", fallback_lang="fr") == "Universal label" + assert WikidataLabel.get_lang_val(labels_without_mul, lang="en", fallback_lang="fr") == "Etiquette" + + +def test_get_all_missing_labels_ids_collects_nested_ids(): + """It should collect IDs from nested property, unit, claim, and datavalue branches.""" + payload = { + "property": "P31", + "unit": "http://www.wikidata.org/entity/Q11573", + "datatype": "wikibase-item", + "datavalue": {"value": {"id": "Q5"}}, + "claims": {"P279": []}, + "nested": [{"property": "P17"}], + } + + ids = WikidataLabel.get_all_missing_labels_ids(payload) + + assert ids == {"P31", "Q11573", "Q5", "P279", "P17"} + + +def test_lazy_label_factory_resolves_pending_labels_in_bulk(monkeypatch): + """It should resolve pending IDs via a single bulk lookup when cast to ``str``.""" + + def fake_get_bulk_labels(ids): + return {"Q42": {"en": "Douglas Adams"}} + + monkeypatch.setattr(WikidataLabel, "get_bulk_labels", staticmethod(fake_get_bulk_labels)) + + factory = LazyLabelFactory(lang="en", fallback_lang="en") + lazy_label = factory.create("Q42") + + assert str(lazy_label) == "Douglas Adams" diff --git a/uv.lock b/uv.lock index d6fd462..eacff25 100644 --- a/uv.lock +++ b/uv.lock @@ -144,6 +144,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -153,6 +162,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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 = "pydantic" version = "2.11.7" @@ -196,6 +214,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + [[package]] name = "pymysql" version = "1.1.2" @@ -214,6 +241,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +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 = "rdflib" version = "7.5.0" @@ -243,27 +286,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.8" +version = "0.15.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, - { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, - { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, ] [[package]] @@ -367,6 +410,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] @@ -382,4 +426,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.9.0" }] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "ruff", specifier = ">=0.9.0" }, +]