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/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
+ )