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.
- {strictcli-0.35.2 → strictcli-0.35.4}/PKG-INFO +1 -1
- {strictcli-0.35.2 → strictcli-0.35.4}/pyproject.toml +1 -1
- {strictcli-0.35.2 → strictcli-0.35.4}/strictcli/__init__.py +138 -21
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_effects.py +181 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_reserved_quartet.py +139 -5
- {strictcli-0.35.2 → strictcli-0.35.4}/uv.lock +1 -1
- {strictcli-0.35.2 → strictcli-0.35.4}/.claude/settings.json +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.github/workflows/publish.yml +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.gitignore +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/config.json +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.rlsbl/version +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/.strictcli/schema.json +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/CLAUDE.md +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/LICENSE +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/README.md +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/scripts/add_effect_classification.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/scripts/add_forwarding_declaration.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/strictcli/py.typed +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/conftest.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/flagship_app.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_arg_default.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_at_prefix.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_auto_version.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_call.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_command.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_discovery.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_provider.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_public_api.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_runner.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_schema.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_check_types.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_choices.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_choices_none.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_classification.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_command_tags.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_compound_types.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_config.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_config_fields.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_config_file_path.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_confirm.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_connection_env.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_context.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_coverage.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_dependencies.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_deprecated.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_dump_schema.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_e2e.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_effects_bypass_check.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_env.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_exit_codes.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_flag_sets.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_flagship_preview.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_float_format.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_float_type.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_float_vectors.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_global_flags.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_guard_v2.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_help.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_hermetic.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_infra_env.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_int_type.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_invoke.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_mcp.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_mutex.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_nesting.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_parser.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_passthrough.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_provenance.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_registration.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_repeatable.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_tagdsl.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_toml_loading.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_tool_export.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_typed_args.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_unique.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_utilities.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_validate.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_variadic.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/tests/test_visibility.py +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/todo/.defer/deferred.md +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.35.2 → strictcli-0.35.4}/todo/.done/original-idea.md +0 -0
- {strictcli-0.35.2 → 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
|
|
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
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
|
|
6076
|
+
# -- terminates the whole scan: everything after it is data
|
|
6038
6077
|
if tok == "--":
|
|
6039
6078
|
break
|
|
6040
6079
|
|
|
6041
|
-
# Non-flag token = command
|
|
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
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
"""
|
|
132
|
-
|
|
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", "--
|
|
208
|
+
r = app.test(["run", "--hermetic"])
|
|
135
209
|
assert r.exit_code == 1
|
|
136
|
-
assert "unknown flag '--
|
|
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)."""
|
|
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
|