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.
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/PKG-INFO +6 -4
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/README.md +4 -2
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/cli-spec-v1.schema.json +5 -2
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/cli.py +31 -4
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/format.py +338 -100
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/PKG-INFO +6 -4
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/pyproject.toml +2 -2
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/tests/test_cli.py +35 -4
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/tests/test_format.py +54 -9
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/__init__.py +0 -0
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/cli-help-v2.schema.json +0 -0
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/protocol-v1.schema.json +0 -0
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/registry.json +0 -0
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/SOURCES.txt +0 -0
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/dependency_links.txt +0 -0
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/top_level.txt +0 -0
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/setup.cfg +0 -0
- {agent_first_data-0.35.0 → agent_first_data-0.36.0}/tests/test_no_stderr_policy.py +0 -0
- {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.
|
|
4
|
-
Summary: A naming convention that lets AI agents understand your data without being told what it means, plus
|
|
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.
|
|
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.
|
|
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
|
|
{agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/cli-spec-v1.schema.json
RENAMED
|
@@ -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.
|
|
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
|
|
367
|
-
because the reader hung up (broken pipe)
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
"""
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
|
452
|
-
|
|
453
|
-
|
|
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(
|
|
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=
|
|
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
|
|
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
|
-
|
|
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)
|
|
1399
|
-
return
|
|
1400
|
-
|
|
1401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
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
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
|
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
|
-
#
|
|
1469
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
1552
|
-
return stripped, f"{_plain_scalar(
|
|
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
|
|
1557
|
-
return stripped, f"{_plain_scalar(
|
|
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
|
|
1562
|
-
return stripped, f"{_plain_scalar(
|
|
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 =
|
|
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
|
|
1587
|
-
return stripped, f"{_plain_scalar(
|
|
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
|
|
1600
|
-
return stripped, f"{_plain_scalar(
|
|
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
|
|
1605
|
-
return stripped, f"{_plain_scalar(
|
|
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(
|
|
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
|
|
1616
|
-
return stripped, f"{_plain_scalar(
|
|
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
|
-
|
|
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:
|
|
1862
|
-
#
|
|
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(
|
|
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:
|
|
1932
|
-
#
|
|
1933
|
-
#
|
|
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
|
|
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
|
|
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
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
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 {
|
|
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 [
|
|
1987
|
-
|
|
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.
|
|
4
|
-
Summary: A naming convention that lets AI agents understand your data without being told what it means, plus
|
|
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.
|
|
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.
|
|
4
|
-
description = "A naming convention that lets AI agents understand your data without being told what it means, plus
|
|
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
|
|
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
|
|
462
|
-
#
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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=***"}
|
|
File without changes
|
{agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/cli-help-v2.schema.json
RENAMED
|
File without changes
|
{agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data/assets/protocol-v1.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_first_data-0.35.0 → agent_first_data-0.36.0}/agent_first_data.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|