agent-first-data 0.35.0__tar.gz → 0.36.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 (19) hide show
  1. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/PKG-INFO +6 -4
  2. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/README.md +4 -2
  3. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/cli-spec-v1.schema.json +5 -2
  4. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/cli.py +31 -4
  5. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/format.py +338 -100
  6. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/PKG-INFO +6 -4
  7. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/pyproject.toml +2 -2
  8. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/tests/test_cli.py +35 -4
  9. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/tests/test_format.py +54 -9
  10. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/__init__.py +0 -0
  11. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/cli-help-v2.schema.json +0 -0
  12. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/protocol-v1.schema.json +0 -0
  13. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/registry.json +0 -0
  14. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/SOURCES.txt +0 -0
  15. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/dependency_links.txt +0 -0
  16. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/top_level.txt +0 -0
  17. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/setup.cfg +0 -0
  18. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/tests/test_no_stderr_policy.py +0 -0
  19. {agent_first_data-0.35.0 → agent_first_data-0.36.0}/tests/test_requires_python_floor.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.35.0
4
- Summary: A naming convention that lets AI agents understand your data without being told what it means, plus a CLI and library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.
3
+ Version: 0.36.0
4
+ Summary: A naming convention that lets AI agents understand your data without being told what it means, plus the afdata CLI and a Rust library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/agentfirstkit/agent-first-data
7
7
  Requires-Python: >=3.9
@@ -28,10 +28,12 @@ print(render(value, OutputFormat.JSON))
28
28
  print(render(value, OutputFormat.PLAIN))
29
29
  ```
30
30
 
31
- Useful names use Python casing: `render` (the single value x format x options -> str entry point; takes an `OutputFormat` and an optional keyword-only `options=`), `redacted_value`, `redact_url_secrets`, `redact_urls_in_text`, `redact_argv`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `is_valid_rfc3339`, `is_valid_bcp47`, `cli_parse_output`, `build_cli_error`, `build_cli_version`, `decode_protocol_event`.
31
+ Useful names use Python casing: `render` (the single value x format x options -> str entry point; takes an `OutputFormat` and an optional keyword-only `options=`), `redacted_value`, `redact_url_secrets`, `redact_urls_in_text`, `redact_argv`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `is_valid_rfc3339`, `is_valid_bcp47`, `cli_parse_output`, `build_cli_error`, `build_cli_version`, `cli_parse_log_filters`, `CliEmitter`, `decode_protocol_event`.
32
32
 
33
33
  ## Behavior Notes
34
34
 
35
+ - `OutputOptions(reveal_names=...)` reveals exact structured field names at the selected output boundary. Children still pass through redaction; URL parameters and argv receive no exemption.
36
+ - The protocol reader accepts base-profile events. An absent `error.retryable` remains nullable; strict validation is a separate opt-in.
35
37
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
36
38
  - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed, and internal whitespace or a `://` that is not the start of a clean URL redacts the whole field, where whitespace is the explicit Unicode `White_Space` set the spec enumerates, not the language's native predicate.
37
39
  - `OutputOptions.url_names` applies URL treatment to exact legacy field names
@@ -40,7 +42,7 @@ Useful names use Python casing: `render` (the single value x format x options ->
40
42
  - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON; it quotes a value only when it contains ASCII space, tab, newline, carriage return, form feed, vertical tab, NBSP, `=`, `"`, or `\`, and escapes `\`, `"`, newline, carriage return, tab, form feed, and vertical tab inside the quotes.
41
43
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
42
44
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
43
- - Do not add a raw version/help pre-parser. Until the Python `CliSpec` compiler lands, applications should keep lifecycle parsing in their own parser rather than claim AFDATA closed-world help compatibility.
45
+ - Do not add a raw version/help pre-parser. This SDK provides emitters and readers; argv and lifecycle parsing belong to the application. Claim help-v2 or closed-world compatibility only when using a registry compiler.
44
46
 
45
47
  ## Reference
46
48
 
@@ -19,10 +19,12 @@ print(render(value, OutputFormat.JSON))
19
19
  print(render(value, OutputFormat.PLAIN))
20
20
  ```
21
21
 
22
- Useful names use Python casing: `render` (the single value x format x options -> str entry point; takes an `OutputFormat` and an optional keyword-only `options=`), `redacted_value`, `redact_url_secrets`, `redact_urls_in_text`, `redact_argv`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `is_valid_rfc3339`, `is_valid_bcp47`, `cli_parse_output`, `build_cli_error`, `build_cli_version`, `decode_protocol_event`.
22
+ Useful names use Python casing: `render` (the single value x format x options -> str entry point; takes an `OutputFormat` and an optional keyword-only `options=`), `redacted_value`, `redact_url_secrets`, `redact_urls_in_text`, `redact_argv`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `is_valid_rfc3339`, `is_valid_bcp47`, `cli_parse_output`, `build_cli_error`, `build_cli_version`, `cli_parse_log_filters`, `CliEmitter`, `decode_protocol_event`.
23
23
 
24
24
  ## Behavior Notes
25
25
 
26
+ - `OutputOptions(reveal_names=...)` reveals exact structured field names at the selected output boundary. Children still pass through redaction; URL parameters and argv receive no exemption.
27
+ - The protocol reader accepts base-profile events. An absent `error.retryable` remains nullable; strict validation is a separate opt-in.
26
28
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
27
29
  - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed, and internal whitespace or a `://` that is not the start of a clean URL redacts the whole field, where whitespace is the explicit Unicode `White_Space` set the spec enumerates, not the language's native predicate.
28
30
  - `OutputOptions.url_names` applies URL treatment to exact legacy field names
@@ -31,7 +33,7 @@ Useful names use Python casing: `render` (the single value x format x options ->
31
33
  - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON; it quotes a value only when it contains ASCII space, tab, newline, carriage return, form feed, vertical tab, NBSP, `=`, `"`, or `\`, and escapes `\`, `"`, newline, carriage return, tab, form feed, and vertical tab inside the quotes.
32
34
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
33
35
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
34
- - Do not add a raw version/help pre-parser. Until the Python `CliSpec` compiler lands, applications should keep lifecycle parsing in their own parser rather than claim AFDATA closed-world help compatibility.
36
+ - Do not add a raw version/help pre-parser. This SDK provides emitters and readers; argv and lifecycle parsing belong to the application. Claim help-v2 or closed-world compatibility only when using a registry compiler.
35
37
 
36
38
  ## Reference
37
39
 
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "exit_codes": {
46
46
  "type": "array",
47
- "description": "Exit codes this CLI returns beyond AFDATA's 0/1/2, rendered into the reference's exit-code table. 0/1/2 are AFDATA's own and may not be redeclared.",
47
+ "description": "Exit codes this CLI returns beyond AFDATA's 0/1/2/4, rendered into the reference's exit-code table. AFDATA's codes may not be redeclared.",
48
48
  "items": {
49
49
  "type": "object",
50
50
  "additionalProperties": false,
@@ -56,7 +56,10 @@
56
56
  "code": {
57
57
  "type": "integer",
58
58
  "minimum": 3,
59
- "maximum": 255
59
+ "maximum": 255,
60
+ "not": {
61
+ "const": 4
62
+ }
60
63
  },
61
64
  "meaning": {
62
65
  "$ref": "#/$defs/nonEmpty"
@@ -210,6 +210,7 @@ class CliEmitter:
210
210
  self._output_options = output_options or OutputOptions()
211
211
  self._strict_protocol = strict_protocol
212
212
  self._terminal_emitted = False
213
+ self._write_failed = False
213
214
 
214
215
  def with_strict_protocol(self) -> CliEmitter:
215
216
  """Opt this emitter into the AFDATA recommended strict profile."""
@@ -277,6 +278,7 @@ class CliEmitter:
277
278
 
278
279
  def emit(self, event: Event | dict) -> None:
279
280
  """Emit a typed Event or a dict (for compatibility)."""
281
+ self._write_failed = False
280
282
  if isinstance(event, Event):
281
283
  envelope = event.to_dict()
282
284
  else:
@@ -320,6 +322,10 @@ class CliEmitter:
320
322
  finally:
321
323
  os.close(null_descriptor)
322
324
  raise
325
+ except Exception:
326
+ # Marks the failure as output's, not the event's, for finish().
327
+ self._write_failed = True
328
+ raise
323
329
  if kind in ("result", "error"):
324
330
  self._terminal_emitted = True
325
331
 
@@ -363,19 +369,40 @@ class CliEmitter:
363
369
  process exit code, so a one-shot CLI need not hand-roll the
364
370
  emit-then-exit dance.
365
371
 
366
- Returns ``success_code`` on a successful write; ``0`` if the write failed
367
- because the reader hung up (broken pipe); ``4`` on any other emit,
368
- validation, or write failure. A library never calls :func:`sys.exit`
369
- itself — return this code from the program's entry point.
372
+ Returns ``success_code`` on a successful write; ``0``, saying nothing,
373
+ if the write failed because the reader hung up (broken pipe). Any other
374
+ write failure returns ``4`` after reporting ``output_write_failed`` as
375
+ an ``error`` event: on the diagnostic writer in finite mode, on
376
+ ``sys.stderr`` in event-stream mode, whose only writer is the one that
377
+ just failed. That report is best effort. An event the emitter refuses
378
+ (a protocol violation or a second terminal event) is the command's own
379
+ failure, not an output one, and returns ``1``. A library never calls
380
+ :func:`sys.exit` itself — return this code from the program's entry
381
+ point.
370
382
  """
371
383
  try:
372
384
  self.emit(event)
373
385
  except BrokenPipeError:
374
386
  return 0
375
387
  except Exception:
388
+ if not self._write_failed:
389
+ return 1
390
+ self._report_output_write_failed()
376
391
  return 4
377
392
  return success_code
378
393
 
394
+ def _report_output_write_failed(self) -> None:
395
+ event = json_error("output_write_failed", "could not write command output").build()
396
+ try:
397
+ if self._diagnostic is not None:
398
+ self.emit(event)
399
+ else:
400
+ # Event-stream mode has no diagnostic writer, so the report
401
+ # goes to the process's stderr through an emitter of its own.
402
+ CliEmitter.stream(sys.stderr, self._format).with_strict_protocol().emit(event)
403
+ except Exception:
404
+ pass
405
+
379
406
  def finish_result(self, payload: Any) -> int:
380
407
  """Convenience over :meth:`finish`: emit a ``result`` payload and return
381
408
  ``0`` on success.
@@ -51,7 +51,7 @@ class EventBuildError(Exception):
51
51
 
52
52
 
53
53
  class Event:
54
- """Opaque typed event envelope wrapping a validated protocol dict."""
54
+ """SDK event envelope; validate externally supplied trace values explicitly."""
55
55
 
56
56
  def __init__(self, envelope: dict) -> None:
57
57
  """Private: only builders and validators construct Events."""
@@ -302,7 +302,7 @@ def _validate_error_payload(error: Any) -> None:
302
302
  if not isinstance(message, str) or message == "":
303
303
  raise ValueError("event.error.message must be a non-empty string")
304
304
  if "retryable" in error and not isinstance(error["retryable"], bool):
305
- raise ValueError("event.error.retryable must be a boolean")
305
+ raise ValueError("event.error.retryable must be a boolean when present")
306
306
  if "hint" in error and not isinstance(error["hint"], str):
307
307
  raise ValueError("event.error.hint must be a string when present")
308
308
 
@@ -364,11 +364,9 @@ class _RawNumber:
364
364
  """Internal: a decoded JSON number whose exact source literal must
365
365
  survive re-emission (see decode_protocol_event's number-fidelity note).
366
366
 
367
- Deliberately not an int/float subclass: Part 2's suffix-driven formatting
368
- (`_is_number`, `_as_int`, ...) checks `isinstance(value, (int, float))`,
369
- so a `_RawNumber` gracefully falls through to the existing "wrong type"
370
- passthrough there rather than risking a lossy or crashing arithmetic
371
- conversion. JSON/YAML/plain leaf rendering special-case it directly.
367
+ Deliberately not an int/float subclass. Serializers treat it as an opaque
368
+ scalar; integer-required suffixes inspect its exact decimal digits, while
369
+ unit scaling uses a bounded floating conversion only where needed.
372
370
  """
373
371
 
374
372
  __slots__ = ("literal",)
@@ -383,11 +381,15 @@ class _RawNumber:
383
381
  def _parse_int_lossless(text: str) -> Any:
384
382
  """json.loads parse_int hook: int() is exact for every JSON integer
385
383
  literal except "-0" (int("-0") == 0, dropping the sign), so only that
386
- one case needs wrapping; every other integer stays a plain arbitrary-
387
- precision Python int, unchanged from the pre-fidelity behavior."""
384
+ case and literals beyond Python's conversion limit need wrapping. Other
385
+ integers stay plain arbitrary-precision Python ints."""
388
386
  if text == "-0":
389
387
  return _RawNumber(text)
390
- return int(text)
388
+ try:
389
+ return int(text)
390
+ except ValueError:
391
+ # Python's configurable conversion limit is not a JSON grammar limit.
392
+ return _RawNumber(text)
391
393
 
392
394
 
393
395
  def _parse_float_lossless(text: str) -> Any:
@@ -412,7 +414,7 @@ class DecodedError:
412
414
 
413
415
  code: str
414
416
  message: str
415
- retryable: bool
417
+ retryable: bool | None
416
418
  hint: str | None = None
417
419
  fields: dict[str, Any] = field(default_factory=dict)
418
420
  trace: dict | None = None
@@ -434,12 +436,55 @@ class DecodedLog:
434
436
  trace: dict | None = None
435
437
 
436
438
 
439
+ def _reject_non_json_constant(name: str) -> Any:
440
+ # ``json.loads`` accepts ``NaN`` and ``Infinity`` by default; they are not
441
+ # JSON, and every other SDK's decoder refuses them.
442
+ raise ValueError(f"{name} is not a JSON number")
443
+
444
+
445
+ # How many arrays and objects a protocol line may nest, counting the envelope
446
+ # object. Every SDK enforces the same bound.
447
+ _MAX_PROTOCOL_NESTING = 127
448
+ _JSON_STRING_OR_BRACKET = re.compile(r'"(?:[^"\\]+|\\.)*"|[\[\]{}]')
449
+ _SURROGATE = re.compile("[\ud800-\udfff]")
450
+
451
+
452
+ def _nesting_exceeds_limit(text: str) -> bool:
453
+ """Whether brackets outside strings nest past the bound. Checked before
454
+ ``json.loads``, which would otherwise recurse until RecursionError."""
455
+ if text.count("[") + text.count("{") <= _MAX_PROTOCOL_NESTING:
456
+ return False
457
+ depth = 0
458
+ for match in _JSON_STRING_OR_BRACKET.finditer(text):
459
+ token = match.group()
460
+ if token in "[{":
461
+ depth += 1
462
+ if depth > _MAX_PROTOCOL_NESTING:
463
+ return True
464
+ elif token in "]}":
465
+ depth -= 1
466
+ return False
467
+
468
+
469
+ def _has_lone_surrogate(value: Any) -> bool:
470
+ """``json.loads`` joins an escaped surrogate pair into one character and
471
+ keeps a lone surrogate (escaped or raw) as-is, so any surrogate code point
472
+ left in a decoded key or string is a lone one."""
473
+ if isinstance(value, str):
474
+ return _SURROGATE.search(value) is not None
475
+ if isinstance(value, dict):
476
+ return any(_has_lone_surrogate(k) or _has_lone_surrogate(v) for k, v in value.items())
477
+ if isinstance(value, list):
478
+ return any(_has_lone_surrogate(item) for item in value)
479
+ return False
480
+
481
+
437
482
  def decode_protocol_event(
438
483
  text: str,
439
484
  ) -> DecodedResult | DecodedError | DecodedProgress | DecodedLog:
440
- """Parse and strict-validate a single protocol v1 JSON line into a typed event.
485
+ """Parse and base-validate a single protocol v1 JSON line into a typed event.
441
486
 
442
- Raises EventDecodeError if ``text`` is not valid JSON or fails strict
487
+ Raises EventDecodeError if ``text`` is not valid JSON or fails base
443
488
  validation.
444
489
 
445
490
  Number literal fidelity: ``json.loads``'s default integer parsing is
@@ -448,17 +493,26 @@ def decode_protocol_event(
448
493
  default float parsing collapses any literal through float64 (30 significant
449
494
  digits become 15-17, "0.1000...055511..." becomes "0.1"). ``parse_int``/
450
495
  ``parse_float`` hooks below route both exceptions through ``_RawNumber``,
451
- which stores the exact source literal; every JSON/YAML/plain renderer in
452
- this module treats it as an opaque scalar and re-emits that literal
453
- verbatim. This is internal — ``_RawNumber`` is never part of the public API.
496
+ which stores the exact source literal; JSON re-emits that literal
497
+ verbatim, and YAML/plain render it in the spec's canonical number form
498
+ without losing a digit. This is internal — ``_RawNumber`` is never part of the public API.
454
499
  """
500
+ if _nesting_exceeds_limit(text):
501
+ raise EventDecodeError(f"invalid JSON: nesting deeper than {_MAX_PROTOCOL_NESTING}")
455
502
  try:
456
- event = json.loads(text, parse_int=_parse_int_lossless, parse_float=_parse_float_lossless)
503
+ event = json.loads(
504
+ text,
505
+ parse_int=_parse_int_lossless,
506
+ parse_float=_parse_float_lossless,
507
+ parse_constant=_reject_non_json_constant,
508
+ )
457
509
  except (ValueError, TypeError) as exc:
458
510
  raise EventDecodeError(f"invalid JSON: {exc}") from exc
511
+ if _has_lone_surrogate(event):
512
+ raise EventDecodeError("invalid JSON: lone surrogate in a string")
459
513
 
460
514
  try:
461
- validate_protocol_event(event, strict=True)
515
+ validate_protocol_event(event, strict=False)
462
516
  except ValueError as exc:
463
517
  raise EventDecodeError(str(exc)) from exc
464
518
 
@@ -475,7 +529,7 @@ def decode_protocol_event(
475
529
  return DecodedError(
476
530
  code=error["code"],
477
531
  message=error["message"],
478
- retryable=error["retryable"],
532
+ retryable=error.get("retryable"),
479
533
  hint=error.get("hint"),
480
534
  fields=fields,
481
535
  trace=trace,
@@ -544,6 +598,8 @@ class OutputOptions:
544
598
  # None or RedactionPolicy.All = full redaction (default).
545
599
  policy: RedactionPolicy | None = None
546
600
  style: PlainStyle = PlainStyle.Readable
601
+ # Exact structured field names to reveal; URL parameters and argv stay redacted.
602
+ reveal_names: Sequence[str] = ()
547
603
 
548
604
  @classmethod
549
605
  def for_policy(cls, policy: RedactionPolicy) -> OutputOptions:
@@ -561,6 +617,7 @@ def _format_json(value: Any, *, options: OutputOptions | None = None) -> str:
561
617
  value,
562
618
  secret_names=output_options.secret_names,
563
619
  url_names=output_options.url_names,
620
+ reveal_names=output_options.reveal_names,
564
621
  policy=output_options.policy,
565
622
  )
566
623
  return _encode_json_lossless(redacted)
@@ -601,6 +658,7 @@ def _format_yaml(value: Any, *, options: OutputOptions | None = None) -> str:
601
658
  value,
602
659
  secret_names=output_options.secret_names,
603
660
  url_names=output_options.url_names,
661
+ reveal_names=output_options.reveal_names,
604
662
  policy=output_options.policy,
605
663
  )
606
664
  lines = ["---"]
@@ -618,6 +676,7 @@ def _format_plain(value: Any, *, options: OutputOptions | None = None) -> str:
618
676
  value,
619
677
  secret_names=output_options.secret_names,
620
678
  url_names=output_options.url_names,
679
+ reveal_names=output_options.reveal_names,
621
680
  policy=output_options.policy,
622
681
  )
623
682
  if not isinstance(value, dict):
@@ -646,6 +705,7 @@ def redacted_value(
646
705
  *,
647
706
  secret_names: Sequence[str] = (),
648
707
  url_names: Sequence[str] = (),
708
+ reveal_names: Sequence[str] = (),
649
709
  policy: RedactionPolicy | None = None,
650
710
  ) -> Any:
651
711
  """Return a JSON-safe copy with redaction options applied.
@@ -654,7 +714,7 @@ def redacted_value(
654
714
  Redacts _url fields and exact ``url_names`` entries by the same rules.
655
715
  """
656
716
  v = _sanitize_for_json(value)
657
- _apply_redaction(v, secret_names, url_names, policy)
717
+ _apply_redaction(v, secret_names, url_names, policy, reveal_names)
658
718
  return v
659
719
 
660
720
 
@@ -770,8 +830,9 @@ def _apply_redaction(
770
830
  secret_names: Sequence[str],
771
831
  url_names: Sequence[str],
772
832
  policy: RedactionPolicy | None,
833
+ reveal_names: Sequence[str] = (),
773
834
  ) -> None:
774
- context = _RedactionContext.from_names(secret_names, url_names)
835
+ context = _RedactionContext.from_names(secret_names, url_names, reveal_names)
775
836
  _apply_redaction_policy_with_context(value, policy, context)
776
837
 
777
838
 
@@ -1036,16 +1097,19 @@ class _RedactionContext:
1036
1097
 
1037
1098
  secret_names: frozenset[str] = frozenset()
1038
1099
  url_names: frozenset[str] = frozenset()
1100
+ reveal_names: frozenset[str] = frozenset()
1039
1101
 
1040
1102
  @classmethod
1041
1103
  def from_names(
1042
1104
  cls,
1043
1105
  secret_names: Sequence[str],
1044
1106
  url_names: Sequence[str] = (),
1107
+ reveal_names: Sequence[str] = (),
1045
1108
  ) -> _RedactionContext:
1046
1109
  return cls(
1047
1110
  secret_names=frozenset(secret_names),
1048
1111
  url_names=frozenset(url_names),
1112
+ reveal_names=frozenset(reveal_names),
1049
1113
  )
1050
1114
 
1051
1115
  def is_secret_key(self, key: str) -> bool:
@@ -1069,7 +1133,7 @@ def _redact_secrets(value: Any, context: _RedactionContext = _RedactionContext()
1069
1133
  if isinstance(value, dict):
1070
1134
  for k in list(value.keys()):
1071
1135
  v = value[k]
1072
- if context.is_secret_key(k):
1136
+ if context.is_secret_key(k) and k not in context.reveal_names:
1073
1137
  # A null secret is an absent secret. Masking it would
1074
1138
  # manufacture the appearance of a configured credential —
1075
1139
  # readers cannot tell "***"-because-set from
@@ -1103,7 +1167,7 @@ def _redact_url_value(value: Any, context: _RedactionContext, depth: int) -> Any
1103
1167
  if isinstance(value, dict):
1104
1168
  for key in list(value.keys()):
1105
1169
  item = value[key]
1106
- if context.is_secret_key(key):
1170
+ if context.is_secret_key(key) and key not in context.reveal_names:
1107
1171
  if item is not None:
1108
1172
  value[key] = REDACTED_MARKER
1109
1173
  else:
@@ -1117,7 +1181,7 @@ def _redact_url_value(value: Any, context: _RedactionContext, depth: int) -> Any
1117
1181
  # ═══════════════════════════════════════════
1118
1182
 
1119
1183
 
1120
- def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1184
+ def _redact_url_in_str(s: str, context: _RedactionContext, depth: int = 0) -> str | None:
1121
1185
  """Redact secret components of a single URL string.
1122
1186
 
1123
1187
  Returns the redacted string when ``s`` is a processable URL, or None when it
@@ -1127,6 +1191,9 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1127
1191
  # Fast path + precondition: a single, whitespace-free, scheme-prefixed URL.
1128
1192
  if "://" not in s or not _is_single_url(s):
1129
1193
  return None
1194
+ # Bound recursive redirect inspection, failing closed before stack growth.
1195
+ if depth >= 16:
1196
+ return REDACTED_MARKER
1130
1197
  scheme_sep = s.find("://")
1131
1198
  scheme = s[:scheme_sep]
1132
1199
  rest = s[scheme_sep + 3 :]
@@ -1137,9 +1204,29 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1137
1204
  if c in "/?#":
1138
1205
  auth_end = i
1139
1206
  break
1207
+ # An unencoded '?' or '#' inside a password ends that authority early and
1208
+ # leaves the rest of the password in the query or fragment. When a ':'
1209
+ # precedes that early end and an '@' follows it before the first '/', the
1210
+ # userinfo runs to that '@' — how libpq reads such a connection string.
1211
+ first_slash = rest.find("/")
1212
+ if first_slash == -1:
1213
+ first_slash = len(rest)
1214
+ at = rest.rfind("@", 0, first_slash)
1215
+ if ":" in rest[:auth_end] and at > auth_end:
1216
+ auth_end = len(rest)
1217
+ for i in range(at, len(rest)):
1218
+ if rest[i] in "/?#":
1219
+ auth_end = i
1220
+ break
1140
1221
  authority = rest[:auth_end]
1141
1222
  remainder = rest[auth_end:]
1142
1223
 
1224
+ # An unencoded '/' inside a password ends the authority at a "port" that
1225
+ # is not a number, with the '@' somewhere after it. Where the password
1226
+ # ends cannot be known, so the whole value is replaced.
1227
+ if "@" not in authority and _has_non_numeric_port(authority) and "@" in remainder:
1228
+ return REDACTED_MARKER
1229
+
1143
1230
  new_authority = _redact_userinfo_password(authority)
1144
1231
 
1145
1232
  # `remainder` is `path[?query][#fragment]`; '#' ends the query, so split the
@@ -1147,7 +1234,7 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1147
1234
  before_fragment, hash_sep, fragment = remainder.partition("#")
1148
1235
  path, question_sep, query = before_fragment.partition("?")
1149
1236
  if question_sep:
1150
- new_before_fragment = f"{path}?{_redact_query(query, context)}"
1237
+ new_before_fragment = f"{path}?{_redact_query(query, context, depth)}"
1151
1238
  else:
1152
1239
  new_before_fragment = before_fragment
1153
1240
  # A fragment gets the same treatment as the query: `k=v&k=v` after the '#'
@@ -1155,7 +1242,19 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1155
1242
  # secret-named fragment parameter must not survive where the identically
1156
1243
  # named query parameter would not. A fragment that is not in that shape has
1157
1244
  # no '=' in its segments and passes through byte-for-byte.
1158
- new_fragment = f"#{_redact_query(fragment, context)}" if hash_sep else ""
1245
+ # A hash route (`#/cb?k=v`) carries its parameters after its first '?',
1246
+ # while a form reader keeps that '?' inside a value; redact both readings:
1247
+ # the fragment as parameters first, so a secret value absorbs its '?',
1248
+ # then whatever follows the first '?' that survives.
1249
+ if not hash_sep:
1250
+ new_fragment = ""
1251
+ else:
1252
+ params = _redact_query(fragment, context, depth)
1253
+ route, route_sep, route_query = params.partition("?")
1254
+ if route_sep:
1255
+ new_fragment = f"#{route}?{_redact_query(route_query, context, depth)}"
1256
+ else:
1257
+ new_fragment = f"#{params}"
1159
1258
 
1160
1259
  return f"{scheme}://{new_authority}{new_before_fragment}{new_fragment}"
1161
1260
 
@@ -1306,7 +1405,7 @@ def _redact_userinfo_password(authority: str) -> str:
1306
1405
  return f"{authority[:colon]}:{REDACTED_MARKER}{authority[at:]}"
1307
1406
 
1308
1407
 
1309
- def _redact_query(query: str, context: _RedactionContext) -> str:
1408
+ def _redact_query(query: str, context: _RedactionContext, depth: int) -> str:
1310
1409
  """Redact the values of secret-named query parameters.
1311
1410
 
1312
1411
  Preserves the raw bytes of every other segment (keys, benign values,
@@ -1324,11 +1423,33 @@ def _redact_query(query: str, context: _RedactionContext) -> str:
1324
1423
  name = unquote_plus(raw_key)
1325
1424
  if context.is_secret_key(name):
1326
1425
  segments.append(f"{raw_key}={REDACTED_MARKER}")
1426
+ continue
1427
+ # A redirect target carries its own parameters: redact a nested URL in
1428
+ # place, or the whole value when it arrived encoded.
1429
+ raw_value = segment[eq + 1 :]
1430
+ value = unquote_plus(raw_value)
1431
+ redacted = _redact_url_in_str(value, context, depth + 1)
1432
+ if redacted is not None and redacted != value:
1433
+ if value == raw_value:
1434
+ segments.append(f"{raw_key}={redacted}")
1435
+ else:
1436
+ segments.append(f"{raw_key}={REDACTED_MARKER}")
1327
1437
  else:
1328
1438
  segments.append(segment)
1329
1439
  return "&".join(segments)
1330
1440
 
1331
1441
 
1442
+ def _has_non_numeric_port(authority: str) -> bool:
1443
+ """True when the authority's text after its last ':' is not a port number.
1444
+
1445
+ A bracketed IPv6 literal without a port ends in ']' and has none.
1446
+ """
1447
+ colon = authority.rfind(":")
1448
+ if colon == -1 or authority.endswith("]"):
1449
+ return False
1450
+ return not all("0" <= c <= "9" for c in authority[colon + 1 :])
1451
+
1452
+
1332
1453
  def _is_single_url(s: str) -> bool:
1333
1454
  """True when ``s`` is a single bare URL, not a URL embedded in prose.
1334
1455
 
@@ -1395,17 +1516,100 @@ def _is_number(value: Any) -> bool:
1395
1516
 
1396
1517
  def _number_str(value: int | float) -> str:
1397
1518
  """Render a number canonically for YAML/plain output."""
1398
- if isinstance(value, float) and math.isfinite(value) and value.is_integer() and abs(value) < 1e21:
1399
- return str(int(value))
1400
- s = repr(value) if isinstance(value, float) else str(value)
1401
- return _normalize_exponent(s)
1519
+ if isinstance(value, float):
1520
+ return _number_literal_str(repr(value))
1521
+ return str(value)
1522
+
1523
+
1524
+ def _number_literal_str(literal: str) -> str:
1525
+ """Render a number's literal canonically for YAML/plain output: a float
1526
+ literal whose value is an integer below 1e21 in magnitude renders as that
1527
+ integer (``3.0`` -> ``3``, ``-0.0`` -> ``-0``); anything else keeps its
1528
+ digits, placed by ``_place_decimal_point``. Native floats (via ``repr``)
1529
+ and decoded literals share this, and it works on the text so a digit is
1530
+ never lost to a float64 round trip."""
1531
+ integral = _integral_float_text(literal)
1532
+ if integral is not None:
1533
+ return integral
1534
+ placed = _place_decimal_point(literal)
1535
+ return placed if placed is not None else _normalize_exponent(literal)
1536
+
1537
+
1538
+ _I64_MIN = -(2**63)
1539
+ _I64_MAX = 2**63 - 1
1540
+
1541
+
1542
+ def _place_decimal_point(literal: str) -> str | None:
1543
+ """Spell a non-integral float literal with the notation ECMAScript's
1544
+ ``Number#toString`` picks: positional when the value lies in [1e-6, 1e21),
1545
+ exponent form otherwise. ``repr`` switches below 1e-4 and the other SDKs'
1546
+ native formatters elsewhere, so the choice is made here, on the digits as
1547
+ written. ``None`` for integer literals and exponents beyond i64, which keep
1548
+ their spelling."""
1549
+ if not any(c in literal for c in ".eE"):
1550
+ return None
1551
+ sign = "-" if literal.startswith("-") else ""
1552
+ unsigned = literal[1:] if sign else literal
1553
+ mantissa, *exponent = re.split("[eE]", unsigned, maxsplit=1)
1554
+ whole, _, fraction = mantissa.partition(".")
1555
+ digits = whole + fraction
1556
+ significant = digits.lstrip("0")
1557
+ if not significant:
1558
+ return None
1559
+ exponent_text = exponent[0] if exponent else "0"
1560
+ shape = re.fullmatch(r"([+-]?)0*([0-9]*)", exponent_text)
1561
+ if shape is None or not exponent_text.lstrip("+-") or len(shape.group(2)) > 19:
1562
+ return None
1563
+ exp = int(shape.group(1) + (shape.group(2) or "0"))
1564
+ # point: how many significant digits precede the decimal point.
1565
+ point = len(whole) - (len(digits) - len(significant)) + exp
1566
+ if not (_I64_MIN <= exp <= _I64_MAX and _I64_MIN <= point - 1 and point <= _I64_MAX):
1567
+ return None
1568
+ if 1 <= point <= 21:
1569
+ integer, rest = significant[:point], significant[point:]
1570
+ if not rest:
1571
+ return None
1572
+ text = f"{integer}.{rest}"
1573
+ elif -5 <= point <= 0:
1574
+ text = "0." + "0" * -point + significant
1575
+ else:
1576
+ scale = point - 1
1577
+ rest = f".{significant[1:]}" if len(significant) > 1 else ""
1578
+ text = f"{significant[0]}{rest}e{'-' if scale < 0 else '+'}{abs(scale)}"
1579
+ return sign + text
1580
+
1581
+
1582
+ def _integral_float_text(literal: str) -> str | None:
1583
+ if not any(c in literal for c in ".eE"):
1584
+ return None
1585
+ negative = literal.startswith("-")
1586
+ unsigned = literal[1:] if negative else literal
1587
+ mantissa, *exponent = re.split("[eE]", unsigned, maxsplit=1)
1588
+ whole, _, fraction = mantissa.partition(".")
1589
+ digits = whole + fraction
1590
+ if not digits.strip("0"):
1591
+ return "-0" if negative else "0"
1592
+ try:
1593
+ exp = int(exponent[0]) if exponent else 0
1594
+ except ValueError:
1595
+ return None
1596
+ point = len(whole) + exp
1597
+ split = min(max(point, 0), len(digits))
1598
+ if any(d != "0" for d in digits[split:]):
1599
+ return None
1600
+ padding = min(max(point - len(digits), 0), 22)
1601
+ integer = (digits[:split] + "0" * padding).lstrip("0")
1602
+ if len(integer) > 21:
1603
+ return None
1604
+ return ("-" if negative else "") + (integer or "0")
1402
1605
 
1403
1606
 
1404
1607
  def _normalize_exponent(s: str) -> str:
1405
1608
  if "e" not in s and "E" not in s:
1406
1609
  return s
1407
1610
  mantissa, exp = re.split("[eE]", s, maxsplit=1)
1408
- sign = ""
1611
+ # An unsigned exponent is positive; spell it like repr() does (e+23).
1612
+ sign = "+"
1409
1613
  if exp.startswith(("+", "-")):
1410
1614
  sign, exp = exp[0], exp[1:]
1411
1615
  exp = exp.lstrip("0") or "0"
@@ -1420,25 +1624,35 @@ def _as_int(value: Any) -> int | None:
1420
1624
  # Accept integral-valued floats (3.0 -> 3): JS/TS cannot distinguish 3 from
1421
1625
  # 3.0 after JSON parsing, so integrality (not lexical form) gates integer
1422
1626
  # suffixes, keeping the four implementations consistent.
1423
- if isinstance(value, float) and math.isfinite(value) and value.is_integer():
1424
- return int(value)
1627
+ # A float is read through its shortest spelling (``repr``), the text the
1628
+ # canonical number form prints, so 2.0**60 counts as 1152921504606847000
1629
+ # exactly as a decoded literal of that spelling does in every SDK; int()
1630
+ # of the binary value would give ...846976 and disagree with Rust.
1631
+ if isinstance(value, float) and math.isfinite(value):
1632
+ literal = repr(value)
1633
+ integer = _integral_float_text(literal) if re.search(r"[.eE]", literal) else literal
1634
+ return int(integer) if integer is not None else None
1425
1635
  return None
1426
1636
 
1427
1637
 
1428
- def _as_non_neg_int(value: Any) -> int | None:
1429
- n = _as_int(value)
1430
- if n is not None and n >= 0:
1638
+ def _raw_as_int(value: Any) -> int | None:
1639
+ if isinstance(value, _RawNumber):
1640
+ if re.fullmatch(r"-?[0-9]+", value.literal):
1641
+ return int(value.literal) if len(value.literal.lstrip("-")) <= 20 else None
1642
+ integer = _integral_float_text(value.literal)
1643
+ return int(integer) if integer is not None else None
1644
+ return _as_int(value)
1645
+
1646
+
1647
+ def _as_u64(value: Any) -> int | None:
1648
+ n = _raw_as_int(value)
1649
+ if n is not None and 0 <= n <= 18_446_744_073_709_551_615:
1431
1650
  return n
1432
1651
  return None
1433
1652
 
1434
1653
 
1435
1654
  def _as_i64(value: Any) -> int | None:
1436
- if isinstance(value, _RawNumber):
1437
- if not re.fullmatch(r"-?\d+", value.literal):
1438
- return None
1439
- n = int(value.literal)
1440
- else:
1441
- n = _as_int(value)
1655
+ n = _raw_as_int(value)
1442
1656
  if n is not None and -9_223_372_036_854_775_808 <= n <= 9_223_372_036_854_775_807:
1443
1657
  return n
1444
1658
  return None
@@ -1449,37 +1663,46 @@ def _signed_magnitude(value: int) -> tuple[str, int]:
1449
1663
 
1450
1664
 
1451
1665
  def _as_decimal_int(value: Any) -> int | None:
1452
- if isinstance(value, str) and re.fullmatch(r"-?\d+", value):
1453
- return int(value)
1454
- return _as_int(value)
1666
+ text = _decimal_int_text(value)
1667
+ return int(text) if text is not None else None
1455
1668
 
1456
1669
 
1457
1670
  def _decimal_int_text(value: Any) -> str | None:
1458
- if isinstance(value, str) and re.fullmatch(r"-?\d+", value):
1459
- return value
1460
- n = _as_int(value)
1461
- if n is None:
1671
+ """The exact digits of an integer-valued number or decimal integer string,
1672
+ of any magnitude. Bitcoin amounts and ``_epoch_ns`` only append a unit or
1673
+ divide, so unlike money suffixes they are not bounded by i64: an integer
1674
+ literal keeps its digits, and a float counts when its value is an integer
1675
+ below 1e21 (the canonical number form's integer range). A numeric ``-0``
1676
+ is zero."""
1677
+ if isinstance(value, str):
1678
+ return value if re.fullmatch(r"-?[0-9]+", value) else None
1679
+ if isinstance(value, bool):
1680
+ return None
1681
+ if isinstance(value, int):
1682
+ return str(value)
1683
+ if isinstance(value, float):
1684
+ if not math.isfinite(value):
1685
+ return None
1686
+ literal = repr(value)
1687
+ elif isinstance(value, _RawNumber):
1688
+ literal = value.literal
1689
+ else:
1690
+ return None
1691
+ integer = _integral_float_text(literal) if re.search(r"[.eE]", literal) else literal
1692
+ if integer is None:
1462
1693
  return None
1463
- return str(n)
1694
+ return "0" if integer == "-0" else integer
1464
1695
 
1465
1696
 
1466
1697
  def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1467
1698
  """Try suffix-driven processing. Returns (stripped_key, formatted_value) or None."""
1468
- # Plain-format suffix arithmetic below only works on a native int/float. A
1469
- # decoded _RawNumber (see decode_protocol_event's fidelity note) is
1470
- # normalized to a float here for that arithmetic, so an ordinary decoded
1471
- # value (e.g. cpu_percent: 85.5) keeps formatting exactly as before; this
1472
- # is Plain's documented lossy/human path. The _epoch_ns/_msats/_sats
1473
- # branches below keep exactness by reading `value` directly instead of
1474
- # `numeric` (their _as_decimal_int/_decimal_int_text helpers already
1475
- # handle plain arbitrary-precision int values, which is what every
1476
- # decoded integer literal already is -- only floats get wrapped).
1477
- numeric: Any = float(value.literal) if isinstance(value, _RawNumber) else value
1699
+ # Adding a unit does no arithmetic, so it must not round decoded digits.
1700
+ is_number = _is_number(value) or isinstance(value, _RawNumber)
1478
1701
 
1479
1702
  # Group 1: compound timestamp suffixes
1480
1703
  stripped = _strip_suffix_ci(key, "_epoch_ms")
1481
1704
  if stripped is not None:
1482
- n = _as_int(numeric)
1705
+ n = _as_i64(value)
1483
1706
  if n is not None:
1484
1707
  formatted = _format_rfc3339_ms(n)
1485
1708
  if formatted is not None:
@@ -1487,7 +1710,7 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1487
1710
  return None
1488
1711
  stripped = _strip_suffix_ci(key, "_epoch_s")
1489
1712
  if stripped is not None:
1490
- n = _as_int(numeric)
1713
+ n = _as_i64(value)
1491
1714
  if n is not None:
1492
1715
  formatted = _format_rfc3339_ms(n * 1000)
1493
1716
  if formatted is not None:
@@ -1548,18 +1771,18 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1548
1771
  return None
1549
1772
  stripped = _strip_suffix_ci(key, "_minutes")
1550
1773
  if stripped is not None:
1551
- if _is_number(numeric):
1552
- return stripped, f"{_plain_scalar(numeric)} minutes"
1774
+ if is_number:
1775
+ return stripped, f"{_plain_scalar(value)} minutes"
1553
1776
  return None
1554
1777
  stripped = _strip_suffix_ci(key, "_hours")
1555
1778
  if stripped is not None:
1556
- if _is_number(numeric):
1557
- return stripped, f"{_plain_scalar(numeric)} hours"
1779
+ if is_number:
1780
+ return stripped, f"{_plain_scalar(value)} hours"
1558
1781
  return None
1559
1782
  stripped = _strip_suffix_ci(key, "_days")
1560
1783
  if stripped is not None:
1561
- if _is_number(numeric):
1562
- return stripped, f"{_plain_scalar(numeric)} days"
1784
+ if is_number:
1785
+ return stripped, f"{_plain_scalar(value)} days"
1563
1786
  return None
1564
1787
 
1565
1788
  # Group 4: single-unit suffixes
@@ -1577,14 +1800,14 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1577
1800
  return None
1578
1801
  stripped = _strip_suffix_ci(key, "_bytes")
1579
1802
  if stripped is not None:
1580
- n = _as_non_neg_int(numeric)
1803
+ n = _as_u64(value)
1581
1804
  if n is not None:
1582
1805
  return stripped, _format_bytes_human(n)
1583
1806
  return None
1584
1807
  stripped = _strip_suffix_ci(key, "_percent")
1585
1808
  if stripped is not None:
1586
- if _is_number(numeric):
1587
- return stripped, f"{_plain_scalar(numeric)}%"
1809
+ if is_number:
1810
+ return stripped, f"{_plain_scalar(value)}%"
1588
1811
  return None
1589
1812
  # Group 5: short suffixes (last to avoid false positives)
1590
1813
  stripped = _strip_suffix_ci(key, "_jpy")
@@ -1596,24 +1819,24 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1596
1819
  return None
1597
1820
  stripped = _strip_suffix_ci(key, "_ns")
1598
1821
  if stripped is not None:
1599
- if _is_number(numeric):
1600
- return stripped, f"{_plain_scalar(numeric)}ns"
1822
+ if is_number:
1823
+ return stripped, f"{_plain_scalar(value)}ns"
1601
1824
  return None
1602
1825
  stripped = _strip_suffix_ci(key, "_us")
1603
1826
  if stripped is not None:
1604
- if _is_number(numeric):
1605
- return stripped, f"{_plain_scalar(numeric)}\u03bcs"
1827
+ if is_number:
1828
+ return stripped, f"{_plain_scalar(value)}\u03bcs"
1606
1829
  return None
1607
1830
  stripped = _strip_suffix_ci(key, "_ms")
1608
1831
  if stripped is not None:
1609
- fv = _format_ms_value(numeric)
1832
+ fv = _format_ms_value(value)
1610
1833
  if fv is not None:
1611
1834
  return stripped, fv
1612
1835
  return None
1613
1836
  stripped = _strip_suffix_ci(key, "_s")
1614
1837
  if stripped is not None:
1615
- if _is_number(numeric):
1616
- return stripped, f"{_plain_scalar(numeric)}s"
1838
+ if is_number:
1839
+ return stripped, f"{_plain_scalar(value)}s"
1617
1840
  return None
1618
1841
 
1619
1842
  return None
@@ -1685,9 +1908,11 @@ def _format_ms_as_seconds(ms: float) -> str:
1685
1908
 
1686
1909
  def _format_ms_value(value: Any) -> str | None:
1687
1910
  """Format _ms value: < 1000 -> {n}ms, >= 1000 -> seconds."""
1688
- if not _is_number(value):
1911
+ if not _is_number(value) and not isinstance(value, _RawNumber):
1912
+ return None
1913
+ n = float(value.literal) if isinstance(value, _RawNumber) else float(value)
1914
+ if not math.isfinite(n):
1689
1915
  return None
1690
- n = float(value)
1691
1916
  if abs(n) >= 1000:
1692
1917
  return _format_ms_as_seconds(n)
1693
1918
  return f"{_plain_scalar(value)}ms"
@@ -1698,7 +1923,12 @@ def _format_rfc3339_ms(ms: int) -> str | None:
1698
1923
  return None
1699
1924
  try:
1700
1925
  dt = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(milliseconds=ms)
1701
- return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{ms % 1000:03d}Z"
1926
+ # Not strftime: glibc's `%Y` does not pad a year below 1000, so year 1
1927
+ # printed as `1-01-01` on Linux while macOS printed `0001-01-01`.
1928
+ return (
1929
+ f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d}T"
1930
+ f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d}.{ms % 1000:03d}Z"
1931
+ )
1702
1932
  except (OverflowError, ValueError):
1703
1933
  return None
1704
1934
 
@@ -1858,11 +2088,11 @@ def _yaml_scalar(value: Any) -> str:
1858
2088
  if isinstance(value, (int, float)):
1859
2089
  return _number_str(value)
1860
2090
  if isinstance(value, _RawNumber):
1861
- # Decoded number: emit the exact source literal unquoted, matching
1862
- # JSON's structure-preserving fidelity contract for YAML too.
1863
- return value.literal
2091
+ # Decoded number: its exact digits, in the same canonical form a
2092
+ # native number gets.
2093
+ return _number_literal_str(value.literal)
1864
2094
  if isinstance(value, (dict, list)):
1865
- return f'"{_escape_yaml_str(_canonical_json(value))}"'
2095
+ return f'"{_escape_yaml_str(_embedded_json(value))}"'
1866
2096
  return f'"{_escape_yaml_str(str(value))}"'
1867
2097
 
1868
2098
 
@@ -1928,18 +2158,18 @@ def _plain_scalar(value: Any) -> str:
1928
2158
  if isinstance(value, (int, float)):
1929
2159
  return _number_str(value)
1930
2160
  if isinstance(value, _RawNumber):
1931
- # Decoded number: emit the exact source literal (Plain is documented
1932
- # lossy for arithmetic-derived formatting, but a bare pass-through
1933
- # scalar still gets its exact digits, not a float64 round-trip).
1934
- return value.literal
2161
+ # Decoded number: its exact digits (Plain is documented lossy only
2162
+ # for arithmetic-derived formatting), in the same canonical form a
2163
+ # native number gets.
2164
+ return _number_literal_str(value.literal)
1935
2165
  if isinstance(value, (dict, list)):
1936
- return _canonical_json(value)
2166
+ return _embedded_json(value)
1937
2167
  return str(value)
1938
2168
 
1939
2169
 
1940
2170
  def _plain_scalar_raw(value: Any) -> str:
1941
2171
  if isinstance(value, (dict, list)):
1942
- return _canonical_json(value)
2172
+ return _embedded_json(value)
1943
2173
  return _plain_scalar(value)
1944
2174
 
1945
2175
 
@@ -1975,13 +2205,21 @@ def _utf16_sort_key(s: str) -> bytes:
1975
2205
  return s.encode("utf-16-be", "surrogatepass")
1976
2206
 
1977
2207
 
1978
- def _canonical_json(value: Any) -> str:
1979
- return _encode_json_lossless(_sort_json_value(value))
1980
-
1981
-
1982
- def _sort_json_value(value: Any) -> Any:
2208
+ def _embedded_json(value: Any) -> str:
2209
+ """A container nested inside a Plain array (or in a YAML scalar position)
2210
+ is written as compact JSON with sorted keys. Its number leaves take the
2211
+ canonical number form, the same text the value prints as a scalar, so a
2212
+ nested ``1.0`` reads ``1`` and a decoded ``1e5`` reads ``100000`` in every
2213
+ SDK rather than in each runtime's own JSON spelling."""
1983
2214
  if isinstance(value, dict):
1984
- return {k: _sort_json_value(value[k]) for k in _sorted_object_keys(value)}
2215
+ return "{" + ",".join(
2216
+ f"{json.dumps(k, ensure_ascii=False)}:{_embedded_json(value[k])}"
2217
+ for k in _sorted_object_keys(value)
2218
+ ) + "}"
1985
2219
  if isinstance(value, list):
1986
- return [_sort_json_value(item) for item in value]
1987
- return value
2220
+ return "[" + ",".join(_embedded_json(item) for item in value) + "]"
2221
+ if isinstance(value, str):
2222
+ return json.dumps(value, ensure_ascii=False)
2223
+ if value is None or isinstance(value, (bool, int, float, _RawNumber)):
2224
+ return _plain_scalar(value)
2225
+ return json.dumps(str(value), ensure_ascii=False)
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.35.0
4
- Summary: A naming convention that lets AI agents understand your data without being told what it means, plus a CLI and library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.
3
+ Version: 0.36.0
4
+ Summary: A naming convention that lets AI agents understand your data without being told what it means, plus the afdata CLI and a Rust library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/agentfirstkit/agent-first-data
7
7
  Requires-Python: >=3.9
@@ -28,10 +28,12 @@ print(render(value, OutputFormat.JSON))
28
28
  print(render(value, OutputFormat.PLAIN))
29
29
  ```
30
30
 
31
- Useful names use Python casing: `render` (the single value x format x options -> str entry point; takes an `OutputFormat` and an optional keyword-only `options=`), `redacted_value`, `redact_url_secrets`, `redact_urls_in_text`, `redact_argv`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `is_valid_rfc3339`, `is_valid_bcp47`, `cli_parse_output`, `build_cli_error`, `build_cli_version`, `decode_protocol_event`.
31
+ Useful names use Python casing: `render` (the single value x format x options -> str entry point; takes an `OutputFormat` and an optional keyword-only `options=`), `redacted_value`, `redact_url_secrets`, `redact_urls_in_text`, `redact_argv`, `normalize_utc_offset`, `is_valid_rfc3339_date`, `is_valid_rfc3339_time`, `is_valid_rfc3339`, `is_valid_bcp47`, `cli_parse_output`, `build_cli_error`, `build_cli_version`, `cli_parse_log_filters`, `CliEmitter`, `decode_protocol_event`.
32
32
 
33
33
  ## Behavior Notes
34
34
 
35
+ - `OutputOptions(reveal_names=...)` reveals exact structured field names at the selected output boundary. Children still pass through redaction; URL parameters and argv receive no exemption.
36
+ - The protocol reader accepts base-profile events. An absent `error.retryable` remains nullable; strict validation is a separate opt-in.
35
37
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
36
38
  - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed, and internal whitespace or a `://` that is not the start of a clean URL redacts the whole field, where whitespace is the explicit Unicode `White_Space` set the spec enumerates, not the language's native predicate.
37
39
  - `OutputOptions.url_names` applies URL treatment to exact legacy field names
@@ -40,7 +42,7 @@ Useful names use Python casing: `render` (the single value x format x options ->
40
42
  - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON; it quotes a value only when it contains ASCII space, tab, newline, carriage return, form feed, vertical tab, NBSP, `=`, `"`, or `\`, and escapes `\`, `"`, newline, carriage return, tab, form feed, and vertical tab inside the quotes.
41
43
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
42
44
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
43
- - Do not add a raw version/help pre-parser. Until the Python `CliSpec` compiler lands, applications should keep lifecycle parsing in their own parser rather than claim AFDATA closed-world help compatibility.
45
+ - Do not add a raw version/help pre-parser. This SDK provides emitters and readers; argv and lifecycle parsing belong to the application. Claim help-v2 or closed-world compatibility only when using a registry compiler.
44
46
 
45
47
  ## Reference
46
48
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "agent-first-data"
3
- version = "0.35.0"
4
- description = "A naming convention that lets AI agents understand your data without being told what it means, plus a CLI and library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents."
3
+ version = "0.36.0"
4
+ description = "A naming convention that lets AI agents understand your data without being told what it means, plus the afdata CLI and a Rust library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents."
5
5
  license = "MIT"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.9"
@@ -448,9 +448,39 @@ def test_finish_returns_0_on_broken_pipe():
448
448
  assert emitter.finish(json_result({"rows": 1}).build(), 0) == 0
449
449
 
450
450
 
451
- def test_finish_returns_4_on_other_write_failure():
451
+ def _assert_output_write_failed(text: str) -> None:
452
+ reported = json.loads(text)
453
+ assert reported["kind"] == "error"
454
+ assert reported["error"]["code"] == "output_write_failed"
455
+
456
+
457
+ def test_finish_returns_4_on_other_write_failure(monkeypatch):
458
+ # Event-stream mode reports the failure on the process's stderr.
459
+ err = StringIO()
460
+ monkeypatch.setattr("sys.stderr", err)
452
461
  emitter = CliEmitter.stream(_FailingWriter(), OutputFormat.JSON)
453
462
  assert emitter.finish(json_result({"rows": 1}).build(), 0) == 4
463
+ _assert_output_write_failed(err.getvalue())
464
+
465
+
466
+ def test_finish_reports_an_unwritable_result_on_the_diagnostic_sink():
467
+ diagnostic = StringIO()
468
+ emitter = CliEmitter.finite_with(_FailingWriter(), diagnostic, OutputFormat.JSON)
469
+ assert emitter.finish_result({"rows": 2}) == 4
470
+ _assert_output_write_failed(diagnostic.getvalue())
471
+
472
+ # A closed reader stays silent success.
473
+ diagnostic = StringIO()
474
+ emitter = CliEmitter.finite_with(_BrokenPipeWriter(), diagnostic, OutputFormat.JSON)
475
+ assert emitter.finish_result({}) == 0
476
+ assert diagnostic.getvalue() == ""
477
+
478
+
479
+ def test_finish_returns_1_on_a_protocol_violation():
480
+ buf = StringIO()
481
+ emitter = CliEmitter.stream(buf, OutputFormat.JSON).with_strict_protocol()
482
+ assert emitter.finish({"kind": "error", "error": {"code": "failed", "message": "Failed"}}, 2) == 1
483
+ assert buf.getvalue() == ""
454
484
 
455
485
 
456
486
  def test_finish_result_returns_0_on_broken_pipe():
@@ -458,12 +488,13 @@ def test_finish_result_returns_0_on_broken_pipe():
458
488
  assert emitter.finish_result({"ok": True}) == 0
459
489
 
460
490
 
461
- def test_finish_returns_4_on_lifecycle_violation():
462
- # A non-BrokenPipe failure (here a duplicate terminal) resolves to 4.
491
+ def test_finish_returns_1_on_lifecycle_violation():
492
+ # An event the emitter refuses (here a second terminal event) is the
493
+ # command's failure, not output's.
463
494
  buf = StringIO()
464
495
  emitter = CliEmitter.stream(buf, OutputFormat.JSON)
465
496
  assert emitter.finish(json_result({"rows": 1}).build(), 0) == 0
466
- assert emitter.finish(json_error("late", "too late").build(), 1) == 4
497
+ assert emitter.finish(json_error("late", "too late").build(), 2) == 1
467
498
 
468
499
 
469
500
  # ── version helpers ───────────────────────────────────────────────────────────
@@ -89,6 +89,7 @@ def _redaction_options(case):
89
89
  return OutputOptions(
90
90
  policy=policy,
91
91
  secret_names=opts.get("secret_names", ()),
92
+ reveal_names=opts.get("reveal_names", ()),
92
93
  url_names=opts.get("url_names", ()),
93
94
  )
94
95
 
@@ -99,7 +100,8 @@ def _redaction_options(case):
99
100
  def test_redact_fixtures():
100
101
  for case in _load("redact.json"):
101
102
  name = case["name"]
102
- got = redacted_value(case["input"])
103
+ options = _redaction_options(case)
104
+ got = redacted_value(case["input"], secret_names=options.secret_names, url_names=options.url_names, reveal_names=options.reveal_names, policy=options.policy)
103
105
  assert got == case["expected"], f"[redact/{name}] got {got!r}"
104
106
 
105
107
 
@@ -131,6 +133,13 @@ def test_redact_argv_fixtures():
131
133
  for case in _load("redact_argv.json"):
132
134
  name = case["name"]
133
135
  options = _redaction_options(case)
136
+ if case.get("requires") == "redactor_reveal_names":
137
+ # Python's redact_argv has no reveal option, so a reveal list
138
+ # cannot reach argv at all: assert the parameter is refused.
139
+ with pytest.raises(TypeError):
140
+ redact_argv(case["input"], reveal_names=options.reveal_names) # type: ignore[call-arg]
141
+ continue
142
+ assert not options.reveal_names, f"[redact_argv/{name}] reveal_names needs requires"
134
143
  got = redact_argv(
135
144
  case["input"],
136
145
  secret_names=options.secret_names,
@@ -154,6 +163,7 @@ def test_redaction_options_fixtures():
154
163
  case["input"],
155
164
  secret_names=output_options.secret_names,
156
165
  url_names=output_options.url_names,
166
+ reveal_names=output_options.reveal_names,
157
167
  policy=output_options.policy,
158
168
  )
159
169
  assert got == expected, f"[redaction_options/{name}] value mismatch: {got}"
@@ -178,6 +188,7 @@ def test_security_fixtures():
178
188
  case["input"],
179
189
  secret_names=output_options.secret_names,
180
190
  url_names=output_options.url_names,
191
+ reveal_names=output_options.reveal_names,
181
192
  policy=output_options.policy,
182
193
  ) == case["expected"]
183
194
  outputs = (
@@ -534,13 +545,24 @@ def test_helper_fixtures():
534
545
  assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
535
546
 
536
547
 
548
+ def test_native_negative_zero_keeps_its_sign_in_yaml_and_plain():
549
+ # int(-0.0) == 0, so rendering through int dropped the sign; the shared
550
+ # fixtures only reach decoded literals, which never went through int.
551
+ value = {"neg_zero": -0.0, "wait_s": -0.0}
552
+ assert render(value, OutputFormat.YAML) == "---\nneg_zero: -0\nwait_s: -0"
553
+ assert render(value, OutputFormat.PLAIN) == "neg_zero=-0 wait=-0s"
554
+
555
+
537
556
  def test_output_format_fixtures():
538
557
  for case in _load("output_formats.json"):
539
558
  name = case["name"]
540
559
  inp = json.loads(json.dumps(case["input"]))
541
560
 
542
- got_json = json.loads(render(inp, OutputFormat.JSON))
561
+ json_text = render(inp, OutputFormat.JSON)
562
+ got_json = json.loads(json_text)
543
563
  assert got_json == case["expected_json"], f"[output/{name}] json mismatch: {got_json}"
564
+ if "expected_json_text" in case:
565
+ assert json_text == case["expected_json_text"], f"[output/{name}] json text mismatch: {json_text!r}"
544
566
 
545
567
  got_yaml = render(inp, OutputFormat.YAML)
546
568
  assert got_yaml == case["expected_yaml"], f"[output/{name}] yaml mismatch: {got_yaml!r}"
@@ -745,12 +767,10 @@ def test_decode_protocol_event_invalid_envelope_raises():
745
767
  decode_protocol_event(json.dumps({"kind": "result"}))
746
768
 
747
769
 
748
- def test_decode_protocol_event_fails_strict_validation():
749
- import pytest
750
-
751
- # Missing trace fails the strict profile even though the shape is otherwise valid.
752
- with pytest.raises(EventDecodeError):
753
- decode_protocol_event(json.dumps({"kind": "result", "result": {}}))
770
+ def test_decode_protocol_event_accepts_base_profile():
771
+ decoded = decode_protocol_event('{"kind":"error","error":{"code":"failed","message":"Failed"}}')
772
+ assert decoded.retryable is None
773
+ assert decoded.trace is None
754
774
 
755
775
 
756
776
  # --- Number literal fidelity (shared spec/fixtures/number_fidelity.json) ---
@@ -779,14 +799,33 @@ def test_number_fidelity_fixtures():
779
799
  assert got_plain == case["expected_plain"], f"[number_fidelity/{name}] plain mismatch: {got_plain!r}"
780
800
 
781
801
 
802
+ def test_decoded_numbers_beyond_python_conversion_limits_still_render():
803
+ # Python 3.11+ limits int(string) conversions by default; that limit must
804
+ # not turn a valid protocol number into a decode or rendering exception.
805
+ integer = "9" * 5000
806
+ exponent = "1e+" + "9" * 5000
807
+ for literal in (integer, exponent):
808
+ decoded = decode_protocol_event('{"kind":"result","result":' + literal + ',"trace":{}}')
809
+ assert render(decoded.result, OutputFormat.JSON) == literal
810
+ assert render(decoded.result, OutputFormat.YAML) == "---\n" + literal
811
+ assert render(decoded.result, OutputFormat.PLAIN) == literal
812
+ decoded = decode_protocol_event('{"kind":"result","result":{"cost_jpy":' + integer + '},"trace":{}}')
813
+ assert render(decoded.result, OutputFormat.PLAIN) == "cost_jpy=" + integer
814
+
815
+
782
816
  def test_protocol_decode_fixtures():
783
817
  for case in _load("protocol_decode.json"):
818
+ decoded = None
784
819
  try:
785
- decode_protocol_event(case["input_line"])
820
+ decoded = decode_protocol_event(case["input_line"])
786
821
  valid = True
787
822
  except EventDecodeError:
788
823
  valid = False
789
824
  assert valid is case["valid"], f"[protocol_decode/{case['name']}]"
825
+ if "expected_result_json" in case:
826
+ assert isinstance(decoded, DecodedResult), f"[protocol_decode/{case['name']}]"
827
+ got = render(decoded.result, OutputFormat.JSON)
828
+ assert got == case["expected_result_json"], f"[protocol_decode/{case['name']}] got {got!r}"
790
829
 
791
830
 
792
831
  def test_number_fidelity_does_not_regress_ordinary_decoded_numbers_in_plain_output():
@@ -807,3 +846,9 @@ def test_signed_fiat_i64_min_formats_without_overflow():
807
846
  OutputFormat.PLAIN,
808
847
  )
809
848
  assert plain == "adjustment=-$92233720368547758.08"
849
+
850
+
851
+ def test_output_options_preserve_existing_positional_arguments():
852
+ options = OutputOptions((), ("legacy",), None, PlainStyle.Raw)
853
+ rendered = json.loads(render({"legacy": "https://example.test/?token_secret=hidden"}, OutputFormat.JSON, options=options))
854
+ assert rendered == {"legacy": "https://example.test/?token_secret=***"}