strictcli 0.35.2__tar.gz → 0.35.4__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 (98) hide show
  1. {strictcli-0.35.2 → strictcli-0.35.4}/PKG-INFO +1 -1
  2. {strictcli-0.35.2 → strictcli-0.35.4}/pyproject.toml +1 -1
  3. {strictcli-0.35.2 → strictcli-0.35.4}/strictcli/__init__.py +138 -21
  4. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_effects.py +181 -0
  5. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_reserved_quartet.py +139 -5
  6. {strictcli-0.35.2 → strictcli-0.35.4}/uv.lock +1 -1
  7. {strictcli-0.35.2 → strictcli-0.35.4}/.claude/settings.json +0 -0
  8. {strictcli-0.35.2 → strictcli-0.35.4}/.github/workflows/ci.yml +0 -0
  9. {strictcli-0.35.2 → strictcli-0.35.4}/.github/workflows/publish.yml +0 -0
  10. {strictcli-0.35.2 → strictcli-0.35.4}/.gitignore +0 -0
  11. {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  12. {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  13. {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/bases/.gitignore +0 -0
  14. {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  15. {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/config.json +0 -0
  16. {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/lint/python.toml +0 -0
  17. {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/managed-files.json +0 -0
  18. {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/version +0 -0
  19. {strictcli-0.35.2 → strictcli-0.35.4}/.strictcli/schema.json +0 -0
  20. {strictcli-0.35.2 → strictcli-0.35.4}/CLAUDE.md +0 -0
  21. {strictcli-0.35.2 → strictcli-0.35.4}/LICENSE +0 -0
  22. {strictcli-0.35.2 → strictcli-0.35.4}/README.md +0 -0
  23. {strictcli-0.35.2 → strictcli-0.35.4}/scripts/add_effect_classification.py +0 -0
  24. {strictcli-0.35.2 → strictcli-0.35.4}/scripts/add_forwarding_declaration.py +0 -0
  25. {strictcli-0.35.2 → strictcli-0.35.4}/strictcli/py.typed +0 -0
  26. {strictcli-0.35.2 → strictcli-0.35.4}/tests/conftest.py +0 -0
  27. {strictcli-0.35.2 → strictcli-0.35.4}/tests/flagship_app.py +0 -0
  28. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_arg_default.py +0 -0
  29. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_arg_default_validation.py +0 -0
  30. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_at_prefix.py +0 -0
  31. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_auto_version.py +0 -0
  32. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_call.py +0 -0
  33. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_command.py +0 -0
  34. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_discovery.py +0 -0
  35. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_provider.py +0 -0
  36. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_public_api.py +0 -0
  37. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_runner.py +0 -0
  38. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_schema.py +0 -0
  39. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_types.py +0 -0
  40. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_choices.py +0 -0
  41. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_choices_none.py +0 -0
  42. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_classification.py +0 -0
  43. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_command_help_suggestion.py +0 -0
  44. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_command_tags.py +0 -0
  45. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_compound_types.py +0 -0
  46. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_config.py +0 -0
  47. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_config_fields.py +0 -0
  48. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_config_file_path.py +0 -0
  49. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_config_set_bugs.py +0 -0
  50. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_confirm.py +0 -0
  51. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_connection_env.py +0 -0
  52. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_context.py +0 -0
  53. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_coverage.py +0 -0
  54. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_deep_nesting.py +0 -0
  55. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_dependencies.py +0 -0
  56. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_deprecated.py +0 -0
  57. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_dump_schema.py +0 -0
  58. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_e2e.py +0 -0
  59. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_effects_bypass_check.py +0 -0
  60. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_env.py +0 -0
  61. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_exit_codes.py +0 -0
  62. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_flag_sets.py +0 -0
  63. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_flagship_preview.py +0 -0
  64. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_float_format.py +0 -0
  65. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_float_type.py +0 -0
  66. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_float_vectors.py +0 -0
  67. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_global_flag_conflict_position.py +0 -0
  68. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_global_flags.py +0 -0
  69. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_guard_v2.py +0 -0
  70. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_help.py +0 -0
  71. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_hermetic.py +0 -0
  72. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_infra_env.py +0 -0
  73. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_int_type.py +0 -0
  74. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_invoke.py +0 -0
  75. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_keyword_flags.py +0 -0
  76. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_mcp.py +0 -0
  77. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_mutex.py +0 -0
  78. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_nesting.py +0 -0
  79. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_parser.py +0 -0
  80. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_passthrough.py +0 -0
  81. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_provenance.py +0 -0
  82. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_provenance_phase2.py +0 -0
  83. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_registration.py +0 -0
  84. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_repeatable.py +0 -0
  85. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_reserved_global_flags.py +0 -0
  86. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_tagdsl.py +0 -0
  87. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_toml_loading.py +0 -0
  88. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_tool_export.py +0 -0
  89. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_typed_args.py +0 -0
  90. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_unique.py +0 -0
  91. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_utilities.py +0 -0
  92. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_validate.py +0 -0
  93. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_variadic.py +0 -0
  94. {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_visibility.py +0 -0
  95. {strictcli-0.35.2 → strictcli-0.35.4}/todo/.defer/deferred.md +0 -0
  96. {strictcli-0.35.2 → strictcli-0.35.4}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  97. {strictcli-0.35.2 → strictcli-0.35.4}/todo/.done/original-idea.md +0 -0
  98. {strictcli-0.35.2 → strictcli-0.35.4}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.35.2
3
+ Version: 0.35.4
4
4
  Summary: A strict CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.35.2"
7
+ version = "0.35.4"
8
8
  description = "A strict CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.35.2"
5
+ __version__ = "0.35.4"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -674,6 +674,16 @@ def _msg_dry_run_truncated(step: int, cmd: str, brand: str) -> str:
674
674
  )
675
675
 
676
676
 
677
+ def _msg_dry_run_aborted(step: int, cmd: str) -> str:
678
+ """The aborted-preview marker. Same shape and prefix as the truncation
679
+ error above: both say the preview ended before the handler finished, and
680
+ they differ only in why and in what the reader may conclude."""
681
+ return (
682
+ f"error: dry-run preview ends at step {step}: {cmd} aborted — "
683
+ f"the preview above may be incomplete"
684
+ )
685
+
686
+
677
687
  def _msg_confirm_prompt(cmd_path: str) -> str:
678
688
  """The confirm prompt. A prompt, not an error, but parity is still checked."""
679
689
  return f"about to run mutating command '{cmd_path}'. Proceed? [y/N] "
@@ -5508,6 +5518,29 @@ class App:
5508
5518
  """Start a new dispatch: reset the structured effect log."""
5509
5519
  self._effect_log = _EffectLog()
5510
5520
 
5521
+ def _render_dry_log(self, cmd_path: str, *, aborted: bool) -> None:
5522
+ """Write the would-do log for a dry run. No-op outside dry mode.
5523
+
5524
+ Called on every exit path out of a dispatch, so a handler that leaves
5525
+ through ``sys.exit`` or an exception still shows the preview it was
5526
+ asked for. The log always goes to stdout and is never suppressed by
5527
+ ``--quiet``: it is dry mode's primary output.
5528
+
5529
+ ``aborted`` marks a dispatch that did not finish. The log is still
5530
+ written -- the recorded effects are owed either way -- and the marker
5531
+ that follows it on stderr says the reader cannot assume the list is
5532
+ the whole preview. The truncation path (which ends the preview for its
5533
+ own pinned reason) renders itself and never comes through here.
5534
+ """
5535
+ if not self._last_dry_run:
5536
+ return
5537
+ print(self._effect_log.render())
5538
+ if aborted:
5539
+ print(
5540
+ _msg_dry_run_aborted(self._effect_log.next_seq(), cmd_path),
5541
+ file=sys.stderr,
5542
+ )
5543
+
5511
5544
  def _record_cache_write(self, path: str) -> None:
5512
5545
  """Record a framework-blessed CACHE_WRITE.
5513
5546
 
@@ -6004,17 +6037,20 @@ class App:
6004
6037
  self._groups["config"] = config_grp
6005
6038
 
6006
6039
  def _pre_scan_reserved_flags(self, argv: list[str]) -> dict:
6007
- """Position-aware pre-scan for the framework-owned reserved flags.
6040
+ """Pre-scan for the framework-owned reserved flags.
6008
6041
 
6009
6042
  Handles --dump-schema, --mcp, --config, --hermetic and the effects-regime
6010
6043
  quartet --dry-run/--yes/--quiet/--verbose.
6011
6044
 
6012
- Scans the pre-command region of argv (before the first non-flag
6013
- token, before ``--``). Known global flags and their values are
6014
- skipped so that a global-flag value matching a command name does
6015
- not terminate the scan early. Everything at or after the command token
6016
- is left untouched -- which is what keeps a passthrough command's args
6017
- opaque to the framework.
6045
+ Two regions, two rulesets (contract §7.2, amended):
6046
+
6047
+ - The **pre-command region** (before the first non-flag token, before
6048
+ ``--``) recognizes every reserved flag. Known global flags and their
6049
+ values are skipped so that a global-flag value matching a command name
6050
+ does not terminate the region early.
6051
+ - The **command region** recognizes ONLY the quartet, anywhere, exactly
6052
+ like --help/-h. --hermetic/--config/--dump-schema/--mcp stay
6053
+ pre-command-only. See _scan_command_region_quartet.
6018
6054
 
6019
6055
  Returns a dict with keys: dump_schema, serve_mcp, hermetic, config_path,
6020
6056
  dry_run, yes, quiet, verbose, err, cleaned_argv.
@@ -6030,16 +6066,20 @@ class App:
6030
6066
 
6031
6067
  result: dict = {}
6032
6068
  exclude_indices: set[int] = set()
6069
+ # Index where the command region begins; -1 means "never reached one"
6070
+ # (a bare -- or an unknown flag-like token ended the scan for good).
6071
+ command_region_from = -1
6033
6072
  i = 0
6034
6073
  while i < len(argv):
6035
6074
  tok = argv[i]
6036
6075
 
6037
- # -- terminates the pre-command region
6076
+ # -- terminates the whole scan: everything after it is data
6038
6077
  if tok == "--":
6039
6078
  break
6040
6079
 
6041
- # Non-flag token = command name: stop scanning
6080
+ # Non-flag token = the command token: the command region starts here
6042
6081
  if not tok.startswith("-") or tok == "-":
6082
+ command_region_from = i
6043
6083
  break
6044
6084
 
6045
6085
  # --dump-schema
@@ -6120,6 +6160,11 @@ class App:
6120
6160
  # Unknown flag-like token: stop
6121
6161
  break
6122
6162
 
6163
+ if command_region_from >= 0:
6164
+ self._scan_command_region_quartet(
6165
+ argv, command_region_from, result, exclude_indices,
6166
+ )
6167
+
6123
6168
  if exclude_indices:
6124
6169
  result["cleaned_argv"] = [
6125
6170
  tok for j, tok in enumerate(argv) if j not in exclude_indices
@@ -6129,6 +6174,66 @@ class App:
6129
6174
 
6130
6175
  return result
6131
6176
 
6177
+ def _scan_command_region_quartet(
6178
+ self,
6179
+ argv: list[str],
6180
+ start: int,
6181
+ result: dict,
6182
+ exclude_indices: set[int],
6183
+ ) -> None:
6184
+ """Recognize the reserved quartet in the command region of argv.
6185
+
6186
+ Contract §7.2 (amended 2026-08-04): --dry-run/--yes/--quiet/--verbose are
6187
+ recognized ANYWHERE in argv, exactly like --help/-h, because their
6188
+ applicability is per-command -- requiring them before the command name
6189
+ was backwards. Only the quartet is recognized here; --hermetic,
6190
+ --config, --dump-schema and --mcp remain pre-command-only.
6191
+
6192
+ The scan stops for good at two boundaries:
6193
+
6194
+ - a bare ``--``, after which every token is positional data;
6195
+ - a **passthrough** command's name, after which every token belongs to
6196
+ the child process and is forwarded byte-for-byte. Eating a child's own
6197
+ --verbose would silently change what the child does.
6198
+
6199
+ Routing tokens are walked through the group/command tree so a quartet
6200
+ token may sit anywhere among them. Nothing here raises: routing errors
6201
+ are the real parse's job.
6202
+ """
6203
+ groups = self._groups
6204
+ commands = self._commands
6205
+ routing_done = False
6206
+ i = start
6207
+ while i < len(argv):
6208
+ tok = argv[i]
6209
+
6210
+ if tok == "--":
6211
+ return
6212
+
6213
+ if tok.startswith("-") and tok != "-":
6214
+ if tok in _RESERVED_QUARTET_TOKENS:
6215
+ result[_RESERVED_QUARTET_TOKENS[tok]] = True
6216
+ exclude_indices.add(i)
6217
+ i += 1
6218
+ continue
6219
+
6220
+ # A non-flag token: a routing token until routing resolves.
6221
+ if not routing_done:
6222
+ grp = groups.get(tok)
6223
+ if grp is not None:
6224
+ groups = grp._groups
6225
+ commands = grp.commands
6226
+ i += 1
6227
+ continue
6228
+ cmd = commands.get(tok)
6229
+ if cmd is not None and cmd.passthrough is not None:
6230
+ return
6231
+ # Resolved a normal command, or hit an unknown/deprecated token
6232
+ # the real parse will report: routing is over either way.
6233
+ routing_done = True
6234
+
6235
+ i += 1
6236
+
6132
6237
  def _parse(self, argv: list[str]) -> tuple[Command, dict[str, object] | list[str], dict[str, str]]:
6133
6238
  """Parse argv (without program name) into a resolved Command and kwargs.
6134
6239
 
@@ -6801,20 +6906,31 @@ class App:
6801
6906
  )
6802
6907
  # The confirm protocol fires only on the real CLI path.
6803
6908
  self._confirm_mutating(cmd, cmd_path)
6909
+ # The would-do log renders on EVERY exit path out of the dispatch,
6910
+ # not just the normal return: the operator asked for a preview and
6911
+ # the effects were recorded, so a handler that unwinds through
6912
+ # sys.exit or an exception still owes them the list. The clause set
6913
+ # below is exhaustive by construction -- BaseException is the root
6914
+ # of the hierarchy, so no unwind can slip past it.
6804
6915
  try:
6805
6916
  if cmd.passthrough is not None:
6806
6917
  result = cmd.passthrough.handler(ctx, cmd.name, data, self._last_global_values)
6807
6918
  else:
6808
6919
  result = cmd.handler(ctx, **data)
6920
+ exit_code, out_data = _interpret_handler_return(result)
6921
+ if out_data is not _MISSING:
6922
+ print(json.dumps(out_data, default=str, separators=(",", ":")))
6809
6923
  except _DryRunTruncated as trunc:
6810
6924
  print(trunc.log.render())
6811
6925
  print(trunc.message, file=sys.stderr)
6812
6926
  sys.exit(1)
6813
- exit_code, out_data = _interpret_handler_return(result)
6814
- if out_data is not _MISSING:
6815
- print(json.dumps(out_data, default=str, separators=(",", ":")))
6816
- if self._last_dry_run:
6817
- print(self._effect_log.render())
6927
+ except SystemExit:
6928
+ self._render_dry_log(cmd_path, aborted=False)
6929
+ raise
6930
+ except BaseException:
6931
+ self._render_dry_log(cmd_path, aborted=True)
6932
+ raise
6933
+ self._render_dry_log(cmd_path, aborted=False)
6818
6934
  sys.exit(exit_code)
6819
6935
 
6820
6936
  def test(self, argv: list[str]) -> Result:
@@ -6873,9 +6989,9 @@ class App:
6873
6989
  exit_code = 1
6874
6990
  else:
6875
6991
  self._begin_dispatch()
6992
+ cmd_path = ".".join(self._last_resolved_path + [cmd.name])
6876
6993
  # Record test-coverage hit (command-level only).
6877
6994
  if self.test_coverage:
6878
- cmd_path = ".".join(self._last_resolved_path + [cmd.name])
6879
6995
  self._record_coverage(cmd_path)
6880
6996
  # Store sources for function handlers that need provenance info
6881
6997
  self._last_sources = sources
@@ -6887,9 +7003,7 @@ class App:
6887
7003
  dry_run=self._last_dry_run, yes=self._last_yes,
6888
7004
  quiet=self._last_quiet, verbose=self._last_verbose,
6889
7005
  effects=self._arm_effects(
6890
- cmd,
6891
- ".".join(self._last_resolved_path + [cmd.name]),
6892
- dry_run=self._last_dry_run,
7006
+ cmd, cmd_path, dry_run=self._last_dry_run,
6893
7007
  ),
6894
7008
  )
6895
7009
  if cmd.passthrough is not None:
@@ -6902,14 +7016,17 @@ class App:
6902
7016
  if out_data is not _MISSING:
6903
7017
  result_data = out_data
6904
7018
  print(json.dumps(out_data, default=str, separators=(",", ":")))
6905
- if self._last_dry_run:
6906
- print(self._effect_log.render())
7019
+ self._render_dry_log(cmd_path, aborted=False)
6907
7020
  except _DryRunTruncated as trunc:
6908
7021
  print(trunc.log.render())
6909
7022
  print(trunc.message, file=sys.stderr)
6910
7023
  exit_code = 1
6911
7024
  except SystemExit as e:
6912
7025
  exit_code = e.code if isinstance(e.code, int) else (1 if e.code else 0)
7026
+ self._render_dry_log(cmd_path, aborted=False)
7027
+ except BaseException:
7028
+ self._render_dry_log(cmd_path, aborted=True)
7029
+ raise
6913
7030
 
6914
7031
  return Result(
6915
7032
  stdout=stdout_buf.getvalue(),
@@ -203,6 +203,187 @@ class TestWouldDoLog:
203
203
  assert (tmp_path / "build").is_dir()
204
204
 
205
205
 
206
+ _EXPECTED_LOG = (
207
+ "DRY RUN — no changes were made. Would do:\n"
208
+ " 1. write: report.txt (2 bytes)\n"
209
+ )
210
+
211
+
212
+ class TestWouldDoLogOnEveryExitPath:
213
+ """The log renders on every exit path out of a dispatch, not just the
214
+ normal return: `sys.exit`, an uncaught exception, and a bad handler
215
+ return all still show the preview the operator asked for."""
216
+
217
+ @staticmethod
218
+ def _app_with(handler_tail):
219
+ app = _app()
220
+
221
+ @app.command("rel", help="rel", effect="mutating")
222
+ def _rel(ctx):
223
+ ctx.effects.write("report.txt", "ok")
224
+ return handler_tail()
225
+
226
+ return app
227
+
228
+ def test_sys_exit_still_renders_the_log(self):
229
+ app = self._app_with(lambda: sys.exit(1))
230
+ r = app.test(["--dry-run", "rel"])
231
+ assert r.exit_code == 1
232
+ assert r.stdout == _EXPECTED_LOG
233
+ assert r.stderr == ""
234
+
235
+ def test_sys_exit_renders_the_same_bytes_as_an_equivalent_return(self):
236
+ """`sys.exit(1)` and `return 1` are the same intent; the preview must
237
+ not depend on which spelling the handler chose."""
238
+ exiting = self._app_with(lambda: sys.exit(1)).test(["--dry-run", "rel"])
239
+ returning = self._app_with(lambda: 1).test(["--dry-run", "rel"])
240
+ assert exiting.stdout == returning.stdout
241
+ assert exiting.stderr == returning.stderr
242
+ assert exiting.exit_code == returning.exit_code
243
+
244
+ def test_sys_exit_with_no_code_renders_the_log(self):
245
+ app = self._app_with(lambda: sys.exit())
246
+ r = app.test(["--dry-run", "rel"])
247
+ assert r.exit_code == 0
248
+ assert r.stdout == _EXPECTED_LOG
249
+
250
+ def test_sys_exit_renders_nothing_outside_dry_mode(self, tmp_path, monkeypatch):
251
+ monkeypatch.chdir(tmp_path)
252
+ app = self._app_with(lambda: sys.exit(1))
253
+ r = app.test(["rel"])
254
+ assert r.exit_code == 1
255
+ assert "DRY RUN" not in r.stdout
256
+
257
+ def test_sys_exit_renders_the_log_on_the_run_path(self, monkeypatch, capsys):
258
+ app = self._app_with(lambda: sys.exit(1))
259
+ monkeypatch.setattr(sys, "argv", ["app", "--dry-run", "rel"])
260
+ with pytest.raises(SystemExit) as exc:
261
+ app.run()
262
+ assert exc.value.code == 1
263
+ captured = capsys.readouterr()
264
+ assert captured.out == _EXPECTED_LOG
265
+ assert captured.err == ""
266
+
267
+ def test_uncaught_exception_renders_the_log_marked_incomplete(
268
+ self, monkeypatch, capsys
269
+ ):
270
+ def _boom():
271
+ raise RuntimeError("kaboom")
272
+
273
+ app = self._app_with(_boom)
274
+ monkeypatch.setattr(sys, "argv", ["app", "--dry-run", "rel"])
275
+ with pytest.raises(RuntimeError, match="kaboom"):
276
+ app.run()
277
+ captured = capsys.readouterr()
278
+ assert captured.out == _EXPECTED_LOG
279
+ assert captured.err == (
280
+ "error: dry-run preview ends at step 2: rel aborted — "
281
+ "the preview above may be incomplete\n"
282
+ )
283
+
284
+ def test_an_aborted_preview_names_the_dotted_command_path(
285
+ self, monkeypatch, capsys
286
+ ):
287
+ app = _app()
288
+ grp = app.group("release", help="release")
289
+
290
+ @grp.command("run", help="run", effect="mutating")
291
+ def _run(ctx):
292
+ raise RuntimeError("kaboom")
293
+
294
+ monkeypatch.setattr(sys, "argv", ["app", "--dry-run", "release", "run"])
295
+ with pytest.raises(RuntimeError):
296
+ app.run()
297
+ captured = capsys.readouterr()
298
+ assert captured.out == "DRY RUN — no changes were made. Would do:\n"
299
+ assert "release.run aborted" in captured.err
300
+
301
+ def test_uncaught_exception_renders_nothing_outside_dry_mode(
302
+ self, monkeypatch, capsys, tmp_path
303
+ ):
304
+ monkeypatch.chdir(tmp_path)
305
+
306
+ def _boom():
307
+ raise RuntimeError("kaboom")
308
+
309
+ app = self._app_with(_boom)
310
+ monkeypatch.setattr(sys, "argv", ["app", "--yes", "rel"])
311
+ with pytest.raises(RuntimeError):
312
+ app.run()
313
+ captured = capsys.readouterr()
314
+ assert "DRY RUN" not in captured.out
315
+ assert "aborted" not in captured.err
316
+
317
+ def test_bad_handler_return_renders_the_log_marked_incomplete(
318
+ self, monkeypatch, capsys
319
+ ):
320
+ app = self._app_with(lambda: ["not", "an", "outcome"])
321
+ monkeypatch.setattr(sys, "argv", ["app", "--dry-run", "rel"])
322
+ with pytest.raises(TypeError, match="command handler must return"):
323
+ app.run()
324
+ captured = capsys.readouterr()
325
+ assert captured.out == _EXPECTED_LOG
326
+ assert "rel aborted" in captured.err
327
+
328
+ def test_the_abort_marker_never_reaches_stdout(self, monkeypatch, capsys):
329
+ """§3.4: the log is stdout's; the marker that qualifies it is stderr's,
330
+ exactly like the truncation error."""
331
+
332
+ def _boom():
333
+ raise RuntimeError("kaboom")
334
+
335
+ app = self._app_with(_boom)
336
+ monkeypatch.setattr(sys, "argv", ["app", "--quiet", "--dry-run", "rel"])
337
+ with pytest.raises(RuntimeError):
338
+ app.run()
339
+ captured = capsys.readouterr()
340
+ assert "aborted" not in captured.out
341
+ assert captured.out == _EXPECTED_LOG
342
+
343
+ def test_a_read_only_abort_still_renders_header_with_empty_body(
344
+ self, monkeypatch, capsys
345
+ ):
346
+ app = _app()
347
+
348
+ @app.command("look", help="look", effect="read_only")
349
+ def _look(ctx):
350
+ raise RuntimeError("kaboom")
351
+
352
+ monkeypatch.setattr(sys, "argv", ["app", "--dry-run", "look"])
353
+ with pytest.raises(RuntimeError):
354
+ app.run()
355
+ captured = capsys.readouterr()
356
+ assert captured.out == "DRY RUN — no changes were made. Would do:\n"
357
+ assert "look aborted" in captured.err
358
+
359
+ def test_truncation_still_owns_its_own_rendering(self, monkeypatch, capsys):
360
+ """The truncation path renders itself and exits 1; the abort marker
361
+ must not double up on it."""
362
+ app = _app()
363
+
364
+ @app.command("rel", help="rel", effect="mutating")
365
+ def _rel(ctx):
366
+ u = ctx.effects.run(["git", "status"])
367
+ if u:
368
+ pass
369
+ return 0
370
+
371
+ monkeypatch.setattr(sys, "argv", ["app", "--dry-run", "rel"])
372
+ with pytest.raises(SystemExit) as exc:
373
+ app.run()
374
+ assert exc.value.code == 1
375
+ captured = capsys.readouterr()
376
+ assert captured.out == (
377
+ "DRY RUN — no changes were made. Would do:\n"
378
+ " 1. run: git status\n"
379
+ )
380
+ assert captured.err == (
381
+ "error: dry-run preview ends at step 2: rel branched on unsettled "
382
+ "value «step 1 output» — cannot preview past this point\n"
383
+ )
384
+ assert "aborted" not in captured.err
385
+
386
+
206
387
  class TestTruncation:
207
388
  def test_branching_on_a_carrier_truncates(self):
208
389
  app = _app()
@@ -127,13 +127,107 @@ class TestDelivery:
127
127
  assert r.exit_code == 0
128
128
  assert seen == {"ok": True}
129
129
 
130
- def test_pre_scan_is_position_aware(self):
131
- """The quartet is extracted from the pre-command region only, which is
132
- what keeps a passthrough command's args opaque to the framework."""
130
+ @pytest.mark.parametrize("token,key", [
131
+ ("--dry-run", "dry_run"),
132
+ ("--yes", "yes"),
133
+ ("--quiet", "quiet"),
134
+ ("--verbose", "verbose"),
135
+ ])
136
+ def test_each_flag_is_recognized_after_the_command(self, token, key):
137
+ """The quartet is recognized anywhere in argv, exactly like --help."""
138
+ r = _quartet_app().test(["run", token])
139
+ assert r.exit_code == 0
140
+ assert r.data[key] is True
141
+
142
+ def test_all_four_together_after_the_command(self):
143
+ r = _quartet_app().test(["run", "--dry-run", "--yes", "--quiet", "--verbose"])
144
+ assert r.exit_code == 0
145
+ assert r.data == {"dry_run": True, "yes": True, "quiet": True, "verbose": True}
146
+
147
+ def test_mixed_positions_are_unioned(self):
148
+ r = _quartet_app().test(["--dry-run", "run", "--verbose"])
149
+ assert r.exit_code == 0
150
+ assert r.data["dry_run"] is True
151
+ assert r.data["verbose"] is True
152
+
153
+ def test_recognized_after_a_nested_group_subcommand(self):
154
+ app = strictcli.App(name="app", version="1.0.0", help="app")
155
+ outer = app.group("outer", help="outer")
156
+ inner = outer.group("inner", help="inner")
157
+
158
+ @inner.command("run", effect="read_only", help="run")
159
+ def _run(ctx):
160
+ return strictcli.outcome(data={"dry_run": ctx.dry_run})
161
+
162
+ r = app.test(["outer", "inner", "run", "--dry-run"])
163
+ assert r.exit_code == 0
164
+ assert r.data == {"dry_run": True}
165
+
166
+ def test_recognized_between_a_group_and_its_subcommand(self):
167
+ app = strictcli.App(name="app", version="1.0.0", help="app")
168
+ grp = app.group("grp", help="grp")
169
+
170
+ @grp.command("run", effect="read_only", help="run")
171
+ def _run(ctx):
172
+ return strictcli.outcome(data={"dry_run": ctx.dry_run})
173
+
174
+ r = app.test(["grp", "--dry-run", "run"])
175
+ assert r.exit_code == 0
176
+ assert r.data == {"dry_run": True}
177
+
178
+ def test_stripped_from_argv_after_the_command(self):
179
+ """The quartet never reaches the command parser as an argument."""
180
+ app = strictcli.App(name="app", version="1.0.0", help="app")
181
+
182
+ @app.command("run", effect="read_only", help="run",
183
+ args=[strictcli.Arg(name="name", help="a positional")])
184
+ def _run(ctx, name):
185
+ return strictcli.outcome(data={"name": name, "quiet": ctx.quiet})
186
+
187
+ r = app.test(["run", "--quiet", "value"])
188
+ assert r.exit_code == 0
189
+ assert r.data == {"name": "value", "quiet": True}
190
+
191
+ def test_a_token_after_double_dash_is_data(self):
192
+ """A bare -- ends the scan: what follows is positional data."""
193
+ app = strictcli.App(name="app", version="1.0.0", help="app")
194
+
195
+ @app.command("run", effect="read_only", help="run",
196
+ args=[strictcli.Arg(name="rest", help="trailing args",
197
+ variadic=True)])
198
+ def _run(ctx, rest):
199
+ return strictcli.outcome(data={"rest": rest, "dry_run": ctx.dry_run})
200
+
201
+ r = app.test(["run", "--", "--dry-run"])
202
+ assert r.exit_code == 0
203
+ assert r.data == {"rest": ["--dry-run"], "dry_run": False}
204
+
205
+ def test_hermetic_stays_pre_command_only(self):
206
+ """Only the quartet moved; --hermetic is still pre-command-only."""
133
207
  app = _quartet_app()
134
- r = app.test(["run", "--dry-run"])
208
+ r = app.test(["run", "--hermetic"])
135
209
  assert r.exit_code == 1
136
- assert "unknown flag '--dry-run'" in r.stderr
210
+ assert "unknown flag '--hermetic'" in r.stderr
211
+
212
+ def test_read_only_accepts_a_post_command_dry_run(self):
213
+ app = _quartet_app()
214
+ r = app.test(["run", "--dry-run"])
215
+ assert r.exit_code == 0
216
+ assert "DRY RUN — no changes were made. Would do:" in r.stdout
217
+
218
+ def test_read_only_still_rejects_a_mutating_effect_post_command(self):
219
+ """Per-command applicability is unchanged, wherever --dry-run appeared."""
220
+ app = strictcli.App(name="app", version="1.0.0", help="app")
221
+
222
+ @app.command("look", effect="read_only", help="look")
223
+ def _look(ctx):
224
+ ctx.effects.mkdir("d")
225
+ return 0
226
+
227
+ with pytest.raises(ValueError) as exc:
228
+ app.test(["look", "--dry-run"])
229
+ assert ('command "look" is classified read_only; '
230
+ "effects.mkdir is a mutating operation") in str(exc.value)
137
231
 
138
232
  def test_passthrough_args_stay_opaque(self):
139
233
  app = strictcli.App(name="app", version="1.0.0", help="app")
@@ -152,6 +246,46 @@ class TestDelivery:
152
246
  assert r.exit_code == 0
153
247
  assert seen["args"] == ["--quiet", "--verbose"]
154
248
 
249
+ def test_passthrough_under_a_group_stays_opaque(self):
250
+ app = strictcli.App(name="app", version="1.0.0", help="app")
251
+ seen = {}
252
+
253
+ def _pt(ctx, name, args, globals):
254
+ seen["args"] = args
255
+ seen["verbose"] = ctx.verbose
256
+ return 0
257
+
258
+ grp = app.group("grp", help="grp")
259
+
260
+ @grp.command("exec", effect="read_only", help="exec",
261
+ passthrough=strictcli.Passthrough(handler=_pt))
262
+ def _exec(ctx, **kw):
263
+ return 0
264
+
265
+ r = app.test(["grp", "exec", "--verbose", "child"])
266
+ assert r.exit_code == 0
267
+ assert seen == {"args": ["--verbose", "child"], "verbose": False}
268
+
269
+ def test_pre_command_position_is_the_passthrough_escape_hatch(self):
270
+ """A pre-command quartet token reaches the Context AND leaves the
271
+ child's identically-spelled argument untouched."""
272
+ app = strictcli.App(name="app", version="1.0.0", help="app")
273
+ seen = {}
274
+
275
+ def _pt(ctx, name, args, globals):
276
+ seen["args"] = args
277
+ seen["verbose"] = ctx.verbose
278
+ return 0
279
+
280
+ @app.command("exec", effect="read_only", help="exec",
281
+ passthrough=strictcli.Passthrough(handler=_pt))
282
+ def _exec(ctx, **kw):
283
+ return 0
284
+
285
+ r = app.test(["--verbose", "exec", "--verbose", "child"])
286
+ assert r.exit_code == 0
287
+ assert seen == {"args": ["--verbose", "child"], "verbose": True}
288
+
155
289
 
156
290
  class TestGating:
157
291
  """The --quiet/--verbose gating table (quiet dominates verbose)."""
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.35.2"
235
+ version = "0.35.4"
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
File without changes
File without changes