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 +86 -0
- base_cli/_runtime.py +89 -0
- base_cli/app.py +483 -0
- base_cli/command_filters.py +37 -0
- base_cli/command_protocol.py +263 -0
- base_cli/config.py +270 -0
- base_cli/context.py +103 -0
- base_cli/exit_codes.py +9 -0
- base_cli/history.py +426 -0
- base_cli/ide_schema.py +74 -0
- base_cli/inspection.py +45 -0
- base_cli/logging.py +182 -0
- base_cli/output.py +202 -0
- base_cli/paths.py +136 -0
- base_cli/py.typed +0 -0
- base_cli/redaction.py +50 -0
- base_cli/testing.py +59 -0
- base_cli-0.1.0.dist-info/LICENSE +183 -0
- base_cli-0.1.0.dist-info/METADATA +543 -0
- base_cli-0.1.0.dist-info/RECORD +22 -0
- base_cli-0.1.0.dist-info/WHEEL +5 -0
- base_cli-0.1.0.dist-info/top_level.txt +1 -0
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
|