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.
Files changed (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. 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 ""