strictcli 0.41.2__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.2 → strictcli-0.42.0}/PKG-INFO +3 -3
  2. {strictcli-0.41.2 → strictcli-0.42.0}/README.md +1 -1
  3. {strictcli-0.41.2 → strictcli-0.42.0}/pyproject.toml +2 -2
  4. {strictcli-0.41.2 → strictcli-0.42.0}/strictcli/__init__.py +57 -35
  5. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_coverage.py +127 -23
  6. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_effects.py +6 -3
  7. {strictcli-0.41.2 → strictcli-0.42.0}/uv.lock +1 -1
  8. {strictcli-0.41.2 → strictcli-0.42.0}/.claude/settings.json +0 -0
  9. {strictcli-0.41.2 → strictcli-0.42.0}/.github/workflows/ci.yml +0 -0
  10. {strictcli-0.41.2 → strictcli-0.42.0}/.github/workflows/publish.yml +0 -0
  11. {strictcli-0.41.2 → strictcli-0.42.0}/.gitignore +0 -0
  12. {strictcli-0.41.2 → strictcli-0.42.0}/.rlsbl/config.json +0 -0
  13. {strictcli-0.41.2 → strictcli-0.42.0}/.rlsbl/lint/python.toml +0 -0
  14. {strictcli-0.41.2 → strictcli-0.42.0}/.rlsbl/managed-files.json +0 -0
  15. {strictcli-0.41.2 → strictcli-0.42.0}/CLAUDE.md +0 -0
  16. {strictcli-0.41.2 → strictcli-0.42.0}/LICENSE +0 -0
  17. {strictcli-0.41.2 → strictcli-0.42.0}/scripts/add_effect_classification.py +0 -0
  18. {strictcli-0.41.2 → strictcli-0.42.0}/scripts/add_forwarding_declaration.py +0 -0
  19. {strictcli-0.41.2 → strictcli-0.42.0}/scripts/migrate_presence.py +0 -0
  20. {strictcli-0.41.2 → strictcli-0.42.0}/strictcli/py.typed +0 -0
  21. {strictcli-0.41.2 → strictcli-0.42.0}/tests/conftest.py +0 -0
  22. {strictcli-0.41.2 → strictcli-0.42.0}/tests/flagship_app.py +0 -0
  23. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_arg_decorator_order.py +0 -0
  24. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_arg_default.py +0 -0
  25. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_arg_default_validation.py +0 -0
  26. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_argv_value_order.py +0 -0
  27. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_at_prefix.py +0 -0
  28. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_auto_version.py +0 -0
  29. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_call.py +0 -0
  30. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_check_command.py +0 -0
  31. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_check_discovery.py +0 -0
  32. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_check_provider.py +0 -0
  33. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_check_public_api.py +0 -0
  34. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_check_runner.py +0 -0
  35. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_check_schema.py +0 -0
  36. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_check_types.py +0 -0
  37. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_choice_records.py +0 -0
  38. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_choices.py +0 -0
  39. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_choices_none.py +0 -0
  40. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_claimed_rendering.py +0 -0
  41. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_classification.py +0 -0
  42. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_command_help_suggestion.py +0 -0
  43. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_command_tags.py +0 -0
  44. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_compound_types.py +0 -0
  45. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_config.py +0 -0
  46. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_config_fields.py +0 -0
  47. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_config_file_path.py +0 -0
  48. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_config_set_bugs.py +0 -0
  49. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_confirm.py +0 -0
  50. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_connection_env.py +0 -0
  51. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_constraints.py +0 -0
  52. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_context.py +0 -0
  53. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_deep_nesting.py +0 -0
  54. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_deprecated.py +0 -0
  55. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_dry_run_unsupported.py +0 -0
  56. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_dump_schema.py +0 -0
  57. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_e2e.py +0 -0
  58. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_effects_bypass_check.py +0 -0
  59. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_env.py +0 -0
  60. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_exit_codes.py +0 -0
  61. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_flag_sets.py +0 -0
  62. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_flagship_preview.py +0 -0
  63. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_flat_pre_typed.py +0 -0
  64. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_float_format.py +0 -0
  65. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_float_type.py +0 -0
  66. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_float_vectors.py +0 -0
  67. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_global_flag_conflict_position.py +0 -0
  68. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_global_flags.py +0 -0
  69. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_guard_v2.py +0 -0
  70. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_help.py +0 -0
  71. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_hermetic.py +0 -0
  72. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_infra_env.py +0 -0
  73. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_int_type.py +0 -0
  74. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_invoke.py +0 -0
  75. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_keyword_flags.py +0 -0
  76. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_machine_mode.py +0 -0
  77. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_mcp.py +0 -0
  78. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_member_spelling.py +0 -0
  79. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_nesting.py +0 -0
  80. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_owns_stdout.py +0 -0
  81. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_parser.py +0 -0
  82. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_passthrough.py +0 -0
  83. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_payload_schema.py +0 -0
  84. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_presence.py +0 -0
  85. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_provenance.py +0 -0
  86. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_provenance_phase2.py +0 -0
  87. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_record_pre_typed.py +0 -0
  88. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_registration.py +0 -0
  89. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_repeatable.py +0 -0
  90. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_reserved_global_flags.py +0 -0
  91. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_reserved_quartet.py +0 -0
  92. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_selectors.py +0 -0
  93. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_tagdsl.py +0 -0
  94. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_toml_loading.py +0 -0
  95. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_tool_export.py +0 -0
  96. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_trace_store.py +0 -0
  97. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_typed_args.py +0 -0
  98. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_ulid_vectors.py +0 -0
  99. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_unique.py +0 -0
  100. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_update.py +0 -0
  101. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_utilities.py +0 -0
  102. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_validate.py +0 -0
  103. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_value_sweep_order.py +0 -0
  104. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_variadic.py +0 -0
  105. {strictcli-0.41.2 → strictcli-0.42.0}/tests/test_visibility.py +0 -0
  106. {strictcli-0.41.2 → strictcli-0.42.0}/todo/.defer/deferred.md +0 -0
  107. {strictcli-0.41.2 → strictcli-0.42.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  108. {strictcli-0.41.2 → strictcli-0.42.0}/todo/.done/original-idea.md +0 -0
  109. {strictcli-0.41.2 → strictcli-0.42.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: strictcli
3
- Version: 0.41.2
4
- Summary: A strict CLI framework for Python, Go and TypeScript that makes you declare every command, flag, argument and help string, and errors at registration time on anything left unstated (Python implementation)
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
5
  Project-URL: Homepage, https://smmh.dev/strictcli/
6
6
  Project-URL: Documentation, https://smmh.dev/strictcli/
7
7
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  # strictcli
26
26
 
27
- A strict CLI framework for Python, Go and TypeScript that makes you declare every command, flag, argument and help string, and errors at registration time on anything left unstated (Python implementation).
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).
28
28
 
29
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.
30
30
 
@@ -1,6 +1,6 @@
1
1
  # strictcli
2
2
 
3
- A strict CLI framework for Python, Go and TypeScript that makes you declare every command, flag, argument and help string, and errors at registration time on anything left unstated (Python implementation).
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,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.41.2"
8
- description = "A strict CLI framework for Python, Go and TypeScript that makes you declare every command, flag, argument and help string, and errors at registration time on anything left unstated (Python implementation)"
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"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.41.2"
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,28 +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
- #
8982
- # Only the PATHS are computed here. The directory itself is created
8983
- # lazily by _record_coverage, immediately before the first shard
8984
- # write: shards are written only on the test-harness paths (test()
8985
- # and call()), so a plain CLI invocation must leave no .strictcli/
8986
- # behind in whatever directory it was run from.
8987
- self._coverage_dir = os.path.abspath(
8988
- os.path.join(".strictcli", "coverage")
8989
- )
8990
- self._coverage_manifest_path = os.path.abspath(
8991
- os.path.join(".strictcli", "test-coverage.json")
8992
- )
8993
- self._coverage_shard_path = os.path.join(
8994
- self._coverage_dir,
8995
- f"{os.getpid()}.jsonl",
8996
- )
8997
- 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)
8998
9020
 
8999
9021
  def _validate_flag_infra_marker(self, f: Flag) -> None:
9000
9022
  """Panic if a flag's default is a RelativeToRoot marker referencing an
@@ -9074,19 +9096,19 @@ class App:
9074
9096
  def _test_coverage_provider(self) -> list[CheckSpec]:
9075
9097
  """Built-in check provider for cli-test-coverage.
9076
9098
 
9077
- Registered automatically when test_coverage=True. The verdict is derived
9078
- from committed state: the covered set is the union of the committed
9079
- manifest (.strictcli/test-coverage.json) and any per-process shard files
9080
- merged from .strictcli/coverage/. Every live registered command path
9081
- (minus the injected check command) must be present in that union to pass;
9082
- 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.
9083
9106
 
9084
9107
  Because the verdict reads the committed manifest, it is deterministic on
9085
9108
  every machine -- a machine that never ran the suite (no local shards)
9086
9109
  still gets a stable verdict from the committed manifest alone. Both the
9087
- coverage dir and the manifest path are anchored to the App's
9088
- construction-time cwd, so the check evaluated from a foreign cwd reads
9089
- 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.
9090
9112
 
9091
9113
  The manifest is rewritten as the monotonic union of its prior contents
9092
9114
  and the freshly merged shards, but ONLY when that content actually
@@ -11515,7 +11537,7 @@ class App:
11515
11537
  self._begin_dispatch()
11516
11538
  cmd_path = ".".join(self._last_resolved_path + [cmd.name])
11517
11539
  # Record test-coverage hit (command-level only, test mode only).
11518
- if mode == "test" and self.test_coverage:
11540
+ if mode == "test" and self._coverage_shard_path is not None:
11519
11541
  self._record_coverage(cmd_path)
11520
11542
  # Store sources for function handlers that need provenance info
11521
11543
  self._last_sources = sources
@@ -11782,7 +11804,7 @@ class App:
11782
11804
 
11783
11805
  self._begin_dispatch()
11784
11806
  # Record test-coverage hit (command-level only).
11785
- if self.test_coverage:
11807
+ if self._coverage_shard_path is not None:
11786
11808
  self._record_coverage(command_path)
11787
11809
 
11788
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")
@@ -273,20 +280,20 @@ class TestCoverageCheck:
273
280
 
274
281
 
275
282
  class TestCoverageChdirSafety:
276
- def test_record_anchored_to_construction_cwd(self, tmp_path):
277
- """test() records into the construction-time coverage dir, not the cwd
278
- that a test happened to chdir into."""
279
- 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
280
287
  other = tmp_path / "elsewhere"
281
288
  other.mkdir()
282
289
  os.chdir(other)
283
290
 
284
291
  app.test(["deploy"])
285
292
 
286
- construction_shards = list(
293
+ declared_shards = list(
287
294
  (tmp_path / ".strictcli" / "coverage").glob("*.jsonl")
288
295
  )
289
- assert construction_shards, "shard must land under the construction cwd"
296
+ assert declared_shards, "shard must be written under the declared dir"
290
297
  foreign = other / ".strictcli" / "coverage"
291
298
  assert not foreign.exists(), "must not record into the chdir'd cwd"
292
299
 
@@ -311,7 +318,7 @@ class TestManifestUnionVerdict:
311
318
 
312
319
  def test_check_from_foreign_cwd_reads_app_state(self, tmp_path):
313
320
  """The check evaluated from a foreign cwd reads the app's own repo state
314
- (anchored manifest), not the foreign directory."""
321
+ (the declared manifest), not the foreign directory."""
315
322
  app = _make_app(tmp_path)
316
323
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
317
324
  manifest_path.parent.mkdir(parents=True, exist_ok=True)
@@ -364,7 +371,7 @@ class TestManifestUnionVerdict:
364
371
 
365
372
  class TestCoverageDisabled:
366
373
  def test_no_recording_when_disabled(self, tmp_path):
367
- """test_coverage=False (default) produces no shard files."""
374
+ """An undeclared test_coverage_dir (the default) produces no shards."""
368
375
  os.chdir(tmp_path)
369
376
  app = strictcli.App(
370
377
  name="nocover", version="1.0.0", help="no coverage",
@@ -381,13 +388,13 @@ class TestCoverageDisabled:
381
388
 
382
389
 
383
390
  class TestCoverageDirectoryIsLazy:
384
- def test_construction_leaves_no_directory(self, tmp_path):
385
- """Constructing an app with test_coverage=True must not create
386
- .strictcli/ -- a plain CLI invocation never records coverage, so it must
387
- not plant an empty directory in whatever cwd it was run from."""
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."""
388
395
  app = _make_app(tmp_path)
389
396
  assert app is not None
390
- assert not (tmp_path / ".strictcli").exists()
397
+ assert not (tmp_path / ".strictcli" / "coverage").exists()
391
398
 
392
399
  def test_recording_creates_the_directory(self, tmp_path):
393
400
  """The recorder creates the coverage directory immediately before the
@@ -398,11 +405,11 @@ class TestCoverageDirectoryIsLazy:
398
405
  shard = tmp_path / ".strictcli" / "coverage" / f"{os.getpid()}.jsonl"
399
406
  assert shard.is_file()
400
407
 
401
- def test_check_skips_when_directory_absent(self, tmp_path):
402
- """The provider reads a coverage root that does not exist without
408
+ def test_check_skips_when_coverage_state_absent(self, tmp_path):
409
+ """The provider reads a declared root holding no coverage state without
403
410
  raising -- it reports the subject-matter SKIP."""
404
411
  app = _make_app(tmp_path)
405
- assert not (tmp_path / ".strictcli").exists()
412
+ assert not (tmp_path / ".strictcli" / "coverage").exists()
406
413
 
407
414
  results, _, _code = app.run_checks(
408
415
  SimpleCtx(project_root=tmp_path),
@@ -410,3 +417,100 @@ class TestCoverageDirectoryIsLazy:
410
417
  )
411
418
  cov_result = next(r for r in results if r.name == "cli-test-coverage")
412
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.2"
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