diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1759eed --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --package grogbot-search-core --extra test + + - name: Run tests + run: uv run --package grogbot-search-core --extra test pytest packages/search-core/tests diff --git a/README.md b/README.md index 254696c..811884e 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,11 @@ Install test dependencies for the search core and run pytest: uv sync --extra test uv run pytest packages/search-core/tests ``` + +Run coverage checks with `pytest-cov`: + +```bash +uv run --package grogbot-search-core --extra test \ + pytest packages/search-core/tests \ + --cov=grogbot_search --cov-report=term-missing +``` diff --git a/packages/search-core/pyproject.toml b/packages/search-core/pyproject.toml index 5faef6c..9c0b469 100644 --- a/packages/search-core/pyproject.toml +++ b/packages/search-core/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ [project.optional-dependencies] test = [ "pytest>=8.0", + "pytest-cov>=5.0", ] [build-system] diff --git a/packages/search-core/tests/conftest.py b/packages/search-core/tests/conftest.py index d1f42fd..6aff749 100644 --- a/packages/search-core/tests/conftest.py +++ b/packages/search-core/tests/conftest.py @@ -1,5 +1,8 @@ from __future__ import annotations +import hashlib +import math +import re import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Dict @@ -11,10 +14,24 @@ @pytest.fixture() def service(tmp_path, monkeypatch): - def fake_embed(texts, *, prompt): - return [[0.0] * 768 for _ in texts] + def fake_embed(texts, *, prompt): # noqa: ARG001 - mirror real signature + embeddings: list[list[float]] = [] + for text in texts: + vector = [0.0] * 768 + tokens = re.findall(r"[a-z0-9]+", text.lower()) + for token in tokens: + digest = hashlib.sha256(token.encode("utf-8")).digest() + index = int.from_bytes(digest[:2], "big") % 768 + sign = 1.0 if digest[2] % 2 == 0 else -1.0 + vector[index] += sign + norm = math.sqrt(sum(value * value for value in vector)) + if norm > 0: + vector = [value / norm for value in vector] + embeddings.append(vector) + return embeddings monkeypatch.setattr("grogbot_search.service.embed_texts", fake_embed) + monkeypatch.setattr("grogbot_search.service.time.sleep", lambda _seconds: None) db_path = tmp_path / "search.db" svc = SearchService(db_path) yield svc @@ -97,6 +114,36 @@ def log_message(self, format, *args): # noqa: A003 - match base signature """ + responses["/article-no-canonical"] = """ + + + No Canonical Tag + + +
+

No Canonical Heading

+

Uses requested URL as canonical fallback.

+
+ + + """ + + responses["/article-published"] = f""" + + + Published Article + + + + +
+

Published Heading

+

Article with metadata timestamp.

+
+ + + """ + responses["/feed"] = f""" @@ -229,6 +276,26 @@ def log_message(self, format, *args): # noqa: A003 - match base signature responses["/invalid-feed"] = "NOT VALID XML" + responses["/feed-summary-and-empty"] = f""" + + + Summary Feed + + Summary Entry + {base_url}/summary-entry + {base_url}/summary-entry + Summary based content.

]]>
+ Tue, 09 Jan 2025 12:00:00 GMT +
+ + Empty Entry + {base_url}/empty-entry + {base_url}/empty-entry + +
+
+ """ + responses["/opml"] = f""" @@ -270,6 +337,8 @@ def log_message(self, format, *args): # noqa: A003 - match base signature """ + responses["/invalid-opml"] = " @@ -291,6 +360,15 @@ def log_message(self, format, *args): # noqa: A003 - match base signature """ + responses["/invalid-sitemap.xml"] = "" + + responses["/sitemap-bootstrap-skip.xml"] = f""" + + {base_url}/backoff-403 + {base_url}/article-2 + + """ + responses["/backoff-403"] = { "status": 403, "body": "Forbidden", diff --git a/packages/search-core/tests/test_chunking.py b/packages/search-core/tests/test_chunking.py new file mode 100644 index 0000000..ee290c2 --- /dev/null +++ b/packages/search-core/tests/test_chunking.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import grogbot_search.chunking as chunking + + +def test_split_sections_breaks_on_headings(): + markdown = """intro line +# Heading One +body one +## Heading Two +body two +""" + + sections = chunking._split_sections(markdown) + + assert sections == [ + "intro line", + "# Heading One\nbody one", + "## Heading Two\nbody two", + ] + + +def test_split_sentences_strips_whitespace_and_empties(): + sentences = chunking._split_sentences(" First sentence. Second sentence! Third? ") + + assert sentences == ["First sentence.", "Second sentence!", "Third?"] + + +def test_chunk_markdown_splits_oversized_block_by_sentences(monkeypatch): + monkeypatch.setattr(chunking, "TARGET_WORDS", 4) + monkeypatch.setattr(chunking, "MAX_WORDS", 6) + + markdown = """# Heading + +preface words + +one two three. four five six. seven eight nine. +""" + + chunks = chunking.chunk_markdown(markdown) + + assert chunks == [ + "Heading preface words", + "one two three. four five six.", + "seven eight nine.", + ] + + +def test_chunk_markdown_flushes_when_next_block_would_exceed_max(monkeypatch): + monkeypatch.setattr(chunking, "TARGET_WORDS", 100) + monkeypatch.setattr(chunking, "MAX_WORDS", 5) + + markdown = """one two three + +four five six +""" + + chunks = chunking.chunk_markdown(markdown) + + assert chunks == ["one two three", "four five six"] + + +def test_chunk_markdown_sentence_group_flushes_on_max_overflow(monkeypatch): + monkeypatch.setattr(chunking, "TARGET_WORDS", 100) + monkeypatch.setattr(chunking, "MAX_WORDS", 5) + + markdown = """# Heading + +one two three. four five six. +""" + + chunks = chunking.chunk_markdown(markdown) + + assert chunks == ["Heading", "one two three.", "four five six."] + + +def test_chunk_markdown_flushes_when_target_is_reached(monkeypatch): + monkeypatch.setattr(chunking, "TARGET_WORDS", 3) + monkeypatch.setattr(chunking, "MAX_WORDS", 10) + + markdown = """one two three + +four five +""" + + chunks = chunking.chunk_markdown(markdown) + + assert chunks == ["one two three", "four five"] diff --git a/packages/search-core/tests/test_embeddings.py b/packages/search-core/tests/test_embeddings.py new file mode 100644 index 0000000..cc376be --- /dev/null +++ b/packages/search-core/tests/test_embeddings.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import grogbot_search.embeddings as embeddings + + +class _FakeArray: + def __init__(self, values): + self._values = values + + def tolist(self): + return list(self._values) + + +def test_load_model_uses_expected_sentence_transformer(monkeypatch): + calls: list[tuple[str, bool]] = [] + model = object() + + def fake_sentence_transformer(model_name: str, trust_remote_code: bool): + calls.append((model_name, trust_remote_code)) + return model + + monkeypatch.setattr(embeddings, "SentenceTransformer", fake_sentence_transformer) + embeddings._load_model.cache_clear() + + first = embeddings._load_model() + second = embeddings._load_model() + + assert first is model + assert second is model + assert calls == [("nomic-ai/nomic-embed-text-v1", True)] + + embeddings._load_model.cache_clear() + + +def test_embed_texts_calls_model_and_returns_lists(monkeypatch): + class FakeModel: + def __init__(self): + self.calls = [] + + def encode(self, texts, *, normalize_embeddings: bool, prompt: str): + self.calls.append((texts, normalize_embeddings, prompt)) + return [_FakeArray([1.0, 2.0]), _FakeArray([3.0, 4.0])] + + fake_model = FakeModel() + monkeypatch.setattr(embeddings, "_load_model", lambda: fake_model) + + result = embeddings.embed_texts(("first", "second"), prompt="search_query") + + assert result == [[1.0, 2.0], [3.0, 4.0]] + assert fake_model.calls == [(["first", "second"], True, "search_query")] diff --git a/packages/search-core/tests/test_ingestion.py b/packages/search-core/tests/test_ingestion.py deleted file mode 100644 index ac26318..0000000 --- a/packages/search-core/tests/test_ingestion.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -from urllib.parse import urlparse - -import pytest - -from grogbot_search.service import BackoffError, SearchService - - -def test_create_document_from_url(service: SearchService, http_server): - document = service.create_document_from_url(f"{http_server}/article") - - assert document.canonical_url == f"{http_server}/canonical" - assert "Article Heading" in document.content_markdown or "Hello world" in document.content_markdown - - source = service.get_source(document.source_id) - assert source is not None - assert source.canonical_domain == urlparse(document.canonical_url).netloc - - -@pytest.mark.parametrize( - "path", - [ - "backoff-403", - "backoff-429", - "backoff-503", - "backoff-retry-after", - "backoff-captcha", - ], -) -def test_create_document_from_url_raises_on_backoff_signals(service: SearchService, http_server, path: str): - with pytest.raises(BackoffError): - service.create_document_from_url(f"{http_server}/{path}") - - -def test_create_documents_from_feed(service: SearchService, http_server): - documents = service.create_documents_from_feed(f"{http_server}/feed") - - assert len(documents) == 1 - document = documents[0] - assert document.canonical_url == f"{http_server}/feed-entry" - assert document.title == "Feed Entry" - - source = service.get_source(document.source_id) - assert source is not None - assert source.rss_feed == f"{http_server}/feed" - - -def test_create_documents_from_feed_backfills_missing_source_attributes(service: SearchService, http_server): - document = service.create_document_from_url(f"{http_server}/article") - - source_before = service.get_source(document.source_id) - assert source_before is not None - assert source_before.name is None - assert source_before.rss_feed is None - - service.create_documents_from_feed(f"{http_server}/feed") - - source_after = service.get_source(document.source_id) - assert source_after is not None - assert source_after.name == "Test Feed" - assert source_after.rss_feed == f"{http_server}/feed" - - -def test_create_documents_from_feed_pagination_disabled(service: SearchService, http_server): - documents = service.create_documents_from_feed(f"{http_server}/feed-paginated") - - assert len(documents) == 1 - assert documents[0].canonical_url == f"{http_server}/feed-paginated-entry-1" - - -def test_create_documents_from_feed_pagination_enabled(service: SearchService, http_server): - documents = service.create_documents_from_feed(f"{http_server}/feed-paginated", paginate=True) - - assert len(documents) == 2 - urls = {doc.canonical_url for doc in documents} - assert f"{http_server}/feed-paginated-entry-1" in urls - assert f"{http_server}/feed-paginated-entry-2" in urls - - -def test_create_documents_from_feed_wordpress_pagination(service: SearchService, http_server): - documents = service.create_documents_from_feed(f"{http_server}/wp-feed", paginate=True) - - assert len(documents) == 2 - urls = {doc.canonical_url for doc in documents} - assert f"{http_server}/wp-feed-entry-1" in urls - assert f"{http_server}/wp-feed-entry-2" in urls - - -def test_create_documents_from_feed_pagination_stops_on_loop(service: SearchService, http_server): - documents = service.create_documents_from_feed(f"{http_server}/feed-loop", paginate=True) - - assert len(documents) == 1 - assert documents[0].canonical_url == f"{http_server}/feed-loop-entry" - - -def test_create_documents_from_feed_pagination_best_effort(service: SearchService, http_server): - documents = service.create_documents_from_feed(f"{http_server}/feed-paginated-error", paginate=True) - - assert len(documents) == 1 - assert documents[0].canonical_url == f"{http_server}/feed-paginated-error-entry" - - -def test_create_documents_from_opml_multi_feed(service: SearchService, http_server): - documents = service.create_documents_from_opml(f"{http_server}/opml") - - assert len(documents) == 2 - urls = {doc.canonical_url for doc in documents} - assert f"{http_server}/feed-entry" in urls - assert f"{http_server}/feed2-entry" in urls - - -def test_create_documents_from_opml_nested_outlines(service: SearchService, http_server): - documents = service.create_documents_from_opml(f"{http_server}/opml-nested") - - # Should find feeds from nested outlines (2 valid, 1 invalid skipped) - assert len(documents) == 2 - urls = {doc.canonical_url for doc in documents} - assert f"{http_server}/feed-entry" in urls - assert f"{http_server}/feed2-entry" in urls - - -def test_create_documents_from_opml_deduplicates_feeds(service: SearchService, http_server): - documents = service.create_documents_from_opml(f"{http_server}/opml-duplicates") - - # Should deduplicate and only process one feed - assert len(documents) == 1 - assert documents[0].canonical_url == f"{http_server}/feed-entry" - - -def test_create_documents_from_sitemap_ingests_all_url_entries(service: SearchService, http_server): - documents = service.create_documents_from_sitemap(f"{http_server}/sitemap.xml") - - # Should ingest valid URLs and skip failures best-effort - assert len(documents) == 2 - urls = {doc.canonical_url for doc in documents} - assert f"{http_server}/canonical" in urls - assert f"{http_server}/canonical-2" in urls - - -def test_create_documents_from_sitemap_deduplicates_urls(service: SearchService, http_server): - documents = service.create_documents_from_sitemap(f"{http_server}/sitemap-duplicates.xml") - - assert len(documents) == 1 - assert documents[0].canonical_url == f"{http_server}/canonical" - - -@pytest.mark.parametrize( - "sitemap_path", - [ - "sitemap-backoff-429.xml", - "sitemap-backoff-503.xml", - "sitemap-backoff-retry-after.xml", - "sitemap-backoff-captcha.xml", - ], -) -def test_create_documents_from_sitemap_raises_on_backoff_signals( - service: SearchService, - http_server, - sitemap_path: str, -): - with pytest.raises(BackoffError): - service.create_documents_from_sitemap(f"{http_server}/{sitemap_path}") - - -def test_create_documents_from_sitemap_halts_on_backoff_and_keeps_prior_documents(service: SearchService, http_server): - with pytest.raises(BackoffError): - service.create_documents_from_sitemap(f"{http_server}/sitemap-backoff-403.xml") - - stored_documents = service.list_documents() - assert len(stored_documents) == 1 - assert stored_documents[0].canonical_url == f"{http_server}/canonical" diff --git a/packages/search-core/tests/test_opml_integration.py b/packages/search-core/tests/test_opml_integration.py deleted file mode 100644 index 5cb3d74..0000000 --- a/packages/search-core/tests/test_opml_integration.py +++ /dev/null @@ -1,549 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from grogbot_search.service import BackoffError, SearchService - - -@pytest.fixture -def api_client(service: SearchService): - """Create a FastAPI test client with the service dependency overridden.""" - from fastapi.testclient import TestClient - from grogbot_api.app import app, get_service - - def override_get_service(): - svc = SearchService(service.db_path) - try: - yield svc - finally: - svc.close() - - app.dependency_overrides[get_service] = override_get_service - client = TestClient(app) - yield client - app.dependency_overrides.clear() - - -def test_api_chunk_document_endpoint_creates_chunks(api_client, service: SearchService): - source = service.upsert_source("example.com", name="Example") - document = service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/chunk", - title="Chunk", - published_at=None, - content_markdown="Chunk me", - ) - - response = api_client.post("/search/documents/chunk", json={"document_id": document.id}) - - assert response.status_code == 200 - data = response.json() - assert data["chunks_created"] > 0 - - -def test_api_chunk_document_missing_returns_404(api_client): - response = api_client.post("/search/documents/chunk", json={"document_id": "missing"}) - - assert response.status_code == 404 - - -def test_api_chunk_documents_sync_endpoint(api_client, service: SearchService): - source = service.upsert_source("example.com", name="Example") - service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/one", - title="One", - published_at=None, - content_markdown="One", - ) - service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/two", - title="Two", - published_at=None, - content_markdown="Two", - ) - - response = api_client.post("/search/documents/chunk/sync", json={"maximum": 1}) - - assert response.status_code == 200 - data = response.json() - assert data["chunks_created"] > 0 - - -def test_api_document_upsert_rejects_empty_content(api_client): - response = api_client.post( - "/search/documents", - json={ - "source_id": "source", - "canonical_url": "https://example.com/empty", - "content_markdown": " ", - }, - ) - - assert response.status_code == 422 - - -def test_api_ingest_opml_endpoint_exists(api_client): - """Test that API endpoint exists and accepts opml_url parameter""" - # Mock the service method directly to verify the route delegates correctly - with patch.object(SearchService, "create_documents_from_opml", return_value=[]) as mock_opml: - response = api_client.post("/search/ingest/opml", json={"opml_url": "http://example.com/opml"}) - - assert response.status_code == 200 - mock_opml.assert_called_once_with("http://example.com/opml", paginate=False) - - -def test_api_ingest_opml_accepts_paginate_flag(api_client): - with patch.object(SearchService, "create_documents_from_opml", return_value=[]) as mock_opml: - response = api_client.post( - "/search/ingest/opml", - json={"opml_url": "http://example.com/opml", "paginate": True}, - ) - - assert response.status_code == 200 - mock_opml.assert_called_once_with("http://example.com/opml", paginate=True) - - -def test_api_ingest_opml_returns_documents(api_client, service: SearchService): - """Test that API endpoint returns documents from create_documents_from_opml""" - # Create some test documents - source = service.upsert_source("example.com", name="Test") - doc1 = service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/doc1", - title="Doc 1", - published_at=None, - content_markdown="Content 1", - ) - doc2 = service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/doc2", - title="Doc 2", - published_at=None, - content_markdown="Content 2", - ) - - with patch.object(SearchService, "create_documents_from_opml", return_value=[doc1, doc2]): - response = api_client.post("/search/ingest/opml", json={"opml_url": "http://example.com/opml"}) - - assert response.status_code == 200 - data = response.json() - assert len(data) == 2 - assert data[0]["title"] == "Doc 1" - assert data[1]["title"] == "Doc 2" - - -def test_cli_ingest_opml_command(service: SearchService, http_server): - """Test that CLI command delegates to service.create_documents_from_opml""" - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - - runner = CliRunner() - - # Mock the config to use our test service's db_path - config = load_config() - config.db_path = service.db_path - - with patch("grogbot_cli.app.load_config", return_value=config): - result = runner.invoke(cli_app, ["search", "ingest-opml", f"{http_server}/opml"]) - - assert result.exit_code == 0 - # Output should be valid JSON array - import json - data = json.loads(result.output) - assert len(data) == 2 - urls = {doc["canonical_url"] for doc in data} - assert f"{http_server}/feed-entry" in urls - assert f"{http_server}/feed2-entry" in urls - - -def test_cli_ingest_opml_paginate_option(service: SearchService, http_server): - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - - runner = CliRunner() - config = load_config() - config.db_path = service.db_path - - with ( - patch("grogbot_cli.app.load_config", return_value=config), - patch.object( - SearchService, - "create_documents_from_opml", - autospec=True, - return_value=[], - ) as mock_opml, - ): - result = runner.invoke(cli_app, ["search", "ingest-opml", f"{http_server}/opml", "--paginate"]) - - assert result.exit_code == 0 - assert mock_opml.call_count == 1 - args, kwargs = mock_opml.call_args - assert args[1] == f"{http_server}/opml" - assert kwargs == {"paginate": True} - - -def test_cli_ingest_opml_output_matches_feed_ingest(service: SearchService, http_server): - """Verify CLI OPML output shape matches feed ingestion output""" - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - - runner = CliRunner() - - config = load_config() - config.db_path = service.db_path - - # First ingest a single feed to get reference output - with patch("grogbot_cli.app.load_config", return_value=config): - feed_result = runner.invoke(cli_app, ["search", "ingest-feed", f"{http_server}/feed"]) - - assert feed_result.exit_code == 0 - import json - feed_data = json.loads(feed_result.output) - - # Now ingest OPML and verify structure matches - with patch("grogbot_cli.app.load_config", return_value=config): - opml_result = runner.invoke(cli_app, ["search", "ingest-opml", f"{http_server}/opml"]) - - assert opml_result.exit_code == 0 - opml_data = json.loads(opml_result.output) - - # Both should be arrays of documents with same fields - assert isinstance(feed_data, list) - assert isinstance(opml_data, list) - if feed_data and opml_data: - assert set(feed_data[0].keys()) == set(opml_data[0].keys()) - - -def test_cli_document_chunk_command(service: SearchService): - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - import json - - source = service.upsert_source("example.com", name="Example") - document = service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/chunk", - title="Chunk", - published_at=None, - content_markdown="Chunk content", - ) - - runner = CliRunner() - config = load_config() - config.db_path = service.db_path - - with patch("grogbot_cli.app.load_config", return_value=config): - result = runner.invoke(cli_app, ["search", "document", "chunk", document.id]) - - assert result.exit_code == 0 - data = json.loads(result.output) - assert data["chunks_created"] > 0 - - -def test_cli_document_chunk_sync_command(service: SearchService): - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - import json - - source = service.upsert_source("example.com", name="Example") - service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/one", - title="One", - published_at=None, - content_markdown="One", - ) - service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/two", - title="Two", - published_at=None, - content_markdown="Two", - ) - - runner = CliRunner() - config = load_config() - config.db_path = service.db_path - - with patch("grogbot_cli.app.load_config", return_value=config): - result = runner.invoke(cli_app, ["search", "document", "chunk-sync", "--maximum", "1"]) - - assert result.exit_code == 0 - data = json.loads(result.output) - assert data["chunks_created"] > 0 - chunked = service.connection.execute( - "SELECT COUNT(DISTINCT document_id) AS count FROM chunks", - ).fetchone()["count"] - assert chunked == 1 - - -def test_opml_request_model_validation(api_client): - """Test that API validates the opml_url parameter is provided""" - # Should return 422 if opml_url is missing or invalid - response = api_client.post("/search/ingest/opml", json={}) - assert response.status_code == 422 - - response = api_client.post("/search/ingest/opml", json={"opml_url": None}) - assert response.status_code == 422 - - -def test_api_ingest_sitemap_endpoint_exists(api_client): - """Test that API endpoint exists and accepts sitemap_url parameter""" - with patch.object(SearchService, "create_documents_from_sitemap", return_value=[]) as mock_sitemap: - response = api_client.post( - "/search/ingest/sitemap", - json={"sitemap_url": "http://example.com/sitemap.xml"}, - ) - - assert response.status_code == 200 - mock_sitemap.assert_called_once_with("http://example.com/sitemap.xml") - - -def test_api_ingest_sitemap_returns_documents(api_client, service: SearchService): - """Test that API endpoint returns documents from create_documents_from_sitemap""" - source = service.upsert_source("example.com", name="Test") - doc1 = service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/doc1", - title="Doc 1", - published_at=None, - content_markdown="Content 1", - ) - doc2 = service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/doc2", - title="Doc 2", - published_at=None, - content_markdown="Content 2", - ) - - with patch.object(SearchService, "create_documents_from_sitemap", return_value=[doc1, doc2]): - response = api_client.post( - "/search/ingest/sitemap", - json={"sitemap_url": "http://example.com/sitemap.xml"}, - ) - - assert response.status_code == 200 - data = response.json() - assert len(data) == 2 - assert data[0]["title"] == "Doc 1" - assert data[1]["title"] == "Doc 2" - - -def test_cli_ingest_sitemap_command(service: SearchService, http_server): - """Test that CLI command delegates to service.create_documents_from_sitemap""" - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - - runner = CliRunner() - - config = load_config() - config.db_path = service.db_path - - with patch("grogbot_cli.app.load_config", return_value=config): - result = runner.invoke(cli_app, ["search", "ingest-sitemap", f"{http_server}/sitemap.xml"]) - - assert result.exit_code == 0 - import json - data = json.loads(result.output) - assert len(data) == 2 - urls = {doc["canonical_url"] for doc in data} - assert f"{http_server}/canonical" in urls - assert f"{http_server}/canonical-2" in urls - - -def test_cli_ingest_sitemap_surfaces_backoff_failure(service: SearchService, http_server): - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - - runner = CliRunner() - - config = load_config() - config.db_path = service.db_path - - with patch("grogbot_cli.app.load_config", return_value=config): - result = runner.invoke(cli_app, ["search", "ingest-sitemap", f"{http_server}/sitemap-backoff-403.xml"]) - - assert result.exit_code != 0 - assert isinstance(result.exception, BackoffError) - - -def test_api_ingest_sitemap_surfaces_backoff_failure(api_client, http_server): - with pytest.raises(BackoffError): - api_client.post( - "/search/ingest/sitemap", - json={"sitemap_url": f"{http_server}/sitemap-backoff-403.xml"}, - ) - - -def test_cli_ingest_url_surfaces_backoff_failure(service: SearchService, http_server): - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - - runner = CliRunner() - - config = load_config() - config.db_path = service.db_path - - with patch("grogbot_cli.app.load_config", return_value=config): - result = runner.invoke(cli_app, ["search", "ingest-url", f"{http_server}/backoff-403"]) - - assert result.exit_code != 0 - assert isinstance(result.exception, BackoffError) - - -def test_api_ingest_url_surfaces_backoff_failure(api_client, http_server): - with pytest.raises(BackoffError): - api_client.post( - "/search/ingest/url", - json={"url": f"{http_server}/backoff-403"}, - ) - - -def test_sitemap_request_model_validation(api_client): - """Test that API validates the sitemap_url parameter is provided""" - response = api_client.post("/search/ingest/sitemap", json={}) - assert response.status_code == 422 - - response = api_client.post("/search/ingest/sitemap", json={"sitemap_url": None}) - assert response.status_code == 422 - - -def test_cli_query_includes_full_content_by_default(service: SearchService): - """Default query output should include chunk.content_text and document.content_markdown.""" - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - import json - - source = service.upsert_source("example.com", name="Example") - document = service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/full", - title="Full", - published_at=None, - content_markdown="hello world full content", - ) - service.chunk_document(document.id) - - runner = CliRunner() - config = load_config() - config.db_path = service.db_path - - with patch("grogbot_cli.app.load_config", return_value=config): - result = runner.invoke(cli_app, ["search", "query", "hello", "--limit", "1"]) - - assert result.exit_code == 0 - data = json.loads(result.output) - assert len(data) == 1 - assert "content_markdown" in data[0]["document"] - assert "content_text" in data[0]["chunk"] - - -def test_cli_query_summary_omits_large_content_fields(service: SearchService): - """Summary query output should omit chunk.content_text and document.content_markdown.""" - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - import json - - source = service.upsert_source("example.com", name="Example") - document = service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/summary", - title="Summary", - published_at=None, - content_markdown="hello world summary content", - ) - service.chunk_document(document.id) - - runner = CliRunner() - config = load_config() - config.db_path = service.db_path - - with patch("grogbot_cli.app.load_config", return_value=config): - result = runner.invoke(cli_app, ["search", "query", "hello", "--limit", "1", "--summary"]) - - assert result.exit_code == 0 - data = json.loads(result.output) - assert len(data) == 1 - assert "content_markdown" not in data[0]["document"] - assert "content_text" not in data[0]["chunk"] - - -def test_cli_bootstrap_reads_sources_and_runs_sitemaps_then_feeds(service: SearchService, tmp_path): - """Bootstrap should process all sitemaps first, then all feeds from sources.toml.""" - from typer.testing import CliRunner - from grogbot_cli.app import app as cli_app - from grogbot_search import load_config - - sources_path = tmp_path / "sources.toml" - sources_path.write_text( - """ -[[source]] -sitemap = "https://example.com/sitemap-1.xml" -feed = "https://example.com/feed-1.xml" - -[[source]] -sitemap = "https://example.com/sitemap-2.xml" -feed = "https://example.com/feed-2.xml" -""".strip() - ) - - call_order: list[tuple[str, str, bool]] = [] - - def sitemap_side_effect(_self, sitemap_url: str, bootstrap: bool = False): - call_order.append(("sitemap", sitemap_url, bootstrap)) - return [] - - def feed_side_effect(_self, feed_url: str, paginate: bool = False): - call_order.append(("feed", feed_url, paginate)) - return [] - - runner = CliRunner() - config = load_config() - config.db_path = service.db_path - - with ( - patch("grogbot_cli.app.load_config", return_value=config), - patch("grogbot_cli.app._bootstrap_sources_path", return_value=sources_path), - patch.object( - SearchService, - "create_documents_from_sitemap", - autospec=True, - side_effect=sitemap_side_effect, - ), - patch.object( - SearchService, - "create_documents_from_feed", - autospec=True, - side_effect=feed_side_effect, - ), - ): - result = runner.invoke(cli_app, ["search", "bootstrap"]) - - assert result.exit_code == 0 - assert result.output == ( - "Scraping sitemap https://example.com/sitemap-1.xml\n" - "Scraping sitemap https://example.com/sitemap-2.xml\n" - "Scraping feed https://example.com/feed-1.xml\n" - "Scraping feed https://example.com/feed-2.xml\n" - ) - assert call_order == [ - ("sitemap", "https://example.com/sitemap-1.xml", True), - ("sitemap", "https://example.com/sitemap-2.xml", True), - ("feed", "https://example.com/feed-1.xml", True), - ("feed", "https://example.com/feed-2.xml", True), - ] diff --git a/packages/search-core/tests/test_service.py b/packages/search-core/tests/test_service.py index ad08338..094db5e 100644 --- a/packages/search-core/tests/test_service.py +++ b/packages/search-core/tests/test_service.py @@ -1,8 +1,15 @@ from __future__ import annotations +from datetime import datetime, timezone +from types import SimpleNamespace +from urllib.parse import urlparse + +import pysqlite3 as sqlite3 import pytest -from grogbot_search.service import DocumentNotFoundError, SearchService +import grogbot_search.service as service_module +from grogbot_search.models import Document +from grogbot_search.service import BackoffError, DocumentNotFoundError, SearchService def _chunk_texts(service: SearchService, document_id: str) -> list[str]: @@ -13,6 +20,8 @@ def _chunk_texts(service: SearchService, document_id: str) -> list[str]: return [row["content_text"] for row in rows] +# Core source/document/chunk persistence behavior + def test_source_upsert_and_cascade_delete(service: SearchService): source = service.upsert_source("example.com", name="Example") document = service.upsert_document( @@ -55,6 +64,179 @@ def test_document_upsert_regenerates_chunks(service: SearchService): assert initial_chunks != updated_chunks +def test_upsert_document_without_content_change_preserves_existing_chunks(service: SearchService): + source = service.upsert_source("example.com", name="Example") + document = service.upsert_document( + source_id=source.id, + canonical_url="https://example.com/stable", + title="Stable", + published_at=None, + content_markdown="stable body", + ) + service.chunk_document(document.id) + + original_chunk_rows = service.connection.execute( + "SELECT id, content_text FROM chunks WHERE document_id = ? ORDER BY chunk_index", + (document.id,), + ).fetchall() + + updated = service.upsert_document( + source_id=source.id, + canonical_url="https://example.com/stable", + title="Stable (renamed)", + published_at=None, + content_markdown="stable body", + ) + + assert updated.id == document.id + assert updated.title == "Stable (renamed)" + + updated_chunk_rows = service.connection.execute( + "SELECT id, content_text FROM chunks WHERE document_id = ? ORDER BY chunk_index", + (document.id,), + ).fetchall() + assert [(row["id"], row["content_text"]) for row in updated_chunk_rows] == [ + (row["id"], row["content_text"]) for row in original_chunk_rows + ] + + +def test_upsert_document_rejects_empty_content(service: SearchService): + source = service.upsert_source("example.com", name="Example") + + with pytest.raises(ValueError): + service.upsert_document( + source_id=source.id, + canonical_url="https://example.com/empty", + title="Empty", + published_at=None, + content_markdown=" ", + ) + + doc_count = service.connection.execute("SELECT COUNT(*) AS count FROM documents").fetchone()["count"] + assert doc_count == 0 + + +def test_delete_document_cascades_chunks_and_vector_rows(service: SearchService): + source = service.upsert_source("example.com", name="Example") + document = service.upsert_document( + source_id=source.id, + canonical_url="https://example.com/deletable", + title="Delete Me", + published_at=None, + content_markdown="alpha beta gamma", + ) + service.chunk_document(document.id) + + assert service.delete_document(document.id) is True + assert service.delete_document(document.id) is False + + chunk_rows = service.connection.execute( + "SELECT COUNT(*) AS count FROM chunks WHERE document_id = ?", + (document.id,), + ).fetchone() + assert chunk_rows["count"] == 0 + total_vector_rows = service.connection.execute("SELECT COUNT(*) AS count FROM chunks_vec").fetchone() + assert total_vector_rows["count"] == 0 + + +def test_list_documents_can_filter_by_source(service: SearchService): + source_a = service.upsert_source("a.example", name="A") + source_b = service.upsert_source("b.example", name="B") + + doc_a = service.upsert_document( + source_id=source_a.id, + canonical_url="https://a.example/one", + title="One", + published_at=None, + content_markdown="alpha", + ) + service.upsert_document( + source_id=source_b.id, + canonical_url="https://b.example/two", + title="Two", + published_at=None, + content_markdown="beta", + ) + + filtered = service.list_documents(source_id=source_a.id) + + assert [document.id for document in filtered] == [doc_a.id] + + +def test_chunk_document_returns_count(service: SearchService): + source = service.upsert_source("example.com", name="Example") + document = service.upsert_document( + source_id=source.id, + canonical_url="https://example.com/chunk", + title="Chunk", + published_at=None, + content_markdown="Hello world", + ) + + created = service.chunk_document(document.id) + + row = service.connection.execute( + "SELECT COUNT(*) AS count FROM chunks WHERE document_id = ?", + (document.id,), + ).fetchone() + assert created == row["count"] + assert created > 0 + + +def test_chunk_document_missing_raises(service: SearchService): + with pytest.raises(DocumentNotFoundError): + service.chunk_document("missing-id") + + +def test_synchronize_document_chunks_respects_maximum(service: SearchService): + source = service.upsert_source("example.com", name="Example") + service.upsert_document( + source_id=source.id, + canonical_url="https://example.com/first", + title="First", + published_at=None, + content_markdown="first content", + ) + service.upsert_document( + source_id=source.id, + canonical_url="https://example.com/second", + title="Second", + published_at=None, + content_markdown="second content", + ) + + created = service.synchronize_document_chunks(maximum=1) + chunked_docs = service.connection.execute( + "SELECT DISTINCT document_id FROM chunks ORDER BY document_id", + ).fetchall() + assert len(chunked_docs) == 1 + assert created == service.connection.execute("SELECT COUNT(*) AS count FROM chunks").fetchone()["count"] + + service.synchronize_document_chunks() + chunked_docs = service.connection.execute( + "SELECT DISTINCT document_id FROM chunks ORDER BY document_id", + ).fetchall() + assert len(chunked_docs) == 2 + + +def test_synchronize_document_chunks_non_positive_maximum_is_noop(service: SearchService): + source = service.upsert_source("example.com", name="Example") + service.upsert_document( + source_id=source.id, + canonical_url="https://example.com/noop", + title="Noop", + published_at=None, + content_markdown="noop", + ) + + assert service.synchronize_document_chunks(maximum=0) == 0 + assert service.synchronize_document_chunks(maximum=-5) == 0 + chunk_count = service.connection.execute("SELECT COUNT(*) AS count FROM chunks").fetchone()["count"] + assert chunk_count == 0 + + +# Search behavior + def test_rank_fusion_search_returns_results(service: SearchService): source = service.upsert_source("example.com", name="Example") document = service.upsert_document( @@ -118,115 +300,562 @@ def test_rank_fusion_zero_fills_missing_method_score(service: SearchService): assert top.score == pytest.approx(top.fts_score + top.vector_score) -class _RecordingConnection: - def __init__(self, connection): - self._connection = connection - self.calls: list[tuple[str, tuple]] = [] +def test_search_respects_result_limit(service: SearchService): + source = service.upsert_source("example.com", name="Example") + for index in range(5): + service.upsert_document( + source_id=source.id, + canonical_url=f"https://example.com/doc-{index}", + title=f"Doc {index}", + published_at=None, + content_markdown="alpha", + ) + service.synchronize_document_chunks() - def execute(self, sql: str, params=()): - self.calls.append((sql, params)) - return self._connection.execute(sql, params) + results = service.search("alpha", limit=2) - def __getattr__(self, name): - return getattr(self._connection, name) + assert len(results) == 2 -def test_search_uses_limit_times_ten_candidate_depth(service: SearchService): +def test_search_returns_empty_for_blank_query_or_non_positive_limit(service: SearchService): source = service.upsert_source("example.com", name="Example") document = service.upsert_document( source_id=source.id, - canonical_url="https://example.com/alpha", - title="Alpha", + canonical_url="https://example.com/searchable", + title="Searchable", published_at=None, - content_markdown="alpha alpha", + content_markdown="hello world", ) service.chunk_document(document.id) - recording_connection = _RecordingConnection(service.connection) - service.connection = recording_connection + assert service.search(" ", limit=5) == [] + assert service.search("hello", limit=0) == [] + assert service.search("hello", limit=-1) == [] - service.search("alpha", limit=4) - ranking_calls = [ - params - for _, params in recording_connection.calls - if isinstance(params, tuple) and len(params) == 5 and params[0] == "alpha" - ] - assert ranking_calls +# Ingestion behavior implemented in service.py - params = ranking_calls[-1] - assert params[1] == 40 - assert params[3] == 40 - assert params[4] == 4 +def test_create_document_from_url(service: SearchService, http_server): + document = service.create_document_from_url(f"{http_server}/article") + assert document.canonical_url == f"{http_server}/canonical" + assert "Article Heading" in document.content_markdown or "Hello world" in document.content_markdown -def test_upsert_document_rejects_empty_content(service: SearchService): - source = service.upsert_source("example.com", name="Example") + source = service.get_source(document.source_id) + assert source is not None + assert source.canonical_domain == urlparse(document.canonical_url).netloc - with pytest.raises(ValueError): - service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/empty", - title="Empty", - published_at=None, - content_markdown=" ", - ) - doc_count = service.connection.execute("SELECT COUNT(*) AS count FROM documents").fetchone()["count"] - assert doc_count == 0 +def test_create_document_from_url_falls_back_to_requested_url_when_canonical_missing( + service: SearchService, + http_server, +): + document = service.create_document_from_url(f"{http_server}/article-no-canonical") + assert document.canonical_url == f"{http_server}/article-no-canonical" -def test_chunk_document_returns_count(service: SearchService): - source = service.upsert_source("example.com", name="Example") - document = service.upsert_document( + +def test_create_document_from_url_extracts_published_time_metadata(service: SearchService, http_server): + document = service.create_document_from_url(f"{http_server}/article-published") + + assert document.published_at == datetime(2025, 1, 9, 14, 30, tzinfo=timezone.utc) + + +@pytest.mark.parametrize( + "path", + [ + "backoff-403", + "backoff-429", + "backoff-503", + "backoff-retry-after", + "backoff-captcha", + ], +) +def test_create_document_from_url_raises_on_backoff_signals(service: SearchService, http_server, path: str): + with pytest.raises(BackoffError): + service.create_document_from_url(f"{http_server}/{path}") + + +@pytest.mark.parametrize( + ("path", "reason"), + [ + ("backoff-403", "status_code=403"), + ("backoff-429", "status_code=429"), + ("backoff-503", "status_code=503"), + ("backoff-retry-after", "retry-after-header"), + ("backoff-captcha", "body-marker=captcha"), + ], +) +def test_create_document_from_url_backoff_error_includes_reason( + service: SearchService, + http_server, + path: str, + reason: str, +): + with pytest.raises(BackoffError, match=reason): + service.create_document_from_url(f"{http_server}/{path}") + + +def test_create_documents_from_feed(service: SearchService, http_server): + documents = service.create_documents_from_feed(f"{http_server}/feed") + + assert len(documents) == 1 + document = documents[0] + assert document.canonical_url == f"{http_server}/feed-entry" + assert document.title == "Feed Entry" + + source = service.get_source(document.source_id) + assert source is not None + assert source.rss_feed == f"{http_server}/feed" + + +def test_create_documents_from_feed_uses_summary_when_content_missing(service: SearchService, http_server): + documents = service.create_documents_from_feed(f"{http_server}/feed-summary-and-empty") + + assert len(documents) == 1 + assert documents[0].canonical_url == f"{http_server}/summary-entry" + assert "Summary based content" in documents[0].content_markdown + + +def test_create_documents_from_feed_backfills_missing_source_attributes(service: SearchService, http_server): + document = service.create_document_from_url(f"{http_server}/article") + + source_before = service.get_source(document.source_id) + assert source_before is not None + assert source_before.name is None + assert source_before.rss_feed is None + + service.create_documents_from_feed(f"{http_server}/feed") + + source_after = service.get_source(document.source_id) + assert source_after is not None + assert source_after.name == "Test Feed" + assert source_after.rss_feed == f"{http_server}/feed" + + +def test_create_documents_from_feed_pagination_disabled(service: SearchService, http_server): + documents = service.create_documents_from_feed(f"{http_server}/feed-paginated") + + assert len(documents) == 1 + assert documents[0].canonical_url == f"{http_server}/feed-paginated-entry-1" + + +def test_create_documents_from_feed_pagination_enabled(service: SearchService, http_server): + documents = service.create_documents_from_feed(f"{http_server}/feed-paginated", paginate=True) + + assert len(documents) == 2 + urls = {doc.canonical_url for doc in documents} + assert f"{http_server}/feed-paginated-entry-1" in urls + assert f"{http_server}/feed-paginated-entry-2" in urls + + +def test_create_documents_from_feed_wordpress_pagination(service: SearchService, http_server): + documents = service.create_documents_from_feed(f"{http_server}/wp-feed", paginate=True) + + assert len(documents) == 2 + urls = {doc.canonical_url for doc in documents} + assert f"{http_server}/wp-feed-entry-1" in urls + assert f"{http_server}/wp-feed-entry-2" in urls + + +def test_create_documents_from_feed_pagination_stops_on_loop(service: SearchService, http_server): + documents = service.create_documents_from_feed(f"{http_server}/feed-loop", paginate=True) + + assert len(documents) == 1 + assert documents[0].canonical_url == f"{http_server}/feed-loop-entry" + + +def test_create_documents_from_feed_pagination_best_effort(service: SearchService, http_server): + documents = service.create_documents_from_feed(f"{http_server}/feed-paginated-error", paginate=True) + + assert len(documents) == 1 + assert documents[0].canonical_url == f"{http_server}/feed-paginated-error-entry" + + +def test_create_documents_from_opml_multi_feed(service: SearchService, http_server): + documents = service.create_documents_from_opml(f"{http_server}/opml") + + assert len(documents) == 2 + urls = {doc.canonical_url for doc in documents} + assert f"{http_server}/feed-entry" in urls + assert f"{http_server}/feed2-entry" in urls + + +def test_create_documents_from_opml_nested_outlines(service: SearchService, http_server): + documents = service.create_documents_from_opml(f"{http_server}/opml-nested") + + assert len(documents) == 2 + urls = {doc.canonical_url for doc in documents} + assert f"{http_server}/feed-entry" in urls + assert f"{http_server}/feed2-entry" in urls + + +def test_create_documents_from_opml_deduplicates_feeds(service: SearchService, http_server): + documents = service.create_documents_from_opml(f"{http_server}/opml-duplicates") + + assert len(documents) == 1 + assert documents[0].canonical_url == f"{http_server}/feed-entry" + + +def test_create_documents_from_opml_invalid_xml_raises_value_error(service: SearchService, http_server): + with pytest.raises(ValueError, match="Invalid OPML XML"): + service.create_documents_from_opml(f"{http_server}/invalid-opml") + + +def test_create_documents_from_sitemap_ingests_all_url_entries(service: SearchService, http_server): + documents = service.create_documents_from_sitemap(f"{http_server}/sitemap.xml") + + assert len(documents) == 2 + urls = {doc.canonical_url for doc in documents} + assert f"{http_server}/canonical" in urls + assert f"{http_server}/canonical-2" in urls + + +def test_create_documents_from_sitemap_deduplicates_urls(service: SearchService, http_server): + documents = service.create_documents_from_sitemap(f"{http_server}/sitemap-duplicates.xml") + + assert len(documents) == 1 + assert documents[0].canonical_url == f"{http_server}/canonical" + + +def test_create_documents_from_sitemap_invalid_xml_raises_value_error(service: SearchService, http_server): + with pytest.raises(ValueError, match="Invalid sitemap XML"): + service.create_documents_from_sitemap(f"{http_server}/invalid-sitemap.xml") + + +def test_create_documents_from_sitemap_bootstrap_skips_existing_urls(service: SearchService, http_server): + source = service.upsert_source(urlparse(http_server).netloc) + service.upsert_document( source_id=source.id, - canonical_url="https://example.com/chunk", - title="Chunk", + canonical_url=f"{http_server}/backoff-403", + title="Already ingested", published_at=None, - content_markdown="Hello world", + content_markdown="existing", ) - created = service.chunk_document(document.id) + documents = service.create_documents_from_sitemap(f"{http_server}/sitemap-bootstrap-skip.xml", bootstrap=True) - row = service.connection.execute( - "SELECT COUNT(*) AS count FROM chunks WHERE document_id = ?", - (document.id,), - ).fetchone() - assert created == row["count"] - assert created > 0 + assert len(documents) == 1 + assert documents[0].canonical_url == f"{http_server}/canonical-2" -def test_chunk_document_missing_raises(service: SearchService): - with pytest.raises(DocumentNotFoundError): - service.chunk_document("missing-id") +@pytest.mark.parametrize( + "sitemap_path", + [ + "sitemap-backoff-429.xml", + "sitemap-backoff-503.xml", + "sitemap-backoff-retry-after.xml", + "sitemap-backoff-captcha.xml", + ], +) +def test_create_documents_from_sitemap_raises_on_backoff_signals( + service: SearchService, + http_server, + sitemap_path: str, +): + with pytest.raises(BackoffError): + service.create_documents_from_sitemap(f"{http_server}/{sitemap_path}") -def test_synchronize_document_chunks_respects_maximum(service: SearchService): +def test_create_documents_from_sitemap_halts_on_backoff_and_keeps_prior_documents(service: SearchService, http_server): + with pytest.raises(BackoffError): + service.create_documents_from_sitemap(f"{http_server}/sitemap-backoff-403.xml") + + stored_documents = service.list_documents() + assert len(stored_documents) == 1 + assert stored_documents[0].canonical_url == f"{http_server}/canonical" + + +def test_search_service_supports_context_manager(tmp_path): + db_path = tmp_path / "ctx" / "search.db" + + with SearchService(db_path) as managed: + managed.connection.execute("SELECT 1") + + with pytest.raises(sqlite3.ProgrammingError): + managed.connection.execute("SELECT 1") + + +def test_get_source_missing_and_list_sources_order(service: SearchService): + assert service.get_source("missing") is None + + source_b = service.upsert_source("b.example", name="B") + source_a = service.upsert_source("a.example", name="A") + + sources = service.list_sources() + + assert [source.id for source in sources] == [source_a.id, source_b.id] + + +def test_document_has_chunks_reflects_chunk_lifecycle(service: SearchService): source = service.upsert_source("example.com", name="Example") - service.upsert_document( + document = service.upsert_document( source_id=source.id, - canonical_url="https://example.com/first", - title="First", + canonical_url="https://example.com/chunks", + title="Chunks", published_at=None, - content_markdown="first content", + content_markdown="chunk me", ) - service.upsert_document( - source_id=source.id, - canonical_url="https://example.com/second", - title="Second", + + assert service.document_has_chunks(document.id) is False + + service.chunk_document(document.id) + + assert service.document_has_chunks(document.id) is True + + +def test_parse_datetime_returns_none_for_invalid_values(): + assert service_module._parse_datetime(None) is None + assert service_module._parse_datetime("not a date") is None + + +def test_search_returns_empty_when_ranked_chunk_rows_are_missing(service: SearchService): + service.connection.execute( + "INSERT INTO chunks_vec (rowid, embedding) VALUES (?, ?)", + (9999, service._sqlite_vec.serialize_float32([0.0] * 768)), + ) + service.connection.commit() + + assert service.search("orphan chunk", limit=5) == [] + + +def test_create_document_from_url_rejects_empty_extracted_content(service: SearchService, http_server, monkeypatch): + class EmptyReadable: + def __init__(self, _html: str): + pass + + def summary(self): + return "" + + def short_title(self): + return "" + + monkeypatch.setattr(service_module, "ReadabilityDocument", EmptyReadable) + + with pytest.raises(ValueError, match="Empty content"): + service.create_document_from_url(f"{http_server}/article") + + +def test_create_documents_from_feed_skips_entries_without_url_or_content(service: SearchService, monkeypatch): + class Entry(dict): + def __getattr__(self, item): + try: + return self[item] + except KeyError as exc: # pragma: no cover - parity with dict attribute lookup + raise AttributeError(item) from exc + + parsed = SimpleNamespace( + feed={"title": "Edge Feed"}, + entries=[ + Entry(title="No URL", summary="

summary exists

"), + Entry(title="No Content", link="https://example.com/no-content"), + ], + status=200, + bozo=0, + ) + + monkeypatch.setattr("feedparser.parse", lambda _url: parsed) + + documents = service.create_documents_from_feed("https://example.com/feed") + + assert documents == [] + + +def test_create_documents_from_feed_wordpress_dict_generator_and_invalid_paged_value( + service: SearchService, + monkeypatch, +): + urls_seen: list[str] = [] + + def fake_parse(url: str): + urls_seen.append(url) + if "paged=2" in url: + return SimpleNamespace(feed={"title": "WP", "generator": "WordPress"}, entries=[], status=404, bozo=0) + return SimpleNamespace( + feed={"title": "WP", "generator": {"name": "WordPress"}}, + entries=[], + status=200, + bozo=0, + ) + + monkeypatch.setattr("feedparser.parse", fake_parse) + + documents = service.create_documents_from_feed("https://example.com/wp-feed?paged=oops", paginate=True) + + assert documents == [] + assert urls_seen == [ + "https://example.com/wp-feed?paged=oops", + "https://example.com/wp-feed?paged=2", + ] + + +def test_create_documents_from_feed_reraises_parse_error_on_first_page(service: SearchService, monkeypatch): + def fake_parse(_url: str): + raise RuntimeError("boom") + + monkeypatch.setattr("feedparser.parse", fake_parse) + + with pytest.raises(RuntimeError, match="boom"): + service.create_documents_from_feed("https://example.com/feed", paginate=True) + + +def test_create_documents_from_feed_stops_after_parse_error_on_later_page(service: SearchService, monkeypatch): + class Entry(dict): + def __getattr__(self, item): + try: + return self[item] + except KeyError as exc: # pragma: no cover - parity with dict attribute lookup + raise AttributeError(item) from exc + + calls = {"count": 0} + + def fake_parse(url: str): + calls["count"] += 1 + if calls["count"] == 1: + return SimpleNamespace( + feed={"title": "Paginated", "links": [{"rel": "next", "href": "https://example.com/feed-2"}]}, + entries=[ + Entry( + title="Entry 1", + link="https://example.com/entry-1", + content=[SimpleNamespace(value="

hello

")], + ) + ], + status=200, + bozo=0, + ) + raise RuntimeError(f"failed for {url}") + + monkeypatch.setattr("feedparser.parse", fake_parse) + + documents = service.create_documents_from_feed("https://example.com/feed", paginate=True) + + assert len(documents) == 1 + assert documents[0].canonical_url == "https://example.com/entry-1" + + +def test_create_documents_from_opml_continues_when_one_feed_ingestion_fails( + service: SearchService, + http_server, + monkeypatch, +): + fallback_document = Document( + id="doc-fallback", + source_id="source-fallback", + canonical_url="https://example.com/fallback", + title="Fallback", published_at=None, - content_markdown="second content", + content_markdown="fallback", ) - created = service.synchronize_document_chunks(maximum=1) - chunked_docs = service.connection.execute( - "SELECT DISTINCT document_id FROM chunks ORDER BY document_id", - ).fetchall() - assert len(chunked_docs) == 1 - assert created == service.connection.execute("SELECT COUNT(*) AS count FROM chunks").fetchone()["count"] + def fake_create_documents_from_feed(feed_url: str, paginate: bool = False): # noqa: ARG001 - signature parity + if feed_url.endswith("/feed"): + raise RuntimeError("feed failed") + return [fallback_document] + + monkeypatch.setattr(service, "create_documents_from_feed", fake_create_documents_from_feed) + + documents = service.create_documents_from_opml(f"{http_server}/opml") + + assert documents == [fallback_document] + + +def test_create_documents_from_feed_ignores_non_next_links_when_paging(service: SearchService, monkeypatch): + class Entry(dict): + def __getattr__(self, item): + try: + return self[item] + except KeyError as exc: # pragma: no cover - parity with dict attribute lookup + raise AttributeError(item) from exc + + seen_urls: list[str] = [] + + def fake_parse(url: str): + seen_urls.append(url) + if url.endswith("page-2"): + return SimpleNamespace(feed={"title": "Feed"}, entries=[], status=200, bozo=0) + return SimpleNamespace( + feed={ + "title": "Feed", + "links": [ + {"rel": "self", "href": "https://example.com/feed"}, + {"rel": "next", "href": "https://example.com/page-2"}, + ], + }, + entries=[ + Entry( + title="Entry 1", + link="https://example.com/entry-1", + content=[SimpleNamespace(value="

hello

")], + ) + ], + status=200, + bozo=0, + ) - service.synchronize_document_chunks() - chunked_docs = service.connection.execute( - "SELECT DISTINCT document_id FROM chunks ORDER BY document_id", - ).fetchall() - assert len(chunked_docs) == 2 + monkeypatch.setattr("feedparser.parse", fake_parse) + + documents = service.create_documents_from_feed("https://example.com/feed", paginate=True) + + assert len(documents) == 1 + assert seen_urls == ["https://example.com/feed", "https://example.com/page-2"] + + +def test_create_documents_from_feed_stops_on_bozo_second_page_with_no_entries(service: SearchService, monkeypatch): + class Entry(dict): + def __getattr__(self, item): + try: + return self[item] + except KeyError as exc: # pragma: no cover - parity with dict attribute lookup + raise AttributeError(item) from exc + + parse_count = {"count": 0} + + def fake_parse(url: str): + parse_count["count"] += 1 + if parse_count["count"] == 1: + return SimpleNamespace( + feed={"title": "Feed", "links": [{"rel": "next", "href": "https://example.com/bozo-2"}]}, + entries=[ + Entry( + title="Entry 1", + link="https://example.com/entry-1", + content=[SimpleNamespace(value="

hello

")], + ) + ], + status=200, + bozo=0, + ) + return SimpleNamespace(feed={"title": "Feed"}, entries=[], status=200, bozo=1) + + monkeypatch.setattr("feedparser.parse", fake_parse) + + documents = service.create_documents_from_feed("https://example.com/feed", paginate=True) + + assert len(documents) == 1 + assert documents[0].canonical_url == "https://example.com/entry-1" + + +def test_create_documents_from_feed_pagination_caps_at_100_pages(service: SearchService, monkeypatch): + visited: list[str] = [] + + def fake_parse(url: str): + visited.append(url) + page = int(url.rsplit("=", 1)[-1]) + return SimpleNamespace( + feed={"title": "Loop", "links": [{"rel": "next", "href": f"https://example.com/loop?page={page + 1}"}]}, + entries=[], + status=200, + bozo=0, + ) + + monkeypatch.setattr("feedparser.parse", fake_parse) + + documents = service.create_documents_from_feed("https://example.com/loop?page=1", paginate=True) + + assert documents == [] + assert len(visited) == 100 + + +def test_search_returns_empty_when_no_chunks_match_query(service: SearchService): + assert service.search("missing", limit=5) == [] diff --git a/uv.lock b/uv.lock index 067c22d..c104ca1 100644 --- a/uv.lock +++ b/uv.lock @@ -96,6 +96,110 @@ 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 = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cssselect" version = "1.4.0" @@ -239,6 +343,7 @@ dependencies = [ [package.optional-dependencies] test = [ { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -252,6 +357,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.6" }, { name = "pysqlite3", specifier = ">=0.5.2" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=5.0" }, { name = "python-dateutil", specifier = ">=2.9" }, { name = "python-slugify", specifier = ">=8.0" }, { name = "readability-lxml", specifier = ">=0.8" }, @@ -1038,6 +1144,20 @@ 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-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1533,6 +1653,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + [[package]] name = "torch" version = "2.10.0"