feat: local cache on sqlite, new info command

This commit is contained in:
Igor Ovsyannikov committed 2026-08-25 03:03:13 +03:00
1 parent 4e632b87df
commit d315e73561
13 files changed
+570 -39

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ requires-python = ">=3.14"
dependencies = [
"click>=8.2.1",
"humanize>=4.13.0",
"sqlalchemy>=2.0.52",
"tomlkit>=0.13.3",
]
+78 -17
View File
@@ -1,3 +1,6 @@
from lohup.engine import Snapshot
from lohup.storage.storage import Storage
from lohup.config import LocalRepository, S3Repository
import subprocess as procs
from lohup import config as tomlconf
@@ -10,10 +13,11 @@ class Lohup:
subsystem: str | None
_config: tomlconf.TomlConfig | None
def __init__(self, config_path: str | None, logger: LoggerProto | None = None):
def __init__(self, config_path: str | None, logger: LoggerProto | None = None, cache: bool = False):
self._config_path = config_path or "lohup.toml"
self._config = None
self.subsystem = None
self._cache = cache
self.log = logger or BasicLogger(level=LogLevel.INFO)
def load(self):
@@ -21,13 +25,17 @@ class Lohup:
self.subsystem = self.config.settings.subsystem
if self.subsystem not in ("restic", "rustic"):
raise KeyError(f"Invalid subsystem: {self.subsystem}")
if self._cache:
pth = self.config.settings.cache_dir
if not pth.exists():
pth.mkdir(parents=True)
pth.joinpath("CACHEDIR.TAG").write_text(
"Signature: 8a477f597d28d172789f06886806bc55"
)
self._cache = Storage.from_file(pth / "db.sqlite3")
def invoke_direct(self, repo: str | None, args: tuple[str, ...]):
spec = self.config.repos.get(repo) if repo else self._default_repo
if spec is None:
if repo is None:
raise KeyError("No repository provided and there is no default one")
raise KeyError(f"Unknown repo: {repo}")
spec = self._repo_by_name(repo)
engine = self._engine_for(spec)
with engine:
engine.run(args)
@@ -69,11 +77,14 @@ class Lohup:
raise KeyError(f"No repo attached to profile: {profile.name!r}")
return default
def _engine_for(self, repo: tomlconf.Repository):
def _engine_for(self, repo: tomlconf.Repository) -> Restic | Rustic:
match self.subsystem:
case "rustic":
return Rustic(
repo, log=self.log, conf_dir=self.config.settings.build_dir
repo,
log=self.log,
conf_dir=self.config.settings.build_dir,
storage=self.storage,
)
case "restic":
return Restic(repo, log=self.log)
@@ -104,16 +115,48 @@ class Lohup:
self._exechooks(self.config.hooks.after_all)
self.log.info("Finished!")
def snapshots(self, repo: str | None, is_json=False, profile: str | None = None):
spec = self.config.repos.get(repo) if repo else self._default_repo
if spec is None:
if repo is None:
raise KeyError("No repository provided and there is no default one")
raise KeyError(f"Unknown repo: {repo}")
def snapshots(self, repo: str | None, profile: str | None = None):
spec = self._repo_by_name(repo)
engine = self._engine_for(spec)
format = "json" if is_json else "text"
with engine:
return engine.snapshots(format=format, profile=profile)
result = engine.snapshots(profile=profile)
if cache := self.storage:
total = []
existing = {x.id for x in total}
for arr in result.values():
total.extend(arr)
with cache.session() as session:
print(len(Snapshot.findall(session)))
response = Snapshot.update_all(session, repo=spec.name, items=total)
session.commit()
if not response["untouched"]:
return result
to_delete = existing.difference(response["untouched"])
if not to_delete:
return result
print(response)
with cache.session() as session:
Snapshot.delete_all(session, repo=spec.name, ids=list(to_delete), profile=profile)
return result
def profile_info(self, repo: str | None, profile: str, offset: int):
spec = self._repo_by_name(repo)
engine = self._engine_for(spec)
with engine:
all_snapshots = engine.snapshots(profile=profile)
if not isinstance(all_snapshots, dict):
raise ValueError("internal error - unknown snapshots format")
snapshots = all_snapshots[profile]
snapshot = snapshots[offset]
info = engine.info(snapshot.id)
return info
def snapshot_info(self, repo: str, ref: str):
spec = self._repo_by_name(repo)
engine = self._engine_for(spec)
with engine:
info = engine.info(ref)
return info
def _invoke_profile(self, restic, profile: tomlconf.Profile):
with restic as engine:
@@ -126,8 +169,26 @@ class Lohup:
raise KeyError(f"Unknown backup profile: {name!r}")
return result
def _repo_by_name(self, name: str | None) -> LocalRepository | S3Repository:
result = self._default_repo
if name:
result = self.config.repos.get(name)
if not result:
raise KeyError(f"Unknown repository: {name}")
elif not result:
raise KeyError("No repository provided and there is no default one")
return result
@property
def config(self) -> tomlconf.TomlConfig:
if self._config is None:
raise ValueError("Configuration is not available yet.")
raise ValueError("Configuration is not loaded yet.")
return self._config
@property
def storage(self) -> Storage | None:
if self._cache is None:
raise ValueError("Storage is not available.")
if isinstance(self._cache, bool):
return None
return self._cache
+39 -7
View File
@@ -1,3 +1,4 @@
import re
from typing import no_type_check
import click
@@ -10,10 +11,11 @@ from lohup.config import ConfigError
@click.group()
@click.option("--config", envvar="LOHUP_CONFIG", default="lohup.toml")
@click.option("--cache", type=bool, default=True)
@click.pass_context
def cli(ctx, config):
def cli(ctx, config, cache):
log = logger.CliLogger(level=logger.LogLevel.DEBUG)
ctx.obj = Lohup(config_path=config, logger=log)
ctx.obj = Lohup(config_path=config, logger=log, cache=cache)
try:
ctx.obj.load()
except ConfigError as e:
@@ -47,14 +49,11 @@ def backup_all(obj: Lohup):
@cli.command()
@click.option("--repo", help="Lohup repository name")
@click.option("--raw", "raw_mode", is_flag=True)
@click.option("--profile", help="Filter by profile name")
@click.pass_obj
@no_type_check
def snapshots(obj: Lohup, repo: str | None, raw_mode: bool, profile: str | None):
result = obj.snapshots(repo=repo, is_json=not raw_mode, profile=profile)
if raw_mode:
return click.echo(result, nl=False)
def snapshots(obj: Lohup, repo: str | None, profile: str | None):
result = obj.snapshots(repo=repo, profile=profile)
groups: dict[str, list[engine.Snapshot]] = result
names = sorted(groups)
for name in names:
@@ -87,5 +86,38 @@ def snapshots(obj: Lohup, repo: str | None, raw_mode: bool, profile: str | None)
click.echo()
@cli.command()
@click.option("-r", "--repo", help="Repository name")
@click.option("-d", "--depth", help="File tree depth", type=int, default=3)
@click.argument("ref")
@click.pass_obj
def info(obj: Lohup, repo: str | None, depth: int, ref: str):
"""
Get details about specific snapshot by its ID or by profile name.
\b
Examples:
\b
info profile/code
\b
info --repo=s3 f3bd67e8
"""
pattern = re.compile(r"^profile/([\w-]+)(\^[\d]*)?$")
if match := pattern.match(ref):
profile = match.group(1)
offset = 0
if (value := match.group(2)) is not None:
offset = int(value.removeprefix("^"))
info = obj.profile_info(repo, profile=profile, offset=offset)
elif repo:
info = obj.snapshot_info(repo=repo, ref=ref)
else:
raise click.ClickException("Requested info by ID without repository name.")
info.build_tree(depth=depth)
for line in info.show(depth=depth):
click.echo(line)
if __name__ == "__main__":
cli()
+3
View File
@@ -35,6 +35,7 @@ class Settings:
globalvars: dict[str, str]
build_dir: Path
subsystem: str
cache_dir: Path
@staticmethod
def load(conf: dict, validator: DeepValidator):
@@ -57,11 +58,13 @@ class Settings:
tmp_path = Path(tmpdir)
elif platform.system() == "Windows":
tmp_path = Path.home().joinpath("AppData", "Local", "Temp", "lohup")
cache_path = Path.home().joinpath(".cache", "lohup")
settings = Settings(
backup_base_dir=basepath,
globalvars=conf.get("globals") or {},
build_dir=tmp_path,
subsystem=conf.get("subsystem-name", "restic"),
cache_dir=cache_path
)
for key, value in settings.globalvars.items():
with validator.inner(f"variable {key!r}") as inner:
+229 -3
View File
@@ -1,4 +1,13 @@
from dataclasses import dataclass
from lohup.storage.snapshot import SnapshotRecord
from sqlalchemy.orm import Session
import humanize.filesize
from typing_extensions import cast
from collections import defaultdict
from lohup.storage.file import SnapshotFile
from sqlalchemy import select, delete
from lohup.storage.storage import Storage
from pathlib import PosixPath
from dataclasses import dataclass, field
from datetime import datetime
import typing as ty
@@ -30,7 +39,7 @@ class Snapshot:
end=datetime.fromisoformat(item["summary"]["backup_end"]),
raw=item,
parent=item.get("parent"),
_engine="rustic"
_engine="rustic",
)
@staticmethod
@@ -47,9 +56,58 @@ class Snapshot:
time=datetime.fromisoformat(item["time"]),
end=datetime.fromisoformat(item["summary"]["backup_end"]),
raw=item,
_engine="restic"
parent=item.get("parent"),
_engine="restic",
)
@staticmethod
def findall(session: Session):
out = []
stmt = select(SnapshotRecord)
for row in session.scalars(stmt):
out.append(Snapshot(
id=row.key,
profile=row.profile,
tags=[],
hostname="",
time=row.created,
end=row.created,
raw={},
parent=row.parent,
_engine=""
))
return out
@staticmethod
def update_all(session: Session, repo: str, items: list["Snapshot"]) -> dict[str, list[str]]:
stmt = select(SnapshotRecord.key).where(SnapshotRecord.repo == repo)
stored = set(session.scalars(stmt))
result = {
"added": [],
"untouched": [],
}
for snapshot in items:
if snapshot.id not in stored:
result["added"].append(snapshot.id)
session.add(SnapshotRecord(
repo=repo,
key=snapshot.id,
profile=snapshot.profile,
parent=snapshot.parent,
created=snapshot.time,
))
else:
result["untouched"].append(snapshot.id)
return result
@staticmethod
def delete_all(session: Session, repo: str, ids: list[str], profile: str | None):
stmt = delete(SnapshotRecord).where(SnapshotRecord.repo == repo)
if profile:
stmt = stmt.where(SnapshotRecord.profile == profile)
stmt = stmt.where(SnapshotRecord.key.in_(ids))
session.execute(stmt)
@property
def name(self) -> str:
return self.profile or "-".join(self.tags)
@@ -88,3 +146,171 @@ class Snapshot:
@property
def duration(self):
return self.end - self.time
@dataclass(slots=True)
class FileItem:
path: str
parent: str
name: str
flags: str
size: int
date: datetime
@property
def depth(self):
pth = PosixPath(self.path)
return len(pth.parts)
@dataclass(slots=True)
class Directory:
path: str
parent: str
name: str
date: datetime
size: int = field(default=0)
children: list["Directory" | FileItem] = field(default_factory=list, repr=False)
@property
def depth(self):
pth = PosixPath(self.path)
return len(pth.parts)
@property
def flags(self):
return "d"
T = ty.TypeVar("T")
@dataclass(slots=True)
class LinkedNode[T]:
value: T
next: ty.Self | None
def __str__(self):
nextval = None
if self.next is not None:
nextval = "[has_next]"
return f"LinkedNode(value={self.value}, next={nextval})"
@dataclass
class SnapshotInfo:
tree: dict[str, FileItem | Directory] = field(default_factory=dict)
root: Directory | None = field(default=None)
@staticmethod
def load_db(
storage: Storage, repo: str, snapshot: str
) -> "SnapshotInfo" | None:
info = SnapshotInfo()
with storage.session() as session:
query = select(SnapshotFile).filter_by(repo=repo, snapshot=snapshot)
result = session.scalars(query)
for row in result:
parent = PosixPath(row.path).parent
item = FileItem(
path=row.path,
parent=str(parent),
name=row.name,
flags=row.kind,
size=row.size,
date=row.mtime,
)
info.tree[item.path] = item
if not info.tree:
return None
return info
def save(self, storage: Storage, repo: str, snapshot: str):
with storage.session() as session:
exists = session.scalars(
select(SnapshotFile).filter_by(repo=repo, snapshot=snapshot).limit(1)
)
if exists.first():
return
for file in self.tree.values():
session.add(
SnapshotFile(
repo=repo,
snapshot=snapshot,
kind=file.flags,
path=file.path,
name=file.name,
size=file.size,
mtime=file.date,
)
)
session.commit()
def _itertree(self, depth: int) -> ty.Generator[FileItem | Directory]:
for pth in self.tree:
value = self.tree[pth]
if value.depth > depth:
continue
yield value
def build_tree(self, depth: int):
root: Directory | None = None
for file in self._itertree(depth):
file = cast(FileItem, file)
pathobj = PosixPath(file.path)
if not file.flags.startswith("d"):
continue
item = Directory(
path=file.path, parent=file.parent, name=file.name, date=file.date
)
self.tree[file.path] = item
if root is None or not pathobj.is_relative_to(root.path):
root = item
if root is None:
raise ValueError("Failed to resolve root directory")
for file in self._itertree(depth):
if isinstance(file, FileItem):
self.tree[file.parent].children.append(file) # ty: ignore
continue
file.size = sum(x.size for x in self.tree.values() if x.path.startswith(f"{file.path}/"))
for file in self._itertree(depth):
if parent := self.tree.get(file.parent):
parent.children.append(file) # ty: ignore
self.root = root
def show(self, depth: int) -> list[str]:
if self.root is None:
raise ValueError("Root directory is not available")
by_parent: dict[str, list[FileItem | Directory]] = defaultdict(list)
nodes = {x.path: LinkedNode(x, next=None) for x in self._itertree(depth)}
root = nodes[self.root.path]
for item in self._itertree(depth):
if item.path == self.root.path:
continue
by_parent[item.parent].append(item)
for path, items in by_parent.items():
parent = nodes[path]
items.sort(key=lambda x: x.name)
for file in items:
node = nodes[file.path]
if node.next is not None:
raise ValueError(
f"internal error: {node.value.path} has been already processed"
)
if parent.next is not None:
# insert node between parent and next
end = parent.next
node.next = end
parent.next = node
parent = node
lines = []
current = root
while current is not None:
file = current.value
prefix = " " * (file.depth - 1)
line = f"{file.name}/" if isinstance(file, Directory) else file.name
size = "0"
if file.size:
size = humanize.filesize.naturalsize(file.size)
lines.append(f"{size:>10} {prefix}{line}")
current = current.next
return lines
+6 -1
View File
@@ -64,10 +64,12 @@ class Restic:
with procs.Popen(cmd, stdin=procs.PIPE, env=env) as restic:
procs.check_call(src_cmd, stdout=restic.stdin)
def snapshots(self, format="text"):
def snapshots(self, format="text", profile: str | None = None):
cmd, env = self._prepare(["snapshots", "--compact"])
if format == "json":
cmd.append("--json")
if profile:
cmd.extend(["--tag", f"lohup.profile={profile}"])
result = procs.check_output(cmd, env=env, encoding="utf-8")
if format == "json":
content = json.loads(result)
@@ -78,6 +80,9 @@ class Restic:
return snapshots
return result
def info(self, ref: str) -> engine.SnapshotInfo:
raise NotImplementedError
def _prepare(self, args: list[str]):
if self.repo is None:
raise ValueError("repo not set")
+65 -11
View File
@@ -1,8 +1,13 @@
from lohup.storage.storage import Storage
from typing import cast, IO
import traceback
from datetime import datetime
import re
import json
import subprocess as procs
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from pathlib import Path, PosixPath
import tomlkit
@@ -15,6 +20,7 @@ class Rustic:
repo: config.Repository
log: LoggerProto
conf_dir: Path
storage: Storage | None
binary: str = field(default="rustic")
@property
@@ -85,24 +91,72 @@ class Rustic:
procs.check_call(src_cmd, stdout=rustic.stdin)
def snapshots(
self, format="text", profile: str = None
) -> str | dict[str, list[engine.Snapshot]]:
self, profile: str | None = None
) -> dict[str, list[engine.Snapshot]]:
cmd = self._cmdline()
cmd.extend(["snapshots"])
if format == "json":
cmd.append("--json")
cmd.extend(["snapshots", "--json"])
if profile:
cmd.extend(["--filter-tags", f"lohup.profile={profile}"])
result = procs.check_output(cmd, encoding="utf-8")
if format == "json":
result = procs.check_output(cmd, encoding="utf-8", stderr=procs.DEVNULL)
content = json.loads(result)
snapshots = defaultdict(list)
snapshots = []
grouped = defaultdict(list)
for group in content:
key = group["group_key"]
for x in group["snapshots"]:
snap = engine.Snapshot.from_rustic(x, group=key)
snapshots[snap.name].append(snap)
return snapshots
grouped[snap.name].append(snap)
snapshots.append(snap)
return grouped
def info(self, snapshot: str):
if self.storage:
if value := engine.SnapshotInfo.load_db(
self.storage, repo=self.repo.name, snapshot=snapshot
):
return value
result = engine.SnapshotInfo()
def read_(match):
flags = match.group("flags")
size = match.group("size")
mtime_raw = match.group("mtime").strip()
mtime = datetime.fromtimestamp(0)
if mtime_raw != "?":
mtime = datetime.strptime(mtime_raw, "%d %b %Y %H:%M")
path = match.group("path")
pth = PosixPath(path)
result.tree[path] = engine.FileItem(
path=path,
parent=str(pth.parent),
name=pth.name,
flags=flags,
size=int(size),
date=mtime,
)
cmd = self._cmdline()
cmd.extend(["ls", "-l", snapshot])
pattern = re.compile(
r'^(?P<flags>\S+)\s+\S+\s+\S+\s+(?P<size>\d+)\s+(?P<mtime>[^"]+)"(?P<path>[^"]+)"'
)
with procs.Popen(
cmd, encoding="utf-8", stdout=procs.PIPE, stderr=procs.DEVNULL
) as proc:
stream = cast(IO[str], proc.stdout)
for line in stream:
match = pattern.match(line.strip())
if not match:
print(f"unmatched line: {line!s}")
continue
try:
read_(match)
except Exception:
traceback.print_exc()
if not result.tree:
raise KeyError("No such snapshot")
if self.storage:
result.save(self.storage, repo=self.repo.name, snapshot=snapshot)
return result
def __enter__(self):
View File
Whitespace-only changes.
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import String, Text, BigInteger, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from typing import final
from lohup.storage.base import Base
@final
class SnapshotFile(Base):
__tablename__ = "files"
id: Mapped[int] = mapped_column(primary_key=True)
repo: Mapped[str] = mapped_column(String(64))
snapshot: Mapped[str] = mapped_column(String(64))
kind: Mapped[str] = mapped_column(String(10))
path: Mapped[str] = mapped_column(Text())
name: Mapped[str] = mapped_column(Text())
size: Mapped[int] = mapped_column(BigInteger())
mtime: Mapped[datetime] = mapped_column(DateTime())
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from typing import final
from lohup.storage.base import Base
@final
class SnapshotRecord(Base):
__tablename__ = "snapshots"
id: Mapped[int] = mapped_column(primary_key=True)
repo: Mapped[str] = mapped_column(String(64))
key: Mapped[str] = mapped_column(String(64))
profile: Mapped[str] = mapped_column(String(32))
parent: Mapped[str] = mapped_column(String(64), nullable=True)
created: Mapped[datetime] = mapped_column(DateTime())
+38
View File
@@ -0,0 +1,38 @@
from lohup.storage.snapshot import SnapshotRecord
import typing as ty
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import Session
from lohup.storage.base import Base
from lohup.storage.file import SnapshotFile
@dataclass
class Storage:
engine: Engine
@staticmethod
def from_file(path: Path):
conn = f"sqlite:///{path}"
storage = Storage(engine=create_engine(conn))
if not path.exists():
storage.create()
return storage
@contextmanager
def session(self) -> ty.Generator[Session]:
with Session(self.engine) as sess:
yield sess
def create(self):
Base.metadata.create_all(
self.engine,
tables=[ # ty: ignore[invalid-argument-type]
SnapshotFile.__table__,
SnapshotRecord.__table__,
],
)
Generated
+71
View File
@@ -32,6 +32,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "greenlet"
version = "3.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" },
{ url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" },
{ url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" },
{ url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" },
{ url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" },
{ url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" },
{ url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" },
{ url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" },
{ url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" },
{ url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" },
{ url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" },
{ url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" },
{ url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" },
{ url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" },
{ url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" },
{ url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" },
{ url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" },
{ url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" },
{ url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" },
{ url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" },
{ url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" },
{ url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" },
{ url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" },
{ url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
]
[[package]]
name = "humanize"
version = "4.13.0"
@@ -66,6 +105,7 @@ source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "humanize" },
{ name = "sqlalchemy" },
{ name = "tomlkit" },
]
@@ -80,6 +120,7 @@ dev = [
requires-dist = [
{ name = "click", specifier = ">=8.2.1" },
{ name = "humanize", specifier = ">=4.13.0" },
{ name = "sqlalchemy", specifier = ">=2.0.52" },
{ name = "tomlkit", specifier = ">=0.13.3" },
]
@@ -212,6 +253,27 @@ 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" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" },
{ url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" },
{ url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" },
{ url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" },
{ url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" },
{ url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" },
{ url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" },
{ url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" },
]
[[package]]
name = "tomlkit"
version = "0.13.3"
@@ -220,3 +282,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa3881
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]