aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore289
-rw-r--r--.gitmessage38
-rw-r--r--.python-version1
-rw-r--r--README.md131
-rw-r--r--prometheus_pacman_exporter/__init__.py224
-rw-r--r--prometheus_pacman_exporter/__main__.py7
-rw-r--r--prometheus_pacman_exporter/cli.py161
-rw-r--r--prometheus_pacman_exporter/metrics.py51
-rw-r--r--pyproject.toml66
-rw-r--r--uv.lock205
-rw-r--r--uv.lock.license3
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
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..b6f957d
--- /dev/null
+++ b/uv.lock
@@ -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