diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..db62f93
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,103 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+env:
+ PYTHON_VERSION: "3.13"
+
+jobs:
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Download NLTK data
+ run: |
+ python -c "import nltk; nltk.download('averaged_perceptron_tagger_eng'); nltk.download('punkt_tab')"
+
+ - name: Run tests with coverage
+ run: |
+ pytest --cov=src/drover --cov-report=xml --cov-report=term-missing
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage.xml
+ retention-days: 7
+
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+ cache: 'pip'
+
+ - name: Install ruff
+ run: pip install ruff
+
+ - name: Check formatting
+ run: ruff format --check src/ tests/
+
+ - name: Check linting
+ run: ruff check src/ tests/
+
+ type-check:
+ name: Type Check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Run mypy
+ run: mypy src/
+
+ security:
+ name: Security Scan
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+ cache: 'pip'
+
+ - name: Install bandit
+ run: pip install bandit
+
+ - name: Run security scan
+ run: |
+ bandit -r src/ -c pyproject.toml --severity-level medium --confidence-level medium
diff --git a/.gitignore b/.gitignore
index 949a127..5230e9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,9 @@ wheels/
venv/
ENV/
+# Python version (pyenv/uv) - use pyproject.toml requires-python instead
+.python-version
+
# IDE
.idea/
.vscode/settings.json.local
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 68c5da4..3322ca8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -55,7 +55,7 @@ bandit -r src/ -f json --severity-level medium --confidence-level medium --quiet
src/drover/
├── __init__.py # Package init, version definition
├── __main__.py # Entry point for python -m drover
-├── cli.py # Click CLI commands (classify, tag)
+├── cli.py # Click CLI commands (classify, tag, evaluate)
├── config.py # Configuration management (Pydantic models)
├── loader.py # DocumentLoader - text extraction from documents
├── classifier.py # LLM-based DocumentClassifier
@@ -465,7 +465,7 @@ JSONL file with expected classifications:
```jsonl
{"filename": "bank.pdf", "domain": "financial", "category": "banking", "doctype": "statement"}
-{"filename": "bill.pdf", "domain": "utilities", "category": "electric", "doctype": "bill"}
+{"filename": "bill.pdf", "domain": "financial", "category": "utilities", "doctype": "bill"}
```
### Running Evaluations
diff --git a/README.md b/README.md
index 8ae6d49..3a9e4b5 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,14 @@
Documentation
+
+
+
+
+
+
+
+
---
Drover uses LLMs to analyze documents and suggest consistent, policy-compliant filesystem paths and filenames. Named after herding dogs that drove livestock, Drover herds your scattered files into an organized folder structure.
@@ -42,7 +50,7 @@ Drover uses LLMs to analyze documents and suggest consistent, policy-compliant f
```bash
# Clone and install
-git clone https://github.com/your-org/drover.git
+git clone https://github.com/ckrough/drover.git
cd drover
pip install -e .
diff --git a/docs/adr/001-chain-of-thought-prompting.md b/docs/adr/001-chain-of-thought-prompting.md
index 9fd348b..d9e9ac3 100644
--- a/docs/adr/001-chain-of-thought-prompting.md
+++ b/docs/adr/001-chain-of-thought-prompting.md
@@ -57,4 +57,4 @@ Testing with household documents showed:
## Related
- `prompts/classification.md` - Prompt template with CoT steps
- `test_classifier_parse.py::test_parse_response_classification_analysis_tags` - Tests for CoT parsing
-- `classifier.py:400-404` - CoT tag extraction in `_parse_response()`
+- `classifier.py:_parse_response()` - CoT tag extraction (handles `` tags)
diff --git a/docs/adr/002-privacy-first-design.md b/docs/adr/002-privacy-first-design.md
index b43c269..a4435a1 100644
--- a/docs/adr/002-privacy-first-design.md
+++ b/docs/adr/002-privacy-first-design.md
@@ -55,7 +55,7 @@ ai:
# Optional: cloud provider for better accuracy
# ai:
# provider: anthropic
-# model: claude-3-5-sonnet-latest
+# model: claude-sonnet-4-20250514
```
### Environment Variables for Secrets
diff --git a/pyproject.toml b/pyproject.toml
index 2d73ee0..a5f79be 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,7 @@ dependencies = [
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
+ "pytest-cov>=4.0",
"ruff>=0.8",
"bandit>=1.7.5",
"mypy>=1.8.0",
diff --git a/src/drover/actions/tag.py b/src/drover/actions/tag.py
index 4e217c8..7af687e 100644
--- a/src/drover/actions/tag.py
+++ b/src/drover/actions/tag.py
@@ -6,6 +6,7 @@
import sys
from enum import StrEnum
from pathlib import Path
+from types import ModuleType
from typing import TYPE_CHECKING
from drover.actions.base import ActionPlan, ActionResult
@@ -43,6 +44,8 @@ class TagManager:
suffix indicates the color index (0 = no color, 1-7 = colors).
"""
+ _xattr: ModuleType # xattr module, lazily imported on macOS only
+
def __init__(self) -> None:
"""Initialize TagManager, checking platform compatibility."""
if sys.platform != "darwin":
diff --git a/tests/test_actions_base.py b/tests/test_actions_base.py
index 3f4a8cd..2e3ad16 100644
--- a/tests/test_actions_base.py
+++ b/tests/test_actions_base.py
@@ -4,7 +4,7 @@
import pytest
-from drover.actions.base import ActionPlan, ActionResult, FileAction
+from drover.actions.base import ActionPlan, ActionResult
from drover.actions.runner import ActionRunner
from drover.config import DroverConfig, ErrorMode
from drover.models import ClassificationResult
@@ -148,9 +148,7 @@ async def test_dry_run_plans_without_executing(
action = MockAction()
runner = ActionRunner(config, action)
- monkeypatch.setattr(
- runner._service._classifier, "classify", _make_fake_classify(tmp_path)
- )
+ monkeypatch.setattr(runner._service._classifier, "classify", _make_fake_classify(tmp_path))
outputs: list[ActionPlan | ActionResult] = []
@@ -177,9 +175,7 @@ async def test_execute_mode_runs_action(
action = MockAction()
runner = ActionRunner(config, action)
- monkeypatch.setattr(
- runner._service._classifier, "classify", _make_fake_classify(tmp_path)
- )
+ monkeypatch.setattr(runner._service._classifier, "classify", _make_fake_classify(tmp_path))
outputs: list[ActionPlan | ActionResult] = []
@@ -207,9 +203,7 @@ async def test_action_failure_returns_partial_exit_code(
action = MockAction(should_fail=True)
runner = ActionRunner(config, action)
- monkeypatch.setattr(
- runner._service._classifier, "classify", _make_fake_classify(tmp_path)
- )
+ monkeypatch.setattr(runner._service._classifier, "classify", _make_fake_classify(tmp_path))
exit_code = await runner.run([doc_path], dry_run=False)
@@ -217,9 +211,7 @@ async def test_action_failure_returns_partial_exit_code(
assert exit_code == 2
@pytest.mark.asyncio
- async def test_classification_error_skips_action(
- self, tmp_path: Path
- ) -> None:
+ async def test_classification_error_skips_action(self, tmp_path: Path) -> None:
"""Classification errors don't trigger action planning."""
missing_file = tmp_path / "missing.pdf"
diff --git a/tests/test_classifier_metrics.py b/tests/test_classifier_metrics.py
index b8e5c23..1c4cb7d 100644
--- a/tests/test_classifier_metrics.py
+++ b/tests/test_classifier_metrics.py
@@ -1,5 +1,4 @@
-"""Tests for DocumentClassifier metrics integration with LangChain callbacks.
-"""
+"""Tests for DocumentClassifier metrics integration with LangChain callbacks."""
import pytest
diff --git a/tests/test_classifier_parse.py b/tests/test_classifier_parse.py
index 513939a..a379714 100644
--- a/tests/test_classifier_parse.py
+++ b/tests/test_classifier_parse.py
@@ -21,7 +21,10 @@ def _make_classifier() -> DocumentClassifier:
def test_parse_response_direct_json() -> None:
classifier = _make_classifier()
- payload = '{"domain": "financial", "category": "banking", "doctype": "statement", "vendor": "Bank", "date": "20250101", "subject": "checking"}'
+ payload = (
+ '{"domain": "financial", "category": "banking", "doctype": "statement", '
+ '"vendor": "Bank", "date": "20250101", "subject": "checking"}'
+ )
result = classifier._parse_response(payload)
@@ -31,11 +34,12 @@ def test_parse_response_direct_json() -> None:
def test_parse_response_json_in_code_block() -> None:
classifier = _make_classifier()
+ # Long line intentional - simulates realistic LLM output in code block
payload = """Here is the answer:
```json
{"domain": "financial", "category": "banking", "doctype": "statement", "vendor": "Bank", "date": "20250101", "subject": "checking"}
```
-"""
+""" # noqa: E501
result = classifier._parse_response(payload)
@@ -44,7 +48,11 @@ def test_parse_response_json_in_code_block() -> None:
def test_parse_response_balanced_object_inside_text() -> None:
classifier = _make_classifier()
- payload = "Some explanation before {\n \"domain\": \"financial\",\n \"category\": \"banking\",\n \"doctype\": \"statement\",\n \"vendor\": \"Bank\",\n \"date\": \"20250101\",\n \"subject\": \"checking\"\n} and some trailing text."
+ payload = (
+ 'Some explanation before {\n "domain": "financial",\n "category": "banking",'
+ '\n "doctype": "statement",\n "vendor": "Bank",\n "date": "20250101",'
+ '\n "subject": "checking"\n} and some trailing text.'
+ )
result = classifier._parse_response(payload)
@@ -66,14 +74,14 @@ def test_parse_response_raises_on_invalid_json() -> None:
def test_parse_response_double_brace_wrapper() -> None:
"""LLM sometimes mirrors `{{ ... }}` examples from the prompt template."""
classifier = _make_classifier()
- payload = '''{{
+ payload = """{{
"domain": "financial",
"category": "banking",
"doctype": "statement",
"vendor": "Bank",
"date": "20250101",
"subject": "checking"
-}}'''
+}}"""
result = classifier._parse_response(payload)
diff --git a/tests/test_classifier_retry.py b/tests/test_classifier_retry.py
index 64cccb5..a2c977d 100644
--- a/tests/test_classifier_retry.py
+++ b/tests/test_classifier_retry.py
@@ -93,7 +93,10 @@ async def test_invoke_with_retry_success_no_retry(self) -> None:
mock_llm = MagicMock()
mock_response = MagicMock()
- mock_response.content = '{"domain": "financial", "category": "banking", "doctype": "statement", "vendor": "Bank", "date": "20250101", "subject": "test"}'
+ mock_response.content = (
+ '{"domain": "financial", "category": "banking", "doctype": "statement", '
+ '"vendor": "Bank", "date": "20250101", "subject": "test"}'
+ )
mock_llm.ainvoke = AsyncMock(return_value=mock_response)
with patch.object(classifier, "_get_llm", return_value=mock_llm):
@@ -113,7 +116,10 @@ async def test_invoke_with_retry_retries_on_connection_error(self) -> None:
mock_llm = MagicMock()
mock_response = MagicMock()
- mock_response.content = '{"domain": "financial", "category": "banking", "doctype": "statement", "vendor": "Bank", "date": "20250101", "subject": "test"}'
+ mock_response.content = (
+ '{"domain": "financial", "category": "banking", "doctype": "statement", '
+ '"vendor": "Bank", "date": "20250101", "subject": "test"}'
+ )
# Fail twice, succeed third time
mock_llm.ainvoke = AsyncMock(
@@ -141,7 +147,10 @@ async def test_invoke_with_retry_retries_on_timeout_error(self) -> None:
mock_llm = MagicMock()
mock_response = MagicMock()
- mock_response.content = '{"domain": "medical", "category": "records", "doctype": "report", "vendor": "Hospital", "date": "20250101", "subject": "test"}'
+ mock_response.content = (
+ '{"domain": "medical", "category": "records", "doctype": "report", '
+ '"vendor": "Hospital", "date": "20250101", "subject": "test"}'
+ )
# Fail once, succeed second time
mock_llm.ainvoke = AsyncMock(
@@ -167,9 +176,7 @@ async def test_invoke_with_retry_exhausts_retries(self) -> None:
classifier = _make_classifier(max_retries=2)
mock_llm = MagicMock()
- mock_llm.ainvoke = AsyncMock(
- side_effect=ConnectionError("Persistent network error")
- )
+ mock_llm.ainvoke = AsyncMock(side_effect=ConnectionError("Persistent network error"))
with patch.object(classifier, "_get_llm", return_value=mock_llm):
from langchain_core.messages import HumanMessage
diff --git a/tests/test_models.py b/tests/test_models.py
index 4e098aa..87750a6 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -16,9 +16,7 @@ def test_classification_result_success():
"""
result = ClassificationResult(
original="receipt.pdf",
- suggested_path=(
- "pets/expenses/receipt/receipt-petsmart-food_supplies-20250601.pdf"
- ),
+ suggested_path=("pets/expenses/receipt/receipt-petsmart-food_supplies-20250601.pdf"),
suggested_filename="receipt-petsmart-food_supplies-20250601.pdf",
domain="pets",
category="expenses",
diff --git a/tests/test_tag_manager.py b/tests/test_tag_manager.py
index d8137ed..08146e5 100644
--- a/tests/test_tag_manager.py
+++ b/tests/test_tag_manager.py
@@ -7,7 +7,6 @@
from drover.actions.tag import (
TagAction,
- TagError,
TagManager,
TagMode,
compute_final_tags,
@@ -175,9 +174,7 @@ def test_empty_field_skipped(self) -> None:
def test_all_fields(self) -> None:
"""All available fields work correctly."""
result = self._make_result()
- tags = tags_from_result(
- result, ["domain", "category", "doctype", "vendor", "date"]
- )
+ tags = tags_from_result(result, ["domain", "category", "doctype", "vendor", "date"])
assert tags == ["financial", "banking", "statement", "chase", "2024"]
diff --git a/uv.lock b/uv.lock
index bd3f9fb..1e3485f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -312,6 +312,67 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
]
+[[package]]
+name = "coverage"
+version = "7.13.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" },
+ { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" },
+ { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" },
+ { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" },
+ { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" },
+ { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" },
+ { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" },
+ { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
+ { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
+ { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
+ { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
+ { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
+ { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
+ { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
+ { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
+ { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
+ { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
+ { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
+ { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
+ { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
+ { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
+]
+
[[package]]
name = "cryptography"
version = "46.0.3"
@@ -446,6 +507,7 @@ dev = [
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
+ { name = "pytest-cov" },
{ name = "ruff" },
]
@@ -462,6 +524,7 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.0,<3.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
+ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "rich", specifier = ">=13.0,<14.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
@@ -2320,6 +2383,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
+[[package]]
+name = "pytest-cov"
+version = "7.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { 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"