aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--prometheus_borgmatic_exporter/__init__.py206
-rw-r--r--prometheus_borgmatic_exporter/cli.py116
-rw-r--r--pyproject.toml3
3 files changed, 164 insertions, 161 deletions
diff --git a/prometheus_borgmatic_exporter/__init__.py b/prometheus_borgmatic_exporter/__init__.py
index fea8dbd..373d8ca 100644
--- a/prometheus_borgmatic_exporter/__init__.py
+++ b/prometheus_borgmatic_exporter/__init__.py
@@ -16,13 +16,7 @@ import click_extra as click
from prometheus_client import write_to_textfile
from . import metrics, types
-
-###############################################################################
-# SCRIPT METADATA
-#
-# These variables describe the script and are used for the --version output
-# and for informational messages.
-###############################################################################
+from .cli import FlexibleColorOption, debug, error, msg, verbose, warn
VERSION = "1.0.0"
DESCRIPTION = "Collect borgmatic repository metrics and export them for Prometheus."
@@ -32,124 +26,8 @@ AUTHOR = "Dennis Fink <me+coding@dennisfink.me>"
LICENSE = "BSD-3-Clause"
-###############################################################################
-# FUNCTIONS
-#
-# This section defines the helper functions used throughout the script. They
-# handle formatted output, error reporting, version information, utilities
-# functions and runtime behaviour such as displaying usage instructions.
-###############################################################################
-
-
-def emit(prefix: str, color: str, *message: str, enabled: bool = True) -> None:
- """Render a styled prefix and message to stdout.
-
- Internal helper used by error(), msg(), warn(), verbose(), and debug()
- to avoid duplicating the Click styling and echo logic. The first message
- argument is rendered in bold, subsequent arguments are appended unstyled.
-
- Args:
- prefix: The prefix string shown before the message (e.g. "==>", "==> ERROR:").
- color: A Click-compatible color name (e.g. "red", "green") applied to the prefix.
- *message: One or more message parts to display. At least one is required.
- enabled: If False, the function returns immediately without printing.
- Defaults to True.
-
- Raises:
- TypeError: If no message arguments are provided.
- """
- if not message:
- raise TypeError("emit() missing 1 required positional argument: 'message'")
-
- if not enabled:
- return
-
- click.echo(
- " ".join(
- [
- click.style(prefix, fg=color, bold=True),
- click.style(message[0], bold=True),
- *message[1:],
- ]
- )
- )
-
-
-def error(*message: str) -> None:
- """Print a formatted error message to stdout in red.
-
- The first argument is rendered in bold, subsequent arguments are appended
- unstyled. Always prints regardless of quiet or verbose flags.
-
- Args:
- *message: One or more message parts to display.
- """
- emit("==> ERROR:", "red", *message)
-
-
-@click.pass_obj
-def msg(obj: dict[str, bool], *message: str) -> None:
- """Print a formatted informational message to stdout in green.
-
- Suppressed when the quiet flag is set. The first argument is rendered
- in bold, subsequent arguments are appended unstyled.
-
- Args:
- *message: One or more message parts to display.
- """
- emit("==>", "green", *message, enabled=not obj.get("quiet", False))
-
-
-@click.pass_obj
-def warn(obj: dict[str, bool], *message: str) -> None:
- """Print a formatted warning message to stdout in yellow.
-
- Suppressed when the quiet flag is set. The first argument is rendered
- in bold, subsequent arguments are appended unstyled.
-
- Args:
- *message: One or more message parts to display.
- """
- emit("==>", "yellow", *message, enabled=not obj.get("quiet", False))
-
-
-@click.pass_obj
-def verbose(obj: dict[str, bool], *message: str) -> None:
- """Print a formatted verbose message to stdout in blue.
-
- Only prints when the verbose flag is set and the quiet flag is not.
- The first argument is rendered in bold, subsequent arguments are
- appended unstyled.
-
- Args:
- *message: One or more message parts to display.
- """
- emit(
- "==>",
- "blue",
- *message,
- enabled=obj.get("verbose", False) and not obj.get("quiet", False),
- )
-
-
-@click.pass_obj
-def debug(obj: dict[str, bool], *message: str) -> None:
- """Print a formatted debug message to stdout in magenta.
-
- Only prints when the DEBUG environment variable is set to a truthy
- value (1, true, yes). The first argument is rendered in bold,
- subsequent arguments are appended unstyled.
-
- Args:
- *message: One or more message parts to display.
- """
- emit("==>", "magenta", *message, enabled=obj.get("debug", False))
-
-
def print_version(
- ctx: click.Context,
- param: click.Parameter | None,
- value: bool,
+ ctx: click.Context, param: click.Parameter | None, value: bool
) -> None:
"""Print script metadata (name, version, description, author, dates) and exit.
@@ -202,11 +80,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.""")
ctx.exit()
-###############################################################################
-# MAIN EXECUTION
-###############################################################################
-
-
@click.pass_context
def run_command(
ctx: click.Context, command: list[str]
@@ -226,10 +99,7 @@ def run_command(
debug("Running command", " ".join(command))
try:
return subprocess.run(
- command,
- capture_output=True,
- encoding="utf-8",
- check=True,
+ 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}")
@@ -242,8 +112,8 @@ def run_command(
def load_json(ctx: click.Context, raw: str) -> Any:
"""Parse a JSON string and return the result.
- Exits the application with code 1 if the input is not valid JSON,
- printing the parse error via the error() helper.
+ Exits the application with code 1 if the input is not valid JSON, printing
+ the parse error via the error() helper.
Args:
ctx: The Click context, injected by @click.pass_context.
@@ -321,8 +191,7 @@ def fetch_repositories(
if all_archives:
borgmatic_info = run_command(info_command)
repositories = cast(
- list[types.BorgInfoReturnType],
- load_json(borgmatic_info.stdout),
+ list[types.BorgInfoReturnType], load_json(borgmatic_info.stdout)
)
debug(f"Fetched {len(repositories)} repositories")
return repositories, extract_archive_counts(repositories)
@@ -345,8 +214,8 @@ def collect_repository_metrics(
"""Collect and set all Prometheus metrics for a single repository.
Sets cache stats, encryption info, last modified timestamp, and latest
- archive metrics. When all_archives is True, also collects metrics for
- every individual archive by calling _collect_archive_metrics.
+ archive metrics. When all_archives is True, also collects metrics for every
+ individual archive by calling _collect_archive_metrics.
Args:
repository: Repository info as returned by borgmatic.
@@ -394,10 +263,7 @@ def collect_repository_metrics(
warn("No archives found for repository:", repository_metadata["label"])
return
- latest_archive = max(
- repository["archives"],
- key=lambda archive: archive["start"],
- )
+ latest_archive = max(repository["archives"], key=lambda archive: archive["start"])
debug(f"Latest archive {latest_archive['name']}:", latest_archive["start"])
metrics.LATEST_ARCHIVE_DURATION.labels(**repository_labels).set(
@@ -419,8 +285,7 @@ def collect_repository_metrics(
def collect_archive_metrics(
- archive: types.BorgArchive,
- repository_labels: types.RepositoryLabels,
+ archive: types.BorgArchive, repository_labels: types.RepositoryLabels
) -> None:
"""Collect and set Prometheus metrics for a single archive.
@@ -450,15 +315,7 @@ def collect_archive_metrics(
@click.command(
- context_settings={"help_option_names": ("-h", "--help", "-?")},
- formatter_settings=click.HelpExtraFormatter.settings(
- theme=click.HelpExtraTheme.light(),
- ),
- params=[],
-)
-@click.color_option(
- "--color/--no-color",
- help="Force/Disable colored output.",
+ context_settings={"help_option_names": ("-h", "--help", "-?")}, params=[]
)
@click.option(
"--borgmatic-bin",
@@ -471,6 +328,7 @@ def collect_archive_metrics(
path_type=Path,
),
default="/usr/bin/borgmatic",
+ help="Path to the borgmatic executable.",
)
@click.option(
"--borgmatic-config",
@@ -496,10 +354,36 @@ def collect_archive_metrics(
path_type=Path,
),
default="/var/lib/prometheus/node-exporter",
+ help="Directory where the Prometheus textfile collector file is written.",
+)
+@click.option(
+ "--all-archives",
+ "all_archives_flag",
+ is_flag=True,
+ default=False,
+ help=(
+ "Collect metrics for all archives instead of only the latest archive. "
+ "This can significantly increase runtime and Prometheus storage usage."
+ ),
+)
+@click.option(
+ "-q",
+ "--quiet",
+ "quiet_flag",
+ is_flag=True,
+ default=False,
+ help="Suppress normal output.",
+)
+@click.option(
+ "-v",
+ "--verbose",
+ "verbose_flag",
+ is_flag=True,
+ default=False,
+ help="Enable verbose output.",
)
-@click.option("--all-archives", "all_archives_flag", is_flag=True, default=False)
-@click.option("-q", "--quiet", "quiet_flag", is_flag=True, default=False)
-@click.option("-v", "--verbose", "verbose_flag", is_flag=True, default=False)
+@click.option("--color", cls=FlexibleColorOption)
+@click.no_color_option()
@click.option(
"--version",
callback=print_version,
@@ -521,13 +405,13 @@ def prometheus_borgmatic_exporter(
):
"""Collect borgmatic repository metrics and export them for Prometheus.
- Queries borgmatic for repository and archive statistics and writes them
- to a .prom file for consumption by the Prometheus node exporter textfile
+ Queries borgmatic for repository and archive statistics and writes them to
+ a .prom file for consumption by the Prometheus node exporter textfile
collector.
By default only the latest archive is queried per repository. Use
- --all-archives to collect metrics for every archive, at the cost of
- a significantly longer runtime.
+ --all-archives to collect metrics for every archive, at the cost of a
+ significantly longer runtime.
Using --all-archives will create one time series per archive and may
significantly increase Prometheus storage usage.
diff --git a/prometheus_borgmatic_exporter/cli.py b/prometheus_borgmatic_exporter/cli.py
new file mode 100644
index 0000000..cc6f374
--- /dev/null
+++ b/prometheus_borgmatic_exporter/cli.py
@@ -0,0 +1,116 @@
+# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me>
+#
+# SPDX-License-Identifier: BSD-3-Clause
+
+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 emit(prefix: str, color: str, *message: str, enabled: bool = True) -> None:
+ """Render a styled prefix and message to stdout.
+
+ Internal helper used by error(), msg(), warn(), verbose(), and debug()
+ to avoid duplicating the Click styling and echo logic. The first message
+ argument is rendered in bold, subsequent arguments are appended unstyled.
+
+ Args:
+ prefix: The prefix string shown before the message (e.g. "==>", "==> ERROR:").
+ color: A Click-compatible color name (e.g. "red", "green") applied to the prefix.
+ *message: One or more message parts to display. At least one is required.
+ enabled: If False, the function returns immediately without printing.
+ Defaults to True.
+
+ Raises:
+ TypeError: If no message arguments are provided.
+ """
+ if not message:
+ raise TypeError("emit() missing 1 required positional argument: 'message'")
+
+ if not enabled:
+ return
+
+ click.echo(
+ " ".join(
+ [
+ click.style(prefix, fg=color, bold=True),
+ click.style(message[0], bold=True),
+ *message[1:],
+ ]
+ )
+ )
+
+
+def error(*message: str) -> None:
+ """Print a formatted error message to stdout in red.
+
+ The first argument is rendered in bold, subsequent arguments are appended
+ unstyled. Always prints regardless of quiet or verbose flags.
+
+ Args:
+ *message: One or more message parts to display.
+ """
+ emit("==> ERROR:", "red", *message)
+
+
+@click.pass_obj
+def msg(obj: dict[str, bool], *message: str) -> None:
+ """Print a formatted informational message to stdout in green.
+
+ Suppressed when the quiet flag is set. The first argument is rendered
+ in bold, subsequent arguments are appended unstyled.
+
+ Args:
+ *message: One or more message parts to display.
+ """
+ emit("==>", "green", *message, enabled=not obj.get("quiet", False))
+
+
+@click.pass_obj
+def warn(obj: dict[str, bool], *message: str) -> None:
+ """Print a formatted warning message to stdout in yellow.
+
+ Suppressed when the quiet flag is set. The first argument is rendered
+ in bold, subsequent arguments are appended unstyled.
+
+ Args:
+ *message: One or more message parts to display.
+ """
+ emit("==>", "yellow", *message, enabled=not obj.get("quiet", False))
+
+
+@click.pass_obj
+def verbose(obj: dict[str, bool], *message: str) -> None:
+ """Print a formatted verbose message to stdout in blue.
+
+ Only prints when the verbose flag is set and the quiet flag is not.
+ The first argument is rendered in bold, subsequent arguments are
+ appended unstyled.
+
+ Args:
+ *message: One or more message parts to display.
+ """
+ emit(
+ "==>",
+ "blue",
+ *message,
+ enabled=obj.get("verbose", False) and not obj.get("quiet", False),
+ )
+
+
+@click.pass_obj
+def debug(obj: dict[str, bool], *message: str) -> None:
+ """Print a formatted debug message to stdout in magenta.
+
+ Only prints when the DEBUG environment variable is set to a truthy
+ value (1, true, yes). The first argument is rendered in bold,
+ subsequent arguments are appended unstyled.
+
+ Args:
+ *message: One or more message parts to display.
+ """
+ emit("==>", "magenta", *message, enabled=obj.get("debug", False))
diff --git a/pyproject.toml b/pyproject.toml
index 2108223..0bffd85 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,6 +59,9 @@ include = [
[tool.ruff]
target-version = "py311"
+[tool.ruff.format]
+skip-magic-trailing-comma = true
+
[tool.pyright]
pythonVersion = "3.11"
typeCheckingMode = "strict"