diff options
| author | Dennis Fink | 2026-05-06 19:51:45 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-05-06 19:51:45 +0200 |
| commit | 5a2d4ca818e6ee45f99e4c92310e137a7b1b4aea (patch) | |
| tree | 9f1338915f0755c71f5c65573019939dbef0c24a | |
| parent | d9e410a10b2c175fc32437b35c59eccf5172b051 (diff) | |
| download | prometheus-borgmatic-exporter-1.0.0.tar.gz prometheus-borgmatic-exporter-1.0.0.zip | |
feat: implement initial borgmatic Prometheus exporterv1.0.0
| -rw-r--r-- | .python-version | 1 | ||||
| -rw-r--r-- | LICENSES/BSD-3-Clause.txt | 11 | ||||
| -rw-r--r-- | README.md | 97 | ||||
| -rw-r--r-- | contrib/prometheus-borgmatic-exporter.confd | 1 | ||||
| -rw-r--r-- | contrib/prometheus-borgmatic-exporter.service | 61 | ||||
| -rw-r--r-- | contrib/prometheus-borgmatic-exporter.timer | 11 | ||||
| -rw-r--r-- | prometheus_borgmatic_exporter/__init__.py | 568 | ||||
| -rw-r--r-- | prometheus_borgmatic_exporter/__main__.py | 3 | ||||
| -rw-r--r-- | prometheus_borgmatic_exporter/metrics.py | 124 | ||||
| -rw-r--r-- | prometheus_borgmatic_exporter/types.py | 88 | ||||
| -rw-r--r-- | pyproject.toml | 64 | ||||
| -rw-r--r-- | uv.lock | 348 |
12 files changed, 1377 insertions, 0 deletions
diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt new file mode 100644 index 0000000..092966d --- /dev/null +++ b/LICENSES/BSD-3-Clause.txt @@ -0,0 +1,11 @@ +Copyright (c) 2026 Dennis Fink <me+coding@dennisfink.me>. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dc82ff --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# prometheus-borgmatic-exporter + +A Prometheus metrics exporter for [borgmatic](https://torsion.org/borgmatic/), +designed to be run via a systemd timer and consumed by the [node exporter +textfile +collector](https://github.com/prometheus/node_exporter#textfile-collector). + +## Requirements + +- Python 3.11+ +- borgmatic + +## Installation + +```bash +pip install prometheus-borgmatic-exporter +``` + +## Usage + +``` +Usage: prometheus-borgmatic-exporter [OPTIONS] + + Collect borgmatic repository metrics and export them for Prometheus. + + Queries borgmatic for repository and archive statistics and writes them + to a .prom file for consumption by the Prometheus node exporter textfile + collector. + + By default only the latest archive is queried per repository. Use + --all-archives to collect metrics for every archive, at the cost of + a significantly longer runtime. + + Using --all-archives will create one time series per archive and may + significantly increase Prometheus storage usage. + +Options: + --borgmatic-bin PATH Path to the borgmatic executable. + [default: /usr/bin/borgmatic] + --borgmatic-config PATH Path to the borgmatic configuration file. + [default: /etc/borgmatic/config.yaml] + --textfile-collector-dir PATH Directory where the .prom file will be + written. + [default: /var/lib/prometheus/node-exporter] + --all-archives Collect metrics for all archives, not just + the latest. Significantly slower for large + repositories. + -q, --quiet Suppress all non-error output. + -v, --verbose Enable verbose output. + --color / --no-color Force or disable colored output. + --version Show version information and exit. + -h, --help, -? Show this message and exit. +``` + +## Metrics + +All metrics carry the labels `repository_id`, `repository_label`, and `location`. + +### Repository metrics + +| Metric | Description | +|--------|-------------| +| `borg_total_chunks` | Total number of chunks across all archives | +| `borg_total_compressed_size_bytes` | Total compressed size of all chunks | +| `borg_total_original_size_bytes` | Total original size of all chunks | +| `borg_total_unique_chunks` | Number of unique chunks (deduplicated) | +| `borg_total_deduplicated_compressed_size_bytes` | Compressed size of unique chunks | +| `borg_total_deduplicated_size_bytes` | Original size of unique chunks | +| `borg_last_modified_timestamp_seconds` | Unix timestamp of last repository modification | +| `borg_archives` | Total number of archives in the repository | +| `borg_repository_encryption_info` | Encryption mode as an info metric | + +### Latest archive metrics + +| Metric | Description | +|--------|-------------| +| `borg_latest_archive_compressed_size_bytes` | Compressed size of the latest archive | +| `borg_latest_archive_deduplicated_size_bytes` | Deduplicated size of the latest archive | +| `borg_latest_archive_files_total` | Number of files in the latest archive | +| `borg_latest_archive_original_size_bytes` | Original size of the latest archive | +| `borg_latest_archive_duration_seconds` | Duration of the latest archive creation | + +### Per-archive metrics (--all-archives only) + +These metrics additionally carry `archive_id` and `archive_name` labels. + +| Metric | Description | +|--------|-------------| +| `borg_archive_compressed_size_bytes` | Compressed size of the archive | +| `borg_archive_deduplicated_size_bytes` | Deduplicated size of the archive | +| `borg_archive_files_total` | Number of files in the archive | +| `borg_archive_original_size_bytes` | Original size of the archive | +| `borg_archive_duration_seconds` | Duration of the archive creation | + +## License + +BSD-3-Clause. See [LICENSE](LICENSES/BSD-3-Clause.txt) for details. diff --git a/contrib/prometheus-borgmatic-exporter.confd b/contrib/prometheus-borgmatic-exporter.confd new file mode 100644 index 0000000..cf82ace --- /dev/null +++ b/contrib/prometheus-borgmatic-exporter.confd @@ -0,0 +1 @@ +PROMETHEUS_BORGMATIC_EXPORTER_ARGS="" diff --git a/contrib/prometheus-borgmatic-exporter.service b/contrib/prometheus-borgmatic-exporter.service new file mode 100644 index 0000000..56c3d18 --- /dev/null +++ b/contrib/prometheus-borgmatic-exporter.service @@ -0,0 +1,61 @@ +[Unit] +Description=Borgmatic Prometheus metrics exporter +After=network.target + +[Service] +Type=oneshot +EnvironmentFile=/etc/conf.d/prometheus-borgmatic-exporter +ExecStart=/usr/bin/prometheus-borgmatic-exporter $PROMETHEUS_BORGMATIC_EXPORTER_ARGS + +User=root +Group=root + +# Needs write access to emit the .prom file and read/write the borg cache. +# All other paths read-only by default +ReadWritePaths=/var/lib/prometheus/node-exporter +ReadWritePaths=/root/.cache/borg +# Mount a private /tmp not shared with the rest of the system +PrivateTmp=true +# Mount a minimal /dev without the access to raw block or character devices +PrivateDevices=true +# ProtectSystem and ProtectHome are ineffective for root and intentionally omitted + +# Hide other processes in /proc +ProtectProc=invisible +# Limit /proc to PID-related files only +ProcSubset=pid +# Set predictable permissions for the written .prom file +UMask=0022 + +# Restrict to the address families needed for SSH and local communication +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +# Kernel and system hardening +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectHostname=true +ProtectClock=true +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +RemoveIPC=true + +# Capabilites +# Root always holds capabilites, but we restrict what child processes can inherit +# CAP_DAC_OVERRIDE: needed by borg for file acess +# CAP_NET_RAW: needed for SSH connections +CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_RAW +AmbientCapabilities= +# NoNewPrivileges is intentionally omitted as borg needs to exec SSH + +# Syscall filtering +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources @mount @swap @reboot + +[Install] +WantedBy=multi-user.target diff --git a/contrib/prometheus-borgmatic-exporter.timer b/contrib/prometheus-borgmatic-exporter.timer new file mode 100644 index 0000000..61fa6a8 --- /dev/null +++ b/contrib/prometheus-borgmatic-exporter.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run Borgmatic Prometheus metrics exporter daily +After=network.target + +[Timer] +OnCalendar=daily +AccuracySec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/prometheus_borgmatic_exporter/__init__.py b/prometheus_borgmatic_exporter/__init__.py new file mode 100644 index 0000000..ac2721e --- /dev/null +++ b/prometheus_borgmatic_exporter/__init__.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + + +import json +import os +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +import click_extra as click +from prometheus_client import write_to_textfile + +from . import metrics, types + +############################################################################### +# SCRIPT METADATA +# +# These variables describe the script and are used for the --version output +# and for informational messages. +############################################################################### + +VERSION = "1.0.0" +DESCRIPTION = "Collect borgmatic repository metrics and export them for Prometheus." +DATE_OF_CREATION = "2021-06-12" +DATE_OF_REVISION = "2026-05-01" +AUTHOR = "Dennis Fink <me+coding@dennisfink.me>" +LICENSE = "BSD-3-Clause" + + +############################################################################### +# FUNCTIONS +# +# This section defines the helper functions used throughout the script. They +# handle formatted output, error reporting, version information, utilities +# functions and runtime behaviour such as displaying usage instructions. +############################################################################### + + +def emit(prefix: str, color: str, *message: str, enabled: bool = True) -> None: + """Render a styled prefix and message to stdout. + + Internal helper used by error(), msg(), warn(), verbose(), and debug() + to avoid duplicating the Click styling and echo logic. The first message + argument is rendered in bold, subsequent arguments are appended unstyled. + + Args: + prefix: The prefix string shown before the message (e.g. "==>", "==> ERROR:"). + color: A Click-compatible color name (e.g. "red", "green") applied to the prefix. + *message: One or more message parts to display. At least one is required. + enabled: If False, the function returns immediately without printing. + Defaults to True. + + Raises: + TypeError: If no message arguments are provided. + """ + if not message: + raise TypeError("emit() missing 1 required positional argument: 'message'") + + if not enabled: + return + + click.echo( + " ".join( + [ + click.style(prefix, fg=color, bold=True), + click.style(message[0], bold=True), + *message[1:], + ] + ) + ) + + +def error(*message: str) -> None: + """Print a formatted error message to stdout in red. + + The first argument is rendered in bold, subsequent arguments are appended + unstyled. Always prints regardless of quiet or verbose flags. + + Args: + *message: One or more message parts to display. + """ + emit("==> ERROR:", "red", *message) + + +@click.pass_obj +def msg(obj: dict[str, bool], *message: str) -> None: + """Print a formatted informational message to stdout in green. + + Suppressed when the quiet flag is set. The first argument is rendered + in bold, subsequent arguments are appended unstyled. + + Args: + *message: One or more message parts to display. + """ + emit("==>", "green", *message, enabled=not obj.get("quiet", False)) + + +@click.pass_obj +def warn(obj: dict[str, bool], *message: str) -> None: + """Print a formatted warning message to stdout in yellow. + + Suppressed when the quiet flag is set. The first argument is rendered + in bold, subsequent arguments are appended unstyled. + + Args: + *message: One or more message parts to display. + """ + emit("==>", "yellow", *message, enabled=not obj.get("quiet", False)) + + +@click.pass_obj +def verbose(obj: dict[str, bool], *message: str) -> None: + """Print a formatted verbose message to stdout in blue. + + Only prints when the verbose flag is set and the quiet flag is not. + The first argument is rendered in bold, subsequent arguments are + appended unstyled. + + Args: + *message: One or more message parts to display. + """ + emit( + "==>", + "blue", + *message, + enabled=obj.get("verbose", False) and not obj.get("quiet", False), + ) + + +@click.pass_obj +def debug(obj: dict[str, bool], *message: str) -> None: + """Print a formatted debug message to stdout in magenta. + + Only prints when the DEBUG environment variable is set to a truthy + value (1, true, yes). The first argument is rendered in bold, + subsequent arguments are appended unstyled. + + Args: + *message: One or more message parts to display. + """ + emit("==>", "magenta", *message, enabled=obj.get("debug", False)) + + +def print_version( + ctx: click.Context, + param: click.Parameter | None, + value: bool, +) -> None: + """Print script metadata (name, version, description, author, dates) and exit. + + Args: + ctx: The Click context associated with the currently running command. + """ + if not value or ctx.resilient_parsing: + return + + click.echo(click.style("Scriptname:", fg="red", bold=True) + f" {ctx.info_name}") + click.echo(click.style("Version:", fg="green", bold=True) + f" {VERSION}") + click.echo(click.style("Description:", fg="yellow", bold=True) + f" {DESCRIPTION}") + click.echo(click.style("Author:", fg="blue", bold=True) + f" {AUTHOR}") + click.echo( + click.style("Date of creation:", fg="magenta", bold=True) + + f" {DATE_OF_CREATION}" + ) + click.echo( + click.style("Date of revision:", fg="cyan", bold=True) + f" {DATE_OF_REVISION}" + ) + click.echo(click.style("License:", fg="red", bold=True) + f" {LICENSE}") + + click.echo("""Copyright (c) 2026 Dennis Fink <me+coding@dennisfink.me>. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors +may be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.""") + + ctx.exit() + + +############################################################################### +# MAIN EXECUTION +############################################################################### + + +@click.pass_context +def run_command( + ctx: click.Context, command: list[str] +) -> subprocess.CompletedProcess[str]: + """Run a shell command and return the result. + + Logs the command at debug level before execution. Exits the application + with the command's return code if it fails. + + Args: + ctx: The Click context, injected by @click.pass_context. + command: The command and its arguments as a list of strings. + + Returns: + The completed process result with stdout and stderr captured. + """ + debug("Running command", " ".join(command)) + try: + return subprocess.run( + command, + capture_output=True, + encoding="utf-8", + check=True, + ) + except subprocess.CalledProcessError as e: + error(f"Command ({' '.join(command)}) failed with exit code {e.returncode}") + if e.stderr: + error("stderr:", e.stderr.strip()) + ctx.exit(e.returncode) + + +@click.pass_context +def load_json(ctx: click.Context, raw: str) -> Any: + """Parse a JSON string and return the result. + + Exits the application with code 1 if the input is not valid JSON, + printing the parse error via the error() helper. + + Args: + ctx: The Click context, injected by @click.pass_context. + raw: The raw JSON string to parse. + + Returns: + The parsed JSON value. + """ + try: + return json.loads(raw) + except json.JSONDecodeError as e: + error("Failed to parse JSON:", str(e)) + ctx.exit(1) + + +def extract_archive_counts( + repositories: list[types.BorgInfoReturnType] | list[types.BorgListReturnType], +) -> list[types.ArchiveCount]: + """Extract archive counts and repository metadata from borgmatic info output. + + Args: + repositories: List of repository objects as returned by borgmatic info + or borgmatic list. + + Returns: + A list of ArchiveCount dicts, one per repository, containing the + repository id, label, location, and total number of archives. + """ + return [ + { + "id": repo["repository"]["id"], + "label": repo["repository"]["label"], + "location": repo["repository"]["location"], + "count": len(repo["archives"]), + } + for repo in repositories + ] + + +def fetch_repositories( + borgmatic_bin: Path, + borgmatic_config: Path, + all_archives: bool, +) -> tuple[list[types.BorgInfoReturnType], list[types.ArchiveCount]]: + """ + Fetch repository and archive data from borgmatic. + + When all_archives is False, runs `borgmatic info --archive latest` and + `borgmatic list` in complementary intent: info provides per-repository + stats and the latest archive metrics, while list provides the total archive + count — since info with --archive latest only returns a single archive, + making len() unreliable for counting. + + When all_archives is True, only `borgmatic info` is run. The full archive + list is available directly from the info output, so no separate list call + is needed and archive counts are derived from it. + + Args: + borgmatic_bin: Path to the borgmatic executable. + borgmatic_config: Path to the borgmatic configuration file. + all_archives: If True, fetch and return metrics for all archives. + This is significantly slower for large repositories. + + Returns: + A tuple of: + - repositories: Raw borgmatic info output, one entry per repository. + - archive_counts: Archive count and identifying metadata per repository, + always populated regardless of the all_archives flag. + """ + verbose("Fetching repositories") + borgmatic_cmd = [str(borgmatic_bin), "--config", str(borgmatic_config)] + info_command = [*borgmatic_cmd, "info", "--json"] + + if all_archives: + borgmatic_info = run_command(info_command) + repositories = cast( + list[types.BorgInfoReturnType], + load_json(borgmatic_info.stdout), + ) + debug(f"Fetched {len(repositories)} repositories") + return repositories, extract_archive_counts(repositories) + + info_command += ["--archive", "latest"] + borgmatic_info = run_command(info_command) + repositories = cast( + list[types.BorgInfoReturnType], load_json(borgmatic_info.stdout) + ) + debug(f"Fetched {len(repositories)} repositories") + + borgmatic_list = run_command([*borgmatic_cmd, "list", "--json"]) + archives = cast(list[types.BorgListReturnType], load_json(borgmatic_list.stdout)) + return repositories, extract_archive_counts(archives) + + +def collect_repository_metrics( + repository: types.BorgInfoReturnType, all_archives: bool +) -> None: + """Collect and set all Prometheus metrics for a single repository. + + Sets cache stats, encryption info, last modified timestamp, and latest + archive metrics. When all_archives is True, also collects metrics for + every individual archive by calling _collect_archive_metrics. + + Args: + repository: Repository info as returned by borgmatic. + all_archives: If True, collect metrics for all archives, not just + the latest. + """ + repository_metadata = repository["repository"] + + msg( + "Processing repository:", + repository_metadata["label"], + f"({repository_metadata['id']})", + repository_metadata["location"], + ) + + repository_labels: types.RepositoryLabels = { + "repository_id": repository_metadata["id"], + "repository_label": repository_metadata["label"], + "location": repository_metadata["location"], + } + + repository_last_modified = datetime.fromisoformat( + repository_metadata["last_modified"] + ) + metrics.LAST_MODIFIED.labels(**repository_labels).set( + repository_last_modified.timestamp() + ) + + metrics.ENCRYPTION.labels(**repository_labels).info( + {"mode": repository["encryption"]["mode"]} + ) + + repository_stats = repository["cache"]["stats"] + for metric, key in ( + (metrics.TOTAL_CHUNKS, "total_chunks"), + (metrics.TOTAL_COMPRESSED_SIZE, "total_csize"), + (metrics.TOTAL_ORIGINAL_SIZE, "total_size"), + (metrics.TOTAL_UNIQUE_CHUNKS, "total_unique_chunks"), + (metrics.TOTAL_DEDUPLICATED_COMPRESSED_SIZE, "unique_csize"), + (metrics.TOTAL_DEDUPLICATED_SIZE, "unique_size"), + ): + metric.labels(**repository_labels).set(repository_stats[key]) + + if not repository["archives"]: + warn("No archives found for repository:", repository_metadata["label"]) + return + + latest_archive = max( + repository["archives"], + key=lambda archive: archive["start"], + ) + debug(f"Latest archive {latest_archive['name']}:", latest_archive["start"]) + + metrics.LATEST_ARCHIVE_DURATION.labels(**repository_labels).set( + latest_archive["duration"] + ) + + latest_archive_stats = latest_archive["stats"] + for metric, key in ( + (metrics.LATEST_ARCHIVE_COMPRESSED_SIZE, "compressed_size"), + (metrics.LATEST_ARCHIVE_DEDUPLICATED_SIZE, "deduplicated_size"), + (metrics.LATEST_ARCHIVE_NUMBER_FILES, "nfiles"), + (metrics.LATEST_ARCHIVE_ORIGINAL_SIZE, "original_size"), + ): + metric.labels(**repository_labels).set(latest_archive_stats[key]) + + if all_archives: + for archive in repository["archives"]: + collect_archive_metrics(archive, repository_labels) + + +def collect_archive_metrics( + archive: types.BorgArchive, + repository_labels: types.RepositoryLabels, +) -> None: + """Collect and set Prometheus metrics for a single archive. + + Args: + archive: Archive info as returned by borgmatic. + repository_labels: The parent repository's label dict, used to + populate the shared label dimensions on each metric. + """ + msg("Processing archive:", archive["name"], f"({archive['id']})") + + archive_labels = { + **repository_labels, + "archive_id": archive["id"], + "archive_name": archive["name"], + } + + metrics.ARCHIVE_DURATION.labels(**archive_labels).set(archive["duration"]) + + archive_stats = archive["stats"] + for metric, key in ( + (metrics.ARCHIVE_COMPRESSED_SIZE, "compressed_size"), + (metrics.ARCHIVE_DEDUPLICATED_SIZE, "deduplicated_size"), + (metrics.ARCHIVE_NUMBER_FILES, "nfiles"), + (metrics.ARCHIVE_ORIGINAL_SIZE, "original_size"), + ): + metric.labels(**archive_labels).set(archive_stats[key]) + + +@click.command( + context_settings={"help_option_names": ("-h", "--help", "-?")}, + formatter_settings=click.HelpExtraFormatter.settings( + theme=click.HelpExtraTheme.light(), + ), + params=[], +) +@click.color_option( + "--color/--no-color", + help="Force/Disable colored output.", +) +@click.option( + "--borgmatic-bin", + type=click.Path( + exists=True, + readable=True, + executable=True, + dir_okay=False, + allow_dash=False, + path_type=Path, + ), + default="/usr/bin/borgmatic", +) +@click.option( + "--borgmatic-config", + type=click.Path( + exists=True, + readable=True, + dir_okay=False, + allow_dash=False, + path_type=Path, + ), + default="/etc/borgmatic/config.yaml", +) +@click.option( + "--textfile-collector-dir", + type=click.Path( + exists=True, + readable=True, + writable=True, + file_okay=False, + allow_dash=False, + path_type=Path, + ), + default="/var/lib/prometheus/node-exporter", +) +@click.option("--all-archives", "all_archives_flag", is_flag=True, default=False) +@click.option("-q", "--quiet", "quiet_flag", is_flag=True, default=False) +@click.option("-v", "--verbose", "verbose_flag", is_flag=True, default=False) +@click.option( + "--version", + callback=print_version, + default=False, + expose_value=False, + help="Show the version and exit.", + is_eager=True, + is_flag=True, +) +@click.pass_context +def prometheus_borgmatic_exporter( + ctx: click.Context, + borgmatic_bin: Path, + borgmatic_config: Path, + textfile_collector_dir: Path, + all_archives_flag: bool, + quiet_flag: bool, + verbose_flag: bool, +): + """Collect borgmatic repository metrics and export them for Prometheus. + + Queries borgmatic for repository and archive statistics and writes them + to a .prom file for consumption by the Prometheus node exporter textfile + collector. + + By default only the latest archive is queried per repository. Use + --all-archives to collect metrics for every archive, at the cost of + a significantly longer runtime. + + Using --all-archives will create one time series per archive and may + significantly increase Prometheus storage usage. + """ + + ctx.ensure_object(dict) + ctx.obj["debug"] = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes") + ctx.obj["quiet"] = quiet_flag + ctx.obj["verbose"] = verbose_flag + + debug("Effective configuration:") + debug(" borgmatic_bin:", str(borgmatic_bin)) + debug(" borgmatic_config:", str(borgmatic_config)) + debug(" textfile_collector_dir:", str(textfile_collector_dir)) + debug(" all_archives:", str(all_archives_flag)) + debug(" verbose:", str(verbose_flag)) + debug(" quiet:", str(quiet_flag)) + + repositories, archive_counts = fetch_repositories( + borgmatic_bin, + borgmatic_config, + all_archives_flag, + ) + + for repository in archive_counts: + metrics.ARCHIVES.labels( + repository_id=repository["id"], + repository_label=repository["label"], + location=repository["location"], + ).set(repository["count"]) + + for repository in repositories: + collect_repository_metrics(repository, all_archives_flag) + + metrics_file = textfile_collector_dir / "borgmatic.prom" + write_to_textfile(str(metrics_file.absolute()), metrics.REGISTRY) + msg("Metrics written to:", str(metrics_file.absolute())) + + +if __name__ == "__main__": + prometheus_borgmatic_exporter() diff --git a/prometheus_borgmatic_exporter/__main__.py b/prometheus_borgmatic_exporter/__main__.py new file mode 100644 index 0000000..b5dc8e8 --- /dev/null +++ b/prometheus_borgmatic_exporter/__main__.py @@ -0,0 +1,3 @@ +from . import prometheus_borgmatic_exporter + +prometheus_borgmatic_exporter() diff --git a/prometheus_borgmatic_exporter/metrics.py b/prometheus_borgmatic_exporter/metrics.py new file mode 100644 index 0000000..683c179 --- /dev/null +++ b/prometheus_borgmatic_exporter/metrics.py @@ -0,0 +1,124 @@ +"""Prometheus metric definitions for the borgmatic exporter.""" + +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +from prometheus_client import CollectorRegistry, Gauge, Info + +REGISTRY = CollectorRegistry(auto_describe=True) + +TOTAL_CHUNKS = Gauge( + "borg_total_chunks", + "Total chunks", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +TOTAL_COMPRESSED_SIZE = Gauge( + "borg_total_compressed_size_bytes", + "Total compressed size", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +TOTAL_ORIGINAL_SIZE = Gauge( + "borg_total_original_size_bytes", + "Total original size", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +TOTAL_UNIQUE_CHUNKS = Gauge( + "borg_total_unique_chunks", + "Total unique chunks", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +TOTAL_DEDUPLICATED_COMPRESSED_SIZE = Gauge( + "borg_total_deduplicated_compressed_size_bytes", + "Unique compressed size", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +TOTAL_DEDUPLICATED_SIZE = Gauge( + "borg_total_deduplicated_size_bytes", + "Unique size", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +LAST_MODIFIED = Gauge( + "borg_last_modified_timestamp_seconds", + "Last modified timestamp", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +ARCHIVES = Gauge( + "borg_archives", + "Amount of archives", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +LATEST_ARCHIVE_COMPRESSED_SIZE = Gauge( + "borg_latest_archive_compressed_size_bytes", + "Compressed size of archive", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +LATEST_ARCHIVE_DEDUPLICATED_SIZE = Gauge( + "borg_latest_archive_deduplicated_size_bytes", + "Deduplicated size of archive", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +LATEST_ARCHIVE_NUMBER_FILES = Gauge( + "borg_latest_archive_files_total", + "Number of files in archive", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +LATEST_ARCHIVE_ORIGINAL_SIZE = Gauge( + "borg_latest_archive_original_size_bytes", + "Original size of archive", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +LATEST_ARCHIVE_DURATION = Gauge( + "borg_latest_archive_duration_seconds", + "Duration of archive", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) +ARCHIVE_COMPRESSED_SIZE = Gauge( + "borg_archive_compressed_size_bytes", + "Compressed size of archive", + ["repository_id", "repository_label", "location", "archive_id", "archive_name"], + registry=REGISTRY, +) +ARCHIVE_DEDUPLICATED_SIZE = Gauge( + "borg_archive_deduplicated_size_bytes", + "Deduplicated size of archive", + ["repository_id", "repository_label", "location", "archive_id", "archive_name"], + registry=REGISTRY, +) +ARCHIVE_NUMBER_FILES = Gauge( + "borg_archive_files_total", + "Number of files in archive", + ["repository_id", "repository_label", "location", "archive_id", "archive_name"], + registry=REGISTRY, +) +ARCHIVE_ORIGINAL_SIZE = Gauge( + "borg_archive_original_size_bytes", + "Original size of archive", + ["repository_id", "repository_label", "location", "archive_id", "archive_name"], + registry=REGISTRY, +) +ARCHIVE_DURATION = Gauge( + "borg_archive_duration_seconds", + "Duration of archive", + ["repository_id", "repository_label", "location", "archive_id", "archive_name"], + registry=REGISTRY, +) +ENCRYPTION = Info( + "borg_repository_encryption", + "Encryption information about the repository", + ["repository_id", "repository_label", "location"], + registry=REGISTRY, +) diff --git a/prometheus_borgmatic_exporter/types.py b/prometheus_borgmatic_exporter/types.py new file mode 100644 index 0000000..043bf25 --- /dev/null +++ b/prometheus_borgmatic_exporter/types.py @@ -0,0 +1,88 @@ +"""TypedDict definitions for borgmatic JSON output structures.""" +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +from typing import Any, TypedDict + + +class ArchiveCount(TypedDict): + id: str + label: str + location: str + count: int + + +class RepositoryLabels(TypedDict): + repository_id: str + repository_label: str + location: str + + +class BorgArchiveStats(TypedDict): + compressed_size: int + deduplicated_size: int + nfiles: int + original_size: int + + +class BorgArchive(TypedDict): + chunker_params: list[str | int] + command_line: list[str] + comment: str + cwd: str + duration: float + end: str + hostname: str + id: str + limits: dict[str, Any] + name: str + start: str + stats: BorgArchiveStats + username: str + + +class BorgEncryption(TypedDict): + mode: str + + +class BorgRepository(TypedDict): + id: str + last_modified: str + location: str + label: str + + +class BorgCacheStats(TypedDict): + total_chunks: int + total_csize: int + total_size: int + total_unique_chunks: int + unique_csize: int + unique_size: int + + +class BorgCache(TypedDict): + path: str + stats: BorgCacheStats + + +class BorgInfoReturnType(TypedDict): + archives: list[BorgArchive] + cache: BorgCache + encryption: BorgEncryption + repository: BorgRepository + + +class BorgListArchiveEntry(TypedDict): + archive: str + barchive: str + id: str + name: str + time: str + + +class BorgListReturnType(TypedDict): + archives: list[BorgListArchiveEntry] + encryption: BorgEncryption + repository: BorgRepository diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2108223 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "prometheus-borgmatic-exporter" +dynamic = ["version"] +description = "Collect borgmatic repository metrics and export them for Prometheus." +license = "BSD-3-Clause" +license-files = ["LICENSES/BSD-3-Clause.txt"] +authors = [ + {name = "Dennis Fink", email = "me+coding@dennisfink.me"}, +] +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "click-extra>=7.14.1", + "prometheus-client>=0.25.0", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Environment :: Console", + "Intended Audience :: System Administrators", + "Topic :: System :: Archiving :: Backup", + "Topic :: System :: Monitoring", +] +keywords = ["prometheus", "borgmatic", "borg", "backup", "monitoring", "exporter"] + +[project.scripts] +prometheus-borgmatic-exporter = "prometheus_borgmatic_exporter:prometheus_borgmatic_exporter" + +[dependency-groups] +dev = [ + "pyright>=1.1.409", + "ruff>=0.15.12", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "prometheus_borgmatic_exporter/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["prometheus_borgmatic_exporter"] + +[tool.hatch.build.targets.sdist] +include = [ + "prometheus_borgmatic_exporter/", + "contrib/prometheus-borgmatic-exporter.confd", + "contrib/prometheus-borgmatic-exporter.service", + "contrib/prometheus-borgmatic-exporter.timer", + "LICENSES/", + "README.md", + "pyproject.toml" +] + +[tool.ruff] +target-version = "py311" + +[tool.pyright] +pythonVersion = "3.11" +typeCheckingMode = "strict" @@ -0,0 +1,348 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "boltons" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" }, +] + +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "click-extra" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boltons" }, + { name = "click" }, + { name = "cloup" }, + { name = "deepmerge" }, + { name = "extra-platforms" }, + { name = "requests" }, + { name = "tabulate", extra = ["widechars"] }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c9/f02eef46dd487565fac61a0cf7b77bd2c1ce255129bbf04507a6bd3a2cf5/click_extra-7.14.1.tar.gz", hash = "sha256:acfa952375f5051e509643623ef9e66e2691435b9f4ceb50f5fb078e87761028", size = 145060, upload-time = "2026-04-26T20:34:58.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/a4/61d5e395a62cd8e8a8cca9eb4a055666a56841fef11eaa6fbea3d13ac779/click_extra-7.14.1-py3-none-any.whl", hash = "sha256:1dc23b5f0caeade5d643e3f162f3b8b5acef16ee56e39944bbec6744dd2f2e23", size = 159513, upload-time = "2026-04-26T20:34:56.885Z" }, +] + +[[package]] +name = "cloup" +version = "3.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/64/7f0a66021ff81d51859c66adc13f3c71f0306c2f8dfb9877a0694cbada05/cloup-3.0.9.tar.gz", hash = "sha256:519f524d3c64040e49a0866b5fc0bfd6af3eac0d3d6a4b2b50b33ab0247db2d7", size = 229896, upload-time = "2026-04-04T03:53:54.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/15/435bb5a24e3a2dc8c26afedab0c6f0cf4291c66fb99509c168c38eb81135/cloup-3.0.9-py2.py3-none-any.whl", hash = "sha256:c761ef4e975454335d50c0e1eb83595a932735aac06e8955b3ced774bc62ae7b", size = 54724, upload-time = "2026-04-04T03:53:52.909Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + +[[package]] +name = "extra-platforms" +version = "12.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/f6/a20c4586c9bd653f596258455652725405f4b734e6270f7fc07a1a498932/extra_platforms-12.0.3.tar.gz", hash = "sha256:0a3201a05f93f18c840f3e9970d33f756a621eea719cbdfa24a514f96c79de7e", size = 72879, upload-time = "2026-04-29T06:47:18.131Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/ef/efc376476f41868ca834b30ed100b250d53da2b4929ee8e63ef48e7b0b03/extra_platforms-12.0.3-py3-none-any.whl", hash = "sha256:b6b7fad4b41e1f9f161cc74c03d963e43a3a34e0ee1991943314685314b01cf6", size = 76128, upload-time = "2026-04-29T06:47:16.945Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "prometheus-borgmatic-exporter" +source = { editable = "." } +dependencies = [ + { name = "click-extra" }, + { name = "prometheus-client" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click-extra", specifier = ">=7.14.1" }, + { name = "prometheus-client", specifier = ">=0.25.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.409" }, + { name = "ruff", specifier = ">=0.15.12" }, +] + +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[package.optional-dependencies] +widechars = [ + { name = "wcwidth" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] |
