feat: local cache on sqlite, new info command
This commit is contained in:
1 parent
4e632b87df
commit
d315e73561
13 files changed
+570
-39
No files matched your search
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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__,
|
||||
],
|
||||
)
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in new issue
Block a user