agent-first-data 0.1.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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-first-data
3
+ Version: 0.1.0
4
+ Summary: Agent-First Data (AFD) — suffix-driven output formatting and protocol templates for AI agents
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
@@ -0,0 +1,29 @@
1
+ """Agent-First Data (AFD) — suffix-driven output formatting and protocol templates."""
2
+
3
+ from agent_first_data.format import (
4
+ OutputFormat,
5
+ to_yaml,
6
+ to_plain,
7
+ redact_secrets,
8
+ ok,
9
+ ok_trace,
10
+ error,
11
+ error_trace,
12
+ startup,
13
+ status,
14
+ parse_size,
15
+ )
16
+
17
+ __all__ = [
18
+ "OutputFormat",
19
+ "to_yaml",
20
+ "to_plain",
21
+ "redact_secrets",
22
+ "ok",
23
+ "ok_trace",
24
+ "error",
25
+ "error_trace",
26
+ "startup",
27
+ "status",
28
+ "parse_size",
29
+ ]
@@ -0,0 +1,352 @@
1
+ """AFD output formatting: JSON, YAML, plain text with suffix-driven transforms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+
11
+ class OutputFormat(Enum):
12
+ JSON = "json"
13
+ YAML = "yaml"
14
+ PLAIN = "plain"
15
+
16
+ def format(self, value: Any) -> str:
17
+ if self is OutputFormat.JSON:
18
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
19
+ if self is OutputFormat.YAML:
20
+ return to_yaml(value)
21
+ return to_plain(value)
22
+
23
+ def format_pretty(self, value: Any) -> str:
24
+ if self is OutputFormat.JSON:
25
+ return json.dumps(value, ensure_ascii=False, indent=2)
26
+ if self is OutputFormat.YAML:
27
+ return to_yaml(value)
28
+ return to_plain(value)
29
+
30
+
31
+ # ═══════════════════════════════════════════
32
+ # YAML
33
+ # ═══════════════════════════════════════════
34
+
35
+
36
+ def to_yaml(value: Any) -> str:
37
+ lines = ["---"]
38
+ _render_yaml(value, 0, lines)
39
+ return "\n".join(lines)
40
+
41
+
42
+ def _render_yaml(value: Any, indent: int, lines: list[str]) -> None:
43
+ prefix = " " * indent
44
+ if isinstance(value, dict):
45
+ for k, v in sorted(value.items(), key=lambda kv: kv[0].encode("utf-16-be")):
46
+ if isinstance(v, dict):
47
+ if v:
48
+ lines.append(f"{prefix}{k}:")
49
+ _render_yaml(v, indent + 1, lines)
50
+ else:
51
+ lines.append(f"{prefix}{k}: {{}}")
52
+ elif isinstance(v, list):
53
+ if not v:
54
+ lines.append(f"{prefix}{k}: []")
55
+ else:
56
+ lines.append(f"{prefix}{k}:")
57
+ for item in v:
58
+ if isinstance(item, dict):
59
+ lines.append(f"{prefix} -")
60
+ _render_yaml(item, indent + 2, lines)
61
+ else:
62
+ lines.append(f"{prefix} - {_yaml_scalar(item)}")
63
+ else:
64
+ lines.append(f"{prefix}{k}: {_yaml_scalar(v)}")
65
+ else:
66
+ lines.append(f"{prefix}{_yaml_scalar(value)}")
67
+
68
+
69
+ def _yaml_scalar(value: Any) -> str:
70
+ if isinstance(value, str):
71
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
72
+ escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
73
+ return f'"{escaped}"'
74
+ if value is None:
75
+ return "null"
76
+ if isinstance(value, bool):
77
+ return "true" if value else "false"
78
+ if isinstance(value, (int, float)):
79
+ return str(value)
80
+ escaped = str(value).replace('"', '\\"')
81
+ return f'"{escaped}"'
82
+
83
+
84
+ # ═══════════════════════════════════════════
85
+ # Plain
86
+ # ═══════════════════════════════════════════
87
+
88
+
89
+ def to_plain(value: Any) -> str:
90
+ lines: list[str] = []
91
+ _render_plain(value, 0, lines)
92
+ return "\n".join(lines)
93
+
94
+
95
+ def _render_plain(value: Any, indent: int, lines: list[str]) -> None:
96
+ prefix = " " * indent
97
+ if isinstance(value, dict):
98
+ for k, v in sorted(value.items(), key=lambda kv: kv[0].encode("utf-16-be")):
99
+ if isinstance(v, dict):
100
+ lines.append(f"{prefix}{k}:")
101
+ _render_plain(v, indent + 1, lines)
102
+ elif isinstance(v, list):
103
+ if not v:
104
+ lines.append(f"{prefix}{k}: []")
105
+ elif all(not isinstance(i, (dict, list)) for i in v):
106
+ lines.append(f"{prefix}{k}:")
107
+ for item in v:
108
+ lines.append(f"{prefix} - {_plain_scalar(item)}")
109
+ else:
110
+ lines.append(f"{prefix}{k}:")
111
+ for item in v:
112
+ if isinstance(item, dict):
113
+ lines.append(f"{prefix} -")
114
+ _render_plain(item, indent + 2, lines)
115
+ else:
116
+ lines.append(f"{prefix} - {_plain_scalar(item)}")
117
+ else:
118
+ lines.append(f"{prefix}{k}: {_format_plain_field(k, v)}")
119
+ else:
120
+ lines.append(f"{prefix}{_plain_scalar(value)}")
121
+
122
+
123
+ def _format_plain_field(key: str, value: Any) -> str:
124
+ lower = key.lower()
125
+
126
+ # Secret — always redact
127
+ if lower.endswith("_secret"):
128
+ return "***"
129
+
130
+ # Timestamps → RFC 3339
131
+ if lower.endswith("_epoch_ms"):
132
+ if isinstance(value, int) and not isinstance(value, bool):
133
+ return _format_rfc3339_ms(value)
134
+ if lower.endswith("_epoch_s"):
135
+ if isinstance(value, int) and not isinstance(value, bool):
136
+ return _format_rfc3339_ms(value * 1000)
137
+ if lower.endswith("_epoch_ns"):
138
+ if isinstance(value, int) and not isinstance(value, bool):
139
+ return _format_rfc3339_ms(value // 1_000_000)
140
+ if lower.endswith("_rfc3339"):
141
+ return _plain_scalar(value)
142
+
143
+ # Size
144
+ if lower.endswith("_bytes"):
145
+ if isinstance(value, int) and not isinstance(value, bool):
146
+ return _format_bytes_human(value)
147
+
148
+ # Percentage
149
+ if lower.endswith("_percent"):
150
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
151
+ return f"{_plain_scalar(value)}%"
152
+
153
+ # Currency — Bitcoin
154
+ if lower.endswith("_msats"):
155
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
156
+ return f"{_plain_scalar(value)}msats"
157
+ if lower.endswith("_sats"):
158
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
159
+ return f"{_plain_scalar(value)}sats"
160
+ if lower.endswith("_btc"):
161
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
162
+ return f"{_plain_scalar(value)} BTC"
163
+
164
+ # Currency — Fiat with symbol
165
+ if lower.endswith("_usd_cents"):
166
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
167
+ return f"${value // 100}.{value % 100:02d}"
168
+ if lower.endswith("_eur_cents"):
169
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
170
+ return f"\u20ac{value // 100}.{value % 100:02d}"
171
+ if lower.endswith("_jpy"):
172
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
173
+ return f"\u00a5{_format_with_commas(value)}"
174
+ # Currency — Generic _{code}_cents
175
+ if lower.endswith("_cents"):
176
+ code = _extract_currency_code(lower)
177
+ if code and isinstance(value, int) and not isinstance(value, bool) and value >= 0:
178
+ return f"{value // 100}.{value % 100:02d} {code.upper()}"
179
+
180
+ # Duration — long units
181
+ if lower.endswith("_minutes"):
182
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
183
+ return f"{_plain_scalar(value)} minutes"
184
+ if lower.endswith("_hours"):
185
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
186
+ return f"{_plain_scalar(value)} hours"
187
+ if lower.endswith("_days"):
188
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
189
+ return f"{_plain_scalar(value)} days"
190
+
191
+ # Duration — ms
192
+ if lower.endswith("_ms") and not lower.endswith("_epoch_ms"):
193
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
194
+ if value >= 1000:
195
+ return f"{value / 1000:.2f}s"
196
+ return f"{_plain_scalar(value)}ms"
197
+
198
+ # Duration — ns, us, s
199
+ if lower.endswith("_ns") and not lower.endswith("_epoch_ns"):
200
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
201
+ return f"{_plain_scalar(value)}ns"
202
+ if lower.endswith("_us"):
203
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
204
+ return f"{_plain_scalar(value)}\u03bcs"
205
+ if lower.endswith("_s") and not lower.endswith("_epoch_s"):
206
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
207
+ return f"{_plain_scalar(value)}s"
208
+
209
+ return _plain_scalar(value)
210
+
211
+
212
+ def _plain_scalar(value: Any) -> str:
213
+ if isinstance(value, str):
214
+ return value
215
+ if value is None:
216
+ return "null"
217
+ if isinstance(value, bool):
218
+ return "true" if value else "false"
219
+ if isinstance(value, (int, float)):
220
+ return str(value)
221
+ return str(value)
222
+
223
+
224
+ # ═══════════════════════════════════════════
225
+ # Secret redaction
226
+ # ═══════════════════════════════════════════
227
+
228
+
229
+ def redact_secrets(value: Any) -> Any:
230
+ """Walk a dict/list tree and redact any key ending in '_secret'."""
231
+ if isinstance(value, dict):
232
+ for k in value:
233
+ if k.lower().endswith("_secret") and isinstance(value[k], str):
234
+ value[k] = "***"
235
+ redact_secrets(value[k])
236
+ elif isinstance(value, list):
237
+ for item in value:
238
+ redact_secrets(item)
239
+ return value
240
+
241
+
242
+ # ═══════════════════════════════════════════
243
+ # AFD Protocol templates
244
+ # ═══════════════════════════════════════════
245
+
246
+
247
+ def ok(result: Any) -> dict:
248
+ return {"code": "ok", "result": result}
249
+
250
+
251
+ def ok_trace(result: Any, trace: Any) -> dict:
252
+ return {"code": "ok", "result": result, "trace": trace}
253
+
254
+
255
+ def error(message: str) -> dict:
256
+ return {"code": "error", "error": message}
257
+
258
+
259
+ def error_trace(message: str, trace: Any) -> dict:
260
+ return {"code": "error", "error": message, "trace": trace}
261
+
262
+
263
+ def startup(config: Any, args: Any, env: Any) -> dict:
264
+ return {"code": "startup", "config": config, "args": args, "env": env}
265
+
266
+
267
+ def status(code: str, fields: dict | None = None) -> dict:
268
+ result = dict(fields) if fields else {}
269
+ result["code"] = code
270
+ return result
271
+
272
+
273
+ # ═══════════════════════════════════════════
274
+ # Helpers
275
+ # ═══════════════════════════════════════════
276
+
277
+
278
+ def _format_rfc3339_ms(ms: int) -> str:
279
+ try:
280
+ dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
281
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{ms % 1000:03d}Z"
282
+ except (OSError, OverflowError, ValueError):
283
+ return str(ms)
284
+
285
+
286
+ def _format_bytes_human(n: int) -> str:
287
+ KB = 1024.0
288
+ MB = KB * 1024
289
+ GB = MB * 1024
290
+ TB = GB * 1024
291
+ sign = "-" if n < 0 else ""
292
+ b = float(abs(n))
293
+ if b >= TB:
294
+ return f"{sign}{b / TB:.1f}TB"
295
+ if b >= GB:
296
+ return f"{sign}{b / GB:.1f}GB"
297
+ if b >= MB:
298
+ return f"{sign}{b / MB:.1f}MB"
299
+ if b >= KB:
300
+ return f"{sign}{b / KB:.1f}KB"
301
+ return f"{n}B"
302
+
303
+
304
+ def _format_with_commas(n: int) -> str:
305
+ return f"{n:,}"
306
+
307
+
308
+ def parse_size(s: str) -> int | None:
309
+ """Parse a human-readable size string into bytes.
310
+
311
+ Accepts bare numbers or numbers followed by a unit letter (B/K/M/G/T).
312
+ Case-insensitive. Trims whitespace. Returns None for invalid input.
313
+ """
314
+ _multipliers = {"b": 1, "k": 1024, "m": 1024**2, "g": 1024**3, "t": 1024**4}
315
+ s = s.strip()
316
+ if not s:
317
+ return None
318
+ last = s[-1].lower()
319
+ if last in _multipliers:
320
+ num_str = s[:-1]
321
+ mult = _multipliers[last]
322
+ elif last.isdigit() or last == ".":
323
+ num_str = s
324
+ mult = 1
325
+ else:
326
+ return None
327
+ if not num_str:
328
+ return None
329
+ try:
330
+ n = int(num_str)
331
+ if n < 0:
332
+ return None
333
+ return n * mult
334
+ except ValueError:
335
+ pass
336
+ try:
337
+ f = float(num_str)
338
+ if f < 0 or f != f: # NaN check
339
+ return None
340
+ return int(f * mult)
341
+ except (ValueError, OverflowError):
342
+ return None
343
+
344
+
345
+ def _extract_currency_code(key: str) -> str | None:
346
+ without_cents = key.removesuffix("_cents")
347
+ if without_cents == key:
348
+ return None
349
+ idx = without_cents.rfind("_")
350
+ if idx < 0:
351
+ return None
352
+ return without_cents[idx + 1:]
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-first-data
3
+ Version: 0.1.0
4
+ Summary: Agent-First Data (AFD) — suffix-driven output formatting and protocol templates for AI agents
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
@@ -0,0 +1,8 @@
1
+ pyproject.toml
2
+ agent_first_data/__init__.py
3
+ agent_first_data/format.py
4
+ agent_first_data.egg-info/PKG-INFO
5
+ agent_first_data.egg-info/SOURCES.txt
6
+ agent_first_data.egg-info/dependency_links.txt
7
+ agent_first_data.egg-info/top_level.txt
8
+ tests/test_format.py
@@ -0,0 +1 @@
1
+ agent_first_data
@@ -0,0 +1,11 @@
1
+ [project]
2
+ name = "agent-first-data"
3
+ version = "0.1.0"
4
+ description = "Agent-First Data (AFD) — suffix-driven output formatting and protocol templates for AI agents"
5
+ license = "MIT"
6
+ requires-python = ">=3.9"
7
+ dependencies = []
8
+
9
+ [build-system]
10
+ requires = ["setuptools>=68"]
11
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,153 @@
1
+ """Tests for AFD output formatting — driven by shared spec/fixtures."""
2
+
3
+ import json
4
+ import os
5
+
6
+ from agent_first_data import (
7
+ to_yaml,
8
+ to_plain,
9
+ redact_secrets,
10
+ ok,
11
+ ok_trace,
12
+ error,
13
+ error_trace,
14
+ startup,
15
+ status,
16
+ OutputFormat,
17
+ )
18
+ from agent_first_data.format import (
19
+ _format_bytes_human,
20
+ _format_with_commas,
21
+ _extract_currency_code,
22
+ parse_size,
23
+ )
24
+
25
+ FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "spec", "fixtures")
26
+
27
+
28
+ def _load(name):
29
+ with open(os.path.join(FIXTURES_DIR, name)) as f:
30
+ return json.load(f)
31
+
32
+
33
+ # --- Plain fixtures ---
34
+
35
+
36
+ def test_plain_fixtures():
37
+ for case in _load("plain.json"):
38
+ name = case["name"]
39
+ plain = to_plain(case["input"])
40
+ for s in case["contains"]:
41
+ assert s in plain, f"[plain/{name}] expected {s!r} in {plain!r}"
42
+ for s in case.get("not_contains", []):
43
+ assert s not in plain, f"[plain/{name}] unexpected {s!r} in {plain!r}"
44
+
45
+
46
+ # --- YAML fixtures ---
47
+
48
+
49
+ def test_yaml_fixtures():
50
+ for case in _load("yaml.json"):
51
+ name = case["name"]
52
+ yaml = to_yaml(case["input"])
53
+ if "starts_with" in case:
54
+ assert yaml.startswith(case["starts_with"]), f"[yaml/{name}] starts_with failed"
55
+ for s in case.get("contains", []):
56
+ assert s in yaml, f"[yaml/{name}] expected {s!r} in {yaml!r}"
57
+
58
+
59
+ # --- Redact fixtures ---
60
+
61
+
62
+ def test_redact_fixtures():
63
+ for case in _load("redact.json"):
64
+ name = case["name"]
65
+ inp = json.loads(json.dumps(case["input"])) # deep copy
66
+ redact_secrets(inp)
67
+ assert inp == case["expected"], f"[redact/{name}] got {inp}"
68
+
69
+
70
+ # --- Protocol fixtures ---
71
+
72
+
73
+ def test_protocol_fixtures():
74
+ for case in _load("protocol.json"):
75
+ name = case["name"]
76
+ typ = case["type"]
77
+ args = case["args"]
78
+ if typ == "ok":
79
+ result = ok(args["result"])
80
+ elif typ == "ok_trace":
81
+ result = ok_trace(args["result"], args["trace"])
82
+ elif typ == "error":
83
+ result = error(args["message"])
84
+ elif typ == "error_trace":
85
+ result = error_trace(args["message"], args["trace"])
86
+ elif typ == "startup":
87
+ result = startup(args["config"], args["args"], args["env"])
88
+ elif typ == "status":
89
+ result = status(args["code"], args.get("fields"))
90
+ else:
91
+ raise ValueError(f"unknown type: {typ}")
92
+
93
+ if "expected" in case:
94
+ assert result == case["expected"], f"[protocol/{name}] got {result}"
95
+ if "expected_contains" in case:
96
+ for k, v in case["expected_contains"].items():
97
+ assert result[k] == v, f"[protocol/{name}] key {k}: got {result.get(k)}"
98
+
99
+
100
+ # --- Exact fixtures ---
101
+
102
+
103
+ def test_exact_fixtures():
104
+ for case in _load("exact.json"):
105
+ name = case["name"]
106
+ fmt = case["format"]
107
+ expected = case["expected"]
108
+ if fmt == "plain":
109
+ got = to_plain(case["input"])
110
+ elif fmt == "yaml":
111
+ got = to_yaml(case["input"])
112
+ else:
113
+ raise ValueError(f"unknown format: {fmt}")
114
+ assert got == expected, f"[exact/{name}]\ngot: {got!r}\nwant: {expected!r}"
115
+
116
+
117
+ # --- Helper fixtures ---
118
+
119
+
120
+ def test_helper_fixtures():
121
+ for case in _load("helpers.json"):
122
+ name = case["name"]
123
+ for tc in case["cases"]:
124
+ inp, expected = tc
125
+ if name == "format_bytes_human":
126
+ got = _format_bytes_human(inp)
127
+ assert got == expected, f"[helpers/{name}({inp})] got {got!r}"
128
+ elif name == "format_with_commas":
129
+ got = _format_with_commas(inp)
130
+ assert got == expected, f"[helpers/{name}({inp})] got {got!r}"
131
+ elif name == "extract_currency_code":
132
+ got = _extract_currency_code(inp)
133
+ assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
134
+ elif name == "parse_size":
135
+ got = parse_size(inp)
136
+ assert got == expected, f"[helpers/{name}({inp!r})] got {got!r}"
137
+
138
+
139
+ # --- OutputFormat (not in fixtures — format-specific) ---
140
+
141
+
142
+ def test_output_format_json():
143
+ assert OutputFormat.JSON.format({"status": "ok"}) == '{"status":"ok"}'
144
+
145
+
146
+ def test_output_format_yaml():
147
+ out = OutputFormat.YAML.format({"status": "ok"})
148
+ assert out.startswith("---\n")
149
+ assert 'status: "ok"' in out
150
+
151
+
152
+ def test_output_format_plain():
153
+ assert OutputFormat.PLAIN.format({"status": "ok"}) == "status: ok"