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.
Files changed (50) hide show
  1. {base_cli-0.2.0/lib/python/base_cli.egg-info → base_cli-0.3.0}/PKG-INFO +21 -7
  2. {base_cli-0.2.0 → base_cli-0.3.0}/README.md +17 -6
  3. base_cli-0.3.0/VERSION +1 -0
  4. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/_private_files.py +16 -3
  5. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/_runtime.py +4 -3
  6. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/history.py +23 -12
  7. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/logging.py +9 -8
  8. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/output.py +1 -1
  9. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/paths.py +35 -1
  10. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/profile.py +2 -14
  11. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/testing.py +3 -0
  12. {base_cli-0.2.0 → base_cli-0.3.0/lib/python/base_cli.egg-info}/PKG-INFO +21 -7
  13. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli.egg-info/SOURCES.txt +2 -0
  14. {base_cli-0.2.0 → base_cli-0.3.0}/pyproject.toml +3 -0
  15. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_runtime_errors.py +11 -12
  16. base_cli-0.3.0/tests/test_history.py +61 -0
  17. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_logging.py +14 -0
  18. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_output.py +8 -0
  19. base_cli-0.3.0/tests/test_paths.py +86 -0
  20. base_cli-0.2.0/VERSION +0 -1
  21. {base_cli-0.2.0 → base_cli-0.3.0}/LICENSE +0 -0
  22. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/__init__.py +0 -0
  23. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/_dependencies.py +0 -0
  24. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/app.py +0 -0
  25. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/command_filters.py +0 -0
  26. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/command_protocol.py +0 -0
  27. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/config.py +0 -0
  28. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/context.py +0 -0
  29. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/exit_codes.py +0 -0
  30. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/inspection.py +0 -0
  31. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/py.typed +0 -0
  32. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli/redaction.py +0 -0
  33. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli.egg-info/dependency_links.txt +0 -0
  34. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli.egg-info/requires.txt +0 -0
  35. {base_cli-0.2.0 → base_cli-0.3.0}/lib/python/base_cli.egg-info/top_level.txt +0 -0
  36. {base_cli-0.2.0 → base_cli-0.3.0}/setup.cfg +0 -0
  37. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_dry_run.py +0 -0
  38. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_log_retention.py +0 -0
  39. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_quiet.py +0 -0
  40. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_run.py +0 -0
  41. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_runtime_boundary.py +0 -0
  42. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_app_subcommands.py +0 -0
  43. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_command_filters.py +0 -0
  44. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_command_protocol.py +0 -0
  45. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_context_workspace.py +0 -0
  46. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_generic_core.py +0 -0
  47. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_inspection.py +0 -0
  48. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_profile.py +0 -0
  49. {base_cli-0.2.0 → base_cli-0.3.0}/tests/test_public_api.py +0 -0
  50. {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.2.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. The generic
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 (`0600`) `run.json`,
484
- `logs/`, and `tmp/`, while persistent component caches live in the
485
- bundle's cache directory.
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` when requested, and supplies
527
- `cwd` to the invocation for the duration of the test. Calls that use
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. The generic
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 (`0600`) `run.json`,
455
- `logs/`, and `tmp/`, while persistent component caches live in the
456
- bundle's cache directory.
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` when requested, and supplies
498
- `cwd` to the invocation for the duration of the test. Calls that use
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
- """Ensure an existing runtime file is readable and writable only by its owner."""
17
+ """Apply owner-only POSIX permissions where mode bits are meaningful.
17
18
 
18
- path.chmod(PRIVATE_FILE_MODE)
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.chmod(0o700)
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": os.environ.get("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
- fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
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
- home = str(Path.home().expanduser().resolve(strict=False))
330
- if value == home:
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 value.startswith(f"{home}/"):
333
- return f"~/{value[len(home) + 1:]}"
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
- return (
72
- os.environ.get("BASE_CLI_COLOR") != "0"
73
- and "NO_COLOR" not in os.environ
74
- and hasattr(stream, "isatty")
75
- and stream.isatty()
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.chmod(0o600)
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.expanduser().resolve() if cache_root is not None else _default_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.2.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. The generic
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 (`0600`) `run.json`,
484
- `logs/`, and `tmp/`, while persistent component caches live in the
485
- bundle's cache directory.
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` when requested, and supplies
527
- `cwd` to the invocation for the duration of the test. Calls that use
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 test_run_app_reports_unwritable_cache_root_without_traceback(self) -> None:
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
- cache_root.mkdir()
59
- cache_root.chmod(0o500)
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
- try:
62
- with mock.patch.dict(os.environ, {"HOME": str(home), "BASE_CLI_CACHE_DIR": str(cache_root)}):
63
- with redirect_stderr(stderr):
64
- try:
65
- exit_code = base_cli.run_app(app, [])
66
- except PermissionError as exc:
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