diff --git a/locutus.toml b/locutus.toml new file mode 100644 index 0000000..7fede73 --- /dev/null +++ b/locutus.toml @@ -0,0 +1,56 @@ +[includes] +paths = [ + "/etc", + "/root", + "/srv", + "/usr/local/etc", + "/usr/local/src", + "/usr/share/nginx", + "/var/spool", + "/var/www", + "/home" +] + +[excludes] +paths = [ + "*.bak", + "*/.cache", + "*.config/*Cache/", + "*.config/*cache/", + "*.config/Signal", + "*.config/discord", + "*.config/microsoft-edge", + "*/Cache*", + "*/mnt", + "/home/*/.cache", + "/home/*/.cargo", + "/home/*/.local/*/Trash", + "/home/*/.local/lib", + "/home/*/.local/pipx", + "/home/*/.npm", + "/home/*/CMakeFiles", + "/home/*/Downloads", + "/home/*/downloads", + "/home/*/nextcloud", + "/home/*/snap", + "/home/*/software", + "/home/*/venv", + "/home/*/workspace/*.obj", + "/home/*/workspace/*.obj.d", + "/home/*/workspace/_*", + "/home/*/.rustup", + "/dev", + "/proc", + "/sys", + "/tmp", + "/run" +] + +[prune] +keep_last = 7 +keep_daily = 7 +keep_weekly = 4 +keep_monthly = 6 + +[compact] +enabled = true diff --git a/pyproject.toml b/pyproject.toml index 30063bd..fb82dfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "locutus" version = "0.1.0" description = "A simple borg wrapper" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ ] authors = [ @@ -20,6 +20,12 @@ readme = "README.md" license = {file = "LICENSE"} +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", +] + [tool.setuptools.packages.find] where = ["src"] @@ -38,7 +44,9 @@ select = [ "B", # flake8-bugbear "SIM", # flake8-simplify ] -ignore = [] +ignore = [ + "E101" +] [tool.ruff.format] indent-style = "tab" diff --git a/src/locutus/__init__.py b/src/locutus/__init__.py index edb7f60..d6d9ce1 100644 --- a/src/locutus/__init__.py +++ b/src/locutus/__init__.py @@ -1,5 +1,7 @@ """locutus - A simple borg wrapper""" +from locutus.config import LocutusConfig + __version__ = '0.1.0' __author__ = 'Alexander Wainwright ' -__all__: list[str] = [] +__all__: list[str] = ['LocutusConfig'] diff --git a/src/locutus/backup.py b/src/locutus/backup.py index ab6738c..957b5b6 100644 --- a/src/locutus/backup.py +++ b/src/locutus/backup.py @@ -1,6 +1,173 @@ import argparse +import os +import subprocess +import sys + +from locutus.config import LocutusConfig def run_backup(args: argparse.Namespace) -> int: - print('backup', args) - return 0 + """Orchestrates the backup cycle: create, prune, compact. + + Args: + args: Command-line arguments (should include --config, --profile, + --dry-run). + + Returns: + 0 on success, 1 on error. + """ + try: + # 1. Load config and rc/env + cfg = LocutusConfig(args.config, args.profile) + + # 2. Run backup (borg create) + ok = run_borg_create(cfg, args.dry_run) + if not ok: + print('Backup failed.', file=sys.stderr) + return 1 + + # 3. Run prune + ok = run_borg_prune(cfg, args.dry_run) + if not ok: + print('Prune failed.', file=sys.stderr) + return 1 + + # 4. Run compact (if enabled) + if cfg.compact: + ok = run_borg_compact(cfg, args.dry_run) + if not ok: + print('Compact failed.', file=sys.stderr) + return 1 + + print('Backup cycle completed successfully.') + return 0 + + except Exception as e: + print(f'Backup failed: {e}', file=sys.stderr) + return 1 + + +def run_borg_create(cfg: LocutusConfig, dry_run: bool) -> bool: + """Runs borg create with configured includes and excludes. + + Args: + cfg: The loaded LocutusConfig object. + dry_run: If True, pass --dry-run to borg create. + + Returns: + True on success, False if borg returns nonzero. + """ + # Build archive name: auto-TIMESTAMP + from datetime import datetime + + archive_name = 'auto-' + datetime.now().strftime('%Y-%m-%dT%H%M%S') + repo = cfg.get_repo() + if not repo: + print('No BORG_REPO configured.', file=sys.stderr) + return False + + cmd = [ + 'borg', + 'create', + '--stats', + ] + if dry_run: + cmd.append('--dry-run') + + # Excludes + for pattern in cfg.excludes: + cmd += ['--exclude', pattern] + + # Archive: repo::archive + cmd.append(f'{repo}::{archive_name}') + + # Includes + cmd += cfg.includes + + # Prepare env for subprocess + env = {**os.environ, **cfg.env} + try: + result = subprocess.run(cmd, env=env, check=True) + return result.returncode == 0 + except subprocess.CalledProcessError as e: + print(f'borg create failed: {e}', file=sys.stderr) + return False + + +def run_borg_prune(cfg: LocutusConfig, dry_run: bool) -> bool: + """Runs borg prune with the configured retention policy. + + Args: + cfg: The loaded LocutusConfig object. + dry_run: If True, passes --dry-run to borg prune. + + Returns: + True on success, False if borg returns nonzero or an error. + """ + repo = cfg.get_repo() + if not repo: + print('No BORG_REPO configured for prune.', file=sys.stderr) + return False + + cmd = ['borg', 'prune', repo, '--list'] + if dry_run: + cmd.append('--dry-run') + + prune = cfg.prune + # Map of config keys to borg prune args + keep_args = { + 'keep_last': '--keep-last', + 'keep_daily': '--keep-daily', + 'keep_weekly': '--keep-weekly', + 'keep_monthly': '--keep-monthly', + 'keep_yearly': '--keep-yearly', + } + for key, borg_arg in keep_args.items(): + val = prune.get(key) + if val is not None: + cmd += [borg_arg, str(val)] + + env = {**os.environ, **cfg.env} + try: + result = subprocess.run(cmd, env=env, check=True) + return result.returncode == 0 + except subprocess.CalledProcessError as e: + print(f'borg prune failed: {e}', file=sys.stderr) + return False + except Exception as e: + print(f'borg prune failed (unexpected): {e}', file=sys.stderr) + return False + + +def run_borg_compact(cfg: LocutusConfig, dry_run: bool) -> bool: + """Runs borg compact + + Args: + cfg: The loaded LocutusConfig object. + dry_run: If True, prints what would be done but does not actually run + compact. + + Returns: + True on success, False if borg returns nonzero or an error. + """ + repo = cfg.get_repo() + if not repo: + print('No BORG_REPO configured for compact.', file=sys.stderr) + return False + + if dry_run: + print(f'(dry-run) Would run: borg compact {repo}') + return True + + cmd = ['borg', 'compact', repo, '--verbose'] + + env = {**os.environ, **cfg.env} + try: + result = subprocess.run(cmd, env=env, check=True) + return result.returncode == 0 + except subprocess.CalledProcessError as e: + print(f'borg compact failed: {e}', file=sys.stderr) + return False + except Exception as e: + print(f'borg compact failed (unexpected): {e}', file=sys.stderr) + return False diff --git a/src/locutus/config.py b/src/locutus/config.py new file mode 100644 index 0000000..a3e24bc --- /dev/null +++ b/src/locutus/config.py @@ -0,0 +1,71 @@ +import os +import re +import tomllib +from typing import Any + + +class LocutusConfig: + """Loads and provides access to locutus configuration and profile""" + + def __init__(self, toml_path: str, rc_path: str) -> None: + self.toml_path: str = toml_path + self.rc_path: str = rc_path + self.toml: dict[str, Any] = {} + self.includes: list[str] = [] + self.excludes: list[str] = [] + self.prune: dict[str, Any] = {} + self.compact: bool = False + self.env: dict[str, str] = {} + + self.load_toml() + self.load_rc() + + def load_toml(self) -> None: + """Loads and parses the TOML configuration file + + Raises: + FileNotFoundError: If the TOML file does not exist. + tomllib.TOMLDecodeError: If the TOML file is malformed. + """ + with open(self.toml_path, 'rb') as f: + self.toml = tomllib.load(f) + self.includes = self.toml.get('includes', {}).get('paths', []) + self.excludes = self.toml.get('excludes', {}).get('paths', []) + self.prune = self.toml.get('prune', {}) + self.compact = self.toml.get('compact', {}).get('enabled', False) + + def load_rc(self) -> None: + """Loads environment variables from the rc file + + Parses lines of the form 'export VAR=value' and stores them in self.env. + """ + self.env = {} + if not os.path.isfile(self.rc_path): + return + with open(self.rc_path) as f: + for line in f: + m = re.match( + r'export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)', line.strip() + ) + if m: + key, val = ( + m.group(1), + m.group(2).strip().strip('"').strip("'"), + ) + self.env[key] = val + + def get_repo(self) -> str | None: + """Returns the repository path (BORG_REPO) from the rc file + + Returns: + The BORG_REPO string if present, otherwise None. + """ + return self.env.get('BORG_REPO') + + def get_passphrase(self) -> str | None: + """Returns the passphrase (BORG_PASSPHRASE) from the rc file + + Returns: + The BORG_PASSPHRASE string if present, otherwise None. + """ + return self.env.get('BORG_PASSPHRASE') diff --git a/src/locutus/setup.py b/src/locutus/init.py similarity index 97% rename from src/locutus/setup.py rename to src/locutus/init.py index d420267..e608b3c 100644 --- a/src/locutus/setup.py +++ b/src/locutus/init.py @@ -5,7 +5,7 @@ import subprocess import sys -def run_setup(args: argparse.Namespace) -> int: +def run_init(args: argparse.Namespace) -> int: """Interactively set up a new Borg repository and create an rc file. Prompts the user for a repository path and optional encryption passphrase. diff --git a/src/locutus/list.py b/src/locutus/list.py new file mode 100644 index 0000000..ab24e75 --- /dev/null +++ b/src/locutus/list.py @@ -0,0 +1,81 @@ +import argparse +import os +import subprocess +import sys + +from locutus.config import LocutusConfig + + +def run_list(args: argparse.Namespace) -> int: + """Lists archives or files in a specific archive. + + Args: + args: argparse.Namespace with --config, --profile, and optional 'target' + (index or name). + + Returns: + 0 on success, 1 on error + """ + try: + cfg = LocutusConfig(args.config, args.profile) + repo = cfg.get_repo() + if not repo: + print('No BORG_REPO configured.', file=sys.stderr) + return 1 + + env = {**os.environ, **cfg.env} + + # If user gave an archive index or name + target = getattr(args, 'target', None) + if target is not None: + # If it's an int, treat as index (get list first) + try: + index = int(target) + # get archive names + cmd = ['borg', 'list', '--short', repo] + result = subprocess.run( + cmd, env=env, capture_output=True, text=True, check=True + ) + archives = result.stdout.strip().splitlines()[::-1] + if not archives: + print('No archives found.') + return 1 + if not (0 <= index < len(archives)): + print( + f'Archive index out of range (0..{len(archives) - 1}).', + file=sys.stderr, + ) + return 1 + archive_name = archives[index] + except ValueError: + # Not an int; treat as archive name + archive_name = target + + # Now list files in the selected archive + cmd = ['borg', 'list', f'{repo}::{archive_name}'] + result = subprocess.run( + cmd, env=env, capture_output=True, text=True, check=True + ) + print(result.stdout) + return 0 + + # No target: list archives as before + cmd = ['borg', 'list', '--short', repo] + result = subprocess.run( + cmd, env=env, capture_output=True, text=True, check=True + ) + archives = result.stdout.strip().splitlines()[::-1] + if not archives: + print('No archives found.') + return 0 + + for idx, name in reversed(list(enumerate(archives))): + print(f'{idx:3d}: {name}') + return 0 + + except subprocess.CalledProcessError as e: + print(f'borg list failed: {e}', file=sys.stderr) + return 1 + except Exception as e: + print(f'borg list failed (unexpected): {e}', file=sys.stderr) + return 1 diff --git a/src/locutus/main.py b/src/locutus/main.py index 06ecb62..1fe6798 100644 --- a/src/locutus/main.py +++ b/src/locutus/main.py @@ -30,8 +30,8 @@ def parse_args() -> tuple[argparse.Namespace, argparse.ArgumentParser]: subparsers = parser.add_subparsers(dest='command', required=True) - # setup - _setup = subparsers.add_parser('setup', help='Configure new backup server') + # init + _init = subparsers.add_parser('init', help='Configure new backup server') # backup backup = subparsers.add_parser( @@ -41,6 +41,12 @@ def parse_args() -> tuple[argparse.Namespace, argparse.ArgumentParser]: '--dry-run', action='store_true', help='Do not create the backup' ) + # list + list_parser = subparsers.add_parser('list', help='List backups') + list_parser.add_argument( + 'target', nargs='?', help='Archive index (int) or name (str)' + ) + return parser.parse_args(), parser @@ -48,14 +54,18 @@ def main() -> int: args, parser = parse_args() match args.command: - case 'setup': - from .setup import run_setup + case 'init': + from .init import run_init - return run_setup(args) + return run_init(args) case 'backup': from .backup import run_backup return run_backup(args) + case 'list': + from .list import run_list + + return run_list(args) case _: parser.print_help() return 1 diff --git a/test/test_backup.py b/test/test_backup.py new file mode 100644 index 0000000..3311520 --- /dev/null +++ b/test/test_backup.py @@ -0,0 +1,300 @@ +import argparse +import pytest +import subprocess + +from unittest import mock + +from locutus.backup import ( + run_backup, + run_borg_create, + run_borg_prune, + run_borg_compact, +) + + +def make_args(config, profile, dry_run=False): + args = argparse.Namespace() + args.config = config + args.profile = profile + args.dry_run = dry_run + return args + + +class DummyCfg: + def __init__(self, includes=[], excludes=[], prune=True, repo='', env=''): + self.includes = includes + self.excludes = excludes + self.prune = prune + self.env = env + self._repo = repo + + def get_repo(self): + return self._repo + + +@pytest.fixture +def dummy_cfg(tmp_path): + return DummyCfg( + includes=['/home/alex/docs', '/home/alex/pics'], + excludes=['*.cache', '/tmp'], + repo='user@host:/repo', + env={'BORG_PASSPHRASE': 'hunter2'}, + ) + + +@pytest.fixture +def dummy_compact_cfg(): + return DummyCfg( + repo="user@host:/repo", + env={"BORG_PASSPHRASE": "hunter2"}, + ) + + +@mock.patch('locutus.backup.LocutusConfig') +@mock.patch('locutus.backup.run_borg_create') +@mock.patch('locutus.backup.run_borg_prune') +@mock.patch('locutus.backup.run_borg_compact') +def test_backup_success(mock_compact, mock_prune, mock_create, mock_config): + mock_create.return_value = True + mock_prune.return_value = True + mock_compact.return_value = True + # Simulate cfg.compact = True + instance = mock_config.return_value + instance.compact = True + + args = make_args('dummy.toml', 'dummy.rc') + assert run_backup(args) == 0 + assert mock_create.called + assert mock_prune.called + assert mock_compact.called + + +@mock.patch('locutus.backup.LocutusConfig') +@mock.patch('locutus.backup.run_borg_create') +@mock.patch('locutus.backup.run_borg_prune') +@mock.patch('locutus.backup.run_borg_compact') +def test_backup_success_no_compact( + mock_compact, mock_prune, mock_create, mock_config +): + mock_create.return_value = True + mock_prune.return_value = True + # Compact should not be called if cfg.compact is False + instance = mock_config.return_value + instance.compact = False + + args = make_args('dummy.toml', 'dummy.rc') + assert run_backup(args) == 0 + assert mock_create.called + assert mock_prune.called + assert not mock_compact.called + + +@mock.patch('locutus.backup.LocutusConfig') +@mock.patch('locutus.backup.run_borg_create') +@mock.patch('locutus.backup.run_borg_prune') +@mock.patch('locutus.backup.run_borg_compact') +def test_backup_fails_on_create( + mock_compact, mock_prune, mock_create, mock_config +): + mock_create.return_value = False + args = make_args('dummy.toml', 'dummy.rc') + assert run_backup(args) == 1 + + +@mock.patch('locutus.backup.LocutusConfig') +@mock.patch('locutus.backup.run_borg_create') +@mock.patch('locutus.backup.run_borg_prune') +@mock.patch('locutus.backup.run_borg_compact') +def test_backup_fails_on_prune( + mock_compact, mock_prune, mock_create, mock_config +): + mock_create.return_value = True + mock_prune.return_value = False + instance = mock_config.return_value + instance.compact = True + args = make_args('dummy.toml', 'dummy.rc') + assert run_backup(args) == 1 + + +@mock.patch('locutus.backup.LocutusConfig') +@mock.patch('locutus.backup.run_borg_create') +@mock.patch('locutus.backup.run_borg_prune') +@mock.patch('locutus.backup.run_borg_compact') +def test_backup_fails_on_compact( + mock_compact, mock_prune, mock_create, mock_config +): + mock_create.return_value = True + mock_prune.return_value = True + mock_compact.return_value = False + instance = mock_config.return_value + instance.compact = True + args = make_args('dummy.toml', 'dummy.rc') + assert run_backup(args) == 1 + + +@mock.patch( + 'locutus.backup.LocutusConfig', side_effect=Exception('config error') +) +def test_backup_config_exception(mock_config): + args = make_args('dummy.toml', 'dummy.rc') + assert run_backup(args) == 1 + + +@mock.patch('subprocess.run') +def test_borg_create_success(mock_run, dummy_cfg): + mock_run.return_value.returncode = 0 + assert run_borg_create(dummy_cfg, dry_run=False) is True + # Check command construction + args = mock_run.call_args[0][0] + assert args[0:2] == ['borg', 'create'] + assert '--dry-run' not in args + assert '--exclude' in args + assert any('/home/alex/docs' in a or '/home/alex/pics' in a for a in args) + # Check env contains BORG_PASSPHRASE + assert mock_run.call_args[1]['env']['BORG_PASSPHRASE'] == 'hunter2' + + +@mock.patch('subprocess.run') +def test_borg_create_success_dry_run(mock_run, dummy_cfg): + mock_run.return_value.returncode = 0 + assert run_borg_create(dummy_cfg, dry_run=True) is True + args = mock_run.call_args[0][0] + assert '--dry-run' in args + + +@mock.patch('subprocess.run') +def test_borg_create_no_repo(mock_run, dummy_cfg): + dummy_cfg._repo = None + assert run_borg_create(dummy_cfg, dry_run=False) is False + assert not mock_run.called + + +@mock.patch( + 'subprocess.run', + side_effect=subprocess.CalledProcessError(1, ['borg', 'create']), +) +def test_borg_create_calledprocesserror(mock_run, dummy_cfg, capsys): + assert run_borg_create(dummy_cfg, dry_run=False) is False + out = capsys.readouterr().err + assert 'borg create failed' in out + + +@mock.patch( + 'subprocess.run', side_effect=mock.Mock(side_effect=OSError('fail')) +) +def test_borg_create_raises_other_exception(mock_run, dummy_cfg, capsys): + # Simulate unexpected subprocess failure (should still be caught) + with mock.patch('subprocess.CalledProcessError', OSError): + assert run_borg_create(dummy_cfg, dry_run=False) is False + out = capsys.readouterr().err + assert 'borg create failed' in out + + +@pytest.fixture +def dummy_prune_cfg(): + return DummyCfg( + repo='user@host:/repo', + env={'BORG_PASSPHRASE': 'hunter2'}, + prune={'keep_last': 3, 'keep_daily': 7}, + ) + + +@mock.patch('subprocess.run') +def test_prune_success(mock_run, dummy_prune_cfg): + mock_run.return_value.returncode = 0 + assert run_borg_prune(dummy_prune_cfg, dry_run=False) is True + args = mock_run.call_args[0][0] + assert 'borg' in args + assert 'prune' in args + assert '--keep-last' in args + assert '3' in args + assert '--keep-daily' in args + assert '7' in args + assert '--dry-run' not in args + + +@mock.patch('subprocess.run') +def test_prune_success_dry_run(mock_run, dummy_prune_cfg): + mock_run.return_value.returncode = 0 + assert run_borg_prune(dummy_prune_cfg, dry_run=True) is True + args = mock_run.call_args[0][0] + assert '--dry-run' in args + + +def test_prune_no_repo(): + cfg = DummyCfg(repo=None, env={}, prune={}) + assert run_borg_prune(cfg, dry_run=False) is False + + +@mock.patch('subprocess.run', side_effect=Exception('fail')) +def test_prune_subprocess_exception(mock_run, dummy_prune_cfg, capsys): + assert run_borg_prune(dummy_prune_cfg, dry_run=False) is False + err = capsys.readouterr().err + assert 'borg prune failed' in err + + +@mock.patch( + 'subprocess.run', side_effect=mock.Mock(side_effect=OSError('fail')) +) +def test_prune_raises_other_exception(mock_run, dummy_prune_cfg, capsys): + with mock.patch('subprocess.CalledProcessError', OSError): + assert run_borg_prune(dummy_prune_cfg, dry_run=False) is False + err = capsys.readouterr().err + assert 'borg prune failed' in err + + +@mock.patch( + 'subprocess.run', + side_effect=mock.Mock( + side_effect=pytest.importorskip('subprocess').CalledProcessError( + 1, ['borg', 'prune'] + ) + ), +) +def test_prune_calledprocesserror(mock_run, dummy_prune_cfg, capsys): + assert run_borg_prune(dummy_prune_cfg, dry_run=False) is False + err = capsys.readouterr().err + assert 'borg prune failed' in err + + +@mock.patch('subprocess.run') +def test_compact_success(mock_run, dummy_compact_cfg): + mock_run.return_value.returncode = 0 + assert run_borg_compact(dummy_compact_cfg, dry_run=False) is True + args = mock_run.call_args[0][0] + assert args[:2] == ['borg', 'compact'] + assert '--verbose' in args + + +@mock.patch('subprocess.run') +def test_compact_no_repo(mock_run): + cfg = DummyCfg(repo=None, env={}) + assert run_borg_compact(cfg, dry_run=False) is False + assert not mock_run.called + + +def test_compact_dry_run(dummy_compact_cfg, capsys): + assert run_borg_compact(dummy_compact_cfg, dry_run=True) is True + out = capsys.readouterr().out + assert '(dry-run)' in out + + +@mock.patch('subprocess.run', side_effect=Exception('fail')) +def test_compact_subprocess_exception(mock_run, dummy_compact_cfg, capsys): + assert run_borg_compact(dummy_compact_cfg, dry_run=False) is False + err = capsys.readouterr().err + assert 'borg compact failed' in err + + +@mock.patch( + 'subprocess.run', + side_effect=mock.Mock( + side_effect=pytest.importorskip('subprocess').CalledProcessError( + 1, ['borg', 'compact'] + ) + ), +) +def test_compact_calledprocesserror(mock_run, dummy_compact_cfg, capsys): + assert run_borg_compact(dummy_compact_cfg, dry_run=False) is False + err = capsys.readouterr().err + assert 'borg compact failed' in err diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000..38c20b0 --- /dev/null +++ b/test/test_config.py @@ -0,0 +1,102 @@ + +import pytest + +from locutus import LocutusConfig + + +def make_toml(path: str, text: str) -> None: + with open(path, 'wb') as f: + f.write(text.encode()) + + +def make_rc(path: str, text: str) -> None: + with open(path, 'w') as f: + f.write(text) + + +@pytest.fixture +def temp_config_files(tmp_path): + # Make temp locutus.toml + toml_content = """ +[includes] +paths = ["/test/inc1", "/test/inc2"] + +[excludes] +paths = ["*.cache"] + +[prune] +keep_last = 2 +keep_daily = 3 + +[compact] +enabled = true +""" + toml_path = tmp_path / 'locutus.toml' + make_toml(str(toml_path), toml_content) + + # Make temp locutus.rc + rc_content = ( + 'export BORG_REPO="/tmp/repo"\nexport BORG_PASSPHRASE="hunter2"\n' + ) + rc_path = tmp_path / 'locutus.rc' + make_rc(str(rc_path), rc_content) + + return str(toml_path), str(rc_path) + + +def test_config_loads_correctly(temp_config_files): + toml_path, rc_path = temp_config_files + cfg = LocutusConfig(toml_path, rc_path) + + assert cfg.toml_path == toml_path + assert cfg.rc_path == rc_path + + assert cfg.includes == ['/test/inc1', '/test/inc2'] + assert cfg.excludes == ['*.cache'] + assert cfg.prune['keep_last'] == 2 + assert cfg.prune['keep_daily'] == 3 + assert cfg.compact is True + + assert cfg.get_repo() == '/tmp/repo' + assert cfg.get_passphrase() == 'hunter2' + + +def test_missing_rc(tmp_path): + # Valid toml, missing rc + toml_content = """ +[includes] +paths = ["/test/inc1"] +""" + toml_path = tmp_path / 'locutus.toml' + make_toml(str(toml_path), toml_content) + rc_path = tmp_path / 'missing.rc' + + cfg = LocutusConfig(str(toml_path), str(rc_path)) + + assert cfg.includes == ['/test/inc1'] + assert cfg.get_repo() is None + assert cfg.get_passphrase() is None + + +def test_missing_toml(tmp_path): + # Missing toml + toml_path = tmp_path / 'missing.toml' + rc_path = tmp_path / 'locutus.rc' + make_rc(str(rc_path), 'export BORG_REPO="/tmp/repo"\n') + with pytest.raises(FileNotFoundError): + LocutusConfig(str(toml_path), str(rc_path)) + + +def test_partial_rc(tmp_path): + # Valid toml, partial rc (no passphrase) + toml_content = """ +[includes] +paths = ["/home/test"] +""" + toml_path = tmp_path / 'locutus.toml' + make_toml(str(toml_path), toml_content) + rc_path = tmp_path / 'locutus.rc' + make_rc(str(rc_path), 'export BORG_REPO="/somewhere/repo"\n') + cfg = LocutusConfig(str(toml_path), str(rc_path)) + assert cfg.get_repo() == '/somewhere/repo' + assert cfg.get_passphrase() is None diff --git a/test/test_init.py b/test/test_init.py new file mode 100644 index 0000000..62c340d --- /dev/null +++ b/test/test_init.py @@ -0,0 +1,169 @@ +import argparse +import subprocess +from unittest import mock + +from locutus.init import run_init + + +def make_args(profile_path): + args = argparse.Namespace() + args.profile = profile_path + return args + + +@mock.patch('builtins.input') +@mock.patch('getpass.getpass') +@mock.patch('subprocess.run') +def test_run_init_success_no_passphrase( + mock_subproc, mock_getpass, mock_input, tmp_path +): + # Simulate user enters repo path and empty passphrase + mock_input.return_value = '/test/repo' + mock_getpass.return_value = '' + mock_subproc.return_value = mock.Mock(returncode=0) + + rc_path = tmp_path / 'test.rc' + args = make_args(str(rc_path)) + result = run_init(args) + + assert result == 0 + with open(rc_path) as f: + content = f.read() + assert 'export BORG_REPO="/test/repo"' in content + assert 'BORG_PASSPHRASE' not in content + + mock_subproc.assert_called_once() + call_args = mock_subproc.call_args[0][0] + assert call_args == ['borg', 'init', '--encryption=none', '/test/repo'] + + +@mock.patch('builtins.input') +@mock.patch('getpass.getpass') +@mock.patch('subprocess.run') +def test_run_init_success_with_passphrase( + mock_subproc, mock_getpass, mock_input, tmp_path +): + mock_input.return_value = '/secure/repo' + # First call is passphrase, second is confirm, both match + mock_getpass.side_effect = ['hunter2', 'hunter2'] + mock_subproc.return_value = mock.Mock(returncode=0) + + rc_path = tmp_path / 'secure.rc' + args = make_args(str(rc_path)) + result = run_init(args) + + assert result == 0 + with open(rc_path) as f: + content = f.read() + assert 'export BORG_REPO="/secure/repo"' in content + assert 'export BORG_PASSPHRASE="hunter2"' in content + + mock_subproc.assert_called_once() + call_args = mock_subproc.call_args[0][0] + assert call_args == ['borg', 'init', '--encryption=repokey', '/secure/repo'] + + +@mock.patch('builtins.input') +def test_run_init_empty_repo_aborts(mock_input, tmp_path): + mock_input.return_value = '' + args = make_args(str(tmp_path / 'fail.rc')) + result = run_init(args) + assert result == 1 + + +@mock.patch('builtins.input') +@mock.patch('getpass.getpass') +def test_run_init_passphrase_mismatch(mock_getpass, mock_input, tmp_path): + mock_input.return_value = '/fail/repo' + mock_getpass.side_effect = [ + 'pass1', + 'pass2', + '', + ] # Third call: user gives up + args = make_args(str(tmp_path / 'fail2.rc')) + with mock.patch('subprocess.run'): + result = run_init(args) + assert result == 0 # Exits with no passphrase (encryption=none) + with open(args.profile) as f: + content = f.read() + assert 'BORG_PASSPHRASE' not in content + + +@mock.patch('builtins.input') +@mock.patch('getpass.getpass') +@mock.patch('subprocess.run', side_effect=Exception('init failed')) +def test_run_init_subprocess_failure( + mock_subproc, mock_getpass, mock_input, tmp_path +): + mock_input.return_value = '/failinit/repo' + mock_getpass.return_value = '' + args = make_args(str(tmp_path / 'failinit.rc')) + result = run_init(args) + assert result == 1 + + +@mock.patch('builtins.input') +@mock.patch('getpass.getpass') +@mock.patch('os.makedirs', side_effect=Exception('cannot create dir')) +def test_run_init_makedirs_exception( + mock_makedirs, mock_getpass, mock_input, tmp_path +): + mock_input.return_value = '/faildir/repo' + mock_getpass.return_value = '' + args = make_args(str(tmp_path / 'faildir.rc')) + with mock.patch('subprocess.run'): + result = run_init(args) + assert result == 1 + + +@mock.patch('builtins.input') +@mock.patch('getpass.getpass') +@mock.patch('os.makedirs') +@mock.patch('builtins.open', side_effect=Exception('cannot open file')) +def test_run_init_open_exception( + mock_open, mock_makedirs, mock_getpass, mock_input, tmp_path +): + mock_input.return_value = '/failfile/repo' + mock_getpass.return_value = '' + args = make_args(str(tmp_path / 'failfile.rc')) + with mock.patch('subprocess.run'): + result = run_init(args) + assert result == 1 + + +@mock.patch('builtins.input') +@mock.patch('getpass.getpass') +@mock.patch('os.makedirs') +@mock.patch('builtins.open') +@mock.patch('subprocess.run', side_effect=Exception('unexpected')) +def test_run_init_unexpected_exception( + mock_subproc, mock_open, mock_makedirs, mock_getpass, mock_input, tmp_path +): + mock_input.return_value = '/unexpected/repo' + mock_getpass.return_value = '' + args = make_args(str(tmp_path / 'unexpected.rc')) + result = run_init(args) + assert result == 1 + + +@mock.patch('builtins.input', side_effect=KeyboardInterrupt) +def test_run_init_keyboard_interrupt(mock_input, tmp_path): + args = make_args(str(tmp_path / 'kbint.rc')) + result = run_init(args) + assert result == 1 + + +@mock.patch('builtins.input') +@mock.patch('getpass.getpass') +@mock.patch( + 'subprocess.run', + side_effect=subprocess.CalledProcessError(1, ['borg', 'init']), +) +def test_run_init_calledprocesserror( + mock_subproc, mock_getpass, mock_input, tmp_path +): + mock_input.return_value = '/failsubproc/repo' + mock_getpass.return_value = '' + args = make_args(str(tmp_path / 'failsubproc.rc')) + result = run_init(args) + assert result == 1 diff --git a/test/test_list.py b/test/test_list.py new file mode 100644 index 0000000..0f5efca --- /dev/null +++ b/test/test_list.py @@ -0,0 +1,89 @@ +import argparse +from unittest import mock +import pytest + +from locutus.list import run_list + + +def make_args(config, profile): + args = argparse.Namespace() + args.config = config + args.profile = profile + return args + + +@mock.patch('locutus.list.LocutusConfig') +@mock.patch('subprocess.run') +def test_list_success(mock_subproc, mock_config, capsys): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + + # Simulate borg list output with three archives + mock_subproc.return_value.stdout = ( + 'auto-2024-06-22T123456\nauto-2024-06-21T101112\nfoo\n' + ) + mock_subproc.return_value.returncode = 0 + + args = make_args('dummy.toml', 'dummy.rc') + rc = run_list(args) + assert rc == 0 + out = capsys.readouterr().out + assert ' 0: auto-2024-06-22T123456' in out + assert ' 1: auto-2024-06-21T101112' in out + assert ' 2: foo' in out + + +@mock.patch('locutus.list.LocutusConfig') +@mock.patch('subprocess.run') +def test_list_empty(mock_subproc, mock_config, capsys): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + + mock_subproc.return_value.stdout = '' + mock_subproc.return_value.returncode = 0 + + args = make_args('dummy.toml', 'dummy.rc') + rc = run_list(args) + assert rc == 0 + out = capsys.readouterr().out + assert 'No archives found' in out + + +@mock.patch('locutus.list.LocutusConfig') +def test_list_no_repo(mock_config, capsys): + mock_config.return_value.get_repo.return_value = None + mock_config.return_value.env = {} + args = make_args('dummy.toml', 'dummy.rc') + rc = run_list(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'No BORG_REPO configured' in err + + +@mock.patch('locutus.list.LocutusConfig') +@mock.patch('subprocess.run', side_effect=Exception('fail')) +def test_list_unexpected_exception(mock_subproc, mock_config, capsys): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + args = make_args('dummy.toml', 'dummy.rc') + rc = run_list(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'borg list failed' in err + + +@mock.patch('locutus.list.LocutusConfig') +@mock.patch( + 'subprocess.run', + side_effect=pytest.importorskip('subprocess').CalledProcessError( + 1, ['borg', 'list'] + ), +) +def test_list_calledprocesserror(mock_subproc, mock_config, capsys): + mock_config.return_value.get_repo.return_value = '/repo' + mock_config.return_value.env = {} + args = make_args('dummy.toml', 'dummy.rc') + rc = run_list(args) + assert rc == 1 + err = capsys.readouterr().err + assert 'borg list failed' in err diff --git a/uv.lock b/uv.lock index 234b109..1a16d58 100644 --- a/uv.lock +++ b/uv.lock @@ -1,8 +1,194 @@ version = 1 revision = 2 -requires-python = ">=3.10" +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +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.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload_time = "2025-06-13T13:02:28.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146, upload_time = "2025-06-13T13:00:48.496Z" }, + { url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536, upload_time = "2025-06-13T13:00:51.535Z" }, + { url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092, upload_time = "2025-06-13T13:00:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806, upload_time = "2025-06-13T13:00:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610, upload_time = "2025-06-13T13:00:56.932Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257, upload_time = "2025-06-13T13:00:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309, upload_time = "2025-06-13T13:00:59.836Z" }, + { url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898, upload_time = "2025-06-13T13:01:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561, upload_time = "2025-06-13T13:01:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493, upload_time = "2025-06-13T13:01:05.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869, upload_time = "2025-06-13T13:01:09.345Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload_time = "2025-06-13T13:01:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload_time = "2025-06-13T13:01:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload_time = "2025-06-13T13:01:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload_time = "2025-06-13T13:01:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload_time = "2025-06-13T13:01:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload_time = "2025-06-13T13:01:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload_time = "2025-06-13T13:01:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload_time = "2025-06-13T13:01:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload_time = "2025-06-13T13:01:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload_time = "2025-06-13T13:01:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload_time = "2025-06-13T13:01:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload_time = "2025-06-13T13:01:30.909Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload_time = "2025-06-13T13:01:32.256Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload_time = "2025-06-13T13:01:33.948Z" }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload_time = "2025-06-13T13:01:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload_time = "2025-06-13T13:01:36.712Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload_time = "2025-06-13T13:01:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload_time = "2025-06-13T13:01:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload_time = "2025-06-13T13:01:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload_time = "2025-06-13T13:01:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload_time = "2025-06-13T13:01:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload_time = "2025-06-13T13:01:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload_time = "2025-06-13T13:01:48.554Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload_time = "2025-06-13T13:01:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload_time = "2025-06-13T13:01:51.314Z" }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload_time = "2025-06-13T13:01:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload_time = "2025-06-13T13:01:56.769Z" }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload_time = "2025-06-13T13:01:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload_time = "2025-06-13T13:01:59.645Z" }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload_time = "2025-06-13T13:02:01.37Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload_time = "2025-06-13T13:02:02.905Z" }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload_time = "2025-06-13T13:02:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload_time = "2025-06-13T13:02:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009, upload_time = "2025-06-13T13:02:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload_time = "2025-06-13T13:02:27.173Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" }, +] [[package]] name = "locutus" version = "0.1.0" source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" }, +] + +[[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 = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +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 = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload_time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload_time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload_time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload_time = "2025-06-12T10:47:45.932Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload_time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload_time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload_time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload_time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload_time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload_time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload_time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload_time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload_time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload_time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload_time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload_time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload_time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload_time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload_time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload_time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload_time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload_time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload_time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload_time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload_time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload_time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload_time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload_time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload_time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload_time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload_time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload_time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload_time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload_time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload_time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload_time = "2024-11-27T22:38:35.385Z" }, +]