base-cli 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0/lib/python/base_cli.egg-info → base_cli-0.3.0}/PKG-INFO +21 -7
- {base_cli-0.2.0 → base_cli-0.3.0}/README.md +17 -6
- base_cli-0.3.0/VERSION +1 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/_private_files.py +16 -3
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/_runtime.py +4 -3
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/history.py +23 -12
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/logging.py +9 -8
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/output.py +1 -1
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/paths.py +35 -1
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/profile.py +2 -14
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/testing.py +3 -0
- {base_cli-0.2.0 → base_cli-0.3.0/lib/python/base_cli.egg-info}/PKG-INFO +21 -7
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli.egg-info/SOURCES.txt +2 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/pyproject.toml +3 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_runtime_errors.py +11 -12
- base_cli-0.3.0/tests/test_history.py +61 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_logging.py +14 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_output.py +8 -0
- base_cli-0.3.0/tests/test_paths.py +86 -0
- base_cli-0.2.0/VERSION +0 -1
- {base_cli-0.2.0 → base_cli-0.3.0}/LICENSE +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/__init__.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/_dependencies.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/app.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/command_filters.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/command_protocol.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/config.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/context.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/exit_codes.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/inspection.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/py.typed +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/redaction.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli.egg-info/dependency_links.txt +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli.egg-info/requires.txt +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli.egg-info/top_level.txt +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/setup.cfg +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_dry_run.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_log_retention.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_quiet.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_run.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_runtime_boundary.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_subcommands.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_command_filters.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_command_protocol.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_context_workspace.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_generic_core.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_inspection.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_profile.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_public_api.py +0 -0
- {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: base-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A small, consumer-neutral Python CLI framework
|
|
5
5
|
Author: Base Foundry
|
|
6
6
|
License: Apache-2.0
|
|
@@ -10,6 +10,9 @@ Project-URL: Issues, https://github.com/basefoundry/base-cli/issues
|
|
|
10
10
|
Classifier: Development Status :: 3 - Alpha
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
16
|
Classifier: Programming Language :: Python :: 3
|
|
14
17
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -477,12 +480,22 @@ explicitly and return a clear usage error or actionable message.
|
|
|
477
480
|
The generic profile uses the configured cache root and an application namespace
|
|
478
481
|
to create per-run logs, caches, and temporary directories. Pass
|
|
479
482
|
`cache_root` to `CliProfile.generic()` for deterministic placement in tests or
|
|
480
|
-
applications; otherwise the platform cache directory is used.
|
|
483
|
+
applications; otherwise the platform cache directory is used. Linux and WSL2
|
|
484
|
+
follow `XDG_CACHE_HOME` or `~/.cache`, macOS uses `~/Library/Caches`, and
|
|
485
|
+
Windows uses `%LOCALAPPDATA%` (falling back to `~/AppData/Local`). Set
|
|
486
|
+
`BASE_CLI_CACHE_DIR` to override the default on any platform. The generic
|
|
481
487
|
profile does not prescribe a product-wide cache name or cleanup command.
|
|
482
488
|
|
|
483
|
-
Each invocation is a run bundle containing private
|
|
484
|
-
`
|
|
485
|
-
|
|
489
|
+
Each invocation is a run bundle containing a private `run.json`, `logs/`, and
|
|
490
|
+
`tmp/`, while persistent component caches live in the bundle's cache directory.
|
|
491
|
+
On POSIX, base-cli enforces owner-only `0600`/`0700` modes. On Windows, the
|
|
492
|
+
default user-local cache root relies on inherited user-profile ACLs; consumers
|
|
493
|
+
using a custom cache root must provide the appropriate ACL themselves.
|
|
494
|
+
|
|
495
|
+
See [Platform support](docs/platform-support.md) for the supported Linux,
|
|
496
|
+
WSL2, macOS, and native Windows boundaries. Native Windows support covers the
|
|
497
|
+
generic `base-cli` framework; it does not imply native Windows support for
|
|
498
|
+
Base or `basectl`.
|
|
486
499
|
|
|
487
500
|
Use `ctx.on_cleanup()` for cleanup work that should happen even when helper code
|
|
488
501
|
does not own the main command wrapper:
|
|
@@ -523,8 +536,9 @@ def test_command(tmp_path: Path) -> None:
|
|
|
523
536
|
assert "hello Ada" in result.stdout
|
|
524
537
|
```
|
|
525
538
|
|
|
526
|
-
The helper wraps Click's `CliRunner`, sets `HOME`
|
|
527
|
-
`
|
|
539
|
+
The helper wraps Click's `CliRunner`, sets `HOME` plus the relevant
|
|
540
|
+
`USERPROFILE`, `LOCALAPPDATA`, and `XDG_CACHE_HOME` values when requested, and
|
|
541
|
+
supplies `cwd` to the invocation for the duration of the test. Calls that use
|
|
528
542
|
`cwd` are serialized and the caller's cwd is restored afterward, but this
|
|
529
543
|
remains process-global: do not use it concurrently with code that changes cwd
|
|
530
544
|
outside `invoke()` or from threads spawned by the invoked command. A
|
|
@@ -448,12 +448,22 @@ explicitly and return a clear usage error or actionable message.
|
|
|
448
448
|
The generic profile uses the configured cache root and an application namespace
|
|
449
449
|
to create per-run logs, caches, and temporary directories. Pass
|
|
450
450
|
`cache_root` to `CliProfile.generic()` for deterministic placement in tests or
|
|
451
|
-
applications; otherwise the platform cache directory is used.
|
|
451
|
+
applications; otherwise the platform cache directory is used. Linux and WSL2
|
|
452
|
+
follow `XDG_CACHE_HOME` or `~/.cache`, macOS uses `~/Library/Caches`, and
|
|
453
|
+
Windows uses `%LOCALAPPDATA%` (falling back to `~/AppData/Local`). Set
|
|
454
|
+
`BASE_CLI_CACHE_DIR` to override the default on any platform. The generic
|
|
452
455
|
profile does not prescribe a product-wide cache name or cleanup command.
|
|
453
456
|
|
|
454
|
-
Each invocation is a run bundle containing private
|
|
455
|
-
`
|
|
456
|
-
|
|
457
|
+
Each invocation is a run bundle containing a private `run.json`, `logs/`, and
|
|
458
|
+
`tmp/`, while persistent component caches live in the bundle's cache directory.
|
|
459
|
+
On POSIX, base-cli enforces owner-only `0600`/`0700` modes. On Windows, the
|
|
460
|
+
default user-local cache root relies on inherited user-profile ACLs; consumers
|
|
461
|
+
using a custom cache root must provide the appropriate ACL themselves.
|
|
462
|
+
|
|
463
|
+
See [Platform support](docs/platform-support.md) for the supported Linux,
|
|
464
|
+
WSL2, macOS, and native Windows boundaries. Native Windows support covers the
|
|
465
|
+
generic `base-cli` framework; it does not imply native Windows support for
|
|
466
|
+
Base or `basectl`.
|
|
457
467
|
|
|
458
468
|
Use `ctx.on_cleanup()` for cleanup work that should happen even when helper code
|
|
459
469
|
does not own the main command wrapper:
|
|
@@ -494,8 +504,9 @@ def test_command(tmp_path: Path) -> None:
|
|
|
494
504
|
assert "hello Ada" in result.stdout
|
|
495
505
|
```
|
|
496
506
|
|
|
497
|
-
The helper wraps Click's `CliRunner`, sets `HOME`
|
|
498
|
-
`
|
|
507
|
+
The helper wraps Click's `CliRunner`, sets `HOME` plus the relevant
|
|
508
|
+
`USERPROFILE`, `LOCALAPPDATA`, and `XDG_CACHE_HOME` values when requested, and
|
|
509
|
+
supplies `cwd` to the invocation for the duration of the test. Calls that use
|
|
499
510
|
`cwd` are serialized and the caller's cwd is restored afterward, but this
|
|
500
511
|
remains process-global: do not use it concurrently with code that changes cwd
|
|
501
512
|
outside `invoke()` or from threads spawned by the invoked command. A
|
base_cli-0.3.0/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.0
|
|
@@ -10,12 +10,25 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
PRIVATE_FILE_MODE = 0o600
|
|
13
|
+
PRIVATE_DIRECTORY_MODE = 0o700
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def restrict_file(path: Path) -> None:
|
|
16
|
-
"""
|
|
17
|
+
"""Apply owner-only POSIX permissions where mode bits are meaningful.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
Windows inherits ACLs from the containing directory instead; the generic
|
|
20
|
+
package deliberately does not pretend that ``chmod`` can rewrite them.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
if os.name != "nt":
|
|
24
|
+
path.chmod(PRIVATE_FILE_MODE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def restrict_directory(path: Path) -> None:
|
|
28
|
+
"""Apply owner-only POSIX directory permissions when supported."""
|
|
29
|
+
|
|
30
|
+
if os.name != "nt":
|
|
31
|
+
path.chmod(PRIVATE_DIRECTORY_MODE)
|
|
19
32
|
|
|
20
33
|
|
|
21
34
|
def write_private_json(path: Path, value: Mapping[str, Any]) -> None:
|
|
@@ -25,7 +38,7 @@ def write_private_json(path: Path, value: Mapping[str, Any]) -> None:
|
|
|
25
38
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_FILE_MODE)
|
|
26
39
|
try:
|
|
27
40
|
fchmod = getattr(os, "fchmod", None)
|
|
28
|
-
if fchmod is not None:
|
|
41
|
+
if os.name != "nt" and fchmod is not None:
|
|
29
42
|
fchmod(fd, PRIVATE_FILE_MODE)
|
|
30
43
|
stream = os.fdopen(fd, "w", encoding="utf-8")
|
|
31
44
|
fd = -1
|
|
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
|
+
import os
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
|
-
from ._private_files import write_private_json
|
|
9
|
+
from ._private_files import restrict_directory, write_private_json
|
|
9
10
|
from .paths import runtime_run_directory_name, runtime_slug
|
|
10
11
|
|
|
11
12
|
|
|
@@ -57,9 +58,9 @@ def create_runtime_directory(path: Path, cache_root: Path) -> None:
|
|
|
57
58
|
restrict_permissions = _is_within(path, cache_root)
|
|
58
59
|
try:
|
|
59
60
|
path.mkdir(parents=True, exist_ok=True)
|
|
60
|
-
if restrict_permissions:
|
|
61
|
+
if restrict_permissions and os.name != "nt":
|
|
61
62
|
for directory in [path, *missing]:
|
|
62
|
-
directory
|
|
63
|
+
restrict_directory(directory)
|
|
63
64
|
except OSError as exc:
|
|
64
65
|
raise RuntimeError(_runtime_directory_error(path, cache_root, exc)) from exc
|
|
65
66
|
|
|
@@ -84,7 +84,7 @@ def build_finished_record(
|
|
|
84
84
|
"project_root": compact_optional_path(context.project_root),
|
|
85
85
|
"manifest": compact_optional_path(context.manifest_path),
|
|
86
86
|
"workspace_root": compact_optional_path(context.workspace_root),
|
|
87
|
-
"shell":
|
|
87
|
+
"shell": current_shell(),
|
|
88
88
|
"scope": context.history_scope,
|
|
89
89
|
"parent_run_id": context.history_parent_run_id,
|
|
90
90
|
}
|
|
@@ -188,13 +188,14 @@ def update_run_metadata(run_root: Path, record: dict[str, Any]) -> None:
|
|
|
188
188
|
|
|
189
189
|
|
|
190
190
|
def append_history_line(path: Path, line: str) -> None:
|
|
191
|
-
|
|
191
|
+
binary_flag = getattr(os, "O_BINARY", 0)
|
|
192
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | binary_flag, 0o600)
|
|
192
193
|
lock_fd = fd
|
|
193
194
|
sidecar_fd: int | None = None
|
|
194
195
|
try:
|
|
195
196
|
if _fcntl is None and _msvcrt is not None:
|
|
196
197
|
sidecar_path = path.with_name(f".{path.name}.lock")
|
|
197
|
-
sidecar_fd = os.open(sidecar_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
198
|
+
sidecar_fd = os.open(sidecar_path, os.O_RDWR | os.O_CREAT | binary_flag, 0o600)
|
|
198
199
|
if os.fstat(sidecar_fd).st_size == 0:
|
|
199
200
|
os.write(sidecar_fd, b"0")
|
|
200
201
|
restrict_file(sidecar_path)
|
|
@@ -285,6 +286,12 @@ def normalized_os() -> str:
|
|
|
285
286
|
return system or platform.platform()
|
|
286
287
|
|
|
287
288
|
|
|
289
|
+
def current_shell() -> str | None:
|
|
290
|
+
"""Return the active shell identifier across POSIX and Windows."""
|
|
291
|
+
|
|
292
|
+
return os.environ.get("SHELL") or os.environ.get("COMSPEC")
|
|
293
|
+
|
|
294
|
+
|
|
288
295
|
def redact_history_argv(argv: list[str], sensitive_options: set[str]) -> list[str]:
|
|
289
296
|
redacted = redact_argv(argv, sensitive_options)
|
|
290
297
|
result: list[str] = []
|
|
@@ -315,20 +322,24 @@ def redact_history_text(value: str) -> str:
|
|
|
315
322
|
return compact_home_text(redact_text_value(value))
|
|
316
323
|
|
|
317
324
|
|
|
318
|
-
def compact_optional_path(path: Path | None) -> str | None:
|
|
325
|
+
def compact_optional_path(path: Path | None, *, home: Path | str | None = None) -> str | None:
|
|
319
326
|
if path is None:
|
|
320
327
|
return None
|
|
321
|
-
return compact_path(path)
|
|
328
|
+
return compact_path(path, home=home)
|
|
322
329
|
|
|
323
330
|
|
|
324
|
-
def compact_path(path: Path) -> str:
|
|
325
|
-
return compact_home_text(str(path.expanduser().resolve(strict=False)))
|
|
331
|
+
def compact_path(path: Path, *, home: Path | str | None = None) -> str:
|
|
332
|
+
return compact_home_text(str(path.expanduser().resolve(strict=False)), home=home)
|
|
326
333
|
|
|
327
334
|
|
|
328
|
-
def compact_home_text(value: str) -> str:
|
|
329
|
-
|
|
330
|
-
|
|
335
|
+
def compact_home_text(value: str, *, home: Path | str | None = None) -> str:
|
|
336
|
+
home_text = str(home) if home is not None else str(Path.home().expanduser().resolve(strict=False))
|
|
337
|
+
normalized_value = value.replace("\\", "/")
|
|
338
|
+
normalized_home = home_text.replace("\\", "/").rstrip("/")
|
|
339
|
+
comparison_value = normalized_value.lower() if os.name == "nt" else normalized_value
|
|
340
|
+
comparison_home = normalized_home.lower() if os.name == "nt" else normalized_home
|
|
341
|
+
if comparison_value == comparison_home:
|
|
331
342
|
return "~"
|
|
332
|
-
if
|
|
333
|
-
return f"~/{
|
|
343
|
+
if comparison_value.startswith(f"{comparison_home}/"):
|
|
344
|
+
return f"~/{normalized_value[len(normalized_home) + 1:]}"
|
|
334
345
|
return value
|
|
@@ -8,6 +8,7 @@ import time
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import TextIO
|
|
10
10
|
|
|
11
|
+
from ._private_files import restrict_file
|
|
11
12
|
from .context import get_current_context
|
|
12
13
|
from .paths import current_working_dir
|
|
13
14
|
from .redaction import redact_argv
|
|
@@ -68,16 +69,16 @@ def _handler_formatter(formatter: logging.Formatter | None, *, use_color: bool)
|
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
def _use_color(stream: TextIO) -> bool:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
if os.environ.get("BASE_CLI_COLOR") == "0" or "NO_COLOR" in os.environ:
|
|
73
|
+
return False
|
|
74
|
+
try:
|
|
75
|
+
return bool(stream.isatty())
|
|
76
|
+
except (AttributeError, OSError, ValueError):
|
|
77
|
+
return False
|
|
77
78
|
|
|
78
79
|
|
|
79
80
|
def secure_log_file_permissions(log_file: Path) -> None:
|
|
80
|
-
log_file
|
|
81
|
+
restrict_file(log_file)
|
|
81
82
|
|
|
82
83
|
|
|
83
84
|
class SecureLogFileHandler(logging.FileHandler):
|
|
@@ -85,7 +86,7 @@ class SecureLogFileHandler(logging.FileHandler):
|
|
|
85
86
|
fd = os.open(self.baseFilename, _secure_log_file_open_flags(self.mode), 0o600)
|
|
86
87
|
try:
|
|
87
88
|
fchmod = getattr(os, "fchmod", None)
|
|
88
|
-
if fchmod is not None:
|
|
89
|
+
if os.name != "nt" and fchmod is not None:
|
|
89
90
|
fchmod(fd, 0o600)
|
|
90
91
|
return open(fd, self.mode, encoding=self.encoding, errors=self.errors, closefd=True)
|
|
91
92
|
except BaseException:
|
|
@@ -30,7 +30,7 @@ def is_terminal(stream: TextIO | None = None) -> bool:
|
|
|
30
30
|
candidate = stream if stream is not None else sys.stdout
|
|
31
31
|
try:
|
|
32
32
|
return bool(candidate.isatty())
|
|
33
|
-
except (AttributeError, OSError):
|
|
33
|
+
except (AttributeError, OSError, ValueError):
|
|
34
34
|
return False
|
|
35
35
|
|
|
36
36
|
|
|
@@ -2,10 +2,12 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
4
|
import contextvars
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
7
|
+
import sys
|
|
6
8
|
import time
|
|
7
9
|
import uuid
|
|
8
|
-
from collections.abc import Iterator
|
|
10
|
+
from collections.abc import Iterator, Mapping
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
|
|
11
13
|
_WORKING_DIRECTORY_OVERRIDE: contextvars.ContextVar[Path | None] = contextvars.ContextVar(
|
|
@@ -14,6 +16,38 @@ _WORKING_DIRECTORY_OVERRIDE: contextvars.ContextVar[Path | None] = contextvars.C
|
|
|
14
16
|
)
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
def default_cache_root(
|
|
20
|
+
*,
|
|
21
|
+
environ: Mapping[str, str] | None = None,
|
|
22
|
+
home: Path | None = None,
|
|
23
|
+
platform_name: str | None = None,
|
|
24
|
+
) -> Path:
|
|
25
|
+
"""Return the platform-default cache root for the generic profile.
|
|
26
|
+
|
|
27
|
+
``BASE_CLI_CACHE_DIR`` always wins so consumers and tests can provide an
|
|
28
|
+
explicit location. Linux follows ``XDG_CACHE_HOME`` when it is set,
|
|
29
|
+
macOS uses ``Library/Caches``, and Windows uses ``LOCALAPPDATA``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
environment = os.environ if environ is None else environ
|
|
33
|
+
configured = environment.get("BASE_CLI_CACHE_DIR")
|
|
34
|
+
if configured:
|
|
35
|
+
return Path(configured).expanduser()
|
|
36
|
+
|
|
37
|
+
root = home.expanduser() if home is not None else Path.home()
|
|
38
|
+
system = platform_name or sys.platform
|
|
39
|
+
if system == "darwin":
|
|
40
|
+
return root / "Library" / "Caches"
|
|
41
|
+
if system.startswith("win"):
|
|
42
|
+
local_app_data = environment.get("LOCALAPPDATA")
|
|
43
|
+
return Path(local_app_data).expanduser() if local_app_data else root / "AppData" / "Local"
|
|
44
|
+
|
|
45
|
+
xdg_cache_home = environment.get("XDG_CACHE_HOME")
|
|
46
|
+
if xdg_cache_home:
|
|
47
|
+
return Path(xdg_cache_home).expanduser()
|
|
48
|
+
return root / ".cache"
|
|
49
|
+
|
|
50
|
+
|
|
17
51
|
def current_working_dir() -> Path:
|
|
18
52
|
return _WORKING_DIRECTORY_OVERRIDE.get() or Path.cwd()
|
|
19
53
|
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
3
|
from collections.abc import Callable
|
|
6
4
|
from dataclasses import dataclass
|
|
7
5
|
from datetime import datetime
|
|
@@ -10,7 +8,7 @@ from typing import Any
|
|
|
10
8
|
|
|
11
9
|
from ._runtime import RuntimeLayout, runtime_layout
|
|
12
10
|
from .config import load_yaml_file
|
|
13
|
-
from .paths import make_run_id
|
|
11
|
+
from .paths import default_cache_root, make_run_id
|
|
14
12
|
|
|
15
13
|
|
|
16
14
|
@dataclass(frozen=True)
|
|
@@ -124,7 +122,7 @@ def _generic_runtime_resolver(
|
|
|
124
122
|
application_home: Path | None,
|
|
125
123
|
) -> RuntimeResolver:
|
|
126
124
|
def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBinding:
|
|
127
|
-
root = cache_root
|
|
125
|
+
root = (cache_root if cache_root is not None else default_cache_root()).expanduser().resolve()
|
|
128
126
|
run_id = make_run_id()
|
|
129
127
|
project_root = project.root if project is not None else None
|
|
130
128
|
project_name = project.name if project is not None else None
|
|
@@ -147,13 +145,3 @@ def _generic_runtime_resolver(
|
|
|
147
145
|
)
|
|
148
146
|
|
|
149
147
|
return resolve_runtime
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _default_cache_root() -> Path:
|
|
153
|
-
configured = os.environ.get("BASE_CLI_CACHE_DIR")
|
|
154
|
-
if configured:
|
|
155
|
-
return Path(configured).expanduser().resolve()
|
|
156
|
-
root = Path.home()
|
|
157
|
-
if sys.platform == "darwin":
|
|
158
|
-
return root / "Library" / "Caches"
|
|
159
|
-
return root / ".cache"
|
|
@@ -33,6 +33,9 @@ def invoke(
|
|
|
33
33
|
invoke_env = dict(env or {})
|
|
34
34
|
if home is not None:
|
|
35
35
|
invoke_env.setdefault("HOME", str(home))
|
|
36
|
+
invoke_env.setdefault("USERPROFILE", str(home))
|
|
37
|
+
invoke_env.setdefault("LOCALAPPDATA", str(home / "AppData" / "Local"))
|
|
38
|
+
invoke_env.setdefault("XDG_CACHE_HOME", str(home / ".cache"))
|
|
36
39
|
invoke_env.setdefault("BASE_CLI_CACHE_DIR", str(home / ".cache"))
|
|
37
40
|
runner_kwargs = {}
|
|
38
41
|
if "mix_stderr" in inspect.signature(CliRunner).parameters:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: base-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A small, consumer-neutral Python CLI framework
|
|
5
5
|
Author: Base Foundry
|
|
6
6
|
License: Apache-2.0
|
|
@@ -10,6 +10,9 @@ Project-URL: Issues, https://github.com/basefoundry/base-cli/issues
|
|
|
10
10
|
Classifier: Development Status :: 3 - Alpha
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
16
|
Classifier: Programming Language :: Python :: 3
|
|
14
17
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -477,12 +480,22 @@ explicitly and return a clear usage error or actionable message.
|
|
|
477
480
|
The generic profile uses the configured cache root and an application namespace
|
|
478
481
|
to create per-run logs, caches, and temporary directories. Pass
|
|
479
482
|
`cache_root` to `CliProfile.generic()` for deterministic placement in tests or
|
|
480
|
-
applications; otherwise the platform cache directory is used.
|
|
483
|
+
applications; otherwise the platform cache directory is used. Linux and WSL2
|
|
484
|
+
follow `XDG_CACHE_HOME` or `~/.cache`, macOS uses `~/Library/Caches`, and
|
|
485
|
+
Windows uses `%LOCALAPPDATA%` (falling back to `~/AppData/Local`). Set
|
|
486
|
+
`BASE_CLI_CACHE_DIR` to override the default on any platform. The generic
|
|
481
487
|
profile does not prescribe a product-wide cache name or cleanup command.
|
|
482
488
|
|
|
483
|
-
Each invocation is a run bundle containing private
|
|
484
|
-
`
|
|
485
|
-
|
|
489
|
+
Each invocation is a run bundle containing a private `run.json`, `logs/`, and
|
|
490
|
+
`tmp/`, while persistent component caches live in the bundle's cache directory.
|
|
491
|
+
On POSIX, base-cli enforces owner-only `0600`/`0700` modes. On Windows, the
|
|
492
|
+
default user-local cache root relies on inherited user-profile ACLs; consumers
|
|
493
|
+
using a custom cache root must provide the appropriate ACL themselves.
|
|
494
|
+
|
|
495
|
+
See [Platform support](docs/platform-support.md) for the supported Linux,
|
|
496
|
+
WSL2, macOS, and native Windows boundaries. Native Windows support covers the
|
|
497
|
+
generic `base-cli` framework; it does not imply native Windows support for
|
|
498
|
+
Base or `basectl`.
|
|
486
499
|
|
|
487
500
|
Use `ctx.on_cleanup()` for cleanup work that should happen even when helper code
|
|
488
501
|
does not own the main command wrapper:
|
|
@@ -523,8 +536,9 @@ def test_command(tmp_path: Path) -> None:
|
|
|
523
536
|
assert "hello Ada" in result.stdout
|
|
524
537
|
```
|
|
525
538
|
|
|
526
|
-
The helper wraps Click's `CliRunner`, sets `HOME`
|
|
527
|
-
`
|
|
539
|
+
The helper wraps Click's `CliRunner`, sets `HOME` plus the relevant
|
|
540
|
+
`USERPROFILE`, `LOCALAPPDATA`, and `XDG_CACHE_HOME` values when requested, and
|
|
541
|
+
supplies `cwd` to the invocation for the duration of the test. Calls that use
|
|
528
542
|
`cwd` are serialized and the caller's cwd is restored afterward, but this
|
|
529
543
|
remains process-global: do not use it concurrently with code that changes cwd
|
|
530
544
|
outside `invoke()` or from threads spawned by the invoked command. A
|
|
@@ -37,9 +37,11 @@ tests/test_command_filters.py
|
|
|
37
37
|
tests/test_command_protocol.py
|
|
38
38
|
tests/test_context_workspace.py
|
|
39
39
|
tests/test_generic_core.py
|
|
40
|
+
tests/test_history.py
|
|
40
41
|
tests/test_inspection.py
|
|
41
42
|
tests/test_logging.py
|
|
42
43
|
tests/test_output.py
|
|
44
|
+
tests/test_paths.py
|
|
43
45
|
tests/test_profile.py
|
|
44
46
|
tests/test_public_api.py
|
|
45
47
|
tests/test_testing.py
|
|
@@ -18,6 +18,9 @@ classifiers = [
|
|
|
18
18
|
"Development Status :: 3 - Alpha",
|
|
19
19
|
"Intended Audience :: Developers",
|
|
20
20
|
"License :: OSI Approved :: Apache Software License",
|
|
21
|
+
"Operating System :: MacOS",
|
|
22
|
+
"Operating System :: Microsoft :: Windows",
|
|
23
|
+
"Operating System :: POSIX :: Linux",
|
|
21
24
|
"Programming Language :: Python :: 3",
|
|
22
25
|
"Programming Language :: Python :: 3 :: Only",
|
|
23
26
|
"Programming Language :: Python :: 3.10",
|
|
@@ -42,7 +42,7 @@ class AppRuntimeErrorTests(unittest.TestCase):
|
|
|
42
42
|
invoke(app, [])
|
|
43
43
|
|
|
44
44
|
@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
|
|
45
|
-
def
|
|
45
|
+
def test_run_app_reports_unusable_cache_root_without_traceback(self) -> None:
|
|
46
46
|
app = generic_app(name="cache-failure", version="0.1.0")
|
|
47
47
|
|
|
48
48
|
@app.command()
|
|
@@ -55,18 +55,17 @@ class AppRuntimeErrorTests(unittest.TestCase):
|
|
|
55
55
|
home = root / "home"
|
|
56
56
|
cache_root = root / "cache-root"
|
|
57
57
|
home.mkdir()
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
# A regular file is unusable as a cache root on every platform and
|
|
59
|
+
# also behaves consistently when the test suite runs as root in a
|
|
60
|
+
# Linux distribution container (where mode bits are bypassed).
|
|
61
|
+
cache_root.write_text("not a directory", encoding="utf-8")
|
|
60
62
|
stderr = io.StringIO()
|
|
61
|
-
|
|
62
|
-
with
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
self.fail(f"run_app should handle context creation permission errors: {exc}")
|
|
68
|
-
finally:
|
|
69
|
-
cache_root.chmod(0o700)
|
|
63
|
+
with mock.patch.dict(os.environ, {"HOME": str(home), "BASE_CLI_CACHE_DIR": str(cache_root)}):
|
|
64
|
+
with redirect_stderr(stderr):
|
|
65
|
+
try:
|
|
66
|
+
exit_code = base_cli.run_app(app, [])
|
|
67
|
+
except PermissionError as exc:
|
|
68
|
+
self.fail(f"run_app should handle context creation permission errors: {exc}")
|
|
70
69
|
|
|
71
70
|
error = stderr.getvalue()
|
|
72
71
|
self.assertEqual(exit_code, 1)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from base_cli import history
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FakeMsvcrt:
|
|
14
|
+
LK_LOCK = 1
|
|
15
|
+
LK_UNLCK = 2
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.calls: list[tuple[int, int]] = []
|
|
19
|
+
|
|
20
|
+
def locking(self, _fd: int, mode: int, size: int) -> None:
|
|
21
|
+
self.calls.append((mode, size))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HistoryAppendTests(unittest.TestCase):
|
|
25
|
+
def test_current_shell_falls_back_to_comspec(self) -> None:
|
|
26
|
+
with mock.patch.dict("os.environ", {"COMSPEC": r"C:\Windows\System32\cmd.exe"}, clear=True):
|
|
27
|
+
self.assertEqual(history.current_shell(), r"C:\Windows\System32\cmd.exe")
|
|
28
|
+
|
|
29
|
+
def test_current_shell_prefers_shell(self) -> None:
|
|
30
|
+
with mock.patch.dict(
|
|
31
|
+
"os.environ",
|
|
32
|
+
{"SHELL": "/bin/zsh", "COMSPEC": r"C:\Windows\System32\cmd.exe"},
|
|
33
|
+
clear=True,
|
|
34
|
+
):
|
|
35
|
+
self.assertEqual(history.current_shell(), "/bin/zsh")
|
|
36
|
+
|
|
37
|
+
def test_concurrent_appends_produce_complete_records(self) -> None:
|
|
38
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
39
|
+
path = Path(tmpdir) / "history.jsonl"
|
|
40
|
+
lines = [json.dumps({"run": index}) + "\n" for index in range(24)]
|
|
41
|
+
|
|
42
|
+
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
43
|
+
list(executor.map(lambda line: history.append_history_line(path, line), lines))
|
|
44
|
+
|
|
45
|
+
records = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
|
46
|
+
|
|
47
|
+
self.assertEqual(sorted(record["run"] for record in records), list(range(24)))
|
|
48
|
+
|
|
49
|
+
def test_msvcrt_backend_uses_a_private_sidecar_lock(self) -> None:
|
|
50
|
+
fake_msvcrt = _FakeMsvcrt()
|
|
51
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
52
|
+
path = Path(tmpdir) / "history.jsonl"
|
|
53
|
+
with mock.patch.object(history, "_fcntl", None), mock.patch.object(
|
|
54
|
+
history, "_msvcrt", fake_msvcrt
|
|
55
|
+
):
|
|
56
|
+
history.append_history_line(path, '{"run": 1}\n')
|
|
57
|
+
|
|
58
|
+
self.assertEqual(path.read_text(encoding="utf-8"), '{"run": 1}\n')
|
|
59
|
+
self.assertTrue(path.with_name(".history.jsonl.lock").is_file())
|
|
60
|
+
|
|
61
|
+
self.assertEqual(fake_msvcrt.calls, [(_FakeMsvcrt.LK_LOCK, 1), (_FakeMsvcrt.LK_UNLCK, 1)])
|
|
@@ -105,6 +105,20 @@ class ConfigureLoggerTests(unittest.TestCase):
|
|
|
105
105
|
|
|
106
106
|
self.assertNotIn("\033[", stream.getvalue())
|
|
107
107
|
|
|
108
|
+
def test_configure_logger_handles_streams_that_reject_isatty(self) -> None:
|
|
109
|
+
class ClosedStream(io.StringIO):
|
|
110
|
+
def isatty(self) -> bool:
|
|
111
|
+
raise ValueError("stream is closed")
|
|
112
|
+
|
|
113
|
+
stream = ClosedStream()
|
|
114
|
+
|
|
115
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
116
|
+
logger = base_cli.configure_logger("closed-stream", None, debug=False, stream=stream)
|
|
117
|
+
logger.info("hello closed stream")
|
|
118
|
+
|
|
119
|
+
self.assertNotIn("\033[", stream.getvalue())
|
|
120
|
+
self.assertIn("hello closed stream", stream.getvalue())
|
|
121
|
+
|
|
108
122
|
def test_configure_logger_uses_custom_formatter_for_file_handler(self) -> None:
|
|
109
123
|
formatter = logging.Formatter("%(levelname)s:%(message)s")
|
|
110
124
|
user_stream = io.StringIO()
|
|
@@ -18,6 +18,11 @@ class _Stream(io.StringIO):
|
|
|
18
18
|
return self.terminal
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class _ClosedStream(io.StringIO):
|
|
22
|
+
def isatty(self) -> bool:
|
|
23
|
+
raise ValueError("stream is closed")
|
|
24
|
+
|
|
25
|
+
|
|
21
26
|
RECORDS = (
|
|
22
27
|
{"name": "base", "path": "/work/base"},
|
|
23
28
|
{"name": "demo,one", "path": "/work/demo\tone"},
|
|
@@ -26,6 +31,9 @@ COLUMNS = (("PROJECT", "name"), ("PATH", "path"))
|
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
class OutputTest(unittest.TestCase):
|
|
34
|
+
def test_closed_stream_is_not_treated_as_terminal(self) -> None:
|
|
35
|
+
self.assertEqual(resolve_output_format("text", stream=_ClosedStream()), "tsv")
|
|
36
|
+
|
|
29
37
|
def test_text_is_pretty_on_terminal(self) -> None:
|
|
30
38
|
stream = _Stream(terminal=True)
|
|
31
39
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from base_cli.history import compact_home_text
|
|
7
|
+
from base_cli.paths import default_cache_root
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DefaultCacheRootTests(unittest.TestCase):
|
|
11
|
+
def test_explicit_cache_override_wins_on_every_platform(self) -> None:
|
|
12
|
+
root = default_cache_root(
|
|
13
|
+
environ={
|
|
14
|
+
"BASE_CLI_CACHE_DIR": "/custom/cache",
|
|
15
|
+
"LOCALAPPDATA": "/local/app-data",
|
|
16
|
+
"XDG_CACHE_HOME": "/xdg/cache",
|
|
17
|
+
},
|
|
18
|
+
home=Path("/home/alice"),
|
|
19
|
+
platform_name="win32",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
self.assertEqual(root, Path("/custom/cache"))
|
|
23
|
+
|
|
24
|
+
def test_linux_prefers_xdg_cache_home(self) -> None:
|
|
25
|
+
root = default_cache_root(
|
|
26
|
+
environ={"XDG_CACHE_HOME": "/xdg/cache"},
|
|
27
|
+
home=Path("/home/alice"),
|
|
28
|
+
platform_name="linux",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
self.assertEqual(root, Path("/xdg/cache"))
|
|
32
|
+
|
|
33
|
+
def test_linux_falls_back_to_home_cache(self) -> None:
|
|
34
|
+
root = default_cache_root(
|
|
35
|
+
environ={},
|
|
36
|
+
home=Path("/home/alice"),
|
|
37
|
+
platform_name="linux",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self.assertEqual(root, Path("/home/alice/.cache"))
|
|
41
|
+
|
|
42
|
+
def test_macos_uses_library_caches(self) -> None:
|
|
43
|
+
root = default_cache_root(
|
|
44
|
+
environ={"XDG_CACHE_HOME": "/xdg/cache"},
|
|
45
|
+
home=Path("/Users/alice"),
|
|
46
|
+
platform_name="darwin",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self.assertEqual(root, Path("/Users/alice/Library/Caches"))
|
|
50
|
+
|
|
51
|
+
def test_windows_prefers_local_app_data(self) -> None:
|
|
52
|
+
root = default_cache_root(
|
|
53
|
+
environ={"LOCALAPPDATA": r"C:\Users\alice\AppData\Local"},
|
|
54
|
+
home=Path(r"C:\Users\alice"),
|
|
55
|
+
platform_name="win32",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self.assertEqual(root, Path(r"C:\Users\alice\AppData\Local"))
|
|
59
|
+
|
|
60
|
+
def test_windows_falls_back_to_home_local_app_data(self) -> None:
|
|
61
|
+
root = default_cache_root(
|
|
62
|
+
environ={},
|
|
63
|
+
home=Path(r"C:\Users\alice"),
|
|
64
|
+
platform_name="win32",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.assertEqual(root, Path(r"C:\Users\alice") / "AppData" / "Local")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HomePathCompactionTests(unittest.TestCase):
|
|
71
|
+
def test_compacts_home_paths_with_posix_separators(self) -> None:
|
|
72
|
+
self.assertEqual(
|
|
73
|
+
compact_home_text("/home/alice/project", home=Path("/home/alice")),
|
|
74
|
+
"~/project",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def test_compacts_home_paths_with_windows_separators(self) -> None:
|
|
78
|
+
self.assertEqual(
|
|
79
|
+
compact_home_text(r"C:\Users\Alice\project", home=r"C:\Users\Alice"),
|
|
80
|
+
"~/project",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def test_leaves_paths_outside_home_unchanged(self) -> None:
|
|
84
|
+
value = r"C:\Users\Bob\project"
|
|
85
|
+
|
|
86
|
+
self.assertEqual(compact_home_text(value, home=r"C:\Users\Alice"), value)
|
base_cli-0.2.0/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.2.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|