diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 6d11f442..46c56a71 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -11,9 +11,9 @@ assignees: '' ## Steps To Reproduce -1. -2. -3. +1. +2. +3. ## Expected Behavior @@ -34,4 +34,4 @@ When running in debug mode, a DEBUG button will appear in the interface. Please ## Additional Context - \ No newline at end of file + diff --git a/.github/workflows/build-and-publish-docker-image.yml b/.github/workflows/build-and-publish-docker-image.yml index fde8ba59..9c43aee1 100644 --- a/.github/workflows/build-and-publish-docker-image.yml +++ b/.github/workflows/build-and-publish-docker-image.yml @@ -65,7 +65,7 @@ jobs: - name: Get current date id: date run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - + - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..dedf6180 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: builtin + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.10 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/.vscode/launch.json b/.vscode/launch.json index 545cc4cc..d838f968 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -61,4 +61,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/Makefile b/Makefile index dd2fabd3..c3009dab 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-python-dev dev build preview typecheck frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check python-typecheck python-dead-code python-checks python-test-lint python-test-lint-fix python-test-format python-test-format-check python-test-typecheck python-test-checks +.PHONY: help install install-python-dev dev build preview typecheck frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check python-typecheck python-dead-code python-checks python-test-lint python-test-lint-fix python-test-format python-test-format-check python-test-typecheck python-test-checks python-coverage prek-install # Frontend directory FRONTEND_DIR := src/frontend @@ -32,6 +32,8 @@ help: @echo " python-test-format-check - Check Python test formatting with Ruff" @echo " python-test-typecheck - Run lightweight BasedPyright checks against Python tests" @echo " python-test-checks - Run all relaxed Python test static analysis checks" + @echo " python-coverage - Run tests with coverage report" + @echo " prek-install - Install prek git hooks" @echo " clean - Remove node_modules and build artifacts" @echo "" @echo "Backend (Docker):" @@ -127,6 +129,14 @@ python-test-typecheck: python-test-checks: python-test-lint python-test-format-check python-test-typecheck +python-coverage: + @echo "Running tests with coverage..." + uv run pytest tests/ -x --tb=short -m "not integration and not e2e" --cov --cov-report=term-missing + +prek-install: + @echo "Installing prek git hooks..." + uv run prek install + # Run frontend unit tests frontend-test: @echo "Running frontend unit tests..." diff --git a/data/book-languages.json b/data/book-languages.json index 533fa362..a4b42c3f 100644 --- a/data/book-languages.json +++ b/data/book-languages.json @@ -69,4 +69,4 @@ { "language": "Uyghur", "code": "ug" }, { "language": "Armenian", "code": "hy" }, { "language": "Shan", "code": "shn" } -] \ No newline at end of file +] diff --git a/docs/environment-variables.md b/docs/environment-variables.md index b65b43a9..b2ce52d0 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -331,7 +331,7 @@ Directory where downloaded files are saved. Use {User} for per-user folders (e.g **File Organization** -Choose how downloaded book files are named and organized. +Choose how downloaded book files are named and organized. - **Type:** string (choice) - **Default:** `rename` diff --git a/entrypoint.sh b/entrypoint.sh index 77c29ce3..aad5a930 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -456,7 +456,7 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then --enable-logging --v=1 --log-level=0 \ --log-file=/tmp/chrome_entrypoint_test.log \ --crash-dumps-dir=/tmp/chrome_crash_dumps \ - < /dev/null + < /dev/null EXIT_CODE=$? echo "Chrome exit code: $EXIT_CODE" ls -lh /tmp/chrome_entrypoint_test.log diff --git a/genDebug.sh b/genDebug.sh index 469c478b..47d34911 100755 --- a/genDebug.sh +++ b/genDebug.sh @@ -199,4 +199,3 @@ else echo "Failed to create debug archive" exit 1 fi - diff --git a/pyproject.toml b/pyproject.toml index 5f977001..502d9e23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,9 @@ browser = [ [dependency-groups] dev = [ "basedpyright>=1.39.0", + "prek", "pytest", + "pytest-cov", "pytest-xdist>=3.8.0", "ruff==0.15.10", "vulture>=2.14", @@ -93,6 +95,11 @@ select = [ ignore = ["D", "EM", "FBT", "PLR2004", "UP035", "TRY003", "E501", "TD002", "S104", "S603"] [tool.ruff.lint.per-file-ignores] +"scripts/**/*.py" = [ + "BLE001", + "S", + "TRY", +] "tests/**/*.py" = [ "ANN", "BLE001", @@ -153,5 +160,13 @@ ignore_decorators = [ min_confidence = 90 sort_by_size = true +[tool.coverage.run] +source = ["shelfmark"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_empty = true + [tool.uv] package = false diff --git a/readme.md b/readme.md index 861a9ab8..e5b6bc0e 100644 --- a/readme.md +++ b/readme.md @@ -68,7 +68,7 @@ That's it! Configure settings through the web interface as needed. volumes: - /your/config/path:/config # Config, database, and artwork cache directory - /your/download/path:/books # Downloaded books - - /client/path:/client/path # Optional: For Torrent/Usenet downloads, match your client directory exactly. + - /client/path:/client/path # Optional: For Torrent/Usenet downloads, match your client directory exactly. ``` > **Tip**: Point the download volume to your CWA or Grimmory ingest folder for automatic import. diff --git a/scripts/generate_env_docs.py b/scripts/generate_env_docs.py index 98430728..f88cc37b 100755 --- a/scripts/generate_env_docs.py +++ b/scripts/generate_env_docs.py @@ -17,16 +17,15 @@ import argparse import sys -from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any # Add project root to path project_root = Path(__file__).resolve().parent.parent sys.path.insert(0, str(project_root)) -def get_field_type_name(field) -> str: +def get_field_type_name(field: Any) -> str: """Get a human-readable type name for a field.""" from shelfmark.core.settings_registry import ( CheckboxField, @@ -40,49 +39,47 @@ def get_field_type_name(field) -> str: if isinstance(field, CheckboxField): return "boolean" - elif isinstance(field, NumberField): + if isinstance(field, NumberField): return "number" - elif isinstance(field, SelectField): + if isinstance(field, SelectField): return "string (choice)" - elif isinstance(field, MultiSelectField): + if isinstance(field, MultiSelectField): return "string (comma-separated)" - elif isinstance(field, OrderableListField): + if isinstance(field, OrderableListField): return "JSON array" - elif isinstance(field, PasswordField): + if isinstance(field, PasswordField): return "string (secret)" - elif isinstance(field, TextField): - return "string" - else: + if isinstance(field, TextField): return "string" + return "string" -def format_default_value(field) -> str: +def format_default_value(field: Any) -> str: """Format the default value for display.""" default = field.default if default is None: return "_none_" - elif isinstance(default, bool): + if isinstance(default, bool): return f"`{str(default).lower()}`" - elif isinstance(default, (int, float)): + if isinstance(default, (int, float)): return f"`{default}`" - elif isinstance(default, str): + if isinstance(default, str): if default == "": return "_empty string_" return f"`{default}`" - elif isinstance(default, list): + if isinstance(default, list): if not default: return "_empty list_" # For simple lists, show comma-separated values if all(isinstance(item, str) for item in default): return f"`{','.join(default)}`" # For complex lists (e.g., OrderableListField defaults), summarize - return f"_see UI for defaults_" - else: - return f"`{default}`" + return "_see UI for defaults_" + return f"`{default}`" -def get_select_options(field) -> Optional[List[str]]: +def get_select_options(field: Any) -> list[str] | None: """Get the available options for a SelectField. Returns options formatted as 'value (label)' or just 'value' if they match, @@ -119,7 +116,7 @@ def get_select_options(field) -> Optional[List[str]]: return result -def _generate_bootstrap_env_docs() -> List[str]: +def _generate_bootstrap_env_docs() -> list[str]: """Generate documentation for bootstrap environment variables from env.py.""" # These are environment variables defined in env.py that are used before # the settings registry is available @@ -195,8 +192,10 @@ def _generate_bootstrap_env_docs() -> List[str]: "|----------|-------------|------|---------|", ] - for var in bootstrap_vars: - lines.append(f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |") + lines.extend( + f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |" + for var in bootstrap_vars + ) lines.append("") lines.append("
") @@ -221,17 +220,14 @@ def _generate_bootstrap_env_docs() -> List[str]: def generate_env_docs() -> str: """Generate markdown documentation for all environment variables.""" # Import settings modules to ensure all settings are registered - import shelfmark.config.settings # noqa: F401 - import shelfmark.config.security # noqa: F401 - import shelfmark.release_sources.irc.settings # noqa: F401 + import shelfmark.config.security + import shelfmark.config.settings + import shelfmark.metadata_providers.googlebooks + import shelfmark.metadata_providers.hardcover + import shelfmark.metadata_providers.openlibrary + import shelfmark.release_sources.irc.settings import shelfmark.release_sources.prowlarr.settings # noqa: F401 - import shelfmark.metadata_providers.hardcover # noqa: F401 - import shelfmark.metadata_providers.openlibrary # noqa: F401 - import shelfmark.metadata_providers.googlebooks # noqa: F401 - from shelfmark.core.settings_registry import ( - ActionButton, - HeadingField, get_all_groups, get_all_settings_tabs, ) @@ -240,7 +236,7 @@ def generate_env_docs() -> str: groups = {g.name: g for g in get_all_groups()} # Organize tabs by group - grouped_tabs: Dict[Optional[str], List] = {None: []} + grouped_tabs: dict[str | None, list] = {None: []} for group_name in groups: grouped_tabs[group_name] = [] @@ -309,7 +305,7 @@ def generate_env_docs() -> str: return "\n".join(lines) -def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]: +def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]: """Generate documentation for a single settings tab.""" from shelfmark.core.settings_registry import ActionButton, CustomComponentField, HeadingField @@ -318,7 +314,6 @@ def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]: # Section header if group_prefix: lines.append(f"### {group_prefix}: {tab.display_name}") - anchor_id = f"{group_prefix}-{tab.display_name}".lower().replace(" ", "-") else: lines.append(f"## {tab.display_name}") @@ -391,6 +386,7 @@ def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]: # Show constraints for NumberField from shelfmark.core.settings_registry import NumberField + if isinstance(field, NumberField): constraints = [] if field.min_value is not None: @@ -408,7 +404,7 @@ def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]: return lines -def main(): +def main() -> None: parser = argparse.ArgumentParser( description="Generate markdown documentation for environment variables" ) diff --git a/scripts/test_clients.py b/scripts/test_clients.py index 55bb0f5a..789e464e 100755 --- a/scripts/test_clients.py +++ b/scripts/test_clients.py @@ -51,7 +51,8 @@ import sys import time -from xmlrpc import client +from pathlib import Path +from typing import Any # Test configuration - matches docker-compose.test-clients.yml CONFIG = { @@ -89,7 +90,7 @@ TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso" -def test_nzbget(): +def test_nzbget() -> bool: """Test NZBGet connection.""" import requests @@ -138,7 +139,7 @@ def test_nzbget(): return False -def test_sabnzbd(): +def test_sabnzbd() -> bool: """Test SABnzbd connection.""" import requests @@ -152,10 +153,9 @@ def test_sabnzbd(): # Try to get API key from config if not set if not api_key: try: - import os - ini_path = ".local/test-clients/sabnzbd/config/sabnzbd.ini" - if os.path.exists(ini_path): - with open(ini_path) as f: + ini_path = Path(".local/test-clients/sabnzbd/config/sabnzbd.ini") + if ini_path.exists(): + with ini_path.open() as f: for line in f: if line.startswith("api_key"): api_key = line.split("=")[1].strip() @@ -204,7 +204,7 @@ def test_sabnzbd(): return False -def test_qbittorrent(): +def test_qbittorrent() -> bool: """Test qBittorrent connection.""" print("\n" + "=" * 50) print("Testing qBittorrent") @@ -219,6 +219,7 @@ def test_qbittorrent(): # Parse URL for host/port from urllib.parse import urlparse + parsed = urlparse(url) client = qbittorrentapi.Client( @@ -271,16 +272,17 @@ def test_qbittorrent(): return False -def test_transmission(): +def test_transmission() -> bool: """Test Transmission connection.""" print("\n" + "=" * 50) print("Testing Transmission") print("=" * 50) try: - from transmission_rpc import Client from urllib.parse import urlparse + from transmission_rpc import Client + url = CONFIG["transmission"]["url"] parsed = urlparse(url) @@ -324,7 +326,7 @@ def test_transmission(): return False -def test_deluge(): +def test_deluge() -> bool: """Test Deluge Web UI (JSON-RPC) connection.""" import requests @@ -336,7 +338,7 @@ def test_deluge(): password = CONFIG["deluge"]["password"] rpc_url = f"{base_url}/json" - def rpc_call(session: requests.Session, rpc_id: int, method: str, *params): + def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any: payload = {"id": rpc_id, "method": method, "params": list(params)} resp = session.post(rpc_url, json=payload, timeout=10) resp.raise_for_status() @@ -366,7 +368,11 @@ def rpc_call(session: requests.Session, rpc_id: int, method: str, *params): host_id = hosts[0][0] for entry in hosts: - if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}: + if ( + isinstance(entry, list) + and len(entry) >= 2 + and entry[1] in {"127.0.0.1", "localhost"} + ): host_id = entry[0] break @@ -386,13 +392,18 @@ def rpc_call(session: requests.Session, rpc_id: int, method: str, *params): # Test adding a torrent (then remove it) print(" Testing add/remove torrent...") - torrent_id = rpc_call(session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True}) + torrent_id = rpc_call( + session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True} + ) if torrent_id: torrent_id = str(torrent_id) print(f" Added test torrent: {torrent_id[:20]}...") - status = rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"]) or {} + status = ( + rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"]) + or {} + ) state = status.get("state", "unknown") if isinstance(status, dict) else "unknown" progress = status.get("progress", 0) if isinstance(status, dict) else 0 print(f" Status: {state} ({progress:.1f}%)") @@ -418,7 +429,8 @@ def rpc_call(session: requests.Session, rpc_id: int, method: str, *params): print(" Check Deluge Web UI password (default: deluge)") return False -def test_rtorrent(): + +def test_rtorrent() -> bool: """Test rTorrent connection.""" print("\n" + "=" * 50) print("Testing rTorrent") @@ -457,19 +469,18 @@ def test_rtorrent(): # rtorrent is weird in that it doesn't return the torrent ID/hash on add client.load.start("", TEST_MAGNET, ";".join(commands)) - + # but we know that it is 3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0 from the magnet link - torrent_id = "3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0" # rtorrent uses uppercase hashes + torrent_id = "3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0" # rtorrent uses uppercase hashes print(f" Added test torrent: {torrent_id}") torrents = client.download_list() - print(f" Active torrents: {len(torrents)}") + print(f" Active torrents: {len(torrents)}") torrent_list = client.d.multicall.filtered( "", "default", - f"equal={{d.hash=,cat={torrent_id}}}" - "d.hash=", + f"equal={{d.hash=,cat={torrent_id}}}d.hash=", "d.state=", "d.completed_bytes=", "d.size_bytes=", @@ -483,7 +494,7 @@ def test_rtorrent(): if not torrent: print(" ERROR: Could not find added torrent in list") return False - + # let's test the base path call details = client.d.multicall.filtered( "", @@ -511,7 +522,7 @@ def test_rtorrent(): return False -def main(): +def main() -> int: print("Download Client Test Suite") print("=" * 50) print("Make sure containers are running:") diff --git a/src/README.md b/src/README.md index 85a971d1..cefb99f2 100644 --- a/src/README.md +++ b/src/README.md @@ -96,4 +96,3 @@ make typecheck - Clear `node_modules` and reinstall: `make clean && make install` - Check Node.js version compatibility - Verify TypeScript configuration - diff --git a/src/frontend/index.html b/src/frontend/index.html index 9e94bb48..fd4bc5ac 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -5,16 +5,16 @@ - + - + - + diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index 2ab1e71d..75dde301 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -421,7 +421,7 @@ export const Header = forwardRef(({ }} >
- + ); }; - - diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx index 7d0666a8..0ca61c72 100644 --- a/src/frontend/src/components/resultsViews/CardView.tsx +++ b/src/frontend/src/components/resultsViews/CardView.tsx @@ -215,4 +215,3 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta ); }; - diff --git a/src/frontend/src/components/resultsViews/CompactView.tsx b/src/frontend/src/components/resultsViews/CompactView.tsx index 2d95ee75..b5a62743 100644 --- a/src/frontend/src/components/resultsViews/CompactView.tsx +++ b/src/frontend/src/components/resultsViews/CompactView.tsx @@ -219,4 +219,3 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button ); }; - diff --git a/src/frontend/src/components/resultsViews/ListView.tsx b/src/frontend/src/components/resultsViews/ListView.tsx index bce9eaba..387e6588 100644 --- a/src/frontend/src/components/resultsViews/ListView.tsx +++ b/src/frontend/src/components/resultsViews/ListView.tsx @@ -274,4 +274,3 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto ); }; - diff --git a/src/frontend/src/data/filterOptions.ts b/src/frontend/src/data/filterOptions.ts index e96cc122..3f218dbe 100644 --- a/src/frontend/src/data/filterOptions.ts +++ b/src/frontend/src/data/filterOptions.ts @@ -24,4 +24,3 @@ export const CONTENT_OPTIONS = [ { value: 'other', label: 'Other' }, { value: 'musical_score', label: 'Musical score' }, ]; - diff --git a/src/frontend/src/hooks/useToast.tsx b/src/frontend/src/hooks/useToast.tsx index 3fbd688d..419a2e77 100644 --- a/src/frontend/src/hooks/useToast.tsx +++ b/src/frontend/src/hooks/useToast.tsx @@ -7,13 +7,13 @@ export const useToast = () => { const showToast = useCallback((message: string, type: 'info' | 'success' | 'error' = 'info', persistent: boolean = false): string => { const id = Date.now().toString(); setToasts(prev => [...prev, { id, message, type }]); - + if (!persistent) { setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)); }, 4000); } - + return id; }, []); diff --git a/src/frontend/src/pages/LoginPage.tsx b/src/frontend/src/pages/LoginPage.tsx index ecf27481..ce96cf69 100644 --- a/src/frontend/src/pages/LoginPage.tsx +++ b/src/frontend/src/pages/LoginPage.tsx @@ -38,4 +38,3 @@ export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel
); }; - diff --git a/src/frontend/src/utils/errors.ts b/src/frontend/src/utils/errors.ts index 7b81833d..c2995075 100644 --- a/src/frontend/src/utils/errors.ts +++ b/src/frontend/src/utils/errors.ts @@ -8,4 +8,3 @@ export class UserCancelledError extends Error { export function isUserCancelledError(error: unknown): error is UserCancelledError { return error instanceof UserCancelledError; } - diff --git a/tor.sh b/tor.sh index e3c530e7..2e49493b 100644 --- a/tor.sh +++ b/tor.sh @@ -31,18 +31,18 @@ set -e # Check if EXT_BYPASSER_URL is defined if [ -n "$EXT_BYPASSER_URL" ]; then echo "Extracting hostname and ip from bypasser into /etc/hosts" - + # Extract hostname hostname=$(echo "$EXT_BYPASSER_URL" | cut -d'/' -f3 | cut -d':' -f1) - + # Resolve to IP (using current DNS before switching to TOR) ip=$(getent hosts "$hostname" 2>/dev/null | awk '{print $1}') - + # If getent fails, try dig if [ -z "$ip" ]; then ip=$(dig +short "$hostname" 2>/dev/null | head -n1) fi - + # Only proceed if we got an IP and hostname is not already an IP if [ -n "$ip" ] && [ "$ip" != "$hostname" ]; then # Add to /etc/hosts (remove existing entry first to avoid duplicates) diff --git a/uv.lock b/uv.lock index 1bf3781d..94fa2733 100644 --- a/uv.lock +++ b/uv.lock @@ -202,6 +202,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.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/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.7" @@ -702,6 +741,30 @@ 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 = "prek" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" }, + { url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" }, + { url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" }, + { url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" }, + { url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" }, + { url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -886,6 +949,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.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 = "pytest-html" version = "4.0.2" @@ -1280,7 +1357,9 @@ browser = [ [package.dev-dependencies] dev = [ { name = "basedpyright" }, + { name = "prek" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "vulture" }, @@ -1317,7 +1396,9 @@ provides-extras = ["browser"] [package.metadata.requires-dev] dev = [ { name = "basedpyright", specifier = ">=1.39.0" }, + { name = "prek" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = "==0.15.10" }, { name = "vulture", specifier = ">=2.14" },