base-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
base_cli/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as distribution_version
4
+ from pathlib import Path
5
+
6
+
7
+ def _resolve_version() -> str:
8
+ """Return the checkout version or the installed distribution version."""
9
+
10
+ for parent in Path(__file__).resolve().parents:
11
+ version_file = parent / "VERSION"
12
+ if version_file.is_file():
13
+ value = version_file.read_text(encoding="utf-8").splitlines()[0].strip()
14
+ if value:
15
+ return value
16
+
17
+ try:
18
+ return distribution_version("base-cli")
19
+ except PackageNotFoundError:
20
+ return "0.0.0"
21
+
22
+
23
+ __version__ = _resolve_version()
24
+
25
+ from . import command_filters, command_protocol, history, testing
26
+ from .app import App, argument, command, delegated_display_command, option, run_app
27
+ from .command_filters import command_matches, normalize_command_filter, normalize_command_filters
28
+ from .command_protocol import CommandProtocolError, dumps_record, dumps_records, loads_records
29
+ from .config import UserConfig, UserGithubConfig, UserIdeConfig, UserIdePreference, UserWorkspaceConfig
30
+ from .context import Context, get_current_context
31
+ from .exit_codes import ExitCode
32
+ from .inspection import inspection_envelope, render_inspection_json
33
+ from .logging import configure_logger, log_critical, log_debug, log_error, log_info, log_warning
34
+ from .output import (
35
+ OutputFormatError,
36
+ PUBLIC_OUTPUT_FORMATS,
37
+ is_terminal,
38
+ output_format_choices,
39
+ render_document,
40
+ render_records,
41
+ resolve_output_format,
42
+ )
43
+
44
+ __all__ = [
45
+ "App",
46
+ "__version__",
47
+ "CommandProtocolError",
48
+ "Context",
49
+ "ExitCode",
50
+ "UserConfig",
51
+ "UserGithubConfig",
52
+ "UserIdeConfig",
53
+ "UserIdePreference",
54
+ "UserWorkspaceConfig",
55
+ "command_filters",
56
+ "command_matches",
57
+ "command_protocol",
58
+ "dumps_record",
59
+ "dumps_records",
60
+ "history",
61
+ "inspection_envelope",
62
+ "render_inspection_json",
63
+ "testing",
64
+ "argument",
65
+ "command",
66
+ "configure_logger",
67
+ "delegated_display_command",
68
+ "get_current_context",
69
+ "log_critical",
70
+ "log_debug",
71
+ "log_error",
72
+ "log_info",
73
+ "log_warning",
74
+ "loads_records",
75
+ "normalize_command_filter",
76
+ "normalize_command_filters",
77
+ "OutputFormatError",
78
+ "PUBLIC_OUTPUT_FORMATS",
79
+ "is_terminal",
80
+ "output_format_choices",
81
+ "option",
82
+ "render_document",
83
+ "render_records",
84
+ "resolve_output_format",
85
+ "run_app",
86
+ ]
base_cli/_runtime.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .paths import runtime_owner_root, runtime_run_directory_name
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RuntimeLayout:
12
+ owner_root: Path
13
+ run_root: Path
14
+ state_dir: Path
15
+ log_dir: Path
16
+ cache_dir: Path
17
+ temp_dir: Path
18
+
19
+
20
+ # pylint: disable=too-many-arguments
21
+ def runtime_layout(
22
+ cache_root: Path,
23
+ cli_name: str,
24
+ run_id: str,
25
+ *,
26
+ owner: str = "base",
27
+ project_name: str | None = None,
28
+ project_root: Path | None = None,
29
+ inherited_run_root: Path | None = None,
30
+ ) -> RuntimeLayout:
31
+ owner_root = runtime_owner_root(cache_root, owner, project_name, project_root)
32
+ run_root = inherited_run_root or owner_root / "runs" / runtime_run_directory_name(run_id, cli_name, project_name)
33
+ state_dir = owner_root
34
+ # Every public invocation owns one run bundle and one diagnostic log.
35
+ # Child processes inherit that bundle instead of creating component logs.
36
+ log_dir = run_root / "logs"
37
+ return RuntimeLayout(
38
+ owner_root=owner_root,
39
+ run_root=run_root,
40
+ state_dir=state_dir,
41
+ log_dir=log_dir,
42
+ cache_dir=owner_root / "cache" / "components" / cli_name,
43
+ temp_dir=run_root / "tmp" / cli_name / run_id,
44
+ )
45
+
46
+
47
+ def create_runtime_directory(path: Path, cache_root: Path) -> None:
48
+ try:
49
+ path.mkdir(parents=True, exist_ok=True)
50
+ except OSError as exc:
51
+ raise RuntimeError(_runtime_directory_error(path, cache_root, exc)) from exc
52
+
53
+
54
+ def prune_log_files(
55
+ log_dir: Path,
56
+ current_log_file: Path,
57
+ max_log_files: int,
58
+ logger: logging.Logger,
59
+ ) -> None:
60
+ candidates: list[tuple[str, Path]] = []
61
+ for path in log_dir.rglob("*.log"):
62
+ if _same_path(path, current_log_file):
63
+ continue
64
+ candidates.append((path.name, path))
65
+
66
+ excess_count = len(candidates) + 1 - max_log_files
67
+ if excess_count <= 0:
68
+ return
69
+
70
+ for _, path in sorted(candidates)[:excess_count]:
71
+ try:
72
+ path.unlink()
73
+ except OSError as exc:
74
+ logger.warning("Could not prune log file '%s': %s", path, exc)
75
+
76
+
77
+ def _runtime_directory_error(path: Path, cache_root: Path, exc: OSError) -> str:
78
+ return (
79
+ f"Unable to create Base runtime directory '{path}': {exc}. "
80
+ f"Check permissions on that directory. If the Base cache root '{cache_root}' is unusable, "
81
+ "set BASE_CACHE_DIR to a writable directory."
82
+ )
83
+
84
+
85
+ def _same_path(left: Path, right: Path) -> bool:
86
+ try:
87
+ return left.resolve() == right.resolve()
88
+ except OSError:
89
+ return left.absolute() == right.absolute()
base_cli/app.py ADDED
@@ -0,0 +1,483 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import json
5
+ import os
6
+ import sys
7
+ from contextvars import ContextVar
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+ from ._runtime import create_runtime_directory, prune_log_files, runtime_layout
12
+ from .config import load_config, read_user_config
13
+ from .context import Context, reset_current_context, set_current_context
14
+ from .exit_codes import ExitCode
15
+ from .history import HISTORY_SCOPE_INTERNAL, utc_now, write_finished_record
16
+ from .logging import configure_logger, log_invocation
17
+ from .paths import (
18
+ base_cache_root,
19
+ current_working_dir,
20
+ discover_manifest,
21
+ make_run_id,
22
+ normalize_cli_name,
23
+ normalize_runtime_owner,
24
+ runtime_project_name,
25
+ runtime_project_root,
26
+ resolve_base_home,
27
+ )
28
+ from .redaction import parameter_name_from_decls
29
+
30
+ _STANDARD_OPTION_KEYS = ("debug", "quiet", "environment", "config", "keep_temp", "log_file")
31
+ _GROUP_STANDARD_OPTIONS_KEY = "base_cli_standard_options"
32
+ DISPLAY_COMMAND_ENV = "BASE_CLI_DISPLAY_COMMAND"
33
+ _INVOCATION_ARGV: ContextVar[list[str] | None] = ContextVar("base_cli_invocation_argv", default=None)
34
+
35
+
36
+ def _default_log_file(layout: Any, inherited_path: Path | None) -> Path:
37
+ if inherited_path is not None:
38
+ return Path(
39
+ os.environ.get(
40
+ "BASE_CLI_PRIMARY_LOG",
41
+ str(layout.log_dir / "primary.log"),
42
+ )
43
+ ).expanduser()
44
+ return layout.log_dir / "primary.log"
45
+
46
+
47
+ def _history_scope(inherited_path: Path | None) -> str:
48
+ return os.environ.get(
49
+ "BASE_CLI_HISTORY_SCOPE",
50
+ HISTORY_SCOPE_INTERNAL if inherited_path is not None else "primary",
51
+ )
52
+
53
+
54
+ def _require_click():
55
+ try:
56
+ import click
57
+ except ImportError as exc:
58
+ raise RuntimeError("Click is required for base_cli. Install it with 'pip install click'.") from exc
59
+ return click
60
+
61
+
62
+ # pylint: disable=too-many-statements
63
+ class App:
64
+ """Define a Click-backed command with Base's shared runtime lifecycle."""
65
+
66
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
67
+ def __init__(
68
+ self,
69
+ name: str | None = None,
70
+ version: str | None = None,
71
+ help: str | None = None, # pylint: disable=redefined-builtin
72
+ log_to_file: bool = True,
73
+ max_log_files: int | None = None,
74
+ ) -> None:
75
+ if max_log_files is not None and max_log_files < 1:
76
+ raise ValueError("max_log_files must be greater than 0 when set.")
77
+ self.name = normalize_cli_name(name or sys.argv[0])
78
+ self.version = version
79
+ self.help = help
80
+ self.log_to_file = log_to_file
81
+ self.max_log_files = max_log_files
82
+ self._click_command = None
83
+ self._command_func: Callable[..., Any] | None = None
84
+ self._command_args: tuple[Any, ...] = ()
85
+ self._command_kwargs: dict[str, Any] = {}
86
+ self._subcommands: list[tuple[Callable[..., Any], tuple[Any, ...], dict[str, Any]]] = []
87
+
88
+ def command(self, *command_args: Any, **command_kwargs: Any):
89
+ def decorator(func: Callable[..., Any]):
90
+ if self._subcommands:
91
+ raise RuntimeError(
92
+ f"App '{self.name}' already has registered subcommands. "
93
+ "Use @app.subcommand() for additional entry points."
94
+ )
95
+ if self._command_func is not None:
96
+ raise RuntimeError(
97
+ f"App '{self.name}' already has a registered command. "
98
+ "Use subcommands for multiple entry points."
99
+ )
100
+ self._command_func = func
101
+ self._command_args = command_args
102
+ self._command_kwargs = command_kwargs
103
+ return func
104
+
105
+ return decorator
106
+
107
+ def subcommand(self, *command_args: Any, **command_kwargs: Any):
108
+ def decorator(func: Callable[..., Any]):
109
+ if self._command_func is not None:
110
+ raise RuntimeError(
111
+ f"App '{self.name}' already has a registered command. "
112
+ "Use either @app.command() or @app.subcommand(), not both."
113
+ )
114
+ self._subcommands.append((func, command_args, command_kwargs))
115
+ return func
116
+
117
+ return decorator
118
+
119
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
120
+ return self.click_command(*args, **kwargs)
121
+
122
+ @property
123
+ def click_command(self) -> Any:
124
+ if self._click_command is None:
125
+ self._click_command = self._build_click_command()
126
+ return self._click_command
127
+
128
+ def _build_click_command(self) -> Any:
129
+ if self._command_func is None and not self._subcommands:
130
+ raise RuntimeError("No command has been registered on this base_cli.App.")
131
+
132
+ click = _require_click()
133
+ if self._command_func is not None:
134
+ wrapper = self._build_command_wrapper(click, self._command_func, include_version=True)
135
+ command_kwargs = dict(self._command_kwargs)
136
+ if self.help is not None:
137
+ command_kwargs.setdefault("help", self.help)
138
+ return click.command(*self._command_args, **command_kwargs)(wrapper)
139
+
140
+ group_wrapper = _decorate_standard_options(click, _build_group_wrapper(click), self.version)
141
+ group = click.group(name=self.name, help=self.help)(group_wrapper)
142
+ for func, command_args, command_kwargs in self._subcommands:
143
+ wrapper = self._build_command_wrapper(click, func, include_version=False)
144
+ group.add_command(click.command(*command_args, **command_kwargs)(wrapper))
145
+ return group
146
+
147
+ def _build_command_wrapper(self, click: Any, func: Callable[..., Any], include_version: bool) -> Callable[..., Any]:
148
+ sensitive_options = set(getattr(func, "__base_cli_sensitive_options__", set()))
149
+ dry_run_parameter = getattr(func, "__base_cli_dry_run_parameter__", "dry_run")
150
+
151
+ @functools.wraps(func)
152
+ def wrapper(**kwargs: Any):
153
+ standard = _merge_standard_options(
154
+ _group_standard_options(click),
155
+ _pop_standard_options(kwargs),
156
+ )
157
+ _validate_standard_options(click, standard)
158
+ try:
159
+ context = self._create_context(standard, sensitive_options, dry_run=bool(kwargs.get(dry_run_parameter)))
160
+ except (RuntimeError, ValueError) as exc:
161
+ raise click.ClickException(str(exc)) from exc
162
+ token = set_current_context(context)
163
+ started_at = utc_now()
164
+ exit_code = ExitCode.SUCCESS
165
+ invocation_argv = _current_invocation_argv()
166
+ try:
167
+ log_invocation(context.log, invocation_argv, sensitive_options)
168
+ if context.project_root is not None:
169
+ context.log.debug("project_root=%s", context.project_root)
170
+ if context.manifest_path is not None:
171
+ context.log.debug("manifest_path=%s", context.manifest_path)
172
+ result = func(context, **kwargs)
173
+ exit_code = int(result or ExitCode.SUCCESS)
174
+ return result
175
+ except Exception:
176
+ exit_code = ExitCode.FAILURE
177
+ raise
178
+ finally:
179
+ write_finished_record(context, invocation_argv, sensitive_options, started_at, exit_code)
180
+ reset_current_context(token)
181
+ context.cleanup()
182
+
183
+ for kind, param_decls, attrs in getattr(func, "__base_cli_param_specs__", []):
184
+ if kind == "option":
185
+ wrapper = click.option(*param_decls, **attrs)(wrapper)
186
+ elif kind == "argument":
187
+ wrapper = click.argument(*param_decls, **attrs)(wrapper)
188
+ wrapper = _decorate_standard_options(click, wrapper, self.version if include_version else None)
189
+ return wrapper
190
+
191
+ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], dry_run: bool = False) -> Context:
192
+ del sensitive_options
193
+ manifest_override = os.environ.get("BASE_CLI_PROJECT_MANIFEST")
194
+ manifest_path = (
195
+ Path(manifest_override).expanduser().resolve()
196
+ if manifest_override
197
+ else discover_manifest(current_working_dir())
198
+ )
199
+ project_root = manifest_path.parent if manifest_path is not None else None
200
+ explicit_config = Path(standard["config"]).expanduser() if standard.get("config") else None
201
+ user_config = read_user_config()
202
+ config = load_config(project_root, explicit_config)
203
+
204
+ environment = standard.get("environment") or config.get("environment") or "dev"
205
+ debug = bool(standard.get("debug") or str(config.get("log_level", "")).lower() == "debug")
206
+ quiet = bool(standard.get("quiet"))
207
+ keep_temp = bool(standard.get("keep_temp") or config.get("keep_temp"))
208
+
209
+ cache_root = base_cache_root()
210
+ runtime_owner = normalize_runtime_owner()
211
+ selected_project_root = runtime_project_root() or project_root
212
+ selected_project_name = runtime_project_name() or (
213
+ selected_project_root.name if selected_project_root else None
214
+ )
215
+ inherited_run_root = os.environ.get("BASE_CLI_RUN_ROOT") if runtime_owner == "base" else None
216
+ inherited_path = Path(inherited_run_root).expanduser().resolve() if inherited_run_root else None
217
+ inherited_run_id = os.environ.get("BASE_CLI_RUN_ID") if inherited_path is not None else None
218
+ run_id = inherited_run_id or (inherited_path.name if inherited_path is not None else make_run_id())
219
+ layout = runtime_layout(
220
+ cache_root,
221
+ self.name,
222
+ run_id,
223
+ owner=runtime_owner,
224
+ project_name=selected_project_name,
225
+ project_root=selected_project_root,
226
+ inherited_run_root=inherited_path,
227
+ )
228
+
229
+ log_file = Path(standard["log_file"]).expanduser() if standard.get("log_file") else None
230
+ uses_default_log_file = log_file is None
231
+ if dry_run or not self.log_to_file:
232
+ if log_file is not None:
233
+ create_runtime_directory(log_file.parent, cache_root)
234
+ else:
235
+ for directory in (layout.log_dir, layout.cache_dir, layout.temp_dir):
236
+ create_runtime_directory(directory, cache_root)
237
+ if log_file is None:
238
+ log_file = _default_log_file(layout, inherited_path)
239
+ create_runtime_directory(log_file.parent, cache_root)
240
+ if inherited_path is None and not dry_run and self.log_to_file:
241
+ create_runtime_directory(layout.run_root, cache_root)
242
+ try:
243
+ run_metadata = {
244
+ "run_id": run_id,
245
+ "owner": runtime_owner,
246
+ "cli": self.name,
247
+ "status": "running",
248
+ "started_at": utc_now().isoformat(timespec="seconds").replace("+00:00", "Z"),
249
+ "project": selected_project_name,
250
+ "project_root": str(selected_project_root) if selected_project_root else None,
251
+ "manifest": str(manifest_path) if manifest_path else None,
252
+ "workspace_root": str(user_config.workspace.root) if user_config.workspace.root else None,
253
+ }
254
+ run_metadata_path = layout.run_root / "run.json"
255
+ run_metadata_path.write_text(
256
+ json.dumps(run_metadata, sort_keys=True) + "\n",
257
+ encoding="utf-8",
258
+ )
259
+ run_metadata_path.chmod(0o600)
260
+ except OSError:
261
+ pass
262
+ if runtime_owner == "project" and selected_project_root is not None and not dry_run and self.log_to_file:
263
+ try:
264
+ create_runtime_directory(layout.owner_root, cache_root)
265
+ identity_path = layout.owner_root / "identity.json"
266
+ if not identity_path.exists():
267
+ identity_path.write_text(
268
+ json.dumps(
269
+ {
270
+ "schema_version": 1,
271
+ "project": selected_project_name,
272
+ "project_root": str(selected_project_root),
273
+ "manifest": str(manifest_path) if manifest_path is not None else None,
274
+ "checkout_id": layout.owner_root.name,
275
+ },
276
+ sort_keys=True,
277
+ )
278
+ + "\n",
279
+ encoding="utf-8",
280
+ )
281
+ identity_path.chmod(0o600)
282
+ except OSError:
283
+ pass
284
+ logger = configure_logger(self.name, log_file, debug, quiet=quiet)
285
+ logger.debug("cli=%s run_id=%s environment=%s", self.name, run_id, environment)
286
+ if self.max_log_files is not None and uses_default_log_file and log_file is not None:
287
+ prune_log_files(layout.owner_root / "runs", log_file, self.max_log_files, logger)
288
+
289
+ return Context(
290
+ cli_name=self.name,
291
+ run_id=run_id,
292
+ runtime_owner=runtime_owner,
293
+ owner_root=layout.owner_root,
294
+ run_root=layout.run_root,
295
+ base_home=resolve_base_home(),
296
+ project_root=selected_project_root,
297
+ workspace_root=user_config.workspace.root,
298
+ manifest_path=manifest_path,
299
+ project_name=selected_project_name,
300
+ state_dir=layout.state_dir,
301
+ log_dir=layout.log_dir,
302
+ cache_dir=layout.cache_dir,
303
+ temp_dir=layout.temp_dir,
304
+ log_file=log_file,
305
+ config=config,
306
+ environment=environment,
307
+ debug=debug,
308
+ quiet=quiet,
309
+ keep_temp=keep_temp,
310
+ log=logger,
311
+ user_config=user_config,
312
+ dry_run=dry_run,
313
+ history_scope=_history_scope(inherited_path),
314
+ history_parent_run_id=os.environ.get("BASE_CLI_HISTORY_PARENT_RUN_ID") or None,
315
+ )
316
+
317
+
318
+ def run_app(app: App, argv: list[str] | None = None) -> int:
319
+ """Run an :class:`App` and return its normalized process exit code."""
320
+
321
+ try:
322
+ click = _require_click()
323
+ except RuntimeError as exc:
324
+ print(f"ERROR: {exc}", file=sys.stderr)
325
+ return ExitCode.FAILURE
326
+
327
+ explicit_argv = argv is not None
328
+ args = list(sys.argv[1:] if argv is None else argv)
329
+ try:
330
+ _reject_equals_option_values(click, args)
331
+ display_command = delegated_display_command()
332
+ invocation_argv = _effective_invocation_argv(app, args, explicit_argv, display_command)
333
+ invocation_token = _INVOCATION_ARGV.set(invocation_argv)
334
+ try:
335
+ if display_command:
336
+ result = app.click_command.main(args=args, prog_name=display_command, standalone_mode=False)
337
+ else:
338
+ result = app.click_command.main(args=args, standalone_mode=False)
339
+ finally:
340
+ _INVOCATION_ARGV.reset(invocation_token)
341
+ except click.ClickException as exc:
342
+ exc.show()
343
+ return int(exc.exit_code)
344
+ return int(result or 0)
345
+
346
+
347
+ def _effective_invocation_argv(
348
+ app: App,
349
+ args: list[str],
350
+ explicit_argv: bool,
351
+ display_command: str | None,
352
+ ) -> list[str]:
353
+ if not explicit_argv:
354
+ return list(sys.argv)
355
+ return [display_command or app.name, *args]
356
+
357
+
358
+ def _current_invocation_argv() -> list[str]:
359
+ invocation_argv = _INVOCATION_ARGV.get()
360
+ if invocation_argv is not None:
361
+ return list(invocation_argv)
362
+ return list(sys.argv)
363
+
364
+
365
+ def delegated_display_command(default: str | None = None) -> str | None:
366
+ display_command = os.environ.get(DISPLAY_COMMAND_ENV, "").strip()
367
+ if display_command:
368
+ return display_command
369
+ return default
370
+
371
+
372
+ def command(*args: Any, **kwargs: Any):
373
+ return App().command(*args, **kwargs)
374
+
375
+
376
+ def option(*param_decls: str, sensitive: bool = False, dry_run: bool = False, **attrs: Any):
377
+ def decorator(func: Callable[..., Any]):
378
+ specs = list(getattr(func, "__base_cli_param_specs__", []))
379
+ specs.append(("option", param_decls, attrs))
380
+ func.__base_cli_param_specs__ = specs
381
+ if sensitive:
382
+ options = set(getattr(func, "__base_cli_sensitive_options__", set()))
383
+ options.add(parameter_name_from_decls(param_decls))
384
+ func.__base_cli_sensitive_options__ = options
385
+ if dry_run:
386
+ dry_run_parameter = parameter_name_from_decls(param_decls)
387
+ existing_dry_run_parameter = getattr(func, "__base_cli_dry_run_parameter__", None)
388
+ if existing_dry_run_parameter is not None:
389
+ raise RuntimeError(
390
+ f"{func.__name__} already designates '{existing_dry_run_parameter}' as dry-run. "
391
+ "only one option can be designated dry_run=True."
392
+ )
393
+ func.__base_cli_dry_run_parameter__ = dry_run_parameter
394
+ return func
395
+
396
+ return decorator
397
+
398
+
399
+ def argument(*param_decls: str, **attrs: Any):
400
+ def decorator(func: Callable[..., Any]):
401
+ specs = list(getattr(func, "__base_cli_param_specs__", []))
402
+ specs.append(("argument", param_decls, attrs))
403
+ func.__base_cli_param_specs__ = specs
404
+ return func
405
+
406
+ return decorator
407
+
408
+
409
+ def _decorate_standard_options(click: Any, func: Callable[..., Any], version: str | None):
410
+ func = click.option("--log-file", type=click.Path(dir_okay=False), help="Override the persistent log file.")(func)
411
+ func = click.option("--keep-temp", is_flag=True, default=None, help="Preserve this run's temp directory.")(func)
412
+ func = click.option("--config", type=click.Path(dir_okay=False), help="Load an additional config file.")(func)
413
+ func = click.option("--environment", help="Set the Base CLI environment.")(func)
414
+ func = click.option(
415
+ "--debug",
416
+ is_flag=True,
417
+ default=None,
418
+ help="Enable DEBUG logging on the user-facing stream.",
419
+ )(func)
420
+ func = click.option(
421
+ "--quiet",
422
+ "-q",
423
+ is_flag=True,
424
+ default=None,
425
+ help="Suppress INFO logs on the user-facing stream.",
426
+ )(func)
427
+ if version is not None:
428
+ func = click.version_option(version)(func)
429
+ return func
430
+
431
+
432
+ def _pop_standard_options(kwargs: dict[str, Any]) -> dict[str, Any]:
433
+ standard = {}
434
+ for key in _STANDARD_OPTION_KEYS:
435
+ standard[key] = kwargs.pop(key, None)
436
+ return standard
437
+
438
+
439
+ def _merge_standard_options(group_standard: dict[str, Any], command_standard: dict[str, Any]) -> dict[str, Any]:
440
+ merged = {}
441
+ for key in _STANDARD_OPTION_KEYS:
442
+ value = command_standard.get(key)
443
+ merged[key] = group_standard.get(key) if value is None else value
444
+ return merged
445
+
446
+
447
+ def _validate_standard_options(click: Any, standard: dict[str, Any]) -> None:
448
+ if standard.get("debug") and standard.get("quiet"):
449
+ raise click.UsageError("--debug and --quiet cannot be used together.")
450
+
451
+
452
+ def _reject_equals_option_values(click: Any, argv: list[str]) -> None:
453
+ for token in argv:
454
+ if token == "--":
455
+ return
456
+ if token.startswith("--") and "=" in token and len(token) > 2:
457
+ option_name, value = token.split("=", 1)
458
+ if value:
459
+ raise click.UsageError(
460
+ f"Option '{option_name}' uses unsupported equals syntax. Use '{option_name} {value}' instead."
461
+ )
462
+ raise click.UsageError(
463
+ f"Option '{option_name}' uses unsupported equals syntax. Pass its value as the next argument."
464
+ )
465
+
466
+
467
+ def _group_standard_options(click: Any) -> dict[str, Any]:
468
+ context = click.get_current_context(silent=True)
469
+ parent = context.parent if context is not None else None
470
+ if parent is None or not isinstance(parent.obj, dict):
471
+ return {}
472
+ standard = parent.obj.get(_GROUP_STANDARD_OPTIONS_KEY)
473
+ return dict(standard) if isinstance(standard, dict) else {}
474
+
475
+
476
+ def _build_group_wrapper(click: Any) -> Callable[..., None]:
477
+ @click.pass_context
478
+ def group_wrapper(context: Any, **kwargs: Any) -> None:
479
+ obj = dict(context.obj) if isinstance(context.obj, dict) else {}
480
+ obj[_GROUP_STANDARD_OPTIONS_KEY] = _pop_standard_options(kwargs)
481
+ context.obj = obj
482
+
483
+ return group_wrapper
@@ -0,0 +1,37 @@
1
+ """Shared command-name filter normalization for Base reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ __all__ = [
7
+ "command_matches",
8
+ "normalize_command_filter",
9
+ "normalize_command_filters",
10
+ ]
11
+
12
+
13
+ def normalize_command_filter(value: str) -> str:
14
+ """Normalize one public or internal command name for matching."""
15
+
16
+ normalized = value.strip().lower().removeprefix("base_")
17
+ return normalized.replace("_", "-")
18
+
19
+
20
+ def normalize_command_filters(value: str | None) -> tuple[str, ...]:
21
+ """Normalize a comma-separated command filter and reject empty entries."""
22
+
23
+ if value is None:
24
+ return ()
25
+ parts = value.split(",")
26
+ if any(not part.strip() for part in parts):
27
+ raise ValueError("Option '--command' expects comma-separated command names without empty entries.")
28
+ normalized = tuple(dict.fromkeys(normalize_command_filter(part) for part in parts))
29
+ if not normalized or any(not command for command in normalized):
30
+ raise ValueError("Option '--command' expects at least one command name.")
31
+ return normalized
32
+
33
+
34
+ def command_matches(value: str, command_filters: tuple[str, ...]) -> bool:
35
+ """Return whether a command value matches one of the normalized filters."""
36
+
37
+ return normalize_command_filter(value) in command_filters