agent-first-data 0.30.0__tar.gz → 0.31.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.30.0 → agent_first_data-0.31.0}/PKG-INFO +5 -2
  2. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/README.md +4 -1
  3. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data/__init__.py +2 -0
  4. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data/assets/cli-spec-v1.schema.json +18 -1
  5. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data/cli.py +10 -8
  6. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data/format.py +226 -30
  7. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data.egg-info/PKG-INFO +5 -2
  8. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/pyproject.toml +1 -1
  9. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/tests/test_cli.py +15 -12
  10. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/tests/test_format.py +38 -3
  11. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data/assets/cli-help-v2.schema.json +0 -0
  12. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data/assets/protocol-v1.schema.json +0 -0
  13. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data/assets/registry.json +0 -0
  14. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data.egg-info/SOURCES.txt +0 -0
  15. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data.egg-info/dependency_links.txt +0 -0
  16. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/agent_first_data.egg-info/top_level.txt +0 -0
  17. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/setup.cfg +0 -0
  18. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/tests/test_no_stderr_policy.py +0 -0
  19. {agent_first_data-0.30.0 → agent_first_data-0.31.0}/tests/test_requires_python_floor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.30.0
3
+ Version: 0.31.0
4
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.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/agentfirstkit/agent-first-data
@@ -28,12 +28,15 @@ 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_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`, `decode_protocol_event`.
32
32
 
33
33
  ## Behavior Notes
34
34
 
35
35
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
36
36
  - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed and internal whitespace redacts the whole field.
37
+ - `OutputOptions.url_names` applies URL treatment to exact legacy field names
38
+ and recurses through collections. `redact_urls_in_text` is explicit and scans
39
+ only complete scheme URLs, never arbitrary prose secrets.
37
40
  - 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.
38
41
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
39
42
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
@@ -19,12 +19,15 @@ 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_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`, `decode_protocol_event`.
23
23
 
24
24
  ## Behavior Notes
25
25
 
26
26
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
27
27
  - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed and internal whitespace redacts the whole field.
28
+ - `OutputOptions.url_names` applies URL treatment to exact legacy field names
29
+ and recurses through collections. `redact_urls_in_text` is explicit and scans
30
+ only complete scheme URLs, never arbitrary prose secrets.
28
31
  - 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.
29
32
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
30
33
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
@@ -26,6 +26,7 @@ from agent_first_data.format import (
26
26
  redacted_value,
27
27
  redact_argv,
28
28
  redact_url_secrets,
29
+ redact_urls_in_text,
29
30
  normalize_utc_offset,
30
31
  is_valid_bcp47,
31
32
  is_valid_rfc3339,
@@ -72,6 +73,7 @@ __all__ = [
72
73
  "redacted_value",
73
74
  "redact_argv",
74
75
  "redact_url_secrets",
76
+ "redact_urls_in_text",
75
77
  "normalize_utc_offset",
76
78
  "is_valid_bcp47",
77
79
  "is_valid_rfc3339",
@@ -32,6 +32,13 @@
32
32
  "about": {
33
33
  "$ref": "#/$defs/nonEmpty"
34
34
  },
35
+ "shared_arguments": {
36
+ "description": "Arguments every command accepts, declared once. Spliced into each command and into every combination's optional set when the registry is built, so a resolved spec behaves as if each command declared them.",
37
+ "type": "array",
38
+ "items": {
39
+ "$ref": "#/$defs/argument"
40
+ }
41
+ },
35
42
  "lifecycle_output": {
36
43
  "$ref": "#/$defs/output"
37
44
  },
@@ -164,7 +171,8 @@
164
171
  "i64",
165
172
  "finite_f64",
166
173
  "enum",
167
- "json"
174
+ "json",
175
+ "uuid"
168
176
  ]
169
177
  },
170
178
  "value_name": {
@@ -178,6 +186,15 @@
178
186
  "$ref": "#/$defs/nonEmpty"
179
187
  }
180
188
  },
189
+ "range": {
190
+ "description": "Inclusive [minimum, maximum] bounds for an I64 argument, checked when the value is parsed so an out-of-range value is a usage error.",
191
+ "type": "array",
192
+ "items": {
193
+ "type": "integer"
194
+ },
195
+ "minItems": 2,
196
+ "maxItems": 2
197
+ },
181
198
  "default": true,
182
199
  "repeatable": {
183
200
  "const": true
@@ -52,21 +52,22 @@ class OutputTo(enum.Enum):
52
52
  def parse(cls, value: str) -> OutputTo:
53
53
  """Parse an ``--output-to`` value: ``split`` (default), ``stdout``, or ``stderr``.
54
54
 
55
- Raises ValueError with a message suitable for build_cli_error on unknown values.
55
+ Raises ValueError with a value-safe message suitable for build_cli_error
56
+ on unknown values.
56
57
 
57
58
  >>> OutputTo.parse("split")
58
59
  <OutputTo.SPLIT: 'split'>
59
60
  >>> OutputTo.parse("xml")
60
61
  Traceback (most recent call last):
61
62
  ...
62
- ValueError: unsupported --output-to 'xml'; expected split, stdout, or stderr
63
+ ValueError: unsupported --output-to: expected split, stdout, or stderr
63
64
  """
64
65
  try:
65
66
  return cls(value)
66
67
  except ValueError:
67
68
  raise ValueError(
68
- f"unsupported --output-to {value!r}; expected split, stdout, or stderr"
69
- )
69
+ "unsupported --output-to: expected split, stdout, or stderr"
70
+ ) from None
70
71
 
71
72
 
72
73
  class LogFilters:
@@ -112,21 +113,22 @@ class LogFilters:
112
113
  def cli_parse_output(s: str) -> OutputFormat:
113
114
  """Parse the --output flag value into an OutputFormat.
114
115
 
115
- Raises ValueError with a message suitable for build_cli_error on unknown values.
116
+ Raises ValueError with a value-safe message suitable for build_cli_error on
117
+ unknown values.
116
118
 
117
119
  >>> cli_parse_output("json")
118
120
  <OutputFormat.JSON: 'json'>
119
121
  >>> cli_parse_output("xml")
120
122
  Traceback (most recent call last):
121
123
  ...
122
- ValueError: invalid --output format 'xml': expected json, yaml, or plain
124
+ ValueError: invalid --output format: expected json, yaml, or plain
123
125
  """
124
126
  try:
125
127
  return OutputFormat(s)
126
128
  except ValueError:
127
129
  raise ValueError(
128
- f"invalid --output format {s!r}: expected json, yaml, or plain"
129
- )
130
+ "invalid --output format: expected json, yaml, or plain"
131
+ ) from None
130
132
 
131
133
 
132
134
  def cli_parse_log_filters(entries: list[str]) -> LogFilters:
@@ -539,6 +539,8 @@ class OutputOptions:
539
539
  # Exact field-name matches at any nesting level. The same list also matches
540
540
  # URL query-parameter names inside _url fields (see redact_url_secrets).
541
541
  secret_names: Sequence[str] = ()
542
+ # Exact legacy field names whose string leaves receive URL-aware redaction.
543
+ url_names: Sequence[str] = ()
542
544
  # None or RedactionPolicy.All = full redaction (default).
543
545
  policy: RedactionPolicy | None = None
544
546
  style: PlainStyle = PlainStyle.Readable
@@ -555,7 +557,12 @@ def _format_json(value: Any, *, options: OutputOptions | None = None) -> str:
555
557
  Single-line JSON. Secrets redacted, original keys, raw values.
556
558
  """
557
559
  output_options = options or OutputOptions()
558
- redacted = redacted_value(value, secret_names=output_options.secret_names, policy=output_options.policy)
560
+ redacted = redacted_value(
561
+ value,
562
+ secret_names=output_options.secret_names,
563
+ url_names=output_options.url_names,
564
+ policy=output_options.policy,
565
+ )
559
566
  return _encode_json_lossless(redacted)
560
567
 
561
568
 
@@ -590,7 +597,12 @@ def _format_yaml(value: Any, *, options: OutputOptions | None = None) -> str:
590
597
  types, secrets redacted. `PlainStyle` has no effect on YAML.
591
598
  """
592
599
  output_options = options or OutputOptions()
593
- value = redacted_value(value, secret_names=output_options.secret_names, policy=output_options.policy)
600
+ value = redacted_value(
601
+ value,
602
+ secret_names=output_options.secret_names,
603
+ url_names=output_options.url_names,
604
+ policy=output_options.policy,
605
+ )
594
606
  lines = ["---"]
595
607
  _render_yaml_raw(value, 0, lines)
596
608
  return "\n".join(lines)
@@ -602,7 +614,12 @@ def _format_plain(value: Any, *, options: OutputOptions | None = None) -> str:
602
614
  Single-line logfmt. Keys stripped, values formatted, secrets redacted.
603
615
  """
604
616
  output_options = options or OutputOptions()
605
- value = redacted_value(value, secret_names=output_options.secret_names, policy=output_options.policy)
617
+ value = redacted_value(
618
+ value,
619
+ secret_names=output_options.secret_names,
620
+ url_names=output_options.url_names,
621
+ policy=output_options.policy,
622
+ )
606
623
  if not isinstance(value, dict):
607
624
  return _plain_scalar(value)
608
625
  pairs: list[tuple[str, str]] = []
@@ -624,14 +641,20 @@ def _format_plain(value: Any, *, options: OutputOptions | None = None) -> str:
624
641
  # ═══════════════════════════════════════════
625
642
 
626
643
 
627
- def redacted_value(value: Any, *, secret_names: Sequence[str] = (), policy: RedactionPolicy | None = None) -> Any:
644
+ def redacted_value(
645
+ value: Any,
646
+ *,
647
+ secret_names: Sequence[str] = (),
648
+ url_names: Sequence[str] = (),
649
+ policy: RedactionPolicy | None = None,
650
+ ) -> Any:
628
651
  """Return a JSON-safe copy with redaction options applied.
629
652
 
630
653
  Redacts fields ending in _secret/_SECRET and those listed in secret_names.
631
- Redacts _url fields' query parameters by the same rules.
654
+ Redacts _url fields and exact ``url_names`` entries by the same rules.
632
655
  """
633
656
  v = _sanitize_for_json(value)
634
- _apply_redaction(v, secret_names, policy)
657
+ _apply_redaction(v, secret_names, url_names, policy)
635
658
  return v
636
659
 
637
660
 
@@ -665,6 +688,23 @@ def redact_url_secrets(
665
688
  return redacted if redacted is not None else url
666
689
 
667
690
 
691
+ def redact_urls_in_text(
692
+ text: str,
693
+ *,
694
+ secret_names: Sequence[str] = (),
695
+ policy: RedactionPolicy | None = None,
696
+ ) -> str:
697
+ """Redact complete scheme-prefixed URL spans embedded in prose.
698
+
699
+ This helper is explicit and URL-only: surrounding text is never scanned
700
+ for secret-looking names or values.
701
+ """
702
+ if not _redacts_unscoped_input(policy):
703
+ return text
704
+ context = _RedactionContext.from_names(secret_names)
705
+ return _redact_urls_in_text_with_context(text, context)
706
+
707
+
668
708
  def redact_argv(
669
709
  args: Sequence[str],
670
710
  *,
@@ -725,8 +765,13 @@ def _is_secret_flag_name(flag_name: str, context: _RedactionContext) -> bool:
725
765
  return context.is_secret_key(normalized) or context.is_secret_key(flag_name)
726
766
 
727
767
 
728
- def _apply_redaction(value: Any, secret_names: Sequence[str], policy: RedactionPolicy | None) -> None:
729
- context = _RedactionContext.from_names(secret_names)
768
+ def _apply_redaction(
769
+ value: Any,
770
+ secret_names: Sequence[str],
771
+ url_names: Sequence[str],
772
+ policy: RedactionPolicy | None,
773
+ ) -> None:
774
+ context = _RedactionContext.from_names(secret_names, url_names)
730
775
  _apply_redaction_policy_with_context(value, policy, context)
731
776
 
732
777
 
@@ -987,17 +1032,28 @@ def _sanitize_for_json(value: Any, stack: set[int] | None = None, depth: int = 0
987
1032
 
988
1033
  @dataclass(frozen=True)
989
1034
  class _RedactionContext:
990
- """Internal redaction context: the secret-name set."""
1035
+ """Internal redaction context: exact secret-name and URL-name sets."""
991
1036
 
992
1037
  secret_names: frozenset[str] = frozenset()
1038
+ url_names: frozenset[str] = frozenset()
993
1039
 
994
1040
  @classmethod
995
- def from_names(cls, secret_names: Sequence[str]) -> _RedactionContext:
996
- return cls(secret_names=frozenset(secret_names))
1041
+ def from_names(
1042
+ cls,
1043
+ secret_names: Sequence[str],
1044
+ url_names: Sequence[str] = (),
1045
+ ) -> _RedactionContext:
1046
+ return cls(
1047
+ secret_names=frozenset(secret_names),
1048
+ url_names=frozenset(url_names),
1049
+ )
997
1050
 
998
1051
  def is_secret_key(self, key: str) -> bool:
999
1052
  return _key_has_secret_suffix(key) or key in self.secret_names
1000
1053
 
1054
+ def is_url_key(self, key: str) -> bool:
1055
+ return _key_has_url_suffix(key) or key in self.url_names
1056
+
1001
1057
 
1002
1058
  def _key_has_secret_suffix(key: str) -> bool:
1003
1059
  return key.endswith("_secret") or key.endswith("_SECRET")
@@ -1021,13 +1077,8 @@ def _redact_secrets(value: Any, context: _RedactionContext = _RedactionContext()
1021
1077
  # it does not invent one.
1022
1078
  if v is not None:
1023
1079
  value[k] = REDACTED_MARKER
1024
- elif _key_has_url_suffix(k):
1025
- if isinstance(v, str):
1026
- value[k] = _redact_url_field_value(v, context)
1027
- elif depth + 1 >= MAX_DEPTH:
1028
- value[k] = MAX_DEPTH_MARKER
1029
- else:
1030
- _redact_secrets(v, context, depth + 1)
1080
+ elif context.is_url_key(k):
1081
+ value[k] = _redact_url_value(v, context, depth + 1)
1031
1082
  elif depth + 1 >= MAX_DEPTH:
1032
1083
  value[k] = MAX_DEPTH_MARKER
1033
1084
  else:
@@ -1040,6 +1091,27 @@ def _redact_secrets(value: Any, context: _RedactionContext = _RedactionContext()
1040
1091
  _redact_secrets(item, context, depth + 1)
1041
1092
 
1042
1093
 
1094
+ def _redact_url_value(value: Any, context: _RedactionContext, depth: int) -> Any:
1095
+ if depth >= MAX_DEPTH:
1096
+ return MAX_DEPTH_MARKER
1097
+ if isinstance(value, str):
1098
+ return _redact_url_field_value(value, context)
1099
+ if isinstance(value, list):
1100
+ for index, item in enumerate(value):
1101
+ value[index] = _redact_url_value(item, context, depth + 1)
1102
+ return value
1103
+ if isinstance(value, dict):
1104
+ for key in list(value.keys()):
1105
+ item = value[key]
1106
+ if context.is_secret_key(key):
1107
+ if item is not None:
1108
+ value[key] = REDACTED_MARKER
1109
+ else:
1110
+ value[key] = _redact_url_value(item, context, depth + 1)
1111
+ return value
1112
+ return value
1113
+
1114
+
1043
1115
  # ═══════════════════════════════════════════
1044
1116
  # URL-aware Secret Redaction
1045
1117
  # ═══════════════════════════════════════════
@@ -1088,6 +1160,115 @@ def _redact_url_in_str(s: str, context: _RedactionContext) -> str | None:
1088
1160
  return f"{scheme}://{new_authority}{new_before_fragment}{new_fragment}"
1089
1161
 
1090
1162
 
1163
+ def _redact_urls_in_text_with_context(text: str, context: _RedactionContext) -> str:
1164
+ output: list[str] = []
1165
+ copied_through = 0
1166
+ search_from = 0
1167
+ while True:
1168
+ span = _next_scheme_url_span(text, search_from)
1169
+ if span is None:
1170
+ break
1171
+ start, end = span
1172
+ output.append(text[copied_through:start])
1173
+ url = text[start:end]
1174
+ output.append(_redact_url_in_str(url, context) or url)
1175
+ copied_through = end
1176
+ search_from = end
1177
+ if not output:
1178
+ return text
1179
+ output.append(text[copied_through:])
1180
+ return "".join(output)
1181
+
1182
+
1183
+ def _next_scheme_url_span(text: str, start_at: int) -> tuple[int, int] | None:
1184
+ start = start_at
1185
+ while start < len(text):
1186
+ # Only consider the head of a maximal scheme-character run: a scheme
1187
+ # cannot begin mid-run, so a position whose predecessor is a scheme
1188
+ # character was already covered by the run that contains it.
1189
+ if start == 0 or not _is_scheme_character(text[start - 1]):
1190
+ scheme_end = start
1191
+ while scheme_end < len(text) and _is_scheme_character(text[scheme_end]):
1192
+ scheme_end += 1
1193
+ # A scheme must start with a letter, but the run need not: prose like
1194
+ # "2https://h/?token_secret=x" glues a digit onto the URL. Retry from
1195
+ # the first letter *inside* the run instead of abandoning the span --
1196
+ # giving up there fails open and leaks the whole query.
1197
+ scheme_start = next(
1198
+ (
1199
+ i
1200
+ for i in range(start, scheme_end)
1201
+ if text[i].isascii() and text[i].isalpha()
1202
+ ),
1203
+ None,
1204
+ )
1205
+ if scheme_start is not None and text.startswith("://", scheme_end):
1206
+ end = scheme_end + 3
1207
+ while (
1208
+ end < len(text)
1209
+ and not _is_span_terminator_whitespace(text[end])
1210
+ and not _is_text_url_delimiter(text[end])
1211
+ ):
1212
+ end += 1
1213
+ while (
1214
+ end > scheme_end + 3
1215
+ and _is_trailing_text_url_punctuation(text[end - 1])
1216
+ ):
1217
+ end -= 1
1218
+ if end > scheme_end + 3:
1219
+ return scheme_start, end
1220
+ start += 1
1221
+ return None
1222
+
1223
+
1224
+ def _is_scheme_character(character: str) -> bool:
1225
+ return character.isascii() and (character.isalnum() or character in "+-.")
1226
+
1227
+
1228
+ # Characters that end a URL span in prose: the Unicode White_Space property,
1229
+ # written out here instead of delegated to ``str.isspace()``.
1230
+ #
1231
+ # The four AFDATA implementations must cut the span at the same byte, and each
1232
+ # language's native predicate covers a different set -- ``str.isspace()`` counts
1233
+ # U+001C-U+001F, JS ``\s`` counts U+FEFF but not U+0085, Rust and Go count
1234
+ # neither. Divergence is a defect in both directions: ending a span early leaves
1235
+ # the rest of the query readable, and ending it late pulls the following prose
1236
+ # into the URL, where redacting the parameter it lands in deletes it. So the set
1237
+ # is enumerated, and it is generous -- a redaction helper exists to keep a log
1238
+ # line readable, so anything a human reads as a space must end the URL.
1239
+ #
1240
+ # Two exclusions are deliberate, not oversights:
1241
+ # * U+FEFF (zero-width no-break space) is not White_Space. Treating it as one
1242
+ # would cut a URL carrying it short and leave the secret after it in the
1243
+ # clear -- the exact leak JS ``\s`` used to cause here.
1244
+ # * U+001C-U+001F (the file/group/record/unit separators) are not White_Space
1245
+ # either; only ``str.isspace()`` counted them.
1246
+ #
1247
+ # This is a different question from :func:`_is_single_url`, which asks whether a
1248
+ # whole string is one URL and stays ASCII-only. A span this scanner produces
1249
+ # contains no whitespace at all, so it satisfies that stricter gate either way.
1250
+ _SPAN_TERMINATOR_WHITESPACE = frozenset(
1251
+ "\u0009\u000a\u000b\u000c\u000d\u0020" # TAB, LF, VT, FF, CR, SPACE
1252
+ "\u0085\u00a0\u1680" # NEL, NBSP, OGHAM SPACE MARK
1253
+ "\u2000\u2001\u2002\u2003\u2004\u2005" # EN QUAD .. FOUR-PER-EM SPACE
1254
+ "\u2006\u2007\u2008\u2009\u200a" # SIX-PER-EM SPACE .. HAIR SPACE
1255
+ "\u2028\u2029" # LINE SEPARATOR, PARAGRAPH SEPARATOR
1256
+ "\u202f\u205f\u3000" # NARROW NBSP, MEDIUM MATH SPACE, IDEOGRAPHIC SPACE
1257
+ )
1258
+
1259
+
1260
+ def _is_span_terminator_whitespace(character: str) -> bool:
1261
+ return character in _SPAN_TERMINATOR_WHITESPACE
1262
+
1263
+
1264
+ def _is_text_url_delimiter(character: str) -> bool:
1265
+ return character in "\"'<>`,。;!?)》】」』"
1266
+
1267
+
1268
+ def _is_trailing_text_url_punctuation(character: str) -> bool:
1269
+ return character in ".,;!)]},。;!)》】"
1270
+
1271
+
1091
1272
  def _redact_url_field_value(s: str, context: _RedactionContext) -> str:
1092
1273
  redacted = _redact_url_in_str(s, context)
1093
1274
  if redacted is not None:
@@ -1249,18 +1430,22 @@ def _as_non_neg_int(value: Any) -> int | None:
1249
1430
  return None
1250
1431
 
1251
1432
 
1252
- def _as_non_neg_i64(value: Any) -> int | None:
1433
+ def _as_i64(value: Any) -> int | None:
1253
1434
  if isinstance(value, _RawNumber):
1254
1435
  if not re.fullmatch(r"-?\d+", value.literal):
1255
1436
  return None
1256
1437
  n = int(value.literal)
1257
1438
  else:
1258
1439
  n = _as_int(value)
1259
- if n is not None and 0 <= n <= 9_223_372_036_854_775_807:
1440
+ if n is not None and -9_223_372_036_854_775_808 <= n <= 9_223_372_036_854_775_807:
1260
1441
  return n
1261
1442
  return None
1262
1443
 
1263
1444
 
1445
+ def _signed_magnitude(value: int) -> tuple[str, int]:
1446
+ return ("-", -value) if value < 0 else ("", value)
1447
+
1448
+
1264
1449
  def _as_decimal_int(value: Any) -> int | None:
1265
1450
  if isinstance(value, str) and re.fullmatch(r"-?\d+", value):
1266
1451
  return int(value)
@@ -1318,29 +1503,39 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1318
1503
  # Group 2: compound currency suffixes
1319
1504
  stripped = _strip_suffix_ci(key, "_usd_cents")
1320
1505
  if stripped is not None:
1321
- n = _as_non_neg_i64(value)
1506
+ n = _as_i64(value)
1322
1507
  if n is not None:
1323
- return stripped, f"${n // 100}.{n % 100:02d}"
1508
+ sign, magnitude = _signed_magnitude(n)
1509
+ return stripped, f"{sign}${magnitude // 100}.{magnitude % 100:02d}"
1324
1510
  return None
1325
1511
  stripped = _strip_suffix_ci(key, "_eur_cents")
1326
1512
  if stripped is not None:
1327
- n = _as_non_neg_i64(value)
1513
+ n = _as_i64(value)
1328
1514
  if n is not None:
1329
- return stripped, f"\u20ac{n // 100}.{n % 100:02d}"
1515
+ sign, magnitude = _signed_magnitude(n)
1516
+ return stripped, f"{sign}\u20ac{magnitude // 100}.{magnitude % 100:02d}"
1330
1517
  return None
1331
1518
  gc = _try_strip_generic_cents(key)
1332
1519
  if gc is not None:
1333
1520
  stripped, code = gc
1334
- n = _as_non_neg_i64(value)
1521
+ n = _as_i64(value)
1335
1522
  if n is not None:
1336
- return stripped, f"{n // 100}.{n % 100:02d} {code.upper()}"
1523
+ sign, magnitude = _signed_magnitude(n)
1524
+ return (
1525
+ stripped,
1526
+ f"{sign}{magnitude // 100}.{magnitude % 100:02d} {code.upper()}",
1527
+ )
1337
1528
  return None
1338
1529
  gm = _try_strip_generic_micro(key)
1339
1530
  if gm is not None:
1340
1531
  stripped, code = gm
1341
- n = _as_non_neg_i64(value)
1532
+ n = _as_i64(value)
1342
1533
  if n is not None:
1343
- return stripped, f"{n // 1_000_000}.{n % 1_000_000:06d} {code.upper()}"
1534
+ sign, magnitude = _signed_magnitude(n)
1535
+ return (
1536
+ stripped,
1537
+ f"{sign}{magnitude // 1_000_000}.{magnitude % 1_000_000:06d} {code.upper()}",
1538
+ )
1344
1539
  return None
1345
1540
 
1346
1541
  # Group 3: multi-char suffixes
@@ -1392,9 +1587,10 @@ def _try_process_field(key: str, value: Any) -> tuple[str, str] | None:
1392
1587
  # Group 5: short suffixes (last to avoid false positives)
1393
1588
  stripped = _strip_suffix_ci(key, "_jpy")
1394
1589
  if stripped is not None:
1395
- n = _as_non_neg_i64(value)
1590
+ n = _as_i64(value)
1396
1591
  if n is not None:
1397
- return stripped, f"\u00a5{_format_with_commas(n)}"
1592
+ sign, magnitude = _signed_magnitude(n)
1593
+ return stripped, f"{sign}\u00a5{_format_with_commas(magnitude)}"
1398
1594
  return None
1399
1595
  stripped = _strip_suffix_ci(key, "_ns")
1400
1596
  if stripped is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.30.0
3
+ Version: 0.31.0
4
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.
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/agentfirstkit/agent-first-data
@@ -28,12 +28,15 @@ 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_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`, `decode_protocol_event`.
32
32
 
33
33
  ## Behavior Notes
34
34
 
35
35
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
36
36
  - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed and internal whitespace redacts the whole field.
37
+ - `OutputOptions.url_names` applies URL treatment to exact legacy field names
38
+ and recurses through collections. `redact_urls_in_text` is explicit and scans
39
+ only complete scheme URLs, never arbitrary prose secrets.
37
40
  - 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.
38
41
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
39
42
  - `build_cli_error(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-first-data"
3
- version = "0.30.0"
3
+ version = "0.31.0"
4
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."
5
5
  license = "MIT"
6
6
  readme = "README.md"
@@ -39,11 +39,13 @@ def test_parse_output_rejects_unknown():
39
39
  cli_parse_output("")
40
40
 
41
41
 
42
- def test_parse_output_error_contains_value():
43
- with pytest.raises(ValueError, match="toml"):
44
- cli_parse_output("toml")
45
- with pytest.raises(ValueError, match="json"):
46
- cli_parse_output("toml")
42
+ def test_parse_output_error_omits_value():
43
+ canary = "canary-output-secret"
44
+ with pytest.raises(ValueError) as raised:
45
+ cli_parse_output(canary)
46
+ assert canary not in str(raised.value)
47
+ assert "json" in str(raised.value)
48
+ assert raised.value.__suppress_context__ is True
47
49
 
48
50
 
49
51
  # ── cli_parse_log_filters ─────────────────────────────────────────────────────
@@ -267,13 +269,14 @@ def test_output_to_parse_all_variants():
267
269
 
268
270
 
269
271
  def test_output_to_parse_rejects_unknown():
270
- with pytest.raises(ValueError, match="unsupported --output-to"):
271
- OutputTo.parse("xml")
272
- # The offending value and the accepted set are named in the message.
273
- with pytest.raises(ValueError, match="xml"):
274
- OutputTo.parse("xml")
275
- with pytest.raises(ValueError, match="split, stdout, or stderr"):
276
- OutputTo.parse("both")
272
+ canary = "canary-output-to-secret"
273
+ with pytest.raises(ValueError) as raised:
274
+ OutputTo.parse(canary)
275
+ message = str(raised.value)
276
+ assert "unsupported --output-to" in message
277
+ assert canary not in message
278
+ assert "split, stdout, or stderr" in message
279
+ assert raised.value.__suppress_context__ is True
277
280
 
278
281
 
279
282
  def test_output_to_parse_is_case_sensitive():
@@ -27,6 +27,7 @@ from agent_first_data import (
27
27
  redacted_value,
28
28
  redact_argv,
29
29
  redact_url_secrets,
30
+ redact_urls_in_text,
30
31
  render,
31
32
  normalize_utc_offset,
32
33
  is_valid_rfc3339_date,
@@ -85,7 +86,11 @@ def test_builder_contract_fixtures():
85
86
  def _redaction_options(case):
86
87
  opts = case.get("options", {})
87
88
  policy = RedactionPolicy(opts["policy"]) if "policy" in opts else None
88
- return OutputOptions(policy=policy, secret_names=opts.get("secret_names", ()))
89
+ return OutputOptions(
90
+ policy=policy,
91
+ secret_names=opts.get("secret_names", ()),
92
+ url_names=opts.get("url_names", ()),
93
+ )
89
94
 
90
95
 
91
96
  # --- Redact fixtures ---
@@ -110,6 +115,18 @@ def test_redact_url_fixtures():
110
115
  assert got == case["expected"], f"[redact_url/{name}] got {got!r}"
111
116
 
112
117
 
118
+ def test_redact_urls_in_text_fixtures():
119
+ for case in _load("redact_urls_in_text.json"):
120
+ name = case["name"]
121
+ options = _redaction_options(case)
122
+ got = redact_urls_in_text(
123
+ case["input"],
124
+ secret_names=options.secret_names,
125
+ policy=options.policy,
126
+ )
127
+ assert got == case["expected"], f"[redact_urls_in_text/{name}] got {got!r}"
128
+
129
+
113
130
  def test_redact_argv_fixtures():
114
131
  for case in _load("redact_argv.json"):
115
132
  name = case["name"]
@@ -133,7 +150,12 @@ def test_redaction_options_fixtures():
133
150
  output_options = _redaction_options(case)
134
151
  expected = case["expected"]
135
152
 
136
- got = redacted_value(case["input"], secret_names=output_options.secret_names, policy=output_options.policy)
153
+ got = redacted_value(
154
+ case["input"],
155
+ secret_names=output_options.secret_names,
156
+ url_names=output_options.url_names,
157
+ policy=output_options.policy,
158
+ )
137
159
  assert got == expected, f"[redaction_options/{name}] value mismatch: {got}"
138
160
 
139
161
  got_json = json.loads(render(case["input"], OutputFormat.JSON, options=output_options))
@@ -152,7 +174,12 @@ def test_security_fixtures():
152
174
  for case in fixture["redaction_cases"]:
153
175
  name = case["name"]
154
176
  output_options = _redaction_options(case)
155
- assert redacted_value(case["input"], secret_names=output_options.secret_names, policy=output_options.policy) == case["expected"]
177
+ assert redacted_value(
178
+ case["input"],
179
+ secret_names=output_options.secret_names,
180
+ url_names=output_options.url_names,
181
+ policy=output_options.policy,
182
+ ) == case["expected"]
156
183
  outputs = (
157
184
  render(case["input"], OutputFormat.JSON, options=output_options),
158
185
  render(case["input"], OutputFormat.YAML, options=output_options),
@@ -772,3 +799,11 @@ def test_number_fidelity_does_not_regress_ordinary_decoded_numbers_in_plain_outp
772
799
  decoded = decode_protocol_event(line)
773
800
  plain = render(decoded.result, OutputFormat.PLAIN)
774
801
  assert plain == "cpu=85.5% duration=42ms size=5.0MiB"
802
+
803
+
804
+ def test_signed_fiat_i64_min_formats_without_overflow():
805
+ plain = render(
806
+ {"adjustment_usd_cents": -9_223_372_036_854_775_808},
807
+ OutputFormat.PLAIN,
808
+ )
809
+ assert plain == "adjustment=-$92233720368547758.08"