Skip to content

Commit 61b60ef

Browse files
committed
Merge remote-tracking branch 'origin/main' into bug/341-20260918-reject-nested-run
# Conflicts: # CHANGELOG.md
2 parents e379ee6 + 4084636 commit 61b60ef

13 files changed

Lines changed: 170 additions & 21 deletions

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ and versions are tracked in the repo-root `VERSION` file.
2525

2626
- Reject recursive and concurrent in-process `run_app()` calls before they can
2727
replace another invocation's stdout or logging handlers.
28+
- Resolve lifecycle values through the active Typer/Click context for attached
29+
commands, including renamed options, defaults, and environment variables.
30+
- Reject non-finite numbers in JSON and NDJSON output so emitted records remain
31+
standards-compliant and failed NDJSON writes do not leave partial records.
2832

2933
- Honor all five validated framework `log_level` values on native and attached
3034
user-facing streams while preserving DEBUG-level persistent diagnostics.

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,9 @@ subcommand. Disabled and hidden options do not appear in help; renamed options
643643
appear only under their configured declarations.
644644

645645
Normalized values are available as one typed `LifecycleValues` record in the
646-
active Click context's namespaced metadata:
646+
active Click context's namespaced metadata. The context argument is optional;
647+
when omitted, base-cli resolves the active upstream Click or supported Typer
648+
context automatically:
647649

648650
```python
649651
@click.pass_context

‎docs/json-contracts.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ contains the numeric `exit_code` and captured command stdout. A command's
5858
human output is represented as a JSON string, so it cannot introduce prose or
5959
ANSI escapes as a second stdout record.
6060

61+
All JSON and NDJSON emitters use strict JSON serialization and reject
62+
non-finite numeric values (`NaN`, positive infinity, and negative infinity).
63+
An invalid NDJSON record is fully serialized before it is written, so it does
64+
not leave a partial line in the output stream.
65+
6166
`run_id` is the lifecycle run identifier when startup reached a runtime
6267
context, otherwise it is `null`. Unexpected failures intentionally expose only
6368
the generic message `Unexpected internal error.`; diagnostics stay in logs.

‎lib/python/base_cli/_click_compat.py‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,16 @@ def _vendored_typer_dialect(typer: Any) -> _VendoredClickDialect | None:
9797
except (ImportError, AttributeError):
9898
return None
9999

100-
core = module.core
101-
exceptions = module.exceptions
100+
core = getattr(module, "core", None)
101+
exceptions = getattr(module, "exceptions", None)
102+
echo = getattr(module, "echo", None)
103+
click_exception = getattr(module, "ClickException", None)
104+
abort = getattr(module, "Abort", getattr(exceptions, "Abort", getattr(core, "Abort", None)))
105+
usage_error = getattr(module, "UsageError", getattr(core, "UsageError", None))
106+
if core is None or exceptions is None or not callable(echo):
107+
return None
108+
if not isinstance(abort, type) or not isinstance(usage_error, type) or not isinstance(click_exception, type):
109+
return None
102110

103111
def option(param_decls: list[str], **attrs: Any) -> Any:
104112
return TyperOption(param_decls=list(param_decls), **attrs)
@@ -108,11 +116,11 @@ def option(param_decls: list[str], **attrs: Any) -> Any:
108116
Command=module.Command,
109117
Option=option,
110118
Path=TyperPath,
111-
version_option=_vendor_version_option_factory(TyperOption, module.echo),
119+
version_option=_vendor_version_option_factory(TyperOption, echo),
112120
exceptions=exceptions,
113-
Abort=getattr(module, "Abort", getattr(exceptions, "Abort", core.Abort)),
114-
UsageError=getattr(module, "UsageError", core.UsageError),
115-
ClickException=module.ClickException,
121+
Abort=abort,
122+
UsageError=usage_error,
123+
ClickException=click_exception,
116124
)
117125

118126

@@ -125,6 +133,21 @@ def dialect_for_typer(typer: Any) -> Any:
125133
return dialect if dialect is not None else click
126134

127135

136+
def current_context_candidates(typer: Any, click: Any) -> list[Any]:
137+
"""Return active contexts from Typer's dialect followed by public Click."""
138+
139+
dialect = dialect_for_typer(typer)
140+
candidates: list[Any] = []
141+
if dialect is not click:
142+
get_context = getattr(dialect, "get_current_context", None)
143+
if get_context is None:
144+
get_context = getattr(getattr(dialect, "globals", None), "get_current_context", None)
145+
if callable(get_context):
146+
candidates.append(get_context(silent=True))
147+
candidates.append(click.get_current_context(silent=True))
148+
return candidates
149+
150+
128151
def exit_exception_type(click: Any) -> type[BaseException]:
129152
"""Return the owning dialect's exit exception across Click variants.
130153
@@ -189,4 +212,11 @@ def is_command(command: Any) -> bool:
189212
return dialect is not click and isinstance(command, dialect.Command)
190213

191214

192-
__all__ = ["dialect_for_command", "dialect_for_typer", "exit_exception_type", "is_command", "mark_command_dialect"]
215+
__all__ = [
216+
"current_context_candidates",
217+
"dialect_for_command",
218+
"dialect_for_typer",
219+
"exit_exception_type",
220+
"is_command",
221+
"mark_command_dialect",
222+
]

‎lib/python/base_cli/inspection.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from __future__ import annotations
22

3-
import json
43
from collections.abc import Mapping
54
from typing import Any, Literal
65

6+
from .json_contracts import dumps_strict_json
7+
78
InspectionStatus = Literal["ok", "warn", "error"]
89

910

@@ -35,7 +36,7 @@ def render_inspection_json(
3536
) -> str:
3637
"""Serialize the stable inspection envelope with Python's JSON encoder."""
3738
return (
38-
json.dumps(
39+
dumps_strict_json(
3940
inspection_envelope(command=command, status=status, data=data, error=error),
4041
ensure_ascii=False,
4142
indent=2,

‎lib/python/base_cli/json_contracts.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"error_envelope",
4444
"success_envelope",
4545
"dumps_envelope",
46+
"dumps_strict_json",
4647
"redact_json_value",
4748
]
4849

@@ -91,7 +92,7 @@ def dumps_envelope(envelope: Mapping[str, Any]) -> str:
9192
"""Serialize an envelope as one compact, newline-terminated JSON record."""
9293

9394
return (
94-
json.dumps(
95+
dumps_strict_json(
9596
redact_json_value(dict(envelope)),
9697
ensure_ascii=False,
9798
separators=(",", ":"),
@@ -100,6 +101,13 @@ def dumps_envelope(envelope: Mapping[str, Any]) -> str:
100101
)
101102

102103

104+
def dumps_strict_json(value: Any, **kwargs: Any) -> str:
105+
"""Serialize JSON while rejecting non-finite numeric values."""
106+
107+
kwargs["allow_nan"] = False
108+
return json.dumps(value, **kwargs)
109+
110+
103111
def redact_json_value(value: Any, *, _key: str | None = None) -> Any:
104112
"""Recursively redact secret-looking JSON keys and text values."""
105113

@@ -140,7 +148,7 @@ def format(self, record: LogRecord) -> str:
140148
payload["details"] = {
141149
"exception_type": record.exc_info[0].__name__,
142150
}
143-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
151+
return dumps_strict_json(payload, ensure_ascii=False, separators=(",", ":"))
144152

145153

146154
def _timestamp(value: float) -> str:

‎lib/python/base_cli/lifecycle_options.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,26 @@ def get_lifecycle_values(click_context: Any | None = None) -> LifecycleValues:
186186
import click
187187
except ImportError as exc:
188188
raise RuntimeError("Click is required to inspect lifecycle option values.") from exc
189-
click_context = click.get_current_context(silent=True)
189+
candidates: list[Any] = []
190+
try:
191+
import typer
192+
except ImportError:
193+
pass
194+
else:
195+
from ._click_compat import current_context_candidates
196+
197+
candidates.extend(current_context_candidates(typer, click))
198+
if not candidates:
199+
candidates.append(click.get_current_context(silent=True))
200+
click_context = next(
201+
(
202+
candidate
203+
for candidate in candidates
204+
if candidate is not None
205+
and isinstance(getattr(candidate, "meta", {}).get(LIFECYCLE_META_KEY), LifecycleValues)
206+
),
207+
next((candidate for candidate in candidates if candidate is not None), None),
208+
)
190209
if click_context is None:
191210
raise RuntimeError("Lifecycle option values are not available outside a Click invocation.")
192211
value = getattr(click_context, "meta", {}).get(LIFECYCLE_META_KEY)

‎lib/python/base_cli/output.py‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from ._dependencies import require_yaml
1717
from .integrations import try_render_rich_table
18+
from .json_contracts import dumps_strict_json
1819

1920
PUBLIC_OUTPUT_FORMATS = ("text", "csv", "tsv", "yaml", "json", "ndjson")
2021
NDJSON_SCHEMA = "base-cli.record"
@@ -62,7 +63,10 @@ def write(self, record: StructuredRecord) -> None:
6263
"schema": self.schema,
6364
"record": dict(record),
6465
}
65-
self.stream.write(json.dumps(payload, separators=(",", ":")))
66+
# Serialize the complete record before touching the sink: with strict
67+
# JSON, a nested NaN/Infinity must not leave a partial NDJSON line.
68+
encoded = json.dumps(payload, separators=(",", ":"), allow_nan=False)
69+
self.stream.write(encoded)
6670
self.stream.write("\n")
6771
self.stream.flush()
6872

@@ -135,21 +139,23 @@ def render_records(
135139
resolved = resolve_output_format(requested_format, stream=target)
136140

137141
if resolved in ("csv", "tsv"):
142+
record_list = [dict(record) for record in records]
143+
_validate_delimited_records(record_list, columns)
138144
delimiter = "," if resolved == "csv" else "\t"
139145
writer = csv.writer(target, delimiter=delimiter, lineterminator="\n")
140-
for record in records:
141-
writer.writerow([_delimited_value(record.get(key)) for _header, key in columns])
146+
for row in record_list:
147+
writer.writerow([_delimited_value(row.get(key)) for _header, key in columns])
142148
return resolved
143149

144150
if resolved == "ndjson":
145151
ndjson_writer = NdjsonWriter(target)
146-
for record in records:
147-
ndjson_writer.write(record)
152+
for structured_record in records:
153+
ndjson_writer.write(structured_record)
148154
return resolved
149155

150156
record_list = [dict(record) for record in records]
151157
if resolved == "json":
152-
target.write(json.dumps(record_list, separators=(",", ":")))
158+
target.write(dumps_strict_json(record_list, separators=(",", ":")))
153159
target.write("\n")
154160
return resolved
155161

@@ -195,7 +201,7 @@ def render_document(
195201
if resolved == "text":
196202
return resolved
197203
if resolved == "json":
198-
target.write(json.dumps(dict(document), indent=2))
204+
target.write(dumps_strict_json(dict(document), indent=2))
199205
target.write("\n")
200206
return resolved
201207
if resolved == "yaml":
@@ -249,10 +255,23 @@ def _cell_value(value: Any) -> str:
249255
if isinstance(value, bool):
250256
return "true" if value else "false"
251257
if isinstance(value, (Mapping, list, tuple)):
252-
return json.dumps(value, separators=(",", ":"))
258+
return dumps_strict_json(value, separators=(",", ":"))
253259
return str(value)
254260

255261

262+
def _validate_delimited_records(
263+
records: Sequence[Mapping[str, Any]],
264+
columns: Sequence[tuple[str, str]],
265+
) -> None:
266+
"""Validate nested cell values before a delimited stream is touched."""
267+
268+
for record in records:
269+
for _header, key in columns:
270+
value = record.get(key)
271+
if isinstance(value, (Mapping, list, tuple)):
272+
dumps_strict_json(value, separators=(",", ":"))
273+
274+
256275
def _delimited_value(value: Any) -> str:
257276
"""Return a safe scalar for redirected CSV/TSV output.
258277

‎tests/test_json_contracts.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ def test_envelopes_have_stable_fields_and_recursive_redaction(self) -> None:
5252
self.assertEqual(failure["message"], "authorization=[REDACTED]")
5353
self.assertEqual(json.loads(base_cli.dumps_envelope(failure)), failure)
5454

55+
def test_json_contract_emitters_reject_nested_non_finite_values(self) -> None:
56+
invalid = {"nested": [{"value": float("inf")}]}
57+
envelope = base_cli.success_envelope(run_id=None, details=invalid)
58+
with self.assertRaises(ValueError):
59+
base_cli.dumps_envelope(envelope)
60+
5561
def test_inline_secret_redaction_keeps_delimiters_inside_values(self) -> None:
5662
for value in ("abc,def", "abc;def"):
5763
with self.subTest(value=value):

‎tests/test_output.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,32 @@ def isatty(self) -> bool:
6565

6666

6767
class OutputTest(unittest.TestCase):
68+
def test_json_emitters_reject_non_finite_values_without_partial_output(self) -> None:
69+
invalid_record = {"nested": [{"value": float("nan")}]}
70+
emitters = (
71+
lambda stream: render_records((invalid_record,), requested_format="json", columns=(), stream=stream),
72+
lambda stream: render_document(invalid_record, requested_format="json", stream=stream),
73+
lambda stream: NdjsonWriter(stream).write(invalid_record),
74+
)
75+
76+
for emit in emitters:
77+
with self.subTest(emit=emit):
78+
stream = io.StringIO()
79+
with self.assertRaises(ValueError):
80+
emit(stream)
81+
self.assertEqual(stream.getvalue(), "")
82+
83+
def test_delimited_emitters_validate_nested_values_before_writing(self) -> None:
84+
records = ({"name": "valid"}, {"name": {"value": float("nan")}})
85+
for requested_format in ("csv", "tsv"):
86+
with self.subTest(format=requested_format):
87+
stream = io.StringIO()
88+
with self.assertRaises(ValueError):
89+
render_records(
90+
records, requested_format=requested_format, columns=(("NAME", "name"),), stream=stream
91+
)
92+
self.assertEqual(stream.getvalue(), "")
93+
6894
def test_tsv_consumes_one_pass_iterable_without_materializing(self) -> None:
6995
consumed = False
7096

0 commit comments

Comments
 (0)