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.
Files changed (88) hide show
  1. {strictcli-0.32.2 → strictcli-0.33.0}/PKG-INFO +1 -1
  2. {strictcli-0.32.2 → strictcli-0.33.0}/pyproject.toml +1 -1
  3. {strictcli-0.32.2 → strictcli-0.33.0}/strictcli/__init__.py +199 -23
  4. strictcli-0.33.0/tests/test_connection_env.py +269 -0
  5. {strictcli-0.32.2 → strictcli-0.33.0}/uv.lock +1 -1
  6. {strictcli-0.32.2 → strictcli-0.33.0}/.claude/settings.json +0 -0
  7. {strictcli-0.32.2 → strictcli-0.33.0}/.github/workflows/ci.yml +0 -0
  8. {strictcli-0.32.2 → strictcli-0.33.0}/.github/workflows/publish.yml +0 -0
  9. {strictcli-0.32.2 → strictcli-0.33.0}/.gitignore +0 -0
  10. {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  11. {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  12. {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/bases/.gitignore +0 -0
  13. {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  14. {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/config.json +0 -0
  15. {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/lint/python.toml +0 -0
  16. {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/managed-files.json +0 -0
  17. {strictcli-0.32.2 → strictcli-0.33.0}/.rlsbl/version +0 -0
  18. {strictcli-0.32.2 → strictcli-0.33.0}/.strictcli/schema.json +0 -0
  19. {strictcli-0.32.2 → strictcli-0.33.0}/CLAUDE.md +0 -0
  20. {strictcli-0.32.2 → strictcli-0.33.0}/LICENSE +0 -0
  21. {strictcli-0.32.2 → strictcli-0.33.0}/README.md +0 -0
  22. {strictcli-0.32.2 → strictcli-0.33.0}/strictcli/py.typed +0 -0
  23. {strictcli-0.32.2 → strictcli-0.33.0}/tests/conftest.py +0 -0
  24. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_arg_default.py +0 -0
  25. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_arg_default_validation.py +0 -0
  26. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_at_prefix.py +0 -0
  27. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_auto_version.py +0 -0
  28. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_call.py +0 -0
  29. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_command.py +0 -0
  30. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_discovery.py +0 -0
  31. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_provider.py +0 -0
  32. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_public_api.py +0 -0
  33. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_runner.py +0 -0
  34. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_schema.py +0 -0
  35. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_check_types.py +0 -0
  36. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_choices.py +0 -0
  37. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_choices_none.py +0 -0
  38. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_command_help_suggestion.py +0 -0
  39. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_command_tags.py +0 -0
  40. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_compound_types.py +0 -0
  41. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_config.py +0 -0
  42. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_config_fields.py +0 -0
  43. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_config_file_path.py +0 -0
  44. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_config_set_bugs.py +0 -0
  45. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_context.py +0 -0
  46. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_coverage.py +0 -0
  47. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_deep_nesting.py +0 -0
  48. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_dependencies.py +0 -0
  49. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_deprecated.py +0 -0
  50. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_dump_schema.py +0 -0
  51. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_e2e.py +0 -0
  52. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_env.py +0 -0
  53. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_exit_codes.py +0 -0
  54. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_flag_sets.py +0 -0
  55. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_float_format.py +0 -0
  56. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_float_type.py +0 -0
  57. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_float_vectors.py +0 -0
  58. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_global_flag_conflict_position.py +0 -0
  59. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_global_flags.py +0 -0
  60. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_help.py +0 -0
  61. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_hermetic.py +0 -0
  62. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_infra_env.py +0 -0
  63. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_int_type.py +0 -0
  64. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_invoke.py +0 -0
  65. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_keyword_flags.py +0 -0
  66. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_mcp.py +0 -0
  67. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_mutex.py +0 -0
  68. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_nesting.py +0 -0
  69. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_parser.py +0 -0
  70. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_passthrough.py +0 -0
  71. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_provenance.py +0 -0
  72. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_provenance_phase2.py +0 -0
  73. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_registration.py +0 -0
  74. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_repeatable.py +0 -0
  75. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_reserved_global_flags.py +0 -0
  76. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_tagdsl.py +0 -0
  77. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_toml_loading.py +0 -0
  78. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_tool_export.py +0 -0
  79. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_typed_args.py +0 -0
  80. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_unique.py +0 -0
  81. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_utilities.py +0 -0
  82. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_validate.py +0 -0
  83. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_variadic.py +0 -0
  84. {strictcli-0.32.2 → strictcli-0.33.0}/tests/test_visibility.py +0 -0
  85. {strictcli-0.32.2 → strictcli-0.33.0}/todo/.defer/deferred.md +0 -0
  86. {strictcli-0.32.2 → strictcli-0.33.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  87. {strictcli-0.32.2 → strictcli-0.33.0}/todo/.done/original-idea.md +0 -0
  88. {strictcli-0.32.2 → strictcli-0.33.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.33.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.33.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.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) 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,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. Returns None when nothing is declared."""
2849
- if not self._infra_roots and not self._handshake_envs:
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"})
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.32.2"
235
+ version = "0.33.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