aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDennis Fink2026-09-19 19:16:20 +0200
committerDennis Fink2026-09-19 19:16:20 +0200
commit37e62c47973e7d1e9a9d43e7480e9f393dc77a0e (patch)
treedd1967ae36fc7c94c50d142eb29144cf42c5c4a6
downloadprometheus-pacman-exporter-1.0.0.tar.gz
prometheus-pacman-exporter-1.0.0.zip
feat: add initial pacman Prometheus exporterv1.0.0
Add the first production-ready release of prometheus-pacman-exporter for collecting pacman package statistics through the node exporter textfile collector. Collect installed, explicit, dependency, unrequired, foreign, native, orphan, and updateable package metrics. Support checkupdates as an alternative update source and optional AUR update collection through a configurable command. Add the Click-based CLI, Prometheus metric definitions, package metadata, dependency lockfile, README, licensing metadata, and development configuration.
-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