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.
Files changed (88) hide show
  1. {strictcli-0.32.2 → strictcli-0.34.0}/PKG-INFO +1 -1
  2. {strictcli-0.32.2 → strictcli-0.34.0}/pyproject.toml +1 -1
  3. {strictcli-0.32.2 → strictcli-0.34.0}/strictcli/__init__.py +221 -23
  4. strictcli-0.34.0/tests/test_connection_env.py +303 -0
  5. {strictcli-0.32.2 → strictcli-0.34.0}/uv.lock +1 -1
  6. {strictcli-0.32.2 → strictcli-0.34.0}/.claude/settings.json +0 -0
  7. {strictcli-0.32.2 → strictcli-0.34.0}/.github/workflows/ci.yml +0 -0
  8. {strictcli-0.32.2 → strictcli-0.34.0}/.github/workflows/publish.yml +0 -0
  9. {strictcli-0.32.2 → strictcli-0.34.0}/.gitignore +0 -0
  10. {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  11. {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  12. {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/bases/.gitignore +0 -0
  13. {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  14. {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/config.json +0 -0
  15. {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/lint/python.toml +0 -0
  16. {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/managed-files.json +0 -0
  17. {strictcli-0.32.2 → strictcli-0.34.0}/.rlsbl/version +0 -0
  18. {strictcli-0.32.2 → strictcli-0.34.0}/.strictcli/schema.json +0 -0
  19. {strictcli-0.32.2 → strictcli-0.34.0}/CLAUDE.md +0 -0
  20. {strictcli-0.32.2 → strictcli-0.34.0}/LICENSE +0 -0
  21. {strictcli-0.32.2 → strictcli-0.34.0}/README.md +0 -0
  22. {strictcli-0.32.2 → strictcli-0.34.0}/strictcli/py.typed +0 -0
  23. {strictcli-0.32.2 → strictcli-0.34.0}/tests/conftest.py +0 -0
  24. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_arg_default.py +0 -0
  25. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_arg_default_validation.py +0 -0
  26. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_at_prefix.py +0 -0
  27. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_auto_version.py +0 -0
  28. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_call.py +0 -0
  29. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_command.py +0 -0
  30. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_discovery.py +0 -0
  31. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_provider.py +0 -0
  32. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_public_api.py +0 -0
  33. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_runner.py +0 -0
  34. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_schema.py +0 -0
  35. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_check_types.py +0 -0
  36. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_choices.py +0 -0
  37. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_choices_none.py +0 -0
  38. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_command_help_suggestion.py +0 -0
  39. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_command_tags.py +0 -0
  40. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_compound_types.py +0 -0
  41. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_config.py +0 -0
  42. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_config_fields.py +0 -0
  43. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_config_file_path.py +0 -0
  44. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_config_set_bugs.py +0 -0
  45. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_context.py +0 -0
  46. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_coverage.py +0 -0
  47. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_deep_nesting.py +0 -0
  48. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_dependencies.py +0 -0
  49. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_deprecated.py +0 -0
  50. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_dump_schema.py +0 -0
  51. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_e2e.py +0 -0
  52. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_env.py +0 -0
  53. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_exit_codes.py +0 -0
  54. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_flag_sets.py +0 -0
  55. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_float_format.py +0 -0
  56. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_float_type.py +0 -0
  57. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_float_vectors.py +0 -0
  58. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_global_flag_conflict_position.py +0 -0
  59. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_global_flags.py +0 -0
  60. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_help.py +0 -0
  61. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_hermetic.py +0 -0
  62. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_infra_env.py +0 -0
  63. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_int_type.py +0 -0
  64. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_invoke.py +0 -0
  65. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_keyword_flags.py +0 -0
  66. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_mcp.py +0 -0
  67. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_mutex.py +0 -0
  68. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_nesting.py +0 -0
  69. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_parser.py +0 -0
  70. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_passthrough.py +0 -0
  71. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_provenance.py +0 -0
  72. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_provenance_phase2.py +0 -0
  73. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_registration.py +0 -0
  74. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_repeatable.py +0 -0
  75. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_reserved_global_flags.py +0 -0
  76. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_tagdsl.py +0 -0
  77. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_toml_loading.py +0 -0
  78. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_tool_export.py +0 -0
  79. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_typed_args.py +0 -0
  80. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_unique.py +0 -0
  81. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_utilities.py +0 -0
  82. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_validate.py +0 -0
  83. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_variadic.py +0 -0
  84. {strictcli-0.32.2 → strictcli-0.34.0}/tests/test_visibility.py +0 -0
  85. {strictcli-0.32.2 → strictcli-0.34.0}/todo/.defer/deferred.md +0 -0
  86. {strictcli-0.32.2 → strictcli-0.34.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  87. {strictcli-0.32.2 → strictcli-0.34.0}/todo/.done/original-idea.md +0 -0
  88. {strictcli-0.32.2 → strictcli-0.34.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.32.2
3
+ Version: 0.34.0
4
4
  Summary: A strict CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.32.2"
7
+ version = "0.34.0"
8
8
  description = "A strict CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,13 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.32.2"
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) and declared handshake env vars (read live)."""
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]) -> None:
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
- Raises KeyError if env_var is neither a declared root nor handshake var.
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 handshake env var'
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. Returns None when nothing is declared."""
2849
- if not self._infra_roots and not self._handshake_envs:
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"})
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.32.2"
235
+ version = "0.34.0"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "tomlkit" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes