Add some functionality

This commit is contained in:
Alexander Wainwright
2025-06-22 22:07:45 +10:00
parent 62ca1bfab9
commit c716178b00
13 changed files with 1253 additions and 12 deletions

56
locutus.toml Normal file
View File

@@ -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

View File

@@ -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"

View File

@@ -1,5 +1,7 @@
"""locutus - A simple borg wrapper"""
from locutus.config import LocutusConfig
__version__ = '0.1.0'
__author__ = 'Alexander Wainwright <code@figtree.dev>'
__all__: list[str] = []
__all__: list[str] = ['LocutusConfig']

View File

@@ -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

71
src/locutus/config.py Normal file
View File

@@ -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')

View File

@@ -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.

81
src/locutus/list.py Normal file
View File

@@ -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

View File

@@ -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

300
test/test_backup.py Normal file
View File

@@ -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

102
test/test_config.py Normal file
View File

@@ -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

169
test/test_init.py Normal file
View File

@@ -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

89
test/test_list.py Normal file
View File

@@ -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

188
uv.lock generated
View File

@@ -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" },
]