Skip to content

Commit f8b4183

Browse files
leynosclaude
andcommitted
Add boundary logging to ambrleaks and split out its config module
Address the re-raised Observability warning, which scans the operational paths for instrumentation, by adding standard-library structured logging to the ambrleaks CLI rather than only documenting the rationale. - cli.py: an `ambrleaks` logger (with a NullHandler, library-style) emits INFO records at the configuration, scan (finding count), baseline (suppression count), and failure boundaries. A new -v / --verbose flag attaches a stderr handler at INFO; embedders can configure the logger themselves. Default output is unchanged — the logs are silent without a handler, so findings/errors/exit codes stay the sole user-facing contract. Adding the logging pushed cli.py past the 400-line limit, so the configuration model and parser (Config, ConfigError, the validators, parse_config, read_config, select_rules) move to a new config.py, per AGENTS.md's "break up by feature" guidance. cli.py imports what it uses; tests import the config symbols from their new home. Behaviour and message text are unchanged. Tests cover the config/scan/baseline boundary logs, error logging, --verbose stderr surfacing, default quiet, and handler idempotency. Developers' and users' guides updated for the four-module layout, the logging posture, and the --verbose flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 431db6f commit f8b4183

7 files changed

Lines changed: 339 additions & 177 deletions

File tree

df12_python_lints/ambrleaks/cli.py

Lines changed: 36 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -24,162 +24,25 @@
2424
import collections
2525
import fnmatch
2626
import json
27+
import logging
2728
import pathlib
2829
import re
2930
import sys
3031
import tomllib
3132
import typing as typ
3233

33-
from .rules import DEFAULT_RULES, Rule
34+
from .config import Config, ConfigError, read_config, select_rules
3435
from .scanner import Finding, scan_file
3536

3637
if typ.TYPE_CHECKING:
3738
import collections.abc as cabc
3839

3940
_CONFIG_NAMES: typ.Final = ("ambrleaks.toml", ".ambrleaks.toml")
4041

41-
42-
class ConfigError(ValueError):
43-
"""Raised when a configuration file is structurally invalid."""
44-
45-
46-
class Config(typ.NamedTuple):
47-
"""Loaded configuration for a scan run.
48-
49-
Examples
50-
--------
51-
``default_config()`` gives the defaults: shipped rule states and
52-
empty allowlists.
53-
"""
54-
55-
rule_states: tuple[tuple[str, bool], ...]
56-
allow_values: tuple[str, ...]
57-
allow_tests: tuple[str, ...]
58-
allow_paths: tuple[str, ...]
59-
60-
61-
def default_config() -> Config:
62-
"""Return the configuration used when no file is present.
63-
64-
Examples
65-
--------
66-
``default_config().allow_values`` is empty.
67-
"""
68-
return Config(rule_states=(), allow_values=(), allow_tests=(), allow_paths=())
69-
70-
71-
def _validate_rules(rules: object) -> None:
72-
"""Ensure ``[rules]`` is a table of tables with boolean ``enabled``."""
73-
if not isinstance(rules, dict):
74-
message = "[rules] must be a table"
75-
raise ConfigError(message)
76-
for rule_id, entry in rules.items():
77-
if not isinstance(entry, dict):
78-
message = f"[rules.{rule_id}] must be a table"
79-
raise ConfigError(message)
80-
enabled = entry.get("enabled", True)
81-
if not isinstance(enabled, bool):
82-
message = f"[rules.{rule_id}] enabled must be a boolean"
83-
raise ConfigError(message)
84-
85-
86-
def _validate_string_list(value: object, field: str) -> None:
87-
"""Ensure *value* is a list containing only strings."""
88-
if isinstance(value, list) and all(isinstance(item, str) for item in value):
89-
return
90-
message = f"[allowlist] {field} must be a list of strings"
91-
raise ConfigError(message)
92-
93-
94-
def _validate_allowlist(allowlist: object) -> None:
95-
"""Ensure ``[allowlist]`` is a table of recognised string lists."""
96-
if not isinstance(allowlist, dict):
97-
message = "[allowlist] must be a table"
98-
raise ConfigError(message)
99-
for field in ("values", "tests", "paths"):
100-
_validate_string_list(allowlist.get(field, []), field)
101-
102-
103-
def parse_config(text: str) -> Config:
104-
r"""Parse *text* as an ``ambrleaks`` TOML configuration.
105-
106-
This is the pure configuration core: it decodes and structurally
107-
validates *text* without any filesystem access, so callers can test
108-
it against literal TOML. :func:`read_config` is the filesystem
109-
boundary that supplies *text* from a file.
110-
111-
Raises
112-
------
113-
ConfigError
114-
If the configuration is structurally invalid.
115-
tomllib.TOMLDecodeError
116-
If *text* is not valid TOML.
117-
118-
Examples
119-
--------
120-
``parse_config("[rules.snapshot-phone]\\nenabled = true\\n")``
121-
switches the phone rule on.
122-
"""
123-
data = tomllib.loads(text)
124-
rules = data.get("rules", {})
125-
allowlist = data.get("allowlist", {})
126-
_validate_rules(rules)
127-
_validate_allowlist(allowlist)
128-
return Config(
129-
rule_states=tuple(
130-
(rule_id, bool(entry.get("enabled", True)))
131-
for rule_id, entry in rules.items()
132-
),
133-
allow_values=tuple(allowlist.get("values", ())),
134-
allow_tests=tuple(allowlist.get("tests", ())),
135-
allow_paths=tuple(allowlist.get("paths", ())),
136-
)
137-
138-
139-
def read_config(path: pathlib.Path | None) -> Config:
140-
"""Read *path* as TOML configuration, or defaults when ``None``.
141-
142-
Thin filesystem boundary over :func:`parse_config`: it reads *path*
143-
as UTF-8 and delegates decoding and validation. Read and decode
144-
failures propagate to the caller; the CLI reports them at its
145-
boundary.
146-
147-
Raises
148-
------
149-
ConfigError
150-
If the configuration is structurally invalid.
151-
OSError
152-
If the file cannot be read.
153-
UnicodeDecodeError
154-
If the file is not valid UTF-8.
155-
tomllib.TOMLDecodeError
156-
If the file is not valid TOML.
157-
158-
Examples
159-
--------
160-
Given an ``ambrleaks.toml`` containing ``[rules.snapshot-phone]``
161-
with ``enabled = true``, ``read_config(path)`` switches the phone
162-
rule on.
163-
"""
164-
if path is None:
165-
return default_config()
166-
return parse_config(path.read_text(encoding="utf-8"))
167-
168-
169-
def select_rules(config: Config) -> tuple[Rule, ...]:
170-
"""Return the shipped rules filtered by *config* overrides.
171-
172-
Examples
173-
--------
174-
With no overrides, every rule enabled by default is returned and
175-
the opt-in phone rule is not.
176-
"""
177-
states = dict(config.rule_states)
178-
return tuple(
179-
rule
180-
for rule in DEFAULT_RULES
181-
if states.get(rule.rule_id, rule.enabled_by_default)
182-
)
42+
# Library-style logging: quiet unless the caller (or --verbose) attaches a
43+
# handler, so default CLI output stays limited to findings and errors.
44+
logger = logging.getLogger("ambrleaks")
45+
logger.addHandler(logging.NullHandler())
18346

18447

18548
def _is_allowed(finding: Finding, config: Config) -> bool:
@@ -260,6 +123,12 @@ def _parse_arguments(argv: cabc.Sequence[str] | None) -> argparse.Namespace:
260123
"masked so a scan report does not reproduce the secret"
261124
),
262125
)
126+
parser.add_argument(
127+
"-v",
128+
"--verbose",
129+
action="store_true",
130+
help="log scan, configuration, and baseline boundary details to stderr",
131+
)
263132
return parser.parse_args(argv)
264133

265134

@@ -341,25 +210,44 @@ def _masked_value(value: str) -> str:
341210
return f"{value[0]}{'*' * (len(value) - 2)}{value[-1]}"
342211

343212

213+
def _enable_verbose_logging() -> None:
214+
"""Attach a stderr handler so boundary logs surface under ``--verbose``."""
215+
if any(not isinstance(h, logging.NullHandler) for h in logger.handlers):
216+
logger.setLevel(logging.INFO)
217+
return
218+
handler = logging.StreamHandler()
219+
handler.setFormatter(logging.Formatter("ambrleaks: %(levelname)s: %(message)s"))
220+
logger.addHandler(handler)
221+
logger.setLevel(logging.INFO)
222+
223+
344224
def _run(arguments: argparse.Namespace) -> int:
345225
"""Execute the scan described by *arguments* and return an exit code.
346226
347227
The CLI resolves its base directory once here (the working directory
348228
at invocation) and injects it into scanning, so the scanner core
349-
never reads the ambient ``cwd`` itself.
229+
never reads the ambient ``cwd`` itself. Structured logs mark the
230+
configuration, scan, and baseline boundaries; they are silent unless
231+
``--verbose`` (or a caller-attached handler) surfaces them.
350232
"""
351-
config = read_config(arguments.config or _default_config_path())
233+
config_path = arguments.config or _default_config_path()
234+
config = read_config(config_path)
235+
logger.info("loaded configuration from %s", config_path or "defaults")
352236
base_dir = pathlib.Path.cwd()
353237
findings = _collect_findings(arguments, config, base_dir)
238+
logger.info("scan produced %d finding(s) under %s", len(findings), base_dir)
354239
if arguments.write_baseline is not None:
355240
fingerprints = sorted(f.fingerprint() for f in findings)
356241
arguments.write_baseline.write_text(
357242
json.dumps(fingerprints, indent=2) + "\n", encoding="utf-8"
358243
)
244+
logger.info("wrote baseline of %d fingerprint(s)", len(fingerprints))
359245
print(f"ambrleaks: baselined {len(fingerprints)} finding(s)")
360246
return 0
361247
if arguments.baseline is not None:
248+
before = len(findings)
362249
findings = apply_baseline(findings, read_baseline(arguments.baseline))
250+
logger.info("baseline suppressed %d finding(s)", before - len(findings))
363251
for finding in findings:
364252
value = finding.value if arguments.show_values else _masked_value(finding.value)
365253
print(
@@ -381,6 +269,8 @@ def main(argv: cabc.Sequence[str] | None = None) -> int:
381269
findings remain after allowlists and the baseline.
382270
"""
383271
arguments = _parse_arguments(argv)
272+
if arguments.verbose:
273+
_enable_verbose_logging()
384274
try:
385275
return _run(arguments)
386276
except (
@@ -390,5 +280,6 @@ def main(argv: cabc.Sequence[str] | None = None) -> int:
390280
tomllib.TOMLDecodeError,
391281
json.JSONDecodeError,
392282
) as err:
283+
logger.exception("scan failed")
393284
print(f"ambrleaks: error: {err}", file=sys.stderr)
394285
return 2

0 commit comments

Comments
 (0)