diff --git a/CLAUDE.md b/CLAUDE.md index 2c76099..803efd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,15 @@ docker compose up -d **Auth Flow**: LLDAP → Authelia (OIDC) → Backend JWT → Frontend localStorage +## Testing + +Always run `make test` before pushing code to ensure all tests pass. + +```bash +make test # Run tests +make test-cov # Run tests with coverage +``` + ## Key Patterns - All endpoints are async (`async def`) diff --git a/Makefile b/Makefile index 93ed43b..9b10d58 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deploy build up down restart logs migrate migrate-new frontend +.PHONY: deploy build up down restart logs migrate migrate-new frontend test # Build and deploy deploy: build up @@ -29,6 +29,13 @@ migrate-new: migrate-history: docker compose exec raggr aerich history +# Tests +test: + pytest tests/ -v + +test-cov: + pytest tests/ -v --cov + # Frontend frontend: cd raggr-frontend && yarn install && yarn build diff --git a/pyproject.toml b/pyproject.toml index 5cc22dd..36ae5c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,17 @@ dependencies = [ "aioboto3>=13.0.0", ] +[project.optional-dependencies] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.25.0", + "pytest-cov>=6.0.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + [tool.aerich] tortoise_orm = "config.db.TORTOISE_CONFIG" location = "./migrations" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..08776cc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +import os +import sys + +# Ensure project root is on the path so imports work +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Set FERNET_KEY for tests that import email models (EncryptedTextField needs it at import time) +if "FERNET_KEY" not in os.environ: + from cryptography.fernet import Fernet + + os.environ["FERNET_KEY"] = Fernet.generate_key().decode() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_chunker.py b/tests/unit/test_chunker.py new file mode 100644 index 0000000..e0cee77 --- /dev/null +++ b/tests/unit/test_chunker.py @@ -0,0 +1,139 @@ +"""Tests for text preprocessing functions in utils/chunker.py.""" + +from utils.chunker import ( + remove_headers_footers, + remove_special_characters, + remove_repeated_substrings, + remove_extra_spaces, + preprocess_text, +) + + +class TestRemoveHeadersFooters: + def test_removes_default_header(self): + text = "Header Line\nActual content here" + result = remove_headers_footers(text) + assert "Header" not in result + assert "Actual content here" in result + + def test_removes_default_footer(self): + text = "Actual content\nFooter Line" + result = remove_headers_footers(text) + assert "Footer" not in result + assert "Actual content" in result + + def test_custom_patterns(self): + text = "PAGE 1\nContent\nCopyright 2024" + result = remove_headers_footers( + text, + header_patterns=[r"^PAGE \d+$"], + footer_patterns=[r"^Copyright.*$"], + ) + assert "PAGE 1" not in result + assert "Copyright" not in result + assert "Content" in result + + def test_no_match_preserves_text(self): + text = "Just normal content" + result = remove_headers_footers(text) + assert result == "Just normal content" + + def test_empty_string(self): + assert remove_headers_footers("") == "" + + +class TestRemoveSpecialCharacters: + def test_removes_special_chars(self): + text = "Hello @world #test $100" + result = remove_special_characters(text) + assert "@" not in result + assert "#" not in result + assert "$" not in result + + def test_preserves_allowed_chars(self): + text = "Hello, world! How's it going? Yes-no." + result = remove_special_characters(text) + assert "," in result + assert "!" in result + assert "'" in result + assert "?" in result + assert "-" in result + assert "." in result + + def test_custom_pattern(self): + text = "keep @this but not #that" + result = remove_special_characters(text, special_chars=r"[#]") + assert "@this" in result + assert "#" not in result + + def test_empty_string(self): + assert remove_special_characters("") == "" + + +class TestRemoveRepeatedSubstrings: + def test_collapses_dots(self): + text = "Item.....Value" + result = remove_repeated_substrings(text) + assert result == "Item.Value" + + def test_single_dot_preserved(self): + text = "End of sentence." + result = remove_repeated_substrings(text) + assert result == "End of sentence." + + def test_custom_pattern(self): + text = "hello---world" + result = remove_repeated_substrings(text, pattern=r"-{2,}") + # Function always replaces matched pattern with "." + assert result == "hello.world" + + def test_empty_string(self): + assert remove_repeated_substrings("") == "" + + +class TestRemoveExtraSpaces: + def test_collapses_multiple_blank_lines(self): + text = "Line 1\n\n\n\nLine 2" + result = remove_extra_spaces(text) + # After collapsing newlines to \n\n, then \s+ collapses everything to single spaces + assert "\n\n\n" not in result + + def test_collapses_multiple_spaces(self): + text = "Hello world" + result = remove_extra_spaces(text) + assert result == "Hello world" + + def test_strips_whitespace(self): + text = " Hello world " + result = remove_extra_spaces(text) + assert result == "Hello world" + + def test_empty_string(self): + assert remove_extra_spaces("") == "" + + +class TestPreprocessText: + def test_full_pipeline(self): + text = "Header Info\nHello @world... with spaces\nFooter Info" + result = preprocess_text(text) + assert "Header" not in result + assert "Footer" not in result + assert "@" not in result + assert "..." not in result + assert " " not in result + + def test_preserves_meaningful_content(self): + text = "The cat weighs 10 pounds." + result = preprocess_text(text) + assert "cat" in result + assert "10" in result + assert "pounds" in result + + def test_empty_string(self): + assert preprocess_text("") == "" + + def test_already_clean(self): + text = "Simple clean text here." + result = preprocess_text(text) + assert "Simple" in result + assert "clean" in result diff --git a/tests/unit/test_crypto_service.py b/tests/unit/test_crypto_service.py new file mode 100644 index 0000000..6bd05ca --- /dev/null +++ b/tests/unit/test_crypto_service.py @@ -0,0 +1,91 @@ +"""Tests for encryption/decryption in blueprints/email/crypto_service.py.""" + +import os +from unittest.mock import patch + +import pytest +from cryptography.fernet import Fernet + + +# Generate a valid key for testing +TEST_FERNET_KEY = Fernet.generate_key().decode() + + +class TestEncryptedTextField: + @pytest.fixture + def field(self): + with patch.dict(os.environ, {"FERNET_KEY": TEST_FERNET_KEY}): + from blueprints.email.crypto_service import EncryptedTextField + + return EncryptedTextField() + + def test_encrypt_decrypt_roundtrip(self, field): + original = "my secret password" + encrypted = field.to_db_value(original, None) + decrypted = field.to_python_value(encrypted) + assert decrypted == original + assert encrypted != original + + def test_none_passthrough(self, field): + assert field.to_db_value(None, None) is None + assert field.to_python_value(None) is None + + def test_unicode_roundtrip(self, field): + original = "Hello 世界 🐱" + encrypted = field.to_db_value(original, None) + decrypted = field.to_python_value(encrypted) + assert decrypted == original + + def test_empty_string_roundtrip(self, field): + encrypted = field.to_db_value("", None) + decrypted = field.to_python_value(encrypted) + assert decrypted == "" + + def test_long_text_roundtrip(self, field): + original = "x" * 10000 + encrypted = field.to_db_value(original, None) + decrypted = field.to_python_value(encrypted) + assert decrypted == original + + def test_different_encryptions_differ(self, field): + """Fernet includes a timestamp, so two encryptions of the same value differ.""" + e1 = field.to_db_value("same", None) + e2 = field.to_db_value("same", None) + assert e1 != e2 # Different ciphertexts + assert field.to_python_value(e1) == field.to_python_value(e2) == "same" + + def test_wrong_key_fails(self, field): + encrypted = field.to_db_value("secret", None) + + # Create a field with a different key + other_key = Fernet.generate_key().decode() + with patch.dict(os.environ, {"FERNET_KEY": other_key}): + from blueprints.email.crypto_service import EncryptedTextField + + other_field = EncryptedTextField() + + with pytest.raises(Exception): + other_field.to_python_value(encrypted) + + +class TestValidateFernetKey: + def test_valid_key(self): + with patch.dict(os.environ, {"FERNET_KEY": TEST_FERNET_KEY}): + from blueprints.email.crypto_service import validate_fernet_key + + validate_fernet_key() # Should not raise + + def test_missing_key(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("FERNET_KEY", None) + from blueprints.email.crypto_service import validate_fernet_key + + with pytest.raises(ValueError, match="not set"): + validate_fernet_key() + + def test_invalid_key(self): + with patch.dict(os.environ, {"FERNET_KEY": "not-a-valid-key"}): + from blueprints.email.crypto_service import validate_fernet_key + + with pytest.raises(ValueError, match="validation failed"): + validate_fernet_key() diff --git a/tests/unit/test_email_helpers.py b/tests/unit/test_email_helpers.py new file mode 100644 index 0000000..ed8da09 --- /dev/null +++ b/tests/unit/test_email_helpers.py @@ -0,0 +1,38 @@ +"""Tests for email helper functions in blueprints/email/helpers.py.""" + +from blueprints.email.helpers import generate_email_token, get_user_email_address + + +class TestGenerateEmailToken: + def test_returns_16_char_hex(self): + token = generate_email_token("user-123", "my-secret") + assert len(token) == 16 + assert all(c in "0123456789abcdef" for c in token) + + def test_deterministic(self): + t1 = generate_email_token("user-123", "my-secret") + t2 = generate_email_token("user-123", "my-secret") + assert t1 == t2 + + def test_different_users_different_tokens(self): + t1 = generate_email_token("user-1", "secret") + t2 = generate_email_token("user-2", "secret") + assert t1 != t2 + + def test_different_secrets_different_tokens(self): + t1 = generate_email_token("user-1", "secret-a") + t2 = generate_email_token("user-1", "secret-b") + assert t1 != t2 + + +class TestGetUserEmailAddress: + def test_formats_correctly(self): + addr = get_user_email_address("abc123", "example.com") + assert addr == "ask+abc123@example.com" + + def test_preserves_token(self): + token = "deadbeef12345678" + addr = get_user_email_address(token, "mail.test.org") + assert token in addr + assert addr.startswith("ask+") + assert "@mail.test.org" in addr diff --git a/tests/unit/test_obsidian_service.py b/tests/unit/test_obsidian_service.py new file mode 100644 index 0000000..d05a5d3 --- /dev/null +++ b/tests/unit/test_obsidian_service.py @@ -0,0 +1,259 @@ +"""Tests for ObsidianService markdown parsing and file operations.""" + +import os +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Set vault path before importing so __init__ validation passes +_test_vault_dir = None + + +@pytest.fixture(autouse=True) +def vault_dir(tmp_path): + """Create a temporary vault directory with a sample .md file.""" + global _test_vault_dir + _test_vault_dir = tmp_path + + # Create a sample markdown file so vault validation passes + sample = tmp_path / "sample.md" + sample.write_text("# Sample\nHello world") + + with patch.dict(os.environ, {"OBSIDIAN_VAULT_PATH": str(tmp_path)}): + yield tmp_path + + +@pytest.fixture +def service(vault_dir): + from utils.obsidian_service import ObsidianService + + return ObsidianService() + + +class TestParseMarkdown: + def test_extracts_frontmatter(self, service): + content = "---\ntitle: Test Note\ntags: [cat, vet]\n---\n\nBody content" + result = service.parse_markdown(content) + assert result["metadata"]["title"] == "Test Note" + assert result["metadata"]["tags"] == ["cat", "vet"] + + def test_no_frontmatter(self, service): + content = "Just body content with no frontmatter" + result = service.parse_markdown(content) + assert result["metadata"] == {} + assert "Just body content" in result["content"] + + def test_invalid_yaml_frontmatter(self, service): + content = "---\n: invalid: yaml: [[\n---\n\nBody" + result = service.parse_markdown(content) + assert result["metadata"] == {} + + def test_extracts_tags(self, service): + content = "Some text with #tag1 and #tag2 here" + result = service.parse_markdown(content) + assert "tag1" in result["tags"] + assert "tag2" in result["tags"] + + def test_extracts_wikilinks(self, service): + content = "Link to [[Other Note]] and [[Another Page]]" + result = service.parse_markdown(content) + assert "Other Note" in result["wikilinks"] + assert "Another Page" in result["wikilinks"] + + def test_extracts_embeds(self, service): + content = "An embed [[!my_embed]] here" + result = service.parse_markdown(content) + assert "my_embed" in result["embeds"] + + def test_cleans_wikilinks_from_content(self, service): + content = "Text with [[link]] included" + result = service.parse_markdown(content) + assert "[[" not in result["content"] + assert "]]" not in result["content"] + + def test_filepath_passed_through(self, service): + result = service.parse_markdown("text", filepath=Path("/vault/note.md")) + assert result["filepath"] == "/vault/note.md" + + def test_filepath_none_by_default(self, service): + result = service.parse_markdown("text") + assert result["filepath"] is None + + def test_empty_content(self, service): + result = service.parse_markdown("") + assert result["metadata"] == {} + assert result["tags"] == [] + assert result["wikilinks"] == [] + assert result["embeds"] == [] + + +class TestGetDailyNotePath: + def test_formats_path_correctly(self, service): + date = datetime(2026, 3, 15) + path = service.get_daily_note_path(date) + assert path == "journal/2026/2026-03-15.md" + + def test_defaults_to_today(self, service): + path = service.get_daily_note_path() + today = datetime.now() + assert today.strftime("%Y-%m-%d") in path + assert path.startswith(f"journal/{today.strftime('%Y')}/") + + +class TestWalkVault: + def test_finds_markdown_files(self, service, vault_dir): + (vault_dir / "note1.md").write_text("# Note 1") + (vault_dir / "subdir").mkdir() + (vault_dir / "subdir" / "note2.md").write_text("# Note 2") + + files = service.walk_vault() + filenames = [f.name for f in files] + assert "sample.md" in filenames + assert "note1.md" in filenames + assert "note2.md" in filenames + + def test_excludes_obsidian_dir(self, service, vault_dir): + obsidian_dir = vault_dir / ".obsidian" + obsidian_dir.mkdir() + (obsidian_dir / "config.md").write_text("config") + + files = service.walk_vault() + filenames = [f.name for f in files] + assert "config.md" not in filenames + + def test_ignores_non_md_files(self, service, vault_dir): + (vault_dir / "image.png").write_bytes(b"\x89PNG") + + files = service.walk_vault() + filenames = [f.name for f in files] + assert "image.png" not in filenames + + +class TestCreateNote: + def test_creates_file(self, service, vault_dir): + path = service.create_note("My Test Note", "Body content") + full_path = vault_dir / path + assert full_path.exists() + + def test_sanitizes_title(self, service, vault_dir): + path = service.create_note("Hello World! @#$", "Body") + assert "hello-world" in path + assert "@" not in path + assert "#" not in path + + def test_includes_frontmatter(self, service, vault_dir): + path = service.create_note("Test", "Body", tags=["cat", "vet"]) + full_path = vault_dir / path + content = full_path.read_text() + assert "---" in content + assert "created_by: simbarag" in content + assert "cat" in content + assert "vet" in content + + def test_custom_folder(self, service, vault_dir): + path = service.create_note("Test", "Body", folder="custom/subfolder") + assert path.startswith("custom/subfolder/") + assert (vault_dir / path).exists() + + +class TestDailyNoteTasks: + def test_get_tasks_from_daily_note(self, service, vault_dir): + # Create a daily note with tasks + date = datetime(2026, 1, 15) + rel_path = service.get_daily_note_path(date) + note_path = vault_dir / rel_path + note_path.parent.mkdir(parents=True, exist_ok=True) + note_path.write_text( + "---\nmodified: 2026-01-15\n---\n" + "### tasks\n\n" + "- [ ] Feed the cat\n" + "- [x] Clean litter box\n" + "- [ ] Buy cat food\n\n" + "### log\n" + ) + + result = service.get_daily_tasks(date) + assert result["found"] is True + assert len(result["tasks"]) == 3 + assert result["tasks"][0] == {"text": "Feed the cat", "done": False} + assert result["tasks"][1] == {"text": "Clean litter box", "done": True} + assert result["tasks"][2] == {"text": "Buy cat food", "done": False} + + def test_get_tasks_no_note(self, service): + date = datetime(2099, 12, 31) + result = service.get_daily_tasks(date) + assert result["found"] is False + assert result["tasks"] == [] + + def test_add_task_creates_note(self, service, vault_dir): + date = datetime(2026, 6, 1) + result = service.add_task_to_daily_note("Walk the cat", date) + assert result["success"] is True + assert result["created_note"] is True + + # Verify file was created with the task + note_path = vault_dir / result["path"] + content = note_path.read_text() + assert "- [ ] Walk the cat" in content + + def test_add_task_to_existing_note(self, service, vault_dir): + date = datetime(2026, 6, 2) + rel_path = service.get_daily_note_path(date) + note_path = vault_dir / rel_path + note_path.parent.mkdir(parents=True, exist_ok=True) + note_path.write_text( + "---\nmodified: 2026-06-02\n---\n" + "### tasks\n\n" + "- [ ] Existing task\n\n" + "### log\n" + ) + + result = service.add_task_to_daily_note("New task", date) + assert result["success"] is True + assert result["created_note"] is False + + content = note_path.read_text() + assert "- [ ] Existing task" in content + assert "- [ ] New task" in content + + def test_complete_task_exact_match(self, service, vault_dir): + date = datetime(2026, 6, 3) + rel_path = service.get_daily_note_path(date) + note_path = vault_dir / rel_path + note_path.parent.mkdir(parents=True, exist_ok=True) + note_path.write_text("### tasks\n\n" "- [ ] Feed the cat\n" "- [ ] Buy food\n") + + result = service.complete_task_in_daily_note("Feed the cat", date) + assert result["success"] is True + + content = note_path.read_text() + assert "- [x] Feed the cat" in content + assert "- [ ] Buy food" in content # Other task unchanged + + def test_complete_task_partial_match(self, service, vault_dir): + date = datetime(2026, 6, 4) + rel_path = service.get_daily_note_path(date) + note_path = vault_dir / rel_path + note_path.parent.mkdir(parents=True, exist_ok=True) + note_path.write_text("### tasks\n\n- [ ] Feed the cat at 5pm\n") + + result = service.complete_task_in_daily_note("Feed the cat", date) + assert result["success"] is True + + def test_complete_task_not_found(self, service, vault_dir): + date = datetime(2026, 6, 5) + rel_path = service.get_daily_note_path(date) + note_path = vault_dir / rel_path + note_path.parent.mkdir(parents=True, exist_ok=True) + note_path.write_text("### tasks\n\n- [ ] Feed the cat\n") + + result = service.complete_task_in_daily_note("Walk the dog", date) + assert result["success"] is False + assert "not found" in result["error"] + + def test_complete_task_no_note(self, service): + date = datetime(2099, 12, 31) + result = service.complete_task_in_daily_note("Something", date) + assert result["success"] is False diff --git a/tests/unit/test_rate_limiting.py b/tests/unit/test_rate_limiting.py new file mode 100644 index 0000000..14acfd3 --- /dev/null +++ b/tests/unit/test_rate_limiting.py @@ -0,0 +1,92 @@ +"""Tests for rate limiting logic in email and WhatsApp blueprints.""" + +import time + + +class TestEmailRateLimit: + def setup_method(self): + """Reset rate limit store before each test.""" + from blueprints.email import _rate_limit_store + + _rate_limit_store.clear() + + def test_allows_under_limit(self): + from blueprints.email import _check_rate_limit, RATE_LIMIT_MAX + + for _ in range(RATE_LIMIT_MAX): + assert _check_rate_limit("sender@test.com") is True + + def test_blocks_at_limit(self): + from blueprints.email import _check_rate_limit, RATE_LIMIT_MAX + + for _ in range(RATE_LIMIT_MAX): + _check_rate_limit("sender@test.com") + + assert _check_rate_limit("sender@test.com") is False + + def test_different_senders_independent(self): + from blueprints.email import _check_rate_limit, RATE_LIMIT_MAX + + for _ in range(RATE_LIMIT_MAX): + _check_rate_limit("user1@test.com") + + # user1 is at limit, but user2 should be fine + assert _check_rate_limit("user1@test.com") is False + assert _check_rate_limit("user2@test.com") is True + + def test_window_expiry(self): + from blueprints.email import ( + _check_rate_limit, + _rate_limit_store, + RATE_LIMIT_MAX, + ) + + # Fill up the rate limit with timestamps in the past + past = time.monotonic() - 999 # Well beyond any window + _rate_limit_store["old@test.com"] = [past] * RATE_LIMIT_MAX + + # Should be allowed because all timestamps are expired + assert _check_rate_limit("old@test.com") is True + + +class TestWhatsAppRateLimit: + def setup_method(self): + """Reset rate limit store before each test.""" + from blueprints.whatsapp import _rate_limit_store + + _rate_limit_store.clear() + + def test_allows_under_limit(self): + from blueprints.whatsapp import _check_rate_limit, RATE_LIMIT_MAX + + for _ in range(RATE_LIMIT_MAX): + assert _check_rate_limit("whatsapp:+1234567890") is True + + def test_blocks_at_limit(self): + from blueprints.whatsapp import _check_rate_limit, RATE_LIMIT_MAX + + for _ in range(RATE_LIMIT_MAX): + _check_rate_limit("whatsapp:+1234567890") + + assert _check_rate_limit("whatsapp:+1234567890") is False + + def test_different_numbers_independent(self): + from blueprints.whatsapp import _check_rate_limit, RATE_LIMIT_MAX + + for _ in range(RATE_LIMIT_MAX): + _check_rate_limit("whatsapp:+1111111111") + + assert _check_rate_limit("whatsapp:+1111111111") is False + assert _check_rate_limit("whatsapp:+2222222222") is True + + def test_window_expiry(self): + from blueprints.whatsapp import ( + _check_rate_limit, + _rate_limit_store, + RATE_LIMIT_MAX, + ) + + past = time.monotonic() - 999 + _rate_limit_store["whatsapp:+9999999999"] = [past] * RATE_LIMIT_MAX + + assert _check_rate_limit("whatsapp:+9999999999") is True diff --git a/tests/unit/test_user_model.py b/tests/unit/test_user_model.py new file mode 100644 index 0000000..b63661b --- /dev/null +++ b/tests/unit/test_user_model.py @@ -0,0 +1,86 @@ +"""Tests for User model methods in blueprints/users/models.py.""" + +from unittest.mock import MagicMock + +import bcrypt + + +class TestUserModelMethods: + """Test User model methods without requiring a database connection. + + We instantiate a mock object with the same methods as User + to avoid Tortoise ORM initialization. + """ + + def _make_user(self, ldap_groups=None, password=None): + """Create a mock user with real method implementations.""" + from blueprints.users.models import User + + user = MagicMock(spec=User) + user.ldap_groups = ldap_groups + user.password = password + + # Bind real methods + user.has_group = lambda group: group in (user.ldap_groups or []) + user.is_admin = lambda: user.has_group("lldap_admin") + + def set_password(plain): + user.password = bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()) + + user.set_password = set_password + + def verify_password(plain): + if not user.password: + return False + return bcrypt.checkpw(plain.encode("utf-8"), user.password) + + user.verify_password = verify_password + + return user + + def test_has_group_true(self): + user = self._make_user(ldap_groups=["lldap_admin", "users"]) + assert user.has_group("lldap_admin") is True + assert user.has_group("users") is True + + def test_has_group_false(self): + user = self._make_user(ldap_groups=["users"]) + assert user.has_group("lldap_admin") is False + + def test_has_group_empty_list(self): + user = self._make_user(ldap_groups=[]) + assert user.has_group("anything") is False + + def test_has_group_none(self): + user = self._make_user(ldap_groups=None) + assert user.has_group("anything") is False + + def test_is_admin_true(self): + user = self._make_user(ldap_groups=["lldap_admin"]) + assert user.is_admin() is True + + def test_is_admin_false(self): + user = self._make_user(ldap_groups=["users"]) + assert user.is_admin() is False + + def test_is_admin_empty(self): + user = self._make_user(ldap_groups=[]) + assert user.is_admin() is False + + def test_set_and_verify_password(self): + user = self._make_user() + user.set_password("hunter2") + assert user.password is not None + assert user.verify_password("hunter2") is True + assert user.verify_password("wrong") is False + + def test_verify_password_no_password_set(self): + user = self._make_user(password=None) + assert user.verify_password("anything") is False + + def test_password_is_hashed(self): + user = self._make_user() + user.set_password("mypassword") + # The stored password should not be the plaintext + assert user.password != b"mypassword" + assert user.password != "mypassword" diff --git a/tests/unit/test_ynab_service.py b/tests/unit/test_ynab_service.py new file mode 100644 index 0000000..da19b7b --- /dev/null +++ b/tests/unit/test_ynab_service.py @@ -0,0 +1,254 @@ +"""Tests for YNAB service data formatting and filtering logic.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + + +def _mock_category( + name, budgeted, activity, balance, deleted=False, hidden=False, goal_type=None +): + cat = MagicMock() + cat.name = name + cat.budgeted = budgeted + cat.activity = activity + cat.balance = balance + cat.deleted = deleted + cat.hidden = hidden + cat.goal_type = goal_type + return cat + + +def _mock_transaction( + var_date, payee_name, category_name, amount, memo="", deleted=False, approved=True +): + txn = MagicMock() + txn.var_date = var_date + txn.payee_name = payee_name + txn.category_name = category_name + txn.amount = amount + txn.memo = memo + txn.deleted = deleted + txn.approved = approved + return txn + + +@pytest.fixture +def ynab_service(): + """Create a YNABService with mocked API client.""" + with patch.dict( + os.environ, {"YNAB_ACCESS_TOKEN": "fake-token", "YNAB_BUDGET_ID": "test-budget"} + ): + with patch("utils.ynab_service.ynab") as mock_ynab: + # Mock the configuration and API client chain + mock_ynab.Configuration.return_value = MagicMock() + mock_ynab.ApiClient.return_value = MagicMock() + mock_ynab.PlansApi.return_value = MagicMock() + mock_ynab.TransactionsApi.return_value = MagicMock() + mock_ynab.MonthsApi.return_value = MagicMock() + mock_ynab.CategoriesApi.return_value = MagicMock() + + from utils.ynab_service import YNABService + + service = YNABService() + yield service + + +class TestGetBudgetSummary: + def test_calculates_totals(self, ynab_service): + categories = [ + _mock_category("Groceries", 500_000, -350_000, 150_000), + _mock_category("Rent", 1_500_000, -1_500_000, 0), + ] + + mock_month = MagicMock() + mock_month.to_be_budgeted = 200_000 + + mock_budget = MagicMock() + mock_budget.name = "My Budget" + mock_budget.months = [mock_month] + mock_budget.categories = categories + mock_budget.currency_format = MagicMock(iso_code="USD") + + mock_response = MagicMock() + mock_response.data.budget = mock_budget + ynab_service.plans_api.get_plan_by_id.return_value = mock_response + + result = ynab_service.get_budget_summary() + + assert result["budget_name"] == "My Budget" + assert result["to_be_budgeted"] == 200.0 + assert result["total_budgeted"] == 2000.0 # (500k + 1500k) / 1000 + assert result["total_activity"] == -1850.0 + assert result["currency_format"] == "USD" + + def test_skips_deleted_and_hidden(self, ynab_service): + categories = [ + _mock_category("Active", 100_000, -50_000, 50_000), + _mock_category("Deleted", 999_000, -999_000, 0, deleted=True), + _mock_category("Hidden", 999_000, -999_000, 0, hidden=True), + ] + + mock_month = MagicMock() + mock_month.to_be_budgeted = 0 + mock_budget = MagicMock() + mock_budget.name = "Budget" + mock_budget.months = [mock_month] + mock_budget.categories = categories + mock_budget.currency_format = None + + mock_response = MagicMock() + mock_response.data.budget = mock_budget + ynab_service.plans_api.get_plan_by_id.return_value = mock_response + + result = ynab_service.get_budget_summary() + assert result["total_budgeted"] == 100.0 + assert result["currency_format"] == "USD" # Default fallback + + +class TestGetTransactions: + def test_filters_by_date_range(self, ynab_service): + transactions = [ + _mock_transaction("2026-01-05", "Store", "Groceries", -25_000), + _mock_transaction("2026-01-15", "Gas", "Transport", -40_000), + _mock_transaction( + "2026-02-01", "Store", "Groceries", -30_000 + ), # Out of range + ] + + mock_response = MagicMock() + mock_response.data.transactions = transactions + ynab_service.transactions_api.get_transactions.return_value = mock_response + + result = ynab_service.get_transactions( + start_date="2026-01-01", end_date="2026-01-31" + ) + + assert result["count"] == 2 + assert result["total_amount"] == -65.0 + + def test_filters_by_category(self, ynab_service): + transactions = [ + _mock_transaction("2026-01-05", "Store", "Groceries", -25_000), + _mock_transaction("2026-01-06", "Gas", "Transport", -40_000), + ] + + mock_response = MagicMock() + mock_response.data.transactions = transactions + ynab_service.transactions_api.get_transactions.return_value = mock_response + + result = ynab_service.get_transactions( + start_date="2026-01-01", + end_date="2026-01-31", + category_name="groceries", # Case insensitive + ) + + assert result["count"] == 1 + assert result["transactions"][0]["category"] == "Groceries" + + def test_filters_by_payee(self, ynab_service): + transactions = [ + _mock_transaction("2026-01-05", "Whole Foods", "Groceries", -25_000), + _mock_transaction("2026-01-06", "Shell Gas", "Transport", -40_000), + ] + + mock_response = MagicMock() + mock_response.data.transactions = transactions + ynab_service.transactions_api.get_transactions.return_value = mock_response + + result = ynab_service.get_transactions( + start_date="2026-01-01", + end_date="2026-01-31", + payee_name="whole", + ) + + assert result["count"] == 1 + assert result["transactions"][0]["payee"] == "Whole Foods" + + def test_skips_deleted(self, ynab_service): + transactions = [ + _mock_transaction("2026-01-05", "Store", "Groceries", -25_000), + _mock_transaction("2026-01-06", "Deleted", "Other", -10_000, deleted=True), + ] + + mock_response = MagicMock() + mock_response.data.transactions = transactions + ynab_service.transactions_api.get_transactions.return_value = mock_response + + result = ynab_service.get_transactions( + start_date="2026-01-01", end_date="2026-01-31" + ) + + assert result["count"] == 1 + + def test_converts_milliunits(self, ynab_service): + transactions = [ + _mock_transaction("2026-01-05", "Store", "Groceries", -12_340), + ] + + mock_response = MagicMock() + mock_response.data.transactions = transactions + ynab_service.transactions_api.get_transactions.return_value = mock_response + + result = ynab_service.get_transactions( + start_date="2026-01-01", end_date="2026-01-31" + ) + + assert result["transactions"][0]["amount"] == -12.34 + + def test_sorts_by_date_descending(self, ynab_service): + transactions = [ + _mock_transaction("2026-01-01", "A", "Cat", -10_000), + _mock_transaction("2026-01-15", "B", "Cat", -20_000), + _mock_transaction("2026-01-10", "C", "Cat", -30_000), + ] + + mock_response = MagicMock() + mock_response.data.transactions = transactions + ynab_service.transactions_api.get_transactions.return_value = mock_response + + result = ynab_service.get_transactions( + start_date="2026-01-01", end_date="2026-01-31" + ) + + dates = [t["date"] for t in result["transactions"]] + assert dates == sorted(dates, reverse=True) + + +class TestGetCategorySpending: + def test_month_format_normalization(self, ynab_service): + """Passing YYYY-MM should be normalized to YYYY-MM-01.""" + categories = [_mock_category("Food", 100_000, -50_000, 50_000)] + + mock_month = MagicMock() + mock_month.categories = categories + mock_month.to_be_budgeted = 0 + + mock_response = MagicMock() + mock_response.data.month = mock_month + ynab_service.months_api.get_plan_month.return_value = mock_response + + result = ynab_service.get_category_spending("2026-03") + + assert result["month"] == "2026-03" + + def test_identifies_overspent(self, ynab_service): + categories = [ + _mock_category("Dining", 200_000, -300_000, -100_000), # Overspent + _mock_category("Groceries", 500_000, -400_000, 100_000), # Fine + ] + + mock_month = MagicMock() + mock_month.categories = categories + mock_month.to_be_budgeted = 0 + + mock_response = MagicMock() + mock_response.data.month = mock_month + ynab_service.months_api.get_plan_month.return_value = mock_response + + result = ynab_service.get_category_spending("2026-03") + + assert len(result["overspent_categories"]) == 1 + assert result["overspent_categories"][0]["name"] == "Dining" + assert result["overspent_categories"][0]["overspent_by"] == 100.0 diff --git a/uv.lock b/uv.lock index 284df19..9254303 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/1a/956c6b1e35881bb9835a33c8db1565edcd133f8e45321010489092a0df40/aerich-0.9.2-py3-none-any.whl", hash = "sha256:d0f007acb21f6559f1eccd4e404fb039cf48af2689e0669afa62989389c0582d", size = 46451, upload-time = "2025-10-10T05:53:48.71Z" }, ] +[[package]] +name = "aioboto3" +version = "15.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + [[package]] name = "aiofiles" version = "25.1.0" @@ -115,6 +151,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", size = 9981, upload-time = "2024-11-06T10:44:52.917Z" }, ] +[[package]] +name = "aioimaplib" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/da/a454c47fb8522e607425e15bf1f49ccfdb3d75f4071f40b63ebd49573495/aioimaplib-2.0.1.tar.gz", hash = "sha256:5a494c3b75f220977048f5eb2c7ba9c0570a3148aaf38bee844e37e4d7af8648", size = 35555, upload-time = "2025-01-16T10:38:23.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/52/48aaa287fb3c4c995edcb602370b10d182dc5c48371df7cb3a404356733f/aioimaplib-2.0.1-py3-none-any.whl", hash = "sha256:727e00c35cf25106bd34611dddd6e2ddf91a5f1a7e72d9269f3ce62486b31e14", size = 34729, upload-time = "2025-01-16T10:38:20.427Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -331,6 +385,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "boto3" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, +] + [[package]] name = "build" version = "1.3.0" @@ -523,6 +605,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + [[package]] name = "cryptography" version = "46.0.3" @@ -906,6 +1057,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] +[[package]] +name = "html2text" +version = "2025.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/27/e158d86ba1e82967cc2f790b0cb02030d4a8bef58e0c79a8590e9678107f/html2text-2025.4.15.tar.gz", hash = "sha256:948a645f8f0bc3abe7fd587019a2197a12436cd73d0d4908af95bfc8da337588", size = 64316, upload-time = "2025-04-15T04:02:30.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/84/1a0f9555fd5f2b1c924ff932d99b40a0f8a6b12f6dd625e2a47f415b00ea/html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc", size = 34656, upload-time = "2025-04-15T04:02:28.44Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1052,6 +1212,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "iso8601" version = "2.1.0" @@ -1118,6 +1287,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/22/7ab7b4ec3a1c1f03aef376af11d23b05abcca3fb31fbca1e7557053b1ba2/jiter-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e2bbf24f16ba5ad4441a9845e40e4ea0cb9eed00e76ba94050664ef53ef4406", size = 347102, upload-time = "2025-09-15T09:20:20.16Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "jq" version = "1.10.0" @@ -2027,6 +2205,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pony" version = "0.7.19" @@ -2405,6 +2592,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +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.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2520,6 +2749,8 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aerich" }, + { name = "aioboto3" }, + { name = "aioimaplib" }, { name = "asyncpg" }, { name = "authlib" }, { name = "bcrypt" }, @@ -2528,6 +2759,7 @@ dependencies = [ { name = "flask" }, { name = "flask-jwt-extended" }, { name = "flask-login" }, + { name = "html2text" }, { name = "httpx" }, { name = "jq" }, { name = "langchain" }, @@ -2553,9 +2785,18 @@ dependencies = [ { name = "ynab" }, ] +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + [package.metadata] requires-dist = [ { name = "aerich", specifier = ">=0.8.0" }, + { name = "aioboto3", specifier = ">=13.0.0" }, + { name = "aioimaplib", specifier = ">=2.0.1" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "authlib", specifier = ">=1.3.0" }, { name = "bcrypt", specifier = ">=5.0.0" }, @@ -2564,6 +2805,7 @@ requires-dist = [ { name = "flask", specifier = ">=3.1.2" }, { name = "flask-jwt-extended", specifier = ">=4.7.1" }, { name = "flask-login", specifier = ">=0.6.3" }, + { name = "html2text", specifier = ">=2025.4.15" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jq", specifier = ">=1.10.0" }, { name = "langchain", specifier = ">=1.2.0" }, @@ -2578,6 +2820,9 @@ requires-dist = [ { name = "pre-commit", specifier = ">=4.3.0" }, { name = "pydantic", specifier = ">=2.11.9" }, { name = "pymupdf", specifier = ">=1.24.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "quart", specifier = ">=0.20.0" }, { name = "quart-jwt-extended", specifier = ">=0.1.0" }, @@ -2588,6 +2833,7 @@ requires-dist = [ { name = "twilio", specifier = ">=9.10.2" }, { name = "ynab", specifier = ">=1.3.0" }, ] +provides-extras = ["test"] [[package]] name = "referencing" @@ -2797,6 +3043,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -3243,6 +3501,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "wsproto" version = "1.2.0"