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/logging.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
|
|
11
|
+
from .context import get_current_context
|
|
12
|
+
from .paths import current_working_dir
|
|
13
|
+
from .redaction import redact_argv
|
|
14
|
+
|
|
15
|
+
_COLOR_RESET = "\033[0m"
|
|
16
|
+
_LEVEL_COLORS = {
|
|
17
|
+
logging.DEBUG: "\033[0;36m",
|
|
18
|
+
logging.INFO: "\033[0;32m",
|
|
19
|
+
logging.WARNING: "\033[0;33m",
|
|
20
|
+
logging.ERROR: "\033[0;31m",
|
|
21
|
+
logging.CRITICAL: "\033[0;31m",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# pylint: disable=too-many-arguments
|
|
26
|
+
def configure_logger(
|
|
27
|
+
cli_name: str,
|
|
28
|
+
log_file: Path | None,
|
|
29
|
+
debug: bool,
|
|
30
|
+
*,
|
|
31
|
+
quiet: bool = False,
|
|
32
|
+
stream: TextIO | None = None,
|
|
33
|
+
formatter: logging.Formatter | None = None,
|
|
34
|
+
) -> logging.Logger:
|
|
35
|
+
logger = logging.getLogger(f"base_cli.{cli_name}")
|
|
36
|
+
logger.setLevel(logging.DEBUG)
|
|
37
|
+
logger.propagate = False
|
|
38
|
+
for handler in list(logger.handlers):
|
|
39
|
+
handler.close()
|
|
40
|
+
logger.removeHandler(handler)
|
|
41
|
+
|
|
42
|
+
user_stream = stream if stream is not None else sys.stderr
|
|
43
|
+
user_handler = logging.StreamHandler(user_stream)
|
|
44
|
+
user_handler.setLevel(_user_stream_level(debug, quiet))
|
|
45
|
+
user_handler.setFormatter(_handler_formatter(formatter, use_color=_use_color(user_stream)))
|
|
46
|
+
logger.addHandler(user_handler)
|
|
47
|
+
|
|
48
|
+
if log_file is not None:
|
|
49
|
+
file_handler = SecureLogFileHandler(log_file, encoding="utf-8")
|
|
50
|
+
file_handler.setLevel(logging.DEBUG)
|
|
51
|
+
file_handler.setFormatter(_handler_formatter(formatter, use_color=False))
|
|
52
|
+
logger.addHandler(file_handler)
|
|
53
|
+
return logger
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _user_stream_level(debug: bool, quiet: bool) -> int:
|
|
57
|
+
if quiet:
|
|
58
|
+
return logging.WARNING
|
|
59
|
+
if debug:
|
|
60
|
+
return logging.DEBUG
|
|
61
|
+
return logging.INFO
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _handler_formatter(formatter: logging.Formatter | None, *, use_color: bool) -> logging.Formatter:
|
|
65
|
+
if formatter is not None:
|
|
66
|
+
return formatter
|
|
67
|
+
return BaseCliFormatter(use_color=use_color)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _use_color(stream: TextIO) -> bool:
|
|
71
|
+
return (
|
|
72
|
+
os.environ.get("BASE_CLI_COLOR") == "1"
|
|
73
|
+
and "NO_COLOR" not in os.environ
|
|
74
|
+
and hasattr(stream, "isatty")
|
|
75
|
+
and stream.isatty()
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def secure_log_file_permissions(log_file: Path) -> None:
|
|
80
|
+
log_file.chmod(0o600)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SecureLogFileHandler(logging.FileHandler):
|
|
84
|
+
def _open(self) -> TextIO:
|
|
85
|
+
fd = os.open(self.baseFilename, _secure_log_file_open_flags(self.mode), 0o600)
|
|
86
|
+
try:
|
|
87
|
+
fchmod = getattr(os, "fchmod", None)
|
|
88
|
+
if fchmod is not None:
|
|
89
|
+
fchmod(fd, 0o600)
|
|
90
|
+
return open(fd, self.mode, encoding=self.encoding, errors=self.errors, closefd=True)
|
|
91
|
+
except BaseException:
|
|
92
|
+
os.close(fd)
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _secure_log_file_open_flags(mode: str) -> int:
|
|
97
|
+
flags = os.O_CREAT
|
|
98
|
+
if "x" in mode:
|
|
99
|
+
return flags | os.O_EXCL | os.O_WRONLY
|
|
100
|
+
if "w" in mode:
|
|
101
|
+
return flags | os.O_TRUNC | os.O_WRONLY
|
|
102
|
+
return flags | os.O_APPEND | os.O_WRONLY
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class BaseCliFormatter(logging.Formatter):
|
|
106
|
+
def __init__(self, *, use_utc: bool | None = None, use_color: bool = False) -> None:
|
|
107
|
+
self.use_utc = use_utc if use_utc is not None else os.environ.get("LOG_UTC") == "1"
|
|
108
|
+
self.use_color = use_color
|
|
109
|
+
datefmt = "%Y-%m-%d %H:%M:%S UTC" if self.use_utc else "%Y-%m-%d %H:%M:%S %z"
|
|
110
|
+
super().__init__(datefmt=datefmt)
|
|
111
|
+
self.converter = time.gmtime if self.use_utc else time.localtime
|
|
112
|
+
|
|
113
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
114
|
+
timestamp = self.formatTime(record, self.datefmt)
|
|
115
|
+
source = _source_path(record)
|
|
116
|
+
level = _level_name(record)
|
|
117
|
+
line = f"{timestamp} {level:<7} {source}:{record.lineno} {record.getMessage()}"
|
|
118
|
+
if not self.use_color:
|
|
119
|
+
return line
|
|
120
|
+
color = _LEVEL_COLORS.get(record.levelno)
|
|
121
|
+
return f"{color}{line}{_COLOR_RESET}" if color else line
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _level_name(record: logging.LogRecord) -> str:
|
|
125
|
+
if record.levelno == logging.WARNING:
|
|
126
|
+
return "WARN"
|
|
127
|
+
if record.levelno == logging.CRITICAL:
|
|
128
|
+
return "FATAL"
|
|
129
|
+
return record.levelname
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _source_path(record: logging.LogRecord) -> str:
|
|
133
|
+
path = Path(record.pathname)
|
|
134
|
+
candidates = []
|
|
135
|
+
base_home = os.environ.get("BASE_HOME")
|
|
136
|
+
if base_home:
|
|
137
|
+
candidates.append(Path(base_home))
|
|
138
|
+
project_root = _active_project_root()
|
|
139
|
+
if project_root is not None:
|
|
140
|
+
candidates.append(project_root)
|
|
141
|
+
candidates.append(current_working_dir())
|
|
142
|
+
|
|
143
|
+
for root in candidates:
|
|
144
|
+
try:
|
|
145
|
+
return str(path.resolve().relative_to(root.resolve()))
|
|
146
|
+
except ValueError:
|
|
147
|
+
continue
|
|
148
|
+
return str(path.resolve())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _active_project_root() -> Path | None:
|
|
152
|
+
try:
|
|
153
|
+
context = get_current_context()
|
|
154
|
+
except RuntimeError:
|
|
155
|
+
return None
|
|
156
|
+
return context.project_root
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def log_invocation(logger: logging.Logger, argv: list[str], sensitive_options: set[str]) -> None:
|
|
160
|
+
logger.debug("argv=%s", redact_argv(argv, sensitive_options))
|
|
161
|
+
logger.debug("platform=%s %s", platform.system(), platform.machine())
|
|
162
|
+
logger.debug("python=%s", sys.version.replace("\n", " "))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def log_debug(message: str, *args: object) -> None:
|
|
166
|
+
get_current_context().log.debug(message, *args, stacklevel=2)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def log_info(message: str, *args: object) -> None:
|
|
170
|
+
get_current_context().log.info(message, *args, stacklevel=2)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def log_warning(message: str, *args: object) -> None:
|
|
174
|
+
get_current_context().log.warning(message, *args, stacklevel=2)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def log_error(message: str, *args: object) -> None:
|
|
178
|
+
get_current_context().log.error(message, *args, stacklevel=2)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def log_critical(message: str, *args: object) -> None:
|
|
182
|
+
get_current_context().log.critical(message, *args, stacklevel=2)
|
base_cli/output.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Shared output-format resolution and rendering for Base CLIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
9
|
+
from typing import Any, TextIO
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PUBLIC_OUTPUT_FORMATS = ("text", "csv", "tsv", "yaml", "json")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutputFormatError(ValueError):
|
|
16
|
+
"""Raised when a public output format is not supported."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def output_format_choices() -> str:
|
|
20
|
+
"""Return the public choices in help/error-message order."""
|
|
21
|
+
|
|
22
|
+
return "|".join(PUBLIC_OUTPUT_FORMATS)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_terminal(stream: TextIO | None = None) -> bool:
|
|
26
|
+
"""Return whether *stream* is an interactive terminal."""
|
|
27
|
+
|
|
28
|
+
candidate = stream if stream is not None else sys.stdout
|
|
29
|
+
try:
|
|
30
|
+
return bool(candidate.isatty())
|
|
31
|
+
except (AttributeError, OSError):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_output_format(
|
|
36
|
+
requested: str | None,
|
|
37
|
+
*,
|
|
38
|
+
stream: TextIO | None = None,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Resolve a requested format, making text TTY-aware.
|
|
41
|
+
|
|
42
|
+
``text`` is intentionally a presentation mode rather than a wire format:
|
|
43
|
+
it renders a table for terminals and tab-delimited rows for redirected or
|
|
44
|
+
piped output. Omitting the format follows the same policy.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
normalized = (requested or "text").lower()
|
|
48
|
+
if normalized not in PUBLIC_OUTPUT_FORMATS:
|
|
49
|
+
raise OutputFormatError(
|
|
50
|
+
f"Unsupported output format '{requested}'. Expected one of: {', '.join(PUBLIC_OUTPUT_FORMATS)}."
|
|
51
|
+
)
|
|
52
|
+
if normalized == "text" and not is_terminal(stream):
|
|
53
|
+
return "tsv"
|
|
54
|
+
return normalized
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# pylint: disable=too-many-arguments
|
|
58
|
+
def render_records(
|
|
59
|
+
records: Iterable[Mapping[str, Any]],
|
|
60
|
+
*,
|
|
61
|
+
requested_format: str | None,
|
|
62
|
+
columns: Sequence[tuple[str, str]],
|
|
63
|
+
stream: TextIO | None = None,
|
|
64
|
+
footer: str | None = None,
|
|
65
|
+
minimum_widths: Sequence[int] | None = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Render records according to the shared public output contract.
|
|
68
|
+
|
|
69
|
+
The returned string is also written to *stream* when supplied (or stdout
|
|
70
|
+
when omitted). JSON and YAML retain the mapping shape supplied by the
|
|
71
|
+
caller; delimited formats use the explicit ``columns`` order and never
|
|
72
|
+
emit a header or footer. ``minimum_widths`` applies only to terminal table
|
|
73
|
+
columns; values can still expand beyond those widths.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
target = stream if stream is not None else sys.stdout
|
|
77
|
+
record_list = [dict(record) for record in records]
|
|
78
|
+
resolved = resolve_output_format(requested_format, stream=target)
|
|
79
|
+
|
|
80
|
+
if resolved in ("csv", "tsv"):
|
|
81
|
+
delimiter = "," if resolved == "csv" else "\t"
|
|
82
|
+
writer = csv.writer(target, delimiter=delimiter, lineterminator="\n")
|
|
83
|
+
for record in record_list:
|
|
84
|
+
writer.writerow([_cell_value(record.get(key)) for _header, key in columns])
|
|
85
|
+
return resolved
|
|
86
|
+
|
|
87
|
+
if resolved == "json":
|
|
88
|
+
target.write(json.dumps(record_list, separators=(",", ":")))
|
|
89
|
+
target.write("\n")
|
|
90
|
+
return resolved
|
|
91
|
+
|
|
92
|
+
if resolved == "yaml":
|
|
93
|
+
try:
|
|
94
|
+
import yaml
|
|
95
|
+
except ImportError as exc: # pragma: no cover - environment guard
|
|
96
|
+
raise RuntimeError("PyYAML is required for YAML output.") from exc
|
|
97
|
+
target.write(yaml.safe_dump(record_list, sort_keys=False, allow_unicode=True))
|
|
98
|
+
return resolved
|
|
99
|
+
|
|
100
|
+
_write_table(target, record_list, columns, footer, minimum_widths)
|
|
101
|
+
return resolved
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_document(
|
|
105
|
+
document: Mapping[str, Any],
|
|
106
|
+
*,
|
|
107
|
+
requested_format: str | None,
|
|
108
|
+
records_key: str | None = None,
|
|
109
|
+
columns: Sequence[tuple[str, str]] | None = None,
|
|
110
|
+
stream: TextIO | None = None,
|
|
111
|
+
) -> str:
|
|
112
|
+
"""Render a structured report or leave terminal text to its existing renderer.
|
|
113
|
+
|
|
114
|
+
Structured formats preserve the complete document. Delimited output uses
|
|
115
|
+
the selected record list (or the document itself) and never emits report
|
|
116
|
+
prose, headers, or footers. A terminal ``text`` request returns ``text``
|
|
117
|
+
without writing so the caller can keep its established human report.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
target = stream if stream is not None else sys.stdout
|
|
121
|
+
resolved = resolve_output_format(requested_format, stream=target)
|
|
122
|
+
if resolved == "text":
|
|
123
|
+
return resolved
|
|
124
|
+
if resolved == "json":
|
|
125
|
+
target.write(json.dumps(dict(document), indent=2))
|
|
126
|
+
target.write("\n")
|
|
127
|
+
return resolved
|
|
128
|
+
if resolved == "yaml":
|
|
129
|
+
try:
|
|
130
|
+
import yaml
|
|
131
|
+
except ImportError as exc: # pragma: no cover - environment guard
|
|
132
|
+
raise RuntimeError("PyYAML is required for YAML output.") from exc
|
|
133
|
+
target.write(yaml.safe_dump(dict(document), sort_keys=False, allow_unicode=True))
|
|
134
|
+
return resolved
|
|
135
|
+
|
|
136
|
+
if records_key:
|
|
137
|
+
candidate = document.get(records_key)
|
|
138
|
+
if isinstance(candidate, list):
|
|
139
|
+
records = [record for record in candidate if isinstance(record, Mapping)]
|
|
140
|
+
else:
|
|
141
|
+
records = [document]
|
|
142
|
+
else:
|
|
143
|
+
records = [document]
|
|
144
|
+
selected_columns = columns or _document_columns(records)
|
|
145
|
+
render_records(
|
|
146
|
+
records,
|
|
147
|
+
requested_format=resolved,
|
|
148
|
+
columns=selected_columns,
|
|
149
|
+
stream=target,
|
|
150
|
+
)
|
|
151
|
+
return resolved
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _document_columns(records: Sequence[Mapping[str, Any]]) -> list[tuple[str, str]]:
|
|
155
|
+
if not records:
|
|
156
|
+
return []
|
|
157
|
+
return [(str(key).upper(), str(key)) for key in records[0]]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _cell_value(value: Any) -> str:
|
|
161
|
+
if value is None:
|
|
162
|
+
return ""
|
|
163
|
+
if isinstance(value, bool):
|
|
164
|
+
return "true" if value else "false"
|
|
165
|
+
if isinstance(value, (Mapping, list, tuple)):
|
|
166
|
+
return json.dumps(value, separators=(",", ":"))
|
|
167
|
+
return str(value)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _write_table(
|
|
171
|
+
stream: TextIO,
|
|
172
|
+
records: Sequence[Mapping[str, Any]],
|
|
173
|
+
columns: Sequence[tuple[str, str]],
|
|
174
|
+
footer: str | None,
|
|
175
|
+
minimum_widths: Sequence[int] | None,
|
|
176
|
+
) -> None:
|
|
177
|
+
selected_minimums = minimum_widths or ()
|
|
178
|
+
if len(selected_minimums) > len(columns):
|
|
179
|
+
raise ValueError("minimum_widths cannot contain more entries than columns")
|
|
180
|
+
|
|
181
|
+
if not records:
|
|
182
|
+
if footer:
|
|
183
|
+
stream.write(f"{footer}\n")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
widths = [
|
|
187
|
+
max(len(header), selected_minimums[index] if index < len(selected_minimums) else 0)
|
|
188
|
+
for index, (header, _key) in enumerate(columns)
|
|
189
|
+
]
|
|
190
|
+
rows: list[list[str]] = []
|
|
191
|
+
for record in records:
|
|
192
|
+
row = [_cell_value(record.get(key)) for _header, key in columns]
|
|
193
|
+
rows.append(row)
|
|
194
|
+
widths = [max(width, len(value)) for width, value in zip(widths, row)]
|
|
195
|
+
|
|
196
|
+
stream.write(" ".join(header.ljust(width) for (header, _key), width in zip(columns, widths)).rstrip())
|
|
197
|
+
stream.write("\n")
|
|
198
|
+
for row in rows:
|
|
199
|
+
stream.write(" ".join(value.ljust(width) for value, width in zip(row, widths)).rstrip())
|
|
200
|
+
stream.write("\n")
|
|
201
|
+
if footer:
|
|
202
|
+
stream.write(f"\n{footer}\n")
|
base_cli/paths.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import contextvars
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_WORKING_DIRECTORY_OVERRIDE: contextvars.ContextVar[Path | None] = contextvars.ContextVar(
|
|
15
|
+
"base_cli_working_directory_override",
|
|
16
|
+
default=None,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def base_state_root(home: Path | None = None) -> Path:
|
|
21
|
+
return (home or Path.home()) / ".base.d"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def base_cache_root(home: Path | None = None) -> Path:
|
|
25
|
+
value = os.environ.get("BASE_CACHE_DIR")
|
|
26
|
+
if value:
|
|
27
|
+
return Path(value).expanduser()
|
|
28
|
+
root = home or Path.home()
|
|
29
|
+
if sys.platform == "darwin":
|
|
30
|
+
return root / "Library" / "Caches" / "base"
|
|
31
|
+
return root / ".cache" / "base"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def current_working_dir() -> Path:
|
|
35
|
+
return _WORKING_DIRECTORY_OVERRIDE.get() or Path.cwd()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextlib.contextmanager
|
|
39
|
+
def use_working_dir(path: Path | None) -> Iterator[None]:
|
|
40
|
+
if path is None:
|
|
41
|
+
yield
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
token = _WORKING_DIRECTORY_OVERRIDE.set(path.expanduser().resolve())
|
|
45
|
+
try:
|
|
46
|
+
yield
|
|
47
|
+
finally:
|
|
48
|
+
_WORKING_DIRECTORY_OVERRIDE.reset(token)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_run_id() -> str:
|
|
52
|
+
timestamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
|
|
53
|
+
return f"{timestamp}_{uuid.uuid4().hex[:8]}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_cli_name(name: str) -> str:
|
|
57
|
+
stem = Path(name).name
|
|
58
|
+
if "." in stem:
|
|
59
|
+
stem = stem.rsplit(".", 1)[0]
|
|
60
|
+
return stem.replace(" ", "-")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_runtime_owner(value: str | None = None) -> str:
|
|
64
|
+
"""Return the runtime owner namespace for a command invocation."""
|
|
65
|
+
owner = (value or os.environ.get("BASE_CLI_RUNTIME_OWNER") or "base").strip().lower()
|
|
66
|
+
if owner not in {"base", "project"}:
|
|
67
|
+
raise ValueError("BASE_CLI_RUNTIME_OWNER must be 'base' or 'project'.")
|
|
68
|
+
return owner
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def runtime_project_name(value: str | None = None) -> str | None:
|
|
72
|
+
name = (value or os.environ.get("BASE_CLI_PROJECT_NAME") or "").strip()
|
|
73
|
+
return name or None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def runtime_project_root(value: Path | str | None = None) -> Path | None:
|
|
77
|
+
candidate = value or os.environ.get("BASE_CLI_PROJECT_ROOT")
|
|
78
|
+
if not candidate:
|
|
79
|
+
return None
|
|
80
|
+
return Path(candidate).expanduser().resolve()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def runtime_slug(value: str, fallback: str = "unnamed") -> str:
|
|
84
|
+
normalized = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip(".-_").lower()
|
|
85
|
+
return normalized or fallback
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def runtime_run_directory_name(run_id: str, cli_name: str, project_name: str | None = None) -> str:
|
|
89
|
+
"""Return a readable bundle directory name without changing the canonical run ID."""
|
|
90
|
+
labels = [runtime_slug(cli_name, fallback="run")]
|
|
91
|
+
if project_name:
|
|
92
|
+
labels.append(runtime_slug(project_name))
|
|
93
|
+
return f"{run_id}__{'__'.join(labels)}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def checkout_id(project_root: Path | None) -> str | None:
|
|
97
|
+
if project_root is None:
|
|
98
|
+
return None
|
|
99
|
+
digest = hashlib.sha256(str(project_root.expanduser().resolve()).encode("utf-8")).hexdigest()
|
|
100
|
+
return digest[:12]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def runtime_owner_root(
|
|
104
|
+
cache_root: Path,
|
|
105
|
+
owner: str = "base",
|
|
106
|
+
project_name: str | None = None,
|
|
107
|
+
project_root: Path | None = None,
|
|
108
|
+
) -> Path:
|
|
109
|
+
normalized_owner = normalize_runtime_owner(owner)
|
|
110
|
+
if normalized_owner == "base":
|
|
111
|
+
return cache_root / "base"
|
|
112
|
+
|
|
113
|
+
name = runtime_slug(project_name or "unnamed")
|
|
114
|
+
checkout = checkout_id(project_root) or "unknown"
|
|
115
|
+
return cache_root / "projects" / name / checkout
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def discover_manifest(start: Path) -> Path | None:
|
|
119
|
+
current = start.resolve()
|
|
120
|
+
if current.is_file():
|
|
121
|
+
current = current.parent
|
|
122
|
+
|
|
123
|
+
while True:
|
|
124
|
+
candidate = current / "base_manifest.yaml"
|
|
125
|
+
if candidate.is_file():
|
|
126
|
+
return candidate
|
|
127
|
+
if current.parent == current:
|
|
128
|
+
return None
|
|
129
|
+
current = current.parent
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def resolve_base_home() -> Path | None:
|
|
133
|
+
value = os.environ.get("BASE_HOME")
|
|
134
|
+
if not value:
|
|
135
|
+
return None
|
|
136
|
+
return Path(value).expanduser().resolve()
|
base_cli/py.typed
ADDED
|
File without changes
|
base_cli/redaction.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
REDACTED = "[REDACTED]"
|
|
6
|
+
SECRET_KEY_RE = re.compile(r"(token|password|secret|api[-_]?key|authorization)", re.IGNORECASE)
|
|
7
|
+
URL_CREDENTIALS_RE = re.compile(r"(?P<prefix>[a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def option_name_to_parameter(param_decl: str) -> str:
|
|
11
|
+
name = param_decl.lstrip("-")
|
|
12
|
+
return name.replace("-", "_")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parameter_name_from_decls(param_decls: tuple[str, ...]) -> str:
|
|
16
|
+
options = [decl for decl in param_decls if decl.startswith("--")]
|
|
17
|
+
if options:
|
|
18
|
+
return option_name_to_parameter(options[0])
|
|
19
|
+
return option_name_to_parameter(param_decls[0])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def redact_argv(argv: list[str], sensitive_options: set[str]) -> list[str]:
|
|
23
|
+
redacted: list[str] = []
|
|
24
|
+
skip_next = False
|
|
25
|
+
for arg in argv:
|
|
26
|
+
if skip_next:
|
|
27
|
+
redacted.append(REDACTED)
|
|
28
|
+
skip_next = False
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
option, separator, _value = arg.partition("=")
|
|
32
|
+
normalized = option_name_to_parameter(option) if option.startswith("--") else option
|
|
33
|
+
if option.startswith("--") and normalized in sensitive_options:
|
|
34
|
+
if separator:
|
|
35
|
+
redacted.append(f"{option}={REDACTED}")
|
|
36
|
+
else:
|
|
37
|
+
redacted.append(option)
|
|
38
|
+
skip_next = True
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
redacted.append(arg)
|
|
42
|
+
return redacted
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_secret_key(value: str) -> bool:
|
|
46
|
+
return SECRET_KEY_RE.search(value) is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def redact_text_value(value: str) -> str:
|
|
50
|
+
return URL_CREDENTIALS_RE.sub(lambda match: f"{match.group('prefix')}{REDACTED}@", value)
|
base_cli/testing.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from .paths import use_working_dir
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from click.testing import Result
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# pylint: disable=too-many-arguments
|
|
15
|
+
def invoke(
|
|
16
|
+
app: Any,
|
|
17
|
+
args: list[str] | None = None,
|
|
18
|
+
home: Path | None = None,
|
|
19
|
+
cwd: Path | str | None = None,
|
|
20
|
+
env: dict[str, str] | None = None,
|
|
21
|
+
*,
|
|
22
|
+
manifest: Mapping[str, Any] | None = None,
|
|
23
|
+
) -> Result:
|
|
24
|
+
cwd_path = Path(cwd).expanduser().resolve() if cwd is not None else None
|
|
25
|
+
if manifest is not None:
|
|
26
|
+
if cwd_path is None:
|
|
27
|
+
raise ValueError("manifest requires cwd so base_manifest.yaml has a target directory.")
|
|
28
|
+
_write_manifest_fixture(cwd_path, manifest)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from click.testing import CliRunner
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise RuntimeError("Click is required for base_cli.testing. Install it with 'pip install click'.") from exc
|
|
34
|
+
|
|
35
|
+
invoke_env = dict(env or {})
|
|
36
|
+
if home is not None:
|
|
37
|
+
invoke_env.setdefault("HOME", str(home))
|
|
38
|
+
invoke_env.setdefault("BASE_CACHE_DIR", str(home / ".cache" / "base"))
|
|
39
|
+
runner_kwargs = {}
|
|
40
|
+
if "mix_stderr" in inspect.signature(CliRunner).parameters:
|
|
41
|
+
runner_kwargs["mix_stderr"] = False
|
|
42
|
+
runner = CliRunner(**runner_kwargs)
|
|
43
|
+
with use_working_dir(cwd_path):
|
|
44
|
+
return runner.invoke(app.click_command, args or [], env=invoke_env)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_manifest_fixture(cwd: Path, manifest: Mapping[str, Any]) -> None:
|
|
48
|
+
try:
|
|
49
|
+
import yaml
|
|
50
|
+
except ImportError as exc:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"PyYAML is required to write base_cli.testing manifest fixtures. "
|
|
53
|
+
"Install it with 'pip install PyYAML'."
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
(cwd / "base_manifest.yaml").write_text(
|
|
57
|
+
yaml.safe_dump(dict(manifest), sort_keys=False),
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
)
|