strictcli 0.32.1__tar.gz → 0.33.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.32.1 → strictcli-0.33.0}/PKG-INFO +1 -1
- {strictcli-0.32.1 → strictcli-0.33.0}/pyproject.toml +1 -1
- {strictcli-0.32.1 → strictcli-0.33.0}/strictcli/__init__.py +216 -31
- strictcli-0.33.0/tests/test_connection_env.py +269 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_coverage.py +3 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_float_type.py +36 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_int_type.py +25 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_typed_args.py +34 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/uv.lock +1 -1
- {strictcli-0.32.1 → strictcli-0.33.0}/.claude/settings.json +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.gitignore +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.rlsbl/config.json +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.rlsbl/version +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/.strictcli/schema.json +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/CLAUDE.md +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/LICENSE +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/README.md +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/strictcli/py.typed +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/conftest.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_call.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_check_command.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_check_provider.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_check_types.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_choices.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_config.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_config_fields.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_context.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_dump_schema.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_e2e.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_env.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_float_format.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_float_vectors.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_help.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_infra_env.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_invoke.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_mcp.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_mutex.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_nesting.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_parser.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_provenance.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_registration.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_unique.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_utilities.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_validate.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_variadic.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/tests/test_visibility.py +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.32.1 → strictcli-0.33.0}/todo/.done/public-check-runner-api.md +0 -0
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "0.
|
|
5
|
+
__version__ = "0.33.0"
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
|
|
9
9
|
"Implies", "Passthrough", "DeprecatedCommand", "Result", "InvokeError",
|
|
10
10
|
"flag", "arg",
|
|
11
|
-
"CheckContext", "CheckRunResult",
|
|
11
|
+
"CheckContext", "ConnectionEnvReader", "CheckRunResult",
|
|
12
12
|
"ErrorReporter", "WarnReporter", "SkipCheck",
|
|
13
13
|
"CheckSpec", "error_check_spec", "warn_check_spec",
|
|
14
14
|
"format_check_results", "format_check_results_json",
|
|
@@ -99,6 +99,33 @@ def _resolve_infra_root_path(ref: RelativeToRoot, roots: dict[str, str]) -> str:
|
|
|
99
99
|
return os.path.join(root, *ref.parts)
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
def _validate_connection_binding(f: "Flag", connection_env_names) -> None:
|
|
103
|
+
"""Enforce the connection-URL binding rules at registration time (mechanical
|
|
104
|
+
enforcement, not review). A URL-class flag must bind to a declared connection
|
|
105
|
+
env; the binding drives env resolution by reusing the per-flag env channel
|
|
106
|
+
(connection_env is folded into env)."""
|
|
107
|
+
if not f.connection_url and f.connection_env is None:
|
|
108
|
+
return
|
|
109
|
+
if f.connection_env is not None and f.env is not None and f.env != f.connection_env:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f'flag "{f.name}": a connection-URL binding cannot be combined with a per-flag env var'
|
|
112
|
+
)
|
|
113
|
+
if f.connection_url and f.connection_env is None:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f'flag "{f.name}": connection-URL flag must bind to a declared connection env'
|
|
116
|
+
)
|
|
117
|
+
if f.connection_env is not None and not f.connection_url:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f'flag "{f.name}": connection env binding requires the flag to be marked as a connection-URL flag'
|
|
120
|
+
)
|
|
121
|
+
if f.connection_env not in connection_env_names:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f'flag "{f.name}": connection-URL flag binds to undeclared connection env '
|
|
124
|
+
f'"{f.connection_env}"; declare it as a connection env'
|
|
125
|
+
)
|
|
126
|
+
f.env = f.connection_env
|
|
127
|
+
|
|
128
|
+
|
|
102
129
|
# ---------------------------------------------------------------------------
|
|
103
130
|
# Source provenance (Phase 0c)
|
|
104
131
|
# ---------------------------------------------------------------------------
|
|
@@ -193,13 +220,17 @@ class _SourcedStore:
|
|
|
193
220
|
|
|
194
221
|
class _InfraAccess:
|
|
195
222
|
"""A Context's view of infrastructure env vars: resolved root values
|
|
196
|
-
(captured at construction)
|
|
223
|
+
(captured at construction), declared handshake env vars (read live), and
|
|
224
|
+
declared connection env vars (read live, but suppressed under --hermetic)."""
|
|
197
225
|
|
|
198
|
-
__slots__ = ("roots", "handshakes")
|
|
226
|
+
__slots__ = ("roots", "handshakes", "connections", "hermetic")
|
|
199
227
|
|
|
200
|
-
def __init__(self, roots: dict[str, str], handshakes: set[str]
|
|
228
|
+
def __init__(self, roots: dict[str, str], handshakes: set[str],
|
|
229
|
+
connections: set[str] | None = None, hermetic: bool = False) -> None:
|
|
201
230
|
self.roots = roots
|
|
202
231
|
self.handshakes = handshakes
|
|
232
|
+
self.connections = connections or set()
|
|
233
|
+
self.hermetic = hermetic
|
|
203
234
|
|
|
204
235
|
|
|
205
236
|
class Context:
|
|
@@ -260,7 +291,13 @@ class Context:
|
|
|
260
291
|
LIVE at call time (handshakes are set by the invoking process and carry
|
|
261
292
|
no construction-time value), returning ``(value, is_set)``.
|
|
262
293
|
|
|
263
|
-
|
|
294
|
+
For a declared connection env (``connection_env``), reads the environment
|
|
295
|
+
LIVE at call time and returns ``(value, is_set)`` -- EXCEPT under
|
|
296
|
+
--hermetic, where it resolves as absent ``(None, False)`` so
|
|
297
|
+
connection-dependent behavior skips visibly instead of connecting.
|
|
298
|
+
|
|
299
|
+
Raises KeyError if env_var is not a declared root, handshake, or
|
|
300
|
+
connection var.
|
|
264
301
|
"""
|
|
265
302
|
if self._infra is not None:
|
|
266
303
|
if env_var in self._infra.roots:
|
|
@@ -269,8 +306,32 @@ class Context:
|
|
|
269
306
|
if env_var in os.environ:
|
|
270
307
|
return os.environ[env_var], True
|
|
271
308
|
return None, False
|
|
309
|
+
if env_var in self._infra.connections:
|
|
310
|
+
if self._infra.hermetic:
|
|
311
|
+
return None, False
|
|
312
|
+
if env_var in os.environ:
|
|
313
|
+
return os.environ[env_var], True
|
|
314
|
+
return None, False
|
|
272
315
|
raise KeyError(
|
|
273
|
-
f'"{env_var}" is not a declared infra root or
|
|
316
|
+
f'"{env_var}" is not a declared infra root, handshake, or connection env var'
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def connection_env_value(self, env_var: str) -> tuple[str | None, bool]:
|
|
320
|
+
"""Return the value of a declared connection env (``connection_env``),
|
|
321
|
+
read LIVE at call time -- EXCEPT under --hermetic, where it resolves as
|
|
322
|
+
absent ``(None, False)``. Raises KeyError if env_var is not a declared
|
|
323
|
+
connection env. This is the check-side and handler-side accessor for the
|
|
324
|
+
connection-URL kind; see also ``infra_value``, which resolves all three
|
|
325
|
+
kinds.
|
|
326
|
+
"""
|
|
327
|
+
if self._infra is not None and env_var in self._infra.connections:
|
|
328
|
+
if self._infra.hermetic:
|
|
329
|
+
return None, False
|
|
330
|
+
if env_var in os.environ:
|
|
331
|
+
return os.environ[env_var], True
|
|
332
|
+
return None, False
|
|
333
|
+
raise KeyError(
|
|
334
|
+
f'"{env_var}" is not a declared connection env var'
|
|
274
335
|
)
|
|
275
336
|
|
|
276
337
|
|
|
@@ -1341,6 +1402,10 @@ def _strict_int(s: str) -> int:
|
|
|
1341
1402
|
"""
|
|
1342
1403
|
if s != s.strip():
|
|
1343
1404
|
raise ValueError(f"expected integer, got '{s}'")
|
|
1405
|
+
# Python's int() accepts PEP 515 underscore digit separators ('1_000');
|
|
1406
|
+
# Go's strconv and the TypeScript parser reject them. Reject to match canon.
|
|
1407
|
+
if "_" in s:
|
|
1408
|
+
raise ValueError(f"expected integer, got '{s}'")
|
|
1344
1409
|
try:
|
|
1345
1410
|
n = int(s)
|
|
1346
1411
|
except ValueError:
|
|
@@ -1363,7 +1428,13 @@ def _strict_float(s: str) -> float:
|
|
|
1363
1428
|
raise ValueError("NaN is not allowed")
|
|
1364
1429
|
if low in ("inf", "-inf", "+inf", "infinity", "-infinity", "+infinity"):
|
|
1365
1430
|
raise ValueError("Inf is not allowed")
|
|
1366
|
-
|
|
1431
|
+
result = float(s)
|
|
1432
|
+
# A finite literal that overflows to +/-inf ('1e999') is a parse failure,
|
|
1433
|
+
# not an Inf literal. Go and TypeScript reject it as an invalid float; use
|
|
1434
|
+
# the generic "expected float" path (not "Inf is not allowed") to match.
|
|
1435
|
+
if math.isinf(result):
|
|
1436
|
+
raise ValueError(f"invalid literal for float(): {s!r}")
|
|
1437
|
+
return result
|
|
1367
1438
|
|
|
1368
1439
|
|
|
1369
1440
|
def _float_parse_error(
|
|
@@ -1702,6 +1773,14 @@ class Flag:
|
|
|
1702
1773
|
validate: Callable | None = None
|
|
1703
1774
|
repeatable: bool = False
|
|
1704
1775
|
unique: object = _MISSING
|
|
1776
|
+
# Connection-URL binding. connection_url marks this flag as a connection-URL
|
|
1777
|
+
# (URL-class) flag; connection_env names the app-level connection env
|
|
1778
|
+
# (declared via App(connection_env=...)) it binds to. A URL-class flag MUST
|
|
1779
|
+
# bind to a declared connection env (enforced at registration). The binding
|
|
1780
|
+
# is hermetic-suppressed, lazily read, no default; the CLI token wins over
|
|
1781
|
+
# the env (source "cli" vs "env").
|
|
1782
|
+
connection_url: bool = False
|
|
1783
|
+
connection_env: str | None = None
|
|
1705
1784
|
# Per-flag config conflict mode. _MISSING means "inherit the app default".
|
|
1706
1785
|
# When set explicitly, must be "cli-wins" or "error". Applies to flags only:
|
|
1707
1786
|
# standalone ConfigFields have no CLI/env conflict surface, and a
|
|
@@ -2161,6 +2240,7 @@ class Group:
|
|
|
2161
2240
|
hidden: bool = False
|
|
2162
2241
|
_config_fields_ref: dict[str, ConfigField] = field(default_factory=dict)
|
|
2163
2242
|
_infra_root_names: frozenset[str] = frozenset()
|
|
2243
|
+
_connection_env_names: frozenset[str] = frozenset()
|
|
2164
2244
|
|
|
2165
2245
|
def __post_init__(self) -> None:
|
|
2166
2246
|
_require_non_empty_str(self.help, "help", "Group")
|
|
@@ -2186,7 +2266,8 @@ class Group:
|
|
|
2186
2266
|
_accumulated_tags=self._accumulated_tags | own_tags,
|
|
2187
2267
|
hidden=hidden,
|
|
2188
2268
|
_config_fields_ref=self._config_fields_ref,
|
|
2189
|
-
_infra_root_names=self._infra_root_names
|
|
2269
|
+
_infra_root_names=self._infra_root_names,
|
|
2270
|
+
_connection_env_names=self._connection_env_names)
|
|
2190
2271
|
self._groups[name] = grp
|
|
2191
2272
|
return grp
|
|
2192
2273
|
|
|
@@ -2245,6 +2326,7 @@ class Group:
|
|
|
2245
2326
|
config_fields=config_fields,
|
|
2246
2327
|
config_fields_ref=self._config_fields_ref,
|
|
2247
2328
|
infra_root_names=self._infra_root_names,
|
|
2329
|
+
connection_env_names=self._connection_env_names,
|
|
2248
2330
|
)
|
|
2249
2331
|
self.commands[name] = cmd
|
|
2250
2332
|
return func
|
|
@@ -2562,6 +2644,42 @@ class CheckContext(Protocol):
|
|
|
2562
2644
|
project_root: Path
|
|
2563
2645
|
|
|
2564
2646
|
|
|
2647
|
+
class ConnectionEnvReader(Protocol):
|
|
2648
|
+
"""OPTIONAL capability a check context may expose: the value of a declared
|
|
2649
|
+
connection env (``connection_env``), read live -- EXCEPT under --hermetic,
|
|
2650
|
+
where it resolves as absent ``(None, False)`` so a check can skip visibly
|
|
2651
|
+
instead of connecting. The check command wraps the tool-supplied check
|
|
2652
|
+
context in a value that satisfies this protocol, backed by the app's declared
|
|
2653
|
+
connection envs and the invocation's hermetic state. Checks that need a
|
|
2654
|
+
connection URL call ``ctx.connection_env_value("DATABASE_URL")``."""
|
|
2655
|
+
|
|
2656
|
+
def connection_env_value(self, env_var: str) -> "tuple[str | None, bool]": ...
|
|
2657
|
+
|
|
2658
|
+
|
|
2659
|
+
class _CheckContextWithConn:
|
|
2660
|
+
"""Wraps a tool-supplied check context, delegating attribute access while
|
|
2661
|
+
adding connection-env access (hermetic-suppressed) so check functions can
|
|
2662
|
+
read declared connection envs without the tool implementing anything beyond
|
|
2663
|
+
``project_root``."""
|
|
2664
|
+
|
|
2665
|
+
def __init__(self, base, connections: frozenset[str], hermetic: bool) -> None:
|
|
2666
|
+
self._base = base
|
|
2667
|
+
self._connections = connections
|
|
2668
|
+
self._hermetic = hermetic
|
|
2669
|
+
|
|
2670
|
+
def __getattr__(self, name):
|
|
2671
|
+
return getattr(self._base, name)
|
|
2672
|
+
|
|
2673
|
+
def connection_env_value(self, env_var: str) -> "tuple[str | None, bool]":
|
|
2674
|
+
if env_var in self._connections:
|
|
2675
|
+
if self._hermetic:
|
|
2676
|
+
return None, False
|
|
2677
|
+
if env_var in os.environ:
|
|
2678
|
+
return os.environ[env_var], True
|
|
2679
|
+
return None, False
|
|
2680
|
+
raise KeyError(f'"{env_var}" is not a declared connection env var')
|
|
2681
|
+
|
|
2682
|
+
|
|
2565
2683
|
@dataclass
|
|
2566
2684
|
class _CheckDef:
|
|
2567
2685
|
"""Internal definition of a single check loaded from TOML."""
|
|
@@ -2675,6 +2793,11 @@ class App:
|
|
|
2675
2793
|
# protocol env var -> its help string.
|
|
2676
2794
|
infra_root: dict[str, str] | None = None
|
|
2677
2795
|
handshake_env: dict[str, str] | None = None
|
|
2796
|
+
# connection_env maps a behavioral "reach outside the process" env var
|
|
2797
|
+
# (e.g. a database/service URL) -> its help string. Unlike roots and
|
|
2798
|
+
# handshakes it is hermetic-SUPPRESSED: under --hermetic it resolves as
|
|
2799
|
+
# absent. No default, read lazily. Flags bind to it via connection_url=.
|
|
2800
|
+
connection_env: dict[str, str] | None = None
|
|
2678
2801
|
checks_path: str | Path | None = None
|
|
2679
2802
|
checks_embed: bytes | None = None
|
|
2680
2803
|
test_coverage: bool = False
|
|
@@ -2703,6 +2826,7 @@ class App:
|
|
|
2703
2826
|
self._global_flags: list[Flag] = list(self.flags)
|
|
2704
2827
|
self._last_global_values: dict[str, object] = {}
|
|
2705
2828
|
self._last_sources: dict[str, str] = {}
|
|
2829
|
+
self._last_hermetic: bool = False
|
|
2706
2830
|
|
|
2707
2831
|
# Resolve infrastructure roots eagerly, at construction. Infra vars have
|
|
2708
2832
|
# no argv dependency, so resolution is sound here -- and this is WHY it
|
|
@@ -2730,15 +2854,28 @@ class App:
|
|
|
2730
2854
|
raise ValueError(f'handshake env var "{ev}": help must be a non-empty string')
|
|
2731
2855
|
if ev in self._infra_roots:
|
|
2732
2856
|
raise ValueError(f'handshake env var "{ev}" is already declared as an infra root')
|
|
2857
|
+
# Connection env vars: behavioral, hermetic-suppressed, no default.
|
|
2858
|
+
self._connection_envs: dict[str, str] = dict(self.connection_env) if self.connection_env else {}
|
|
2859
|
+
self._connection_order: list[str] = list(self.connection_env.keys()) if self.connection_env else []
|
|
2860
|
+
for ev in self._connection_order:
|
|
2861
|
+
if not self._connection_envs[ev] or not self._connection_envs[ev].strip():
|
|
2862
|
+
raise ValueError(f'connection env var "{ev}": help must be a non-empty string')
|
|
2863
|
+
if ev in self._infra_roots:
|
|
2864
|
+
raise ValueError(f'connection env var "{ev}" is already declared as an infra root')
|
|
2865
|
+
if ev in self._handshake_envs:
|
|
2866
|
+
raise ValueError(f'connection env var "{ev}" is already declared as a handshake env var')
|
|
2867
|
+
self._connection_env_names: frozenset[str] = frozenset(self._connection_envs)
|
|
2733
2868
|
# A shared frozenset of declared root names, threaded to commands/groups
|
|
2734
2869
|
# so flag-default markers can be validated at registration time.
|
|
2735
2870
|
self._infra_root_names: frozenset[str] = frozenset(self._infra_roots)
|
|
2736
2871
|
# Resolve the config-path marker (if any) now that roots exist.
|
|
2737
2872
|
if isinstance(self.config_path, RelativeToRoot):
|
|
2738
2873
|
self.config_path = _resolve_infra_root_path(self.config_path, self._infra_roots)
|
|
2739
|
-
# Validate global flag default markers against declared roots
|
|
2874
|
+
# Validate global flag default markers against declared roots and
|
|
2875
|
+
# connection-URL bindings against declared connection envs.
|
|
2740
2876
|
for f in self._global_flags:
|
|
2741
2877
|
self._validate_flag_infra_marker(f)
|
|
2878
|
+
_validate_connection_binding(f, self._connection_env_names)
|
|
2742
2879
|
|
|
2743
2880
|
# Validate config_format
|
|
2744
2881
|
if self.config_format not in ("json", "toml"):
|
|
@@ -2801,7 +2938,6 @@ class App:
|
|
|
2801
2938
|
# Test-coverage instrumentation. When enabled, every test() and call()
|
|
2802
2939
|
# invocation records which command was dispatched so a check can verify
|
|
2803
2940
|
# that every command in the app's surface has been exercised.
|
|
2804
|
-
self._coverage_shard_counter: int = 0
|
|
2805
2941
|
self._coverage_shard_path: str | None = None
|
|
2806
2942
|
self._coverage_dir: str | None = None
|
|
2807
2943
|
self._coverage_manifest_path: str | None = None
|
|
@@ -2819,7 +2955,7 @@ class App:
|
|
|
2819
2955
|
)
|
|
2820
2956
|
self._coverage_shard_path = os.path.join(
|
|
2821
2957
|
self._coverage_dir,
|
|
2822
|
-
f"{os.getpid()}
|
|
2958
|
+
f"{os.getpid()}.jsonl",
|
|
2823
2959
|
)
|
|
2824
2960
|
os.makedirs(self._coverage_dir, exist_ok=True)
|
|
2825
2961
|
self.register_check_provider(self._test_coverage_provider)
|
|
@@ -2834,27 +2970,30 @@ class App:
|
|
|
2834
2970
|
f'root "{f.default.env_var}"; declare it as an infra root'
|
|
2835
2971
|
)
|
|
2836
2972
|
|
|
2837
|
-
def _infra_access(self) -> "_InfraAccess | None":
|
|
2973
|
+
def _infra_access(self, hermetic: bool = False) -> "_InfraAccess | None":
|
|
2838
2974
|
"""Snapshot infra data for a Context: resolved roots + declared handshake
|
|
2839
|
-
env var names
|
|
2840
|
-
|
|
2975
|
+
env var names + declared connection env var names. Connection envs are
|
|
2976
|
+
suppressed when hermetic is True. Returns None when nothing is declared."""
|
|
2977
|
+
if not self._infra_roots and not self._handshake_envs and not self._connection_envs:
|
|
2841
2978
|
return None
|
|
2842
2979
|
return _InfraAccess(
|
|
2843
2980
|
roots=dict(self._infra_roots),
|
|
2844
2981
|
handshakes=set(self._handshake_envs),
|
|
2982
|
+
connections=set(self._connection_envs),
|
|
2983
|
+
hermetic=hermetic,
|
|
2845
2984
|
)
|
|
2846
2985
|
|
|
2847
2986
|
def _record_coverage(self, cmd_path: str) -> None:
|
|
2848
2987
|
"""Append a coverage record for the resolved command path.
|
|
2849
2988
|
|
|
2850
|
-
Each test() or call() invocation
|
|
2851
|
-
shard file
|
|
2852
|
-
|
|
2853
|
-
|
|
2989
|
+
Each test() or call() invocation appends one JSONL line to the
|
|
2990
|
+
process's shard file (named "<pid>.jsonl"). Uniqueness across concurrent
|
|
2991
|
+
writers comes from the PID and O_APPEND; one shard per process is
|
|
2992
|
+
sufficient, so there is no per-write shard counter.
|
|
2854
2993
|
"""
|
|
2855
2994
|
if self._coverage_shard_path is None:
|
|
2856
2995
|
return
|
|
2857
|
-
path = self._coverage_shard_path
|
|
2996
|
+
path = self._coverage_shard_path
|
|
2858
2997
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
2859
2998
|
with open(path, "a", encoding="utf-8") as f:
|
|
2860
2999
|
f.write(json.dumps({"command": cmd_path}) + "\n")
|
|
@@ -3284,6 +3423,14 @@ class App:
|
|
|
3284
3423
|
"""
|
|
3285
3424
|
self._check_context_factory = factory
|
|
3286
3425
|
|
|
3426
|
+
def _wrap_check_context(self, base):
|
|
3427
|
+
"""Augment a tool-supplied check context with connection-env access
|
|
3428
|
+
(hermetic-suppressed). When no connection envs are declared, the base
|
|
3429
|
+
context is returned unchanged so the common case is unaffected."""
|
|
3430
|
+
if not self._connection_envs:
|
|
3431
|
+
return base
|
|
3432
|
+
return _CheckContextWithConn(base, self._connection_env_names, self._last_hermetic)
|
|
3433
|
+
|
|
3287
3434
|
def set_scope_adapter(self, adapter: Callable) -> None:
|
|
3288
3435
|
"""Set the scope adapter callback for scoped checks.
|
|
3289
3436
|
|
|
@@ -3531,7 +3678,7 @@ class App:
|
|
|
3531
3678
|
file=sys.stderr,
|
|
3532
3679
|
)
|
|
3533
3680
|
return 1
|
|
3534
|
-
context = app_ref._check_context_factory()
|
|
3681
|
+
context = app_ref._wrap_check_context(app_ref._check_context_factory())
|
|
3535
3682
|
# The check command executes all selected checks; the purity
|
|
3536
3683
|
# partition is an API-only mode (run_checks pure_only=), so nothing
|
|
3537
3684
|
# is ever left in the impure listing here.
|
|
@@ -3618,6 +3765,7 @@ class App:
|
|
|
3618
3765
|
config_fields=config_fields,
|
|
3619
3766
|
config_fields_ref=self._config_fields,
|
|
3620
3767
|
infra_root_names=self._infra_root_names,
|
|
3768
|
+
connection_env_names=self._connection_env_names,
|
|
3621
3769
|
)
|
|
3622
3770
|
self._commands[name] = cmd
|
|
3623
3771
|
return func
|
|
@@ -3634,7 +3782,8 @@ class App:
|
|
|
3634
3782
|
_accumulated_tags=own_tags,
|
|
3635
3783
|
hidden=hidden,
|
|
3636
3784
|
_config_fields_ref=self._config_fields,
|
|
3637
|
-
_infra_root_names=self._infra_root_names
|
|
3785
|
+
_infra_root_names=self._infra_root_names,
|
|
3786
|
+
_connection_env_names=self._connection_env_names)
|
|
3638
3787
|
self._groups[name] = grp
|
|
3639
3788
|
return grp
|
|
3640
3789
|
|
|
@@ -3765,8 +3914,8 @@ class App:
|
|
|
3765
3914
|
if not isinstance(cf.default, _MissingSentinel):
|
|
3766
3915
|
entry["default"] = cf.default
|
|
3767
3916
|
result[cf_name] = entry
|
|
3768
|
-
# Infrastructure section (roots + handshakes)
|
|
3769
|
-
if app_ref._infra_root_order or app_ref._handshake_order:
|
|
3917
|
+
# Infrastructure section (roots + handshakes + connections)
|
|
3918
|
+
if app_ref._infra_root_order or app_ref._handshake_order or app_ref._connection_order:
|
|
3770
3919
|
infra: dict = {}
|
|
3771
3920
|
for ev in app_ref._infra_root_order:
|
|
3772
3921
|
infra[ev] = {
|
|
@@ -3784,6 +3933,16 @@ class App:
|
|
|
3784
3933
|
if is_set:
|
|
3785
3934
|
hs_entry["value"] = os.environ[ev]
|
|
3786
3935
|
infra[ev] = hs_entry
|
|
3936
|
+
for ev in app_ref._connection_order:
|
|
3937
|
+
is_set = ev in os.environ
|
|
3938
|
+
conn_entry: dict = {
|
|
3939
|
+
"kind": "connection",
|
|
3940
|
+
"set": is_set,
|
|
3941
|
+
"help": app_ref._connection_envs[ev],
|
|
3942
|
+
}
|
|
3943
|
+
if is_set:
|
|
3944
|
+
conn_entry["value"] = os.environ[ev]
|
|
3945
|
+
infra[ev] = conn_entry
|
|
3787
3946
|
result["__infrastructure__"] = infra
|
|
3788
3947
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
3789
3948
|
return 0
|
|
@@ -3823,8 +3982,8 @@ class App:
|
|
|
3823
3982
|
f" (source: {source})"
|
|
3824
3983
|
f" -- {cf.help}"
|
|
3825
3984
|
)
|
|
3826
|
-
# Infrastructure section (roots + handshakes)
|
|
3827
|
-
if app_ref._infra_root_order or app_ref._handshake_order:
|
|
3985
|
+
# Infrastructure section (roots + handshakes + connections)
|
|
3986
|
+
if app_ref._infra_root_order or app_ref._handshake_order or app_ref._connection_order:
|
|
3828
3987
|
print()
|
|
3829
3988
|
print("Infrastructure:")
|
|
3830
3989
|
for ev in app_ref._infra_root_order:
|
|
@@ -3835,6 +3994,11 @@ class App:
|
|
|
3835
3994
|
print(f" {ev} (handshake) = {os.environ[ev]} (set) -- {app_ref._handshake_envs[ev]}")
|
|
3836
3995
|
else:
|
|
3837
3996
|
print(f" {ev} (handshake) = <unset> -- {app_ref._handshake_envs[ev]}")
|
|
3997
|
+
for ev in app_ref._connection_order:
|
|
3998
|
+
if ev in os.environ:
|
|
3999
|
+
print(f" {ev} (connection) = {os.environ[ev]} (set) -- {app_ref._connection_envs[ev]}")
|
|
4000
|
+
else:
|
|
4001
|
+
print(f" {ev} (connection) = <unset> -- {app_ref._connection_envs[ev]}")
|
|
3838
4002
|
return 0
|
|
3839
4003
|
|
|
3840
4004
|
config_show_flags = [
|
|
@@ -4248,6 +4412,9 @@ class App:
|
|
|
4248
4412
|
raise _ParseError(pre_scan["err"])
|
|
4249
4413
|
|
|
4250
4414
|
is_hermetic = bool(pre_scan.get("hermetic"))
|
|
4415
|
+
# Record for the dispatch ctx: connection env access is suppressed under
|
|
4416
|
+
# --hermetic so connection-dependent behavior (incl. checks) skips.
|
|
4417
|
+
self._last_hermetic = is_hermetic
|
|
4251
4418
|
|
|
4252
4419
|
# --hermetic + --config mutual exclusion
|
|
4253
4420
|
if is_hermetic and pre_scan.get("config_path"):
|
|
@@ -4869,7 +5036,7 @@ class App:
|
|
|
4869
5036
|
sys.exit(1)
|
|
4870
5037
|
else:
|
|
4871
5038
|
self._last_sources = sources
|
|
4872
|
-
ctx = Context(stdout=sys.stdout, stderr=sys.stderr, sources=sources, infra=self._infra_access())
|
|
5039
|
+
ctx = Context(stdout=sys.stdout, stderr=sys.stderr, sources=sources, infra=self._infra_access(self._last_hermetic))
|
|
4873
5040
|
if cmd.passthrough is not None:
|
|
4874
5041
|
result = cmd.passthrough.handler(ctx, cmd.name, data, self._last_global_values)
|
|
4875
5042
|
else:
|
|
@@ -4942,7 +5109,7 @@ class App:
|
|
|
4942
5109
|
self._last_sources = sources
|
|
4943
5110
|
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
|
|
4944
5111
|
try:
|
|
4945
|
-
ctx = Context(stdout=stdout_buf, stderr=stderr_buf, sources=sources, infra=self._infra_access())
|
|
5112
|
+
ctx = Context(stdout=stdout_buf, stderr=stderr_buf, sources=sources, infra=self._infra_access(self._last_hermetic))
|
|
4946
5113
|
if cmd.passthrough is not None:
|
|
4947
5114
|
handler_return = cmd.passthrough.handler(
|
|
4948
5115
|
ctx, cmd.name, data, self._last_global_values,
|
|
@@ -6051,6 +6218,7 @@ def _build_and_validate_command(
|
|
|
6051
6218
|
config_fields: list[str] | None = None,
|
|
6052
6219
|
config_fields_ref: dict[str, ConfigField] | None = None,
|
|
6053
6220
|
infra_root_names: frozenset[str] | None = None,
|
|
6221
|
+
connection_env_names: frozenset[str] | None = None,
|
|
6054
6222
|
) -> Command:
|
|
6055
6223
|
"""Build a Command from a decorated handler, validate everything."""
|
|
6056
6224
|
if not help or not help.strip():
|
|
@@ -6344,6 +6512,11 @@ def _build_and_validate_command(
|
|
|
6344
6512
|
f'undeclared infra root "{f.default.env_var}"; declare it as an infra root'
|
|
6345
6513
|
)
|
|
6346
6514
|
|
|
6515
|
+
# Validate connection-URL bindings against declared connection envs.
|
|
6516
|
+
_conn_names = connection_env_names or frozenset()
|
|
6517
|
+
for f in all_flags:
|
|
6518
|
+
_validate_connection_binding(f, _conn_names)
|
|
6519
|
+
|
|
6347
6520
|
return Command(
|
|
6348
6521
|
name=name,
|
|
6349
6522
|
help=help,
|
|
@@ -6376,6 +6549,8 @@ def flag(
|
|
|
6376
6549
|
repeatable: bool = False,
|
|
6377
6550
|
unique: object = _MISSING,
|
|
6378
6551
|
conflict_mode: object = _MISSING,
|
|
6552
|
+
connection_url: bool = False,
|
|
6553
|
+
connection_env: str | None = None,
|
|
6379
6554
|
) -> Callable[[F], F]:
|
|
6380
6555
|
"""Module-level decorator to attach a Flag to a command handler."""
|
|
6381
6556
|
|
|
@@ -6395,6 +6570,8 @@ def flag(
|
|
|
6395
6570
|
repeatable=repeatable,
|
|
6396
6571
|
unique=unique,
|
|
6397
6572
|
conflict_mode=conflict_mode,
|
|
6573
|
+
connection_url=connection_url,
|
|
6574
|
+
connection_env=connection_env,
|
|
6398
6575
|
)
|
|
6399
6576
|
if not hasattr(func, "_strictcli_flags"):
|
|
6400
6577
|
func._strictcli_flags = []
|
|
@@ -6489,11 +6666,11 @@ def _format_app_help(app: App) -> str:
|
|
|
6489
6666
|
padding = max_flag_len - len(flag_str) + 4
|
|
6490
6667
|
lines.append(f" {flag_str}{' ' * padding}{help_text}")
|
|
6491
6668
|
|
|
6492
|
-
if app._infra_root_order or app._handshake_order:
|
|
6669
|
+
if app._infra_root_order or app._handshake_order or app._connection_order:
|
|
6493
6670
|
lines.append("")
|
|
6494
6671
|
lines.append("Infrastructure:")
|
|
6495
6672
|
lines.append(" (location/handshake env vars; not suppressed by --hermetic)")
|
|
6496
|
-
all_evs = list(app._infra_root_order) + list(app._handshake_order)
|
|
6673
|
+
all_evs = list(app._infra_root_order) + list(app._handshake_order) + list(app._connection_order)
|
|
6497
6674
|
max_len = max(len(ev) for ev in all_evs)
|
|
6498
6675
|
for ev in app._infra_root_order:
|
|
6499
6676
|
padding = max_len - len(ev) + 4
|
|
@@ -6501,6 +6678,9 @@ def _format_app_help(app: App) -> str:
|
|
|
6501
6678
|
for ev in app._handshake_order:
|
|
6502
6679
|
padding = max_len - len(ev) + 4
|
|
6503
6680
|
lines.append(f" {ev}{' ' * padding}{app._handshake_envs[ev]}")
|
|
6681
|
+
for ev in app._connection_order:
|
|
6682
|
+
padding = max_len - len(ev) + 4
|
|
6683
|
+
lines.append(f" {ev}{' ' * padding}connection URL, suppressed by --hermetic ({app._connection_envs[ev]})")
|
|
6504
6684
|
|
|
6505
6685
|
lines.append("")
|
|
6506
6686
|
lines.append(f"Use '{app.name} <command> --help' for more information.")
|
|
@@ -7636,7 +7816,7 @@ def _dump_schema_core(app: App) -> dict:
|
|
|
7636
7816
|
# root values are intentionally EXCLUDED -- the schema must be machine-stable
|
|
7637
7817
|
# (not machine-specific). Only the declared env var and default path (both
|
|
7638
7818
|
# stable declarations) are emitted for roots.
|
|
7639
|
-
if app._infra_root_order or app._handshake_order:
|
|
7819
|
+
if app._infra_root_order or app._handshake_order or app._connection_order:
|
|
7640
7820
|
infra: dict = {}
|
|
7641
7821
|
if app._infra_root_order:
|
|
7642
7822
|
infra["roots"] = [
|
|
@@ -7648,6 +7828,11 @@ def _dump_schema_core(app: App) -> dict:
|
|
|
7648
7828
|
{"env_var": ev, "help": app._handshake_envs[ev]}
|
|
7649
7829
|
for ev in app._handshake_order
|
|
7650
7830
|
]
|
|
7831
|
+
if app._connection_order:
|
|
7832
|
+
infra["connections"] = [
|
|
7833
|
+
{"env_var": ev, "help": app._connection_envs[ev]}
|
|
7834
|
+
for ev in app._connection_order
|
|
7835
|
+
]
|
|
7651
7836
|
schema["infra"] = infra
|
|
7652
7837
|
return schema
|
|
7653
7838
|
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Tests for the connection-env primitive: app-level declaration, lazy read,
|
|
2
|
+
hermetic suppression, check-side access, registration-time enforcement, and
|
|
3
|
+
precedence (cli > env)."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import strictcli
|
|
12
|
+
from strictcli import App, Context
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# --- Declaration + help + schema surfacing ---
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_connection_env_declaration():
|
|
19
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
20
|
+
connection_env={"DATABASE_URL": "Postgres connection string"})
|
|
21
|
+
assert app._connection_envs["DATABASE_URL"] == "Postgres connection string"
|
|
22
|
+
assert app._connection_order == ["DATABASE_URL"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_connection_env_help_rendering():
|
|
26
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
27
|
+
connection_env={"DATABASE_URL": "Postgres connection string"})
|
|
28
|
+
|
|
29
|
+
@app.command("run", help="run it")
|
|
30
|
+
def run(ctx):
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
r = app.test(["--help"])
|
|
34
|
+
assert "Infrastructure:" in r.stdout
|
|
35
|
+
assert "DATABASE_URL" in r.stdout
|
|
36
|
+
assert "connection URL, suppressed by --hermetic (Postgres connection string)" in r.stdout
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_connection_env_schema_dump():
|
|
40
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
41
|
+
connection_env={"DATABASE_URL": "Postgres connection string"})
|
|
42
|
+
|
|
43
|
+
@app.command("run", help="run it")
|
|
44
|
+
def run(ctx):
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
from strictcli import _dump_schema_core
|
|
48
|
+
schema = _dump_schema_core(app)
|
|
49
|
+
conns = schema["infra"]["connections"]
|
|
50
|
+
assert conns == [{"env_var": "DATABASE_URL", "help": "Postgres connection string"}]
|
|
51
|
+
json.dumps(schema) # must marshal
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- Lazy read + precedence (cli > env) ---
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _make_conn_app():
|
|
58
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
59
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
60
|
+
captured = {}
|
|
61
|
+
|
|
62
|
+
@app.command("run", help="run it")
|
|
63
|
+
@strictcli.flag("dsn", help="connection string", default="",
|
|
64
|
+
connection_url=True, connection_env="DATABASE_URL")
|
|
65
|
+
def run(ctx, dsn):
|
|
66
|
+
captured["dsn"] = dsn
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
return app, captured
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_connection_env_lazy_read_from_env(monkeypatch):
|
|
73
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://from-env/db")
|
|
74
|
+
app, captured = _make_conn_app()
|
|
75
|
+
r = app.test(["run"])
|
|
76
|
+
assert r.exit_code == 0, r.stderr
|
|
77
|
+
assert captured["dsn"] == "postgres://from-env/db"
|
|
78
|
+
assert app._last_sources["dsn"] == "env"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_connection_env_cli_beats_env(monkeypatch):
|
|
82
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://from-env/db")
|
|
83
|
+
app, captured = _make_conn_app()
|
|
84
|
+
r = app.test(["run", "--dsn", "postgres://from-cli/db"])
|
|
85
|
+
assert r.exit_code == 0, r.stderr
|
|
86
|
+
assert captured["dsn"] == "postgres://from-cli/db"
|
|
87
|
+
assert app._last_sources["dsn"] == "cli"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_connection_env_hermetic_suppresses_flag(monkeypatch):
|
|
91
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://from-env/db")
|
|
92
|
+
app, captured = _make_conn_app()
|
|
93
|
+
r = app.test(["--hermetic", "run"])
|
|
94
|
+
assert r.exit_code == 0, r.stderr
|
|
95
|
+
assert captured["dsn"] != "postgres://from-env/db"
|
|
96
|
+
assert app._last_sources["dsn"] != "env"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- Handler-side infra_value / connection_env_value ---
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_connection_env_infra_value_live(monkeypatch):
|
|
103
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://live/db")
|
|
104
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
105
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
106
|
+
captured = {}
|
|
107
|
+
|
|
108
|
+
@app.command("run", help="run it")
|
|
109
|
+
def run(ctx):
|
|
110
|
+
captured["conn"] = ctx.connection_env_value("DATABASE_URL")
|
|
111
|
+
captured["infra"] = ctx.infra_value("DATABASE_URL")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
app.test(["run"])
|
|
115
|
+
assert captured["conn"] == ("postgres://live/db", True)
|
|
116
|
+
assert captured["infra"] == ("postgres://live/db", True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_connection_env_hermetic_suppresses_infra_value(monkeypatch):
|
|
120
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://live/db")
|
|
121
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
122
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
123
|
+
captured = {}
|
|
124
|
+
|
|
125
|
+
@app.command("run", help="run it")
|
|
126
|
+
def run(ctx):
|
|
127
|
+
captured["conn"] = ctx.connection_env_value("DATABASE_URL")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
app.test(["--hermetic", "run"])
|
|
131
|
+
assert captured["conn"] == (None, False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_connection_env_undeclared_value_raises(monkeypatch):
|
|
135
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://live/db")
|
|
136
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
137
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
138
|
+
captured = {}
|
|
139
|
+
|
|
140
|
+
@app.command("run", help="run it")
|
|
141
|
+
def run(ctx):
|
|
142
|
+
try:
|
|
143
|
+
ctx.connection_env_value("NOPE")
|
|
144
|
+
except KeyError as e:
|
|
145
|
+
captured["err"] = str(e)
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
app.test(["run"])
|
|
149
|
+
assert "NOPE" in captured["err"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --- Check-side access via ConnectionEnvReader ---
|
|
153
|
+
|
|
154
|
+
CONN_CHECKS_TOML = """
|
|
155
|
+
app = "myapp"
|
|
156
|
+
|
|
157
|
+
[checks.db-reachable]
|
|
158
|
+
tags = ["db"]
|
|
159
|
+
severity = "error"
|
|
160
|
+
fast = true
|
|
161
|
+
pure = false
|
|
162
|
+
needs_network = true
|
|
163
|
+
depends_on = []
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class SimpleContext:
|
|
169
|
+
project_root: Path
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _make_conn_check_app(tmp_path):
|
|
173
|
+
toml_file = tmp_path / "checks.toml"
|
|
174
|
+
toml_file.write_text(CONN_CHECKS_TOML)
|
|
175
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
176
|
+
connection_env={"DATABASE_URL": "conn"},
|
|
177
|
+
checks_path=str(toml_file))
|
|
178
|
+
|
|
179
|
+
@app.error_check("db-reachable")
|
|
180
|
+
def db_reachable(ctx, reporter):
|
|
181
|
+
dsn, present = ctx.connection_env_value("DATABASE_URL")
|
|
182
|
+
if not present:
|
|
183
|
+
return reporter.skipped("DATABASE_URL absent (hermetic or unset)")
|
|
184
|
+
reporter.note(f"dsn={dsn}")
|
|
185
|
+
return reporter.passed("connection env visible")
|
|
186
|
+
|
|
187
|
+
app.set_check_context(lambda: SimpleContext(project_root=tmp_path))
|
|
188
|
+
return app
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_connection_env_check_side_access(tmp_path, monkeypatch):
|
|
192
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://check/db")
|
|
193
|
+
app = _make_conn_check_app(tmp_path)
|
|
194
|
+
r = app.test(["check", "--tag", "db", "--verbose"])
|
|
195
|
+
assert "dsn=postgres://check/db" in r.stdout
|
|
196
|
+
assert "PASS" in r.stdout
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_connection_env_check_side_hermetic_skips(tmp_path, monkeypatch):
|
|
200
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://check/db")
|
|
201
|
+
app = _make_conn_check_app(tmp_path)
|
|
202
|
+
r = app.test(["--hermetic", "check", "--tag", "db"])
|
|
203
|
+
assert "SKIP" in r.stdout
|
|
204
|
+
assert "dsn=" not in r.stdout
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --- Registration-time enforcement ---
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_connection_url_flag_unbound_raises():
|
|
211
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
212
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
213
|
+
with pytest.raises(ValueError, match="must bind to a declared connection env"):
|
|
214
|
+
@app.command("run", help="run it")
|
|
215
|
+
@strictcli.flag("dsn", help="dsn", default=None, connection_url=True)
|
|
216
|
+
def run(ctx, dsn):
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_connection_url_flag_undeclared_binding_raises():
|
|
221
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
222
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
223
|
+
with pytest.raises(ValueError, match="undeclared connection env"):
|
|
224
|
+
@app.command("run", help="run it")
|
|
225
|
+
@strictcli.flag("dsn", help="dsn", default=None,
|
|
226
|
+
connection_url=True, connection_env="OTHER_URL")
|
|
227
|
+
def run(ctx, dsn):
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_connection_env_binding_without_url_marker_raises():
|
|
232
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
233
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
234
|
+
with pytest.raises(ValueError, match="requires the flag to be marked as a connection-URL flag"):
|
|
235
|
+
@app.command("run", help="run it")
|
|
236
|
+
@strictcli.flag("dsn", help="dsn", default=None, connection_env="DATABASE_URL")
|
|
237
|
+
def run(ctx, dsn):
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_connection_env_binding_plus_per_flag_env_raises():
|
|
242
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
243
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
244
|
+
with pytest.raises(ValueError, match="cannot be combined with a per-flag env var"):
|
|
245
|
+
@app.command("run", help="run it")
|
|
246
|
+
@strictcli.flag("dsn", help="dsn", default=None, env="SOMETHING_ELSE",
|
|
247
|
+
connection_url=True, connection_env="DATABASE_URL")
|
|
248
|
+
def run(ctx, dsn):
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_connection_env_empty_help_raises():
|
|
253
|
+
with pytest.raises(ValueError, match="help must be a non-empty string"):
|
|
254
|
+
App(name="myapp", version="1.0.0", help="t",
|
|
255
|
+
connection_env={"DATABASE_URL": ""})
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_connection_env_collides_with_root_raises():
|
|
259
|
+
with pytest.raises(ValueError, match="already declared as an infra root"):
|
|
260
|
+
App(name="myapp", version="1.0.0", help="t",
|
|
261
|
+
infra_root={"SHARED": "/var/lib"},
|
|
262
|
+
connection_env={"SHARED": "conn"})
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_connection_env_collides_with_handshake_raises():
|
|
266
|
+
with pytest.raises(ValueError, match="already declared as a handshake env var"):
|
|
267
|
+
App(name="myapp", version="1.0.0", help="t",
|
|
268
|
+
handshake_env={"SHARED": "handshake"},
|
|
269
|
+
connection_env={"SHARED": "conn"})
|
|
@@ -84,6 +84,9 @@ class TestCoverageRecording:
|
|
|
84
84
|
shards = list(coverage_dir.glob("*.jsonl"))
|
|
85
85
|
assert len(shards) >= 1
|
|
86
86
|
|
|
87
|
+
# One shard per process, named "<pid>.jsonl" (no shard counter suffix).
|
|
88
|
+
assert shards[0].name == f"{os.getpid()}.jsonl"
|
|
89
|
+
|
|
87
90
|
entries = []
|
|
88
91
|
for shard in shards:
|
|
89
92
|
for line in shard.read_text().strip().splitlines():
|
|
@@ -158,6 +158,42 @@ def test_float_flag_reject_negative_inf():
|
|
|
158
158
|
assert "Inf is not allowed" in r.stderr
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
def test_float_flag_reject_overflow_to_infinity():
|
|
162
|
+
"""'1e999' overflows to inf and is rejected via the generic parse error."""
|
|
163
|
+
app = _make_app_with_float_flag()
|
|
164
|
+
r = app.test(["cmd", "--rate", "1e999"])
|
|
165
|
+
assert r.exit_code == 1
|
|
166
|
+
assert "expected float" in r.stderr
|
|
167
|
+
assert "'1e999'" in r.stderr
|
|
168
|
+
# Overflow uses the generic message, not the literal-Inf rejection.
|
|
169
|
+
assert "Inf is not allowed" not in r.stderr
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_float_flag_reject_negative_overflow_to_infinity():
|
|
173
|
+
"""'-1e999' overflows to -inf and is rejected via the generic parse error."""
|
|
174
|
+
app = _make_app_with_float_flag()
|
|
175
|
+
r = app.test(["cmd", "--rate", "-1e999"])
|
|
176
|
+
assert r.exit_code == 1
|
|
177
|
+
assert "expected float" in r.stderr
|
|
178
|
+
assert "'-1e999'" in r.stderr
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_float_flag_reject_overflow_env_value(monkeypatch):
|
|
182
|
+
"""Overflowing float env var is rejected with the env suffix."""
|
|
183
|
+
app = strictcli.App(name="test", version="1.0.0", help="test app", env_prefix="MYAPP")
|
|
184
|
+
|
|
185
|
+
@app.command("cmd", help="a command")
|
|
186
|
+
@strictcli.flag("rate", type=float, help="the rate", default=1.0, env="MYAPP_RATE")
|
|
187
|
+
def cmd(ctx, rate):
|
|
188
|
+
print(f"rate={rate}")
|
|
189
|
+
|
|
190
|
+
monkeypatch.setenv("MYAPP_RATE", "1e999")
|
|
191
|
+
r = app.test(["cmd"])
|
|
192
|
+
assert r.exit_code == 1
|
|
193
|
+
assert "expected float" in r.stderr
|
|
194
|
+
assert "(from env var 'MYAPP_RATE')" in r.stderr
|
|
195
|
+
|
|
196
|
+
|
|
161
197
|
def test_float_flag_reject_whitespace():
|
|
162
198
|
"""Whitespace-padded values are rejected."""
|
|
163
199
|
app = _make_app_with_float_flag()
|
|
@@ -88,6 +88,31 @@ def test_int_flag_bad_env_value(monkeypatch):
|
|
|
88
88
|
assert "'abc'" in r.stderr
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
def test_int_flag_reject_underscore_separators():
|
|
92
|
+
"""'1_000' (PEP 515 digit separators) is rejected to match Go/TypeScript."""
|
|
93
|
+
app = _make_app_with_int_flag()
|
|
94
|
+
r = app.test(["cmd", "--port", "1_000"])
|
|
95
|
+
assert r.exit_code == 1
|
|
96
|
+
assert "expected integer" in r.stderr
|
|
97
|
+
assert "'1_000'" in r.stderr
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_int_flag_reject_underscore_env_value(monkeypatch):
|
|
101
|
+
"""Underscore-separated int env var is rejected."""
|
|
102
|
+
app = strictcli.App(name="test", version="1.0.0", help="test app", env_prefix="MYAPP")
|
|
103
|
+
|
|
104
|
+
@app.command("cmd", help="a command")
|
|
105
|
+
@strictcli.flag("port", type=int, help="the port", default=80, env="MYAPP_PORT")
|
|
106
|
+
def cmd(ctx, port):
|
|
107
|
+
print(f"port={port}")
|
|
108
|
+
|
|
109
|
+
monkeypatch.setenv("MYAPP_PORT", "1_000")
|
|
110
|
+
r = app.test(["cmd"])
|
|
111
|
+
assert r.exit_code == 1
|
|
112
|
+
assert "expected integer" in r.stderr
|
|
113
|
+
assert "'1_000'" in r.stderr
|
|
114
|
+
|
|
115
|
+
|
|
91
116
|
def test_int_flag_required():
|
|
92
117
|
"""Int flag with no default and omitted -> exit 1, stderr says required."""
|
|
93
118
|
app = _make_app_with_int_flag()
|
|
@@ -126,6 +126,23 @@ def test_int_arg_rejects_float_string():
|
|
|
126
126
|
assert "expected integer" in r.stderr
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
def test_int_arg_rejects_underscore_separators():
|
|
130
|
+
"""Int arg rejects '1_000' (PEP 515 digit separators), matching Go/TypeScript."""
|
|
131
|
+
app = strictcli.App(name="test", version="1.0.0", help="test app")
|
|
132
|
+
|
|
133
|
+
@app.command(
|
|
134
|
+
"cmd",
|
|
135
|
+
help="a command",
|
|
136
|
+
args=[strictcli.Arg(name="count", help="a count", type=int)],
|
|
137
|
+
)
|
|
138
|
+
def cmd(ctx, count):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
r = app.test(["cmd", "1_000"])
|
|
142
|
+
assert r.exit_code == 1
|
|
143
|
+
assert "argument 'count': expected integer, got '1_000'" in r.stderr
|
|
144
|
+
|
|
145
|
+
|
|
129
146
|
def test_int_arg_negative():
|
|
130
147
|
"""Int arg handles negative values (after -- separator)."""
|
|
131
148
|
app = strictcli.App(name="test", version="1.0.0", help="test app")
|
|
@@ -211,6 +228,23 @@ def test_float_arg_rejects_inf():
|
|
|
211
228
|
assert "Inf is not allowed" in r.stderr
|
|
212
229
|
|
|
213
230
|
|
|
231
|
+
def test_float_arg_rejects_overflow_to_infinity():
|
|
232
|
+
"""Float arg rejects '1e999' (overflows to inf), using the generic parse error."""
|
|
233
|
+
app = strictcli.App(name="test", version="1.0.0", help="test app")
|
|
234
|
+
|
|
235
|
+
@app.command(
|
|
236
|
+
"cmd",
|
|
237
|
+
help="a command",
|
|
238
|
+
args=[strictcli.Arg(name="ratio", help="a ratio", type=float)],
|
|
239
|
+
)
|
|
240
|
+
def cmd(ctx, ratio):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
r = app.test(["cmd", "1e999"])
|
|
244
|
+
assert r.exit_code == 1
|
|
245
|
+
assert "argument 'ratio': expected float, got '1e999'" in r.stderr
|
|
246
|
+
|
|
247
|
+
|
|
214
248
|
def test_float_arg_integer_string():
|
|
215
249
|
"""Float arg accepts integer string (coerces to float)."""
|
|
216
250
|
app = strictcli.App(name="test", version="1.0.0", help="test 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
|