strictcli 0.35.3__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.3 → strictcli-0.35.4}/PKG-INFO +1 -1
  2. {strictcli-0.35.3 → strictcli-0.35.4}/pyproject.toml +1 -1
  3. {strictcli-0.35.3 → strictcli-0.35.4}/strictcli/__init__.py +57 -12
  4. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_effects.py +181 -0
  5. {strictcli-0.35.3 → strictcli-0.35.4}/uv.lock +1 -1
  6. {strictcli-0.35.3 → strictcli-0.35.4}/.claude/settings.json +0 -0
  7. {strictcli-0.35.3 → strictcli-0.35.4}/.github/workflows/ci.yml +0 -0
  8. {strictcli-0.35.3 → strictcli-0.35.4}/.github/workflows/publish.yml +0 -0
  9. {strictcli-0.35.3 → strictcli-0.35.4}/.gitignore +0 -0
  10. {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  11. {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  12. {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/bases/.gitignore +0 -0
  13. {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  14. {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/config.json +0 -0
  15. {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/lint/python.toml +0 -0
  16. {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/managed-files.json +0 -0
  17. {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/version +0 -0
  18. {strictcli-0.35.3 → strictcli-0.35.4}/.strictcli/schema.json +0 -0
  19. {strictcli-0.35.3 → strictcli-0.35.4}/CLAUDE.md +0 -0
  20. {strictcli-0.35.3 → strictcli-0.35.4}/LICENSE +0 -0
  21. {strictcli-0.35.3 → strictcli-0.35.4}/README.md +0 -0
  22. {strictcli-0.35.3 → strictcli-0.35.4}/scripts/add_effect_classification.py +0 -0
  23. {strictcli-0.35.3 → strictcli-0.35.4}/scripts/add_forwarding_declaration.py +0 -0
  24. {strictcli-0.35.3 → strictcli-0.35.4}/strictcli/py.typed +0 -0
  25. {strictcli-0.35.3 → strictcli-0.35.4}/tests/conftest.py +0 -0
  26. {strictcli-0.35.3 → strictcli-0.35.4}/tests/flagship_app.py +0 -0
  27. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_arg_default.py +0 -0
  28. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_arg_default_validation.py +0 -0
  29. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_at_prefix.py +0 -0
  30. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_auto_version.py +0 -0
  31. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_call.py +0 -0
  32. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_command.py +0 -0
  33. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_discovery.py +0 -0
  34. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_provider.py +0 -0
  35. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_public_api.py +0 -0
  36. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_runner.py +0 -0
  37. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_schema.py +0 -0
  38. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_types.py +0 -0
  39. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_choices.py +0 -0
  40. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_choices_none.py +0 -0
  41. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_classification.py +0 -0
  42. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_command_help_suggestion.py +0 -0
  43. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_command_tags.py +0 -0
  44. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_compound_types.py +0 -0
  45. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_config.py +0 -0
  46. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_config_fields.py +0 -0
  47. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_config_file_path.py +0 -0
  48. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_config_set_bugs.py +0 -0
  49. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_confirm.py +0 -0
  50. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_connection_env.py +0 -0
  51. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_context.py +0 -0
  52. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_coverage.py +0 -0
  53. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_deep_nesting.py +0 -0
  54. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_dependencies.py +0 -0
  55. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_deprecated.py +0 -0
  56. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_dump_schema.py +0 -0
  57. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_e2e.py +0 -0
  58. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_effects_bypass_check.py +0 -0
  59. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_env.py +0 -0
  60. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_exit_codes.py +0 -0
  61. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_flag_sets.py +0 -0
  62. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_flagship_preview.py +0 -0
  63. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_float_format.py +0 -0
  64. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_float_type.py +0 -0
  65. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_float_vectors.py +0 -0
  66. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_global_flag_conflict_position.py +0 -0
  67. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_global_flags.py +0 -0
  68. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_guard_v2.py +0 -0
  69. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_help.py +0 -0
  70. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_hermetic.py +0 -0
  71. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_infra_env.py +0 -0
  72. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_int_type.py +0 -0
  73. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_invoke.py +0 -0
  74. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_keyword_flags.py +0 -0
  75. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_mcp.py +0 -0
  76. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_mutex.py +0 -0
  77. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_nesting.py +0 -0
  78. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_parser.py +0 -0
  79. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_passthrough.py +0 -0
  80. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_provenance.py +0 -0
  81. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_provenance_phase2.py +0 -0
  82. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_registration.py +0 -0
  83. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_repeatable.py +0 -0
  84. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_reserved_global_flags.py +0 -0
  85. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_reserved_quartet.py +0 -0
  86. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_tagdsl.py +0 -0
  87. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_toml_loading.py +0 -0
  88. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_tool_export.py +0 -0
  89. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_typed_args.py +0 -0
  90. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_unique.py +0 -0
  91. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_utilities.py +0 -0
  92. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_validate.py +0 -0
  93. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_variadic.py +0 -0
  94. {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_visibility.py +0 -0
  95. {strictcli-0.35.3 → strictcli-0.35.4}/todo/.defer/deferred.md +0 -0
  96. {strictcli-0.35.3 → strictcli-0.35.4}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  97. {strictcli-0.35.3 → strictcli-0.35.4}/todo/.done/original-idea.md +0 -0
  98. {strictcli-0.35.3 → 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.3
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.3"
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.3"
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
 
@@ -6873,20 +6906,31 @@ class App:
6873
6906
  )
6874
6907
  # The confirm protocol fires only on the real CLI path.
6875
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.
6876
6915
  try:
6877
6916
  if cmd.passthrough is not None:
6878
6917
  result = cmd.passthrough.handler(ctx, cmd.name, data, self._last_global_values)
6879
6918
  else:
6880
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=(",", ":")))
6881
6923
  except _DryRunTruncated as trunc:
6882
6924
  print(trunc.log.render())
6883
6925
  print(trunc.message, file=sys.stderr)
6884
6926
  sys.exit(1)
6885
- exit_code, out_data = _interpret_handler_return(result)
6886
- if out_data is not _MISSING:
6887
- print(json.dumps(out_data, default=str, separators=(",", ":")))
6888
- if self._last_dry_run:
6889
- 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)
6890
6934
  sys.exit(exit_code)
6891
6935
 
6892
6936
  def test(self, argv: list[str]) -> Result:
@@ -6945,9 +6989,9 @@ class App:
6945
6989
  exit_code = 1
6946
6990
  else:
6947
6991
  self._begin_dispatch()
6992
+ cmd_path = ".".join(self._last_resolved_path + [cmd.name])
6948
6993
  # Record test-coverage hit (command-level only).
6949
6994
  if self.test_coverage:
6950
- cmd_path = ".".join(self._last_resolved_path + [cmd.name])
6951
6995
  self._record_coverage(cmd_path)
6952
6996
  # Store sources for function handlers that need provenance info
6953
6997
  self._last_sources = sources
@@ -6959,9 +7003,7 @@ class App:
6959
7003
  dry_run=self._last_dry_run, yes=self._last_yes,
6960
7004
  quiet=self._last_quiet, verbose=self._last_verbose,
6961
7005
  effects=self._arm_effects(
6962
- cmd,
6963
- ".".join(self._last_resolved_path + [cmd.name]),
6964
- dry_run=self._last_dry_run,
7006
+ cmd, cmd_path, dry_run=self._last_dry_run,
6965
7007
  ),
6966
7008
  )
6967
7009
  if cmd.passthrough is not None:
@@ -6974,14 +7016,17 @@ class App:
6974
7016
  if out_data is not _MISSING:
6975
7017
  result_data = out_data
6976
7018
  print(json.dumps(out_data, default=str, separators=(",", ":")))
6977
- if self._last_dry_run:
6978
- print(self._effect_log.render())
7019
+ self._render_dry_log(cmd_path, aborted=False)
6979
7020
  except _DryRunTruncated as trunc:
6980
7021
  print(trunc.log.render())
6981
7022
  print(trunc.message, file=sys.stderr)
6982
7023
  exit_code = 1
6983
7024
  except SystemExit as e:
6984
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
6985
7030
 
6986
7031
  return Result(
6987
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()
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.35.3"
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