agent-first-data 0.35.1__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.1 → agent_first_data-0.36.0}/PKG-INFO +6 -4
  2. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/README.md +4 -2
  3. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data/assets/cli-spec-v1.schema.json +5 -2
  4. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data/cli.py +31 -4
  5. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data/format.py +267 -89
  6. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data.egg-info/PKG-INFO +6 -4
  7. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/pyproject.toml +2 -2
  8. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/tests/test_cli.py +35 -4
  9. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/tests/test_format.py +54 -9
  10. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data/__init__.py +0 -0
  11. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data/assets/cli-help-v2.schema.json +0 -0
  12. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data/assets/protocol-v1.schema.json +0 -0
  13. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data/assets/registry.json +0 -0
  14. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data.egg-info/SOURCES.txt +0 -0
  15. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data.egg-info/dependency_links.txt +0 -0
  16. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/agent_first_data.egg-info/top_level.txt +0 -0
  17. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/setup.cfg +0 -0
  18. {agent_first_data-0.35.1 → agent_first_data-0.36.0}/tests/test_no_stderr_policy.py +0 -0
  19. {agent_first_data-0.35.1 → 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.1
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:
@@ -1452,17 +1516,100 @@ def _is_number(value: Any) -> bool:
1452
1516
 
1453
1517
  def _number_str(value: int | float) -> str:
1454
1518
  """Render a number canonically for YAML/plain output."""
1455
- if isinstance(value, float) and math.isfinite(value) and value.is_integer() and abs(value) < 1e21:
1456
- return str(int(value))
1457
- s = repr(value) if isinstance(value, float) else str(value)
1458
- 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")
1459
1605
 
1460
1606
 
1461
1607
  def _normalize_exponent(s: str) -> str:
1462
1608
  if "e" not in s and "E" not in s:
1463
1609
  return s
1464
1610
  mantissa, exp = re.split("[eE]", s, maxsplit=1)
1465
- sign = ""
1611
+ # An unsigned exponent is positive; spell it like repr() does (e+23).
1612
+ sign = "+"
1466
1613
  if exp.startswith(("+", "-")):
1467
1614
  sign, exp = exp[0], exp[1:]
1468
1615
  exp = exp.lstrip("0") or "0"
@@ -1477,16 +1624,23 @@ def _as_int(value: Any) -> int | None:
1477
1624
  # Accept integral-valued floats (3.0 -> 3): JS/TS cannot distinguish 3 from
1478
1625
  # 3.0 after JSON parsing, so integrality (not lexical form) gates integer
1479
1626
  # suffixes, keeping the four implementations consistent.
1480
- if isinstance(value, float) and math.isfinite(value) and value.is_integer():
1481
- 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
1482
1635
  return None
1483
1636
 
1484
1637
 
1485
1638
  def _raw_as_int(value: Any) -> int | None:
1486
1639
  if isinstance(value, _RawNumber):
1487
- if re.fullmatch(r"-?\d+", value.literal):
1488
- return int(value.literal)
1489
- return _as_int(float(value.literal))
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
1490
1644
  return _as_int(value)
1491
1645
 
1492
1646
 
@@ -1509,37 +1663,46 @@ def _signed_magnitude(value: int) -> tuple[str, int]:
1509
1663
 
1510
1664
 
1511
1665
  def _as_decimal_int(value: Any) -> int | None:
1512
- if isinstance(value, str) and re.fullmatch(r"-?\d+", value):
1513
- return int(value)
1514
- return _as_int(value)
1666
+ text = _decimal_int_text(value)
1667
+ return int(text) if text is not None else None
1515
1668
 
1516
1669
 
1517
1670
  def _decimal_int_text(value: Any) -> str | None:
1518
- if isinstance(value, str) and re.fullmatch(r"-?\d+", value):
1519
- return value
1520
- n = _as_int(value)
1521
- 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:
1522
1693
  return None
1523
- return str(n)
1694
+ return "0" if integer == "-0" else integer
1524
1695
 
1525
1696
 
1526
1697
  def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1527
1698
  """Try suffix-driven processing. Returns (stripped_key, formatted_value) or None."""
1528
- # Plain-format suffix arithmetic below only works on a native int/float. A
1529
- # decoded _RawNumber (see decode_protocol_event's fidelity note) is
1530
- # normalized to a float here for that arithmetic, so an ordinary decoded
1531
- # value (e.g. cpu_percent: 85.5) keeps formatting exactly as before; this
1532
- # is Plain's documented lossy/human path. The _epoch_ns/_msats/_sats
1533
- # branches below keep exactness by reading `value` directly instead of
1534
- # `numeric` (their _as_decimal_int/_decimal_int_text helpers already
1535
- # handle plain arbitrary-precision int values, which is what every
1536
- # decoded integer literal already is -- only floats get wrapped).
1537
- 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)
1538
1701
 
1539
1702
  # Group 1: compound timestamp suffixes
1540
1703
  stripped = _strip_suffix_ci(key, "_epoch_ms")
1541
1704
  if stripped is not None:
1542
- n = _as_int(numeric)
1705
+ n = _as_i64(value)
1543
1706
  if n is not None:
1544
1707
  formatted = _format_rfc3339_ms(n)
1545
1708
  if formatted is not None:
@@ -1547,7 +1710,7 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1547
1710
  return None
1548
1711
  stripped = _strip_suffix_ci(key, "_epoch_s")
1549
1712
  if stripped is not None:
1550
- n = _as_int(numeric)
1713
+ n = _as_i64(value)
1551
1714
  if n is not None:
1552
1715
  formatted = _format_rfc3339_ms(n * 1000)
1553
1716
  if formatted is not None:
@@ -1608,18 +1771,18 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1608
1771
  return None
1609
1772
  stripped = _strip_suffix_ci(key, "_minutes")
1610
1773
  if stripped is not None:
1611
- if _is_number(numeric):
1612
- return stripped, f"{_plain_scalar(numeric)} minutes"
1774
+ if is_number:
1775
+ return stripped, f"{_plain_scalar(value)} minutes"
1613
1776
  return None
1614
1777
  stripped = _strip_suffix_ci(key, "_hours")
1615
1778
  if stripped is not None:
1616
- if _is_number(numeric):
1617
- return stripped, f"{_plain_scalar(numeric)} hours"
1779
+ if is_number:
1780
+ return stripped, f"{_plain_scalar(value)} hours"
1618
1781
  return None
1619
1782
  stripped = _strip_suffix_ci(key, "_days")
1620
1783
  if stripped is not None:
1621
- if _is_number(numeric):
1622
- return stripped, f"{_plain_scalar(numeric)} days"
1784
+ if is_number:
1785
+ return stripped, f"{_plain_scalar(value)} days"
1623
1786
  return None
1624
1787
 
1625
1788
  # Group 4: single-unit suffixes
@@ -1643,8 +1806,8 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1643
1806
  return None
1644
1807
  stripped = _strip_suffix_ci(key, "_percent")
1645
1808
  if stripped is not None:
1646
- if _is_number(numeric):
1647
- return stripped, f"{_plain_scalar(numeric)}%"
1809
+ if is_number:
1810
+ return stripped, f"{_plain_scalar(value)}%"
1648
1811
  return None
1649
1812
  # Group 5: short suffixes (last to avoid false positives)
1650
1813
  stripped = _strip_suffix_ci(key, "_jpy")
@@ -1656,24 +1819,24 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1656
1819
  return None
1657
1820
  stripped = _strip_suffix_ci(key, "_ns")
1658
1821
  if stripped is not None:
1659
- if _is_number(numeric):
1660
- return stripped, f"{_plain_scalar(numeric)}ns"
1822
+ if is_number:
1823
+ return stripped, f"{_plain_scalar(value)}ns"
1661
1824
  return None
1662
1825
  stripped = _strip_suffix_ci(key, "_us")
1663
1826
  if stripped is not None:
1664
- if _is_number(numeric):
1665
- return stripped, f"{_plain_scalar(numeric)}\u03bcs"
1827
+ if is_number:
1828
+ return stripped, f"{_plain_scalar(value)}\u03bcs"
1666
1829
  return None
1667
1830
  stripped = _strip_suffix_ci(key, "_ms")
1668
1831
  if stripped is not None:
1669
- fv = _format_ms_value(numeric)
1832
+ fv = _format_ms_value(value)
1670
1833
  if fv is not None:
1671
1834
  return stripped, fv
1672
1835
  return None
1673
1836
  stripped = _strip_suffix_ci(key, "_s")
1674
1837
  if stripped is not None:
1675
- if _is_number(numeric):
1676
- return stripped, f"{_plain_scalar(numeric)}s"
1838
+ if is_number:
1839
+ return stripped, f"{_plain_scalar(value)}s"
1677
1840
  return None
1678
1841
 
1679
1842
  return None
@@ -1745,9 +1908,11 @@ def _format_ms_as_seconds(ms: float) -> str:
1745
1908
 
1746
1909
  def _format_ms_value(value: Any) -> str | None:
1747
1910
  """Format _ms value: < 1000 -> {n}ms, >= 1000 -> seconds."""
1748
- 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):
1749
1915
  return None
1750
- n = float(value)
1751
1916
  if abs(n) >= 1000:
1752
1917
  return _format_ms_as_seconds(n)
1753
1918
  return f"{_plain_scalar(value)}ms"
@@ -1758,7 +1923,12 @@ def _format_rfc3339_ms(ms: int) -> str | None:
1758
1923
  return None
1759
1924
  try:
1760
1925
  dt = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(milliseconds=ms)
1761
- 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
+ )
1762
1932
  except (OverflowError, ValueError):
1763
1933
  return None
1764
1934
 
@@ -1918,11 +2088,11 @@ def _yaml_scalar(value: Any) -> str:
1918
2088
  if isinstance(value, (int, float)):
1919
2089
  return _number_str(value)
1920
2090
  if isinstance(value, _RawNumber):
1921
- # Decoded number: emit the exact source literal unquoted, matching
1922
- # JSON's structure-preserving fidelity contract for YAML too.
1923
- 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)
1924
2094
  if isinstance(value, (dict, list)):
1925
- return f'"{_escape_yaml_str(_canonical_json(value))}"'
2095
+ return f'"{_escape_yaml_str(_embedded_json(value))}"'
1926
2096
  return f'"{_escape_yaml_str(str(value))}"'
1927
2097
 
1928
2098
 
@@ -1988,18 +2158,18 @@ def _plain_scalar(value: Any) -> str:
1988
2158
  if isinstance(value, (int, float)):
1989
2159
  return _number_str(value)
1990
2160
  if isinstance(value, _RawNumber):
1991
- # Decoded number: emit the exact source literal (Plain is documented
1992
- # lossy for arithmetic-derived formatting, but a bare pass-through
1993
- # scalar still gets its exact digits, not a float64 round-trip).
1994
- 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)
1995
2165
  if isinstance(value, (dict, list)):
1996
- return _canonical_json(value)
2166
+ return _embedded_json(value)
1997
2167
  return str(value)
1998
2168
 
1999
2169
 
2000
2170
  def _plain_scalar_raw(value: Any) -> str:
2001
2171
  if isinstance(value, (dict, list)):
2002
- return _canonical_json(value)
2172
+ return _embedded_json(value)
2003
2173
  return _plain_scalar(value)
2004
2174
 
2005
2175
 
@@ -2035,13 +2205,21 @@ def _utf16_sort_key(s: str) -> bytes:
2035
2205
  return s.encode("utf-16-be", "surrogatepass")
2036
2206
 
2037
2207
 
2038
- def _canonical_json(value: Any) -> str:
2039
- return _encode_json_lossless(_sort_json_value(value))
2040
-
2041
-
2042
- 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."""
2043
2214
  if isinstance(value, dict):
2044
- 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
+ ) + "}"
2045
2219
  if isinstance(value, list):
2046
- return [_sort_json_value(item) for item in value]
2047
- 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.1
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.1"
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=***"}