feat: rustic support

This commit is contained in:
Igor Ovsyannikov
2025-09-21 15:29:05 +03:00
parent a2ff0ae91f
commit 0b05990776
10 changed files with 238 additions and 51 deletions
+8 -3
View File
@@ -1,24 +1,29 @@
[settings] [settings]
backup-base-dir = "/mnt/snap1" backup-base-dir = "/mnt/snap1"
tmp-dir = "/tmp/rustic"
# backup engine to use, "restic" (default) or "rustic"
subsystem-name = "rustic"
[settings.globalvars] [settings.globalvars]
# also built-in
BTRVOL = "snap1" BTRVOL = "snap1"
CONF_BASE = "/home/osmium/backup" CONF_BASE = "/home/osmium/backup"
[repo.cloud] [repo.cloud]
kind = "s3" kind = "s3"
# restic by default expects path-style bucket URL endpoint = "https://s3.some-storage.localdomain"
url = "https://s3.some-storage.localdomain//my-bucket/restic-backups/"
region = "us-east-1" region = "us-east-1"
access-key-file = "$CONF_BASE/.s3/access.key" access-key-file = "$CONF_BASE/.s3/access.key"
secret-key-file = "$CONF_BASE/.s3/secret.key" secret-key-file = "$CONF_BASE/.s3/secret.key"
repo-key-file = "$CONF_BASE/repo-s3.password" repo-key-file = "$CONF_BASE/repo-s3.password"
path = "/restic-backups"
bucket = "my-bucket"
[repo.localdir] [repo.localdir]
kind = "local" kind = "local"
path = "$CONF_BASE/local-repo" path = "$CONF_BASE/local-repo"
restic-key-file = "$CONF_BASE/repo-local.password" repo-key-file = "$CONF_BASE/repo-local.password"
default = true default = true
+2 -1
View File
@@ -7,7 +7,7 @@ maintainers = [
{name = "Igor Ovsyannikov"} {name = "Igor Ovsyannikov"}
] ]
license = "MIT" license = "MIT"
keywords = ["restic", "backups"] keywords = ["restic", "rustic", "backups"]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Environment :: Console", "Environment :: Console",
@@ -20,6 +20,7 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"click>=8.2.1", "click>=8.2.1",
"humanize>=4.13.0", "humanize>=4.13.0",
"tomlkit>=0.13.3",
] ]
[project.urls] [project.urls]
+2
View File
@@ -1 +1,3 @@
from lohup.app import Lohup from lohup.app import Lohup
__all__ = ("Lohup",)
+39 -32
View File
@@ -1,28 +1,31 @@
import subprocess as procs import subprocess as procs
from lohup import config from lohup import config
from lohup.logger import BasicLogger, LogLevel from lohup.logger import BasicLogger, LogLevel, LoggerProto
from lohup.restic import Restic from lohup.restic import Restic
from lohup.rustic import Rustic
class Lohup: class Lohup:
def __init__(self, config_path: str | None, logger=None): def __init__(self, config_path: str | None, logger: LoggerProto = None):
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.log = logger or BasicLogger(level=LogLevel.INFO) self.log = logger or BasicLogger(level=LogLevel.INFO)
def load(self): def load(self):
self.config = config.TomlConfig.from_file(self._config_path, logger=self.log) self.config = config.TomlConfig.from_file(self._config_path, logger=self.log)
self.subsystem = self.config.settings.subsystem
if self.subsystem not in ("restic", "rustic"):
raise KeyError(f"Invalid subsystem: {self.subsystem}")
def invoke_restic(self, repo: str, args: tuple[str, ...]): def invoke_direct(self, repo: str, args: tuple[str, ...]):
spec = self.config.repos.get(repo) spec = self.config.repos.get(repo) if repo else self._default_repo
if spec is None: if spec is None:
raise KeyError(f"Unknown repo: {repo}") raise KeyError(f"Unknown repo: {repo}")
restic = Restic(spec, self.log) engine = self._engine_for(spec)
try: with engine:
restic.run(args) engine.run(args)
except procs.CalledProcessError as e:
self.log.error(e)
def _exechooks(self, hooks: list): def _exechooks(self, hooks: list):
for h in hooks: for h in hooks:
@@ -43,9 +46,13 @@ class Lohup:
raise ValueError(f"Unknown btrfs action: {spec.action}") raise ValueError(f"Unknown btrfs action: {spec.action}")
procs.check_call(cmd) procs.check_call(cmd)
def _repo_for(self, profile: config.Profile): @property
def _default_repo(self):
default_list = list(filter(lambda x: x.default, self.config.repos.values())) default_list = list(filter(lambda x: x.default, self.config.repos.values()))
default = default_list.pop() if default_list else None return default_list.pop() if default_list else None
def _repo_for(self, profile: config.Profile):
default = self._default_repo
if profile.repo is not None: if profile.repo is not None:
if repo := self.config.repos.get(profile.repo): if repo := self.config.repos.get(profile.repo):
return repo return repo
@@ -55,52 +62,52 @@ 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: config.Repository):
match self.subsystem:
case "rustic":
return Rustic(
repo, log=self.log, conf_dir=self.config.settings.build_dir
)
case "restic":
return Restic(repo, log=self.log)
case x:
raise KeyError(f"Unknown subsystem: {x}")
def backup(self, profile: str): def backup(self, profile: str):
spec = self._profile_for(profile) spec = self._profile_for(profile)
repo = self._repo_for(spec) repo = self._repo_for(spec)
restic = Restic(repo, log=self.log) engine = self._engine_for(repo)
self._exechooks(self.config.hooks.before_all) self._exechooks(self.config.hooks.before_all)
try: try:
self._invoke_profile(restic, profile=profile) self._invoke_profile(engine, profile=spec)
finally: finally:
self._exechooks(self.config.hooks.after_all) self._exechooks(self.config.hooks.after_all)
self.log.info("Finished!") self.log.info("Finished!")
def backup_all(self): def backup_all(self):
restics = {} engines = {}
for name, spec in self.config.profiles.items(): for name, spec in self.config.profiles.items():
restic = Restic(self._repo_for(spec), log=self.log) engine = self._engine_for(self._repo_for(spec))
restics[name] = restic engines[name] = engine
self._exechooks(self.config.hooks.before_all) self._exechooks(self.config.hooks.before_all)
try: try:
for name, spec in self.config.profiles.items(): for name, spec in self.config.profiles.items():
restic = restics[name] self._invoke_profile(engines[name], profile=spec)
self._invoke_profile(restic, profile=spec)
finally: finally:
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, is_json=False): def snapshots(self, repo: str, is_json=False):
spec = self.config.repos.get(repo) spec = self.config.repos.get(repo) if repo else self._default_repo
if spec is None: if spec is None:
raise KeyError(f"Unknown repo: {repo}") raise KeyError(f"Unknown repo: {repo}")
restic = Restic(spec, log=self.log) restic = Restic(spec, log=self.log)
format = "json" if is_json else "text" format = "json" if is_json else "text"
return restic.snapshots(format=format) return restic.snapshots(format=format)
def _invoke_profile(self, restic: Restic, profile: config.Profile): def _invoke_profile(self, restic, profile: config.Profile):
args = ["backup", "--tag", profile.name] with restic as engine:
match profile: engine.backup(profile)
case config.PathsProfile():
args.append("--read-concurrency=6")
for pth in profile.exclude_paths:
args.extend(["-e", pth])
args.extend(profile.paths)
restic.run(args)
case config.CommandProfile():
args.append("--stdin")
restic.pipe_stdout(args, src_cmd=profile.command.split())
self.log.info("Backup created successfully.") self.log.info("Backup created successfully.")
def _profile_for(self, name: str): def _profile_for(self, name: str):
+6 -3
View File
@@ -28,7 +28,7 @@ def restic(obj: Lohup, repo, args: tuple[str, ...]):
""" """
Pass command to restic Pass command to restic
""" """
obj.invoke_restic(repo, args) obj.invoke_direct(repo, args)
@cli.command() @cli.command()
@@ -62,6 +62,8 @@ def snapshots(obj: Lohup, repo: str, raw_mode: bool):
summary = snap["summary"] summary = snap["summary"]
end = datetime.fromisoformat(summary["backup_end"]) end = datetime.fromisoformat(summary["backup_end"])
changes = summary["files_new"] + summary["files_changed"] changes = summary["files_new"] + summary["files_changed"]
total_files = summary["total_files_processed"]
changes_ratio = changes / total_files if total_files else 0
snapshots.append( snapshots.append(
dict( dict(
id=snap["short_id"], id=snap["short_id"],
@@ -71,7 +73,7 @@ def snapshots(obj: Lohup, repo: str, raw_mode: bool):
hostname=snap["hostname"], hostname=snap["hostname"],
duration=end - dt, duration=end - dt,
changes=changes, changes=changes,
changes_ratio=changes / summary["total_files_processed"], changes_ratio=changes_ratio,
size_comp=summary["data_added_packed"], size_comp=summary["data_added_packed"],
size_raw=summary["data_added"], size_raw=summary["data_added"],
processed=summary["total_bytes_processed"], processed=summary["total_bytes_processed"],
@@ -90,7 +92,8 @@ def snapshots(obj: Lohup, repo: str, raw_mode: bool):
click.echo(f"\tFiles changed (since previous): {snap['changes']} ({ratio})") click.echo(f"\tFiles changed (since previous): {snap['changes']} ({ratio})")
size = humanize.naturalsize(snap["size_raw"], binary=True) size = humanize.naturalsize(snap["size_raw"], binary=True)
compressed = humanize.naturalsize(snap["size_comp"], binary=True) compressed = humanize.naturalsize(snap["size_comp"], binary=True)
ratio = snap["size_raw"] / snap["processed"] total_bytes = snap["processed"]
ratio = snap["size_raw"] / total_bytes if total_bytes else 0
ratio = click.style(f"{ratio:.1f}%", fg="blue") ratio = click.style(f"{ratio:.1f}%", fg="blue")
click.echo(f"\tDiff size: {compressed}, unpacked: {size} ({ratio})") click.echo(f"\tDiff size: {compressed}, unpacked: {size} ({ratio})")
click.echo(f"\tHost: {snap['hostname']}") click.echo(f"\tHost: {snap['hostname']}")
+32 -8
View File
@@ -1,3 +1,4 @@
import platform
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
@@ -15,16 +16,29 @@ class ConfigError(ValueError):
class Settings: class Settings:
backup_base_dir: Path backup_base_dir: Path
globalvars: dict[str, str] globalvars: dict[str, str]
build_dir: Path
subsystem: str
@staticmethod @staticmethod
def load(conf: dict): def load(conf: dict):
with catch_errors() as catch: with catch_errors() as catch:
basedir = conf.get("backup-base-dir") basedir = conf.get("backup-base-dir")
basepath = Path(basedir) if basedir else Path.cwd() basepath = Path(basedir) if basedir else Path.cwd()
tmpdir = conf.get("tmp-dir")
tmp_path = Path("/tmp/lohup")
if not tmpdir:
if platform.system() == "Windows":
tmp_path = Path.home().joinpath("AppData", "Local", "Temp", "lohup")
else:
tmp_path = Path(tmpdir)
settings = Settings( settings = Settings(
backup_base_dir=basepath, globalvars=conf.get("globalvars") or {} backup_base_dir=basepath,
globalvars=conf.get("globalvars") or {},
build_dir=tmp_path,
subsystem=conf.get("subsystem-name", "restic"),
) )
settings.globalvars["BDIR"] = str(basepath) settings.globalvars["BDIR"] = str(basepath)
settings.globalvars["BUILDDIR"] = str(settings.build_dir)
for key, value in settings.globalvars.items(): for key, value in settings.globalvars.items():
if "$" in value: if "$" in value:
catch.error( catch.error(
@@ -59,27 +73,33 @@ class LocalRepository:
@dataclass @dataclass
class S3Repository: class S3Repository:
name: str name: str
url: str endpoint: str
region: str region: str
access_key_file: Masked access_key_file: Masked
secret_key_file: Masked secret_key_file: Masked
repo_key_file: Masked repo_key_file: Masked
bucket: str
path: str
default: bool default: bool
@staticmethod @staticmethod
def load(name: str, conf: dict, expander: VarExpander): def load(name: str, conf: dict, expander: VarExpander):
repo = S3Repository( repo = S3Repository(
name=name, name=name,
url=expander.expand(conf.get("url", "")), endpoint=expander.expand(conf.get("endpoint", "https://s3.amazonaws.com")),
region=conf.get("region", ""), region=conf.get("region", ""),
access_key_file=Masked(expander.expand(conf.get("access-key-file", ""))), access_key_file=Masked(expander.expand(conf.get("access-key-file", ""))),
secret_key_file=Masked(expander.expand(conf.get("secret-key-file", ""))), secret_key_file=Masked(expander.expand(conf.get("secret-key-file", ""))),
repo_key_file=Masked(expander.expand(conf.get("repo-key-file", ""))), repo_key_file=Masked(expander.expand(conf.get("repo-key-file", ""))),
bucket=expander.expand(conf.get("bucket", "")),
path=expander.expand(conf.get("path", "/")),
default=conf.get("default", False), default=conf.get("default", False),
) )
with catch_errors() as catcher: with catch_errors() as catcher:
if not repo.url: if not repo.endpoint:
catcher.error("field 'url': not set") catcher.error("field 'endpoint': not set")
if not repo.bucket:
catcher.error("field 'bucket': not set")
if value := repo.access_key_file.value: if value := repo.access_key_file.value:
if msg := ensure_exists(value, field="access-key-file"): if msg := ensure_exists(value, field="access-key-file"):
catcher.error(msg) catcher.error(msg)
@@ -178,13 +198,15 @@ class PathsProfile:
repo: str | None repo: str | None
paths: list[str] paths: list[str]
exclude_paths: list[str] exclude_paths: list[str]
cli_args: list[str]
@dataclass @dataclass
class CommandProfile: class CommandProfile:
name: str name: str
repo: str | None repo: str | None
command: str command: str | list[str]
cli_args: list[str]
Profile = PathsProfile | CommandProfile Profile = PathsProfile | CommandProfile
@@ -205,7 +227,7 @@ class TomlConfig:
with catch_errors(failfast=logger.accepts(LogLevel.VERBOSE)) as catcher: with catch_errors(failfast=logger.accepts(LogLevel.VERBOSE)) as catcher:
return TomlConfig._ffile_impl(name, path, catcher) return TomlConfig._ffile_impl(name, path, catcher)
except CatcherError as e: except CatcherError as e:
raise ConfigError(f"Failed to parse TOML config") from e raise ConfigError("Failed to parse TOML config") from e
@staticmethod @staticmethod
def _ffile_impl(name: str, path: Path, catcher): def _ffile_impl(name: str, path: Path, catcher):
@@ -247,9 +269,10 @@ class TomlConfig:
) )
profiles = {} profiles = {}
for name, opts in conf.get("profiles", {}).items(): for name, opts in conf.get("profiles", {}).items():
args = opts.get("cli-args", [])
if cmd := opts.get("command"): if cmd := opts.get("command"):
profiles[name] = CommandProfile( profiles[name] = CommandProfile(
name, repo=opts.get("repo"), command=cmd name, repo=opts.get("repo"), command=cmd, cli_args=args
) )
else: else:
paths = opts.get("paths") paths = opts.get("paths")
@@ -263,6 +286,7 @@ class TomlConfig:
exclude_paths=[ exclude_paths=[
expander.expand(x) for x in opts.get("exclude-paths", []) expander.expand(x) for x in opts.get("exclude-paths", [])
], ],
cli_args=args,
) )
toml = TomlConfig( toml = TomlConfig(
settings=settings, settings=settings,
+18 -3
View File
@@ -6,6 +6,7 @@ import enum
class LogLevel(enum.IntEnum): class LogLevel(enum.IntEnum):
ERROR = 40 ERROR = 40
WARNING = 30
INFO = 20 INFO = 20
VERBOSE = 15 VERBOSE = 15
DEBUG = 10 DEBUG = 10
@@ -22,15 +23,23 @@ class CliLogger:
import traceback import traceback
msg = "".join(traceback.format_exception(msg)) msg = "".join(traceback.format_exception(msg))
click.echo(click.style(msg, fg="red")) msg = (click.style("[error]", fg="red"), msg)
click.echo(" ".join(msg))
def warning(self, msg):
if self.level <= LogLevel.WARNING:
msg = (click.style("[warn]", fg="orange"), msg)
click.echo(" ".join(msg))
def info(self, msg): def info(self, msg):
if self.level <= LogLevel.INFO: if self.level <= LogLevel.INFO:
click.echo(click.style(msg, fg="blue")) msg = (click.style("[info]", fg="blue"), msg)
click.echo(" ".join(msg))
def debug(self, msg): def debug(self, msg):
if self.level <= LogLevel.DEBUG: if self.level <= LogLevel.DEBUG:
click.echo(click.style(msg, fg="white")) msg = (click.style("[debug]", fg="white"), msg)
click.echo(" ".join(msg))
def accepts(self, level): def accepts(self, level):
return self.level <= level return self.level <= level
@@ -51,6 +60,9 @@ class BasicLogger:
else: else:
self.logger.error(msg) self.logger.error(msg)
def warning(self, msg):
self.logger.warning(msg)
def info(self, msg): def info(self, msg):
self.logger.info(msg) self.logger.info(msg)
@@ -59,3 +71,6 @@ class BasicLogger:
def accepts(self, level): def accepts(self, level):
return self.level <= level return self.level <= level
LoggerProto = CliLogger | BasicLogger
+28 -1
View File
@@ -24,7 +24,9 @@ class Restic:
if masked := self.repo.access_key_file: if masked := self.repo.access_key_file:
env["AWS_ACCESS_KEY_ID"] = Path(masked.value).read_text().strip() env["AWS_ACCESS_KEY_ID"] = Path(masked.value).read_text().strip()
if masked := self.repo.secret_key_file: if masked := self.repo.secret_key_file:
env["AWS_SECRET_ACCESS_KEY"] = Path(masked.value).read_text().strip() env["AWS_SECRET_ACCESS_KEY"] = (
Path(masked.value).read_text().strip()
)
env["RESTIC_REPOSITORY"] = f"s3:{self.repo.url}" env["RESTIC_REPOSITORY"] = f"s3:{self.repo.url}"
case config.LocalRepository(): case config.LocalRepository():
env["RESTIC_REPOSITORY"] = self.repo.path env["RESTIC_REPOSITORY"] = self.repo.path
@@ -34,6 +36,25 @@ class Restic:
cmd, env = self._prepare(args) cmd, env = self._prepare(args)
procs.check_call(cmd, env=env) procs.check_call(cmd, env=env)
def backup(self, profile: config.Profile):
args = ["backup", "--tag", profile.name]
args.extend(profile.cli_args)
match profile:
case config.PathsProfile():
for pth in profile.exclude_paths:
args.extend(["-e", pth])
args.extend(profile.paths)
self.run(args)
case config.CommandProfile():
args.append("--stdin")
match profile.command:
case str(x):
self.pipe_stdout(args, src_cmd=x.split())
case list(x):
self.pipe_stdout(args, src_cmd=x)
case _:
raise NotImplementedError(profile.command)
def pipe_stdout(self, args: list[str], src_cmd: list[str]): def pipe_stdout(self, args: list[str], src_cmd: list[str]):
cmd = [self.binary] + args cmd = [self.binary] + args
env = self.environ() env = self.environ()
@@ -56,3 +77,9 @@ class Restic:
cmd.extend(args) cmd.extend(args)
env = self.environ() env = self.environ()
return cmd, env return cmd, env
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
return self
+92
View File
@@ -0,0 +1,92 @@
from pathlib import Path
from dataclasses import dataclass, field
import subprocess as procs
import json
import tomlkit
from lohup import config
from lohup.logger import LoggerProto
@dataclass
class Rustic:
repo: config.Repository
log: LoggerProto
conf_dir: Path
binary: str = field(default="rustic")
@property
def conf_file(self) -> Path:
return self.conf_dir / "rustic.toml"
def write_config(self) -> Path:
out = {}
repo_pass = Path(self.repo.repo_key_file.value).read_text().strip()
out["repository"] = {"password": repo_pass}
match self.repo:
case config.S3Repository():
out["repository"]["repository"] = "opendal:s3"
opts = {}
if masked := self.repo.access_key_file:
opts["access_key_id"] = Path(masked.value).read_text().strip()
if masked := self.repo.secret_key_file:
opts["secret_access_key"] = Path(masked.value).read_text().strip()
opts["endpoint"] = self.repo.endpoint
opts["bucket"] = self.repo.bucket
opts["root"] = self.repo.path
opts["region"] = self.repo.region
out["repository"]["options"] = opts
case config.LocalRepository():
out["repository"]["repository"] = self.repo.path
self.conf_dir.mkdir(exist_ok=True)
with self.conf_file.open("w") as f:
tomlkit.dump(out, f)
def _cmdline(self):
out = [self.binary, "--log-level=warn", "-P", str(self.conf_dir / "rustic")]
return out
def run(self, args):
cmd = self._cmdline()
cmd.extend(args)
procs.check_call(cmd)
def backup(self, profile: config.Profile):
args = ["backup", "--tag", profile.name]
args.extend(profile.cli_args)
match profile:
case config.PathsProfile():
for pth in profile.exclude_paths:
args.extend(["--glob", f"!{pth}"])
args.extend(profile.paths)
self.run(args)
case config.CommandProfile():
args.append("-")
match profile.command:
case str(x):
self.pipe_stdout(args, src_cmd=x.split())
case list(x):
self.pipe_stdout(args, src_cmd=x)
def pipe_stdout(self, args: list[str], src_cmd: list[str]):
cmd = self._cmdline()
cmd.extend(args)
with procs.Popen(cmd, stdin=procs.PIPE) as rustic:
procs.check_call(src_cmd, stdout=rustic.stdin)
def snapshots(self, format="text"):
cmd = self._cmdline()
cmd.extend(["snapshots", "--compact"])
if format == "json":
cmd.append("--json")
result = procs.check_output(cmd, encoding="utf-8")
if format == "json":
return json.loads(result)
return result
def __enter__(self):
self.write_config()
return self
def __exit__(self, type, value, traceback):
self.conf_file.unlink(True)
Generated
+11
View File
@@ -57,6 +57,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "humanize" }, { name = "humanize" },
{ name = "tomlkit" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -69,6 +70,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 = "tomlkit", specifier = ">=0.13.3" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -199,3 +201,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887
wheels = [ 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 = "tomlkit"
version = "0.13.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
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" },
]