strictcli 0.32.1__tar.gz → 0.33.0__tar.gz

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