feat: initial commit
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -0,0 +1,55 @@
|
||||
[settings]
|
||||
backup-base-dir = "/mnt/snap1"
|
||||
|
||||
[settings.globalvars]
|
||||
BTRVOL = "snap1"
|
||||
CONF_BASE = "/home/osmium/backup"
|
||||
|
||||
|
||||
[repo.cloud]
|
||||
kind = "s3"
|
||||
# restic by default expects path-style bucket URL
|
||||
url = "https://s3.some-storage.localdomain//my-bucket/restic-backups/"
|
||||
region = "us-east-1"
|
||||
access-key-file = "$CONF_BASE/.s3/access.key"
|
||||
secret-key-file = "$CONF_BASE/.s3/secret.key"
|
||||
repo-key-file = "$CONF_BASE/repo-s3.password"
|
||||
|
||||
[repo.localdir]
|
||||
kind = "local"
|
||||
path = "$CONF_BASE/local-repo"
|
||||
restic-key-file = "$CONF_BASE/repo-local.password"
|
||||
default = true
|
||||
|
||||
|
||||
[[hooks.before-all]]
|
||||
kind = "btrfs"
|
||||
action = "snapshot"
|
||||
subvolume = "/home"
|
||||
snapshot = "$BDIR"
|
||||
|
||||
[[hooks.before-all]]
|
||||
kind = "command"
|
||||
command = "du -sh $CONF_BASE/local-repo"
|
||||
|
||||
[[hooks.after-all]]
|
||||
kind = "btrfs"
|
||||
action = "delete"
|
||||
subvolume = "$BDIR"
|
||||
|
||||
|
||||
[profiles.documents]
|
||||
paths = ["$BDIR/Documents"]
|
||||
|
||||
[profiles.code]
|
||||
paths = ["$BDIR/code"]
|
||||
exclude-paths = [
|
||||
".cache",
|
||||
"venv",
|
||||
".venv",
|
||||
".terragrunt-cache",
|
||||
"node_modules"
|
||||
]
|
||||
|
||||
[profiles.flatpak-list]
|
||||
command = "flatpak list"
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='main',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
[project]
|
||||
name = "lohup"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"click>=8.2.1",
|
||||
"humanize>=4.13.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pyinstaller>=6.15.0",
|
||||
"pytest>=8.4.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
lohup = "lohup.cli:cli"
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
@@ -0,0 +1 @@
|
||||
from lohup.app import Lohup
|
||||
@@ -0,0 +1,110 @@
|
||||
import subprocess as procs
|
||||
|
||||
from lohup import config
|
||||
from lohup.logger import BasicLogger, LogLevel
|
||||
from lohup.restic import Restic
|
||||
|
||||
|
||||
class Lohup:
|
||||
def __init__(self, config_path: str | None, logger=None):
|
||||
self._config_path = config_path or "lohup.toml"
|
||||
self.config = None
|
||||
self.log = logger or BasicLogger(level=LogLevel.INFO)
|
||||
|
||||
def load(self):
|
||||
self.config = config.TomlConfig.from_file(self._config_path, logger=self.log)
|
||||
|
||||
def invoke_restic(self, repo: str, args: tuple[str, ...]):
|
||||
spec = self.config.repos.get(repo)
|
||||
if spec is None:
|
||||
raise KeyError(f"Unknown repo: {repo}")
|
||||
restic = Restic(spec, self.log)
|
||||
try:
|
||||
restic.run(args)
|
||||
except procs.CalledProcessError as e:
|
||||
self.log.error(e)
|
||||
|
||||
def _exechooks(self, hooks: list):
|
||||
for h in hooks:
|
||||
match h:
|
||||
case config.BtrfsHook():
|
||||
self._btrfs(h)
|
||||
case config.CommandHook():
|
||||
procs.check_call(h.command.split())
|
||||
|
||||
@staticmethod
|
||||
def _btrfs(spec: config.BtrfsHook):
|
||||
cmd = ["btrfs"]
|
||||
if spec.action == "snapshot":
|
||||
cmd += ["subvolume", "snapshot", spec.subvolume, spec.snapshot]
|
||||
elif spec.action == "delete":
|
||||
cmd += ["subvolume", "delete", spec.subvolume]
|
||||
else:
|
||||
raise ValueError(f"Unknown btrfs action: {spec.action}")
|
||||
procs.check_call(cmd)
|
||||
|
||||
def _repo_for(self, profile: config.Profile):
|
||||
default_list = list(filter(lambda x: x.default, self.config.repos.values()))
|
||||
default = default_list.pop() if default_list else None
|
||||
if profile.repo is not None:
|
||||
if repo := self.config.repos.get(profile.repo):
|
||||
return repo
|
||||
else:
|
||||
raise KeyError(f"No repo attached to profile: {profile.name!r}")
|
||||
if default is None:
|
||||
raise KeyError(f"No repo attached to profile: {profile.name!r}")
|
||||
return default
|
||||
|
||||
def backup(self, profile: str):
|
||||
spec = self._profile_for(profile)
|
||||
repo = self._repo_for(spec)
|
||||
restic = Restic(repo, log=self.log)
|
||||
self._exechooks(self.config.hooks.before_all)
|
||||
try:
|
||||
self._invoke_profile(restic, profile=profile)
|
||||
finally:
|
||||
self._exechooks(self.config.hooks.after_all)
|
||||
self.log.info("Finished!")
|
||||
|
||||
def backup_all(self):
|
||||
restics = {}
|
||||
for name, spec in self.config.profiles.items():
|
||||
restic = Restic(self._repo_for(spec), log=self.log)
|
||||
restics[name] = restic
|
||||
|
||||
self._exechooks(self.config.hooks.before_all)
|
||||
try:
|
||||
for name, spec in self.config.profiles.items():
|
||||
restic = restics[name]
|
||||
self._invoke_profile(restic, profile=spec)
|
||||
finally:
|
||||
self._exechooks(self.config.hooks.after_all)
|
||||
self.log.info("Finished!")
|
||||
|
||||
def snapshots(self, repo: str, is_json=False):
|
||||
spec = self.config.repos.get(repo)
|
||||
if spec is None:
|
||||
raise KeyError(f"Unknown repo: {repo}")
|
||||
restic = Restic(spec, log=self.log)
|
||||
format = "json" if is_json else "text"
|
||||
return restic.snapshots(format=format)
|
||||
|
||||
def _invoke_profile(self, restic: Restic, profile: config.Profile):
|
||||
args = ["backup", "--tag", profile.name]
|
||||
match profile:
|
||||
case config.PathsProfile():
|
||||
args.append("--read-concurrency=6")
|
||||
for pth in profile.exclude_paths:
|
||||
args.extend(["-e", pth])
|
||||
args.extend(profile.paths)
|
||||
restic.run(args)
|
||||
case config.CommandProfile():
|
||||
args.append("--stdin")
|
||||
restic.pipe_stdout(args, src_cmd=profile.command.split())
|
||||
self.log.info("Backup created successfully.")
|
||||
|
||||
def _profile_for(self, name: str):
|
||||
result = self.config.profiles.get(name)
|
||||
if not result:
|
||||
raise KeyError(f"Unknown backup profile: {name!r}")
|
||||
return result
|
||||
@@ -0,0 +1,101 @@
|
||||
import click
|
||||
import humanize
|
||||
from datetime import datetime
|
||||
|
||||
from lohup.app import Lohup
|
||||
from lohup import logger
|
||||
from lohup.config import ConfigError
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option("--config", envvar="LOHUP_CONFIG", default="lohup.toml")
|
||||
@click.pass_context
|
||||
def cli(ctx, config):
|
||||
log = logger.CliLogger(level=logger.LogLevel.DEBUG)
|
||||
ctx.obj = Lohup(config_path=config, logger=log)
|
||||
try:
|
||||
ctx.obj.load()
|
||||
except ConfigError as e:
|
||||
log.error(e)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--repo", help="Lohup repository name", required=True)
|
||||
@click.argument("args", nargs=-1)
|
||||
@click.pass_obj
|
||||
def restic(obj: Lohup, repo, args: tuple[str, ...]):
|
||||
"""
|
||||
Pass command to restic
|
||||
"""
|
||||
obj.invoke_restic(repo, args)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("profile", required=True, nargs=1)
|
||||
@click.pass_obj
|
||||
def backup(obj: Lohup, profile: str):
|
||||
obj.backup(profile=profile)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_obj
|
||||
def backup_all(obj: Lohup):
|
||||
obj.backup_all()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--repo", help="Lohup repository name", required=True)
|
||||
@click.option("--raw", "raw_mode", is_flag=True)
|
||||
@click.pass_obj
|
||||
def snapshots(obj: Lohup, repo: str, raw_mode: bool):
|
||||
result = obj.snapshots(repo=repo, is_json=not raw_mode)
|
||||
if raw_mode:
|
||||
return click.echo(result, nl=False)
|
||||
# time, parent?, tree, paths, hostname, uid, gid
|
||||
# tags, version, summary, id, short_id
|
||||
snapshots = []
|
||||
for snap in result:
|
||||
name = "-".join(snap["tags"])
|
||||
dt = datetime.fromisoformat(snap["time"])
|
||||
delta = humanize.naturaltime(datetime.now())
|
||||
summary = snap["summary"]
|
||||
end = datetime.fromisoformat(summary["backup_end"])
|
||||
changes = summary["files_new"] + summary["files_changed"]
|
||||
snapshots.append(
|
||||
dict(
|
||||
id=snap["short_id"],
|
||||
name=name,
|
||||
when_started=dt,
|
||||
started_human=delta,
|
||||
hostname=snap["hostname"],
|
||||
duration=end - dt,
|
||||
changes=changes,
|
||||
changes_ratio=changes / summary["total_files_processed"],
|
||||
size_comp=summary["data_added_packed"],
|
||||
size_raw=summary["data_added"],
|
||||
processed=summary["total_bytes_processed"],
|
||||
)
|
||||
)
|
||||
snapshots.sort(key=lambda x: x["when_started"])
|
||||
for snap in snapshots:
|
||||
name = click.style(snap["name"], fg="blue", italic=True)
|
||||
click.echo(f"Snapshot {snap['id']} ({name}):")
|
||||
started = humanize.naturaltime(snap["when_started"])
|
||||
text = click.style(started, fg="blue")
|
||||
took = humanize.naturaldelta(snap["duration"])
|
||||
click.echo(f"\tStarted: {text} (took {took})")
|
||||
ratio = snap["changes_ratio"] * 100
|
||||
ratio = click.style(f"{ratio:.1f}%", fg="blue")
|
||||
click.echo(f"\tFiles changed (since previous): {snap['changes']} ({ratio})")
|
||||
size = humanize.naturalsize(snap["size_raw"], binary=True)
|
||||
compressed = humanize.naturalsize(snap["size_comp"], binary=True)
|
||||
ratio = snap["size_raw"] / snap["processed"]
|
||||
ratio = click.style(f"{ratio:.1f}%", fg="blue")
|
||||
click.echo(f"\tDiff size: {compressed}, unpacked: {size} ({ratio})")
|
||||
click.echo(f"\tHost: {snap['hostname']}")
|
||||
click.echo()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,274 @@
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
from lohup.util import catch_errors, ensure_exists, CatcherError, Masked
|
||||
from lohup.expander import VarExpander
|
||||
from lohup.logger import LogLevel
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
backup_base_dir: Path
|
||||
globalvars: dict[str, str]
|
||||
|
||||
@staticmethod
|
||||
def load(conf: dict):
|
||||
with catch_errors() as catch:
|
||||
basedir = conf.get("backup-base-dir")
|
||||
basepath = Path(basedir) if basedir else Path.cwd()
|
||||
settings = Settings(
|
||||
backup_base_dir=basepath, globalvars=conf.get("globalvars") or {}
|
||||
)
|
||||
settings.globalvars["BDIR"] = str(basepath)
|
||||
for key, value in settings.globalvars.items():
|
||||
if "$" in value:
|
||||
catch.error(
|
||||
f"variable {key!r}: nested references in globals are not allowed"
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalRepository:
|
||||
name: str
|
||||
path: str
|
||||
repo_key_file: Masked
|
||||
default: bool
|
||||
|
||||
@staticmethod
|
||||
def load(name: str, conf: dict, expander: VarExpander):
|
||||
with catch_errors() as catcher:
|
||||
repo = LocalRepository(
|
||||
name=name,
|
||||
path=expander.expand(conf.get("path")),
|
||||
repo_key_file=Masked(expander.expand(conf.get("repo-key-file", ""))),
|
||||
default=conf.get("default", False),
|
||||
)
|
||||
if not repo.path:
|
||||
catcher.error("field 'path' not set")
|
||||
if msg := ensure_exists(repo.repo_key_file, field="repo-key-file"):
|
||||
catcher.error(msg)
|
||||
return repo
|
||||
|
||||
|
||||
@dataclass
|
||||
class S3Repository:
|
||||
name: str
|
||||
url: str
|
||||
region: str
|
||||
access_key_file: Masked
|
||||
secret_key_file: Masked
|
||||
repo_key_file: Masked
|
||||
default: bool
|
||||
|
||||
@staticmethod
|
||||
def load(name: str, conf: dict, expander: VarExpander):
|
||||
repo = S3Repository(
|
||||
name=name,
|
||||
url=expander.expand(conf.get("url", "")),
|
||||
region=conf.get("region", ""),
|
||||
access_key_file=Masked(expander.expand(conf.get("access-key-file", ""))),
|
||||
secret_key_file=Masked(expander.expand(conf.get("secret-key-file", ""))),
|
||||
repo_key_file=Masked(expander.expand(conf.get("repo-key-file", ""))),
|
||||
default=conf.get("default", False),
|
||||
)
|
||||
with catch_errors() as catcher:
|
||||
if not repo.url:
|
||||
catcher.error("field 'url': not set")
|
||||
if value := repo.access_key_file.value:
|
||||
if msg := ensure_exists(value, field="access-key-file"):
|
||||
catcher.error(msg)
|
||||
if value := repo.secret_key_file.value:
|
||||
if msg := ensure_exists(value, field="secret-key-file"):
|
||||
catcher.error(msg)
|
||||
if msg := ensure_exists(repo.repo_key_file, field="repo-key-file"):
|
||||
catcher.error(msg)
|
||||
return repo
|
||||
|
||||
|
||||
Repository = LocalRepository | S3Repository
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandHook:
|
||||
command: str
|
||||
hook_kind: str
|
||||
|
||||
def load(conf: dict, kind: str, expander: VarExpander):
|
||||
return CommandHook(command=expander.expand(conf.get("command")), hook_kind=kind)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BtrfsHook:
|
||||
action: str
|
||||
subvolume: str
|
||||
snapshot: str | None
|
||||
hook_kind: str
|
||||
|
||||
@staticmethod
|
||||
def load(conf: dict, kind: str, expander: VarExpander):
|
||||
return BtrfsHook(
|
||||
action=conf.get("action"),
|
||||
subvolume=expander.expand(conf.get("subvolume")),
|
||||
snapshot=expander.expand(conf.get("snapshot")),
|
||||
hook_kind=kind,
|
||||
)
|
||||
|
||||
|
||||
Hook = CommandHook | BtrfsHook
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookSet:
|
||||
before_all: list[Hook]
|
||||
after_all: list[Hook]
|
||||
|
||||
@staticmethod
|
||||
def load(conf: dict, expander: VarExpander):
|
||||
with catch_errors() as catcher:
|
||||
before_all = []
|
||||
after_all = []
|
||||
for hook in conf.get("before-all", []):
|
||||
match kind := hook.get("kind"):
|
||||
case "command":
|
||||
catcher.catch(
|
||||
lambda: before_all.append(
|
||||
CommandHook.load(hook, kind=kind, expander=expander)
|
||||
),
|
||||
prefix="before-all command:",
|
||||
)
|
||||
case "btrfs":
|
||||
catcher.catch(
|
||||
lambda: before_all.append(
|
||||
BtrfsHook.load(hook, kind=kind, expander=expander)
|
||||
),
|
||||
prefix="before-all btrfs:",
|
||||
)
|
||||
case _:
|
||||
catcher.error(f"Unsupported before-all: {kind}")
|
||||
for hook in conf.get("after-all", []):
|
||||
match kind := hook.get("kind"):
|
||||
case "command":
|
||||
catcher.catch(
|
||||
lambda: after_all.append(
|
||||
CommandHook.load(hook, kind=kind, expander=expander)
|
||||
),
|
||||
prefix="after-all command:",
|
||||
)
|
||||
case "btrfs":
|
||||
catcher.catch(
|
||||
lambda: after_all.append(
|
||||
BtrfsHook.load(hook, kind=kind, expander=expander)
|
||||
),
|
||||
prefix="after-all btrfs:",
|
||||
)
|
||||
case _:
|
||||
catcher.error(f"after-all: unknown kind {kind!r}")
|
||||
return HookSet(before_all=before_all, after_all=after_all)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PathsProfile:
|
||||
name: str
|
||||
repo: str | None
|
||||
paths: list[str]
|
||||
exclude_paths: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandProfile:
|
||||
name: str
|
||||
repo: str | None
|
||||
command: str
|
||||
|
||||
|
||||
Profile = PathsProfile | CommandProfile
|
||||
|
||||
|
||||
@dataclass
|
||||
class TomlConfig:
|
||||
settings: Settings
|
||||
repos: dict[str, Repository]
|
||||
hooks: HookSet
|
||||
expander: VarExpander
|
||||
profiles: dict[str, Profile]
|
||||
|
||||
@staticmethod
|
||||
def from_file(name: str, logger):
|
||||
path = Path(name)
|
||||
try:
|
||||
with catch_errors(failfast=logger.accepts(LogLevel.VERBOSE)) as catcher:
|
||||
return TomlConfig._ffile_impl(name, path, catcher)
|
||||
except CatcherError as e:
|
||||
raise ConfigError(f"Failed to parse TOML config") from e
|
||||
|
||||
@staticmethod
|
||||
def _ffile_impl(name: str, path: Path, catcher):
|
||||
if not path.exists():
|
||||
catcher.error(f"Config {name!r} does not exist")
|
||||
return
|
||||
with path.open("rb") as fp:
|
||||
conf: dict = tomllib.load(fp)
|
||||
settings = Settings.load({})
|
||||
if value := conf.get("settings"):
|
||||
settings = catcher.catch(lambda: Settings.load(value), prefix="settings:")
|
||||
if not settings:
|
||||
return
|
||||
expander = VarExpander.from_conf(settings)
|
||||
repos = {}
|
||||
for name, opts in conf.get("repos", {}).items():
|
||||
error_prefix = f"repo {name!r}:"
|
||||
match kind := opts.get("kind"):
|
||||
case "s3":
|
||||
repos[name] = catcher.catch(
|
||||
lambda: S3Repository.load(name, opts, expander=expander),
|
||||
prefix=error_prefix,
|
||||
)
|
||||
case "local":
|
||||
repos[name] = catcher.catch(
|
||||
lambda: LocalRepository.load(name, opts, expander=expander),
|
||||
prefix=error_prefix,
|
||||
)
|
||||
case None:
|
||||
catcher.error(f"{error_prefix} repository type not set")
|
||||
case _:
|
||||
catcher.error(f"{error_prefix} unsupported kind: {kind}")
|
||||
if not repos:
|
||||
catcher.error("no repositories defined")
|
||||
hooks = None
|
||||
if hook_conf := conf.get("hooks"):
|
||||
hooks = catcher.catch(
|
||||
lambda: HookSet.load(hook_conf, expander=expander), prefix="hook:"
|
||||
)
|
||||
profiles = {}
|
||||
for name, opts in conf.get("profiles", {}).items():
|
||||
if cmd := opts.get("command"):
|
||||
profiles[name] = CommandProfile(
|
||||
name, repo=opts.get("repo"), command=cmd
|
||||
)
|
||||
else:
|
||||
paths = opts.get("paths")
|
||||
if not paths:
|
||||
catcher.error(f"profile {name!r}: no paths or command provided")
|
||||
continue
|
||||
profiles[name] = PathsProfile(
|
||||
name,
|
||||
repo=opts.get("repo"),
|
||||
paths=[expander.expand(x) for x in paths],
|
||||
exclude_paths=[
|
||||
expander.expand(x) for x in opts.get("exclude-paths", [])
|
||||
],
|
||||
)
|
||||
toml = TomlConfig(
|
||||
settings=settings,
|
||||
repos=repos,
|
||||
hooks=hooks,
|
||||
expander=expander,
|
||||
profiles=profiles,
|
||||
)
|
||||
return toml
|
||||
@@ -0,0 +1,28 @@
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
|
||||
@dataclass
|
||||
class VarExpander:
|
||||
globalvars: dict[str, str]
|
||||
_PATTERN = re.compile(r"(\$([\w_]+)[^\w_/$]*)+")
|
||||
|
||||
def expand(self, text: str | None, extras: dict[str, str] | None = None):
|
||||
if not text or "$" not in text:
|
||||
return text
|
||||
all_vars = self.globalvars.copy()
|
||||
if extras:
|
||||
all_vars.update(extras)
|
||||
match = self._PATTERN.findall(text)
|
||||
if not match:
|
||||
return
|
||||
for var, name in match:
|
||||
value = all_vars.get(name)
|
||||
if not value:
|
||||
raise KeyError(f"variable {var} is not defined")
|
||||
text = text.replace(var, value)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def from_conf(settings):
|
||||
return VarExpander(globalvars=settings.globalvars)
|
||||
@@ -0,0 +1,61 @@
|
||||
from dataclasses import dataclass, field
|
||||
import click
|
||||
import logging
|
||||
import enum
|
||||
|
||||
|
||||
class LogLevel(enum.IntEnum):
|
||||
ERROR = 40
|
||||
INFO = 20
|
||||
VERBOSE = 15
|
||||
DEBUG = 10
|
||||
TRACE = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliLogger:
|
||||
level: LogLevel = field(default=LogLevel.INFO)
|
||||
|
||||
def error(self, msg):
|
||||
if self.level <= LogLevel.ERROR:
|
||||
if isinstance(msg, Exception):
|
||||
import traceback
|
||||
|
||||
msg = "".join(traceback.format_exception(msg))
|
||||
click.echo(click.style(msg, fg="red"))
|
||||
|
||||
def info(self, msg):
|
||||
if self.level <= LogLevel.INFO:
|
||||
click.echo(click.style(msg, fg="blue"))
|
||||
|
||||
def debug(self, msg):
|
||||
if self.level <= LogLevel.DEBUG:
|
||||
click.echo(click.style(msg, fg="white"))
|
||||
|
||||
def accepts(self, level):
|
||||
return self.level <= level
|
||||
|
||||
|
||||
class BasicLogger:
|
||||
LEVEL_DEBUG = logging.DEBUG
|
||||
LEVEL_INFO = logging.INFO
|
||||
LEVEL_ERROR = logging.ERROR
|
||||
|
||||
def __init__(self, level=LogLevel.DEBUG):
|
||||
self.level = level
|
||||
self.logger = logging.Logger("lohup", level=level.value)
|
||||
|
||||
def error(self, msg):
|
||||
if isinstance(msg, Exception):
|
||||
self.logger.error("Got exception", exc_info=msg)
|
||||
else:
|
||||
self.logger.error(msg)
|
||||
|
||||
def info(self, msg):
|
||||
self.logger.info(msg)
|
||||
|
||||
def debug(self, msg):
|
||||
self.logger.debug(msg)
|
||||
|
||||
def accepts(self, level):
|
||||
return self.level <= level
|
||||
@@ -0,0 +1,58 @@
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
import os
|
||||
import subprocess as procs
|
||||
import json
|
||||
|
||||
from lohup import config
|
||||
from lohup.logger import CliLogger, BasicLogger
|
||||
|
||||
|
||||
@dataclass
|
||||
class Restic:
|
||||
repo: config.Repository
|
||||
log: CliLogger | BasicLogger
|
||||
binary: str = field(default="restic")
|
||||
|
||||
def environ(self):
|
||||
env = os.environ.copy()
|
||||
env["RESTIC_PASSWORD_FILE"] = self.repo.repo_key_file.value
|
||||
match self.repo:
|
||||
case config.S3Repository():
|
||||
if value := self.repo.region:
|
||||
env["AWS_DEFAULT_REGION"] = value
|
||||
if masked := self.repo.access_key_file:
|
||||
env["AWS_ACCESS_KEY_ID"] = Path(masked.value).read_text().strip()
|
||||
if masked := self.repo.secret_key_file:
|
||||
env["AWS_SECRET_ACCESS_KEY"] = Path(masked.value).read_text().strip()
|
||||
env["RESTIC_REPOSITORY"] = f"s3:{self.repo.url}"
|
||||
case config.LocalRepository():
|
||||
env["RESTIC_REPOSITORY"] = self.repo.path
|
||||
return env
|
||||
|
||||
def run(self, args):
|
||||
cmd, env = self._prepare(args)
|
||||
procs.check_call(cmd, env=env)
|
||||
|
||||
def pipe_stdout(self, args: list[str], src_cmd: list[str]):
|
||||
cmd = [self.binary] + args
|
||||
env = self.environ()
|
||||
with procs.Popen(cmd, stdin=procs.PIPE, env=env) as restic:
|
||||
procs.check_call(src_cmd, stdout=restic.stdin)
|
||||
|
||||
def snapshots(self, format="text"):
|
||||
cmd, env = self._prepare(["snapshots", "--compact"])
|
||||
if format == "json":
|
||||
cmd.append("--json")
|
||||
result = procs.check_output(cmd, env=env, encoding="utf-8")
|
||||
if format == "json":
|
||||
return json.loads(result)
|
||||
return result
|
||||
|
||||
def _prepare(self, args: list[str]):
|
||||
if self.repo is None:
|
||||
raise ValueError("repo not set")
|
||||
cmd = [self.binary]
|
||||
cmd.extend(args)
|
||||
env = self.environ()
|
||||
return cmd, env
|
||||
@@ -0,0 +1,100 @@
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
@dataclass(repr=False)
|
||||
class Masked:
|
||||
value: str
|
||||
|
||||
def __str__(self):
|
||||
return "[masked]"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def catch_errors(sep="\n", prefix=None, failfast=False):
|
||||
catcher = ErrorCatcher(prefix=prefix, failfast=failfast)
|
||||
try:
|
||||
yield catcher
|
||||
except:
|
||||
raise
|
||||
catcher.verify(sep=sep)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorCatcher:
|
||||
outer_prefix: str | None = field(default=None)
|
||||
prefix: str | None = field(default=None)
|
||||
errorlist: list = field(default_factory=list)
|
||||
failfast: bool = field(default=False)
|
||||
|
||||
def catch(self, func, prefix=None):
|
||||
try:
|
||||
return func()
|
||||
except CatcherError as e:
|
||||
if e.catcher.prefix:
|
||||
e.catcher.outer_prefix = prefix
|
||||
else:
|
||||
e.catcher.prefix = prefix
|
||||
self.errorlist.append(e)
|
||||
except Exception as e:
|
||||
if self.failfast:
|
||||
raise
|
||||
self.errorlist.append(e)
|
||||
|
||||
def error(self, msg):
|
||||
self.errorlist.append(msg)
|
||||
|
||||
def verify(self, sep="\n"):
|
||||
result = self.lines()
|
||||
if result:
|
||||
raise CatcherError(
|
||||
sep.join(self._prefixed(x) for x in result), catcher=self
|
||||
)
|
||||
|
||||
def lines(self):
|
||||
out = []
|
||||
extras = []
|
||||
for err in self.errorlist:
|
||||
match err:
|
||||
case str():
|
||||
out.append(err)
|
||||
case CatcherError():
|
||||
extras.extend(err.catcher.lines())
|
||||
case _:
|
||||
extras.append(str(err))
|
||||
out.extend(extras)
|
||||
return [self._prefixed(x) for x in out]
|
||||
|
||||
def _prefixed(self, s):
|
||||
if self.outer_prefix:
|
||||
return f"{self.outer_prefix} {self.prefix} {s}"
|
||||
return f"{self.prefix} {s}" if self.prefix else s
|
||||
|
||||
|
||||
class CatcherError(ValueError):
|
||||
catcher: ErrorCatcher
|
||||
|
||||
def __init__(self, message, catcher=None):
|
||||
self.catcher = catcher
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def ensure_exists(name, expect="file", field=None) -> str | None:
|
||||
if not name:
|
||||
if field is not None:
|
||||
return f"field {field!r}: empty value"
|
||||
return "empty value"
|
||||
pth = Path(str(name))
|
||||
if isinstance(name, Masked):
|
||||
pth = Path(name.value)
|
||||
msg = None
|
||||
if not pth.exists():
|
||||
msg = f"file {name} not found"
|
||||
elif expect == "file" and not pth.is_file():
|
||||
msg = f"{name}: not a file"
|
||||
elif expect == "dir" and not pth.is_dir():
|
||||
msg = f"{name}: not a directory"
|
||||
if msg is not None and field is not None:
|
||||
return f"field {field!r}: {msg}"
|
||||
return msg
|
||||
@@ -0,0 +1,58 @@
|
||||
from lohup.app import Lohup
|
||||
from lohup.config import ConfigError
|
||||
from lohup.logger import BasicLogger, LogLevel
|
||||
|
||||
import pytest
|
||||
|
||||
toml1 = """
|
||||
[settings.globals]
|
||||
"""
|
||||
|
||||
toml2 = """
|
||||
[[hooks.before-all]]
|
||||
kind = "unknown"
|
||||
foo = "bar"
|
||||
|
||||
[repos.local]
|
||||
kind = "local"
|
||||
path = "{base}/repo"
|
||||
repo-key-file = "{pwfile}"
|
||||
"""
|
||||
|
||||
|
||||
class RepoEnvironment:
|
||||
def __init__(self, tmp_path):
|
||||
self.basepath = tmp_path
|
||||
self.log = BasicLogger(level=LogLevel.DEBUG)
|
||||
|
||||
def write_password(self, value: str = "1"):
|
||||
pth = self.basepath / "password.txt"
|
||||
pth.write_text("1")
|
||||
return pth
|
||||
|
||||
|
||||
def test_error_message(tmp_path):
|
||||
env = RepoEnvironment(tmp_path)
|
||||
pwfile = env.write_password()
|
||||
path = tmp_path / "lohup.toml"
|
||||
path.write_text(toml1.format(base=tmp_path, pwfile=pwfile))
|
||||
app = Lohup(config_path=path, logger=env.log)
|
||||
msg = "no repositories defined"
|
||||
with pytest.raises(ConfigError, match="Failed to parse TOML config") as exc:
|
||||
app.load()
|
||||
env.log.error(exc.value)
|
||||
assert str(exc.value.__cause__) == msg
|
||||
|
||||
|
||||
def test_hook_error(tmp_path):
|
||||
env = RepoEnvironment(tmp_path)
|
||||
pwfile = env.write_password()
|
||||
tmp_path.joinpath("repo").mkdir()
|
||||
path = tmp_path / "lohup.toml"
|
||||
path.write_text(toml2.format(base=tmp_path, pwfile=pwfile))
|
||||
app = Lohup(config_path=path, logger=env.log)
|
||||
msg = "hook: Unsupported before-all: unknown"
|
||||
with pytest.raises(ConfigError, match="Failed to parse TOML config") as exc:
|
||||
app.load()
|
||||
env.log.error(exc.value)
|
||||
assert str(exc.value.__cause__) == msg
|
||||
@@ -0,0 +1,201 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "altgraph"
|
||||
version = "0.17.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418, upload-time = "2023-09-25T09:04:52.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
|
||||
]
|
||||
|
||||
[[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 = "humanize"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/1d/3062fcc89ee05a715c0b9bfe6490c00c576314f27ffee3a704122c6fd259/humanize-4.13.0.tar.gz", hash = "sha256:78f79e68f76f0b04d711c4e55d32bebef5be387148862cb1ef83d2b58e7935a0", size = 81884, upload-time = "2025-08-25T09:39:20.04Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c7/316e7ca04d26695ef0635dc81683d628350810eb8e9b2299fc08ba49f366/humanize-4.13.0-py3-none-any.whl", hash = "sha256:b810820b31891813b1673e8fec7f1ed3312061eab2f26e3fa192c393d11ed25f", size = 128869, upload-time = "2025-08-25T09:39:18.54Z" },
|
||||
]
|
||||
|
||||
[[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 = "lohup"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "humanize" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.2.1" },
|
||||
{ name = "humanize", specifier = ">=4.13.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pyinstaller", specifier = ">=6.15.0" },
|
||||
{ name = "pytest", specifier = ">=8.4.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macholib"
|
||||
version = "1.16.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309, upload-time = "2023-09-25T09:10:16.155Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094, upload-time = "2023-09-25T09:10:14.188Z" },
|
||||
]
|
||||
|
||||
[[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 = "pefile"
|
||||
version = "2023.2.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854, upload-time = "2023-02-07T12:23:55.958Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791, upload-time = "2023-02-07T12:28:36.678Z" },
|
||||
]
|
||||
|
||||
[[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 = "pyinstaller"
|
||||
version = "6.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
{ name = "macholib", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyinstaller-hooks-contrib" },
|
||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/17/b2bb4de22650adbeef401fa82a1b43028976547a8728602e4d29735b455e/pyinstaller-6.15.0.tar.gz", hash = "sha256:a48fc4644ee4aa2aa2a35e7b51f496f8fbd7eecf6a2150646bbf1613ad07bc2d", size = 4331521, upload-time = "2025-08-03T18:33:35.709Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/dd/d5c8a127446adda954f68ea7fac22772f7ab8656ad4b06df396d82574ca9/pyinstaller-6.15.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:9f00c71c40148cd1e61695b2c6f1e086693d3bcf9bfa22ab513aa4254c3b966f", size = 1016981, upload-time = "2025-08-03T18:31:52.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/2a/7b50593b419db43e48d9bdeebaac0ff92a5fe035f3c30f87ca3e1650d7e2/pyinstaller-6.15.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cbcc8eb77320c60722030ac875883b564e00768fe3ff1721c7ba3ad0e0a277e9", size = 726337, upload-time = "2025-08-03T18:31:57.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/83/7f498fba0154c57eb5fc93eb9680a2dbadb9f780a3389fb85b8d79683378/pyinstaller-6.15.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c33e6302bc53db2df1104ed5566bd980b3e0ee7f18416a6e3caa908c12a54542", size = 737539, upload-time = "2025-08-03T18:32:02.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/d6/e4477feab7c8379fb49e7ec95c82d0a69ad88f6ccc247f76bef3cb0e3432/pyinstaller-6.15.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:eb902d0fed3bb1f8b7190dc4df5c11f3b59505767e0d56d1ed782b853938bbf3", size = 735426, upload-time = "2025-08-03T18:32:06.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/7e/ff25648276f15e2e77fc563d36d8cfcd917e077bf2a172420df3588601b4/pyinstaller-6.15.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b4df862adae7cf1f08eff53c43ace283822447f7f528f72e4f94749062712f15", size = 732210, upload-time = "2025-08-03T18:32:21.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3d/267a7dddd0647de95d260780050ccd8228ab29d2b9edea54ed1f56800967/pyinstaller-6.15.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b9ebf16ed0f99016ae8ae5746dee4cb244848a12941539e62ce2eea1df5a3f95", size = 732194, upload-time = "2025-08-03T18:32:29.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/61/962b2eb79ef225233e2d6e04600e998935328011dfb2fa775b1dd16b943a/pyinstaller-6.15.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:22193489e6a22435417103f61e7950363bba600ef36ec3ab1487303668c81092", size = 731256, upload-time = "2025-08-03T18:32:36.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/5e/4e20e1c0e5791b09b69bef3ac921fd0cd25551b56879324ad999b92fa045/pyinstaller-6.15.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:18f743069849dbaee3e10900385f35795a5743eabab55e99dcc42f204e40a0db", size = 731148, upload-time = "2025-08-03T18:32:41.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/31/28956c534991f289e2f981c715730b6241e75dc6295737a8cbd050a0cc8c/pyinstaller-6.15.0-py3-none-win32.whl", hash = "sha256:60da8f1b5071766b45c0f607d8bc3d7e59ba2c3b262d08f2e4066ba65f3544a2", size = 1312297, upload-time = "2025-08-03T18:32:50.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ab/6a45186c7f8e34c422faecd72580116a67d068158c57faa2d2f6d01faa7f/pyinstaller-6.15.0-py3-none-win_amd64.whl", hash = "sha256:cbea297e16eeda30b41c300d6ec2fd2abea4dbd8d8a32650eeec36431c94fcd9", size = 1373091, upload-time = "2025-08-03T18:32:58.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/86/72159af032b9db36f2470a3b085f79277ec1c38e7e48f8c5dc1ed16dc4e1/pyinstaller-6.15.0-py3-none-win_arm64.whl", hash = "sha256:f43c035621742cf2d19b84308c60e4e44e72c94786d176b8f6adcde351b5bd98", size = 1314305, upload-time = "2025-08-03T18:33:05.557Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller-hooks-contrib"
|
||||
version = "2025.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/d6/e5b378b7d4add8c879295c531309b0320e9c07a70458665d091760ffdc87/pyinstaller_hooks_contrib-2025.8.tar.gz", hash = "sha256:3402ad41dfe9b5110af134422e37fc5d421ba342c6cb980bd67cb30b7415641c", size = 164214, upload-time = "2025-07-27T16:37:31.943Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/34/1d973d0dae849683e53fbcda84443ce016f315e6f4dc7605ede4f56a28c3/pyinstaller_hooks_contrib-2025.8-py3-none-any.whl", hash = "sha256:8d0b8cfa0cb689a619294ae200497374234bd4e3994b3ace2a4442274c899064", size = 442346, upload-time = "2025-07-27T16:37:30.268Z" },
|
||||
]
|
||||
|
||||
[[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 = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user