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 = [ dependencies = [
"click>=8.2.1", "click>=8.2.1",
"humanize>=4.13.0", "humanize>=4.13.0",
"sqlalchemy>=2.0.52",
"tomlkit>=0.13.3", "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 import subprocess as procs
from lohup import config as tomlconf from lohup import config as tomlconf
@@ -10,10 +13,11 @@ class Lohup:
subsystem: str | None subsystem: str | None
_config: tomlconf.TomlConfig | 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_path = config_path or "lohup.toml"
self._config = None self._config = None
self.subsystem = None self.subsystem = None
self._cache = cache
self.log = logger or BasicLogger(level=LogLevel.INFO) self.log = logger or BasicLogger(level=LogLevel.INFO)
def load(self): def load(self):
@@ -21,13 +25,17 @@ class Lohup:
self.subsystem = self.config.settings.subsystem self.subsystem = self.config.settings.subsystem
if self.subsystem not in ("restic", "rustic"): if self.subsystem not in ("restic", "rustic"):
raise KeyError(f"Invalid subsystem: {self.subsystem}") 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, ...]): def invoke_direct(self, repo: str | None, args: tuple[str, ...]):
spec = self.config.repos.get(repo) if repo else self._default_repo spec = self._repo_by_name(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}")
engine = self._engine_for(spec) engine = self._engine_for(spec)
with engine: with engine:
engine.run(args) engine.run(args)
@@ -69,11 +77,14 @@ class Lohup:
raise KeyError(f"No repo attached to profile: {profile.name!r}") raise KeyError(f"No repo attached to profile: {profile.name!r}")
return default return default
def _engine_for(self, repo: tomlconf.Repository): def _engine_for(self, repo: tomlconf.Repository) -> Restic | Rustic:
match self.subsystem: match self.subsystem:
case "rustic": case "rustic":
return 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": case "restic":
return Restic(repo, log=self.log) return Restic(repo, log=self.log)
@@ -104,16 +115,48 @@ class Lohup:
self._exechooks(self.config.hooks.after_all) self._exechooks(self.config.hooks.after_all)
self.log.info("Finished!") self.log.info("Finished!")
def snapshots(self, repo: str | None, is_json=False, profile: str | None = None): def snapshots(self, repo: str | None, profile: str | None = None):
spec = self.config.repos.get(repo) if repo else self._default_repo spec = self._repo_by_name(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}")
engine = self._engine_for(spec) engine = self._engine_for(spec)
format = "json" if is_json else "text"
with engine: 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): def _invoke_profile(self, restic, profile: tomlconf.Profile):
with restic as engine: with restic as engine:
@@ -126,8 +169,26 @@ class Lohup:
raise KeyError(f"Unknown backup profile: {name!r}") raise KeyError(f"Unknown backup profile: {name!r}")
return result 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 @property
def config(self) -> tomlconf.TomlConfig: def config(self) -> tomlconf.TomlConfig:
if self._config is None: if self._config is None:
raise ValueError("Configuration is not available yet.") raise ValueError("Configuration is not loaded yet.")
return self._config 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 from typing import no_type_check
import click import click
@@ -10,10 +11,11 @@ from lohup.config import ConfigError
@click.group() @click.group()
@click.option("--config", envvar="LOHUP_CONFIG", default="lohup.toml") @click.option("--config", envvar="LOHUP_CONFIG", default="lohup.toml")
@click.option("--cache", type=bool, default=True)
@click.pass_context @click.pass_context
def cli(ctx, config): def cli(ctx, config, cache):
log = logger.CliLogger(level=logger.LogLevel.DEBUG) 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: try:
ctx.obj.load() ctx.obj.load()
except ConfigError as e: except ConfigError as e:
@@ -47,14 +49,11 @@ def backup_all(obj: Lohup):
@cli.command() @cli.command()
@click.option("--repo", help="Lohup repository name") @click.option("--repo", help="Lohup repository name")
@click.option("--raw", "raw_mode", is_flag=True)
@click.option("--profile", help="Filter by profile name") @click.option("--profile", help="Filter by profile name")
@click.pass_obj @click.pass_obj
@no_type_check @no_type_check
def snapshots(obj: Lohup, repo: str | None, raw_mode: bool, profile: str | None): def snapshots(obj: Lohup, repo: str | None, profile: str | None):
result = obj.snapshots(repo=repo, is_json=not raw_mode, profile=profile) result = obj.snapshots(repo=repo, profile=profile)
if raw_mode:
return click.echo(result, nl=False)
groups: dict[str, list[engine.Snapshot]] = result groups: dict[str, list[engine.Snapshot]] = result
names = sorted(groups) names = sorted(groups)
for name in names: for name in names:
@@ -87,5 +86,38 @@ def snapshots(obj: Lohup, repo: str | None, raw_mode: bool, profile: str | None)
click.echo() 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__": if __name__ == "__main__":
cli() cli()
+3
View File
@@ -35,6 +35,7 @@ class Settings:
globalvars: dict[str, str] globalvars: dict[str, str]
build_dir: Path build_dir: Path
subsystem: str subsystem: str
cache_dir: Path
@staticmethod @staticmethod
def load(conf: dict, validator: DeepValidator): def load(conf: dict, validator: DeepValidator):
@@ -57,11 +58,13 @@ class Settings:
tmp_path = Path(tmpdir) tmp_path = Path(tmpdir)
elif platform.system() == "Windows": elif platform.system() == "Windows":
tmp_path = Path.home().joinpath("AppData", "Local", "Temp", "lohup") tmp_path = Path.home().joinpath("AppData", "Local", "Temp", "lohup")
cache_path = Path.home().joinpath(".cache", "lohup")
settings = Settings( settings = Settings(
backup_base_dir=basepath, backup_base_dir=basepath,
globalvars=conf.get("globals") or {}, globalvars=conf.get("globals") or {},
build_dir=tmp_path, build_dir=tmp_path,
subsystem=conf.get("subsystem-name", "restic"), subsystem=conf.get("subsystem-name", "restic"),
cache_dir=cache_path
) )
for key, value in settings.globalvars.items(): for key, value in settings.globalvars.items():
with validator.inner(f"variable {key!r}") as inner: 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 from datetime import datetime
import typing as ty import typing as ty
@@ -30,7 +39,7 @@ class Snapshot:
end=datetime.fromisoformat(item["summary"]["backup_end"]), end=datetime.fromisoformat(item["summary"]["backup_end"]),
raw=item, raw=item,
parent=item.get("parent"), parent=item.get("parent"),
_engine="rustic" _engine="rustic",
) )
@staticmethod @staticmethod
@@ -47,9 +56,58 @@ class Snapshot:
time=datetime.fromisoformat(item["time"]), time=datetime.fromisoformat(item["time"]),
end=datetime.fromisoformat(item["summary"]["backup_end"]), end=datetime.fromisoformat(item["summary"]["backup_end"]),
raw=item, 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 @property
def name(self) -> str: def name(self) -> str:
return self.profile or "-".join(self.tags) return self.profile or "-".join(self.tags)
@@ -88,3 +146,171 @@ class Snapshot:
@property @property
def duration(self): def duration(self):
return self.end - self.time 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: with procs.Popen(cmd, stdin=procs.PIPE, env=env) as restic:
procs.check_call(src_cmd, stdout=restic.stdin) 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"]) cmd, env = self._prepare(["snapshots", "--compact"])
if format == "json": if format == "json":
cmd.append("--json") cmd.append("--json")
if profile:
cmd.extend(["--tag", f"lohup.profile={profile}"])
result = procs.check_output(cmd, env=env, encoding="utf-8") result = procs.check_output(cmd, env=env, encoding="utf-8")
if format == "json": if format == "json":
content = json.loads(result) content = json.loads(result)
@@ -78,6 +80,9 @@ class Restic:
return snapshots return snapshots
return result return result
def info(self, ref: str) -> engine.SnapshotInfo:
raise NotImplementedError
def _prepare(self, args: list[str]): def _prepare(self, args: list[str]):
if self.repo is None: if self.repo is None:
raise ValueError("repo not set") 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 json
import subprocess as procs import subprocess as procs
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path, PosixPath
import tomlkit import tomlkit
@@ -15,6 +20,7 @@ class Rustic:
repo: config.Repository repo: config.Repository
log: LoggerProto log: LoggerProto
conf_dir: Path conf_dir: Path
storage: Storage | None
binary: str = field(default="rustic") binary: str = field(default="rustic")
@property @property
@@ -85,24 +91,72 @@ class Rustic:
procs.check_call(src_cmd, stdout=rustic.stdin) procs.check_call(src_cmd, stdout=rustic.stdin)
def snapshots( def snapshots(
self, format="text", profile: str = None self, profile: str | None = None
) -> str | dict[str, list[engine.Snapshot]]: ) -> dict[str, list[engine.Snapshot]]:
cmd = self._cmdline() cmd = self._cmdline()
cmd.extend(["snapshots"]) cmd.extend(["snapshots", "--json"])
if format == "json":
cmd.append("--json")
if profile: if profile:
cmd.extend(["--filter-tags", f"lohup.profile={profile}"]) cmd.extend(["--filter-tags", f"lohup.profile={profile}"])
result = procs.check_output(cmd, encoding="utf-8") result = procs.check_output(cmd, encoding="utf-8", stderr=procs.DEVNULL)
if format == "json":
content = json.loads(result) content = json.loads(result)
snapshots = defaultdict(list) snapshots = []
grouped = defaultdict(list)
for group in content: for group in content:
key = group["group_key"] key = group["group_key"]
for x in group["snapshots"]: for x in group["snapshots"]:
snap = engine.Snapshot.from_rustic(x, group=key) snap = engine.Snapshot.from_rustic(x, group=key)
snapshots[snap.name].append(snap) grouped[snap.name].append(snap)
return snapshots 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 return result
def __enter__(self): 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" }, { 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]] [[package]]
name = "humanize" name = "humanize"
version = "4.13.0" version = "4.13.0"
@@ -66,6 +105,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "humanize" }, { name = "humanize" },
{ name = "sqlalchemy" },
{ name = "tomlkit" }, { name = "tomlkit" },
] ]
@@ -80,6 +120,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.2.1" }, { name = "click", specifier = ">=8.2.1" },
{ name = "humanize", specifier = ">=4.13.0" }, { name = "humanize", specifier = ">=4.13.0" },
{ name = "sqlalchemy", specifier = ">=2.0.52" },
{ name = "tomlkit", specifier = ">=0.13.3" }, { 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" }, { 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]] [[package]]
name = "tomlkit" name = "tomlkit"
version = "0.13.3" version = "0.13.3"
@@ -220,3 +282,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa3881
wheels = [ 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" }, { 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" },
]