From a8e880fad6cdc2e26b0e568113b441b729ed6fd5 Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 8 Jul 2026 12:55:30 -0400 Subject: [PATCH] refactor(errors): introduce MissingEnvVarError for missing host env vars Replace die()/RuntimeError at the point of detection with a typed MissingEnvVarError, and catch it in the CLI dispatcher alongside ManifestError. Library code stays free of stderr output; the CLI renders all user-facing error lines in one place. Sites updated: egress.egress_resolve_token_values (unset/empty token), git_gate._provision_dynamic_key and revoke_git_gate_provisioned_keys (missing forge_token_env), env.resolve_env (unset interpolated var). Co-Authored-By: Claude Sonnet 4.6 --- bot_bottle/cli/__init__.py | 6 ++++-- bot_bottle/egress.py | 11 +++++++---- bot_bottle/env.py | 6 ++++-- bot_bottle/errors.py | 18 ++++++++++++++++++ bot_bottle/git_gate.py | 13 ++++++++----- 5 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 bot_bottle/errors.py diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index 879d067..2c81e6b 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations import sys +from ..errors import MissingEnvVarError from ..log import Die, die, error from ..manifest import ManifestError from ._common import PROG @@ -76,9 +77,10 @@ def main(argv: list[str] | None = None) -> int: die(f"unknown command: {command}") try: return handler(rest) or 0 + except MissingEnvVarError as e: + error(str(e)) + return 1 except ManifestError as e: - # Manifest/config problems surface as a catchable exception; - # print the reason and exit non-zero (same UX die() used to give). error(str(e)) return 1 except Die as e: diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 8d29a00..98ea7bf 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -23,6 +23,7 @@ from .egress_addon_core import ( PathMatch as CorePathMatch, Route, ) +from .errors import MissingEnvVarError from .log import die if TYPE_CHECKING: @@ -354,16 +355,18 @@ def egress_resolve_token_values( for token_env, token_ref in token_env_map.items(): value = host_env.get(token_ref) if value is None: - die( + raise MissingEnvVarError( + token_ref, f"egress: host env var '{token_ref}' is unset. Set it " f"before launching, or remove the corresponding auth block " - f"from bottle.egress.routes." + f"from bottle.egress.routes.", ) if not value: - die( + raise MissingEnvVarError( + token_ref, f"egress: host env var '{token_ref}' is empty. The " f"egress will not inject an empty token; set it to " - f"the real value or remove the route's auth block." + f"the real value or remove the route's auth block.", ) out[token_env] = value return out diff --git a/bot_bottle/env.py b/bot_bottle/env.py index 767ad04..63eaebb 100644 --- a/bot_bottle/env.py +++ b/bot_bottle/env.py @@ -35,6 +35,7 @@ import re import sys from dataclasses import dataclass, field +from .errors import MissingEnvVarError from .log import die from .manifest import Manifest @@ -136,9 +137,10 @@ def resolve_env(manifest: Manifest) -> ResolvedEnv: host_var = env_entry_interpolated_from(raw) host_value = os.environ.get(host_var, "") if not host_value: - die( + raise MissingEnvVarError( + host_var, f"env entry {name} is interpolated from ${host_var}, " - f"but ${host_var} is unset or empty in the host environment." + f"but ${host_var} is unset or empty in the host environment.", ) forwarded[name] = host_value else: # literal diff --git a/bot_bottle/errors.py b/bot_bottle/errors.py new file mode 100644 index 0000000..8866878 --- /dev/null +++ b/bot_bottle/errors.py @@ -0,0 +1,18 @@ +"""bot-bottle application-level exception hierarchy. + +Exceptions here are caught by the CLI dispatcher (``cli/__init__.py``) +and rendered as ``bot-bottle: error: …`` lines — same UX as ``die()``, +but without coupling library code to stderr output.""" + +from __future__ import annotations + + +class MissingEnvVarError(Exception): + """Raised when a required host environment variable is unset or empty. + + ``var_name`` is the exact name of the missing variable so callers + can display it without re-parsing the message string.""" + + def __init__(self, var_name: str, message: str) -> None: + self.var_name = var_name + super().__init__(message) diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index dff530a..734c84d 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -36,7 +36,8 @@ from abc import ABC from dataclasses import dataclass from pathlib import Path -from .log import die, info +from .errors import MissingEnvVarError +from .log import info from .manifest import ManifestBottle, ManifestGitEntry @@ -564,9 +565,10 @@ def _provision_dynamic_key( pk = entry.Key token = os.environ.get(pk.forge_token_env) if token is None: - die( + raise MissingEnvVarError( + pk.forge_token_env, f"git-gate.repos[{entry.Name!r}] key.forge_token_env" - f" = {pk.forge_token_env!r}: env var is not set" + f" = {pk.forge_token_env!r}: env var is not set", ) api_url = pk.api_url or f"https://{entry.UpstreamHost}" provisioner = get_provisioner(pk.provider, token, api_url) @@ -608,10 +610,11 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) -> key_id = id_file.read_text().strip() token = os.environ.get(pk.forge_token_env) if token is None: - raise RuntimeError( + raise MissingEnvVarError( + pk.forge_token_env, f"git-gate.repos[{entry.Name!r}] key.forge_token_env" f" = {pk.forge_token_env!r}: env var is not set;" - f" cannot revoke deploy key {key_id}" + f" cannot revoke deploy key {key_id}", ) api_url = pk.api_url or f"https://{entry.UpstreamHost}" provisioner = get_provisioner(pk.provider, token, api_url)