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.
- agent_first_data-0.1.0/PKG-INFO +6 -0
- agent_first_data-0.1.0/agent_first_data/__init__.py +29 -0
- agent_first_data-0.1.0/agent_first_data/format.py +352 -0
- agent_first_data-0.1.0/agent_first_data.egg-info/PKG-INFO +6 -0
- agent_first_data-0.1.0/agent_first_data.egg-info/SOURCES.txt +8 -0
- agent_first_data-0.1.0/agent_first_data.egg-info/dependency_links.txt +1 -0
- agent_first_data-0.1.0/agent_first_data.egg-info/top_level.txt +1 -0
- agent_first_data-0.1.0/pyproject.toml +11 -0
- agent_first_data-0.1.0/setup.cfg +4 -0
- agent_first_data-0.1.0/tests/test_format.py +153 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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"
|