diff options
| -rw-r--r-- | .gitignore | 289 | ||||
| -rw-r--r-- | .gitmessage | 38 | ||||
| -rw-r--r-- | .python-version | 1 | ||||
| -rw-r--r-- | README.md | 131 | ||||
| -rw-r--r-- | prometheus_pacman_exporter/__init__.py | 224 | ||||
| -rw-r--r-- | prometheus_pacman_exporter/__main__.py | 7 | ||||
| -rw-r--r-- | prometheus_pacman_exporter/cli.py | 161 | ||||
| -rw-r--r-- | prometheus_pacman_exporter/metrics.py | 51 | ||||
| -rw-r--r-- | pyproject.toml | 66 | ||||
| -rw-r--r-- | uv.lock | 205 | ||||
| -rw-r--r-- | uv.lock.license | 3 |
11 files changed, 1176 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4aec67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,289 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +# Created by https://www.toptal.com/developers/gitignore/api/git,python,windows,linux,macos,vim +# Edit at https://www.toptal.com/developers/gitignore?templates=git,python,windows,linux,macos,vim + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +#*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +# *.mo +# *.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/git,python,windows,linux,macos,vim diff --git a/.gitmessage b/.gitmessage new file mode 100644 index 0000000..0504dbc --- /dev/null +++ b/.gitmessage @@ -0,0 +1,38 @@ + +# <type>[optional scope]: <short description> (max 72 chars total) ------>| +# +# Types (choose ONE): +# feat New feature (MINOR bump in SemVer) +# fix Bug fix (PATCH bump) +# docs Documentation only (README, man page, comments) +# style Formatting, whitespace — no logic change +# refactor Code restructure — no feature/fix +# perf Performance improvement +# test Tests only +# build Build system / dependency changes +# ci CI/CD configuration +# chore Maintenance (release commits, tooling) +# revert Revert a previous commit +# +# Breaking changes: append ! before the colon → feat(cli)!: ... +# -------------------------------------------------------------------------- + +# [blank line — required before body] + +# Body (optional): explain WHY, not WHAT. Wrap at 72 chars. ---->| +# +# Motivation for the change, background, trade-offs considered, etc. +# Use multiple paragraphs separated by blank lines if needed. + +# [blank line before footers] + +# Footers (optional): +# BREAKING CHANGE: <description of what breaks and how to migrate> +# Fixes: #<issue> +# Refs: #<issue>, <commit-sha> +# Reviewed-by: Name <email> +# Co-authored-by: Name <email> + +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a5e90f --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +<!-- +SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> + +SPDX-License-Identifier: BSD-3-Clause +--> + +# prometheus-pacman-exporter + +A Prometheus metrics exporter for [pacman](https://archlinux.org/pacman/), +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.12+ +- pacman +- checkupdates (optional) +- An AUR helper or other update command (optional) + +## Installation + +```bash +pip install prometheus-pacman-exporter +``` + +## Usage + +``` +Usage: prometheus-pacman-exporter [OPTIONS] + + Collect pacman package metrics and export them for Prometheus. + + Queries pacman for installed package statistics and available updates, then + writes them to a .prom file for consumption by the Prometheus node exporter + textfile collector. + + By default, updateable packages are determined using pacman --query + --upgrades. Use --use-checkupdates to query updates with checkupdates + instead. AUR update metrics can optionally be collected by providing --aur + together with --aur-command. + +Options: + --textfile-collector-dir DIRECTORY + Directory where the Prometheus collector + textfile will be written. + [default: /var/lib/prometheus/node-exporter] + --use-checkupdates Use checkupdates instead of pacman --query + --upgrades to check for available updates. + --aur Include AUR updates. + --aur-command TEXT Command used to check for AUR updates. + --color [auto|always|never] Colorize the output. A bare --color is the + same as --color=always. + [default: auto] + --no-color Disable colorization (alias of + --color=never). + -q, --quiet Suppress all non-error output. + -v, --verbose Enable verbose output. + --version Show version information and exit. + -h, --help, -? Show this message and exit. +``` + +### Checking for updates + +By default, available repository updates are determined using: + +```bash +pacman --query --upgrades +``` + +This only reports updates already known to the local package database. + +Use `--use-checkupdates` to instead use +[`checkupdates`](https://man.archlinux.org/man/checkupdates.8), which checks +for available updates without modifying the system's package database: + +```bash +prometheus-pacman-exporter --use-checkupdates +``` + +### AUR updates + +AUR updates can be included with `--aur`. An update command must also be +provided with `--aur-command`: + +```bash +prometheus-pacman-exporter \ + --aur \ + --aur-command "paru --query --upgrades --aur" +``` + +The AUR command may use any AUR helper or custom program, provided its standard +output contains **exactly one available package update per line**. The exporter +counts the number of output lines; it does not interpret the contents of those +lines. + +For example, output such as: + +```text +package-one 1.2.3-1 -> 1.2.4-1 +package-two 4.5.6-1 -> 4.6.0-1 +``` + +is interpreted as two available AUR updates. + +Diagnostic or informational messages from the command should therefore be +written to standard error rather than standard output. + +The value of `--aur-command` is split into command-line arguments and executed +directly. It is not executed through a shell, so shell features such as pipes, +redirections, variable expansion, and command substitution are not available. + +## Metrics + +| Metric | Description | +| -------------------------------- | -------------------------------------------------- | +| `pacman_installed_packages` | Total number of installed packages | +| `pacman_explicit_packages` | Number of explicitly installed packages | +| `pacman_depends_packages` | Number of packages installed as dependencies | +| `pacman_unrequired_packages` | Number of packages not required by another package | +| `pacman_foreign_packages` | Number of packages not present in sync databases | +| `pacman_native_packages` | Number of packages present in sync databases | +| `pacman_orphan_packages` | Number of orphaned dependency packages | +| `pacman_updateable_packages` | Number of available repository package updates | +| `pacman_aur_updateable_packages` | Number of available AUR package updates | + +The AUR update metric is only collected when `--aur` is enabled. + +## License + +BSD-3-Clause. See [LICENSE](LICENSES/BSD-3-Clause.txt) for details. diff --git a/prometheus_pacman_exporter/__init__.py b/prometheus_pacman_exporter/__init__.py new file mode 100644 index 0000000..baf69dd --- /dev/null +++ b/prometheus_pacman_exporter/__init__.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +import os +import shlex +import subprocess +from pathlib import Path +from typing import cast + +import click_extra as click +from cloup.constraints import If, require_all +from prometheus_client import write_to_textfile + +from . import metrics +from .cli import FlexibleColorOption, debug, error, msg + +VERSION = "1.0.0" +DESCRIPTION = "Collect pacman package metrics and export them for Prometheus." +DATE_OF_CREATION = "2021-05-15" +DATE_OF_REVISION = "2026-05-08" +AUTHOR = "Dennis Fink <me+coding@dennisfink.me>" +LICENSE = "BSD-3-Clause" + + +def print_version( + ctx: click.Context, param: click.Parameter | None, value: bool +) -> None: + """Print script metadata (name, version, description, author, dates) and exit. + + :param ctx: Click context injected by :func:`click.pass_context`. + """ + 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() + + +@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. + + :param ctx: Click context injected by :func:`click.pass_context`. + :param command: Command and its arguments. + :return: Completed process with stdout and stderr captured. + """ + debug("Running command", " ".join(command), err=True) + 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.command( + context_settings={"help_option_names": ("-h", "--help", "-?")}, params=[] +) +@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", + help="Directory where the Prometheus collector textfile will be written.", +) +@click.option( + "--use-checkupdates", "use_checkupdates_flag", is_flag=True, default=False +) +@click.option( + "--aur", "aur_flag", is_flag=True, default=False, help="Include AUR updates." +) +@click.option("--aur-command", help="Command used to check for AUR updates.") +@click.constraint( + If("aur_flag", then=require_all).hidden(), ["aur_flag", "aur_command"] +) +@click.option("--color", cls=FlexibleColorOption) +@click.no_color_option() +@click.option( + "-q", + "--quiet", + "quiet_flag", + is_flag=True, + default=False, + help="Suppress all non-error output.", +) +@click.option( + "-v", + "--verbose", + "verbose_flag", + is_flag=True, + default=False, + help="Enable verbose output.", +) +@click.option( + "--version", + callback=print_version, + default=False, + expose_value=False, + help="Show version information and exit.", + is_eager=True, + is_flag=True, +) +@click.pass_context +def prometheus_pacman_exporter( + ctx: click.Context, + textfile_collector_dir: Path, + use_checkupdates_flag: bool, + aur_flag: bool, + aur_command: str | None, + quiet_flag: bool, + verbose_flag: bool, +): + """Collect pacman package metrics and export them for Prometheus. + + Queries pacman for installed package statistics and available updates, then + writes them to a ``.prom`` file for consumption by the Prometheus node + exporter textfile collector. + + By default, updateable packages are determined using ``pacman --query + --upgrades``. Use ``--use-checkupdates`` to query updates with + ``checkupdates`` instead. AUR update metrics can optionally be collected by + providing ``--aur`` together with ``--aur-command``. + """ + + 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:", err=True) + debug(" textfile_collector_dir:", str(textfile_collector_dir), err=True) + debug(" use_checkupdates:", str(use_checkupdates_flag), err=True) + debug(" aur:", str(aur_flag), err=True) + debug(" aur_command:", str(aur_command), err=True) + debug(" verbose:", str(verbose_flag), err=True) + debug(" quiet:", str(quiet_flag), err=True) + + pacman = ["pacman", "--query"] + queries = [ + ([*pacman], metrics.INSTALLED_PACKAGES), + ([*pacman, "--explicit"], metrics.EXPLICIT_PACKAGES), + ([*pacman, "--deps"], metrics.DEPENDS_PACKAGES), + ([*pacman, "--unrequired"], metrics.UNREQUIRED_PACKAGES), + ([*pacman, "--foreign"], metrics.FOREIGN_PACKAGES), + ([*pacman, "--native"], metrics.NATIVE_PACKAGES), + ([*pacman, "--deps", "--unrequired"], metrics.ORPHAN_PACKAGES), + ( + ["checkupdates"] if use_checkupdates_flag else [*pacman, "--upgrades"], + metrics.UPDATEABLE_PACKAGES, + ), + ] + + if aur_flag: + queries.append( + (shlex.split(cast(str, aur_command)), metrics.AUR_UPDATEABLE_PACKAGES) + ) + + for command, metric in queries: + output = run_command(command) + metric.set(len(output.stdout.splitlines())) + + metrics_file = textfile_collector_dir / "pacman.prom" + write_to_textfile(str(metrics_file.absolute()), metrics.REGISTRY) + msg("Metrics written to:", str(metrics_file.absolute()), err=True) + + +if __name__ == "__main__": + prometheus_pacman_exporter() diff --git a/prometheus_pacman_exporter/__main__.py b/prometheus_pacman_exporter/__main__.py new file mode 100644 index 0000000..877708c --- /dev/null +++ b/prometheus_pacman_exporter/__main__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +from prometheus_pacman_exporter import prometheus_pacman_exporter + +prometheus_pacman_exporter() diff --git a/prometheus_pacman_exporter/cli.py b/prometheus_pacman_exporter/cli.py new file mode 100644 index 0000000..5d91b05 --- /dev/null +++ b/prometheus_pacman_exporter/cli.py @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Define Click parameter types and styled terminal message helpers.""" + +from collections.abc import Callable +from functools import update_wrapper +from typing import Any, Concatenate + +import click_extra as click + + +class FlexibleColorOption(click.ColorOption): + """Allow the color option value to be passed with or without ``=``.""" + + _gnu_optional_value = False + + +def pass_obj_silent[T, **P, R]( + f: Callable[Concatenate[T | None, P], R], +) -> Callable[P, R]: + """Pass the current context object to a callback, if available. + + The current Click context is retrieved silently. If no context exists or + its object is not a dictionary, ``None`` is passed instead. + + The context object is inserted as the first positional argument to *f*. + """ + + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: + ctx = click.get_current_context(silent=True) + obj = ctx.obj if ctx is not None else None + return f(obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + +def emit( + prefix: str, color: str, *message: str, enabled: bool = True, err: bool = False +) -> None: + """Render a styled prefix and message to stdout. + + This helper is used by :func:`error`, :func:`msg`, :func:`warn`, + :func:`verbose`, and :func:`debug`. The first message argument is rendered in + bold and subsequent arguments are appended unstyled. + + :param prefix: Prefix displayed before the message. + :param color: Click-compatible color name applied to the prefix. + :param message: One or more message parts to display. + :param enabled: Whether the message should be emitted. + :param err: Write to `stderr` instead of `stdout`. + :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:], + ] + ), + err=err, + ) + + +def error(*message: str) -> None: + """Print a formatted error message to stdout in red. + + The first argument is rendered in bold and subsequent arguments are appended + unstyled. Error messages are always printed regardless of quiet or verbose + flags. + + :param message: One or more message parts to display. + """ + emit("==> ERROR:", "red", *message, err=True) + + +@pass_obj_silent +def msg(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted informational message to stdout in green. + + The message is suppressed when the quiet flag is set. When called outside a + Click context, the message is emitted normally. The first argument is rendered + in bold and subsequent arguments are appended unstyled. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "green", + *message, + enabled=obj is None or not obj.get("quiet", False), + err=err, + ) + + +@pass_obj_silent +def warn(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted warning message to stdout in yellow. + + The message is suppressed when the quiet flag is set. When called outside a + Click context, the message is emitted normally. The first argument is rendered + in bold and subsequent arguments are appended unstyled. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "yellow", + *message, + enabled=obj is None or not obj.get("quiet", False), + err=err, + ) + + +@pass_obj_silent +def verbose(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted verbose message to stdout in blue. + + The message is printed only when called from a Click context with the verbose + flag set and the quiet flag unset. Calls made outside a Click context are a + no-op. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "blue", + *message, + enabled=( + obj is not None + and obj.get("verbose", False) + and not obj.get("quiet", False) + ), + err=err, + ) + + +@pass_obj_silent +def debug(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted debug message to stdout in magenta. + + The message is printed only when called from a Click context with the debug + flag enabled. Calls made outside a Click context are a no-op. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "magenta", + *message, + enabled=obj is not None and obj.get("debug", False), + err=err, + ) diff --git a/prometheus_pacman_exporter/metrics.py b/prometheus_pacman_exporter/metrics.py new file mode 100644 index 0000000..f8a99fd --- /dev/null +++ b/prometheus_pacman_exporter/metrics.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Prometheus metric definitions for the pacman exporter.""" + +from prometheus_client import CollectorRegistry, Gauge + +REGISTRY = CollectorRegistry(auto_describe=True) + +INSTALLED_PACKAGES = Gauge( + "pacman_installed_packages", + "The current number of installed packages", + registry=REGISTRY, +) +UPDATEABLE_PACKAGES = Gauge( + "pacman_updateable_packages", + "The current number of updateable packages", + registry=REGISTRY, +) +EXPLICIT_PACKAGES = Gauge( + "pacman_explicit_packages", + "The current number of explicitly installed packages", + registry=REGISTRY, +) +DEPENDS_PACKAGES = Gauge( + "pacman_depends_packages", + "The current number of packages installed as dependencies", + registry=REGISTRY, +) +UNREQUIRED_PACKAGES = Gauge( + "pacman_unrequired_packages", + "The current number of unrequired packages", + registry=REGISTRY, +) +FOREIGN_PACKAGES = Gauge( + "pacman_foreign_packages", + "The current number of foreign packages", + registry=REGISTRY, +) +NATIVE_PACKAGES = Gauge( + "pacman_native_packages", "The current number of native packages", registry=REGISTRY +) +ORPHAN_PACKAGES = Gauge( + "pacman_orphan_packages", "The current number of orphan packages", registry=REGISTRY +) +AUR_UPDATEABLE_PACKAGES = Gauge( + "pacman_aur_updateable_packages", + "The current number of updates packages from the AUR", + registry=REGISTRY, +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..508115a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +[project] +name = "prometheus-pacman-exporter" +dynamic = ["version"] +description = "Collect pacman package 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.12" +dependencies = [ + "click-extra>=9.1.0", + "prometheus-client>=0.26.0", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Environment :: Console", + "Intended Audience :: System Administrators", + "Topic :: System :: Monitoring", +] +keywords = ["prometheus", "monitoring", "exporter"] + +[project.urls] +Repository = "https://git.dennisfink.me/prometheus-pacman-exporter/" + +[project.scripts] +prometheus-pacman-exporter = "prometheus_pacman_exporter:prometheus_pacman_exporter" + +[dependency-groups] +dev = [ + "pyright>=1.1.411", + "ruff>=0.16.6", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "prometheus_pacman_exporter/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["prometheus_pacman_exporter"] + +[tool.hatch.build.targets.sdist] +include = [ + "prometheus_pacman_exporter/", + "contrib/prometheus-pacman-exporter.confd", + "contrib/prometheus-pacman-exporter.service", + "contrib/prometheus-pacman-exporter.timer", + "LICENSES/", + "README.md", + "pyproject.toml" +] + +[tool.ruff.format] +skip-magic-trailing-comma = true @@ -0,0 +1,205 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[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 = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "click-extra" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boltons" }, + { name = "click" }, + { name = "cloup" }, + { name = "deepmerge" }, + { name = "extra-platforms" }, + { name = "tabulate", extra = ["widechars"] }, + { name = "wcmatch" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/b9/40f42f760ed0d4ce0e445cec909d252c88f8a165993551aa2eec7699b030/click_extra-9.1.0.tar.gz", hash = "sha256:68e7dbf80500deb369794ab1ef44ae1713d0bb64698072b0ba6f28fbdea98084", size = 1608251, upload-time = "2026-09-03T09:16:23.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/8d/663be1cb83ef91f97b49529f6a91e2b2cedcfe2d2c874d61dee6f6406585/click_extra-9.1.0-py3-none-any.whl", hash = "sha256:b385b0d5d9eb05fd1a7795642bb577a1cd557b6a793351b7869ca1ed57ae30b5", size = 610197, upload-time = "2026-09-03T09:16:21.606Z" }, +] + +[[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 = "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 = "13.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/5d/b27245cf9053955747983d598a61c1058fc8a1fc3b0e39a04815894eadbc/extra_platforms-13.8.0.tar.gz", hash = "sha256:74e3f81abca4ac47f8f46cbae9a35eac51157077468121e50cfec2171388df07", size = 479823, upload-time = "2026-09-07T16:23:07.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/3fc8fa3a5341619bc757ed836f82251b260acab5dba40a2c4f7db8b2c87f/extra_platforms-13.8.0-py3-none-any.whl", hash = "sha256:f1bee44eb3ac09ba4bcc19a8ddc1f73b72bacab76446f1e5e3121ab80899cde7", size = 103440, upload-time = "2026-09-07T16:23:05.599Z" }, +] + +[[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-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "prometheus-pacman-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 = ">=9.1.0" }, + { name = "prometheus-client", specifier = ">=0.26.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.411" }, + { name = "ruff", specifier = ">=0.16.6" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + +[[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 = "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.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] diff --git a/uv.lock.license b/uv.lock.license new file mode 100644 index 0000000..5a44073 --- /dev/null +++ b/uv.lock.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> + +SPDX-License-Identifier: BSD-3-Clause |
