strictcli 0.41.1__tar.gz → 0.42.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 (109) hide show
  1. {strictcli-0.41.1 → strictcli-0.42.0}/PKG-INFO +10 -5
  2. {strictcli-0.41.1 → strictcli-0.42.0}/README.md +1 -1
  3. {strictcli-0.41.1 → strictcli-0.42.0}/pyproject.toml +18 -4
  4. {strictcli-0.41.1 → strictcli-0.42.0}/strictcli/__init__.py +57 -30
  5. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_coverage.py +155 -15
  6. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_effects.py +6 -3
  7. {strictcli-0.41.1 → strictcli-0.42.0}/uv.lock +1 -1
  8. {strictcli-0.41.1 → strictcli-0.42.0}/.claude/settings.json +0 -0
  9. {strictcli-0.41.1 → strictcli-0.42.0}/.github/workflows/ci.yml +0 -0
  10. {strictcli-0.41.1 → strictcli-0.42.0}/.github/workflows/publish.yml +0 -0
  11. {strictcli-0.41.1 → strictcli-0.42.0}/.gitignore +0 -0
  12. {strictcli-0.41.1 → strictcli-0.42.0}/.rlsbl/config.json +0 -0
  13. {strictcli-0.41.1 → strictcli-0.42.0}/.rlsbl/lint/python.toml +0 -0
  14. {strictcli-0.41.1 → strictcli-0.42.0}/.rlsbl/managed-files.json +0 -0
  15. {strictcli-0.41.1 → strictcli-0.42.0}/CLAUDE.md +0 -0
  16. {strictcli-0.41.1 → strictcli-0.42.0}/LICENSE +0 -0
  17. {strictcli-0.41.1 → strictcli-0.42.0}/scripts/add_effect_classification.py +0 -0
  18. {strictcli-0.41.1 → strictcli-0.42.0}/scripts/add_forwarding_declaration.py +0 -0
  19. {strictcli-0.41.1 → strictcli-0.42.0}/scripts/migrate_presence.py +0 -0
  20. {strictcli-0.41.1 → strictcli-0.42.0}/strictcli/py.typed +0 -0
  21. {strictcli-0.41.1 → strictcli-0.42.0}/tests/conftest.py +0 -0
  22. {strictcli-0.41.1 → strictcli-0.42.0}/tests/flagship_app.py +0 -0
  23. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_arg_decorator_order.py +0 -0
  24. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_arg_default.py +0 -0
  25. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_arg_default_validation.py +0 -0
  26. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_argv_value_order.py +0 -0
  27. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_at_prefix.py +0 -0
  28. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_auto_version.py +0 -0
  29. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_call.py +0 -0
  30. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_command.py +0 -0
  31. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_discovery.py +0 -0
  32. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_provider.py +0 -0
  33. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_public_api.py +0 -0
  34. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_runner.py +0 -0
  35. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_schema.py +0 -0
  36. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_types.py +0 -0
  37. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_choice_records.py +0 -0
  38. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_choices.py +0 -0
  39. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_choices_none.py +0 -0
  40. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_claimed_rendering.py +0 -0
  41. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_classification.py +0 -0
  42. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_command_help_suggestion.py +0 -0
  43. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_command_tags.py +0 -0
  44. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_compound_types.py +0 -0
  45. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_config.py +0 -0
  46. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_config_fields.py +0 -0
  47. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_config_file_path.py +0 -0
  48. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_config_set_bugs.py +0 -0
  49. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_confirm.py +0 -0
  50. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_connection_env.py +0 -0
  51. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_constraints.py +0 -0
  52. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_context.py +0 -0
  53. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_deep_nesting.py +0 -0
  54. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_deprecated.py +0 -0
  55. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_dry_run_unsupported.py +0 -0
  56. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_dump_schema.py +0 -0
  57. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_e2e.py +0 -0
  58. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_effects_bypass_check.py +0 -0
  59. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_env.py +0 -0
  60. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_exit_codes.py +0 -0
  61. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_flag_sets.py +0 -0
  62. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_flagship_preview.py +0 -0
  63. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_flat_pre_typed.py +0 -0
  64. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_float_format.py +0 -0
  65. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_float_type.py +0 -0
  66. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_float_vectors.py +0 -0
  67. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_global_flag_conflict_position.py +0 -0
  68. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_global_flags.py +0 -0
  69. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_guard_v2.py +0 -0
  70. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_help.py +0 -0
  71. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_hermetic.py +0 -0
  72. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_infra_env.py +0 -0
  73. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_int_type.py +0 -0
  74. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_invoke.py +0 -0
  75. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_keyword_flags.py +0 -0
  76. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_machine_mode.py +0 -0
  77. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_mcp.py +0 -0
  78. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_member_spelling.py +0 -0
  79. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_nesting.py +0 -0
  80. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_owns_stdout.py +0 -0
  81. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_parser.py +0 -0
  82. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_passthrough.py +0 -0
  83. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_payload_schema.py +0 -0
  84. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_presence.py +0 -0
  85. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_provenance.py +0 -0
  86. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_provenance_phase2.py +0 -0
  87. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_record_pre_typed.py +0 -0
  88. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_registration.py +0 -0
  89. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_repeatable.py +0 -0
  90. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_reserved_global_flags.py +0 -0
  91. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_reserved_quartet.py +0 -0
  92. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_selectors.py +0 -0
  93. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_tagdsl.py +0 -0
  94. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_toml_loading.py +0 -0
  95. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_tool_export.py +0 -0
  96. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_trace_store.py +0 -0
  97. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_typed_args.py +0 -0
  98. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_ulid_vectors.py +0 -0
  99. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_unique.py +0 -0
  100. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_update.py +0 -0
  101. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_utilities.py +0 -0
  102. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_validate.py +0 -0
  103. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_value_sweep_order.py +0 -0
  104. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_variadic.py +0 -0
  105. {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_visibility.py +0 -0
  106. {strictcli-0.41.1 → strictcli-0.42.0}/todo/.defer/deferred.md +0 -0
  107. {strictcli-0.41.1 → strictcli-0.42.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  108. {strictcli-0.41.1 → strictcli-0.42.0}/todo/.done/original-idea.md +0 -0
  109. {strictcli-0.41.1 → strictcli-0.42.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,16 +1,21 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: strictcli
3
- Version: 0.41.1
4
- Summary: A strict CLI framework for Python
5
- Project-URL: Homepage, https://github.com/smm-h/strictcli
3
+ Version: 0.42.0
4
+ Summary: A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation)
5
+ Project-URL: Homepage, https://smmh.dev/strictcli/
6
+ Project-URL: Documentation, https://smmh.dev/strictcli/
6
7
  Project-URL: Repository, https://github.com/smm-h/strictcli
8
+ Project-URL: Issues, https://github.com/smm-h/strictcli/issues
9
+ Project-URL: Changelog, https://github.com/smm-h/strictcli/blob/main/CHANGELOG.md
7
10
  Author-email: "S. M. Hosseini" <m.hosseini@veliu.com>
8
11
  License-Expression: MIT
9
12
  License-File: LICENSE
10
- Keywords: argparse,cli,command-line,framework,rlsbl,strictcli
13
+ Keywords: argparse,argument-parser,cli,cli-framework,command-line,framework,rlsbl,strictcli
11
14
  Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
12
16
  Classifier: Intended Audience :: Developers
13
17
  Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
14
19
  Classifier: Programming Language :: Python :: 3
15
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
21
  Requires-Python: >=3.11
@@ -19,7 +24,7 @@ Description-Content-Type: text/markdown
19
24
 
20
25
  # strictcli
21
26
 
22
- A strict CLI framework for Python.
27
+ A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation).
23
28
 
24
29
  strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Four types only: `str`, `bool`, `int`, `float`. No magic type inference, no implicit defaults.
25
30
 
@@ -1,6 +1,6 @@
1
1
  # strictcli
2
2
 
3
- A strict CLI framework for Python.
3
+ A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation).
4
4
 
5
5
  strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Four types only: `str`, `bool`, `int`, `float`. No magic type inference, no implicit defaults.
6
6
 
@@ -4,17 +4,28 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.41.1"
8
- description = "A strict CLI framework for Python"
7
+ version = "0.42.0"
8
+ description = "A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.11"
12
12
  authors = [{ name = "S. M. Hosseini", email = "m.hosseini@veliu.com" }]
13
- keywords = ["strictcli", "cli", "argparse", "command-line", "framework", "rlsbl"]
13
+ keywords = [
14
+ "strictcli",
15
+ "cli",
16
+ "cli-framework",
17
+ "command-line",
18
+ "argument-parser",
19
+ "argparse",
20
+ "framework",
21
+ "rlsbl",
22
+ ]
14
23
  classifiers = [
15
24
  "Development Status :: 3 - Alpha",
25
+ "Environment :: Console",
16
26
  "Intended Audience :: Developers",
17
27
  "License :: OSI Approved :: MIT License",
28
+ "Operating System :: OS Independent",
18
29
  "Programming Language :: Python :: 3",
19
30
  "Topic :: Software Development :: Libraries :: Python Modules",
20
31
  ]
@@ -29,5 +40,8 @@ dev = ["pytest", "allpairspy", "jsonschema"]
29
40
  testpaths = ["tests"]
30
41
 
31
42
  [project.urls]
32
- Homepage = "https://github.com/smm-h/strictcli"
43
+ Homepage = "https://smmh.dev/strictcli/"
44
+ Documentation = "https://smmh.dev/strictcli/"
33
45
  Repository = "https://github.com/smm-h/strictcli"
46
+ Issues = "https://github.com/smm-h/strictcli/issues"
47
+ Changelog = "https://github.com/smm-h/strictcli/blob/main/CHANGELOG.md"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.41.1"
5
+ __version__ = "0.42.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet",
@@ -8724,6 +8724,17 @@ def warn_check_spec(
8724
8724
  )
8725
8725
 
8726
8726
 
8727
+ def _msg_test_coverage_boolean_retired() -> str:
8728
+ """The retired boolean's refusal (contract §12.12: one sentence, each
8729
+ language's own spellings inside it -- `test_coverage` / `test_coverage_dir`
8730
+ here, `WithTestCoverage` / `WithTestCoverageDir` in Go, `testCoverage` /
8731
+ `testCoverageDir` in TypeScript)."""
8732
+ return (
8733
+ "test_coverage is not accepted; declare the directory holding "
8734
+ "coverage/ and test-coverage.json with test_coverage_dir"
8735
+ )
8736
+
8737
+
8727
8738
  @dataclass
8728
8739
  class App:
8729
8740
  """The root CLI application."""
@@ -8761,13 +8772,25 @@ class App:
8761
8772
  proc_observe_allowlist: list[list[str]] | None = None
8762
8773
  checks_path: str | Path | None = None
8763
8774
  checks_embed: bytes | None = None
8764
- test_coverage: bool = False
8775
+ # The directory holding this app's coverage state: `coverage/` (per-process
8776
+ # shard files) and `test-coverage.json` (the committed manifest). Declared,
8777
+ # never discovered. Absent means coverage is off -- no provider registered,
8778
+ # no paths computed, nothing touched. A declared directory that does not
8779
+ # exist at construction likewise leaves coverage off: that is the installed
8780
+ # distribution, where the path naming the source checkout is simply gone.
8781
+ test_coverage_dir: str | os.PathLike | None = None
8782
+ # Refusal-only. The retired boolean has no accepted spelling; this field
8783
+ # exists so that passing it names the option that replaced it instead of
8784
+ # raising CPython's bare "unexpected keyword argument" TypeError.
8785
+ test_coverage: bool | None = None
8765
8786
  flags: list[Flag] = field(default_factory=list)
8766
8787
  _commands: dict[str, Command] = field(default_factory=dict)
8767
8788
  _groups: dict[str, Group] = field(default_factory=dict)
8768
8789
  _deprecated: dict[str, DeprecatedCommand] = field(default_factory=dict)
8769
8790
 
8770
8791
  def __post_init__(self) -> None:
8792
+ if self.test_coverage is not None:
8793
+ raise ValueError(_msg_test_coverage_boolean_retired())
8771
8794
  _require_non_empty_str(self.version, "version", "App")
8772
8795
  _require_non_empty_str(self.help, "help", "App")
8773
8796
  # Check for duplicate and reserved global flag names
@@ -8973,23 +8996,27 @@ class App:
8973
8996
  self._coverage_dir: str | None = None
8974
8997
  self._coverage_manifest_path: str | None = None
8975
8998
  self._last_resolved_path: list[str] = []
8976
- if self.test_coverage:
8977
- # Anchor the coverage root to the cwd AT CONSTRUCTION TIME. Both the
8978
- # recorder and the check provider use these absolute paths so that
8979
- # tests which chdir still record into the repo, and a check evaluated
8980
- # from a foreign cwd reads the app's own repo state.
8981
- self._coverage_dir = os.path.abspath(
8982
- os.path.join(".strictcli", "coverage")
8983
- )
8984
- self._coverage_manifest_path = os.path.abspath(
8985
- os.path.join(".strictcli", "test-coverage.json")
8986
- )
8987
- self._coverage_shard_path = os.path.join(
8988
- self._coverage_dir,
8989
- f"{os.getpid()}.jsonl",
8990
- )
8991
- os.makedirs(self._coverage_dir, exist_ok=True)
8992
- self.register_check_provider(self._test_coverage_provider)
8999
+ if self.test_coverage_dir is not None:
9000
+ # The declared directory decides everything. An app installed
9001
+ # elsewhere finds it absent and stays uninstrumented: no provider,
9002
+ # no paths, no writes anywhere -- in particular not into whatever
9003
+ # directory the consumer happened to start the CLI from.
9004
+ root = os.path.abspath(os.fspath(self.test_coverage_dir))
9005
+ if os.path.isdir(root):
9006
+ # Only the PATHS are computed here. The coverage directory
9007
+ # itself is created lazily by _record_coverage, immediately
9008
+ # before the first shard write: shards are written only on the
9009
+ # test-harness paths (test() and call()), so a plain CLI
9010
+ # invocation leaves no coverage/ behind.
9011
+ self._coverage_dir = os.path.join(root, "coverage")
9012
+ self._coverage_manifest_path = os.path.join(
9013
+ root, "test-coverage.json"
9014
+ )
9015
+ self._coverage_shard_path = os.path.join(
9016
+ self._coverage_dir,
9017
+ f"{os.getpid()}.jsonl",
9018
+ )
9019
+ self.register_check_provider(self._test_coverage_provider)
8993
9020
 
8994
9021
  def _validate_flag_infra_marker(self, f: Flag) -> None:
8995
9022
  """Panic if a flag's default is a RelativeToRoot marker referencing an
@@ -9069,19 +9096,19 @@ class App:
9069
9096
  def _test_coverage_provider(self) -> list[CheckSpec]:
9070
9097
  """Built-in check provider for cli-test-coverage.
9071
9098
 
9072
- Registered automatically when test_coverage=True. The verdict is derived
9073
- from committed state: the covered set is the union of the committed
9074
- manifest (.strictcli/test-coverage.json) and any per-process shard files
9075
- merged from .strictcli/coverage/. Every live registered command path
9076
- (minus the injected check command) must be present in that union to pass;
9077
- otherwise the check fails naming each uncovered command.
9099
+ Registered automatically when test_coverage_dir names a directory that
9100
+ exists. The verdict is derived from committed state: the covered set is
9101
+ the union of the committed manifest (test-coverage.json in that
9102
+ directory) and any per-process shard files merged from its coverage/
9103
+ subdirectory. Every live registered command path (minus the injected
9104
+ check command) must be present in that union to pass; otherwise the
9105
+ check fails naming each uncovered command.
9078
9106
 
9079
9107
  Because the verdict reads the committed manifest, it is deterministic on
9080
9108
  every machine -- a machine that never ran the suite (no local shards)
9081
9109
  still gets a stable verdict from the committed manifest alone. Both the
9082
- coverage dir and the manifest path are anchored to the App's
9083
- construction-time cwd, so the check evaluated from a foreign cwd reads
9084
- the app's own repo state.
9110
+ coverage dir and the manifest path sit under the DECLARED directory, so
9111
+ the check evaluated from any cwd reads the app's own repo state.
9085
9112
 
9086
9113
  The manifest is rewritten as the monotonic union of its prior contents
9087
9114
  and the freshly merged shards, but ONLY when that content actually
@@ -11510,7 +11537,7 @@ class App:
11510
11537
  self._begin_dispatch()
11511
11538
  cmd_path = ".".join(self._last_resolved_path + [cmd.name])
11512
11539
  # Record test-coverage hit (command-level only, test mode only).
11513
- if mode == "test" and self.test_coverage:
11540
+ if mode == "test" and self._coverage_shard_path is not None:
11514
11541
  self._record_coverage(cmd_path)
11515
11542
  # Store sources for function handlers that need provenance info
11516
11543
  self._last_sources = sources
@@ -11777,7 +11804,7 @@ class App:
11777
11804
 
11778
11805
  self._begin_dispatch()
11779
11806
  # Record test-coverage hit (command-level only).
11780
- if self.test_coverage:
11807
+ if self._coverage_shard_path is not None:
11781
11808
  self._record_coverage(command_path)
11782
11809
 
11783
11810
  # Passthrough commands: forward raw args to the passthrough handler
@@ -1,12 +1,14 @@
1
1
  """Tests for the cli-test-coverage mechanism.
2
2
 
3
3
  Verifies that:
4
- - test_coverage=True enables recording of command hits
5
- - test() and call() both record to per-process shard files
4
+ - test_coverage_dir=<existing dir> enables recording of command hits
5
+ - test() and call() both record to per-process shard files under that directory
6
6
  - The cli-test-coverage check merges shards, compares against the command
7
7
  surface, and FAILs listing uncovered commands
8
8
  - Full coverage produces a PASS
9
9
  - Empty/stale manifest is a hard error
10
+ - A declared directory that does not exist leaves the instrumentation off
11
+ - The retired boolean is refused by name
10
12
  """
11
13
 
12
14
  import json
@@ -24,12 +26,18 @@ class SimpleCtx:
24
26
  project_root: Path
25
27
 
26
28
 
29
+ def _coverage_root(tmp_path):
30
+ """The declared coverage directory, created so the option takes effect."""
31
+ root = tmp_path / ".strictcli"
32
+ root.mkdir(parents=True, exist_ok=True)
33
+ return root
34
+
35
+
27
36
  def _make_app(tmp_path):
28
- """Build a 3-command app with test_coverage enabled, rooted in tmp_path."""
29
- os.chdir(tmp_path)
37
+ """Build a 3-command app whose coverage directory is tmp_path/.strictcli."""
30
38
  app = strictcli.App(
31
39
  name="coverapp", version="1.0.0", help="coverage test app",
32
- test_coverage=True,
40
+ test_coverage_dir=str(_coverage_root(tmp_path)),
33
41
  )
34
42
 
35
43
  @app.command(name="deploy", effect="read_only", forwarding=strictcli.Forwarding(reason="test handler absorbs global flag values"), help="deploy the app")
@@ -50,10 +58,9 @@ def _make_app(tmp_path):
50
58
 
51
59
  def _make_grouped_app(tmp_path):
52
60
  """Build an app with grouped commands for dotted-path coverage."""
53
- os.chdir(tmp_path)
54
61
  app = strictcli.App(
55
62
  name="grpapp", version="1.0.0", help="grouped coverage test",
56
- test_coverage=True,
63
+ test_coverage_dir=str(_coverage_root(tmp_path)),
57
64
  )
58
65
 
59
66
  grp = app.group("infra", help="infrastructure commands")
@@ -206,6 +213,7 @@ class TestCoverageCheck:
206
213
  when NEITHER a manifest NOR any shards exist."""
207
214
  app = _make_app(tmp_path)
208
215
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
216
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
209
217
  manifest_path.write_text("[]\n")
210
218
 
211
219
  results, _, code = app.run_checks(
@@ -272,20 +280,20 @@ class TestCoverageCheck:
272
280
 
273
281
 
274
282
  class TestCoverageChdirSafety:
275
- def test_record_anchored_to_construction_cwd(self, tmp_path):
276
- """test() records into the construction-time coverage dir, not the cwd
277
- that a test happened to chdir into."""
278
- app = _make_app(tmp_path) # constructs with cwd == tmp_path
283
+ def test_record_anchored_to_declared_directory(self, tmp_path):
284
+ """test() records into the declared coverage dir, not the cwd that a
285
+ test happened to chdir into."""
286
+ app = _make_app(tmp_path) # declares tmp_path/.strictcli
279
287
  other = tmp_path / "elsewhere"
280
288
  other.mkdir()
281
289
  os.chdir(other)
282
290
 
283
291
  app.test(["deploy"])
284
292
 
285
- construction_shards = list(
293
+ declared_shards = list(
286
294
  (tmp_path / ".strictcli" / "coverage").glob("*.jsonl")
287
295
  )
288
- assert construction_shards, "shard must land under the construction cwd"
296
+ assert declared_shards, "shard must be written under the declared dir"
289
297
  foreign = other / ".strictcli" / "coverage"
290
298
  assert not foreign.exists(), "must not record into the chdir'd cwd"
291
299
 
@@ -296,6 +304,7 @@ class TestManifestUnionVerdict:
296
304
  with no shard files (the machine never ran the suite)."""
297
305
  app = _make_app(tmp_path)
298
306
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
307
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
299
308
  manifest_path.write_text(
300
309
  json.dumps(["build", "deploy", "status"], indent=2) + "\n"
301
310
  )
@@ -309,9 +318,10 @@ class TestManifestUnionVerdict:
309
318
 
310
319
  def test_check_from_foreign_cwd_reads_app_state(self, tmp_path):
311
320
  """The check evaluated from a foreign cwd reads the app's own repo state
312
- (anchored manifest), not the foreign directory."""
321
+ (the declared manifest), not the foreign directory."""
313
322
  app = _make_app(tmp_path)
314
323
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
324
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
315
325
  manifest_path.write_text(
316
326
  json.dumps(["build", "deploy", "status"], indent=2) + "\n"
317
327
  )
@@ -330,6 +340,7 @@ class TestManifestUnionVerdict:
330
340
  """A run recording only a subset keeps prior commands covered (union)."""
331
341
  app = _make_app(tmp_path)
332
342
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
343
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
333
344
  manifest_path.write_text(
334
345
  json.dumps(["build", "deploy", "status"], indent=2) + "\n"
335
346
  )
@@ -360,7 +371,7 @@ class TestManifestUnionVerdict:
360
371
 
361
372
  class TestCoverageDisabled:
362
373
  def test_no_recording_when_disabled(self, tmp_path):
363
- """test_coverage=False (default) produces no shard files."""
374
+ """An undeclared test_coverage_dir (the default) produces no shards."""
364
375
  os.chdir(tmp_path)
365
376
  app = strictcli.App(
366
377
  name="nocover", version="1.0.0", help="no coverage",
@@ -374,3 +385,132 @@ class TestCoverageDisabled:
374
385
 
375
386
  coverage_dir = tmp_path / ".strictcli" / "coverage"
376
387
  assert not coverage_dir.exists()
388
+
389
+
390
+ class TestCoverageDirectoryIsLazy:
391
+ def test_construction_leaves_no_coverage_subdirectory(self, tmp_path):
392
+ """Constructing an app with a declared coverage directory must not
393
+ create coverage/ inside it -- a plain CLI invocation never records
394
+ coverage, so it must not plant an empty directory."""
395
+ app = _make_app(tmp_path)
396
+ assert app is not None
397
+ assert not (tmp_path / ".strictcli" / "coverage").exists()
398
+
399
+ def test_recording_creates_the_directory(self, tmp_path):
400
+ """The recorder creates the coverage directory immediately before the
401
+ first shard write."""
402
+ app = _make_app(tmp_path)
403
+ app.test(["deploy"])
404
+
405
+ shard = tmp_path / ".strictcli" / "coverage" / f"{os.getpid()}.jsonl"
406
+ assert shard.is_file()
407
+
408
+ def test_check_skips_when_coverage_state_absent(self, tmp_path):
409
+ """The provider reads a declared root holding no coverage state without
410
+ raising -- it reports the subject-matter SKIP."""
411
+ app = _make_app(tmp_path)
412
+ assert not (tmp_path / ".strictcli" / "coverage").exists()
413
+
414
+ results, _, _code = app.run_checks(
415
+ SimpleCtx(project_root=tmp_path),
416
+ run_all=True,
417
+ )
418
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
419
+ assert cov_result.status == "skip"
420
+
421
+
422
+ class TestDeclaredDirectoryAbsent:
423
+ """A declared directory that does not exist leaves coverage off.
424
+
425
+ This is the installed-wheel case: the path that names the source
426
+ checkout's ``.strictcli`` directory is simply not there once the CLI is
427
+ installed elsewhere, so the app registers no check, computes no paths and
428
+ creates nothing.
429
+ """
430
+
431
+ def test_missing_directory_registers_no_check(self, tmp_path):
432
+ missing = tmp_path / "nowhere" / ".strictcli"
433
+ app = strictcli.App(
434
+ name="coverapp", version="1.0.0", help="coverage test app",
435
+ test_coverage_dir=str(missing),
436
+ )
437
+
438
+ @app.command(name="deploy", effect="read_only", help="deploy the app")
439
+ def cmd_deploy(ctx):
440
+ pass
441
+
442
+ # No provider, so the check system never turns on: there is no `check`
443
+ # command to route to, and no cli-test-coverage to list.
444
+ result = app.test(["check", "--all"])
445
+ assert result.exit_code == 1
446
+ assert "cli-test-coverage" not in result.stdout
447
+ with pytest.raises(ValueError, match="checks are not enabled"):
448
+ app.run_checks(SimpleCtx(project_root=tmp_path), run_all=True)
449
+
450
+ def test_missing_directory_creates_nothing(self, tmp_path):
451
+ missing = tmp_path / "nowhere" / ".strictcli"
452
+ app = strictcli.App(
453
+ name="coverapp", version="1.0.0", help="coverage test app",
454
+ test_coverage_dir=str(missing),
455
+ )
456
+
457
+ @app.command(name="deploy", effect="read_only", help="deploy the app")
458
+ def cmd_deploy(ctx):
459
+ pass
460
+
461
+ app.test(["deploy"])
462
+ app.call("deploy")
463
+
464
+ assert not (tmp_path / "nowhere").exists()
465
+
466
+
467
+ class TestForeignDirectoryRun:
468
+ """A consumer-style run from a foreign directory.
469
+
470
+ The installed CLI is started in some unrelated project. Its declared
471
+ coverage directory does not exist there, so `check` must neither list
472
+ cli-test-coverage nor write a `.strictcli/` into the foreign directory.
473
+ """
474
+
475
+ def test_foreign_run_lists_no_check_and_touches_nothing(self, tmp_path):
476
+ foreign = tmp_path / "some-other-project"
477
+ foreign.mkdir()
478
+ os.chdir(foreign)
479
+
480
+ app = strictcli.App(
481
+ name="coverapp", version="1.0.0", help="coverage test app",
482
+ test_coverage_dir=str(tmp_path / "gone" / ".strictcli"),
483
+ )
484
+
485
+ @app.command(name="deploy", effect="read_only", help="deploy the app")
486
+ def cmd_deploy(ctx):
487
+ pass
488
+
489
+ result = app.test(["check", "--all"])
490
+ assert "cli-test-coverage" not in result.stdout
491
+
492
+ assert not (foreign / ".strictcli").exists()
493
+ assert list(foreign.iterdir()) == []
494
+
495
+
496
+ class TestRetiredBooleanRefused:
497
+ def test_boolean_true_is_refused_naming_the_directory_option(self):
498
+ with pytest.raises(ValueError) as exc:
499
+ strictcli.App(
500
+ name="coverapp", version="1.0.0", help="coverage test app",
501
+ test_coverage=True,
502
+ )
503
+ assert str(exc.value) == (
504
+ "test_coverage is not accepted; declare the directory holding "
505
+ "coverage/ and test-coverage.json with test_coverage_dir"
506
+ )
507
+
508
+ def test_boolean_false_is_refused_too(self):
509
+ """There is no accepted spelling of the retired option, not even the
510
+ one that used to mean "off" -- absence is how coverage is declared off."""
511
+ with pytest.raises(ValueError) as exc:
512
+ strictcli.App(
513
+ name="coverapp", version="1.0.0", help="coverage test app",
514
+ test_coverage=False,
515
+ )
516
+ assert "test_coverage_dir" in str(exc.value)
@@ -1147,7 +1147,8 @@ class TestCacheWrites:
1147
1147
 
1148
1148
  def test_cache_writes_never_appear_in_the_would_do_log(self, tmp_path, monkeypatch):
1149
1149
  monkeypatch.chdir(tmp_path)
1150
- app = _app(test_coverage=True)
1150
+ (tmp_path / ".strictcli").mkdir()
1151
+ app = _app(test_coverage_dir=str(tmp_path / ".strictcli"))
1151
1152
 
1152
1153
  @app.command("run", help="run", effect="read_only")
1153
1154
  def _run(ctx):
@@ -1166,7 +1167,8 @@ class TestCacheWrites:
1166
1167
  would move user-visible numbering for no visible reason.
1167
1168
  """
1168
1169
  monkeypatch.chdir(tmp_path)
1169
- app = _app(test_coverage=True)
1170
+ (tmp_path / ".strictcli").mkdir()
1171
+ app = _app(test_coverage_dir=str(tmp_path / ".strictcli"))
1170
1172
 
1171
1173
  @app.command("rel", help="rel", effect="mutating")
1172
1174
  def _rel(ctx):
@@ -1189,7 +1191,8 @@ class TestCacheWrites:
1189
1191
  def test_cache_writes_do_not_shift_the_truncation_step(self, tmp_path,
1190
1192
  monkeypatch):
1191
1193
  monkeypatch.chdir(tmp_path)
1192
- app = _app(test_coverage=True)
1194
+ (tmp_path / ".strictcli").mkdir()
1195
+ app = _app(test_coverage_dir=str(tmp_path / ".strictcli"))
1193
1196
 
1194
1197
  @app.command("rel", help="rel", effect="mutating")
1195
1198
  def _rel(ctx):
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.41.1"
235
+ version = "0.42.0"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "tomlkit" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes