From b8261aeef82c31cc1c301327ff52c05eeeb91d38 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sat, 31 Jan 2026 21:25:27 +1000 Subject: [PATCH 1/2] Upgrade to Python 3.12, add linting and type checking CI (closes #4) - Upgrade Python from 3.10 to 3.12 - Update all dependencies to latest versions - Add ruff for linting and formatting - Add mypy for type checking - Add pytest with coverage to CI workflow - Modernize type hints (Optional -> |, typing -> collections.abc) - Fix Config._loaded_from to be a proper dataclass field - Remove unused ParseError class from toc module - Fix bug: Missing addons now display original name, not lowercase key Co-Authored-By: Claude Opus 4.5 --- .github/workflows/package.yml | 33 +++- .gitignore | 14 ++ .python-version | 2 +- pyproject.toml | 43 +++- src/mygit.py | 64 +++--- src/signature.py | 17 +- src/snapjaw.py | 165 ++++++++-------- src/toc.py | 18 +- uv.lock | 358 ++++++++++++++++++++++------------ 9 files changed, 447 insertions(+), 267 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index bef06bc..ac41cf0 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -10,7 +10,38 @@ permissions: contents: write jobs: + check: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + architecture: x64 + + - name: Install dependencies + run: uv sync + + - name: Ruff check + run: uv run ruff check src/ tests/ + + - name: Ruff format + run: uv run ruff format --check src/ tests/ + + - name: Mypy + run: uv run mypy src/ + + - name: Tests with coverage + run: uv run pytest --cov=src --cov-report=term-missing --cov-fail-under=100 + build: + needs: check runs-on: windows-latest steps: - name: Checkout @@ -22,7 +53,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' architecture: x64 - name: Install dependencies diff --git a/.gitignore b/.gitignore index 02618e9..5747f08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ /.idea /build /dist + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ + +# Coverage +.coverage +htmlcov/ +coverage.xml + +# pytest +.pytest_cache/ diff --git a/.python-version b/.python-version index ac957df..e4fba21 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10.6 +3.12 diff --git a/pyproject.toml b/pyproject.toml index 9e07502..f2ba209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,20 +3,47 @@ authors = [ {name = "Roman Kharitonov", email = "rkharito@yandex.ru"}, ] license = {text = "GPL-3.0-or-later"} -requires-python = "<3.12,>=3.10" +requires-python = ">=3.12,<3.13" dependencies = [ - "tabulate<1.0.0,>=0.9.0", - "colorama<1.0.0,>=0.4.5", - "dataclasses-json<1.0.0,>=0.6.3", - "humanize<5.0.0,>=4.9.0", - "pygit2<2.0.0,>=1.14.0", + "colorama>=0.4.6", + "dataclasses-json>=0.6.7", + "humanize>=4.15.0", + "pygit2>=1.19.1", + "tabulate>=0.9.0", ] name = "snapjaw" version = "1.0" description = "Vanilla WoW AddOn Manager" +[tool.ruff] +target-version = "py312" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +markers = [ + "integration: mark test as integration test (requires network)", + "slow: mark test as slow", +] +addopts = "-m 'not integration'" + [dependency-groups] dev = [ - "nuitka<2.0.0,>=1.5.7", - "setuptools<70.0.0,>=69.0.3", + "mypy>=1.19.1", + "nuitka>=2.8.10", + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "ruff>=0.14.14", + "setuptools>=80.10.2", + "types-colorama>=0.4.15.20250801", + "types-tabulate>=0.9.0.20241207", ] diff --git a/src/mygit.py b/src/mygit.py index 598e2bf..1610173 100644 --- a/src/mygit.py +++ b/src/mygit.py @@ -1,13 +1,12 @@ import math +from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from datetime import datetime from hashlib import sha1 -from multiprocessing import Process, Pipe +from multiprocessing import Pipe, Process from multiprocessing.connection import Connection from tempfile import TemporaryDirectory -from collections.abc import Iterator -from typing import Optional import humanize import pygit2 @@ -26,7 +25,7 @@ class RepositoryInfo: # Workaround for https://github.com/libgit2/pygit2/issues/264 -def clone(url: str, branch: Optional[str], path: str) -> RepositoryInfo: +def clone(url: str, branch: str | None, path: str) -> RepositoryInfo: def make_pipe() -> tuple[Connection, Connection]: return Pipe() @@ -40,25 +39,26 @@ def make_pipe() -> tuple[Connection, Connection]: if parent_error_conn.poll(0): raise parent_error_conn.recv() - return parent_data_conn.recv() + result: RepositoryInfo = parent_data_conn.recv() + return result -def _clone(url: str, branch: Optional[str], path: str, data_conn: Connection, error_conn: Connection): +def _clone(url: str, branch: str | None, path: str, data_conn: Connection, error_conn: Connection): try: - repo: pygit2.Repository = pygit2.clone_repository(url, - path, - depth=1, - checkout_branch=branch, - callbacks=_GitProgressCallbacks()) + repo: pygit2.Repository = pygit2.clone_repository( + url, path, depth=1, checkout_branch=branch, callbacks=_GitProgressCallbacks() + ) except (pygit2.GitError, KeyError) as error: error_conn.send(GitError(str(error))) return - head: pygit2.Commit = repo[repo.head.target] + head = repo[repo.head.target] + assert isinstance(head, pygit2.Commit) info = RepositoryInfo( workdir=repo.workdir, branch=repo.head.shorthand, head_commit_hex=str(head.id), - head_commit_time=datetime.fromtimestamp(head.commit_time)) + head_commit_time=datetime.fromtimestamp(head.commit_time), + ) data_conn.send(info) @@ -70,27 +70,27 @@ def __init__(self): self._max_progress_len = 0 def sideband_progress(self, progress: str) -> None: - print(progress, end='\r') + print(progress, end="\r") def transfer_progress(self, progress: pygit2.remotes.TransferProgress) -> None: def print_progress(prefix: str, suffix: str, cur_count: int, max_count: int) -> None: - eol = '\r' - text = f'{prefix}: {math.ceil(cur_count / max_count * 100)}% ({cur_count}/{max_count}) {suffix}' + eol = "\r" + text = f"{prefix}: {math.ceil(cur_count / max_count * 100)}% ({cur_count}/{max_count}) {suffix}" if cur_count == max_count: - eol = '\n' - text = f'{text.strip()}, done.' + eol = "\n" + text = f"{text.strip()}, done." self._max_progress_len = max(self._max_progress_len, len(text)) print(text.ljust(self._max_progress_len), end=eol) if not self._objects_done: a, b = progress.received_objects, progress.total_objects size = humanize.naturalsize(progress.received_bytes) - print_progress('Receiving objects', f'[{size}]', a, b) + print_progress("Receiving objects", f"[{size}]", a, b) self._objects_done = a >= b elif not self._deltas_done: a, b = progress.indexed_deltas, progress.total_deltas if b > 0: - print_progress('Indexing deltas', '', a, b) + print_progress("Indexing deltas", "", a, b) self._deltas_done = a >= b @@ -104,23 +104,23 @@ class RemoteStateRequest: class RemoteState: url: str branch: str - head_commit_hex: Optional[str] - error: Optional[str] + head_commit_hex: str | None + error: str | None @dataclass class _RemoteLsResult: remote: pygit2.Remote refs: list[dict] - error: Optional[str] + error: str | None def fetch_states(requests: list[RemoteStateRequest]) -> Iterator[RemoteState]: with TemporaryDirectory() as repo_dir: repo = pygit2.init_repository(repo_dir) - remote_name_to_branches = {} + remote_name_to_branches: dict[str, list[str]] = {} for request in requests: - name = sha1(request.url.encode('utf-8')).hexdigest() + name = sha1(request.url.encode("utf-8")).hexdigest() if not _has_remote(repo, name): repo.remotes.create(name, request.url) remote_name_to_branches.setdefault(name, []).append(request.branch) @@ -139,14 +139,18 @@ def ls(remote: pygit2.Remote) -> _RemoteLsResult: for future in as_completed(futures): ls_result: _RemoteLsResult = future.result() - for branch in remote_name_to_branches[ls_result.remote.name]: + remote_name = ls_result.remote.name + assert remote_name is not None + for branch in remote_name_to_branches[remote_name]: + url = ls_result.remote.url or "" if ls_result.error is not None: - yield RemoteState(ls_result.remote.url, branch, None, ls_result.error) + yield RemoteState(url, branch, None, ls_result.error) else: - branch_ref = f'refs/heads/{branch}' + branch_ref = f"refs/heads/{branch}" for ref in ls_result.refs: - if ref['name'] == 'HEAD' and ref['symref_target'] == branch_ref or ref['name'] == branch_ref: - yield RemoteState(ls_result.remote.url, branch, str(ref['oid']), None) + is_head = ref["name"] == "HEAD" and ref["symref_target"] == branch_ref + if is_head or ref["name"] == branch_ref: + yield RemoteState(url, branch, str(ref["oid"]), None) break diff --git a/src/signature.py b/src/signature.py index 8f266b9..3c17528 100644 --- a/src/signature.py +++ b/src/signature.py @@ -1,6 +1,6 @@ import hashlib import os -from typing import Generator +from collections.abc import Generator, Iterable _LATEST_VERSION = 2 @@ -15,25 +15,26 @@ def validate(dirpath: str, signature: str) -> bool: def _pack(checksum: str, version) -> str: - return f'{checksum}|{version}' + return f"{checksum}|{version}" def _unpack(signature: str) -> tuple[str, int]: - if '|' not in signature: + if "|" not in signature: return signature, 1 - checksum, version = signature.split('|') + checksum, version = signature.split("|") return checksum, int(version) def _get_checksum(dirpath: str, version: int) -> str: if not os.path.isdir(dirpath): raise ValueError(f'Directory "{dirpath}" not found') + chunks: Iterable[str] if version == 1: chunks = sorted(_get_dir_chunks_v1(dirpath)) elif version == 2: chunks = _get_dir_chunks_v2(dirpath) else: - raise RuntimeError('Invalid hash version') + raise RuntimeError("Invalid hash version") return _hash(chunks) @@ -53,7 +54,7 @@ def _get_dir_chunks_v2(dirpath: str) -> Generator[str, None, None]: def _get_file_chunks(filepath: str) -> Generator[bytes, None, None]: - with open(filepath, 'rb') as filehandle: + with open(filepath, "rb") as filehandle: while True: data = filehandle.read(64 * 1024) if not data: @@ -61,10 +62,10 @@ def _get_file_chunks(filepath: str) -> Generator[bytes, None, None]: yield data -def _hash(chunks: Generator[bytes|str, None, None]) -> str: +def _hash(chunks: Iterable[bytes | str]) -> str: hasher = hashlib.sha1() for chunk in chunks: if isinstance(chunk, str): - chunk = chunk.encode('utf-8') + chunk = chunk.encode("utf-8") hasher.update(chunk) return hasher.hexdigest() diff --git a/src/snapjaw.py b/src/snapjaw.py index 0ebe49d..33b1071 100644 --- a/src/snapjaw.py +++ b/src/snapjaw.py @@ -14,12 +14,11 @@ from datetime import datetime from pathlib import Path from tempfile import TemporaryDirectory -from typing import Optional import colorama as cr import humanize import tabulate -from dataclasses_json import dataclass_json +from dataclasses_json import DataClassJsonMixin import mygit import signature @@ -30,36 +29,35 @@ class CliError(RuntimeError): pass -@dataclass_json @dataclass -class ConfigAddon: +class ConfigAddon(DataClassJsonMixin): name: str url: str branch: str commit: str released_at: datetime installed_at: datetime - checksum: Optional[str] = None + checksum: str | None = None -@dataclass_json @dataclass -class Config: +class Config(DataClassJsonMixin): addons_by_key: dict[str, ConfigAddon] + _loaded_from: str = "" @staticmethod - def load_or_setup(path: str): + def load_or_setup(path: str) -> "Config": if os.path.exists(path): with open(path) as config_file: config = Config.from_json(config_file.read()) else: config = Config(addons_by_key={}) - setattr(config, '_loaded_from', path) + config._loaded_from = path return config def save(self): self.addons_by_key = sort_addons_dict(self.addons_by_key) - with open(getattr(self, '_loaded_from'), 'w') as config_file: + with open(self._loaded_from, "w") as config_file: json.dump(json.loads(self.to_json()), config_file, indent=4) @staticmethod @@ -73,7 +71,7 @@ def main(): try: cmd_args.callback(cmd_args) except CliError as error: - print(f'error: {error}', file=sys.stderr) + print(f"error: {error}", file=sys.stderr) return 1 return 0 @@ -84,38 +82,39 @@ def parse_args(): wow_dir = None cwd = Path.cwd() while wow_dir is None and cwd != cwd.parent: - if cwd.joinpath('WoW.exe').is_file(): + if cwd.joinpath("WoW.exe").is_file(): wow_dir = cwd cwd = cwd.parent addons_dir = None if wow_dir is not None: - addons_dir = wow_dir.joinpath('Interface', 'Addons') + addons_dir = wow_dir.joinpath("Interface", "Addons") parser.add_argument( - '--addons-dir', + "--addons-dir", required=False, type=arg_type_dir, default=addons_dir, - help='optional path to Interface\\Addons directory') + help="optional path to Interface\\Addons directory", + ) subparsers = parser.add_subparsers(required=True) - install = subparsers.add_parser('install', help='install new addon(s)') - install.add_argument('url', type=str, help='url to git repository') - install.add_argument('--branch', type=str, help='specific git branch to use') + install = subparsers.add_parser("install", help="install new addon(s)") + install.add_argument("url", type=str, help="url to git repository") + install.add_argument("--branch", type=str, help="specific git branch to use") install.set_defaults(callback=functools.partial(run_command, cmd_install, False)) - remove = subparsers.add_parser('remove', help='remove installed addon') - remove.add_argument('names', help='addon name', nargs='+') + remove = subparsers.add_parser("remove", help="remove installed addon") + remove.add_argument("names", help="addon name", nargs="+") remove.set_defaults(callback=functools.partial(run_command, cmd_remove, False)) - update = subparsers.add_parser('update', help='update installed addon(s)') - update.add_argument('names', help='addon name', nargs='*') + update = subparsers.add_parser("update", help="update installed addon(s)") + update.add_argument("names", help="addon name", nargs="*") update.set_defaults(callback=functools.partial(run_command, cmd_update, False)) - status = subparsers.add_parser('status', help='list installed addons') - status.add_argument('-v', '--verbose', action='store_true', help='enable more verbose output') + status = subparsers.add_parser("status", help="list installed addons") + status.add_argument("-v", "--verbose", action="store_true", help="enable more verbose output") status.set_defaults(callback=functools.partial(run_command, cmd_status, True)) return parser.parse_args() @@ -123,17 +122,17 @@ def parse_args(): def arg_type_dir(value): if not os.path.isdir(value): - raise argparse.ArgumentTypeError('invalid directory path') + raise argparse.ArgumentTypeError("invalid directory path") return value # TODO get rid of read_only, check config.is_dirty() def run_command(cmd_callback, read_only, args): - if not os.path.isdir(args.addons_dir or ''): - raise CliError('addons directory not found') + if not os.path.isdir(args.addons_dir or ""): + raise CliError("addons directory not found") - config_path = os.path.join(args.addons_dir, 'snapjaw.json') - backup_path = os.path.join(args.addons_dir, 'snapjaw.backup.json') + config_path = os.path.join(args.addons_dir, "snapjaw.json") + backup_path = os.path.join(args.addons_dir, "snapjaw.backup.json") if not read_only and os.path.exists(config_path): shutil.copyfile(config_path, backup_path) @@ -141,9 +140,9 @@ def run_command(cmd_callback, read_only, args): try: cmd_callback(config, args) if not read_only: - logging.info('Saving config...') + logging.info("Saving config...") config.save() - logging.info('Done!') + logging.info("Done!") except Exception: if not read_only and os.path.exists(backup_path): shutil.copyfile(backup_path, config_path) @@ -154,42 +153,39 @@ def cmd_install(config: Config, args): scheme, netloc, path_string, params, query, fragment = urllib.parse.urlparse(args.url) branch_from_url = None - if netloc in ('github.com', 'gitlab.com'): - path = path_string.lstrip('/').split('/') + if netloc in ("github.com", "gitlab.com"): + path = path_string.lstrip("/").split("/") author = path.pop(0) repository = path.pop(0) if path: - if path[0] == '-' and path[1] == 'tree': + if path[0] == "-" and path[1] == "tree": path = path[2:] - elif path[0] == 'tree': + elif path[0] == "tree": path = path[1:] - branch_from_url = '/'.join(path) - path_string = '/'.join([author, repository]) - if not path_string.endswith('.git'): - path_string += '.git' + branch_from_url = "/".join(path) + path_string = "/".join([author, repository]) + if not path_string.endswith(".git"): + path_string += ".git" if args.branch and branch_from_url and args.branch != branch_from_url: - raise CliError(f'requested branch {args.branch}, but found branch {branch_from_url} in repository URL') + raise CliError(f"requested branch {args.branch}, but found branch {branch_from_url} in repository URL") repo_url = urllib.parse.urlunparse((scheme, netloc, path_string, params, query, fragment)) return install_addon(config, repo_url, args.branch or branch_from_url, args.addons_dir) -def install_addon(config: Config, repo_url: str, branch: Optional[str], addons_dir: str) -> None: - logging.info(f'Cloning {repo_url}') +def install_addon(config: Config, repo_url: str, branch: str | None, addons_dir: str) -> None: + logging.info(f"Cloning {repo_url}") with TemporaryDirectory() as repo_dir: try: repo = mygit.clone(repo_url, branch, repo_dir) except mygit.GitError as error: - raise CliError(str(error)) + raise CliError(str(error)) from error - try: - addons_by_dir = {item.path: item for item in toc.find_addons(repo.workdir, 11200)} - except toc.ParseError as error: - raise CliError(str(error)) + addons_by_dir = {item.path: item for item in toc.find_addons(repo.workdir, 11200)} if not addons_by_dir: - raise CliError('no vanilla addons found') + raise CliError("no vanilla addons found") for addon in addons_by_dir.values(): logging.info(f'Installing addon "{addon.name}", branch "{repo.branch}"') @@ -198,11 +194,11 @@ def install_addon(config: Config, repo_url: str, branch: Optional[str], addons_d # TODO backup remove_addon_dir(dst_addon_dir) - shutil.copytree(addon.path, dst_addon_dir, ignore=shutil.ignore_patterns('.git*')) + shutil.copytree(addon.path, dst_addon_dir, ignore=shutil.ignore_patterns(".git*")) if repo.workdir != addon.path: # Copy additional readme files from root folder, if any - for wc in ['*readme*', '*.txt', '*.html']: + for wc in ["*readme*", "*.txt", "*.html"]: for fn in glob.glob(wc, root_dir=repo.workdir): src_path = os.path.join(repo.workdir, fn) dst_path = os.path.join(dst_addon_dir, fn) @@ -216,10 +212,11 @@ def install_addon(config: Config, repo_url: str, branch: Optional[str], addons_d commit=repo.head_commit_hex, released_at=repo.head_commit_time, installed_at=datetime.now(), - checksum=signature.calculate(dst_addon_dir)) + checksum=signature.calculate(dst_addon_dir), + ) config.save() - logging.info('Done') + logging.info("Done") def cmd_remove(config: Config, args): @@ -229,7 +226,7 @@ def cmd_remove(config: Config, args): if addon is None: print(f'Addon not found: "{name}"') else: - print(f'Removing addon {addon.name}') + print(f"Removing addon {addon.name}") del config.addons_by_key[key] remove_addon_dir(os.path.join(args.addons_dir, addon.name)) @@ -241,7 +238,7 @@ def remove_addon_dir(path): try: shutil.rmtree(path) except OSError as e: - if e.args and e.args[0] == 'Cannot call rmtree on a symbolic link': + if e.args and e.args[0] == "Cannot call rmtree on a symbolic link": os.remove(path) else: raise @@ -256,12 +253,12 @@ def cmd_update(config: Config, args): addons = [] for state in get_addon_states(config, args.addons_dir): if state.error is not None: - print(f'Error: {state.addon}: {state.error}') + print(f"Error: {state.addon}: {state.error}") elif state.status == AddonStatus.Outdated: addons.append(config.addons_by_key[addon_key(state.addon)]) if not addons: - print('No addons to update found') + print("No addons to update found") return for addon in addons: @@ -271,19 +268,19 @@ def cmd_update(config: Config, args): def get_addon_from_config(config: Config, addon_name: str) -> ConfigAddon: addon = config.addons_by_key.get(addon_key(addon_name)) if addon is None: - raise argparse.ArgumentTypeError('unknown addon') + raise argparse.ArgumentTypeError("unknown addon") return addon def cmd_status(config: Config, args): addon_states = get_addon_states(config, args.addons_dir) if not addon_states: - print('No addons found') + print("No addons found") return - def format_dt(dt: Optional[datetime]) -> str: + def format_dt(dt: datetime | None) -> str: if not dt: - return '' + return "" return humanize.naturaldate(dt) status_to_color = { @@ -308,45 +305,45 @@ def format_dt(dt: Optional[datetime]) -> str: format_dt(state.installed_at), ] if has_error: - columns.append(state.error or '') + columns.append(state.error or "") table.append(columns) # TODO add updated_at, rename released_at - headers = ['addon', 'status', 'released_at', 'installed_at'] + headers = ["addon", "status", "released_at", "installed_at"] if has_error: - headers.append('error') + headers.append("error") cr.init() - print(tabulate.tabulate(table, tablefmt='psql', headers=headers)) + print(tabulate.tabulate(table, tablefmt="psql", headers=headers)) if not args.verbose: num_updated = Counter(s.status for s in addon_states)[AddonStatus.UpToDate] if num_updated > 0: - msg = f'{num_updated}{" other" if table else ""} addons are up to date' + msg = f"{num_updated}{' other' if table else ''} addons are up to date" print(cr.Fore.GREEN + msg + cr.Fore.RESET) cr.deinit() class AddonStatus(enum.Enum): - Unknown = 'unknown' - Modified = 'modified' - UpToDate = 'up-to-date' - Outdated = 'outdated' - Untracked = 'untracked' - Missing = 'missing' - Error = 'error' + Unknown = "unknown" + Modified = "modified" + UpToDate = "up-to-date" + Outdated = "outdated" + Untracked = "untracked" + Missing = "missing" + Error = "error" @dataclass class AddonState: addon: str status: AddonStatus - error: Optional[str] - released_at: Optional[datetime] - installed_at: Optional[datetime] + error: str | None + released_at: datetime | None + installed_at: datetime | None def get_addon_states(config: Config, addons_dir: str) -> list[AddonState]: - url_to_branch_to_addons = {} + url_to_branch_to_addons: dict[str, dict[str, list[ConfigAddon]]] = {} for addon in config.addons_by_key.values(): url_to_branch_to_addons.setdefault(addon.url, {}).setdefault(addon.branch, []).append(addon) @@ -357,31 +354,33 @@ def get_addon_states(config: Config, addons_dir: str) -> list[AddonState]: for state in mygit.fetch_states(requests): for addon in url_to_branch_to_addons[state.url][state.branch]: processed += 1 - print(f'{processed}/{total_addons}', end='\r') + print(f"{processed}/{total_addons}", end="\r") comment = None if state.error is not None: status = AddonStatus.Error comment = state.error elif state.head_commit_hex is None: status = AddonStatus.Unknown - elif not signature.validate(os.path.join(addons_dir, addon.name), addon.checksum): + elif addon.checksum is None or not signature.validate(os.path.join(addons_dir, addon.name), addon.checksum): status = AddonStatus.Modified elif state.head_commit_hex == addon.commit: status = AddonStatus.UpToDate else: status = AddonStatus.Outdated addon_key_to_state[addon_key(addon.name)] = AddonState( - addon.name, status, comment, addon.released_at, addon.installed_at) + addon.name, status, comment, addon.released_at, addon.installed_at + ) print() for name in os.listdir(addons_dir): path = os.path.join(addons_dir, name) name_key = addon_key(name) - if os.path.isdir(path) and not name.startswith('Blizzard_') and name_key not in addon_key_to_state: + if os.path.isdir(path) and not name.startswith("Blizzard_") and name_key not in addon_key_to_state: addon_key_to_state[name_key] = AddonState(name, AddonStatus.Untracked, None, None, None) - for name in set(config.addons_by_key.keys()) - set(addon_key_to_state.keys()): - addon_key_to_state[addon_key(name)] = AddonState(name, AddonStatus.Missing, None, None, None) + for key in set(config.addons_by_key.keys()) - set(addon_key_to_state.keys()): + addon = config.addons_by_key[key] + addon_key_to_state[key] = AddonState(addon.name, AddonStatus.Missing, None, None, None) return list(sort_addons_dict(addon_key_to_state).values()) @@ -394,6 +393,6 @@ def addon_key(name: str) -> str: return name.lower() -if __name__ == '__main__': +if __name__ == "__main__": # pragma: no cover multiprocessing.freeze_support() sys.exit(main()) diff --git a/src/toc.py b/src/toc.py index 702c5b5..420acd3 100644 --- a/src/toc.py +++ b/src/toc.py @@ -1,7 +1,7 @@ import re +from collections.abc import Generator from dataclasses import dataclass from pathlib import Path -from typing import Generator, Optional @dataclass @@ -16,10 +16,6 @@ class _TocFile: game_version: int -class ParseError(RuntimeError): - pass - - def find_addons(dir_path: str, max_game_version: int) -> Generator[Addon, None, None]: def sort_key(toc: _TocFile) -> int: return len(toc.path.parents) @@ -32,18 +28,18 @@ def sort_key(toc: _TocFile) -> int: def _find_toc_files(root_dir: str, max_game_version: int) -> Generator[_TocFile, None, None]: - for path in Path(root_dir).rglob('*'): - if path.is_file() and path.suffix.lower() == '.toc': + for path in Path(root_dir).rglob("*"): + if path.is_file() and path.suffix.lower() == ".toc": game_version = _get_game_version(path) if game_version is not None and game_version <= max_game_version: yield _TocFile(path, game_version) -def _get_game_version(toc_path: Path) -> Optional[int]: - regexp = re.compile(b'## Interface: *(?P[0-9]+)') - with toc_path.open(mode='rb') as fp: +def _get_game_version(toc_path: Path) -> int | None: + regexp = re.compile(b"## Interface: *(?P[0-9]+)") + with toc_path.open(mode="rb") as fp: for line in fp: match = regexp.search(line) if match: - return int(match.groupdict()['v']) + return int(match.groupdict()["v"]) return None diff --git a/uv.lock b/uv.lock index a9fe51b..2ed7bb6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 -revision = 2 -requires-python = ">=3.10, <3.12" -resolution-markers = [ - "python_full_version >= '3.11'", - "python_full_version < '3.11'", -] +revision = 3 +requires-python = "==3.12.*" [[package]] name = "cffi" @@ -15,31 +11,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, ] [[package]] @@ -51,6 +34,28 @@ 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.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" }, + { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" }, + { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, +] + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -73,6 +78,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, ] +[[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 = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, +] + [[package]] name = "marshmallow" version = "3.26.2" @@ -85,6 +118,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -96,13 +150,13 @@ wheels = [ [[package]] name = "nuitka" -version = "1.9.7" +version = "2.8.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ordered-set" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/c0/c66026007e0d5dd0aea6bb2b144da603b33344d6be144c4f255190fa1f13/Nuitka-1.9.7.tar.gz", hash = "sha256:4db728e224043308112b40a8d0a2168e9ccd956e713238617b3e47779a740512", size = 3916330, upload-time = "2024-01-07T19:02:32.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/b8/5c58a2c4d66631ec12eb641c08b7a31116d972c4ccbb0a340a047db51238/nuitka-2.8.10.tar.gz", hash = "sha256:03e4d0756d8a11cb2627da3a2d9b518c802d031bf4f2c629e0a7b8c773497452", size = 4331977, upload-time = "2026-01-23T09:54:56.396Z" } [[package]] name = "ordered-set" @@ -122,6 +176,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[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 = "pycparser" version = "3.0" @@ -133,74 +205,97 @@ wheels = [ [[package]] name = "pygit2" -version = "1.18.2" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "cffi", marker = "python_full_version < '3.11'" }, + { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/ea/762d00f6f518423cd889e39b12028844cc95f91a6413cf7136e184864821/pygit2-1.18.2.tar.gz", hash = "sha256:eca87e0662c965715b7f13491d5e858df2c0908341dee9bde2bc03268e460f55", size = 797200, upload-time = "2025-08-16T13:52:36.853Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/49/cf8350817de19f4cafe4ae47881e38f56d9bbebaa9e5ef31a5458af4bcf8/pygit2-1.19.1.tar.gz", hash = "sha256:3165f784aae56a309a27d8eeae7923d53da2e8f6094308c7f5b428deec925cf9", size = 800869, upload-time = "2025-12-29T11:47:48.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/54/a747b5a80698c22b7e510de61facaf7b7dd196fe4540d0d28eb05eacaeba/pygit2-1.18.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a84fbc62b0d2103059559b5af7e939289a0f3fc7d0a7ad84d822eaa97a6db687", size = 5509510, upload-time = "2025-08-16T13:39:01.887Z" }, - { url = "https://files.pythonhosted.org/packages/d4/bc/865c6090efa25a5cfe7e1d2cec28c2515a2d7239d3b428f36184af6610ac/pygit2-1.18.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c84aa50acba5a2c6bb36863fbcc1d772dc00199f9ea41bb5cac73c5fdad42bce", size = 5762592, upload-time = "2025-08-16T13:39:03.06Z" }, - { url = "https://files.pythonhosted.org/packages/41/96/69a408e57fd68555e1bdb134a15edb4cb77a24ba266dcbf6edf6d5d4a807/pygit2-1.18.2-cp310-cp310-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7b8570f0df4f0a854c3d3bdcec4a5767b50b0acb13ef163f6b96db593e3611f", size = 4599930, upload-time = "2025-08-16T13:39:04.66Z" }, - { url = "https://files.pythonhosted.org/packages/aa/bc/ee2335c98995cce3dfec7ccd54fff027b769a839832457fa784fe14e4538/pygit2-1.18.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cccceadab2c772a52081eac4680c3664d2ff21966171d339fee6aaf303ccbe23", size = 5493592, upload-time = "2025-08-16T13:39:06.025Z" }, - { url = "https://files.pythonhosted.org/packages/31/54/af78c3870c62b3bbfe86ed1f2ee1f46a8a43c1db70c0d35769365fa8b145/pygit2-1.18.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c51e0b4a733e72212c86c8b3890a4c3572b1cae6d381e56b4d53ba3dafbeecf2", size = 5760887, upload-time = "2025-08-21T13:32:22.347Z" }, - { url = "https://files.pythonhosted.org/packages/23/de/419658ecdbf37e89094b171b63c941774ff46d1bb6f65efd40f0c25d1df9/pygit2-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:970e9214e9146c893249acb9610fda9220fe048ae76c80fd7f36d0ec3381676b", size = 5460906, upload-time = "2025-08-16T13:39:07.633Z" }, - { url = "https://files.pythonhosted.org/packages/c7/91/bbaca03aa624915c4dd95c60961f34d683b069249c0f25d1faef29195873/pygit2-1.18.2-cp310-cp310-win32.whl", hash = "sha256:546f9b8e7bf9d88d77008a82d7d989c624f5756c4fba26af1b8985019985dc8a", size = 1238396, upload-time = "2025-08-16T13:10:33.39Z" }, - { url = "https://files.pythonhosted.org/packages/53/a5/1d10b3e9d85ca62cbe5d5bbda611d3ca1f5fd0603910a00132b440bbbfd9/pygit2-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:5383cdfc1315e7d49d7a59a9aa37c4f0f60d08c4de3137f31d20e4be2055ad47", size = 1323973, upload-time = "2025-08-16T13:15:10.479Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c5/d3bd32443f4d7275928f7e07beb87b907401570e4a0b2d6b671909373d23/pygit2-1.18.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3fc89da1426793227e06f2dec5f2df98a0c6806fb4024eec6a125fb7a5042bbf", size = 5509503, upload-time = "2025-08-16T13:39:09.095Z" }, - { url = "https://files.pythonhosted.org/packages/71/e4/b26e970a493f65f646ec33ab77c462c6cb6b5527a11aa51b0b18bfe47642/pygit2-1.18.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6ab37a87b58032c596c37bcd0e3926cc6071748230f6f0911b7fe398e021ae", size = 5768944, upload-time = "2025-08-16T13:39:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/86/32/09d5ef009dd28529afcf377f4a767156fd105b58496405a815e4b66c1944/pygit2-1.18.2-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d9642f57943703de3651906f81b9535cb257b3cbe45ecca8f97cf475f1cb6b5f", size = 4606504, upload-time = "2025-08-16T13:39:12.131Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2f/13fddef74a8dd6080e24a0bbd19c253e13e293f52c282596b9e3d0dc9148/pygit2-1.18.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1aa3efba6459e10608900fe26679e3b52ea566761f3e7ef9c0805d69a5548631", size = 5500249, upload-time = "2025-08-16T13:39:13.727Z" }, - { url = "https://files.pythonhosted.org/packages/80/c5/235376a6908a4b7cf25f92e3090e4f3f9828af49d021299a89eae66ecf9e/pygit2-1.18.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:25957ccf70e37f3e8020748724a14faf4731ceac69ed00ccbb422f99de0a80cc", size = 5767739, upload-time = "2025-08-21T13:33:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/a2/1e/e2f914bfa0e8ca0b7c518c32d1b2183254c21d7d1eca3e21d6aeb7ccf066/pygit2-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6c9cdbad0888d664b80f30efda055c4c5b8fdae22c709bd57b1060daf8bde055", size = 5467750, upload-time = "2025-08-16T13:39:15.414Z" }, - { url = "https://files.pythonhosted.org/packages/d0/96/ac263bc9ce48a4f9cc31437dcaa812cc893382a8837c32cfe4764b03127e/pygit2-1.18.2-cp311-cp311-win32.whl", hash = "sha256:91bde9503ad35be55c95251c9a90cfe33cd608042dcc08d3991ed188f41ebec2", size = 1238394, upload-time = "2025-08-16T13:19:37.689Z" }, - { url = "https://files.pythonhosted.org/packages/fd/98/7fae3f7779469f2f4514e20d887d4011953c0a996af4b7f6b8bb73de4c0f/pygit2-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:840d01574e164d9d2428d36d9d32d377091ac592a4b1a3aa3452a5342a3f6175", size = 1324157, upload-time = "2025-08-16T13:24:17.196Z" }, - { url = "https://files.pythonhosted.org/packages/17/3f/da4563009011dd5e4427740ca7fe3f1005158bf6c6670727e8e9d6078d8a/pygit2-1.18.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd82d37cf5ce474a74388a04b9fb3c28670f44bc7fe970cabbb477a4d1cb871f", size = 5318756, upload-time = "2025-08-16T13:39:31.435Z" }, - { url = "https://files.pythonhosted.org/packages/7f/08/0aae26a1c74aedfe99b6f529011cd6e9f335f7840a0e92aeaa4620bcf117/pygit2-1.18.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:991fe6bcbe914507abfe81be1c96bd5039ec315354e4132efffcb03eb8b363fb", size = 5043500, upload-time = "2025-08-16T13:39:33.006Z" }, - { url = "https://files.pythonhosted.org/packages/57/91/f6655a5d171c0a080a7507b8d6855067f4365b326c0d946c6af12a633a80/pygit2-1.18.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d801d272f6331e067bd0d560671311d1ce4bb8f81536675706681ed44cc0d7dc", size = 5317765, upload-time = "2025-08-16T13:39:34.222Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c8/288d1a56092b3e01524d03eeff24a85efc4eaa3861c6813e3098cde9ee02/pygit2-1.18.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e1ff2d60420c98e6e25fd188069cddf8fa7b0417db7405ce7677a2f546e6b03", size = 5042134, upload-time = "2025-08-16T13:39:35.871Z" }, + { url = "https://files.pythonhosted.org/packages/1a/36/0784870218794d6069bf8ebae55679964edc44b8e59279f4526aa1220569/pygit2-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8e6a4f4a711750c286a13cea0007b40f7466c4d741c3d9b223ffbc3bbfbafe7", size = 5700218, upload-time = "2025-12-29T11:46:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/65/47206823900ddca606022025369ba3e136de9d2310585acac10d8cef81fd/pygit2-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f2340a668eb3e2d8927dcbeb1a043d3a65d2dd39a913995b34fc437da5e73af", size = 5692231, upload-time = "2025-12-29T11:46:45.821Z" }, + { url = "https://files.pythonhosted.org/packages/19/27/c6b52f53ee16b9d7eaacc575f08add3c336f53b5561cf94fe41ceeab1589/pygit2-1.19.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe41f09b1e334c43def6636b1133d2f4c91a20d9a6691bb4e7558e42a31bcb4e", size = 6022217, upload-time = "2025-12-29T11:46:47.086Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ac/41d7a1ed69e117e9cd99b2f40f63898f9725ac6c4245b2b531ae0b7e59da/pygit2-1.19.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527e57133d30ff6ea96634da6bf428f7d551958207fa73f9e9a18582b885e192", size = 4622846, upload-time = "2025-12-29T11:46:48.679Z" }, + { url = "https://files.pythonhosted.org/packages/09/22/f8fc7860b7b7ba15f7bf802ae3bce52b3e765b48846db115cb1c8372f971/pygit2-1.19.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a9340cb85b7be40080186a9d4dbf712a6be8a842556acbbfb305baebfb854f3", size = 5785236, upload-time = "2025-12-29T11:46:50.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/62/ee9275c48ecc119a7f5c48209aaa06d5f71d8476703c7700182c49c8a7a8/pygit2-1.19.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66ecfa69f2287f50ec95dfc04821219c2f664c4cd292c7b33c10ed9afe975132", size = 6028266, upload-time = "2025-12-29T11:46:51.5Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/311112a50e6e319921f06c20ff237360c10bb2e8a1f959361567e48835f3/pygit2-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14c76ec968ae20a6689c7b6fa833ef546c7bc176127d71e7b67cb2345a9813fb", size = 5755041, upload-time = "2025-12-29T11:46:53.337Z" }, + { url = "https://files.pythonhosted.org/packages/e1/45/f6a24326fb94e56ddae9906e21d4e4a006a36131a3a73819be1177e30e93/pygit2-1.19.1-cp312-cp312-win32.whl", hash = "sha256:ffe94118d39f6969fda594224b2b6df1ae79306adaf090ede65bcaf1a41b3a81", size = 942948, upload-time = "2025-12-29T11:46:54.465Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1a/912ee3a33ba665f82cf8ed0087e7446f1f8e117aba1627e0c4ccc9b2a8c5/pygit2-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:c2ee3f2e91b0a5674ab7cb373234c23cf5f1cf6d84e56e6d12ff3db21414cf47", size = 1159880, upload-time = "2025-12-29T11:46:55.523Z" }, + { url = "https://files.pythonhosted.org/packages/24/fc/784eeceab43c2b4978aa46f03c267409f2502331fa18d0a8e58116d143d0/pygit2-1.19.1-cp312-cp312-win_arm64.whl", hash = "sha256:c8747d968d8d6b9d390263907f014d38a0f67bd26d8243e5bc3384cb252ec3d3", size = 966904, upload-time = "2025-12-29T11:46:56.888Z" }, ] [[package]] -name = "pygit2" -version = "1.19.1" +name = "pygments" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/49/cf8350817de19f4cafe4ae47881e38f56d9bbebaa9e5ef31a5458af4bcf8/pygit2-1.19.1.tar.gz", hash = "sha256:3165f784aae56a309a27d8eeae7923d53da2e8f6094308c7f5b428deec925cf9", size = 800869, upload-time = "2025-12-29T11:47:48.618Z" } +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-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/4f/c8c29c4af2de6b8b7e086cad24e200ec7f165587caa77b7d2d495366204e/pygit2-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b54f3a94648ac8e287f5e4333710d9fe05f9e09de3da232d50df753bb01b643", size = 5702353, upload-time = "2025-12-29T11:46:28.548Z" }, - { url = "https://files.pythonhosted.org/packages/b9/04/814b305804f067fd8d1cd7166dc3704900704a8fa71280703212abbacf9f/pygit2-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e46618a912fc984b8a9f4d8322704620f1315264359c7fa61c899128e23e226", size = 5691612, upload-time = "2025-12-29T11:46:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/cb/04/61c84d1ab2585f50a2551199e4228f3a800635c482e451e93f2cd0c0ae3d/pygit2-1.19.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb386b3e98f7056d76bc7e805e8fce3cd0a773cbbb30b0f7e144c0ac37270f2", size = 6021372, upload-time = "2025-12-29T11:46:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/be/7a/daca8780c72b0d5a56165e0bff3b76d2fa8e0a8f7269f40aa17f10ed0356/pygit2-1.19.1-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f41a9b866676922ac9b0ec60f0dc9735a5d1ba6bb34146a6212dc0012d7959f", size = 4623817, upload-time = "2025-12-29T11:46:33.964Z" }, - { url = "https://files.pythonhosted.org/packages/92/f6/d065bb189c9fd86c5e540eb264567b4fe3eb06447da1408c03a35e15096b/pygit2-1.19.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2cdc81ecffd990d8c6dce44a16b1dc4494b5dd5381d6e1f508e459c4bca09e0", size = 5781284, upload-time = "2025-12-29T11:46:35.703Z" }, - { url = "https://files.pythonhosted.org/packages/ad/8a/2b9195619a9a0dc6e25525e784f7474174614ebc064a91b2a2087952a583/pygit2-1.19.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a1c8645287556aa9b670886dbc0d5daa1d49040511940822fd43dbda13cfe4e8", size = 6027281, upload-time = "2025-12-29T11:46:37.331Z" }, - { url = "https://files.pythonhosted.org/packages/d7/b7/20837029e8f5177d4ac48396a4448d02dfe455e988bb722d43dc42f6b0af/pygit2-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e388d1eb0c44d92d8ff01b25eb9a969fc28748966843c2e26e9e084e42567f7d", size = 5750642, upload-time = "2025-12-29T11:46:38.626Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/18cc94976a35451a5653abf047356f94b5f503b1c0b02223a6d9e72979d3/pygit2-1.19.1-cp311-cp311-win32.whl", hash = "sha256:815c0b12845253929f2275759d623b3b4093e67e6536d2463177e6ff1d9ff0df", size = 942173, upload-time = "2025-12-29T11:46:40.087Z" }, - { url = "https://files.pythonhosted.org/packages/61/19/590708fc3182d47b40f0274f80671ccdf9c1a8fa5a838b554e6fe15a2bb3/pygit2-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:93f4986b35984aaaa5e7613ceb1ba4c184d890589df60b0d8d74e7dccec1d8cb", size = 1159463, upload-time = "2025-12-29T11:46:41.338Z" }, - { url = "https://files.pythonhosted.org/packages/90/a8/a2c1eb6f8c5f30cb5633a3c21e60ee6be2e4a3148b302f578e4b48e769ef/pygit2-1.19.1-cp311-cp311-win_arm64.whl", hash = "sha256:fef27b206955e66e3a63664e2ec93821e00ce2d917f8b4eae87c738163c00e14", size = 966795, upload-time = "2025-12-29T11:46:42.842Z" }, - { url = "https://files.pythonhosted.org/packages/45/01/607b8a400ffe46340df083d67cb05296f90e0d302d09addac5dc1afee47f/pygit2-1.19.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3c56ef9ac89e020ca005a39db4e045792b1ce98c2450a53f79815e9d831c006a", size = 5646594, upload-time = "2025-12-29T11:47:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/18/59/45e517b86692120fd927b8949916203c50ffce0cd7a7124131d90d816fde/pygit2-1.19.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a6d89079f3af32f25abb8680eabea31143a4f02f3d1da6644c296ba89b6a2fc", size = 5644506, upload-time = "2025-12-29T11:47:42.779Z" }, - { url = "https://files.pythonhosted.org/packages/db/25/41c0c37c0f8b23677364d9f82ddbb1377d2342666045d39b508acc3d6f97/pygit2-1.19.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bfd44dc6f1d5b1165cc2097c39000c4a5cc05443d27a3a5f2791ad338f52b07", size = 5559864, upload-time = "2025-12-29T11:47:44.399Z" }, - { url = "https://files.pythonhosted.org/packages/76/c0/16ff6c4d732d8644ab84a5d48141b55f6b353e08da5ffcbee03a5c58c3a5/pygit2-1.19.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0aca00ff7e3420f9c06d9386b0bfc76c18fd8a2c5234412db0e200a6cc47ed03", size = 5312681, upload-time = "2025-12-29T11:47:46.022Z" }, - { url = "https://files.pythonhosted.org/packages/08/cc/f762a2378d148ae40766fcac3f1ae1b5d925ae80128422366788eea9f5e6/pygit2-1.19.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f89f047667a218b71ebc96c398aca1e5109f149045a8d59ca9fd4a557d1e932e", size = 1130023, upload-time = "2025-12-29T11:47:47.55Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] [[package]] name = "setuptools" -version = "69.5.1" +version = "80.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/4f/b10f707e14ef7de524fe1f8988a294fb262a29c9b5b12275c7e188864aed/setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", size = 2291314, upload-time = "2024-04-13T21:06:28.589Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/29/13965af254e3373bceae8fb9a0e6ea0d0e571171b80d6646932131d6439b/setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32", size = 894566, upload-time = "2024-04-13T21:06:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, ] [[package]] @@ -211,30 +306,41 @@ dependencies = [ { name = "colorama" }, { name = "dataclasses-json" }, { name = "humanize" }, - { name = "pygit2", version = "1.18.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pygit2", version = "1.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pygit2" }, { name = "tabulate" }, ] [package.dev-dependencies] dev = [ + { name = "mypy" }, { name = "nuitka" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, { name = "setuptools" }, + { name = "types-colorama" }, + { name = "types-tabulate" }, ] [package.metadata] requires-dist = [ - { name = "colorama", specifier = ">=0.4.5,<1.0.0" }, - { name = "dataclasses-json", specifier = ">=0.6.3,<1.0.0" }, - { name = "humanize", specifier = ">=4.9.0,<5.0.0" }, - { name = "pygit2", specifier = ">=1.14.0,<2.0.0" }, - { name = "tabulate", specifier = ">=0.9.0,<1.0.0" }, + { name = "colorama", specifier = ">=0.4.6" }, + { name = "dataclasses-json", specifier = ">=0.6.7" }, + { name = "humanize", specifier = ">=4.15.0" }, + { name = "pygit2", specifier = ">=1.19.1" }, + { name = "tabulate", specifier = ">=0.9.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "nuitka", specifier = ">=1.5.7,<2.0.0" }, - { name = "setuptools", specifier = ">=69.0.3,<70.0.0" }, + { name = "mypy", specifier = ">=1.19.1" }, + { name = "nuitka", specifier = ">=2.8.10" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.14.14" }, + { name = "setuptools", specifier = ">=80.10.2" }, + { name = "types-colorama", specifier = ">=0.4.15.20250801" }, + { name = "types-tabulate", specifier = ">=0.9.0.20241207" }, ] [[package]] @@ -246,6 +352,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] +[[package]] +name = "types-colorama" +version = "0.4.15.20250801" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/37/af713e7d73ca44738c68814cbacf7a655aa40ddd2e8513d431ba78ace7b3/types_colorama-0.4.15.20250801.tar.gz", hash = "sha256:02565d13d68963d12237d3f330f5ecd622a3179f7b5b14ee7f16146270c357f5", size = 10437, upload-time = "2025-08-01T03:48:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" }, +] + +[[package]] +name = "types-tabulate" +version = "0.9.0.20241207" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/43/16030404a327e4ff8c692f2273854019ed36718667b2993609dc37d14dd4/types_tabulate-0.9.0.20241207.tar.gz", hash = "sha256:ac1ac174750c0a385dfd248edc6279fa328aaf4ea317915ab879a2ec47833230", size = 8195, upload-time = "2024-12-07T02:54:42.554Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/86/a9ebfd509cbe74471106dffed320e208c72537f9aeb0a55eaa6b1b5e4d17/types_tabulate-0.9.0.20241207-py3-none-any.whl", hash = "sha256:b8dad1343c2a8ba5861c5441370c3e35908edd234ff036d4298708a1d4cf8a85", size = 8307, upload-time = "2024-12-07T02:54:41.031Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -274,37 +398,21 @@ version = "0.25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, - { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, - { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, - { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, - { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, - { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, - { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, - { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, - { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, - { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, - { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, - { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, - { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, - { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, - { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, ] From 15cfb2836648abb6476c368eaa8c89dfed1197c0 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sat, 31 Jan 2026 21:25:41 +1000 Subject: [PATCH 2/2] Add comprehensive test suite (closes #24) - Add pytest fixtures for Config, ConfigAddon, toc files - Test signature module: calculate, validate, v1/v2 backward compat - Test toc module: find_addons, version filtering, encoding (BOM, CRLF) - Test snapjaw: config serialization, URL parsing, CLI commands, addon states - Test mygit: clone, fetch_states, progress callbacks - Add integration tests for real git repos (skipped by default, run with -m integration) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/{package.yml => ci.yml} | 4 +- src/mygit.py | 2 +- tests/__init__.py | 0 tests/conftest.py | 105 ++++++ tests/test_integration.py | 137 +++++++ tests/test_mygit.py | 421 ++++++++++++++++++++++ tests/test_signature.py | 131 +++++++ tests/test_snapjaw_commands.py | 397 ++++++++++++++++++++ tests/test_snapjaw_config.py | 121 +++++++ tests/test_snapjaw_helpers.py | 168 +++++++++ tests/test_snapjaw_states.py | 195 ++++++++++ tests/test_snapjaw_url_parsing.py | 99 +++++ tests/test_toc.py | 113 ++++++ 13 files changed, 1890 insertions(+), 3 deletions(-) rename .github/workflows/{package.yml => ci.yml} (97%) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_mygit.py create mode 100644 tests/test_signature.py create mode 100644 tests/test_snapjaw_commands.py create mode 100644 tests/test_snapjaw_config.py create mode 100644 tests/test_snapjaw_helpers.py create mode 100644 tests/test_snapjaw_states.py create mode 100644 tests/test_snapjaw_url_parsing.py create mode 100644 tests/test_toc.py diff --git a/.github/workflows/package.yml b/.github/workflows/ci.yml similarity index 97% rename from .github/workflows/package.yml rename to .github/workflows/ci.yml index ac41cf0..0a56de1 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Build +name: CI on: push: @@ -64,7 +64,7 @@ jobs: with: path: | ~\AppData\Local\Nuitka - key: nuitka + key: nuitka-py3.12-${{ hashFiles('uv.lock') }} - name: Build run: | diff --git a/src/mygit.py b/src/mygit.py index 1610173..3903713 100644 --- a/src/mygit.py +++ b/src/mygit.py @@ -26,7 +26,7 @@ class RepositoryInfo: # Workaround for https://github.com/libgit2/pygit2/issues/264 def clone(url: str, branch: str | None, path: str) -> RepositoryInfo: - def make_pipe() -> tuple[Connection, Connection]: + def make_pipe(): return Pipe() parent_data_conn, child_data_conn = make_pipe() 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..dad8b12 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,105 @@ +"""Shared fixtures for snapjaw tests.""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pygit2 +import pytest + +from snapjaw import Config, ConfigAddon, addon_key + + +def pytest_configure(config): + """Register custom markers.""" + config.addinivalue_line("markers", "integration: mark test as integration test (requires network)") + config.addinivalue_line("markers", "slow: mark test as slow") + + +# Use a fixed naive datetime to match production code (datetime.now()) +FIXED_NOW = datetime(2024, 6, 15, 12, 0, 0) + + +@pytest.fixture +def fixed_now(): + """Return a fixed datetime for testing.""" + return FIXED_NOW + + +@pytest.fixture +def make_addon(fixed_now): + """Factory fixture for creating ConfigAddon instances.""" + + def _make_addon( + name="TestAddon", + url="https://github.com/test/test.git", + branch="master", + commit="abc123", + checksum=None, + released_at=None, + installed_at=None, + ): + return ConfigAddon( + name=name, + url=url, + branch=branch, + commit=commit, + released_at=released_at or fixed_now, + installed_at=installed_at or fixed_now, + checksum=checksum, + ) + + return _make_addon + + +@pytest.fixture +def make_config(make_addon): + """Factory fixture for creating Config instances.""" + + def _make_config(*names): + addons = {} + for name in names: + addons[addon_key(name)] = make_addon(name=name) + return Config(addons_by_key=addons) + + return _make_config + + +@pytest.fixture +def make_toc_addon(tmp_path): + """Factory fixture for creating addon directories with .toc files.""" + + def _make_toc_addon(name, interface_version): + addon_dir = tmp_path / name + addon_dir.mkdir(parents=True, exist_ok=True) + toc_content = f"## Interface: {interface_version}\n## Title: {name}\n" + (addon_dir / f"{name}.toc").write_bytes(toc_content.encode("utf-8")) + return addon_dir + + return _make_toc_addon + + +@pytest.fixture +def mock_pygit2_repo(): + """Create a mocked pygit2.Repository.""" + repo = MagicMock() + repo.remotes = MagicMock() + repo.remotes.__iter__ = MagicMock(return_value=iter([])) + repo.remotes.__getitem__ = MagicMock(side_effect=KeyError) + return repo + + +@pytest.fixture +def mock_remote(): + """Factory fixture for creating mocked remotes.""" + + def _mock_remote(name, url, refs=None, error=None): + remote = MagicMock() + remote.name = name + remote.url = url + if error: + remote.ls_remotes.side_effect = pygit2.GitError(error) + else: + remote.ls_remotes.return_value = refs or [] + return remote + + return _mock_remote diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..5e810f8 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,137 @@ +"""Integration tests using real Git repositories. + +These tests require network access and are skipped by default. +Run with: pytest -m integration + +Test repositories (from TESTPLAN.md): +- https://github.com/refaim/MissingCrafts - vanilla addon +- https://github.com/refaim/MasterTradeSkills - vanilla addon +- https://github.com/fusionpit/QuestFrameFixer - has branch "1.12.1" +- https://github.com/refaim/LibCraftingProfessions-1.0 - NOT an addon (no .toc) +- https://gitlab.com/Artur91425/GrimoireKeeper - GitLab addon +""" + +import os + +import pytest + +from mygit import GitError, RemoteStateRequest, clone, fetch_states +from toc import find_addons + +pytestmark = pytest.mark.integration + + +class TestCloneRealRepos: + """Integration tests for cloning real repositories.""" + + def test_clone_github_repo(self, tmp_path): + """Clone a real GitHub repository.""" + repo = clone( + "https://github.com/refaim/MissingCrafts.git", + None, + str(tmp_path / "repo"), + ) + assert repo.workdir.endswith("/") + assert os.path.isdir(repo.workdir) + assert repo.head_commit_hex is not None + + def test_clone_with_branch(self, tmp_path): + """Clone a specific branch from GitHub.""" + repo = clone( + "https://github.com/fusionpit/QuestFrameFixer.git", + "1.12.1", + str(tmp_path / "repo"), + ) + assert repo.branch == "1.12.1" + + def test_clone_nonexistent_repo_raises_error(self, tmp_path): + """Cloning non-existent repo raises GitError.""" + with pytest.raises(GitError): + clone( + "https://github.com/this-user-does-not-exist-12345/no-such-repo.git", + None, + str(tmp_path / "repo"), + ) + + def test_clone_nonexistent_branch_raises_error(self, tmp_path): + """Cloning non-existent branch raises GitError.""" + with pytest.raises(GitError): + clone( + "https://github.com/refaim/MissingCrafts.git", + "this-branch-does-not-exist-12345", + str(tmp_path / "repo"), + ) + + +class TestFetchStatesRealRepos: + """Integration tests for fetching remote repository states.""" + + def test_fetch_single_repo_state(self): + """Fetch state of a real repository.""" + requests = [ + RemoteStateRequest("https://github.com/refaim/MissingCrafts.git", "master"), + ] + states = list(fetch_states(requests)) + assert len(states) == 1 + assert states[0].head_commit_hex is not None + assert states[0].error is None + + def test_fetch_multiple_repos(self): + """Fetch states of multiple repositories in parallel.""" + requests = [ + RemoteStateRequest("https://github.com/refaim/MissingCrafts.git", "master"), + RemoteStateRequest("https://github.com/refaim/MasterTradeSkills.git", "master"), + ] + states = list(fetch_states(requests)) + assert len(states) == 2 + assert all(s.head_commit_hex is not None for s in states) + + def test_fetch_nonexistent_repo(self): + """Fetching non-existent repo returns error state.""" + requests = [ + RemoteStateRequest( + "https://github.com/this-user-does-not-exist-12345/no-such-repo.git", + "master", + ), + ] + states = list(fetch_states(requests)) + assert len(states) == 1 + assert states[0].error is not None + + +class TestFindAddonsRealRepos: + """Integration tests for finding addons in cloned repositories.""" + + def test_find_vanilla_addon(self, tmp_path): + """Find addon in a vanilla WoW addon repository.""" + repo = clone( + "https://github.com/refaim/MissingCrafts.git", + None, + str(tmp_path / "repo"), + ) + addons = list(find_addons(repo.workdir, 11200)) + assert len(addons) >= 1 + addon_names = {a.name for a in addons} + assert "MissingCrafts" in addon_names + + def test_library_repo_no_addons(self, tmp_path): + """Library repository (no .toc) returns no addons.""" + repo = clone( + "https://github.com/refaim/LibCraftingProfessions-1.0.git", + None, + str(tmp_path / "repo"), + ) + addons = list(find_addons(repo.workdir, 11200)) + # Libraries typically don't have a .toc with proper Interface version + # or their toc is for embedding, not standalone use + assert len(addons) == 0 + + def test_gitlab_repo(self, tmp_path): + """Clone and find addon from GitLab.""" + repo = clone( + "https://gitlab.com/Artur91425/GrimoireKeeper.git", + None, + str(tmp_path / "repo"), + ) + addons = list(find_addons(repo.workdir, 11200)) + assert len(addons) >= 1 diff --git a/tests/test_mygit.py b/tests/test_mygit.py new file mode 100644 index 0000000..4459332 --- /dev/null +++ b/tests/test_mygit.py @@ -0,0 +1,421 @@ +"""Tests for mygit.py - Git operations wrapper. + +Tests focus on public API (clone, fetch_states) through behavior verification. +Internal implementation details are not tested directly. +""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +from mygit import ( + GitError, + RemoteState, + RemoteStateRequest, + RepositoryInfo, + clone, + fetch_states, +) + + +class TestClone: + """Tests for the clone() function - cloning git repositories.""" + + def test_success_returns_repository_info(self): + """Successful clone returns RepositoryInfo with correct data.""" + expected = RepositoryInfo( + workdir="/tmp/repo/", + branch="master", + head_commit_hex="abc123", + head_commit_time=datetime(2024, 1, 1), + ) + + mock_data_conn = MagicMock() + mock_data_conn.recv.return_value = expected + mock_error_conn = MagicMock() + mock_error_conn.poll.return_value = False + + with ( + patch("mygit.Pipe", side_effect=[(mock_data_conn, MagicMock()), (mock_error_conn, MagicMock())]), + patch("mygit.Process") as mock_process_cls, + ): + mock_process_cls.return_value = MagicMock() + + result = clone("https://github.com/test/repo.git", "master", "/tmp/repo") + + assert result == expected + + def test_error_raises_git_error(self): + """Clone failure raises GitError with message.""" + error = GitError("authentication failed") + + mock_data_conn = MagicMock() + mock_error_conn = MagicMock() + mock_error_conn.poll.return_value = True + mock_error_conn.recv.return_value = error + + with ( + patch("mygit.Pipe", side_effect=[(mock_data_conn, MagicMock()), (mock_error_conn, MagicMock())]), + patch("mygit.Process"), + pytest.raises(GitError, match="authentication failed"), + ): + clone("https://github.com/test/repo.git", None, "/tmp/repo") + + +class TestFetchStates: + """Tests for fetch_states() - checking remote repository states.""" + + @pytest.fixture + def mock_tmpdir_context(self): + """Context manager for mocking TemporaryDirectory.""" + mock = MagicMock() + mock.__enter__ = MagicMock(return_value="/tmp/repo") + mock.__exit__ = MagicMock(return_value=False) + return patch("mygit.TemporaryDirectory", return_value=mock) + + def test_success_returns_commit_hash(self, mock_remote, mock_pygit2_repo, mock_tmpdir_context): + """Successful fetch returns commit hash for branch.""" + remote = mock_remote( + "abc123", + "https://github.com/test/repo.git", + refs=[{"name": "refs/heads/master", "symref_target": "", "oid": "deadbeef"}], + ) + mock_pygit2_repo.remotes.__iter__ = MagicMock(return_value=iter([remote])) + + with ( + patch("mygit.pygit2.init_repository", return_value=mock_pygit2_repo), + patch("mygit.pygit2.GitError", Exception), + patch("mygit.sha1") as mock_sha1, + mock_tmpdir_context, + ): + mock_sha1.return_value.hexdigest.return_value = "abc123" + + requests = [RemoteStateRequest("https://github.com/test/repo.git", "master")] + states = list(fetch_states(requests)) + + assert len(states) == 1 + assert states[0].head_commit_hex == "deadbeef" + assert states[0].error is None + + def test_head_symref_resolves_branch(self, mock_remote, mock_pygit2_repo, mock_tmpdir_context): + """HEAD symref pointing to branch returns correct commit.""" + remote = mock_remote( + "abc123", + "https://github.com/test/repo.git", + refs=[{"name": "HEAD", "symref_target": "refs/heads/main", "oid": "cafebabe"}], + ) + mock_pygit2_repo.remotes.__iter__ = MagicMock(return_value=iter([remote])) + + with ( + patch("mygit.pygit2.init_repository", return_value=mock_pygit2_repo), + patch("mygit.pygit2.GitError", Exception), + patch("mygit.sha1") as mock_sha1, + mock_tmpdir_context, + ): + mock_sha1.return_value.hexdigest.return_value = "abc123" + + requests = [RemoteStateRequest("https://github.com/test/repo.git", "main")] + states = list(fetch_states(requests)) + + assert len(states) == 1 + assert states[0].head_commit_hex == "cafebabe" + + def test_git_error_returns_error_state(self, mock_remote, mock_pygit2_repo, mock_tmpdir_context): + """Git error returns state with error message, no commit.""" + remote = mock_remote( + "abc123", + "https://github.com/test/repo.git", + error="connection refused", + ) + mock_pygit2_repo.remotes.__iter__ = MagicMock(return_value=iter([remote])) + + with ( + patch("mygit.pygit2.init_repository", return_value=mock_pygit2_repo), + patch("mygit.pygit2.GitError", Exception), + patch("mygit.sha1") as mock_sha1, + mock_tmpdir_context, + ): + mock_sha1.return_value.hexdigest.return_value = "abc123" + + requests = [RemoteStateRequest("https://github.com/test/repo.git", "master")] + states = list(fetch_states(requests)) + + assert len(states) == 1 + assert states[0].error == "connection refused" + assert states[0].head_commit_hex is None + + def test_existing_remote_reused(self, mock_remote, mock_pygit2_repo, mock_tmpdir_context): + """Existing remote is reused, not recreated.""" + remote = mock_remote("abc123", "https://github.com/test/repo.git", refs=[]) + mock_pygit2_repo.remotes.__iter__ = MagicMock(return_value=iter([remote])) + mock_pygit2_repo.remotes.__getitem__ = MagicMock(return_value=remote) + + with ( + patch("mygit.pygit2.init_repository", return_value=mock_pygit2_repo), + patch("mygit.pygit2.GitError", Exception), + patch("mygit.sha1") as mock_sha1, + mock_tmpdir_context, + ): + mock_sha1.return_value.hexdigest.return_value = "abc123" + + requests = [RemoteStateRequest("https://github.com/test/repo.git", "master")] + list(fetch_states(requests)) + + mock_pygit2_repo.remotes.create.assert_not_called() + + +class TestCloneInternalProcess: + """Tests for _clone() - the subprocess worker function. + + These test the actual clone logic that runs in a subprocess. + We mock pygit2 to avoid real network calls. + """ + + def test_success_sends_repository_info(self): + """Successful clone sends RepositoryInfo through data connection.""" + mock_commit = MagicMock() + mock_commit.id = "abc123" + mock_commit.commit_time = 1704067200 # 2024-01-01 00:00:00 UTC + + mock_head = MagicMock() + mock_head.target = "ref" + mock_head.shorthand = "master" + + mock_repo = MagicMock() + mock_repo.workdir = "/tmp/repo/" + mock_repo.head = mock_head + mock_repo.__getitem__ = MagicMock(return_value=mock_commit) + + data_conn = MagicMock() + error_conn = MagicMock() + + from mygit import _clone + + with patch("mygit.pygit2") as mock_pygit2: + mock_pygit2.clone_repository.return_value = mock_repo + mock_pygit2.Commit = type(mock_commit) + _clone("https://github.com/test/repo.git", "master", "/tmp/repo", data_conn, error_conn) + + data_conn.send.assert_called_once() + info = data_conn.send.call_args[0][0] + assert info.workdir == "/tmp/repo/" + assert info.branch == "master" + error_conn.send.assert_not_called() + + def test_git_error_sends_error(self): + """Git error sends GitError through error connection.""" + data_conn = MagicMock() + error_conn = MagicMock() + + from mygit import _clone + + with patch("mygit.pygit2") as mock_pygit2: + mock_pygit2.GitError = Exception + mock_pygit2.clone_repository.side_effect = Exception("auth failed") + _clone("https://github.com/test/repo.git", None, "/tmp/repo", data_conn, error_conn) + + error_conn.send.assert_called_once() + sent_error = error_conn.send.call_args[0][0] + assert isinstance(sent_error, GitError) + data_conn.send.assert_not_called() + + def test_key_error_sends_error(self): + """KeyError (bad branch) sends GitError through error connection.""" + data_conn = MagicMock() + error_conn = MagicMock() + + from mygit import _clone + + with patch("mygit.pygit2") as mock_pygit2: + mock_pygit2.GitError = Exception + mock_pygit2.clone_repository.side_effect = KeyError("bad branch") + _clone("https://github.com/test/repo.git", "nonexistent", "/tmp/repo", data_conn, error_conn) + + error_conn.send.assert_called_once() + data_conn.send.assert_not_called() + + +class TestGitProgressCallbacks: + """Tests for _GitProgressCallbacks - progress reporting during clone. + + Tests verify output format contains expected substrings, not exact text. + """ + + def test_sideband_progress_prints(self, capsys): + """sideband_progress prints message.""" + from mygit import _GitProgressCallbacks + + cb = _GitProgressCallbacks() + cb.sideband_progress("remote: Counting objects") + captured = capsys.readouterr() + assert "remote: Counting objects" in captured.out + + def test_transfer_progress_receiving_objects(self, capsys): + """Receiving objects shows percentage.""" + from types import SimpleNamespace + + from mygit import _GitProgressCallbacks + + cb = _GitProgressCallbacks() + progress = SimpleNamespace( + received_objects=50, + total_objects=100, + received_bytes=1024, + indexed_deltas=0, + total_deltas=0, + ) + cb.transfer_progress(progress) + captured = capsys.readouterr() + assert "50%" in captured.out + + def test_transfer_progress_objects_done(self, capsys): + """Completed objects shows 'done'.""" + from types import SimpleNamespace + + from mygit import _GitProgressCallbacks + + cb = _GitProgressCallbacks() + progress = SimpleNamespace( + received_objects=100, + total_objects=100, + received_bytes=2048, + indexed_deltas=0, + total_deltas=0, + ) + cb.transfer_progress(progress) + captured = capsys.readouterr() + assert "done" in captured.out + + def test_transfer_progress_indexing_deltas(self, capsys): + """Indexing deltas shows percentage after objects done.""" + from types import SimpleNamespace + + from mygit import _GitProgressCallbacks + + cb = _GitProgressCallbacks() + cb._objects_done = True + progress = SimpleNamespace( + received_objects=100, + total_objects=100, + received_bytes=2048, + indexed_deltas=5, + total_deltas=10, + ) + cb.transfer_progress(progress) + captured = capsys.readouterr() + assert "50%" in captured.out + + def test_transfer_progress_deltas_done(self, capsys): + """Completed deltas shows 'done'.""" + from types import SimpleNamespace + + from mygit import _GitProgressCallbacks + + cb = _GitProgressCallbacks() + cb._objects_done = True + progress = SimpleNamespace( + received_objects=100, + total_objects=100, + received_bytes=2048, + indexed_deltas=10, + total_deltas=10, + ) + cb.transfer_progress(progress) + captured = capsys.readouterr() + assert "done" in captured.out + + def test_transfer_progress_zero_deltas_skipped(self, capsys): + """Zero total deltas produces no output.""" + from types import SimpleNamespace + + from mygit import _GitProgressCallbacks + + cb = _GitProgressCallbacks() + cb._objects_done = True + progress = SimpleNamespace( + received_objects=100, + total_objects=100, + received_bytes=2048, + indexed_deltas=0, + total_deltas=0, + ) + cb.transfer_progress(progress) + captured = capsys.readouterr() + assert captured.out == "" + + def test_transfer_progress_after_all_done_no_output(self, capsys): + """After both objects and deltas done, no output.""" + from types import SimpleNamespace + + from mygit import _GitProgressCallbacks + + cb = _GitProgressCallbacks() + cb._objects_done = True + cb._deltas_done = True + progress = SimpleNamespace( + received_objects=100, + total_objects=100, + received_bytes=2048, + indexed_deltas=10, + total_deltas=10, + ) + cb.transfer_progress(progress) + captured = capsys.readouterr() + assert captured.out == "" + + +class TestHasRemote: + """Tests for _has_remote() helper function.""" + + def test_remote_exists(self): + """Returns True when remote exists.""" + from mygit import _has_remote + + repo = MagicMock() + repo.remotes.__getitem__ = MagicMock(return_value=MagicMock()) + assert _has_remote(repo, "origin") is True + + def test_remote_not_exists(self): + """Returns False when remote doesn't exist.""" + from mygit import _has_remote + + repo = MagicMock() + repo.remotes.__getitem__ = MagicMock(side_effect=KeyError) + assert _has_remote(repo, "origin") is False + + +class TestDataClasses: + """Tests for data classes - ensure they hold expected data.""" + + def test_repository_info_fields(self): + """RepositoryInfo stores all required fields.""" + info = RepositoryInfo( + workdir="/tmp/repo/", + branch="master", + head_commit_hex="abc123", + head_commit_time=datetime(2024, 1, 1), + ) + assert info.workdir == "/tmp/repo/" + assert info.branch == "master" + assert info.head_commit_hex == "abc123" + + def test_remote_state_fields(self): + """RemoteState stores URL, branch, commit, and error.""" + state = RemoteState( + url="https://github.com/test/repo.git", + branch="master", + head_commit_hex="abc123", + error=None, + ) + assert state.url == "https://github.com/test/repo.git" + assert state.error is None + + def test_remote_state_request_fields(self): + """RemoteStateRequest stores URL and branch.""" + request = RemoteStateRequest( + url="https://github.com/test/repo.git", + branch="master", + ) + assert request.url == "https://github.com/test/repo.git" + assert request.branch == "master" diff --git a/tests/test_signature.py b/tests/test_signature.py new file mode 100644 index 0000000..070b3ed --- /dev/null +++ b/tests/test_signature.py @@ -0,0 +1,131 @@ +import hashlib + +import pytest + +from signature import _get_checksum, _hash, _pack, _unpack, calculate, validate + + +class TestPack: + def test_format(self): + assert _pack("abc123", 2) == "abc123|2" + + def test_round_trip(self): + checksum = "da39a3ee5e6b4b0d3255bfef95601890afd80709" + packed = _pack(checksum, 2) + unpacked_checksum, unpacked_version = _unpack(packed) + assert unpacked_checksum == checksum + assert unpacked_version == 2 + + +class TestUnpack: + def test_with_version(self): + checksum, version = _unpack("abc123|2") + assert checksum == "abc123" + assert version == 2 + + def test_v1_no_separator(self): + checksum, version = _unpack("abc123") + assert checksum == "abc123" + assert version == 1 + + +class TestHash: + def test_bytes(self): + expected = hashlib.sha1(b"hello").hexdigest() + assert _hash([b"hello"]) == expected + + def test_string(self): + expected = hashlib.sha1(b"hello").hexdigest() + assert _hash(["hello"]) == expected + + def test_multiple_chunks(self): + expected = hashlib.sha1(b"helloworld").hexdigest() + assert _hash([b"hello", b"world"]) == expected + + def test_mixed_types(self): + expected = hashlib.sha1(b"helloworld").hexdigest() + assert _hash([b"hello", "world"]) == expected + + def test_empty(self): + expected = hashlib.sha1(b"").hexdigest() + assert _hash([]) == expected + + +class TestCalculateValidate: + def test_round_trip(self, tmp_path): + (tmp_path / "file.txt").write_text("hello world") + sig = calculate(str(tmp_path)) + assert validate(str(tmp_path), sig) + + def test_modification_detected(self, tmp_path): + (tmp_path / "file.txt").write_text("hello world") + sig = calculate(str(tmp_path)) + (tmp_path / "file.txt").write_text("changed") + assert not validate(str(tmp_path), sig) + + def test_added_file_detected(self, tmp_path): + (tmp_path / "file.txt").write_text("hello") + sig = calculate(str(tmp_path)) + (tmp_path / "new.txt").write_text("extra") + assert not validate(str(tmp_path), sig) + + def test_empty_dir(self, tmp_path): + sig = calculate(str(tmp_path)) + assert validate(str(tmp_path), sig) + + def test_nested_dirs(self, tmp_path): + sub = tmp_path / "subdir" + sub.mkdir() + (sub / "inner.txt").write_text("nested content") + sig = calculate(str(tmp_path)) + assert validate(str(tmp_path), sig) + + def test_subdir_name_matters_v2(self, tmp_path): + sub = tmp_path / "aaa" + sub.mkdir() + (sub / "file.txt").write_text("content") + sig = calculate(str(tmp_path)) + + # Recreate with different subdir name + sub.rename(tmp_path / "bbb") + assert not validate(str(tmp_path), sig) + + def test_missing_dir_raises(self): + with pytest.raises(ValueError, match="not found"): + calculate("/nonexistent/path") + + def test_invalid_version_raises(self, tmp_path): + with pytest.raises(RuntimeError, match="Invalid hash version"): + _get_checksum(str(tmp_path), version=99) + + def test_v1_backward_compat_single_file(self, tmp_path): + (tmp_path / "file.txt").write_text("hello") + + # A raw checksum without version separator is treated as v1 + v1_checksum = hashlib.sha1(hashlib.sha1(b"hello").hexdigest().encode("utf-8")).hexdigest() + assert validate(str(tmp_path), v1_checksum) + + def test_v1_backward_compat_multiple_files(self, tmp_path): + # V1 sorts file hashes, so order of creation shouldn't matter + (tmp_path / "b.txt").write_text("second") + (tmp_path / "a.txt").write_text("first") + + hash_a = hashlib.sha1(b"first").hexdigest() + hash_b = hashlib.sha1(b"second").hexdigest() + v1_checksum = hashlib.sha1("".join(sorted([hash_a, hash_b])).encode("utf-8")).hexdigest() + assert validate(str(tmp_path), v1_checksum) + + def test_deleted_file_detected(self, tmp_path): + """Deleting a file invalidates checksum.""" + (tmp_path / "keep.txt").write_text("keep") + (tmp_path / "delete.txt").write_text("delete me") + sig = calculate(str(tmp_path)) + (tmp_path / "delete.txt").unlink() + assert not validate(str(tmp_path), sig) + + def test_renamed_file_detected(self, tmp_path): + """Renaming a file invalidates checksum (v2 includes file paths).""" + (tmp_path / "original.txt").write_text("content") + sig = calculate(str(tmp_path)) + (tmp_path / "original.txt").rename(tmp_path / "renamed.txt") + assert not validate(str(tmp_path), sig) diff --git a/tests/test_snapjaw_commands.py b/tests/test_snapjaw_commands.py new file mode 100644 index 0000000..32f7d3e --- /dev/null +++ b/tests/test_snapjaw_commands.py @@ -0,0 +1,397 @@ +"""Tests for snapjaw CLI commands (install, remove, update, status).""" + +import json +import os +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from mygit import RepositoryInfo +from snapjaw import ( + AddonState, + AddonStatus, + CliError, + Config, + cmd_remove, + cmd_status, + cmd_update, + install_addon, + remove_addon_dir, + run_command, +) + + +class TestRunCommand: + """Tests for run_command() - command execution wrapper.""" + + def test_missing_addons_dir_raises_error(self): + """Missing addons_dir raises CliError.""" + args = SimpleNamespace(addons_dir=None) + with pytest.raises(CliError, match="addons directory not found"): + run_command(MagicMock(), False, args) + + def test_read_only_does_not_create_backup(self, tmp_path): + """Read-only command doesn't create backup even if config exists.""" + config_path = tmp_path / "snapjaw.json" + config_path.write_text('{"addons_by_key": {}}') + callback = MagicMock() + args = SimpleNamespace(addons_dir=str(tmp_path)) + run_command(callback, True, args) + callback.assert_called_once() + assert not os.path.exists(tmp_path / "snapjaw.backup.json") + + def test_write_saves_config(self, tmp_path): + """Write command saves config after execution.""" + callback = MagicMock() + args = SimpleNamespace(addons_dir=str(tmp_path)) + run_command(callback, False, args) + assert os.path.exists(tmp_path / "snapjaw.json") + + def test_write_creates_backup(self, tmp_path): + """Write command creates backup of existing config.""" + config_path = tmp_path / "snapjaw.json" + config_path.write_text('{"addons_by_key": {}}') + callback = MagicMock() + args = SimpleNamespace(addons_dir=str(tmp_path)) + run_command(callback, False, args) + assert os.path.exists(tmp_path / "snapjaw.backup.json") + + def test_error_restores_backup(self, tmp_path, make_addon): + """On error, backup is restored.""" + config_path = tmp_path / "snapjaw.json" + config_path.write_text('{"addons_by_key": {}}') + + def bad_callback(config, args): + config.addons_by_key["test"] = make_addon() + config.save() + raise RuntimeError("boom") + + args = SimpleNamespace(addons_dir=str(tmp_path)) + with pytest.raises(RuntimeError, match="boom"): + run_command(bad_callback, False, args) + + with open(config_path) as f: + restored = json.load(f) + assert restored["addons_by_key"] == {} + + +class TestInstallAddon: + """Tests for install_addon() function.""" + + def test_success(self, tmp_path, monkeypatch, fixed_now): + """Successful install copies addon and updates config.""" + addons_dir = tmp_path / "Addons" + addons_dir.mkdir() + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + addon_dir = repo_dir / "MyAddon" + addon_dir.mkdir() + (addon_dir / "MyAddon.toc").write_text("## Interface: 11200\n") + (addon_dir / "init.lua").write_text("-- addon code") + + repo_info = RepositoryInfo( + workdir=str(repo_dir) + "/", + branch="master", + head_commit_hex="abc123", + head_commit_time=fixed_now, + ) + monkeypatch.setattr("snapjaw.mygit.clone", lambda url, branch, path: repo_info) + mock_tmpdir = MagicMock() + mock_tmpdir.__enter__ = MagicMock(return_value=str(repo_dir)) + mock_tmpdir.__exit__ = MagicMock(return_value=False) + monkeypatch.setattr("snapjaw.TemporaryDirectory", lambda: mock_tmpdir) + monkeypatch.setattr("snapjaw.signature.calculate", lambda path: "sig|2") + + config = Config(addons_by_key={}) + config._loaded_from = str(tmp_path / "snapjaw.json") + install_addon(config, "https://github.com/test/repo.git", "master", str(addons_dir)) + + assert "myaddon" in config.addons_by_key + assert os.path.exists(addons_dir / "MyAddon" / "init.lua") + + def test_git_error_raises_cli_error(self, tmp_path, monkeypatch): + """Git clone failure raises CliError.""" + from mygit import GitError + + monkeypatch.setattr("snapjaw.mygit.clone", MagicMock(side_effect=GitError("auth failed"))) + + config = Config(addons_by_key={}) + with pytest.raises(CliError, match="auth failed"): + install_addon(config, "https://github.com/test/repo.git", None, str(tmp_path)) + + def test_no_addons_found_raises_error(self, tmp_path, monkeypatch, fixed_now): + """No vanilla addons in repo raises CliError.""" + repo_info = RepositoryInfo( + workdir=str(tmp_path) + "/", branch="master", head_commit_hex="abc", head_commit_time=fixed_now + ) + monkeypatch.setattr("snapjaw.mygit.clone", lambda url, branch, path: repo_info) + monkeypatch.setattr("snapjaw.toc.find_addons", lambda workdir, version: iter([])) + + config = Config(addons_by_key={}) + with pytest.raises(CliError, match="no vanilla addons found"): + install_addon(config, "https://github.com/test/repo.git", None, str(tmp_path)) + + def test_copies_readme_from_root(self, tmp_path, monkeypatch, fixed_now): + """Readme from repo root is copied to addon directory.""" + addons_dir = tmp_path / "Addons" + addons_dir.mkdir() + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + addon_dir = repo_dir / "MyAddon" + addon_dir.mkdir() + (addon_dir / "MyAddon.toc").write_text("## Interface: 11200\n") + (repo_dir / "README.txt").write_text("read me") + + repo_info = RepositoryInfo( + workdir=str(repo_dir) + "/", + branch="master", + head_commit_hex="abc123", + head_commit_time=fixed_now, + ) + monkeypatch.setattr("snapjaw.mygit.clone", lambda url, branch, path: repo_info) + mock_tmpdir = MagicMock() + mock_tmpdir.__enter__ = MagicMock(return_value=str(repo_dir)) + mock_tmpdir.__exit__ = MagicMock(return_value=False) + monkeypatch.setattr("snapjaw.TemporaryDirectory", lambda: mock_tmpdir) + monkeypatch.setattr("snapjaw.signature.calculate", lambda path: "sig|2") + + config = Config(addons_by_key={}) + config._loaded_from = str(tmp_path / "snapjaw.json") + install_addon(config, "https://github.com/test/repo.git", "master", str(addons_dir)) + + assert (addons_dir / "MyAddon" / "README.txt").read_text() == "read me" + + +class TestCmdRemove: + """Tests for cmd_remove() command.""" + + def test_remove_existing_addon(self, tmp_path, make_config): + """Existing addon is removed from config and disk.""" + addon_dir = tmp_path / "MyAddon" + addon_dir.mkdir() + (addon_dir / "file.lua").write_text("code") + + config = make_config("MyAddon") + args = SimpleNamespace(names=["MyAddon"], addons_dir=str(tmp_path)) + cmd_remove(config, args) + + assert "myaddon" not in config.addons_by_key + assert not addon_dir.exists() + + def test_remove_not_found_prints_message(self, tmp_path, make_config, capsys): + """Removing non-existent addon prints message.""" + config = make_config() + args = SimpleNamespace(names=["Missing"], addons_dir=str(tmp_path)) + cmd_remove(config, args) + assert 'Addon not found: "Missing"' in capsys.readouterr().out + + +class TestRemoveAddonDir: + """Tests for remove_addon_dir() function.""" + + def test_removes_regular_dir(self, tmp_path): + """Regular directory is removed with contents.""" + d = tmp_path / "addon" + d.mkdir() + (d / "file.txt").write_text("x") + remove_addon_dir(str(d)) + assert not d.exists() + + @pytest.mark.skipif(sys.platform == "win32", reason="Symlinks require admin on Windows") + def test_removes_symlink_not_target(self, tmp_path): + """Symlink is removed but target directory remains.""" + target = tmp_path / "target" + target.mkdir() + link = tmp_path / "link" + link.symlink_to(target) + remove_addon_dir(str(link)) + assert not link.exists() + assert target.exists() + + def test_removes_symlink_via_islink(self, tmp_path, monkeypatch): + """Symlink path detected via islink is removed with os.remove.""" + path = str(tmp_path / "fake_link") + removed = [] + monkeypatch.setattr("snapjaw.os.path.islink", lambda p: True) + monkeypatch.setattr("snapjaw.os.remove", lambda p: removed.append(p)) + remove_addon_dir(path) + assert removed == [path] + + def test_nonexistent_path_is_noop(self, tmp_path): + """Non-existent path does not raise error.""" + remove_addon_dir(str(tmp_path / "noexist")) + + def test_rmtree_symlink_error_falls_back_to_remove(self, tmp_path, monkeypatch): + """When rmtree fails with symlink error, falls back to os.remove.""" + d = tmp_path / "addon" + d.mkdir() + + remove_called = [] + + def fake_rmtree(path): + raise OSError("Cannot call rmtree on a symbolic link") + + def fake_remove(path): + remove_called.append(path) + + monkeypatch.setattr("snapjaw.shutil.rmtree", fake_rmtree) + monkeypatch.setattr("snapjaw.os.remove", fake_remove) + remove_addon_dir(str(d)) + + assert len(remove_called) == 1 + assert remove_called[0] == str(d) + + def test_rmtree_other_error_propagates(self, tmp_path, monkeypatch): + """Non-symlink rmtree errors are re-raised.""" + d = tmp_path / "addon" + d.mkdir() + + def fake_rmtree(path): + raise OSError("permission denied") + + monkeypatch.setattr("snapjaw.shutil.rmtree", fake_rmtree) + with pytest.raises(OSError, match="permission denied"): + remove_addon_dir(str(d)) + + +class TestCmdUpdate: + """Tests for cmd_update() command.""" + + def test_update_by_name(self, tmp_path, monkeypatch, make_config): + """Update specific addon by name calls install_addon.""" + calls = [] + monkeypatch.setattr("snapjaw.install_addon", lambda config, url, branch, d: calls.append(url)) + + config = make_config("MyAddon") + args = SimpleNamespace(names=["MyAddon"], addons_dir=str(tmp_path)) + cmd_update(config, args) + assert len(calls) == 1 + + def test_update_all_outdated(self, tmp_path, monkeypatch, make_config, fixed_now): + """Update without names updates all outdated addons.""" + calls = [] + monkeypatch.setattr("snapjaw.install_addon", lambda config, url, branch, d: calls.append(url)) + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [AddonState("MyAddon", AddonStatus.Outdated, None, fixed_now, fixed_now)], + ) + + config = make_config("MyAddon") + args = SimpleNamespace(names=[], addons_dir=str(tmp_path)) + cmd_update(config, args) + assert len(calls) == 1 + + def test_update_no_outdated_prints_message(self, tmp_path, monkeypatch, make_config, fixed_now, capsys): + """No outdated addons prints informational message.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [AddonState("MyAddon", AddonStatus.UpToDate, None, fixed_now, fixed_now)], + ) + + config = make_config("MyAddon") + args = SimpleNamespace(names=[], addons_dir=str(tmp_path)) + cmd_update(config, args) + assert "No addons to update found" in capsys.readouterr().out + + def test_update_with_error_prints_error(self, tmp_path, monkeypatch, make_config, fixed_now, capsys): + """Addons with fetch errors print error message.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [AddonState("MyAddon", AddonStatus.Error, "timeout", fixed_now, fixed_now)], + ) + + config = make_config("MyAddon") + args = SimpleNamespace(names=[], addons_dir=str(tmp_path)) + cmd_update(config, args) + out = capsys.readouterr().out + assert "Error: MyAddon: timeout" in out + assert "No addons to update found" in out + + +class TestCmdStatus: + """Tests for cmd_status() command.""" + + def test_no_addons_prints_message(self, tmp_path, monkeypatch, capsys): + """Empty addon list prints informational message.""" + monkeypatch.setattr("snapjaw.get_addon_states", lambda config, d: []) + config = Config(addons_by_key={}) + args = SimpleNamespace(addons_dir=str(tmp_path), verbose=False) + cmd_status(config, args) + assert "No addons found" in capsys.readouterr().out + + def test_with_addons_shows_table(self, tmp_path, monkeypatch, fixed_now, capsys): + """Addons are displayed in table format.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("MyAddon", AddonStatus.Outdated, None, fixed_now, fixed_now), + AddonState("Other", AddonStatus.UpToDate, None, fixed_now, fixed_now), + ], + ) + config = Config(addons_by_key={}) + args = SimpleNamespace(addons_dir=str(tmp_path), verbose=False) + cmd_status(config, args) + out = capsys.readouterr().out + assert "MyAddon" in out + assert "1 other addons are up to date" in out + + def test_verbose_shows_all_addons(self, tmp_path, monkeypatch, fixed_now, capsys): + """Verbose mode shows up-to-date addons in table.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("MyAddon", AddonStatus.UpToDate, None, fixed_now, fixed_now), + ], + ) + config = Config(addons_by_key={}) + args = SimpleNamespace(addons_dir=str(tmp_path), verbose=True) + cmd_status(config, args) + out = capsys.readouterr().out + assert "MyAddon" in out + + def test_with_errors_shows_error_column(self, tmp_path, monkeypatch, fixed_now, capsys): + """Addons with errors show error message.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("Bad", AddonStatus.Error, "connection refused", fixed_now, fixed_now), + ], + ) + config = Config(addons_by_key={}) + args = SimpleNamespace(addons_dir=str(tmp_path), verbose=False) + cmd_status(config, args) + out = capsys.readouterr().out + assert "connection refused" in out + + def test_all_up_to_date_shows_summary(self, tmp_path, monkeypatch, fixed_now, capsys): + """All up-to-date shows count summary without table rows.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("Addon1", AddonStatus.UpToDate, None, fixed_now, fixed_now), + AddonState("Addon2", AddonStatus.UpToDate, None, fixed_now, fixed_now), + ], + ) + config = Config(addons_by_key={}) + args = SimpleNamespace(addons_dir=str(tmp_path), verbose=False) + cmd_status(config, args) + out = capsys.readouterr().out + assert "2 addons are up to date" in out + + def test_none_dates_handled(self, tmp_path, monkeypatch, capsys): + """Addons with None dates (untracked) don't crash.""" + monkeypatch.setattr( + "snapjaw.get_addon_states", + lambda config, d: [ + AddonState("Untracked", AddonStatus.Untracked, None, None, None), + ], + ) + config = Config(addons_by_key={}) + args = SimpleNamespace(addons_dir=str(tmp_path), verbose=False) + cmd_status(config, args) + out = capsys.readouterr().out + assert "Untracked" in out diff --git a/tests/test_snapjaw_config.py b/tests/test_snapjaw_config.py new file mode 100644 index 0000000..0003990 --- /dev/null +++ b/tests/test_snapjaw_config.py @@ -0,0 +1,121 @@ +"""Tests for snapjaw Config and ConfigAddon classes.""" + +import json + +import pytest + +from snapjaw import Config, ConfigAddon + + +class TestConfigAddon: + """Tests for ConfigAddon dataclass and serialization.""" + + def test_serialize_deserialize_with_checksum(self, fixed_now): + """ConfigAddon with checksum survives JSON round-trip.""" + addon = ConfigAddon( + name="TestAddon", + url="https://github.com/test/test.git", + branch="master", + commit="abc123", + released_at=fixed_now, + installed_at=fixed_now, + checksum="hash|2", + ) + json_str = addon.to_json() + restored = ConfigAddon.from_json(json_str) + assert restored.name == "TestAddon" + assert restored.checksum == "hash|2" + + def test_serialize_deserialize_without_checksum(self, fixed_now): + """ConfigAddon without checksum (None) survives JSON round-trip.""" + addon = ConfigAddon( + name="TestAddon", + url="https://github.com/test/test.git", + branch="master", + commit="abc123", + released_at=fixed_now, + installed_at=fixed_now, + ) + json_str = addon.to_json() + restored = ConfigAddon.from_json(json_str) + assert restored.checksum is None + + +class TestConfig: + """Tests for Config class - load/save operations.""" + + def test_addon_name_to_key_lowercases(self): + """addon_name_to_key converts to lowercase.""" + assert Config.addon_name_to_key("MyAddon") == "myaddon" + assert Config.addon_name_to_key("UPPER") == "upper" + assert Config.addon_name_to_key("already-lower") == "already-lower" + + def test_load_creates_new_if_not_exists(self, tmp_path): + """load_or_setup creates empty config when file doesn't exist.""" + config_path = str(tmp_path / "snapjaw.json") + config = Config.load_or_setup(config_path) + assert config.addons_by_key == {} + assert config._loaded_from == config_path + + def test_load_reads_existing_file(self, tmp_path): + """load_or_setup reads existing config file.""" + config_path = str(tmp_path / "snapjaw.json") + config = Config(addons_by_key={}) + config._loaded_from = config_path + config.save() + + loaded = Config.load_or_setup(config_path) + assert loaded.addons_by_key == {} + assert loaded._loaded_from == config_path + + def test_load_with_addons(self, tmp_path, make_addon): + """load_or_setup correctly deserializes addons.""" + config_path = str(tmp_path / "snapjaw.json") + addon = make_addon() + config = Config(addons_by_key={"testaddon": addon}) + config._loaded_from = config_path + config.save() + + loaded = Config.load_or_setup(config_path) + assert "testaddon" in loaded.addons_by_key + assert loaded.addons_by_key["testaddon"].name == "TestAddon" + + def test_save_sorts_keys(self, tmp_path, make_addon): + """save() writes addons sorted by key.""" + config_path = str(tmp_path / "snapjaw.json") + config = Config( + addons_by_key={ + "b": make_addon(name="B"), + "a": make_addon(name="A"), + } + ) + config._loaded_from = config_path + config.save() + + with open(config_path) as f: + data = json.load(f) + assert list(data["addons_by_key"].keys()) == ["a", "b"] + + def test_roundtrip_preserves_data(self, fixed_now): + """Config survives JSON serialization round-trip.""" + addon = ConfigAddon( + name="TestAddon", + url="https://github.com/test/test.git", + branch="master", + commit="abc123", + released_at=fixed_now, + installed_at=fixed_now, + checksum="hash|2", + ) + config = Config(addons_by_key={"testaddon": addon}) + json_str = config.to_json() + restored = Config.from_json(json_str) + assert restored.addons_by_key["testaddon"].name == "TestAddon" + assert restored.addons_by_key["testaddon"].checksum == "hash|2" + + def test_load_invalid_json_raises(self, tmp_path): + """Corrupted JSON file raises JSONDecodeError.""" + config_path = tmp_path / "snapjaw.json" + config_path.write_text("{invalid json") + with pytest.raises(json.JSONDecodeError): + Config.load_or_setup(str(config_path)) diff --git a/tests/test_snapjaw_helpers.py b/tests/test_snapjaw_helpers.py new file mode 100644 index 0000000..ccbdd2e --- /dev/null +++ b/tests/test_snapjaw_helpers.py @@ -0,0 +1,168 @@ +"""Tests for snapjaw helper functions and CLI argument parsing.""" + +import argparse +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from snapjaw import ( + CliError, + addon_key, + arg_type_dir, + get_addon_from_config, + main, + parse_args, + sort_addons_dict, +) + + +class TestAddonKey: + """Tests for addon_key() - case-insensitive key generation.""" + + @pytest.mark.parametrize( + "name,expected", + [ + ("MyAddon", "myaddon"), + ("myaddon", "myaddon"), + ("Friend-O-Tron", "friend-o-tron"), + ("UPPERCASE", "uppercase"), + ], + ) + def test_converts_to_lowercase(self, name, expected): + """addon_key converts name to lowercase.""" + assert addon_key(name) == expected + + +class TestSortAddonsDict: + """Tests for sort_addons_dict() - dictionary sorting.""" + + def test_sorts_by_key(self): + """Dictionary is sorted by keys.""" + d = {"b": 2, "a": 1, "c": 3} + result = sort_addons_dict(d) + assert list(result.keys()) == ["a", "b", "c"] + + def test_empty_dict(self): + """Empty dictionary returns empty dictionary.""" + assert sort_addons_dict({}) == {} + + +class TestGetAddonFromConfig: + """Tests for get_addon_from_config() - addon lookup by name.""" + + def test_found(self, make_config): + """Existing addon is returned.""" + config = make_config("MyAddon") + addon = get_addon_from_config(config, "MyAddon") + assert addon.name == "MyAddon" + + def test_case_insensitive(self, make_config): + """Lookup is case-insensitive.""" + config = make_config("MyAddon") + addon = get_addon_from_config(config, "myaddon") + assert addon.name == "MyAddon" + + def test_not_found_raises_error(self, make_config): + """Non-existent addon raises ArgumentTypeError.""" + config = make_config("MyAddon") + with pytest.raises(argparse.ArgumentTypeError): + get_addon_from_config(config, "NonExistent") + + +class TestArgTypeDir: + """Tests for arg_type_dir() - directory path validation.""" + + def test_valid_dir_returns_path(self, tmp_path): + """Valid directory path is returned unchanged.""" + assert arg_type_dir(str(tmp_path)) == str(tmp_path) + + def test_invalid_dir_raises_error(self): + """Non-existent directory raises ArgumentTypeError.""" + with pytest.raises(argparse.ArgumentTypeError, match="invalid directory path"): + arg_type_dir("/nonexistent/dir") + + +class TestParseArgs: + """Tests for parse_args() - CLI argument parsing.""" + + def test_install_command(self, monkeypatch, tmp_path): + """Install command parses URL argument.""" + monkeypatch.setattr( + "sys.argv", + ["snapjaw", "--addons-dir", str(tmp_path), "install", "https://example.com/repo.git"], + ) + args = parse_args() + assert args.url == "https://example.com/repo.git" + + def test_remove_command(self, monkeypatch, tmp_path): + """Remove command parses addon names.""" + monkeypatch.setattr( + "sys.argv", + ["snapjaw", "--addons-dir", str(tmp_path), "remove", "MyAddon"], + ) + args = parse_args() + assert args.names == ["MyAddon"] + + def test_update_all(self, monkeypatch, tmp_path): + """Update without names has empty names list.""" + monkeypatch.setattr( + "sys.argv", + ["snapjaw", "--addons-dir", str(tmp_path), "update"], + ) + args = parse_args() + assert args.names == [] + + def test_update_specific(self, monkeypatch, tmp_path): + """Update with names parses addon names.""" + monkeypatch.setattr( + "sys.argv", + ["snapjaw", "--addons-dir", str(tmp_path), "update", "MyAddon"], + ) + args = parse_args() + assert args.names == ["MyAddon"] + + def test_status_default_not_verbose(self, monkeypatch, tmp_path): + """Status command defaults to non-verbose.""" + monkeypatch.setattr( + "sys.argv", + ["snapjaw", "--addons-dir", str(tmp_path), "status"], + ) + args = parse_args() + assert args.verbose is False + + def test_status_verbose_flag(self, monkeypatch, tmp_path): + """Status -v flag sets verbose=True.""" + monkeypatch.setattr( + "sys.argv", + ["snapjaw", "--addons-dir", str(tmp_path), "status", "-v"], + ) + args = parse_args() + assert args.verbose is True + + def test_wow_dir_auto_detection(self, tmp_path, monkeypatch): + """WoW directory is auto-detected from current working directory.""" + (tmp_path / "WoW.exe").touch() + addons_dir = tmp_path / "Interface" / "Addons" + addons_dir.mkdir(parents=True) + monkeypatch.setattr("pathlib.Path.cwd", lambda: addons_dir) + monkeypatch.setattr("sys.argv", ["snapjaw", "status"]) + args = parse_args() + assert str(args.addons_dir) == str(addons_dir) + + +class TestMain: + """Tests for main() - application entry point.""" + + def test_success_returns_zero(self, monkeypatch): + """Successful execution returns 0.""" + mock_args = SimpleNamespace(callback=MagicMock()) + monkeypatch.setattr("snapjaw.parse_args", lambda: mock_args) + assert main() == 0 + mock_args.callback.assert_called_once_with(mock_args) + + def test_cli_error_returns_one(self, monkeypatch): + """CliError returns 1.""" + mock_args = SimpleNamespace(callback=MagicMock(side_effect=CliError("test error"))) + monkeypatch.setattr("snapjaw.parse_args", lambda: mock_args) + assert main() == 1 diff --git a/tests/test_snapjaw_states.py b/tests/test_snapjaw_states.py new file mode 100644 index 0000000..1a1996b --- /dev/null +++ b/tests/test_snapjaw_states.py @@ -0,0 +1,195 @@ +"""Tests for snapjaw addon state detection (get_addon_states).""" + +from mygit import RemoteState +from snapjaw import AddonStatus, Config, get_addon_states + + +class TestGetAddonStates: + """Tests for get_addon_states() - detecting addon status. + + Note: get_addon_states() prints progress to stdout (e.g., "1/2"). + Tests use capsys to capture and verify this output. + """ + + def test_up_to_date(self, tmp_path, monkeypatch, make_addon, capsys): + """Addon with matching commit and valid checksum is up-to-date.""" + addon_dir = tmp_path / "TestAddon" + addon_dir.mkdir() + (addon_dir / "init.lua").write_text("-- addon code") + + addon = make_addon(checksum="sig|2") + config = Config(addons_by_key={"testaddon": addon}) + + monkeypatch.setattr( + "snapjaw.mygit.fetch_states", + lambda reqs: iter([RemoteState("https://github.com/test/test.git", "master", "abc123", None)]), + ) + monkeypatch.setattr("snapjaw.signature.validate", lambda path, sig: True) + + states = get_addon_states(config, str(tmp_path)) + assert len(states) == 1 + assert states[0].status == AddonStatus.UpToDate + + # Verify progress was printed + captured = capsys.readouterr() + assert "1/1" in captured.out + + def test_outdated(self, tmp_path, monkeypatch, make_addon, capsys): + """Addon with different remote commit is outdated.""" + addon_dir = tmp_path / "TestAddon" + addon_dir.mkdir() + (addon_dir / "init.lua").write_text("-- addon code") + + addon = make_addon(checksum="sig|2") + config = Config(addons_by_key={"testaddon": addon}) + + monkeypatch.setattr( + "snapjaw.mygit.fetch_states", + lambda reqs: iter([RemoteState("https://github.com/test/test.git", "master", "newcommit", None)]), + ) + monkeypatch.setattr("snapjaw.signature.validate", lambda path, sig: True) + + states = get_addon_states(config, str(tmp_path)) + assert states[0].status == AddonStatus.Outdated + capsys.readouterr() # Consume output + + def test_modified_invalid_checksum(self, tmp_path, monkeypatch, make_addon, capsys): + """Addon with invalid checksum is marked as modified.""" + addon_dir = tmp_path / "TestAddon" + addon_dir.mkdir() + (addon_dir / "init.lua").write_text("-- modified code") + + addon = make_addon(checksum="sig|2") + config = Config(addons_by_key={"testaddon": addon}) + + monkeypatch.setattr( + "snapjaw.mygit.fetch_states", + lambda reqs: iter([RemoteState("https://github.com/test/test.git", "master", "abc123", None)]), + ) + monkeypatch.setattr("snapjaw.signature.validate", lambda path, sig: False) + + states = get_addon_states(config, str(tmp_path)) + assert states[0].status == AddonStatus.Modified + capsys.readouterr() + + def test_modified_no_checksum(self, tmp_path, monkeypatch, make_addon, capsys): + """Addon without checksum is marked as modified.""" + addon_dir = tmp_path / "TestAddon" + addon_dir.mkdir() + (addon_dir / "init.lua").write_text("-- addon code") + + addon = make_addon() # checksum=None + config = Config(addons_by_key={"testaddon": addon}) + + monkeypatch.setattr( + "snapjaw.mygit.fetch_states", + lambda reqs: iter([RemoteState("https://github.com/test/test.git", "master", "abc123", None)]), + ) + + states = get_addon_states(config, str(tmp_path)) + assert states[0].status == AddonStatus.Modified + capsys.readouterr() + + def test_error(self, tmp_path, monkeypatch, make_addon, capsys): + """Fetch error sets Error status with message.""" + addon = make_addon() + config = Config(addons_by_key={"testaddon": addon}) + + monkeypatch.setattr( + "snapjaw.mygit.fetch_states", + lambda reqs: iter([RemoteState("https://github.com/test/test.git", "master", None, "timeout")]), + ) + + states = get_addon_states(config, str(tmp_path)) + assert states[0].status == AddonStatus.Error + assert states[0].error == "timeout" + capsys.readouterr() + + def test_unknown(self, tmp_path, monkeypatch, make_addon, capsys): + """No commit and no error sets Unknown status.""" + addon = make_addon() + config = Config(addons_by_key={"testaddon": addon}) + + monkeypatch.setattr( + "snapjaw.mygit.fetch_states", + lambda reqs: iter([RemoteState("https://github.com/test/test.git", "master", None, None)]), + ) + + states = get_addon_states(config, str(tmp_path)) + assert states[0].status == AddonStatus.Unknown + capsys.readouterr() + + def test_untracked(self, tmp_path, monkeypatch, capsys): + """Directory not in config is Untracked.""" + (tmp_path / "SomeAddon").mkdir() + config = Config(addons_by_key={}) + monkeypatch.setattr("snapjaw.mygit.fetch_states", lambda reqs: iter([])) + + states = get_addon_states(config, str(tmp_path)) + assert len(states) == 1 + assert states[0].status == AddonStatus.Untracked + capsys.readouterr() + + def test_blizzard_addons_ignored(self, tmp_path, monkeypatch, capsys): + """Blizzard_ prefixed directories are ignored.""" + (tmp_path / "Blizzard_UI").mkdir() + config = Config(addons_by_key={}) + monkeypatch.setattr("snapjaw.mygit.fetch_states", lambda reqs: iter([])) + + states = get_addon_states(config, str(tmp_path)) + assert len(states) == 0 + capsys.readouterr() + + def test_missing(self, tmp_path, monkeypatch, make_addon, capsys): + """Addon in config but not on disk is Missing.""" + addon = make_addon(name="MyMissingAddon") + config = Config(addons_by_key={"mymissingaddon": addon}) + + monkeypatch.setattr("snapjaw.mygit.fetch_states", lambda reqs: iter([])) + + states = get_addon_states(config, str(tmp_path)) + assert len(states) == 1 + assert states[0].status == AddonStatus.Missing + # Verify original addon name is used, not lowercase key + assert states[0].addon == "MyMissingAddon" + capsys.readouterr() + + def test_multiple_addons_different_statuses(self, tmp_path, monkeypatch, make_addon, capsys): + """Multiple addons with different statuses are correctly detected.""" + # Create addon directories with content + (tmp_path / "UpToDateAddon").mkdir() + (tmp_path / "UpToDateAddon" / "init.lua").write_text("-- code") + (tmp_path / "OutdatedAddon").mkdir() + (tmp_path / "OutdatedAddon" / "init.lua").write_text("-- code") + (tmp_path / "UntrackedAddon").mkdir() + + addon1 = make_addon( + name="UpToDateAddon", url="https://github.com/test/uptodate.git", commit="abc123", checksum="sig|2" + ) + addon2 = make_addon( + name="OutdatedAddon", url="https://github.com/test/outdated.git", commit="old123", checksum="sig|2" + ) + config = Config( + addons_by_key={ + "uptodateaddon": addon1, + "outdatedaddon": addon2, + } + ) + + def mock_fetch_states(reqs): + for req in reqs: + if "uptodate" in req.url: + yield RemoteState(req.url, "master", "abc123", None) + elif "outdated" in req.url: + yield RemoteState(req.url, "master", "new456", None) + + monkeypatch.setattr("snapjaw.mygit.fetch_states", mock_fetch_states) + monkeypatch.setattr("snapjaw.signature.validate", lambda path, sig: True) + + states = get_addon_states(config, str(tmp_path)) + status_by_name = {s.addon: s.status for s in states} + + assert status_by_name["UpToDateAddon"] == AddonStatus.UpToDate + assert status_by_name["OutdatedAddon"] == AddonStatus.Outdated + assert status_by_name["UntrackedAddon"] == AddonStatus.Untracked + capsys.readouterr() diff --git a/tests/test_snapjaw_url_parsing.py b/tests/test_snapjaw_url_parsing.py new file mode 100644 index 0000000..883acfe --- /dev/null +++ b/tests/test_snapjaw_url_parsing.py @@ -0,0 +1,99 @@ +"""Tests for URL parsing logic in snapjaw cmd_install.""" + +from types import SimpleNamespace + +import pytest + +from snapjaw import CliError, Config, cmd_install + + +class TestUrlParsing: + """Tests for repository URL parsing and normalization.""" + + @pytest.fixture + def run_install(self, monkeypatch, tmp_path): + """Fixture to run cmd_install and capture the parsed URL/branch.""" + + def _run(url, branch=None): + calls = [] + + def mock_install(config, repo_url, branch, addons_dir): + calls.append((repo_url, branch)) + + monkeypatch.setattr("snapjaw.install_addon", mock_install) + + args = SimpleNamespace(url=url, branch=branch, addons_dir=str(tmp_path)) + config = Config(addons_by_key={}) + cmd_install(config, args) + return calls[0] + + return _run + + @pytest.mark.parametrize( + "input_url,expected_url,expected_branch", + [ + # GitHub URLs + ( + "https://github.com/refaim/MissingCrafts", + "https://github.com/refaim/MissingCrafts.git", + None, + ), + ( + "https://github.com/refaim/MissingCrafts.git", + "https://github.com/refaim/MissingCrafts.git", + None, + ), + ( + "https://github.com/fusionpit/QuestFrameFixer/tree/1.12.1", + "https://github.com/fusionpit/QuestFrameFixer.git", + "1.12.1", + ), + # GitLab URLs + ( + "https://gitlab.com/Artur91425/GrimoireKeeper", + "https://gitlab.com/Artur91425/GrimoireKeeper.git", + None, + ), + ( + "https://gitlab.com/Artur91425/GrimoireKeeper/-/tree/master", + "https://gitlab.com/Artur91425/GrimoireKeeper.git", + "master", + ), + # Non-GitHub/GitLab URL passed through + ( + "https://custom.server/repo.git", + "https://custom.server/repo.git", + None, + ), + ], + ids=[ + "github_simple", + "github_with_git_suffix", + "github_with_branch", + "gitlab_simple", + "gitlab_with_branch", + "custom_url_passthrough", + ], + ) + def test_url_parsing(self, run_install, input_url, expected_url, expected_branch): + """URL is correctly parsed and normalized.""" + repo_url, branch = run_install(input_url) + assert repo_url == expected_url + assert branch == expected_branch + + def test_explicit_branch_override(self, run_install): + """Explicit --branch argument is used when URL has no branch.""" + repo_url, branch = run_install( + "https://github.com/fusionpit/QuestFrameFixer", + branch="1.12.1", + ) + assert repo_url == "https://github.com/fusionpit/QuestFrameFixer.git" + assert branch == "1.12.1" + + def test_branch_conflict_raises_error(self, run_install): + """Conflicting branch in URL and --branch argument raises CliError.""" + with pytest.raises(CliError, match="requested branch"): + run_install( + "https://github.com/fusionpit/QuestFrameFixer/tree/1.12.1", + branch="other-branch", + ) diff --git a/tests/test_toc.py b/tests/test_toc.py new file mode 100644 index 0000000..5d5e414 --- /dev/null +++ b/tests/test_toc.py @@ -0,0 +1,113 @@ +"""Tests for toc.py - .toc file parsing and addon discovery.""" + +import pytest + +from toc import find_addons + + +class TestFindAddons: + """Tests for finding WoW addons by parsing .toc files.""" + + def test_simple_addon(self, make_toc_addon, tmp_path): + """Single addon with valid Interface version is found.""" + make_toc_addon("MyAddon", 11200) + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 1 + assert addons[0].name == "MyAddon" + + @pytest.mark.parametrize( + "version,expected_count", + [ + (11200, 1), # vanilla addon found + (20000, 0), # TBC addon filtered out + (11201, 0), # version just above max + ], + ) + def test_version_filtering(self, make_toc_addon, tmp_path, version, expected_count): + """Addons are filtered based on Interface version.""" + make_toc_addon("TestAddon", version) + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == expected_count + + def test_multiple_addons_different_versions(self, make_toc_addon, tmp_path): + """Only addons within version range are returned.""" + make_toc_addon("VanillaAddon", 11200) + make_toc_addon("TBCAddon", 20000) + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 1 + assert addons[0].name == "VanillaAddon" + + def test_no_interface_header(self, tmp_path): + """Addon without Interface header is skipped.""" + addon_dir = tmp_path / "NoHeader" + addon_dir.mkdir() + (addon_dir / "NoHeader.toc").write_text("## Title: NoHeader\n") + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 0 + + def test_multiple_addons(self, make_toc_addon, tmp_path): + """Multiple valid addons are all found.""" + make_toc_addon("AddonA", 11200) + make_toc_addon("AddonB", 11200) + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 2 + names = {a.name for a in addons} + assert names == {"AddonA", "AddonB"} + + def test_nested_addon_takes_outer(self, make_toc_addon, tmp_path): + """When addon is nested inside another, only outer addon is returned.""" + outer = make_toc_addon("OuterAddon", 11200) + inner_dir = outer / "InnerAddon" + inner_dir.mkdir() + (inner_dir / "InnerAddon.toc").write_text("## Interface: 11200\n") + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 1 + assert addons[0].name == "OuterAddon" + + def test_empty_dir(self, tmp_path): + """Empty directory returns no addons.""" + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 0 + + def test_toc_case_insensitive(self, tmp_path): + """Addon with .TOC extension (uppercase) is found.""" + addon_dir = tmp_path / "CaseAddon" + addon_dir.mkdir() + (addon_dir / "CaseAddon.TOC").write_text("## Interface: 11200\n") + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 1 + + @pytest.mark.parametrize( + "interface_line", + [ + "## Interface: abc", + "## Interface: ", + "## Interface:", + "##Interface: 11200", # no space after ## + ], + ) + def test_invalid_interface_format_skipped(self, tmp_path, interface_line): + """Invalid Interface format is skipped.""" + addon_dir = tmp_path / "BadAddon" + addon_dir.mkdir() + (addon_dir / "BadAddon.toc").write_text(f"{interface_line}\n") + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 0 + + def test_utf8_bom_encoding(self, tmp_path): + """Addon with UTF-8 BOM encoding is found.""" + addon_dir = tmp_path / "BomAddon" + addon_dir.mkdir() + # UTF-8 BOM + content + content = b"\xef\xbb\xbf## Interface: 11200\n## Title: BomAddon\n" + (addon_dir / "BomAddon.toc").write_bytes(content) + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 1 + + def test_windows_line_endings(self, tmp_path): + """Addon with Windows line endings (CRLF) is found.""" + addon_dir = tmp_path / "WinAddon" + addon_dir.mkdir() + (addon_dir / "WinAddon.toc").write_bytes(b"## Interface: 11200\r\n## Title: WinAddon\r\n") + addons = list(find_addons(str(tmp_path), 11200)) + assert len(addons) == 1