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.
- {strictcli-0.35.3 → strictcli-0.35.4}/PKG-INFO +1 -1
- {strictcli-0.35.3 → strictcli-0.35.4}/pyproject.toml +1 -1
- {strictcli-0.35.3 → strictcli-0.35.4}/strictcli/__init__.py +57 -12
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_effects.py +181 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/uv.lock +1 -1
- {strictcli-0.35.3 → strictcli-0.35.4}/.claude/settings.json +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.github/workflows/publish.yml +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.gitignore +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/config.json +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.rlsbl/version +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/.strictcli/schema.json +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/CLAUDE.md +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/LICENSE +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/README.md +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/scripts/add_effect_classification.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/scripts/add_forwarding_declaration.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/strictcli/py.typed +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/conftest.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/flagship_app.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_arg_default.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_at_prefix.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_auto_version.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_call.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_command.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_discovery.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_provider.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_public_api.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_runner.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_schema.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_check_types.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_choices.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_choices_none.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_classification.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_command_tags.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_compound_types.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_config.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_config_fields.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_config_file_path.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_confirm.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_connection_env.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_context.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_coverage.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_dependencies.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_deprecated.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_dump_schema.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_e2e.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_effects_bypass_check.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_env.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_exit_codes.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_flag_sets.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_flagship_preview.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_float_format.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_float_type.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_float_vectors.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_global_flags.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_guard_v2.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_help.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_hermetic.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_infra_env.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_int_type.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_invoke.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_mcp.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_mutex.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_nesting.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_parser.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_passthrough.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_provenance.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_registration.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_repeatable.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_reserved_quartet.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_tagdsl.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_toml_loading.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_tool_export.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_typed_args.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_unique.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_utilities.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_validate.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_variadic.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/tests/test_visibility.py +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/todo/.defer/deferred.md +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/todo/.done/original-idea.md +0 -0
- {strictcli-0.35.3 → strictcli-0.35.4}/todo/.done/public-check-runner-api.md +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "0.35.
|
|
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
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
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
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|