strictcli 0.32.2__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.2 → strictcli-0.33.0}/PKG-INFO +1 -1
- {strictcli-0.32.2 → strictcli-0.33.0}/pyproject.toml +1 -1
- {strictcli-0.32.2 → strictcli-0.33.0}/strictcli/__init__.py +199 -23
- strictcli-0.33.0/tests/test_connection_env.py +269 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/uv.lock +1 -1
- {strictcli-0.32.2 → strictcli-0.33.0}/.claude/settings.json +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.gitignore +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/config.json +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/version +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/.strictcli/schema.json +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/CLAUDE.md +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/LICENSE +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/README.md +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/strictcli/py.typed +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/conftest.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_call.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_command.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_provider.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_types.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_choices.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_config.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_config_fields.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_context.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_coverage.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_dump_schema.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_e2e.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_env.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_float_format.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_float_type.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_float_vectors.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_help.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_infra_env.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_int_type.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_invoke.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_mcp.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_mutex.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_nesting.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_parser.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_provenance.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_registration.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_typed_args.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_unique.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_utilities.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_validate.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_variadic.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_visibility.py +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.32.2 → strictcli-0.33.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.32.2 → 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
|
|
|
@@ -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,42 @@ 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
|
+
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
|
+
|
|
2575
2683
|
@dataclass
|
|
2576
2684
|
class _CheckDef:
|
|
2577
2685
|
"""Internal definition of a single check loaded from TOML."""
|
|
@@ -2685,6 +2793,11 @@ class App:
|
|
|
2685
2793
|
# protocol env var -> its help string.
|
|
2686
2794
|
infra_root: dict[str, str] | None = None
|
|
2687
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
|
|
2688
2801
|
checks_path: str | Path | None = None
|
|
2689
2802
|
checks_embed: bytes | None = None
|
|
2690
2803
|
test_coverage: bool = False
|
|
@@ -2713,6 +2826,7 @@ class App:
|
|
|
2713
2826
|
self._global_flags: list[Flag] = list(self.flags)
|
|
2714
2827
|
self._last_global_values: dict[str, object] = {}
|
|
2715
2828
|
self._last_sources: dict[str, str] = {}
|
|
2829
|
+
self._last_hermetic: bool = False
|
|
2716
2830
|
|
|
2717
2831
|
# Resolve infrastructure roots eagerly, at construction. Infra vars have
|
|
2718
2832
|
# no argv dependency, so resolution is sound here -- and this is WHY it
|
|
@@ -2740,15 +2854,28 @@ class App:
|
|
|
2740
2854
|
raise ValueError(f'handshake env var "{ev}": help must be a non-empty string')
|
|
2741
2855
|
if ev in self._infra_roots:
|
|
2742
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)
|
|
2743
2868
|
# A shared frozenset of declared root names, threaded to commands/groups
|
|
2744
2869
|
# so flag-default markers can be validated at registration time.
|
|
2745
2870
|
self._infra_root_names: frozenset[str] = frozenset(self._infra_roots)
|
|
2746
2871
|
# Resolve the config-path marker (if any) now that roots exist.
|
|
2747
2872
|
if isinstance(self.config_path, RelativeToRoot):
|
|
2748
2873
|
self.config_path = _resolve_infra_root_path(self.config_path, self._infra_roots)
|
|
2749
|
-
# 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.
|
|
2750
2876
|
for f in self._global_flags:
|
|
2751
2877
|
self._validate_flag_infra_marker(f)
|
|
2878
|
+
_validate_connection_binding(f, self._connection_env_names)
|
|
2752
2879
|
|
|
2753
2880
|
# Validate config_format
|
|
2754
2881
|
if self.config_format not in ("json", "toml"):
|
|
@@ -2843,14 +2970,17 @@ class App:
|
|
|
2843
2970
|
f'root "{f.default.env_var}"; declare it as an infra root'
|
|
2844
2971
|
)
|
|
2845
2972
|
|
|
2846
|
-
def _infra_access(self) -> "_InfraAccess | None":
|
|
2973
|
+
def _infra_access(self, hermetic: bool = False) -> "_InfraAccess | None":
|
|
2847
2974
|
"""Snapshot infra data for a Context: resolved roots + declared handshake
|
|
2848
|
-
env var names
|
|
2849
|
-
|
|
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:
|
|
2850
2978
|
return None
|
|
2851
2979
|
return _InfraAccess(
|
|
2852
2980
|
roots=dict(self._infra_roots),
|
|
2853
2981
|
handshakes=set(self._handshake_envs),
|
|
2982
|
+
connections=set(self._connection_envs),
|
|
2983
|
+
hermetic=hermetic,
|
|
2854
2984
|
)
|
|
2855
2985
|
|
|
2856
2986
|
def _record_coverage(self, cmd_path: str) -> None:
|
|
@@ -3293,6 +3423,14 @@ class App:
|
|
|
3293
3423
|
"""
|
|
3294
3424
|
self._check_context_factory = factory
|
|
3295
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
|
+
|
|
3296
3434
|
def set_scope_adapter(self, adapter: Callable) -> None:
|
|
3297
3435
|
"""Set the scope adapter callback for scoped checks.
|
|
3298
3436
|
|
|
@@ -3540,7 +3678,7 @@ class App:
|
|
|
3540
3678
|
file=sys.stderr,
|
|
3541
3679
|
)
|
|
3542
3680
|
return 1
|
|
3543
|
-
context = app_ref._check_context_factory()
|
|
3681
|
+
context = app_ref._wrap_check_context(app_ref._check_context_factory())
|
|
3544
3682
|
# The check command executes all selected checks; the purity
|
|
3545
3683
|
# partition is an API-only mode (run_checks pure_only=), so nothing
|
|
3546
3684
|
# is ever left in the impure listing here.
|
|
@@ -3627,6 +3765,7 @@ class App:
|
|
|
3627
3765
|
config_fields=config_fields,
|
|
3628
3766
|
config_fields_ref=self._config_fields,
|
|
3629
3767
|
infra_root_names=self._infra_root_names,
|
|
3768
|
+
connection_env_names=self._connection_env_names,
|
|
3630
3769
|
)
|
|
3631
3770
|
self._commands[name] = cmd
|
|
3632
3771
|
return func
|
|
@@ -3643,7 +3782,8 @@ class App:
|
|
|
3643
3782
|
_accumulated_tags=own_tags,
|
|
3644
3783
|
hidden=hidden,
|
|
3645
3784
|
_config_fields_ref=self._config_fields,
|
|
3646
|
-
_infra_root_names=self._infra_root_names
|
|
3785
|
+
_infra_root_names=self._infra_root_names,
|
|
3786
|
+
_connection_env_names=self._connection_env_names)
|
|
3647
3787
|
self._groups[name] = grp
|
|
3648
3788
|
return grp
|
|
3649
3789
|
|
|
@@ -3774,8 +3914,8 @@ class App:
|
|
|
3774
3914
|
if not isinstance(cf.default, _MissingSentinel):
|
|
3775
3915
|
entry["default"] = cf.default
|
|
3776
3916
|
result[cf_name] = entry
|
|
3777
|
-
# Infrastructure section (roots + handshakes)
|
|
3778
|
-
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:
|
|
3779
3919
|
infra: dict = {}
|
|
3780
3920
|
for ev in app_ref._infra_root_order:
|
|
3781
3921
|
infra[ev] = {
|
|
@@ -3793,6 +3933,16 @@ class App:
|
|
|
3793
3933
|
if is_set:
|
|
3794
3934
|
hs_entry["value"] = os.environ[ev]
|
|
3795
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
|
|
3796
3946
|
result["__infrastructure__"] = infra
|
|
3797
3947
|
print(json.dumps(result, indent=2, sort_keys=True))
|
|
3798
3948
|
return 0
|
|
@@ -3832,8 +3982,8 @@ class App:
|
|
|
3832
3982
|
f" (source: {source})"
|
|
3833
3983
|
f" -- {cf.help}"
|
|
3834
3984
|
)
|
|
3835
|
-
# Infrastructure section (roots + handshakes)
|
|
3836
|
-
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:
|
|
3837
3987
|
print()
|
|
3838
3988
|
print("Infrastructure:")
|
|
3839
3989
|
for ev in app_ref._infra_root_order:
|
|
@@ -3844,6 +3994,11 @@ class App:
|
|
|
3844
3994
|
print(f" {ev} (handshake) = {os.environ[ev]} (set) -- {app_ref._handshake_envs[ev]}")
|
|
3845
3995
|
else:
|
|
3846
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]}")
|
|
3847
4002
|
return 0
|
|
3848
4003
|
|
|
3849
4004
|
config_show_flags = [
|
|
@@ -4257,6 +4412,9 @@ class App:
|
|
|
4257
4412
|
raise _ParseError(pre_scan["err"])
|
|
4258
4413
|
|
|
4259
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
|
|
4260
4418
|
|
|
4261
4419
|
# --hermetic + --config mutual exclusion
|
|
4262
4420
|
if is_hermetic and pre_scan.get("config_path"):
|
|
@@ -4878,7 +5036,7 @@ class App:
|
|
|
4878
5036
|
sys.exit(1)
|
|
4879
5037
|
else:
|
|
4880
5038
|
self._last_sources = sources
|
|
4881
|
-
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))
|
|
4882
5040
|
if cmd.passthrough is not None:
|
|
4883
5041
|
result = cmd.passthrough.handler(ctx, cmd.name, data, self._last_global_values)
|
|
4884
5042
|
else:
|
|
@@ -4951,7 +5109,7 @@ class App:
|
|
|
4951
5109
|
self._last_sources = sources
|
|
4952
5110
|
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
|
|
4953
5111
|
try:
|
|
4954
|
-
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))
|
|
4955
5113
|
if cmd.passthrough is not None:
|
|
4956
5114
|
handler_return = cmd.passthrough.handler(
|
|
4957
5115
|
ctx, cmd.name, data, self._last_global_values,
|
|
@@ -6060,6 +6218,7 @@ def _build_and_validate_command(
|
|
|
6060
6218
|
config_fields: list[str] | None = None,
|
|
6061
6219
|
config_fields_ref: dict[str, ConfigField] | None = None,
|
|
6062
6220
|
infra_root_names: frozenset[str] | None = None,
|
|
6221
|
+
connection_env_names: frozenset[str] | None = None,
|
|
6063
6222
|
) -> Command:
|
|
6064
6223
|
"""Build a Command from a decorated handler, validate everything."""
|
|
6065
6224
|
if not help or not help.strip():
|
|
@@ -6353,6 +6512,11 @@ def _build_and_validate_command(
|
|
|
6353
6512
|
f'undeclared infra root "{f.default.env_var}"; declare it as an infra root'
|
|
6354
6513
|
)
|
|
6355
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
|
+
|
|
6356
6520
|
return Command(
|
|
6357
6521
|
name=name,
|
|
6358
6522
|
help=help,
|
|
@@ -6385,6 +6549,8 @@ def flag(
|
|
|
6385
6549
|
repeatable: bool = False,
|
|
6386
6550
|
unique: object = _MISSING,
|
|
6387
6551
|
conflict_mode: object = _MISSING,
|
|
6552
|
+
connection_url: bool = False,
|
|
6553
|
+
connection_env: str | None = None,
|
|
6388
6554
|
) -> Callable[[F], F]:
|
|
6389
6555
|
"""Module-level decorator to attach a Flag to a command handler."""
|
|
6390
6556
|
|
|
@@ -6404,6 +6570,8 @@ def flag(
|
|
|
6404
6570
|
repeatable=repeatable,
|
|
6405
6571
|
unique=unique,
|
|
6406
6572
|
conflict_mode=conflict_mode,
|
|
6573
|
+
connection_url=connection_url,
|
|
6574
|
+
connection_env=connection_env,
|
|
6407
6575
|
)
|
|
6408
6576
|
if not hasattr(func, "_strictcli_flags"):
|
|
6409
6577
|
func._strictcli_flags = []
|
|
@@ -6498,11 +6666,11 @@ def _format_app_help(app: App) -> str:
|
|
|
6498
6666
|
padding = max_flag_len - len(flag_str) + 4
|
|
6499
6667
|
lines.append(f" {flag_str}{' ' * padding}{help_text}")
|
|
6500
6668
|
|
|
6501
|
-
if app._infra_root_order or app._handshake_order:
|
|
6669
|
+
if app._infra_root_order or app._handshake_order or app._connection_order:
|
|
6502
6670
|
lines.append("")
|
|
6503
6671
|
lines.append("Infrastructure:")
|
|
6504
6672
|
lines.append(" (location/handshake env vars; not suppressed by --hermetic)")
|
|
6505
|
-
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)
|
|
6506
6674
|
max_len = max(len(ev) for ev in all_evs)
|
|
6507
6675
|
for ev in app._infra_root_order:
|
|
6508
6676
|
padding = max_len - len(ev) + 4
|
|
@@ -6510,6 +6678,9 @@ def _format_app_help(app: App) -> str:
|
|
|
6510
6678
|
for ev in app._handshake_order:
|
|
6511
6679
|
padding = max_len - len(ev) + 4
|
|
6512
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]})")
|
|
6513
6684
|
|
|
6514
6685
|
lines.append("")
|
|
6515
6686
|
lines.append(f"Use '{app.name} <command> --help' for more information.")
|
|
@@ -7645,7 +7816,7 @@ def _dump_schema_core(app: App) -> dict:
|
|
|
7645
7816
|
# root values are intentionally EXCLUDED -- the schema must be machine-stable
|
|
7646
7817
|
# (not machine-specific). Only the declared env var and default path (both
|
|
7647
7818
|
# stable declarations) are emitted for roots.
|
|
7648
|
-
if app._infra_root_order or app._handshake_order:
|
|
7819
|
+
if app._infra_root_order or app._handshake_order or app._connection_order:
|
|
7649
7820
|
infra: dict = {}
|
|
7650
7821
|
if app._infra_root_order:
|
|
7651
7822
|
infra["roots"] = [
|
|
@@ -7657,6 +7828,11 @@ def _dump_schema_core(app: App) -> dict:
|
|
|
7657
7828
|
{"env_var": ev, "help": app._handshake_envs[ev]}
|
|
7658
7829
|
for ev in app._handshake_order
|
|
7659
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
|
+
]
|
|
7660
7836
|
schema["infra"] = infra
|
|
7661
7837
|
return schema
|
|
7662
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"})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|