strictcli 0.41.1__tar.gz → 0.42.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {strictcli-0.41.1 → strictcli-0.42.0}/PKG-INFO +10 -5
- {strictcli-0.41.1 → strictcli-0.42.0}/README.md +1 -1
- {strictcli-0.41.1 → strictcli-0.42.0}/pyproject.toml +18 -4
- {strictcli-0.41.1 → strictcli-0.42.0}/strictcli/__init__.py +57 -30
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_coverage.py +155 -15
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_effects.py +6 -3
- {strictcli-0.41.1 → strictcli-0.42.0}/uv.lock +1 -1
- {strictcli-0.41.1 → strictcli-0.42.0}/.claude/settings.json +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/.gitignore +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/.rlsbl/config.json +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/CLAUDE.md +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/LICENSE +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/scripts/add_effect_classification.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/scripts/add_forwarding_declaration.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/scripts/migrate_presence.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/strictcli/py.typed +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/conftest.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/flagship_app.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_arg_decorator_order.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_argv_value_order.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_call.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_command.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_provider.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_check_types.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_choice_records.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_choices.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_claimed_rendering.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_classification.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_config.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_config_fields.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_confirm.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_connection_env.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_constraints.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_context.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_dry_run_unsupported.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_dump_schema.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_e2e.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_effects_bypass_check.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_env.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_flagship_preview.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_flat_pre_typed.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_float_format.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_float_type.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_float_vectors.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_guard_v2.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_help.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_infra_env.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_int_type.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_invoke.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_machine_mode.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_mcp.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_member_spelling.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_nesting.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_owns_stdout.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_parser.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_payload_schema.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_presence.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_provenance.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_record_pre_typed.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_registration.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_reserved_quartet.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_selectors.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_trace_store.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_typed_args.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_ulid_vectors.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_unique.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_update.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_utilities.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_validate.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_value_sweep_order.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_variadic.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/tests/test_visibility.py +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.41.1 → strictcli-0.42.0}/todo/.done/public-check-runner-api.md +0 -0
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: strictcli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: A
|
|
5
|
-
Project-URL: Homepage, https://
|
|
3
|
+
Version: 0.42.0
|
|
4
|
+
Summary: A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation)
|
|
5
|
+
Project-URL: Homepage, https://smmh.dev/strictcli/
|
|
6
|
+
Project-URL: Documentation, https://smmh.dev/strictcli/
|
|
6
7
|
Project-URL: Repository, https://github.com/smm-h/strictcli
|
|
8
|
+
Project-URL: Issues, https://github.com/smm-h/strictcli/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/smm-h/strictcli/blob/main/CHANGELOG.md
|
|
7
10
|
Author-email: "S. M. Hosseini" <m.hosseini@veliu.com>
|
|
8
11
|
License-Expression: MIT
|
|
9
12
|
License-File: LICENSE
|
|
10
|
-
Keywords: argparse,cli,command-line,framework,rlsbl,strictcli
|
|
13
|
+
Keywords: argparse,argument-parser,cli,cli-framework,command-line,framework,rlsbl,strictcli
|
|
11
14
|
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Console
|
|
12
16
|
Classifier: Intended Audience :: Developers
|
|
13
17
|
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
14
19
|
Classifier: Programming Language :: Python :: 3
|
|
15
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
21
|
Requires-Python: >=3.11
|
|
@@ -19,7 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
19
24
|
|
|
20
25
|
# strictcli
|
|
21
26
|
|
|
22
|
-
A
|
|
27
|
+
A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation).
|
|
23
28
|
|
|
24
29
|
strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Four types only: `str`, `bool`, `int`, `float`. No magic type inference, no implicit defaults.
|
|
25
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# strictcli
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation).
|
|
4
4
|
|
|
5
5
|
strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Four types only: `str`, `bool`, `int`, `float`. No magic type inference, no implicit defaults.
|
|
6
6
|
|
|
@@ -4,17 +4,28 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "strictcli"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "A
|
|
7
|
+
version = "0.42.0"
|
|
8
|
+
description = "A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.11"
|
|
12
12
|
authors = [{ name = "S. M. Hosseini", email = "m.hosseini@veliu.com" }]
|
|
13
|
-
keywords = [
|
|
13
|
+
keywords = [
|
|
14
|
+
"strictcli",
|
|
15
|
+
"cli",
|
|
16
|
+
"cli-framework",
|
|
17
|
+
"command-line",
|
|
18
|
+
"argument-parser",
|
|
19
|
+
"argparse",
|
|
20
|
+
"framework",
|
|
21
|
+
"rlsbl",
|
|
22
|
+
]
|
|
14
23
|
classifiers = [
|
|
15
24
|
"Development Status :: 3 - Alpha",
|
|
25
|
+
"Environment :: Console",
|
|
16
26
|
"Intended Audience :: Developers",
|
|
17
27
|
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
18
29
|
"Programming Language :: Python :: 3",
|
|
19
30
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
31
|
]
|
|
@@ -29,5 +40,8 @@ dev = ["pytest", "allpairspy", "jsonschema"]
|
|
|
29
40
|
testpaths = ["tests"]
|
|
30
41
|
|
|
31
42
|
[project.urls]
|
|
32
|
-
Homepage = "https://
|
|
43
|
+
Homepage = "https://smmh.dev/strictcli/"
|
|
44
|
+
Documentation = "https://smmh.dev/strictcli/"
|
|
33
45
|
Repository = "https://github.com/smm-h/strictcli"
|
|
46
|
+
Issues = "https://github.com/smm-h/strictcli/issues"
|
|
47
|
+
Changelog = "https://github.com/smm-h/strictcli/blob/main/CHANGELOG.md"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "0.
|
|
5
|
+
__version__ = "0.42.0"
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"App", "Flag", "Arg", "FlagSet",
|
|
@@ -8724,6 +8724,17 @@ def warn_check_spec(
|
|
|
8724
8724
|
)
|
|
8725
8725
|
|
|
8726
8726
|
|
|
8727
|
+
def _msg_test_coverage_boolean_retired() -> str:
|
|
8728
|
+
"""The retired boolean's refusal (contract §12.12: one sentence, each
|
|
8729
|
+
language's own spellings inside it -- `test_coverage` / `test_coverage_dir`
|
|
8730
|
+
here, `WithTestCoverage` / `WithTestCoverageDir` in Go, `testCoverage` /
|
|
8731
|
+
`testCoverageDir` in TypeScript)."""
|
|
8732
|
+
return (
|
|
8733
|
+
"test_coverage is not accepted; declare the directory holding "
|
|
8734
|
+
"coverage/ and test-coverage.json with test_coverage_dir"
|
|
8735
|
+
)
|
|
8736
|
+
|
|
8737
|
+
|
|
8727
8738
|
@dataclass
|
|
8728
8739
|
class App:
|
|
8729
8740
|
"""The root CLI application."""
|
|
@@ -8761,13 +8772,25 @@ class App:
|
|
|
8761
8772
|
proc_observe_allowlist: list[list[str]] | None = None
|
|
8762
8773
|
checks_path: str | Path | None = None
|
|
8763
8774
|
checks_embed: bytes | None = None
|
|
8764
|
-
|
|
8775
|
+
# The directory holding this app's coverage state: `coverage/` (per-process
|
|
8776
|
+
# shard files) and `test-coverage.json` (the committed manifest). Declared,
|
|
8777
|
+
# never discovered. Absent means coverage is off -- no provider registered,
|
|
8778
|
+
# no paths computed, nothing touched. A declared directory that does not
|
|
8779
|
+
# exist at construction likewise leaves coverage off: that is the installed
|
|
8780
|
+
# distribution, where the path naming the source checkout is simply gone.
|
|
8781
|
+
test_coverage_dir: str | os.PathLike | None = None
|
|
8782
|
+
# Refusal-only. The retired boolean has no accepted spelling; this field
|
|
8783
|
+
# exists so that passing it names the option that replaced it instead of
|
|
8784
|
+
# raising CPython's bare "unexpected keyword argument" TypeError.
|
|
8785
|
+
test_coverage: bool | None = None
|
|
8765
8786
|
flags: list[Flag] = field(default_factory=list)
|
|
8766
8787
|
_commands: dict[str, Command] = field(default_factory=dict)
|
|
8767
8788
|
_groups: dict[str, Group] = field(default_factory=dict)
|
|
8768
8789
|
_deprecated: dict[str, DeprecatedCommand] = field(default_factory=dict)
|
|
8769
8790
|
|
|
8770
8791
|
def __post_init__(self) -> None:
|
|
8792
|
+
if self.test_coverage is not None:
|
|
8793
|
+
raise ValueError(_msg_test_coverage_boolean_retired())
|
|
8771
8794
|
_require_non_empty_str(self.version, "version", "App")
|
|
8772
8795
|
_require_non_empty_str(self.help, "help", "App")
|
|
8773
8796
|
# Check for duplicate and reserved global flag names
|
|
@@ -8973,23 +8996,27 @@ class App:
|
|
|
8973
8996
|
self._coverage_dir: str | None = None
|
|
8974
8997
|
self._coverage_manifest_path: str | None = None
|
|
8975
8998
|
self._last_resolved_path: list[str] = []
|
|
8976
|
-
if self.
|
|
8977
|
-
#
|
|
8978
|
-
#
|
|
8979
|
-
#
|
|
8980
|
-
#
|
|
8981
|
-
|
|
8982
|
-
|
|
8983
|
-
|
|
8984
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
8988
|
-
self._coverage_dir,
|
|
8989
|
-
|
|
8990
|
-
|
|
8991
|
-
|
|
8992
|
-
|
|
8999
|
+
if self.test_coverage_dir is not None:
|
|
9000
|
+
# The declared directory decides everything. An app installed
|
|
9001
|
+
# elsewhere finds it absent and stays uninstrumented: no provider,
|
|
9002
|
+
# no paths, no writes anywhere -- in particular not into whatever
|
|
9003
|
+
# directory the consumer happened to start the CLI from.
|
|
9004
|
+
root = os.path.abspath(os.fspath(self.test_coverage_dir))
|
|
9005
|
+
if os.path.isdir(root):
|
|
9006
|
+
# Only the PATHS are computed here. The coverage directory
|
|
9007
|
+
# itself is created lazily by _record_coverage, immediately
|
|
9008
|
+
# before the first shard write: shards are written only on the
|
|
9009
|
+
# test-harness paths (test() and call()), so a plain CLI
|
|
9010
|
+
# invocation leaves no coverage/ behind.
|
|
9011
|
+
self._coverage_dir = os.path.join(root, "coverage")
|
|
9012
|
+
self._coverage_manifest_path = os.path.join(
|
|
9013
|
+
root, "test-coverage.json"
|
|
9014
|
+
)
|
|
9015
|
+
self._coverage_shard_path = os.path.join(
|
|
9016
|
+
self._coverage_dir,
|
|
9017
|
+
f"{os.getpid()}.jsonl",
|
|
9018
|
+
)
|
|
9019
|
+
self.register_check_provider(self._test_coverage_provider)
|
|
8993
9020
|
|
|
8994
9021
|
def _validate_flag_infra_marker(self, f: Flag) -> None:
|
|
8995
9022
|
"""Panic if a flag's default is a RelativeToRoot marker referencing an
|
|
@@ -9069,19 +9096,19 @@ class App:
|
|
|
9069
9096
|
def _test_coverage_provider(self) -> list[CheckSpec]:
|
|
9070
9097
|
"""Built-in check provider for cli-test-coverage.
|
|
9071
9098
|
|
|
9072
|
-
Registered automatically when
|
|
9073
|
-
from committed state: the covered set is
|
|
9074
|
-
manifest (
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9099
|
+
Registered automatically when test_coverage_dir names a directory that
|
|
9100
|
+
exists. The verdict is derived from committed state: the covered set is
|
|
9101
|
+
the union of the committed manifest (test-coverage.json in that
|
|
9102
|
+
directory) and any per-process shard files merged from its coverage/
|
|
9103
|
+
subdirectory. Every live registered command path (minus the injected
|
|
9104
|
+
check command) must be present in that union to pass; otherwise the
|
|
9105
|
+
check fails naming each uncovered command.
|
|
9078
9106
|
|
|
9079
9107
|
Because the verdict reads the committed manifest, it is deterministic on
|
|
9080
9108
|
every machine -- a machine that never ran the suite (no local shards)
|
|
9081
9109
|
still gets a stable verdict from the committed manifest alone. Both the
|
|
9082
|
-
coverage dir and the manifest path
|
|
9083
|
-
|
|
9084
|
-
the app's own repo state.
|
|
9110
|
+
coverage dir and the manifest path sit under the DECLARED directory, so
|
|
9111
|
+
the check evaluated from any cwd reads the app's own repo state.
|
|
9085
9112
|
|
|
9086
9113
|
The manifest is rewritten as the monotonic union of its prior contents
|
|
9087
9114
|
and the freshly merged shards, but ONLY when that content actually
|
|
@@ -11510,7 +11537,7 @@ class App:
|
|
|
11510
11537
|
self._begin_dispatch()
|
|
11511
11538
|
cmd_path = ".".join(self._last_resolved_path + [cmd.name])
|
|
11512
11539
|
# Record test-coverage hit (command-level only, test mode only).
|
|
11513
|
-
if mode == "test" and self.
|
|
11540
|
+
if mode == "test" and self._coverage_shard_path is not None:
|
|
11514
11541
|
self._record_coverage(cmd_path)
|
|
11515
11542
|
# Store sources for function handlers that need provenance info
|
|
11516
11543
|
self._last_sources = sources
|
|
@@ -11777,7 +11804,7 @@ class App:
|
|
|
11777
11804
|
|
|
11778
11805
|
self._begin_dispatch()
|
|
11779
11806
|
# Record test-coverage hit (command-level only).
|
|
11780
|
-
if self.
|
|
11807
|
+
if self._coverage_shard_path is not None:
|
|
11781
11808
|
self._record_coverage(command_path)
|
|
11782
11809
|
|
|
11783
11810
|
# Passthrough commands: forward raw args to the passthrough handler
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""Tests for the cli-test-coverage mechanism.
|
|
2
2
|
|
|
3
3
|
Verifies that:
|
|
4
|
-
-
|
|
5
|
-
- test() and call() both record to per-process shard files
|
|
4
|
+
- test_coverage_dir=<existing dir> enables recording of command hits
|
|
5
|
+
- test() and call() both record to per-process shard files under that directory
|
|
6
6
|
- The cli-test-coverage check merges shards, compares against the command
|
|
7
7
|
surface, and FAILs listing uncovered commands
|
|
8
8
|
- Full coverage produces a PASS
|
|
9
9
|
- Empty/stale manifest is a hard error
|
|
10
|
+
- A declared directory that does not exist leaves the instrumentation off
|
|
11
|
+
- The retired boolean is refused by name
|
|
10
12
|
"""
|
|
11
13
|
|
|
12
14
|
import json
|
|
@@ -24,12 +26,18 @@ class SimpleCtx:
|
|
|
24
26
|
project_root: Path
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _coverage_root(tmp_path):
|
|
30
|
+
"""The declared coverage directory, created so the option takes effect."""
|
|
31
|
+
root = tmp_path / ".strictcli"
|
|
32
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return root
|
|
34
|
+
|
|
35
|
+
|
|
27
36
|
def _make_app(tmp_path):
|
|
28
|
-
"""Build a 3-command app
|
|
29
|
-
os.chdir(tmp_path)
|
|
37
|
+
"""Build a 3-command app whose coverage directory is tmp_path/.strictcli."""
|
|
30
38
|
app = strictcli.App(
|
|
31
39
|
name="coverapp", version="1.0.0", help="coverage test app",
|
|
32
|
-
|
|
40
|
+
test_coverage_dir=str(_coverage_root(tmp_path)),
|
|
33
41
|
)
|
|
34
42
|
|
|
35
43
|
@app.command(name="deploy", effect="read_only", forwarding=strictcli.Forwarding(reason="test handler absorbs global flag values"), help="deploy the app")
|
|
@@ -50,10 +58,9 @@ def _make_app(tmp_path):
|
|
|
50
58
|
|
|
51
59
|
def _make_grouped_app(tmp_path):
|
|
52
60
|
"""Build an app with grouped commands for dotted-path coverage."""
|
|
53
|
-
os.chdir(tmp_path)
|
|
54
61
|
app = strictcli.App(
|
|
55
62
|
name="grpapp", version="1.0.0", help="grouped coverage test",
|
|
56
|
-
|
|
63
|
+
test_coverage_dir=str(_coverage_root(tmp_path)),
|
|
57
64
|
)
|
|
58
65
|
|
|
59
66
|
grp = app.group("infra", help="infrastructure commands")
|
|
@@ -206,6 +213,7 @@ class TestCoverageCheck:
|
|
|
206
213
|
when NEITHER a manifest NOR any shards exist."""
|
|
207
214
|
app = _make_app(tmp_path)
|
|
208
215
|
manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
|
|
216
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
217
|
manifest_path.write_text("[]\n")
|
|
210
218
|
|
|
211
219
|
results, _, code = app.run_checks(
|
|
@@ -272,20 +280,20 @@ class TestCoverageCheck:
|
|
|
272
280
|
|
|
273
281
|
|
|
274
282
|
class TestCoverageChdirSafety:
|
|
275
|
-
def
|
|
276
|
-
"""test() records into the
|
|
277
|
-
|
|
278
|
-
app = _make_app(tmp_path) #
|
|
283
|
+
def test_record_anchored_to_declared_directory(self, tmp_path):
|
|
284
|
+
"""test() records into the declared coverage dir, not the cwd that a
|
|
285
|
+
test happened to chdir into."""
|
|
286
|
+
app = _make_app(tmp_path) # declares tmp_path/.strictcli
|
|
279
287
|
other = tmp_path / "elsewhere"
|
|
280
288
|
other.mkdir()
|
|
281
289
|
os.chdir(other)
|
|
282
290
|
|
|
283
291
|
app.test(["deploy"])
|
|
284
292
|
|
|
285
|
-
|
|
293
|
+
declared_shards = list(
|
|
286
294
|
(tmp_path / ".strictcli" / "coverage").glob("*.jsonl")
|
|
287
295
|
)
|
|
288
|
-
assert
|
|
296
|
+
assert declared_shards, "shard must be written under the declared dir"
|
|
289
297
|
foreign = other / ".strictcli" / "coverage"
|
|
290
298
|
assert not foreign.exists(), "must not record into the chdir'd cwd"
|
|
291
299
|
|
|
@@ -296,6 +304,7 @@ class TestManifestUnionVerdict:
|
|
|
296
304
|
with no shard files (the machine never ran the suite)."""
|
|
297
305
|
app = _make_app(tmp_path)
|
|
298
306
|
manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
|
|
307
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
299
308
|
manifest_path.write_text(
|
|
300
309
|
json.dumps(["build", "deploy", "status"], indent=2) + "\n"
|
|
301
310
|
)
|
|
@@ -309,9 +318,10 @@ class TestManifestUnionVerdict:
|
|
|
309
318
|
|
|
310
319
|
def test_check_from_foreign_cwd_reads_app_state(self, tmp_path):
|
|
311
320
|
"""The check evaluated from a foreign cwd reads the app's own repo state
|
|
312
|
-
(
|
|
321
|
+
(the declared manifest), not the foreign directory."""
|
|
313
322
|
app = _make_app(tmp_path)
|
|
314
323
|
manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
|
|
324
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
315
325
|
manifest_path.write_text(
|
|
316
326
|
json.dumps(["build", "deploy", "status"], indent=2) + "\n"
|
|
317
327
|
)
|
|
@@ -330,6 +340,7 @@ class TestManifestUnionVerdict:
|
|
|
330
340
|
"""A run recording only a subset keeps prior commands covered (union)."""
|
|
331
341
|
app = _make_app(tmp_path)
|
|
332
342
|
manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
|
|
343
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
333
344
|
manifest_path.write_text(
|
|
334
345
|
json.dumps(["build", "deploy", "status"], indent=2) + "\n"
|
|
335
346
|
)
|
|
@@ -360,7 +371,7 @@ class TestManifestUnionVerdict:
|
|
|
360
371
|
|
|
361
372
|
class TestCoverageDisabled:
|
|
362
373
|
def test_no_recording_when_disabled(self, tmp_path):
|
|
363
|
-
"""
|
|
374
|
+
"""An undeclared test_coverage_dir (the default) produces no shards."""
|
|
364
375
|
os.chdir(tmp_path)
|
|
365
376
|
app = strictcli.App(
|
|
366
377
|
name="nocover", version="1.0.0", help="no coverage",
|
|
@@ -374,3 +385,132 @@ class TestCoverageDisabled:
|
|
|
374
385
|
|
|
375
386
|
coverage_dir = tmp_path / ".strictcli" / "coverage"
|
|
376
387
|
assert not coverage_dir.exists()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class TestCoverageDirectoryIsLazy:
|
|
391
|
+
def test_construction_leaves_no_coverage_subdirectory(self, tmp_path):
|
|
392
|
+
"""Constructing an app with a declared coverage directory must not
|
|
393
|
+
create coverage/ inside it -- a plain CLI invocation never records
|
|
394
|
+
coverage, so it must not plant an empty directory."""
|
|
395
|
+
app = _make_app(tmp_path)
|
|
396
|
+
assert app is not None
|
|
397
|
+
assert not (tmp_path / ".strictcli" / "coverage").exists()
|
|
398
|
+
|
|
399
|
+
def test_recording_creates_the_directory(self, tmp_path):
|
|
400
|
+
"""The recorder creates the coverage directory immediately before the
|
|
401
|
+
first shard write."""
|
|
402
|
+
app = _make_app(tmp_path)
|
|
403
|
+
app.test(["deploy"])
|
|
404
|
+
|
|
405
|
+
shard = tmp_path / ".strictcli" / "coverage" / f"{os.getpid()}.jsonl"
|
|
406
|
+
assert shard.is_file()
|
|
407
|
+
|
|
408
|
+
def test_check_skips_when_coverage_state_absent(self, tmp_path):
|
|
409
|
+
"""The provider reads a declared root holding no coverage state without
|
|
410
|
+
raising -- it reports the subject-matter SKIP."""
|
|
411
|
+
app = _make_app(tmp_path)
|
|
412
|
+
assert not (tmp_path / ".strictcli" / "coverage").exists()
|
|
413
|
+
|
|
414
|
+
results, _, _code = app.run_checks(
|
|
415
|
+
SimpleCtx(project_root=tmp_path),
|
|
416
|
+
run_all=True,
|
|
417
|
+
)
|
|
418
|
+
cov_result = next(r for r in results if r.name == "cli-test-coverage")
|
|
419
|
+
assert cov_result.status == "skip"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class TestDeclaredDirectoryAbsent:
|
|
423
|
+
"""A declared directory that does not exist leaves coverage off.
|
|
424
|
+
|
|
425
|
+
This is the installed-wheel case: the path that names the source
|
|
426
|
+
checkout's ``.strictcli`` directory is simply not there once the CLI is
|
|
427
|
+
installed elsewhere, so the app registers no check, computes no paths and
|
|
428
|
+
creates nothing.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
def test_missing_directory_registers_no_check(self, tmp_path):
|
|
432
|
+
missing = tmp_path / "nowhere" / ".strictcli"
|
|
433
|
+
app = strictcli.App(
|
|
434
|
+
name="coverapp", version="1.0.0", help="coverage test app",
|
|
435
|
+
test_coverage_dir=str(missing),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
@app.command(name="deploy", effect="read_only", help="deploy the app")
|
|
439
|
+
def cmd_deploy(ctx):
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
# No provider, so the check system never turns on: there is no `check`
|
|
443
|
+
# command to route to, and no cli-test-coverage to list.
|
|
444
|
+
result = app.test(["check", "--all"])
|
|
445
|
+
assert result.exit_code == 1
|
|
446
|
+
assert "cli-test-coverage" not in result.stdout
|
|
447
|
+
with pytest.raises(ValueError, match="checks are not enabled"):
|
|
448
|
+
app.run_checks(SimpleCtx(project_root=tmp_path), run_all=True)
|
|
449
|
+
|
|
450
|
+
def test_missing_directory_creates_nothing(self, tmp_path):
|
|
451
|
+
missing = tmp_path / "nowhere" / ".strictcli"
|
|
452
|
+
app = strictcli.App(
|
|
453
|
+
name="coverapp", version="1.0.0", help="coverage test app",
|
|
454
|
+
test_coverage_dir=str(missing),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
@app.command(name="deploy", effect="read_only", help="deploy the app")
|
|
458
|
+
def cmd_deploy(ctx):
|
|
459
|
+
pass
|
|
460
|
+
|
|
461
|
+
app.test(["deploy"])
|
|
462
|
+
app.call("deploy")
|
|
463
|
+
|
|
464
|
+
assert not (tmp_path / "nowhere").exists()
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class TestForeignDirectoryRun:
|
|
468
|
+
"""A consumer-style run from a foreign directory.
|
|
469
|
+
|
|
470
|
+
The installed CLI is started in some unrelated project. Its declared
|
|
471
|
+
coverage directory does not exist there, so `check` must neither list
|
|
472
|
+
cli-test-coverage nor write a `.strictcli/` into the foreign directory.
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
def test_foreign_run_lists_no_check_and_touches_nothing(self, tmp_path):
|
|
476
|
+
foreign = tmp_path / "some-other-project"
|
|
477
|
+
foreign.mkdir()
|
|
478
|
+
os.chdir(foreign)
|
|
479
|
+
|
|
480
|
+
app = strictcli.App(
|
|
481
|
+
name="coverapp", version="1.0.0", help="coverage test app",
|
|
482
|
+
test_coverage_dir=str(tmp_path / "gone" / ".strictcli"),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
@app.command(name="deploy", effect="read_only", help="deploy the app")
|
|
486
|
+
def cmd_deploy(ctx):
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
result = app.test(["check", "--all"])
|
|
490
|
+
assert "cli-test-coverage" not in result.stdout
|
|
491
|
+
|
|
492
|
+
assert not (foreign / ".strictcli").exists()
|
|
493
|
+
assert list(foreign.iterdir()) == []
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class TestRetiredBooleanRefused:
|
|
497
|
+
def test_boolean_true_is_refused_naming_the_directory_option(self):
|
|
498
|
+
with pytest.raises(ValueError) as exc:
|
|
499
|
+
strictcli.App(
|
|
500
|
+
name="coverapp", version="1.0.0", help="coverage test app",
|
|
501
|
+
test_coverage=True,
|
|
502
|
+
)
|
|
503
|
+
assert str(exc.value) == (
|
|
504
|
+
"test_coverage is not accepted; declare the directory holding "
|
|
505
|
+
"coverage/ and test-coverage.json with test_coverage_dir"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def test_boolean_false_is_refused_too(self):
|
|
509
|
+
"""There is no accepted spelling of the retired option, not even the
|
|
510
|
+
one that used to mean "off" -- absence is how coverage is declared off."""
|
|
511
|
+
with pytest.raises(ValueError) as exc:
|
|
512
|
+
strictcli.App(
|
|
513
|
+
name="coverapp", version="1.0.0", help="coverage test app",
|
|
514
|
+
test_coverage=False,
|
|
515
|
+
)
|
|
516
|
+
assert "test_coverage_dir" in str(exc.value)
|
|
@@ -1147,7 +1147,8 @@ class TestCacheWrites:
|
|
|
1147
1147
|
|
|
1148
1148
|
def test_cache_writes_never_appear_in_the_would_do_log(self, tmp_path, monkeypatch):
|
|
1149
1149
|
monkeypatch.chdir(tmp_path)
|
|
1150
|
-
|
|
1150
|
+
(tmp_path / ".strictcli").mkdir()
|
|
1151
|
+
app = _app(test_coverage_dir=str(tmp_path / ".strictcli"))
|
|
1151
1152
|
|
|
1152
1153
|
@app.command("run", help="run", effect="read_only")
|
|
1153
1154
|
def _run(ctx):
|
|
@@ -1166,7 +1167,8 @@ class TestCacheWrites:
|
|
|
1166
1167
|
would move user-visible numbering for no visible reason.
|
|
1167
1168
|
"""
|
|
1168
1169
|
monkeypatch.chdir(tmp_path)
|
|
1169
|
-
|
|
1170
|
+
(tmp_path / ".strictcli").mkdir()
|
|
1171
|
+
app = _app(test_coverage_dir=str(tmp_path / ".strictcli"))
|
|
1170
1172
|
|
|
1171
1173
|
@app.command("rel", help="rel", effect="mutating")
|
|
1172
1174
|
def _rel(ctx):
|
|
@@ -1189,7 +1191,8 @@ class TestCacheWrites:
|
|
|
1189
1191
|
def test_cache_writes_do_not_shift_the_truncation_step(self, tmp_path,
|
|
1190
1192
|
monkeypatch):
|
|
1191
1193
|
monkeypatch.chdir(tmp_path)
|
|
1192
|
-
|
|
1194
|
+
(tmp_path / ".strictcli").mkdir()
|
|
1195
|
+
app = _app(test_coverage_dir=str(tmp_path / ".strictcli"))
|
|
1193
1196
|
|
|
1194
1197
|
@app.command("rel", help="rel", effect="mutating")
|
|
1195
1198
|
def _rel(ctx):
|
|
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
|
|
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
|