From d315e7356101c4045d658705b69ac2dcd2933a81 Mon Sep 17 00:00:00 2001 From: Igor Ovsyannikov Date: Tue, 25 Aug 2026 03:03:13 +0300 Subject: [PATCH] feat: local cache on sqlite, new info command --- pyproject.toml | 1 + src/lohup/app.py | 95 +++++++++++--- src/lohup/cli.py | 46 ++++++- src/lohup/config.py | 3 + src/lohup/engine.py | 232 +++++++++++++++++++++++++++++++++- src/lohup/restic.py | 7 +- src/lohup/rustic.py | 86 ++++++++++--- src/lohup/storage/__init__.py | 0 src/lohup/storage/base.py | 5 + src/lohup/storage/file.py | 18 +++ src/lohup/storage/snapshot.py | 17 +++ src/lohup/storage/storage.py | 38 ++++++ uv.lock | 71 +++++++++++ 13 files changed, 575 insertions(+), 44 deletions(-) create mode 100644 src/lohup/storage/__init__.py create mode 100644 src/lohup/storage/base.py create mode 100644 src/lohup/storage/file.py create mode 100644 src/lohup/storage/snapshot.py create mode 100644 src/lohup/storage/storage.py diff --git a/pyproject.toml b/pyproject.toml index 9fc41f5..ed12e57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/lohup/app.py b/src/lohup/app.py index 81d2c69..40c560e 100644 --- a/src/lohup/app.py +++ b/src/lohup/app.py @@ -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 diff --git a/src/lohup/cli.py b/src/lohup/cli.py index d76ba62..7a7d0de 100644 --- a/src/lohup/cli.py +++ b/src/lohup/cli.py @@ -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() diff --git a/src/lohup/config.py b/src/lohup/config.py index 9965ec0..eb949d9 100644 --- a/src/lohup/config.py +++ b/src/lohup/config.py @@ -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: diff --git a/src/lohup/engine.py b/src/lohup/engine.py index 2d25213..f1c0375 100644 --- a/src/lohup/engine.py +++ b/src/lohup/engine.py @@ -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 diff --git a/src/lohup/restic.py b/src/lohup/restic.py index 38831ad..b419299 100644 --- a/src/lohup/restic.py +++ b/src/lohup/restic.py @@ -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") diff --git a/src/lohup/rustic.py b/src/lohup/rustic.py index 91b51ee..c7789a3 100644 --- a/src/lohup/rustic.py +++ b/src/lohup/rustic.py @@ -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": - content = json.loads(result) - snapshots = 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 + result = procs.check_output(cmd, encoding="utf-8", stderr=procs.DEVNULL) + content = json.loads(result) + 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) + 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\S+)\s+\S+\s+\S+\s+(?P\d+)\s+(?P[^"]+)"(?P[^"]+)"' + ) + 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): diff --git a/src/lohup/storage/__init__.py b/src/lohup/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lohup/storage/base.py b/src/lohup/storage/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/src/lohup/storage/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/src/lohup/storage/file.py b/src/lohup/storage/file.py new file mode 100644 index 0000000..2a9b6df --- /dev/null +++ b/src/lohup/storage/file.py @@ -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()) diff --git a/src/lohup/storage/snapshot.py b/src/lohup/storage/snapshot.py new file mode 100644 index 0000000..8fa95ee --- /dev/null +++ b/src/lohup/storage/snapshot.py @@ -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()) + + diff --git a/src/lohup/storage/storage.py b/src/lohup/storage/storage.py new file mode 100644 index 0000000..ec8388f --- /dev/null +++ b/src/lohup/storage/storage.py @@ -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__, + ], + ) diff --git a/uv.lock b/uv.lock index 7111b1c..12e11ce 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]