strictcli 0.32.2__tar.gz → 0.34.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.2 → strictcli-0.34.0}/PKG-INFO +1 -1
- {strictcli-0.32.2 → strictcli-0.34.0}/pyproject.toml +1 -1
- {strictcli-0.32.2 → strictcli-0.34.0}/strictcli/__init__.py +221 -23
- strictcli-0.34.0/tests/test_connection_env.py +303 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/uv.lock +1 -1
- {strictcli-0.32.2 → strictcli-0.34.0}/.claude/settings.json +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.gitignore +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/config.json +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/version +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/.strictcli/schema.json +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/CLAUDE.md +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/LICENSE +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/README.md +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/strictcli/py.typed +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/conftest.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_call.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_command.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_provider.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_types.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_choices.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_config.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_config_fields.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_context.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_coverage.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_dump_schema.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_e2e.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_env.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_float_format.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_float_type.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_float_vectors.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_help.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_infra_env.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_int_type.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_invoke.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_mcp.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_mutex.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_nesting.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_parser.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_provenance.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_registration.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_typed_args.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_unique.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_utilities.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_validate.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_variadic.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_visibility.py +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.32.2 → strictcli-0.34.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.32.2 → strictcli-0.34.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.34.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
|
|
|
@@ -1712,6 +1773,14 @@ class Flag:
|
|
|
1712
1773
|
validate: Callable | None = None
|
|
1713
1774
|
repeatable: bool = False
|
|
1714
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
|
|
1715
1784
|
# Per-flag config conflict mode. _MISSING means "inherit the app default".
|
|
1716
1785
|
# When set explicitly, must be "cli-wins" or "error". Applies to flags only:
|
|
1717
1786
|
# standalone ConfigFields have no CLI/env conflict surface, and a
|
|
@@ -2171,6 +2240,7 @@ class Group:
|
|
|
2171
2240
|
hidden: bool = False
|
|
2172
2241
|
_config_fields_ref: dict[str, ConfigField] = field(default_factory=dict)
|
|
2173
2242
|
_infra_root_names: frozenset[str] = frozenset()
|
|
2243
|
+
_connection_env_names: frozenset[str] = frozenset()
|
|
2174
2244
|
|
|
2175
2245
|
def __post_init__(self) -> None:
|
|
2176
2246
|
_require_non_empty_str(self.help, "help", "Group")
|
|
@@ -2196,7 +2266,8 @@ class Group:
|
|
|
2196
2266
|
_accumulated_tags=self._accumulated_tags | own_tags,
|
|
2197
2267
|
hidden=hidden,
|
|
2198
2268
|
_config_fields_ref=self._config_fields_ref,
|
|
2199
|
-
_infra_root_names=self._infra_root_names
|
|
2269
|
+
_infra_root_names=self._infra_root_names,
|
|
2270
|
+
_connection_env_names=self._connection_env_names)
|
|
2200
2271
|
self._groups[name] = grp
|
|
2201
2272
|
return grp
|
|
2202
2273
|
|
|
@@ -2255,6 +2326,7 @@ class Group:
|
|
|
2255
2326
|
config_fields=config_fields,
|
|
2256
2327
|
config_fields_ref=self._config_fields_ref,
|
|
2257
2328
|
infra_root_names=self._infra_root_names,
|
|
2329
|
+
connection_env_names=self._connection_env_names,
|
|
2258
2330
|
)
|
|
2259
2331
|
self.commands[name] = cmd
|
|
2260
2332
|
return func
|
|
@@ -2572,6 +2644,64 @@ class CheckContext(Protocol):
|
|
|
2572
2644
|
project_root: Path
|
|
2573
2645
|
|
|
2574
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
|
+
``is_hermetic()`` reports whether the invocation ran under --hermetic. It
|
|
2657
|
+
exists so a check can DISTINGUISH the two cases that
|
|
2658
|
+
``connection_env_value``'s ``present=False`` otherwise conflates:
|
|
2659
|
+
"--hermetic suppressed the connection env" vs "the env var is simply unset".
|
|
2660
|
+
A check that layers config fallbacks below the env must honor hermetic even
|
|
2661
|
+
when the env is unset -- otherwise it falls through to a config URL and
|
|
2662
|
+
connects, violating the hermetic guarantee::
|
|
2663
|
+
|
|
2664
|
+
dsn, present = ctx.connection_env_value("DATABASE_URL")
|
|
2665
|
+
if not present:
|
|
2666
|
+
if ctx.is_hermetic():
|
|
2667
|
+
return reporter.skipped("hermetic: connection suppressed")
|
|
2668
|
+
# env unset but not hermetic -- config fallback is allowed here
|
|
2669
|
+
"""
|
|
2670
|
+
|
|
2671
|
+
def connection_env_value(self, env_var: str) -> "tuple[str | None, bool]": ...
|
|
2672
|
+
|
|
2673
|
+
def is_hermetic(self) -> bool: ...
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
class _CheckContextWithConn:
|
|
2677
|
+
"""Wraps a tool-supplied check context, delegating attribute access while
|
|
2678
|
+
adding connection-env access (hermetic-suppressed) so check functions can
|
|
2679
|
+
read declared connection envs without the tool implementing anything beyond
|
|
2680
|
+
``project_root``."""
|
|
2681
|
+
|
|
2682
|
+
def __init__(self, base, connections: frozenset[str], hermetic: bool) -> None:
|
|
2683
|
+
self._base = base
|
|
2684
|
+
self._connections = connections
|
|
2685
|
+
self._hermetic = hermetic
|
|
2686
|
+
|
|
2687
|
+
def __getattr__(self, name):
|
|
2688
|
+
return getattr(self._base, name)
|
|
2689
|
+
|
|
2690
|
+
def connection_env_value(self, env_var: str) -> "tuple[str | None, bool]":
|
|
2691
|
+
if env_var in self._connections:
|
|
2692
|
+
if self._hermetic:
|
|
2693
|
+
return None, False
|
|
2694
|
+
if env_var in os.environ:
|
|
2695
|
+
return os.environ[env_var], True
|
|
2696
|
+
return None, False
|
|
2697
|
+
raise KeyError(f'"{env_var}" is not a declared connection env var')
|
|
2698
|
+
|
|
2699
|
+
def is_hermetic(self) -> bool:
|
|
2700
|
+
"""Report whether the invocation ran under --hermetic. Mirrors the
|
|
2701
|
+
hermetic flag captured when the wrapper was built."""
|
|
2702
|
+
return self._hermetic
|
|
2703
|
+
|
|
2704
|
+
|
|
2575
2705
|
@dataclass
|
|
2576
2706
|
class _CheckDef:
|
|
2577
2707
|
"""Internal definition of a single check loaded from TOML."""
|
|
@@ -2685,6 +2815,11 @@ class App:
|
|
|
2685
2815
|
# protocol env var -> its help string.
|
|
2686
2816
|
infra_root: dict[str, str] | None = None
|
|
2687
2817
|
handshake_env: dict[str, str] | None = None
|
|
2818
|
+
# connection_env maps a behavioral "reach outside the process" env var
|
|
2819
|
+
# (e.g. a database/service URL) -> its help string. Unlike roots and
|
|
2820
|
+
# handshakes it is hermetic-SUPPRESSED: under --hermetic it resolves as
|
|
2821
|
+
# absent. No default, read lazily. Flags bind to it via connection_url=.
|
|
2822
|
+
connection_env: dict[str, str] | None = None
|
|
2688
2823
|
checks_path: str | Path | None = None
|
|
2689
2824
|
checks_embed: bytes | None = None
|
|
2690
2825
|
test_coverage: bool = False
|
|
@@ -2713,6 +2848,7 @@ class App:
|
|
|
2713
2848
|
self._global_flags: list[Flag] = list(self.flags)
|
|
2714
2849
|
self._last_global_values: dict[str, object] = {}
|
|
2715
2850
|
self._last_sources: dict[str, str] = {}
|
|
2851
|
+
self._last_hermetic: bool = False
|
|
2716
2852
|
|
|
2717
2853
|
# Resolve infrastructure roots eagerly, at construction. Infra vars have
|
|
2718
2854
|
# no argv dependency, so resolution is sound here -- and this is WHY it
|
|
@@ -2740,15 +2876,28 @@ class App:
|
|
|
2740
2876
|
raise ValueError(f'handshake env var "{ev}": help must be a non-empty string')
|
|
2741
2877
|
if ev in self._infra_roots:
|
|
2742
2878
|
raise ValueError(f'handshake env var "{ev}" is already declared as an infra root')
|
|
2879
|
+
# Connection env vars: behavioral, hermetic-suppressed, no default.
|
|
2880
|
+
self._connection_envs: dict[str, str] = dict(self.connection_env) if self.connection_env else {}
|
|
2881
|
+
self._connection_order: list[str] = list(self.connection_env.keys()) if self.connection_env else []
|
|
2882
|
+
for ev in self._connection_order:
|
|
2883
|
+
if not self._connection_envs[ev] or not self._connection_envs[ev].strip():
|
|
2884
|
+
raise ValueError(f'connection env var "{ev}": help must be a non-empty string')
|
|
2885
|
+
if ev in self._infra_roots:
|
|
2886
|
+
raise ValueError(f'connection env var "{ev}" is already declared as an infra root')
|
|
2887
|
+
if ev in self._handshake_envs:
|
|
2888
|
+
raise ValueError(f'connection env var "{ev}" is already declared as a handshake env var')
|
|
2889
|
+
self._connection_env_names: frozenset[str] = frozenset(self._connection_envs)
|
|
2743
2890
|
# A shared frozenset of declared root names, threaded to commands/groups
|
|
2744
2891
|
# so flag-default markers can be validated at registration time.
|
|
2745
2892
|
self._infra_root_names: frozenset[str] = frozenset(self._infra_roots)
|
|
2746
2893
|
# Resolve the config-path marker (if any) now that roots exist.
|
|
2747
2894
|
if isinstance(self.config_path, RelativeToRoot):
|
|
2748
2895
|
self.config_path = _resolve_infra_root_path(self.config_path, self._infra_roots)
|
|
2749
|
-
# Validate global flag default markers against declared roots
|
|
2896
|
+
# Validate global flag default markers against declared roots and
|
|
2897
|
+
# connection-URL bindings against declared connection envs.
|
|
2750
2898
|
for f in self._global_flags:
|
|
2751
2899
|
self._validate_flag_infra_marker(f)
|
|
2900
|
+
_validate_connection_binding(f, self._connection_env_names)
|
|
2752
2901
|
|
|
2753
2902
|
# Validate config_format
|
|
2754
2903
|
if self.config_format not in ("json", "toml"):
|
|
@@ -2843,14 +2992,17 @@ class App:
|
|
|
2843
2992
|
f'root "{f.default.env_var}"; declare it as an infra root'
|
|
2844
2993
|
)
|
|
2845
2994
|
|
|
2846
|
-
def _infra_access(self) -> "_InfraAccess | None":
|
|
2995
|
+
def _infra_access(self, hermetic: bool = False) -> "_InfraAccess | None":
|
|
2847
2996
|
"""Snapshot infra data for a Context: resolved roots + declared handshake
|
|
2848
|
-
env var names
|
|
2849
|
-
|
|
2997
|
+
env var names + declared connection env var names. Connection envs are
|
|
2998
|
+
suppressed when hermetic is True. Returns None when nothing is declared."""
|
|
2999
|
+
if not self._infra_roots and not self._handshake_envs and not self._connection_envs:
|
|
2850
3000
|
return None
|
|
2851
3001
|
return _InfraAccess(
|
|
2852
3002
|
roots=dict(self._infra_roots),
|
|
2853
3003
|
handshakes=set(self._handshake_envs),
|
|
3004
|
+
connections=set(self._connection_envs),
|
|
3005
|
+
hermetic=hermetic,
|
|
2854
3006
|
)
|
|
2855
3007
|
|
|
2856
3008
|
def _record_coverage(self, cmd_path: str) -> None:
|
|
@@ -3293,6 +3445,14 @@ class App:
|
|
|
3293
3445
|
"""
|
|
3294
3446
|
self._check_context_factory = factory
|
|
3295
3447
|
|
|
3448
|
+
def _wrap_check_context(self, base):
|
|
3449
|
+
"""Augment a tool-supplied check context with connection-env access
|
|
3450
|
+
(hermetic-suppressed). When no connection envs are declared, the base
|
|
3451
|
+
context is returned unchanged so the common case is unaffected."""
|
|
3452
|
+
if not self._connection_envs:
|
|
3453
|
+
return base
|
|
3454
|
+
return _CheckContextWithConn(base, self._connection_env_names, self._last_hermetic)
|
|
3455
|
+
|
|
3296
3456
|
def set_scope_adapter(self, adapter: Callable) -> None:
|
|
3297
3457
|
"""Set the scope adapter callback for scoped checks.
|
|
3298
3458
|
|
|
@@ -3540,7 +3700,7 @@ class App:
|
|
|
3540
3700
|
file=sys.stderr,
|
|
3541
3701
|
)
|
|
3542
3702
|
return 1
|
|
3543
|
-
context = app_ref._check_context_factory()
|
|
3703
|
+
context = app_ref._wrap_check_context(app_ref._check_context_factory())
|
|
3544
3704
|
# The check command executes all selected checks; the purity
|
|
3545
3705
|
# partition is an API-only mode (run_checks pure_only=), so nothing
|
|
3546
3706
|
# is ever left in the impure listing here.
|
|
@@ -3627,6 +3787,7 @@ class App:
|
|
|
3627
3787
|
config_fields=config_fields,
|
|
3628
3788
|
config_fields_ref=self._config_fields,
|
|
3629
3789
|
infra_root_names=self._infra_root_names,
|
|
3790
|
+
connection_env_names=self._connection_env_names,
|
|
3630
3791
|
)
|
|
3631
3792
|
self._commands[name] = cmd
|
|
3632
3793
|
return func
|
|
@@ -3643,7 +3804,8 @@ class App:
|
|
|
3643
3804
|
_accumulated_tags=own_tags,
|
|
3644
3805
|
hidden=hidden,
|
|
3645
3806
|
_config_fields_ref=self._config_fields,
|
|
3646
|
-
_infra_root_names=self._infra_root_names
|
|
3807
|
+
_infra_root_names=self._infra_root_names,
|
|
3808
|
+
_connection_env_names=self._connection_env_names)
|
|
3647
3809
|
self._groups[name] = grp
|
|
3648
3810
|
return grp
|
|
3649
3811
|
|
|
@@ -3774,8 +3936,8 @@ class App:
|
|
|
3774
3936
|
if not isinstance(cf.default, _MissingSentinel):
|
|
3775
3937
|
entry["default"] = cf.default
|
|
3776
3938
|
result[cf_name] = entry
|
|
3777
|
-
# Infrastructure section (roots + handshakes)
|
|
3778
|
-
if app_ref._infra_root_order or app_ref._handshake_order:
|
|
3939
|
+
# Infrastructure section (roots + handshakes + connections)
|
|
3940
|
+
if app_ref._infra_root_order or app_ref._handshake_order or app_ref._connection_order:
|
|
3779
3941
|
infra: dict = {}
|
|
3780
3942
|
for ev in app_ref._infra_root_order:
|
|
3781
3943
|
infra[ev] = {
|
|
@@ -3793,6 +3955,16 @@ class App:
|
|
|
3793
3955
|
if is_set:
|
|
3794
3956
|
hs_entry["value"] = os.environ[ev]
|
|
3795
3957
|
infra[ev] = hs_entry
|
|
3958
|
+
for ev in app_ref._connection_order:
|
|
3959
|
+
is_set = ev in os.environ
|
|
3960
|
+
conn_entry: dict = {
|
|
3961
|
+
"kind": "connection",
|
|
3962
|
+
"set": is_set,
|
|
3963
|
+
"help": app_ref._connection_envs[ev],
|
|
3964
|
+
}
|
|
3965
|
+
if is_set:
|
|
3966
|
+
conn_entry["value"] = os.environ[ev]
|
|
3967
|
+
infra[ev] = conn_entry
|
|
3796
3968
|
result["__infrastructure__"] = infra
|
|
3797
3969
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
3798
3970
|
return 0
|
|
@@ -3832,8 +4004,8 @@ class App:
|
|
|
3832
4004
|
f" (source: {source})"
|
|
3833
4005
|
f" -- {cf.help}"
|
|
3834
4006
|
)
|
|
3835
|
-
# Infrastructure section (roots + handshakes)
|
|
3836
|
-
if app_ref._infra_root_order or app_ref._handshake_order:
|
|
4007
|
+
# Infrastructure section (roots + handshakes + connections)
|
|
4008
|
+
if app_ref._infra_root_order or app_ref._handshake_order or app_ref._connection_order:
|
|
3837
4009
|
print()
|
|
3838
4010
|
print("Infrastructure:")
|
|
3839
4011
|
for ev in app_ref._infra_root_order:
|
|
@@ -3844,6 +4016,11 @@ class App:
|
|
|
3844
4016
|
print(f" {ev} (handshake) = {os.environ[ev]} (set) -- {app_ref._handshake_envs[ev]}")
|
|
3845
4017
|
else:
|
|
3846
4018
|
print(f" {ev} (handshake) = <unset> -- {app_ref._handshake_envs[ev]}")
|
|
4019
|
+
for ev in app_ref._connection_order:
|
|
4020
|
+
if ev in os.environ:
|
|
4021
|
+
print(f" {ev} (connection) = {os.environ[ev]} (set) -- {app_ref._connection_envs[ev]}")
|
|
4022
|
+
else:
|
|
4023
|
+
print(f" {ev} (connection) = <unset> -- {app_ref._connection_envs[ev]}")
|
|
3847
4024
|
return 0
|
|
3848
4025
|
|
|
3849
4026
|
config_show_flags = [
|
|
@@ -4257,6 +4434,9 @@ class App:
|
|
|
4257
4434
|
raise _ParseError(pre_scan["err"])
|
|
4258
4435
|
|
|
4259
4436
|
is_hermetic = bool(pre_scan.get("hermetic"))
|
|
4437
|
+
# Record for the dispatch ctx: connection env access is suppressed under
|
|
4438
|
+
# --hermetic so connection-dependent behavior (incl. checks) skips.
|
|
4439
|
+
self._last_hermetic = is_hermetic
|
|
4260
4440
|
|
|
4261
4441
|
# --hermetic + --config mutual exclusion
|
|
4262
4442
|
if is_hermetic and pre_scan.get("config_path"):
|
|
@@ -4878,7 +5058,7 @@ class App:
|
|
|
4878
5058
|
sys.exit(1)
|
|
4879
5059
|
else:
|
|
4880
5060
|
self._last_sources = sources
|
|
4881
|
-
ctx = Context(stdout=sys.stdout, stderr=sys.stderr, sources=sources, infra=self._infra_access())
|
|
5061
|
+
ctx = Context(stdout=sys.stdout, stderr=sys.stderr, sources=sources, infra=self._infra_access(self._last_hermetic))
|
|
4882
5062
|
if cmd.passthrough is not None:
|
|
4883
5063
|
result = cmd.passthrough.handler(ctx, cmd.name, data, self._last_global_values)
|
|
4884
5064
|
else:
|
|
@@ -4951,7 +5131,7 @@ class App:
|
|
|
4951
5131
|
self._last_sources = sources
|
|
4952
5132
|
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
|
|
4953
5133
|
try:
|
|
4954
|
-
ctx = Context(stdout=stdout_buf, stderr=stderr_buf, sources=sources, infra=self._infra_access())
|
|
5134
|
+
ctx = Context(stdout=stdout_buf, stderr=stderr_buf, sources=sources, infra=self._infra_access(self._last_hermetic))
|
|
4955
5135
|
if cmd.passthrough is not None:
|
|
4956
5136
|
handler_return = cmd.passthrough.handler(
|
|
4957
5137
|
ctx, cmd.name, data, self._last_global_values,
|
|
@@ -6060,6 +6240,7 @@ def _build_and_validate_command(
|
|
|
6060
6240
|
config_fields: list[str] | None = None,
|
|
6061
6241
|
config_fields_ref: dict[str, ConfigField] | None = None,
|
|
6062
6242
|
infra_root_names: frozenset[str] | None = None,
|
|
6243
|
+
connection_env_names: frozenset[str] | None = None,
|
|
6063
6244
|
) -> Command:
|
|
6064
6245
|
"""Build a Command from a decorated handler, validate everything."""
|
|
6065
6246
|
if not help or not help.strip():
|
|
@@ -6353,6 +6534,11 @@ def _build_and_validate_command(
|
|
|
6353
6534
|
f'undeclared infra root "{f.default.env_var}"; declare it as an infra root'
|
|
6354
6535
|
)
|
|
6355
6536
|
|
|
6537
|
+
# Validate connection-URL bindings against declared connection envs.
|
|
6538
|
+
_conn_names = connection_env_names or frozenset()
|
|
6539
|
+
for f in all_flags:
|
|
6540
|
+
_validate_connection_binding(f, _conn_names)
|
|
6541
|
+
|
|
6356
6542
|
return Command(
|
|
6357
6543
|
name=name,
|
|
6358
6544
|
help=help,
|
|
@@ -6385,6 +6571,8 @@ def flag(
|
|
|
6385
6571
|
repeatable: bool = False,
|
|
6386
6572
|
unique: object = _MISSING,
|
|
6387
6573
|
conflict_mode: object = _MISSING,
|
|
6574
|
+
connection_url: bool = False,
|
|
6575
|
+
connection_env: str | None = None,
|
|
6388
6576
|
) -> Callable[[F], F]:
|
|
6389
6577
|
"""Module-level decorator to attach a Flag to a command handler."""
|
|
6390
6578
|
|
|
@@ -6404,6 +6592,8 @@ def flag(
|
|
|
6404
6592
|
repeatable=repeatable,
|
|
6405
6593
|
unique=unique,
|
|
6406
6594
|
conflict_mode=conflict_mode,
|
|
6595
|
+
connection_url=connection_url,
|
|
6596
|
+
connection_env=connection_env,
|
|
6407
6597
|
)
|
|
6408
6598
|
if not hasattr(func, "_strictcli_flags"):
|
|
6409
6599
|
func._strictcli_flags = []
|
|
@@ -6498,11 +6688,11 @@ def _format_app_help(app: App) -> str:
|
|
|
6498
6688
|
padding = max_flag_len - len(flag_str) + 4
|
|
6499
6689
|
lines.append(f" {flag_str}{' ' * padding}{help_text}")
|
|
6500
6690
|
|
|
6501
|
-
if app._infra_root_order or app._handshake_order:
|
|
6691
|
+
if app._infra_root_order or app._handshake_order or app._connection_order:
|
|
6502
6692
|
lines.append("")
|
|
6503
6693
|
lines.append("Infrastructure:")
|
|
6504
6694
|
lines.append(" (location/handshake env vars; not suppressed by --hermetic)")
|
|
6505
|
-
all_evs = list(app._infra_root_order) + list(app._handshake_order)
|
|
6695
|
+
all_evs = list(app._infra_root_order) + list(app._handshake_order) + list(app._connection_order)
|
|
6506
6696
|
max_len = max(len(ev) for ev in all_evs)
|
|
6507
6697
|
for ev in app._infra_root_order:
|
|
6508
6698
|
padding = max_len - len(ev) + 4
|
|
@@ -6510,6 +6700,9 @@ def _format_app_help(app: App) -> str:
|
|
|
6510
6700
|
for ev in app._handshake_order:
|
|
6511
6701
|
padding = max_len - len(ev) + 4
|
|
6512
6702
|
lines.append(f" {ev}{' ' * padding}{app._handshake_envs[ev]}")
|
|
6703
|
+
for ev in app._connection_order:
|
|
6704
|
+
padding = max_len - len(ev) + 4
|
|
6705
|
+
lines.append(f" {ev}{' ' * padding}connection URL, suppressed by --hermetic ({app._connection_envs[ev]})")
|
|
6513
6706
|
|
|
6514
6707
|
lines.append("")
|
|
6515
6708
|
lines.append(f"Use '{app.name} <command> --help' for more information.")
|
|
@@ -7645,7 +7838,7 @@ def _dump_schema_core(app: App) -> dict:
|
|
|
7645
7838
|
# root values are intentionally EXCLUDED -- the schema must be machine-stable
|
|
7646
7839
|
# (not machine-specific). Only the declared env var and default path (both
|
|
7647
7840
|
# stable declarations) are emitted for roots.
|
|
7648
|
-
if app._infra_root_order or app._handshake_order:
|
|
7841
|
+
if app._infra_root_order or app._handshake_order or app._connection_order:
|
|
7649
7842
|
infra: dict = {}
|
|
7650
7843
|
if app._infra_root_order:
|
|
7651
7844
|
infra["roots"] = [
|
|
@@ -7657,6 +7850,11 @@ def _dump_schema_core(app: App) -> dict:
|
|
|
7657
7850
|
{"env_var": ev, "help": app._handshake_envs[ev]}
|
|
7658
7851
|
for ev in app._handshake_order
|
|
7659
7852
|
]
|
|
7853
|
+
if app._connection_order:
|
|
7854
|
+
infra["connections"] = [
|
|
7855
|
+
{"env_var": ev, "help": app._connection_envs[ev]}
|
|
7856
|
+
for ev in app._connection_order
|
|
7857
|
+
]
|
|
7660
7858
|
schema["infra"] = infra
|
|
7661
7859
|
return schema
|
|
7662
7860
|
|
|
@@ -0,0 +1,303 @@
|
|
|
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
|
+
reporter.note(f"hermetic={ctx.is_hermetic()}")
|
|
182
|
+
dsn, present = ctx.connection_env_value("DATABASE_URL")
|
|
183
|
+
if not present:
|
|
184
|
+
# Distinguish hermetic suppression from a plainly-unset env: a
|
|
185
|
+
# consumer layering config fallbacks below the env must honor
|
|
186
|
+
# hermetic (skip) rather than fall through and connect.
|
|
187
|
+
if ctx.is_hermetic():
|
|
188
|
+
return reporter.skipped("DATABASE_URL suppressed by --hermetic")
|
|
189
|
+
return reporter.skipped("DATABASE_URL unset")
|
|
190
|
+
reporter.note(f"dsn={dsn}")
|
|
191
|
+
return reporter.passed("connection env visible")
|
|
192
|
+
|
|
193
|
+
app.set_check_context(lambda: SimpleContext(project_root=tmp_path))
|
|
194
|
+
return app
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_connection_env_check_side_access(tmp_path, monkeypatch):
|
|
198
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://check/db")
|
|
199
|
+
app = _make_conn_check_app(tmp_path)
|
|
200
|
+
r = app.test(["check", "--tag", "db", "--verbose"])
|
|
201
|
+
assert "dsn=postgres://check/db" in r.stdout
|
|
202
|
+
assert "PASS" in r.stdout
|
|
203
|
+
# A non-hermetic invocation reports is_hermetic()==False to the check.
|
|
204
|
+
assert "hermetic=False" in r.stdout
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_connection_env_check_side_hermetic_skips(tmp_path, monkeypatch):
|
|
208
|
+
monkeypatch.setenv("DATABASE_URL", "postgres://check/db")
|
|
209
|
+
app = _make_conn_check_app(tmp_path)
|
|
210
|
+
r = app.test(["--hermetic", "check", "--tag", "db", "--verbose"])
|
|
211
|
+
assert "SKIP" in r.stdout
|
|
212
|
+
assert "dsn=" not in r.stdout
|
|
213
|
+
# The check SEES hermetic (is_hermetic()==True) and skips for that reason.
|
|
214
|
+
assert "hermetic=True" in r.stdout
|
|
215
|
+
assert "suppressed by --hermetic" in r.stdout
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_connection_env_check_side_hermetic_conflation(tmp_path, monkeypatch):
|
|
219
|
+
"""Documents the exact gap is_hermetic() closes: under --hermetic with the
|
|
220
|
+
env var UNSET, connection_env_value returns present=False (indistinguishable
|
|
221
|
+
from a plain unset), yet is_hermetic() returns True -- so a check layering
|
|
222
|
+
config fallbacks can honor hermetic instead of connecting via a config URL."""
|
|
223
|
+
monkeypatch.delenv("DATABASE_URL", raising=False) # env UNSET
|
|
224
|
+
app = _make_conn_check_app(tmp_path)
|
|
225
|
+
r = app.test(["--hermetic", "check", "--tag", "db", "--verbose"])
|
|
226
|
+
assert "hermetic=True" in r.stdout
|
|
227
|
+
assert "suppressed by --hermetic" in r.stdout
|
|
228
|
+
assert "DATABASE_URL unset" not in r.stdout
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_connection_env_check_side_unset_not_hermetic(tmp_path, monkeypatch):
|
|
232
|
+
"""Counterpart: env unset and NOT hermetic -> is_hermetic()==False, so a
|
|
233
|
+
consumer is free to consult a config fallback (here reported as plain unset)."""
|
|
234
|
+
monkeypatch.delenv("DATABASE_URL", raising=False) # env UNSET, no --hermetic
|
|
235
|
+
app = _make_conn_check_app(tmp_path)
|
|
236
|
+
r = app.test(["check", "--tag", "db", "--verbose"])
|
|
237
|
+
assert "hermetic=False" in r.stdout
|
|
238
|
+
assert "DATABASE_URL unset" in r.stdout
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --- Registration-time enforcement ---
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_connection_url_flag_unbound_raises():
|
|
245
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
246
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
247
|
+
with pytest.raises(ValueError, match="must bind to a declared connection env"):
|
|
248
|
+
@app.command("run", help="run it")
|
|
249
|
+
@strictcli.flag("dsn", help="dsn", default=None, connection_url=True)
|
|
250
|
+
def run(ctx, dsn):
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_connection_url_flag_undeclared_binding_raises():
|
|
255
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
256
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
257
|
+
with pytest.raises(ValueError, match="undeclared connection env"):
|
|
258
|
+
@app.command("run", help="run it")
|
|
259
|
+
@strictcli.flag("dsn", help="dsn", default=None,
|
|
260
|
+
connection_url=True, connection_env="OTHER_URL")
|
|
261
|
+
def run(ctx, dsn):
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_connection_env_binding_without_url_marker_raises():
|
|
266
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
267
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
268
|
+
with pytest.raises(ValueError, match="requires the flag to be marked as a connection-URL flag"):
|
|
269
|
+
@app.command("run", help="run it")
|
|
270
|
+
@strictcli.flag("dsn", help="dsn", default=None, connection_env="DATABASE_URL")
|
|
271
|
+
def run(ctx, dsn):
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_connection_env_binding_plus_per_flag_env_raises():
|
|
276
|
+
app = App(name="myapp", version="1.0.0", help="t",
|
|
277
|
+
connection_env={"DATABASE_URL": "conn"})
|
|
278
|
+
with pytest.raises(ValueError, match="cannot be combined with a per-flag env var"):
|
|
279
|
+
@app.command("run", help="run it")
|
|
280
|
+
@strictcli.flag("dsn", help="dsn", default=None, env="SOMETHING_ELSE",
|
|
281
|
+
connection_url=True, connection_env="DATABASE_URL")
|
|
282
|
+
def run(ctx, dsn):
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_connection_env_empty_help_raises():
|
|
287
|
+
with pytest.raises(ValueError, match="help must be a non-empty string"):
|
|
288
|
+
App(name="myapp", version="1.0.0", help="t",
|
|
289
|
+
connection_env={"DATABASE_URL": ""})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_connection_env_collides_with_root_raises():
|
|
293
|
+
with pytest.raises(ValueError, match="already declared as an infra root"):
|
|
294
|
+
App(name="myapp", version="1.0.0", help="t",
|
|
295
|
+
infra_root={"SHARED": "/var/lib"},
|
|
296
|
+
connection_env={"SHARED": "conn"})
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_connection_env_collides_with_handshake_raises():
|
|
300
|
+
with pytest.raises(ValueError, match="already declared as a handshake env var"):
|
|
301
|
+
App(name="myapp", version="1.0.0", help="t",
|
|
302
|
+
handshake_env={"SHARED": "handshake"},
|
|
303
|
+
connection_env={"SHARED": "conn"})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|