affinity-sdk 0.9.5__py3-none-any.whl
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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Output formatters for CLI commands.
|
|
2
|
+
|
|
3
|
+
Provides unified formatting for all output types:
|
|
4
|
+
- JSON: Full structured output (preserves CommandResult envelope)
|
|
5
|
+
- JSONL: One JSON object per line (streaming-friendly, data-only)
|
|
6
|
+
- Markdown: GitHub-flavored markdown tables (data-only)
|
|
7
|
+
- TOON: Token-Optimized Object Notation (data-only)
|
|
8
|
+
- CSV: Comma-separated values (data-only)
|
|
9
|
+
- Table: Rich terminal tables (existing, delegates to render.py)
|
|
10
|
+
|
|
11
|
+
TOON is an open specification designed for LLM token efficiency.
|
|
12
|
+
See: https://github.com/toon-format/spec/blob/main/SPEC.md
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
OutputFormat = Literal["table", "json", "jsonl", "markdown", "toon", "csv"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_cell(value: Any) -> str:
|
|
27
|
+
"""Convert value to string for tabular output.
|
|
28
|
+
|
|
29
|
+
This is the canonical implementation used by all formatters.
|
|
30
|
+
Handles various nested structures for human readability:
|
|
31
|
+
- Dropdown fields: extracts "text" value
|
|
32
|
+
- Person entities: "firstName lastName (id=N)"
|
|
33
|
+
- Company entities: "name (id=N)"
|
|
34
|
+
- Fields containers: "Field1=val, Field2=val... (N fields)"
|
|
35
|
+
- Other dicts: "object (N keys)"
|
|
36
|
+
"""
|
|
37
|
+
if value is None:
|
|
38
|
+
return ""
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
return "true" if value else "false"
|
|
41
|
+
if isinstance(value, (int, float)):
|
|
42
|
+
return str(value)
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
return value
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
# For multi-select fields, extract text if available
|
|
47
|
+
texts = []
|
|
48
|
+
for v in value:
|
|
49
|
+
if isinstance(v, dict) and "text" in v:
|
|
50
|
+
texts.append(str(v["text"]))
|
|
51
|
+
elif v is not None:
|
|
52
|
+
texts.append(to_cell(v))
|
|
53
|
+
return "; ".join(texts)
|
|
54
|
+
if isinstance(value, dict):
|
|
55
|
+
# For dropdown fields, extract text if available
|
|
56
|
+
if "text" in value:
|
|
57
|
+
return str(value["text"])
|
|
58
|
+
|
|
59
|
+
# Check for typed entities (person/company)
|
|
60
|
+
entity_type = value.get("type")
|
|
61
|
+
entity_id = value.get("id")
|
|
62
|
+
|
|
63
|
+
if entity_type == "person":
|
|
64
|
+
name = _extract_person_name(value)
|
|
65
|
+
if name and entity_id is not None:
|
|
66
|
+
return f"{name} (id={entity_id})"
|
|
67
|
+
elif name:
|
|
68
|
+
return name
|
|
69
|
+
elif entity_id is not None:
|
|
70
|
+
return f"person (id={entity_id})"
|
|
71
|
+
|
|
72
|
+
if entity_type == "company":
|
|
73
|
+
name = value.get("name") or value.get("domain")
|
|
74
|
+
if name and entity_id is not None:
|
|
75
|
+
return f"{name} (id={entity_id})"
|
|
76
|
+
elif name:
|
|
77
|
+
return str(name)
|
|
78
|
+
elif entity_id is not None:
|
|
79
|
+
return f"company (id={entity_id})"
|
|
80
|
+
|
|
81
|
+
# Check for fields container (raw API format with "data" dict)
|
|
82
|
+
if "data" in value and isinstance(value.get("data"), dict):
|
|
83
|
+
preview = _extract_fields_preview(value["data"])
|
|
84
|
+
count = len(value["data"])
|
|
85
|
+
if preview and count > 0:
|
|
86
|
+
return f"{preview}... ({count} fields)"
|
|
87
|
+
elif count > 0:
|
|
88
|
+
return f"({count} fields)"
|
|
89
|
+
|
|
90
|
+
# Generic dict with name (or entityName for list export format)
|
|
91
|
+
name = value.get("name") or value.get("entityName")
|
|
92
|
+
if isinstance(name, str) and name.strip():
|
|
93
|
+
# Include entityId if available for drill-down
|
|
94
|
+
entity_id = value.get("entityId")
|
|
95
|
+
if entity_id is not None:
|
|
96
|
+
return f"{name} (id={entity_id})"
|
|
97
|
+
return name
|
|
98
|
+
|
|
99
|
+
# Check for normalized fields dict (query executor format: {"FieldName": value, ...})
|
|
100
|
+
# Only if no common keys that indicate a different structure
|
|
101
|
+
if _is_flat_fields_dict(value):
|
|
102
|
+
preview = _extract_flat_fields_preview(value)
|
|
103
|
+
count = len(value)
|
|
104
|
+
if preview:
|
|
105
|
+
# Only show count when truncated (more than 2 fields shown)
|
|
106
|
+
if count > 2:
|
|
107
|
+
return f"{preview}... ({count} fields)"
|
|
108
|
+
return preview
|
|
109
|
+
elif count > 0:
|
|
110
|
+
return f"({count} fields)"
|
|
111
|
+
|
|
112
|
+
# Fallback: compact placeholder
|
|
113
|
+
return f"object ({len(value)} keys)"
|
|
114
|
+
return str(value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract_person_name(value: dict[str, Any]) -> str | None:
|
|
118
|
+
"""Extract display name from a person entity."""
|
|
119
|
+
first = value.get("firstName")
|
|
120
|
+
last = value.get("lastName")
|
|
121
|
+
parts = []
|
|
122
|
+
if isinstance(first, str) and first.strip():
|
|
123
|
+
parts.append(first.strip())
|
|
124
|
+
if isinstance(last, str) and last.strip():
|
|
125
|
+
parts.append(last.strip())
|
|
126
|
+
return " ".join(parts) if parts else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _extract_fields_preview(data: dict[str, Any], max_fields: int = 2) -> str | None:
|
|
130
|
+
"""Extract a preview of field values from a fields data dict.
|
|
131
|
+
|
|
132
|
+
The fields data structure is:
|
|
133
|
+
{"field-abc": {"name": "Status", "value": {"data": {"text": "Active"}}}, ...}
|
|
134
|
+
|
|
135
|
+
Returns something like: "Status=Active, Owner=Jane"
|
|
136
|
+
"""
|
|
137
|
+
previews: list[str] = []
|
|
138
|
+
for field_obj in data.values():
|
|
139
|
+
if len(previews) >= max_fields:
|
|
140
|
+
break
|
|
141
|
+
if not isinstance(field_obj, dict):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
field_name = field_obj.get("name")
|
|
145
|
+
if not isinstance(field_name, str) or not field_name.strip():
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Extract the value - could be nested in value.data
|
|
149
|
+
value_wrapper = field_obj.get("value")
|
|
150
|
+
display_value: str | None = None
|
|
151
|
+
|
|
152
|
+
if isinstance(value_wrapper, dict):
|
|
153
|
+
inner_data = value_wrapper.get("data")
|
|
154
|
+
if isinstance(inner_data, dict):
|
|
155
|
+
# Dropdown: {"text": "Active"}
|
|
156
|
+
if "text" in inner_data:
|
|
157
|
+
display_value = str(inner_data["text"])
|
|
158
|
+
# Person reference: {"firstName": "Jane", "lastName": "Doe"}
|
|
159
|
+
elif "firstName" in inner_data or "lastName" in inner_data:
|
|
160
|
+
display_value = _extract_person_name(inner_data)
|
|
161
|
+
# Company reference: {"name": "Acme"}
|
|
162
|
+
elif "name" in inner_data:
|
|
163
|
+
display_value = str(inner_data["name"])
|
|
164
|
+
elif inner_data is not None:
|
|
165
|
+
# Simple value (string, number)
|
|
166
|
+
display_value = str(inner_data)
|
|
167
|
+
elif value_wrapper is not None:
|
|
168
|
+
display_value = str(value_wrapper)
|
|
169
|
+
|
|
170
|
+
if display_value:
|
|
171
|
+
previews.append(f"{field_name}={display_value}")
|
|
172
|
+
|
|
173
|
+
return ", ".join(previews) if previews else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _is_flat_fields_dict(value: dict[str, Any]) -> bool:
|
|
177
|
+
"""Check if a dict looks like normalized fields (flat key-value pairs).
|
|
178
|
+
|
|
179
|
+
Normalized fields from the query executor look like:
|
|
180
|
+
{"Team Member": "LB", "Status": "Active", "Deal Size": 1000000}
|
|
181
|
+
|
|
182
|
+
Multi-select fields may have list values:
|
|
183
|
+
{"Team Member": ["LB", "JD"], "Status": "Active"}
|
|
184
|
+
|
|
185
|
+
Returns True if:
|
|
186
|
+
- Has no common structural keys (id, type, name, data, etc.)
|
|
187
|
+
- All values are simple types (str, int, float, bool, None), dropdown dicts, or lists thereof
|
|
188
|
+
"""
|
|
189
|
+
if not value:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
# Common keys that indicate this is NOT a fields dict
|
|
193
|
+
common_keys = {
|
|
194
|
+
"id",
|
|
195
|
+
"type",
|
|
196
|
+
"name",
|
|
197
|
+
"data",
|
|
198
|
+
"text", # Entity/structure keys
|
|
199
|
+
"entityId",
|
|
200
|
+
"entityName",
|
|
201
|
+
"entityType",
|
|
202
|
+
"listEntryId", # List export keys
|
|
203
|
+
"firstName",
|
|
204
|
+
"lastName",
|
|
205
|
+
"domain",
|
|
206
|
+
"domains", # Person/company keys
|
|
207
|
+
"city",
|
|
208
|
+
"state",
|
|
209
|
+
"country",
|
|
210
|
+
"zip",
|
|
211
|
+
"street", # Location keys
|
|
212
|
+
"pagination",
|
|
213
|
+
"requested",
|
|
214
|
+
"nextCursor", # API response keys
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# If it has any common key, it's not a flat fields dict
|
|
218
|
+
if value.keys() & common_keys:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
for v in value.values():
|
|
222
|
+
if v is None:
|
|
223
|
+
continue
|
|
224
|
+
if isinstance(v, (str, int, float, bool)):
|
|
225
|
+
continue
|
|
226
|
+
if isinstance(v, dict) and "text" in v:
|
|
227
|
+
continue
|
|
228
|
+
# Check for list of simple values (multi-select fields)
|
|
229
|
+
if isinstance(v, list) and all(
|
|
230
|
+
item is None
|
|
231
|
+
or isinstance(item, (str, int, float, bool))
|
|
232
|
+
or (isinstance(item, dict) and "text" in item)
|
|
233
|
+
for item in v
|
|
234
|
+
):
|
|
235
|
+
continue
|
|
236
|
+
# Has complex nested value - not a flat fields dict
|
|
237
|
+
return False
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _extract_flat_fields_preview(value: dict[str, Any], max_fields: int = 2) -> str | None:
|
|
242
|
+
"""Extract preview from a flat fields dict.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
value: Dict like {"Team Member": "LB", "Status": "Active"}
|
|
246
|
+
or with lists: {"Team Member": ["LB", "JD"], "Status": "Active"}
|
|
247
|
+
max_fields: Maximum number of fields to show
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Preview string like "Team Member=LB, Status=Active"
|
|
251
|
+
or "Team Member=LB; JD, Status=Active" for multi-select
|
|
252
|
+
"""
|
|
253
|
+
previews: list[str] = []
|
|
254
|
+
for field_name, field_value in value.items():
|
|
255
|
+
if len(previews) >= max_fields:
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
if field_value is None:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Extract display value
|
|
262
|
+
if isinstance(field_value, list):
|
|
263
|
+
# Multi-select field: join values with semicolons
|
|
264
|
+
parts = []
|
|
265
|
+
for item in field_value:
|
|
266
|
+
if item is None:
|
|
267
|
+
continue
|
|
268
|
+
if isinstance(item, dict) and "text" in item:
|
|
269
|
+
parts.append(str(item["text"]))
|
|
270
|
+
elif isinstance(item, bool):
|
|
271
|
+
parts.append("true" if item else "false")
|
|
272
|
+
else:
|
|
273
|
+
parts.append(str(item))
|
|
274
|
+
display = "; ".join(parts) if parts else ""
|
|
275
|
+
elif isinstance(field_value, dict) and "text" in field_value:
|
|
276
|
+
display = str(field_value["text"])
|
|
277
|
+
elif isinstance(field_value, bool):
|
|
278
|
+
display = "true" if field_value else "false"
|
|
279
|
+
else:
|
|
280
|
+
display = str(field_value)
|
|
281
|
+
|
|
282
|
+
if display:
|
|
283
|
+
previews.append(f"{field_name}={display}")
|
|
284
|
+
|
|
285
|
+
return ", ".join(previews) if previews else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def format_data(
|
|
289
|
+
data: list[dict[str, Any]],
|
|
290
|
+
format: OutputFormat,
|
|
291
|
+
*,
|
|
292
|
+
fieldnames: list[str] | None = None,
|
|
293
|
+
pretty: bool = False,
|
|
294
|
+
) -> str:
|
|
295
|
+
"""Format tabular data in the specified output format.
|
|
296
|
+
|
|
297
|
+
This formats the DATA portion only, not the full CommandResult envelope.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
data: List of row dictionaries
|
|
301
|
+
format: Output format
|
|
302
|
+
fieldnames: Column order (auto-detected from first row if None)
|
|
303
|
+
pretty: Pretty-print where applicable (JSON only)
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Formatted string
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
ValueError: If format is "table" (use render.py instead)
|
|
310
|
+
"""
|
|
311
|
+
if format == "table":
|
|
312
|
+
raise ValueError("Use render.py for table format")
|
|
313
|
+
|
|
314
|
+
if not data:
|
|
315
|
+
return _empty_output(format, fieldnames)
|
|
316
|
+
|
|
317
|
+
fieldnames = fieldnames or list(data[0].keys())
|
|
318
|
+
|
|
319
|
+
match format:
|
|
320
|
+
case "json":
|
|
321
|
+
return format_json_data(data, pretty=pretty)
|
|
322
|
+
case "jsonl":
|
|
323
|
+
return format_jsonl(data)
|
|
324
|
+
case "markdown":
|
|
325
|
+
return format_markdown(data, fieldnames)
|
|
326
|
+
case "toon":
|
|
327
|
+
return format_toon(data, fieldnames)
|
|
328
|
+
case "csv":
|
|
329
|
+
return format_csv(data, fieldnames)
|
|
330
|
+
case _:
|
|
331
|
+
raise ValueError(f"Unknown format: {format}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def format_json_data(data: list[dict[str, Any]], *, pretty: bool = False) -> str:
|
|
335
|
+
"""Format as JSON array (data only, no envelope)."""
|
|
336
|
+
indent = 2 if pretty else None
|
|
337
|
+
return json.dumps(data, indent=indent, default=str, ensure_ascii=False)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def format_jsonl(data: list[dict[str, Any]]) -> str:
|
|
341
|
+
"""Format as JSON Lines (one object per line).
|
|
342
|
+
|
|
343
|
+
Standard: https://jsonlines.org/
|
|
344
|
+
|
|
345
|
+
Requirements:
|
|
346
|
+
- Each line is a valid JSON value (typically object)
|
|
347
|
+
- Lines separated by \\n (\\r\\n also acceptable)
|
|
348
|
+
- UTF-8 encoding, no BOM
|
|
349
|
+
- Trailing newline recommended for file concatenation
|
|
350
|
+
"""
|
|
351
|
+
if not data:
|
|
352
|
+
return ""
|
|
353
|
+
lines = [json.dumps(row, default=str, ensure_ascii=False) for row in data]
|
|
354
|
+
return "\n".join(lines) + "\n" # Trailing newline per spec recommendation
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def format_markdown(data: list[dict[str, Any]], fieldnames: list[str]) -> str:
|
|
358
|
+
"""Format as GitHub-flavored Markdown table.
|
|
359
|
+
|
|
360
|
+
Features:
|
|
361
|
+
- Numeric columns are right-aligned
|
|
362
|
+
- Pipe characters escaped
|
|
363
|
+
- Newlines converted to <br>
|
|
364
|
+
"""
|
|
365
|
+
if not data:
|
|
366
|
+
return "_No results_"
|
|
367
|
+
|
|
368
|
+
# Detect numeric columns for alignment
|
|
369
|
+
numeric_cols = _detect_numeric_columns(data, fieldnames)
|
|
370
|
+
|
|
371
|
+
# Header row
|
|
372
|
+
header = "| " + " | ".join(fieldnames) + " |"
|
|
373
|
+
|
|
374
|
+
# Separator row with alignment
|
|
375
|
+
separators = []
|
|
376
|
+
for f in fieldnames:
|
|
377
|
+
if f in numeric_cols:
|
|
378
|
+
separators.append("---:") # Right-align numbers
|
|
379
|
+
else:
|
|
380
|
+
separators.append("---")
|
|
381
|
+
separator = "| " + " | ".join(separators) + " |"
|
|
382
|
+
|
|
383
|
+
# Data rows
|
|
384
|
+
rows = []
|
|
385
|
+
for row in data:
|
|
386
|
+
cells = [_md_escape(to_cell(row.get(f))) for f in fieldnames]
|
|
387
|
+
rows.append("| " + " | ".join(cells) + " |")
|
|
388
|
+
|
|
389
|
+
return "\n".join([header, separator, *rows])
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def format_toon(data: list[dict[str, Any]], fieldnames: list[str]) -> str:
|
|
393
|
+
"""Format as TOON (Token-Optimized Object Notation).
|
|
394
|
+
|
|
395
|
+
TOON is an open specification for LLM-optimized data serialization.
|
|
396
|
+
Spec: https://github.com/toon-format/spec/blob/main/SPEC.md
|
|
397
|
+
|
|
398
|
+
For uniform object arrays (our use case), TOON uses tabular format:
|
|
399
|
+
[N]{field1,field2,field3}:
|
|
400
|
+
value1,value2,value3
|
|
401
|
+
value4,value5,value6
|
|
402
|
+
|
|
403
|
+
Note: Rows are indented by 2 spaces (TOON spec §12).
|
|
404
|
+
|
|
405
|
+
Benefits:
|
|
406
|
+
- 30-60% fewer tokens than JSON for tabular data
|
|
407
|
+
- Schema-aware (field names declared once)
|
|
408
|
+
- Lossless JSON round-trip
|
|
409
|
+
- Row count enables truncation detection
|
|
410
|
+
|
|
411
|
+
Trade-offs:
|
|
412
|
+
- Less effective for non-uniform/nested structures
|
|
413
|
+
- Most LLMs lack explicit TOON training data
|
|
414
|
+
"""
|
|
415
|
+
if not data:
|
|
416
|
+
return "[0]{}:"
|
|
417
|
+
|
|
418
|
+
# TOON root-level tabular array format: [count]{fields}:
|
|
419
|
+
header = f"[{len(data)}]{{{','.join(fieldnames)}}}:"
|
|
420
|
+
|
|
421
|
+
lines = [header]
|
|
422
|
+
for row in data:
|
|
423
|
+
cells = []
|
|
424
|
+
for f in fieldnames:
|
|
425
|
+
val = row.get(f)
|
|
426
|
+
# Numbers are emitted directly without quoting in TOON
|
|
427
|
+
if isinstance(val, bool):
|
|
428
|
+
cells.append("true" if val else "false")
|
|
429
|
+
elif isinstance(val, (int, float)):
|
|
430
|
+
cells.append(str(val))
|
|
431
|
+
else:
|
|
432
|
+
cells.append(_toon_quote(to_cell(val)))
|
|
433
|
+
# Rows indented by 2 spaces per TOON spec
|
|
434
|
+
lines.append(" " + ",".join(cells))
|
|
435
|
+
|
|
436
|
+
return "\n".join(lines)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def format_csv(data: list[dict[str, Any]], fieldnames: list[str]) -> str:
|
|
440
|
+
"""Format as CSV string.
|
|
441
|
+
|
|
442
|
+
Always includes header row, even for empty data (when fieldnames provided).
|
|
443
|
+
"""
|
|
444
|
+
output = io.StringIO()
|
|
445
|
+
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
|
446
|
+
writer.writeheader()
|
|
447
|
+
for row in data:
|
|
448
|
+
# Use fieldnames to ensure consistent column order
|
|
449
|
+
writer.writerow({f: to_cell(row.get(f)) for f in fieldnames})
|
|
450
|
+
return output.getvalue()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _detect_numeric_columns(data: list[dict[str, Any]], fieldnames: list[str]) -> set[str]:
|
|
454
|
+
"""Detect columns that contain numeric values for right-alignment.
|
|
455
|
+
|
|
456
|
+
Design decision: Requires ALL non-null values to be numeric.
|
|
457
|
+
A threshold approach (e.g., >80% numeric) was considered but rejected
|
|
458
|
+
because mixed-type columns are rare in our data model, and strict
|
|
459
|
+
detection avoids surprising alignment for edge cases.
|
|
460
|
+
"""
|
|
461
|
+
numeric = set()
|
|
462
|
+
for f in fieldnames:
|
|
463
|
+
values = [row.get(f) for row in data if row.get(f) is not None]
|
|
464
|
+
if values and all(isinstance(v, (int, float)) for v in values):
|
|
465
|
+
numeric.add(f)
|
|
466
|
+
return numeric
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _md_escape(text: str) -> str:
|
|
470
|
+
"""Escape special markdown characters in table cells."""
|
|
471
|
+
# Escape pipe characters (table delimiter)
|
|
472
|
+
text = text.replace("|", "\\|")
|
|
473
|
+
# Replace newlines with <br> for multi-line content
|
|
474
|
+
text = text.replace("\n", "<br>")
|
|
475
|
+
return text
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _toon_quote(text: str) -> str:
|
|
479
|
+
"""Quote TOON string values per spec §7.
|
|
480
|
+
|
|
481
|
+
TOON spec: strings MUST be quoted if they:
|
|
482
|
+
- Are empty
|
|
483
|
+
- Have leading/trailing whitespace
|
|
484
|
+
- Equal true, false, null (case-sensitive)
|
|
485
|
+
- Match numeric patterns
|
|
486
|
+
- Contain: colon, quote, backslash, brackets, control chars, delimiter
|
|
487
|
+
- Equal "-" or start with hyphen
|
|
488
|
+
|
|
489
|
+
Valid escapes: \\\\ \" \\n \\r \\t
|
|
490
|
+
"""
|
|
491
|
+
# Empty string must be quoted
|
|
492
|
+
if not text:
|
|
493
|
+
return '""'
|
|
494
|
+
|
|
495
|
+
# Check if quoting needed
|
|
496
|
+
needs_quotes = (
|
|
497
|
+
text != text.strip() # leading/trailing whitespace
|
|
498
|
+
or text in ("true", "false", "null") # reserved words (case-sensitive)
|
|
499
|
+
or text == "-"
|
|
500
|
+
or text.startswith("-") # hyphen rules
|
|
501
|
+
or re.match(r"^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$", text, re.I) # numeric
|
|
502
|
+
or re.match(r"^0\d+$", text) # octal-like
|
|
503
|
+
or any(c in text for c in ':"\\[]{}') # special chars
|
|
504
|
+
or any(ord(c) < 32 for c in text) # control chars
|
|
505
|
+
or "," in text # delimiter
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if not needs_quotes:
|
|
509
|
+
return text
|
|
510
|
+
|
|
511
|
+
# Apply escaping per spec §7.1
|
|
512
|
+
escaped = text
|
|
513
|
+
escaped = escaped.replace("\\", "\\\\") # backslash first
|
|
514
|
+
escaped = escaped.replace('"', '\\"')
|
|
515
|
+
escaped = escaped.replace("\n", "\\n")
|
|
516
|
+
escaped = escaped.replace("\r", "\\r")
|
|
517
|
+
escaped = escaped.replace("\t", "\\t")
|
|
518
|
+
|
|
519
|
+
return f'"{escaped}"'
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _empty_output(format: OutputFormat, fieldnames: list[str] | None = None) -> str:
|
|
523
|
+
"""Return appropriate empty output for format.
|
|
524
|
+
|
|
525
|
+
For tabular formats (markdown, csv), includes headers when fieldnames known.
|
|
526
|
+
This provides consistent structure even with empty results.
|
|
527
|
+
"""
|
|
528
|
+
match format:
|
|
529
|
+
case "json":
|
|
530
|
+
return "[]"
|
|
531
|
+
case "jsonl":
|
|
532
|
+
return "" # Empty is valid JSONL
|
|
533
|
+
case "markdown":
|
|
534
|
+
# Return header-only table when fieldnames known (consistent with CSV)
|
|
535
|
+
if fieldnames:
|
|
536
|
+
header = "| " + " | ".join(fieldnames) + " |"
|
|
537
|
+
separator = "| " + " | ".join(["---"] * len(fieldnames)) + " |"
|
|
538
|
+
return f"{header}\n{separator}"
|
|
539
|
+
return "_No results_"
|
|
540
|
+
case "toon":
|
|
541
|
+
# Include field names in empty output when known
|
|
542
|
+
if fieldnames:
|
|
543
|
+
return f"[0]{{{','.join(fieldnames)}}}:"
|
|
544
|
+
return "[0]{}:"
|
|
545
|
+
case "csv":
|
|
546
|
+
# Include header even with no data (when fieldnames known)
|
|
547
|
+
if fieldnames:
|
|
548
|
+
return ",".join(fieldnames) + "\n"
|
|
549
|
+
return ""
|
|
550
|
+
case _:
|
|
551
|
+
return ""
|