base-cli 0.4.1__tar.gz → 0.4.2__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 (166) hide show
  1. {base_cli-0.4.1 → base_cli-0.4.2}/CHANGELOG.md +28 -0
  2. {base_cli-0.4.1 → base_cli-0.4.2}/PKG-INFO +2 -2
  3. {base_cli-0.4.1 → base_cli-0.4.2}/README.md +1 -1
  4. base_cli-0.4.2/VERSION +1 -0
  5. {base_cli-0.4.1 → base_cli-0.4.2}/docs/index.md +2 -1
  6. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/_lifecycle.py +0 -2
  7. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/app.py +1 -5
  8. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/command_protocol.py +7 -0
  9. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/context.py +4 -0
  10. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/history.py +14 -0
  11. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/logging.py +6 -0
  12. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli.egg-info/PKG-INFO +2 -2
  13. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_run_metadata.py +2 -0
  14. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_invocation_parity.py +58 -1
  15. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_public_api.py +11 -0
  16. base_cli-0.4.1/VERSION +0 -1
  17. {base_cli-0.4.1 → base_cli-0.4.2}/.github/ISSUE_TEMPLATE/support.md +0 -0
  18. {base_cli-0.4.1 → base_cli-0.4.2}/.github/base-project.yml +0 -0
  19. {base_cli-0.4.1 → base_cli-0.4.2}/.github/pull_request_template.md +0 -0
  20. {base_cli-0.4.1 → base_cli-0.4.2}/.github/workflows/compatibility.yml +0 -0
  21. {base_cli-0.4.1 → base_cli-0.4.2}/.github/workflows/docs.yml +0 -0
  22. {base_cli-0.4.1 → base_cli-0.4.2}/.github/workflows/examples.yml +0 -0
  23. {base_cli-0.4.1 → base_cli-0.4.2}/.github/workflows/issue-branch-policy.yml +0 -0
  24. {base_cli-0.4.1 → base_cli-0.4.2}/.github/workflows/package.yml +0 -0
  25. {base_cli-0.4.1 → base_cli-0.4.2}/.github/workflows/project-intake.yml +0 -0
  26. {base_cli-0.4.1 → base_cli-0.4.2}/.github/workflows/tests.yml +0 -0
  27. {base_cli-0.4.1 → base_cli-0.4.2}/CONTRIBUTING.md +0 -0
  28. {base_cli-0.4.1 → base_cli-0.4.2}/LICENSE +0 -0
  29. {base_cli-0.4.1 → base_cli-0.4.2}/MANIFEST.in +0 -0
  30. {base_cli-0.4.1 → base_cli-0.4.2}/SECURITY.md +0 -0
  31. {base_cli-0.4.1 → base_cli-0.4.2}/base_manifest.yaml +0 -0
  32. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/README.md +0 -0
  33. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/atlas_click/README.md +0 -0
  34. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/atlas_click/pyproject.toml +0 -0
  35. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/atlas_click/src/atlas_click/__init__.py +0 -0
  36. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/atlas_click/src/atlas_click/cli.py +0 -0
  37. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/atlas_click/tests/test_consumer.py +0 -0
  38. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/beacon_typer/README.md +0 -0
  39. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/beacon_typer/pyproject.toml +0 -0
  40. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/beacon_typer/src/beacon_typer/__init__.py +0 -0
  41. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/beacon_typer/src/beacon_typer/cli.py +0 -0
  42. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/beacon_typer/tests/test_consumer.py +0 -0
  43. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/cinder_automation/README.md +0 -0
  44. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/cinder_automation/pyproject.toml +0 -0
  45. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/cinder_automation/src/cinder_automation/__init__.py +0 -0
  46. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/cinder_automation/src/cinder_automation/cli.py +0 -0
  47. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/cinder_automation/tests/test_consumer.py +0 -0
  48. {base_cli-0.4.1 → base_cli-0.4.2}/compatibility/consumers/manifest.json +0 -0
  49. {base_cli-0.4.1 → base_cli-0.4.2}/docs/adopter-readiness.md +0 -0
  50. {base_cli-0.4.1 → base_cli-0.4.2}/docs/api-stability.md +0 -0
  51. {base_cli-0.4.1 → base_cli-0.4.2}/docs/cache-ownership-and-layout.md +0 -0
  52. {base_cli-0.4.1 → base_cli-0.4.2}/docs/consumer-profiles.md +0 -0
  53. {base_cli-0.4.1 → base_cli-0.4.2}/docs/extensions.md +0 -0
  54. {base_cli-0.4.1 → base_cli-0.4.2}/docs/integrations.md +0 -0
  55. {base_cli-0.4.1 → base_cli-0.4.2}/docs/json-contracts.md +0 -0
  56. {base_cli-0.4.1 → base_cli-0.4.2}/docs/local-config.md +0 -0
  57. {base_cli-0.4.1 → base_cli-0.4.2}/docs/migrations.md +0 -0
  58. {base_cli-0.4.1 → base_cli-0.4.2}/docs/output-contracts.md +0 -0
  59. {base_cli-0.4.1 → base_cli-0.4.2}/docs/performance.md +0 -0
  60. {base_cli-0.4.1 → base_cli-0.4.2}/docs/platform-support.md +0 -0
  61. {base_cli-0.4.1 → base_cli-0.4.2}/docs/releasing.md +0 -0
  62. {base_cli-0.4.1 → base_cli-0.4.2}/docs/security-review.md +0 -0
  63. {base_cli-0.4.1 → base_cli-0.4.2}/docs/security-threat-model.md +0 -0
  64. {base_cli-0.4.1 → base_cli-0.4.2}/docs/typer-adapter.md +0 -0
  65. {base_cli-0.4.1 → base_cli-0.4.2}/docs/user-config-typing.md +0 -0
  66. {base_cli-0.4.1 → base_cli-0.4.2}/examples/README.md +0 -0
  67. {base_cli-0.4.1 → base_cli-0.4.2}/examples/automation_observability_app/README.md +0 -0
  68. {base_cli-0.4.1 → base_cli-0.4.2}/examples/automation_observability_app/pyproject.toml +0 -0
  69. {base_cli-0.4.1 → base_cli-0.4.2}/examples/automation_observability_app/src/automation_observability_app/__init__.py +0 -0
  70. {base_cli-0.4.1 → base_cli-0.4.2}/examples/automation_observability_app/src/automation_observability_app/cli.py +0 -0
  71. {base_cli-0.4.1 → base_cli-0.4.2}/examples/automation_observability_app/tests/test_cli.py +0 -0
  72. {base_cli-0.4.1 → base_cli-0.4.2}/examples/minimal_cli/README.md +0 -0
  73. {base_cli-0.4.1 → base_cli-0.4.2}/examples/minimal_cli/pyproject.toml +0 -0
  74. {base_cli-0.4.1 → base_cli-0.4.2}/examples/minimal_cli/src/minimal_cli/__init__.py +0 -0
  75. {base_cli-0.4.1 → base_cli-0.4.2}/examples/minimal_cli/src/minimal_cli/cli.py +0 -0
  76. {base_cli-0.4.1 → base_cli-0.4.2}/examples/minimal_cli/tests/test_cli.py +0 -0
  77. {base_cli-0.4.1 → base_cli-0.4.2}/examples/nested_click_app/README.md +0 -0
  78. {base_cli-0.4.1 → base_cli-0.4.2}/examples/nested_click_app/pyproject.toml +0 -0
  79. {base_cli-0.4.1 → base_cli-0.4.2}/examples/nested_click_app/src/nested_click_app/__init__.py +0 -0
  80. {base_cli-0.4.1 → base_cli-0.4.2}/examples/nested_click_app/src/nested_click_app/cli.py +0 -0
  81. {base_cli-0.4.1 → base_cli-0.4.2}/examples/nested_click_app/src/nested_click_app/plugin.py +0 -0
  82. {base_cli-0.4.1 → base_cli-0.4.2}/examples/nested_click_app/tests/test_cli.py +0 -0
  83. {base_cli-0.4.1 → base_cli-0.4.2}/examples/typed_consumer.py +0 -0
  84. {base_cli-0.4.1 → base_cli-0.4.2}/examples/typer_app/README.md +0 -0
  85. {base_cli-0.4.1 → base_cli-0.4.2}/examples/typer_app/pyproject.toml +0 -0
  86. {base_cli-0.4.1 → base_cli-0.4.2}/examples/typer_app/src/typer_app/__init__.py +0 -0
  87. {base_cli-0.4.1 → base_cli-0.4.2}/examples/typer_app/src/typer_app/cli.py +0 -0
  88. {base_cli-0.4.1 → base_cli-0.4.2}/examples/typer_app/tests/test_cli.py +0 -0
  89. {base_cli-0.4.1 → base_cli-0.4.2}/examples/typer_consumer.py +0 -0
  90. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/__init__.py +0 -0
  91. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/_cleanup.py +0 -0
  92. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/_click_compat.py +0 -0
  93. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/_dependencies.py +0 -0
  94. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/_private_files.py +0 -0
  95. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/_runtime.py +0 -0
  96. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/attachment.py +0 -0
  97. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/command_filters.py +0 -0
  98. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/config.py +0 -0
  99. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/deprecations.py +0 -0
  100. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/errors.py +0 -0
  101. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/exit_codes.py +0 -0
  102. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/experimental.py +0 -0
  103. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/extensions.py +0 -0
  104. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/inspection.py +0 -0
  105. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/integrations.py +0 -0
  106. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/json_contracts.py +0 -0
  107. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/lifecycle_options.py +0 -0
  108. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/output.py +0 -0
  109. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/paths.py +0 -0
  110. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/profile.py +0 -0
  111. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/py.typed +0 -0
  112. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/redaction.py +0 -0
  113. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/runtime.py +0 -0
  114. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/testing.py +0 -0
  115. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli/typer.py +0 -0
  116. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli.egg-info/SOURCES.txt +0 -0
  117. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli.egg-info/dependency_links.txt +0 -0
  118. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli.egg-info/requires.txt +0 -0
  119. {base_cli-0.4.1 → base_cli-0.4.2}/lib/python/base_cli.egg-info/top_level.txt +0 -0
  120. {base_cli-0.4.1 → base_cli-0.4.2}/pyproject.toml +0 -0
  121. {base_cli-0.4.1 → base_cli-0.4.2}/scripts/benchmark_runtime.py +0 -0
  122. {base_cli-0.4.1 → base_cli-0.4.2}/scripts/validate_consumers.py +0 -0
  123. {base_cli-0.4.1 → base_cli-0.4.2}/scripts/validate_docs.py +0 -0
  124. {base_cli-0.4.1 → base_cli-0.4.2}/scripts/validate_examples.py +0 -0
  125. {base_cli-0.4.1 → base_cli-0.4.2}/scripts/validate_installed_package.py +0 -0
  126. {base_cli-0.4.1 → base_cli-0.4.2}/scripts/validate_package_artifact.py +0 -0
  127. {base_cli-0.4.1 → base_cli-0.4.2}/setup.cfg +0 -0
  128. {base_cli-0.4.1 → base_cli-0.4.2}/tests/conftest.py +0 -0
  129. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_adversarial_regressions.py +0 -0
  130. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_api_stability.py +0 -0
  131. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_dry_run.py +0 -0
  132. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_lifecycle.py +0 -0
  133. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_log_retention.py +0 -0
  134. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_quiet.py +0 -0
  135. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_registration.py +0 -0
  136. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_run.py +0 -0
  137. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_runtime_boundary.py +0 -0
  138. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_runtime_errors.py +0 -0
  139. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_security_boundaries.py +0 -0
  140. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_startup_transaction.py +0 -0
  141. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_app_subcommands.py +0 -0
  142. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_batteries_included_config.py +0 -0
  143. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_benchmark_runtime.py +0 -0
  144. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_cleanup_security.py +0 -0
  145. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_click_tree_attachment.py +0 -0
  146. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_command_filters.py +0 -0
  147. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_command_protocol.py +0 -0
  148. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_context_workspace.py +0 -0
  149. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_explicit_config_validation.py +0 -0
  150. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_extensions.py +0 -0
  151. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_generic_core.py +0 -0
  152. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_history.py +0 -0
  153. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_inspection.py +0 -0
  154. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_integrations.py +0 -0
  155. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_json_contracts.py +0 -0
  156. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_lifecycle_options.py +0 -0
  157. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_logging.py +0 -0
  158. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_output.py +0 -0
  159. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_paths.py +0 -0
  160. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_profile.py +0 -0
  161. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_redaction_security.py +0 -0
  162. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_run_bundle_retention.py +0 -0
  163. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_security_documentation.py +0 -0
  164. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_testing.py +0 -0
  165. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_typed_contracts.py +0 -0
  166. {base_cli-0.4.1 → base_cli-0.4.2}/tests/test_typer_adapter.py +0 -0
@@ -11,6 +11,34 @@ and versions are tracked in the repo-root `VERSION` file.
11
11
 
12
12
  - Continue compatibility hardening and adoption work for the next release.
13
13
 
14
+ ## [0.4.2] - 2026-08-08
15
+
16
+ This is a compatible pre-1.0 patch release. It contains correctness fixes,
17
+ security hardening, documentation improvements, and adoption polish after
18
+ 0.4.1; it does not introduce a new API or JSON-contract compatibility
19
+ boundary.
20
+
21
+ ### Changed
22
+
23
+ - Apply the profile-resolved display command to ambient production invocation
24
+ metadata so logs and history match the documented wrapper-label contract.
25
+
26
+ ### Security
27
+
28
+ - Compact the launcher path in retained invocation logs so diagnostic bundles
29
+ do not disclose the local user's home directory or username.
30
+
31
+ ### Fixed
32
+
33
+ - Fix the published documentation link to the reference applications.
34
+ - Classify a command that returns exit code `130` as `nonzero_return`; the
35
+ `interrupted` outcome remains reserved for actual interrupt exceptions.
36
+
37
+ ### Documentation
38
+
39
+ - Document the exported history, command-protocol, and context APIs and enforce
40
+ public-symbol docstrings in the API tests.
41
+
14
42
  ## [0.4.1] - 2026-08-07
15
43
 
16
44
  This is a compatible pre-1.0 patch release. It contains adoption polish,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: base-cli
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: A small, consumer-neutral Python CLI framework
5
5
  Author: Base Foundry
6
6
  License: Apache-2.0
@@ -58,7 +58,7 @@ Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
58
58
 
59
59
  | Version | License | Install | Release notes |
60
60
  | --- | --- | --- | --- |
61
- | `0.4.1` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.4.1](https://github.com/basefoundry/base-cli/releases/tag/v0.4.1) |
61
+ | `0.4.2` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.4.2](https://github.com/basefoundry/base-cli/releases/tag/v0.4.2) |
62
62
 
63
63
  `base-cli` is the PyPI distribution; import it in Python as `base_cli`.
64
64
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  | Version | License | Install | Release notes |
9
9
  | --- | --- | --- | --- |
10
- | `0.4.1` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.4.1](https://github.com/basefoundry/base-cli/releases/tag/v0.4.1) |
10
+ | `0.4.2` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.4.2](https://github.com/basefoundry/base-cli/releases/tag/v0.4.2) |
11
11
 
12
12
  `base-cli` is the PyPI distribution; import it in Python as `base_cli`.
13
13
 
base_cli-0.4.2/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.4.2
@@ -71,6 +71,7 @@ Continue with the framework guides:
71
71
 
72
72
  - [API overview](api-stability.md) for the supported public surface and
73
73
  compatibility policy.
74
- - [Reference applications](../examples/) for complete consumer patterns.
74
+ - [Reference applications](https://github.com/basefoundry/base-cli/tree/main/examples)
75
+ for complete consumer patterns.
75
76
  - [Installation and packaging](adopter-readiness.md) for dependency extras,
76
77
  distribution checks, and release guidance.
@@ -116,8 +116,6 @@ def outcome_from_exit_code(exit_code: int) -> InvocationOutcome:
116
116
  return InvocationOutcome("success", "ok", exit_code)
117
117
  if exit_code == ExitCode.USAGE_ERROR:
118
118
  return InvocationOutcome("usage_error", "error", exit_code)
119
- if exit_code == ExitCode.INTERRUPTED:
120
- return InvocationOutcome("interrupted", "error", exit_code)
121
119
  return InvocationOutcome("nonzero_return", "error", exit_code)
122
120
 
123
121
 
@@ -2740,7 +2740,6 @@ def run_app(
2740
2740
  print(f"ERROR: {exc}", file=sys.stderr)
2741
2741
  return ExitCode.FAILURE
2742
2742
 
2743
- explicit_argv = argv is not None
2744
2743
  args = list(sys.argv[1:] if argv is None else argv)
2745
2744
  leading_debug, leading_quiet = _leading_output_flags(
2746
2745
  args,
@@ -2760,7 +2759,7 @@ def run_app(
2760
2759
  try:
2761
2760
  try:
2762
2761
  display_command = app.profile.display_command()
2763
- invocation_argv = _effective_invocation_argv(app, args, explicit_argv, display_command)
2762
+ invocation_argv = _effective_invocation_argv(app, args, display_command)
2764
2763
  command = app.click_command
2765
2764
  click = dialect_for_command(command)
2766
2765
  invocation_token = _INVOCATION_ARGV.set(invocation_argv)
@@ -3038,11 +3037,8 @@ def _leading_output_flags(
3038
3037
  def _effective_invocation_argv(
3039
3038
  app: App,
3040
3039
  args: list[str],
3041
- explicit_argv: bool,
3042
3040
  display_command: str | None,
3043
3041
  ) -> list[str]:
3044
- if not explicit_argv:
3045
- return list(sys.argv)
3046
3042
  return [display_command or app.name, *args]
3047
3043
 
3048
3044
 
@@ -26,11 +26,15 @@ MAX_RECORD_COUNT = 1_000_000
26
26
 
27
27
 
28
28
  class CommandProtocolError(ValueError):
29
+ """Raised when a command-protocol schema or payload violates its contract."""
30
+
29
31
  pass
30
32
 
31
33
 
32
34
  @dataclass(frozen=True)
33
35
  class FieldSpec:
36
+ """Describe the wire type and nullability of one command-record field."""
37
+
34
38
  value_type: str
35
39
  nullable: bool = False
36
40
 
@@ -138,6 +142,7 @@ def dumps_record(
138
142
  protocol_header: str = PROTOCOL_HEADER,
139
143
  registry: CommandSchemaRegistry | None = None,
140
144
  ) -> str:
145
+ """Serialize one typed command record using the protocol framing."""
141
146
  return dumps_records(
142
147
  record_type,
143
148
  (record,),
@@ -153,6 +158,7 @@ def dumps_records(
153
158
  protocol_header: str = PROTOCOL_HEADER,
154
159
  registry: CommandSchemaRegistry | None = None,
155
160
  ) -> str:
161
+ """Serialize a sequence of typed command records using the protocol framing."""
156
162
  active_registry = registry or DEFAULT_SCHEMA_REGISTRY
157
163
  schema = active_registry.schema(record_type)
158
164
  if len(records) > MAX_RECORD_COUNT:
@@ -180,6 +186,7 @@ def loads_records(
180
186
  protocol_header: str = PROTOCOL_HEADER,
181
187
  registry: CommandSchemaRegistry | None = None,
182
188
  ) -> tuple[str, tuple[dict[str, RecordValue], ...]]:
189
+ """Validate and decode protocol-framed command records."""
183
190
  active_registry = registry or DEFAULT_SCHEMA_REGISTRY
184
191
  # The wire framing is LF-delimited. `str.splitlines()` also accepts CR,
185
192
  # vertical tab, form feed, and Unicode separators, which would make the
@@ -167,10 +167,12 @@ class Context(Generic[ConfigT, ApplicationStateT, ServicesT]):
167
167
  def set_current_context(
168
168
  context: Context[Any, Any, Any] | None,
169
169
  ) -> contextvars.Token[Context[Any, Any, Any] | None]:
170
+ """Set the active context and return a token that can restore the prior value."""
170
171
  return _current_context.set(context)
171
172
 
172
173
 
173
174
  def reset_current_context(token: contextvars.Token[Context[Any, Any, Any] | None]) -> None:
175
+ """Restore the context associated with a token, recovering safely if needed."""
174
176
  try:
175
177
  _current_context.reset(token)
176
178
  except BaseException: # pylint: disable=broad-exception-caught
@@ -178,11 +180,13 @@ def reset_current_context(token: contextvars.Token[Context[Any, Any, Any] | None
178
180
 
179
181
 
180
182
  def recover_current_context(token: contextvars.Token[Context[Any, Any, Any] | None]) -> None:
183
+ """Best-effort restoration used when normal context-token reset fails."""
181
184
  previous = token.old_value
182
185
  _current_context.set(None if previous is contextvars.Token.MISSING else previous)
183
186
 
184
187
 
185
188
  def get_current_context() -> Context[Any, Any, Any]:
189
+ """Return the active command context or raise when called outside a command."""
186
190
  context = _current_context.get()
187
191
  if context is None:
188
192
  raise RuntimeError("base_cli context is not active. Run inside a base_cli.App command.")
@@ -52,6 +52,7 @@ HISTORY_SCOPE_INTERNAL = "internal"
52
52
 
53
53
 
54
54
  def utc_now() -> datetime:
55
+ """Return the current time as a timezone-aware UTC datetime."""
55
56
  return datetime.now(timezone.utc)
56
57
 
57
58
 
@@ -62,6 +63,7 @@ def build_finished_record(
62
63
  started_at: datetime,
63
64
  exit_code: int,
64
65
  ) -> dict[str, Any]:
66
+ """Build a redacted finished-invocation record from an active context."""
65
67
  ended_at = utc_now()
66
68
  safe_argv = redact_history_argv(argv, sensitive_options)
67
69
  record: dict[str, Any] = {
@@ -248,20 +250,24 @@ def write_all(fd: int, data: bytes) -> None:
248
250
 
249
251
 
250
252
  def format_timestamp(value: datetime) -> str:
253
+ """Format a datetime as a second-precision UTC ISO-8601 timestamp."""
251
254
  normalized = value.astimezone(timezone.utc)
252
255
  return normalized.isoformat(timespec="seconds").replace("+00:00", "Z")
253
256
 
254
257
 
255
258
  def duration_ms(started_at: datetime, ended_at: datetime) -> int:
259
+ """Return the non-negative elapsed duration between two datetimes in milliseconds."""
256
260
  return max(0, round((ended_at - started_at).total_seconds() * 1000))
257
261
 
258
262
 
259
263
  def display_command(cli_name: str, argv: list[str]) -> str:
264
+ """Return the stable, human-readable command label used in history."""
260
265
  del argv
261
266
  return cli_name.replace("_", "-")
262
267
 
263
268
 
264
269
  def parse_positive_int(option: str, value: str) -> int:
270
+ """Parse a decimal option value and require it to be greater than zero."""
265
271
  if not value.isdecimal():
266
272
  raise ValueError(f"Option '{option}' must be a positive integer.")
267
273
  amount = int(value)
@@ -271,6 +277,7 @@ def parse_positive_int(option: str, value: str) -> int:
271
277
 
272
278
 
273
279
  def parse_finished_history_record_line(line: str) -> dict[str, Any] | None:
280
+ """Decode a line when it is a valid finished record for this schema version."""
274
281
  try:
275
282
  payload = json.loads(line)
276
283
  except json.JSONDecodeError:
@@ -283,10 +290,12 @@ def parse_finished_history_record_line(line: str) -> dict[str, Any] | None:
283
290
 
284
291
 
285
292
  def optional_string(value: Any) -> str | None:
293
+ """Return a non-empty string value, or ``None`` for other values."""
286
294
  return value if isinstance(value, str) and value else None
287
295
 
288
296
 
289
297
  def optional_int(value: Any) -> int | None:
298
+ """Return an integer value, or ``None`` when the value has another type."""
290
299
  return value if isinstance(value, int) else None
291
300
 
292
301
 
@@ -304,10 +313,12 @@ def current_shell() -> str | None:
304
313
 
305
314
 
306
315
  def redact_history_argv(argv: list[str], sensitive_options: set[str]) -> list[str]:
316
+ """Redact sensitive arguments and compact home paths for history storage."""
307
317
  return [redact_history_text(arg) for arg in redact_argv(argv, sensitive_options)]
308
318
 
309
319
 
310
320
  def redact_history_text(value: str) -> str:
321
+ """Redact sensitive option values and compact home paths in text."""
311
322
  key, separator, _value = value.partition("=")
312
323
  if separator and is_secret_key(option_name_to_parameter(key)):
313
324
  return f"{key}={REDACTED}"
@@ -315,16 +326,19 @@ def redact_history_text(value: str) -> str:
315
326
 
316
327
 
317
328
  def compact_optional_path(path: Path | None, *, home: Path | str | None = None) -> str | None:
329
+ """Return a compact path string, preserving ``None`` for absent paths."""
318
330
  if path is None:
319
331
  return None
320
332
  return compact_path(path, home=home)
321
333
 
322
334
 
323
335
  def compact_path(path: Path, *, home: Path | str | None = None) -> str:
336
+ """Resolve a path and replace the user's home directory with ``~``."""
324
337
  return compact_home_text(str(path.expanduser().resolve(strict=False)), home=home)
325
338
 
326
339
 
327
340
  def compact_home_text(value: str, *, home: Path | str | None = None) -> str:
341
+ """Replace a home-directory prefix in text with the portable ``~`` marker."""
328
342
  home_text = str(home) if home is not None else str(Path.home().expanduser().resolve(strict=False))
329
343
  normalized_value = value.replace("\\", "/")
330
344
  normalized_home = home_text.replace("\\", "/").rstrip("/")
@@ -21,6 +21,7 @@ except ImportError: # pragma: no cover - msvcrt is unavailable outside Windows.
21
21
 
22
22
  from ._private_files import restrict_file
23
23
  from .context import get_current_context
24
+ from .history import compact_home_text
24
25
  from .json_contracts import JsonLogFormatter
25
26
  from .paths import current_working_dir
26
27
  from .redaction import redact_argv
@@ -261,6 +262,11 @@ def log_invocation(
261
262
  sensitive_options: set[str] | None,
262
263
  ) -> None:
263
264
  safe_argv = list(argv) if sensitive_options is None else redact_argv(argv, sensitive_options)
265
+ if safe_argv:
266
+ # Invocation logs are retained in diagnostic bundles. Keep the
267
+ # launcher path useful while avoiding disclosure of the local user's
268
+ # home directory and username.
269
+ safe_argv[0] = compact_home_text(safe_argv[0])
264
270
  logger.debug("argv=%s", safe_argv)
265
271
  logger.debug("platform=%s %s", platform.system(), platform.machine())
266
272
  logger.debug("python=%s", sys.version.replace("\n", " "))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: base-cli
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: A small, consumer-neutral Python CLI framework
5
5
  Author: Base Foundry
6
6
  License: Apache-2.0
@@ -58,7 +58,7 @@ Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
58
58
 
59
59
  | Version | License | Install | Release notes |
60
60
  | --- | --- | --- | --- |
61
- | `0.4.1` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.4.1](https://github.com/basefoundry/base-cli/releases/tag/v0.4.1) |
61
+ | `0.4.2` | [Apache-2.0](LICENSE) | `python -m pip install base-cli` | [v0.4.2](https://github.com/basefoundry/base-cli/releases/tag/v0.4.2) |
62
62
 
63
63
  `base-cli` is the PyPI distribution; import it in Python as `base_cli`.
64
64
 
@@ -84,6 +84,7 @@ class AppRunMetadataTests(unittest.TestCase):
84
84
  ("zero", 0, 0, "ok", "success"),
85
85
  ("usage", 2, 2, "error", "usage_error"),
86
86
  ("nonzero", 7, 7, "error", "nonzero_return"),
87
+ ("returned-interrupted", 130, 130, "error", "nonzero_return"),
87
88
  )
88
89
  for name, returned, expected_code, expected_status, expected_outcome in cases:
89
90
  with self.subTest(name=name), tempfile.TemporaryDirectory() as tmpdir:
@@ -237,6 +238,7 @@ class AppRunMetadataTests(unittest.TestCase):
237
238
 
238
239
  cases = (
239
240
  ("click", click.exceptions.Exit(9), 9, "error", "nonzero_return", ""),
241
+ ("click-interrupted-code", click.exceptions.Exit(130), 130, "error", "nonzero_return", ""),
240
242
  ("system-none", SystemExit(None), 0, "ok", "system_exit", ""),
241
243
  ("system-success", SystemExit(0), 0, "ok", "system_exit", ""),
242
244
  ("system-failure", SystemExit(5), 5, "error", "system_exit", ""),
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
  from unittest import mock
14
14
 
15
15
  import base_cli
16
+ import base_cli.app as app_module
16
17
  from base_cli.testing import invoke
17
18
 
18
19
 
@@ -125,7 +126,7 @@ def _load_run_artifacts(home: Path) -> tuple[dict[str, object], list[str], str]:
125
126
  return metadata, logged_argv, log_text
126
127
 
127
128
 
128
- def _observe_production(app: base_cli.App, args: list[str], home: Path) -> _Observation:
129
+ def _observe_production(app: base_cli.App, args: list[str] | None, home: Path) -> _Observation:
129
130
  stdout = io.StringIO()
130
131
  stderr = io.StringIO()
131
132
  with mock.patch.dict(os.environ, _isolated_environment(home)), redirect_stdout(stdout), redirect_stderr(stderr):
@@ -171,6 +172,62 @@ def _stable_stderr(observation: _Observation) -> str:
171
172
 
172
173
  @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed")
173
174
  class InvocationParityTests(unittest.TestCase):
175
+ def test_ambient_invocation_uses_display_command_in_logs_and_history(self) -> None:
176
+ observed_history_argv: list[str] = []
177
+
178
+ def history_writer(
179
+ _ctx: base_cli.Context,
180
+ argv: list[str],
181
+ _sensitive: set[str],
182
+ _started: object,
183
+ _exit_code: int,
184
+ ) -> None:
185
+ observed_history_argv.extend(argv)
186
+
187
+ profile = replace(_profile(), history_writer=history_writer)
188
+ app = base_cli.App(name="internal-tool", profile=profile)
189
+
190
+ @app.command()
191
+ @base_cli.option("--name", required=True)
192
+ def main(ctx: base_cli.Context, name: str) -> None:
193
+ del ctx
194
+ print(f"hello {name}")
195
+
196
+ with tempfile.TemporaryDirectory() as tmpdir:
197
+ home = (Path(tmpdir) / "home").resolve()
198
+ home.mkdir()
199
+ launcher = home / ".venv" / "bin" / "internal-tool"
200
+ with mock.patch.object(app_module.sys, "argv", [str(launcher), "--name=Ada"]):
201
+ observation = _observe_production(app, None, home)
202
+
203
+ expected_argv = ["parity-tool", "--name=Ada"]
204
+ self.assertEqual(observation.logged_argv, expected_argv)
205
+ self.assertEqual(observed_history_argv, expected_argv)
206
+ self.assertNotIn(str(home), observation.log_text)
207
+
208
+ def test_production_compacts_launcher_home_path_in_retained_log(self) -> None:
209
+ with tempfile.TemporaryDirectory() as tmpdir:
210
+ home = (Path(tmpdir) / "home").resolve()
211
+ home.mkdir()
212
+ launcher = home / ".venv" / "bin" / "parity-tool"
213
+ profile = replace(
214
+ base_cli.CliProfile.generic(),
215
+ display_command=lambda: str(launcher),
216
+ )
217
+ app = base_cli.App(name="parity-tool", profile=profile)
218
+
219
+ @app.command()
220
+ @base_cli.option("--name", required=True)
221
+ def main(ctx: base_cli.Context, name: str) -> None:
222
+ del ctx
223
+ print(f"hello {name}")
224
+
225
+ with mock.patch.object(app_module.sys, "argv", [str(launcher), "--name=Ada"]):
226
+ observation = _observe_production(app, None, home)
227
+
228
+ self.assertEqual(observation.logged_argv[0], "~/.venv/bin/parity-tool")
229
+ self.assertNotIn(str(home), observation.log_text)
230
+
174
231
  def test_production_and_testing_match_parser_usage_errors_without_bundles(self) -> None:
175
232
  with tempfile.TemporaryDirectory() as tmpdir:
176
233
  root = Path(tmpdir)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  import tempfile
4
5
  import unittest
5
6
  from pathlib import Path
@@ -11,6 +12,7 @@ from base_cli import (
11
12
  command_filters,
12
13
  command_protocol,
13
14
  config,
15
+ context,
14
16
  deprecations,
15
17
  experimental,
16
18
  history,
@@ -173,6 +175,15 @@ class PublicApiTests(unittest.TestCase):
173
175
  self.assertTrue(base_cli.get_command_app.__doc__)
174
176
  self.assertTrue(base_cli.run_app.__doc__)
175
177
 
178
+ def test_exported_callables_have_docstrings(self) -> None:
179
+ for module in (history, command_protocol, context):
180
+ for name in module.__all__:
181
+ exported = getattr(module, name)
182
+ if not (inspect.isclass(exported) or inspect.isfunction(exported)):
183
+ continue
184
+ with self.subTest(module=module.__name__, name=name):
185
+ self.assertTrue(inspect.getdoc(exported), f"{module.__name__}.{name} needs a docstring")
186
+
176
187
 
177
188
  if __name__ == "__main__":
178
189
  unittest.main()
base_cli-0.4.1/VERSION DELETED
@@ -1 +0,0 @@
1
- 0.4.1
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