provide-foundation 0.0.0.dev0__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 (149) hide show
  1. provide/__init__.py +15 -0
  2. provide/foundation/__init__.py +155 -0
  3. provide/foundation/_version.py +58 -0
  4. provide/foundation/cli/__init__.py +67 -0
  5. provide/foundation/cli/commands/__init__.py +3 -0
  6. provide/foundation/cli/commands/deps.py +71 -0
  7. provide/foundation/cli/commands/logs/__init__.py +63 -0
  8. provide/foundation/cli/commands/logs/generate.py +357 -0
  9. provide/foundation/cli/commands/logs/generate_old.py +569 -0
  10. provide/foundation/cli/commands/logs/query.py +174 -0
  11. provide/foundation/cli/commands/logs/send.py +166 -0
  12. provide/foundation/cli/commands/logs/tail.py +112 -0
  13. provide/foundation/cli/decorators.py +262 -0
  14. provide/foundation/cli/main.py +65 -0
  15. provide/foundation/cli/testing.py +220 -0
  16. provide/foundation/cli/utils.py +210 -0
  17. provide/foundation/config/__init__.py +106 -0
  18. provide/foundation/config/base.py +295 -0
  19. provide/foundation/config/env.py +369 -0
  20. provide/foundation/config/loader.py +311 -0
  21. provide/foundation/config/manager.py +387 -0
  22. provide/foundation/config/schema.py +284 -0
  23. provide/foundation/config/sync.py +281 -0
  24. provide/foundation/config/types.py +78 -0
  25. provide/foundation/config/validators.py +80 -0
  26. provide/foundation/console/__init__.py +29 -0
  27. provide/foundation/console/input.py +364 -0
  28. provide/foundation/console/output.py +178 -0
  29. provide/foundation/context/__init__.py +12 -0
  30. provide/foundation/context/core.py +356 -0
  31. provide/foundation/core.py +20 -0
  32. provide/foundation/crypto/__init__.py +182 -0
  33. provide/foundation/crypto/algorithms.py +111 -0
  34. provide/foundation/crypto/certificates.py +896 -0
  35. provide/foundation/crypto/checksums.py +301 -0
  36. provide/foundation/crypto/constants.py +57 -0
  37. provide/foundation/crypto/hashing.py +265 -0
  38. provide/foundation/crypto/keys.py +188 -0
  39. provide/foundation/crypto/signatures.py +144 -0
  40. provide/foundation/crypto/utils.py +164 -0
  41. provide/foundation/errors/__init__.py +96 -0
  42. provide/foundation/errors/auth.py +73 -0
  43. provide/foundation/errors/base.py +81 -0
  44. provide/foundation/errors/config.py +103 -0
  45. provide/foundation/errors/context.py +299 -0
  46. provide/foundation/errors/decorators.py +484 -0
  47. provide/foundation/errors/handlers.py +360 -0
  48. provide/foundation/errors/integration.py +105 -0
  49. provide/foundation/errors/platform.py +37 -0
  50. provide/foundation/errors/process.py +140 -0
  51. provide/foundation/errors/resources.py +133 -0
  52. provide/foundation/errors/runtime.py +160 -0
  53. provide/foundation/errors/safe_decorators.py +133 -0
  54. provide/foundation/errors/types.py +276 -0
  55. provide/foundation/file/__init__.py +79 -0
  56. provide/foundation/file/atomic.py +157 -0
  57. provide/foundation/file/directory.py +134 -0
  58. provide/foundation/file/formats.py +236 -0
  59. provide/foundation/file/lock.py +175 -0
  60. provide/foundation/file/safe.py +179 -0
  61. provide/foundation/file/utils.py +170 -0
  62. provide/foundation/hub/__init__.py +88 -0
  63. provide/foundation/hub/click_builder.py +310 -0
  64. provide/foundation/hub/commands.py +42 -0
  65. provide/foundation/hub/components.py +640 -0
  66. provide/foundation/hub/decorators.py +244 -0
  67. provide/foundation/hub/info.py +32 -0
  68. provide/foundation/hub/manager.py +446 -0
  69. provide/foundation/hub/registry.py +279 -0
  70. provide/foundation/hub/type_mapping.py +54 -0
  71. provide/foundation/hub/types.py +28 -0
  72. provide/foundation/logger/__init__.py +41 -0
  73. provide/foundation/logger/base.py +22 -0
  74. provide/foundation/logger/config/__init__.py +16 -0
  75. provide/foundation/logger/config/base.py +40 -0
  76. provide/foundation/logger/config/logging.py +394 -0
  77. provide/foundation/logger/config/telemetry.py +188 -0
  78. provide/foundation/logger/core.py +239 -0
  79. provide/foundation/logger/custom_processors.py +172 -0
  80. provide/foundation/logger/emoji/__init__.py +44 -0
  81. provide/foundation/logger/emoji/matrix.py +209 -0
  82. provide/foundation/logger/emoji/sets.py +458 -0
  83. provide/foundation/logger/emoji/types.py +56 -0
  84. provide/foundation/logger/factories.py +56 -0
  85. provide/foundation/logger/processors/__init__.py +13 -0
  86. provide/foundation/logger/processors/main.py +254 -0
  87. provide/foundation/logger/processors/trace.py +113 -0
  88. provide/foundation/logger/ratelimit/__init__.py +31 -0
  89. provide/foundation/logger/ratelimit/limiters.py +294 -0
  90. provide/foundation/logger/ratelimit/processor.py +203 -0
  91. provide/foundation/logger/ratelimit/queue_limiter.py +305 -0
  92. provide/foundation/logger/setup/__init__.py +29 -0
  93. provide/foundation/logger/setup/coordinator.py +138 -0
  94. provide/foundation/logger/setup/emoji_resolver.py +64 -0
  95. provide/foundation/logger/setup/processors.py +85 -0
  96. provide/foundation/logger/setup/testing.py +39 -0
  97. provide/foundation/logger/trace.py +38 -0
  98. provide/foundation/metrics/__init__.py +119 -0
  99. provide/foundation/metrics/otel.py +122 -0
  100. provide/foundation/metrics/simple.py +165 -0
  101. provide/foundation/observability/__init__.py +53 -0
  102. provide/foundation/observability/openobserve/__init__.py +79 -0
  103. provide/foundation/observability/openobserve/auth.py +72 -0
  104. provide/foundation/observability/openobserve/client.py +307 -0
  105. provide/foundation/observability/openobserve/commands.py +357 -0
  106. provide/foundation/observability/openobserve/exceptions.py +41 -0
  107. provide/foundation/observability/openobserve/formatters.py +298 -0
  108. provide/foundation/observability/openobserve/models.py +134 -0
  109. provide/foundation/observability/openobserve/otlp.py +320 -0
  110. provide/foundation/observability/openobserve/search.py +222 -0
  111. provide/foundation/observability/openobserve/streaming.py +235 -0
  112. provide/foundation/platform/__init__.py +44 -0
  113. provide/foundation/platform/detection.py +193 -0
  114. provide/foundation/platform/info.py +157 -0
  115. provide/foundation/process/__init__.py +39 -0
  116. provide/foundation/process/async_runner.py +373 -0
  117. provide/foundation/process/lifecycle.py +406 -0
  118. provide/foundation/process/runner.py +390 -0
  119. provide/foundation/setup/__init__.py +101 -0
  120. provide/foundation/streams/__init__.py +44 -0
  121. provide/foundation/streams/console.py +57 -0
  122. provide/foundation/streams/core.py +65 -0
  123. provide/foundation/streams/file.py +104 -0
  124. provide/foundation/testing/__init__.py +166 -0
  125. provide/foundation/testing/cli.py +227 -0
  126. provide/foundation/testing/crypto.py +163 -0
  127. provide/foundation/testing/fixtures.py +49 -0
  128. provide/foundation/testing/hub.py +23 -0
  129. provide/foundation/testing/logger.py +106 -0
  130. provide/foundation/testing/streams.py +54 -0
  131. provide/foundation/tracer/__init__.py +49 -0
  132. provide/foundation/tracer/context.py +115 -0
  133. provide/foundation/tracer/otel.py +135 -0
  134. provide/foundation/tracer/spans.py +174 -0
  135. provide/foundation/types.py +32 -0
  136. provide/foundation/utils/__init__.py +97 -0
  137. provide/foundation/utils/deps.py +195 -0
  138. provide/foundation/utils/env.py +491 -0
  139. provide/foundation/utils/formatting.py +483 -0
  140. provide/foundation/utils/parsing.py +235 -0
  141. provide/foundation/utils/rate_limiting.py +112 -0
  142. provide/foundation/utils/streams.py +67 -0
  143. provide/foundation/utils/timing.py +93 -0
  144. provide_foundation-0.0.0.dev0.dist-info/METADATA +469 -0
  145. provide_foundation-0.0.0.dev0.dist-info/RECORD +149 -0
  146. provide_foundation-0.0.0.dev0.dist-info/WHEEL +5 -0
  147. provide_foundation-0.0.0.dev0.dist-info/entry_points.txt +2 -0
  148. provide_foundation-0.0.0.dev0.dist-info/licenses/LICENSE +201 -0
  149. provide_foundation-0.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,357 @@
1
+ """
2
+ CLI commands for OpenObserve integration.
3
+
4
+ These commands are auto-registered by Foundation's command discovery system.
5
+ """
6
+
7
+ try:
8
+ import click
9
+
10
+ _HAS_CLICK = True
11
+ except ImportError:
12
+ click = None
13
+ _HAS_CLICK = False
14
+
15
+ from provide.foundation.logger import get_logger
16
+
17
+ log = get_logger(__name__)
18
+
19
+
20
+ if _HAS_CLICK:
21
+ from provide.foundation.observability.openobserve import (
22
+ OpenObserveClient,
23
+ format_output,
24
+ search_logs,
25
+ tail_logs,
26
+ )
27
+
28
+ @click.group("openobserve", help="Query and manage OpenObserve logs")
29
+ @click.pass_context
30
+ def openobserve_group(ctx):
31
+ """OpenObserve log querying and streaming commands."""
32
+ # Initialize client and store in context
33
+ try:
34
+ client = OpenObserveClient.from_config()
35
+ ctx.obj = client
36
+ except Exception as e:
37
+ log.warning(f"Failed to initialize OpenObserve client: {e}")
38
+ ctx.obj = None
39
+
40
+ @openobserve_group.command("query")
41
+ @click.option(
42
+ "--sql",
43
+ required=True,
44
+ help="SQL query to execute",
45
+ )
46
+ @click.option(
47
+ "--start",
48
+ "-s",
49
+ default="-1h",
50
+ help="Start time (e.g., -1h, -30m, 2024-01-01)",
51
+ )
52
+ @click.option(
53
+ "--end",
54
+ "-e",
55
+ default="now",
56
+ help="End time (e.g., now, -5m, 2024-01-02)",
57
+ )
58
+ @click.option(
59
+ "--size",
60
+ "-n",
61
+ default=100,
62
+ type=int,
63
+ help="Number of results to return",
64
+ )
65
+ @click.option(
66
+ "--format",
67
+ "-f",
68
+ type=click.Choice(["json", "log", "table", "csv", "summary"]),
69
+ default="log",
70
+ help="Output format",
71
+ )
72
+ @click.option(
73
+ "--pretty",
74
+ is_flag=True,
75
+ help="Pretty print JSON output",
76
+ )
77
+ @click.pass_obj
78
+ def query_command(client, sql, start, end, size, format, pretty):
79
+ """Execute SQL query against OpenObserve logs."""
80
+ if client is None:
81
+ click.echo(
82
+ "OpenObserve not configured. Set OPENOBSERVE_URL, OPENOBSERVE_USER, and OPENOBSERVE_PASSWORD.",
83
+ err=True,
84
+ )
85
+ return 1
86
+
87
+ try:
88
+ response = search_logs(
89
+ sql=sql,
90
+ start_time=start,
91
+ end_time=end,
92
+ size=size,
93
+ client=client,
94
+ )
95
+
96
+ output = format_output(response, format_type=format, pretty=pretty)
97
+ click.echo(output)
98
+
99
+ except Exception as e:
100
+ click.echo(f"Query failed: {e}", err=True)
101
+ return 1
102
+
103
+ @openobserve_group.command("tail")
104
+ @click.option(
105
+ "--stream",
106
+ "-s",
107
+ default="default",
108
+ help="Stream name to tail",
109
+ )
110
+ @click.option(
111
+ "--filter",
112
+ "-f",
113
+ "filter_sql",
114
+ help="SQL WHERE clause for filtering (e.g., \"level='ERROR'\")",
115
+ )
116
+ @click.option(
117
+ "--lines",
118
+ "-n",
119
+ default=10,
120
+ type=int,
121
+ help="Number of initial lines to show",
122
+ )
123
+ @click.option(
124
+ "--follow",
125
+ "-F",
126
+ is_flag=True,
127
+ default=True,
128
+ help="Follow mode (like tail -f)",
129
+ )
130
+ @click.option(
131
+ "--format",
132
+ type=click.Choice(["log", "json"]),
133
+ default="log",
134
+ help="Output format",
135
+ )
136
+ @click.pass_obj
137
+ def tail_command(client, stream, filter_sql, lines, follow, format):
138
+ """Tail logs from OpenObserve (like 'tail -f')."""
139
+ if client is None:
140
+ click.echo(
141
+ "OpenObserve not configured. Set OPENOBSERVE_URL, OPENOBSERVE_USER, and OPENOBSERVE_PASSWORD.",
142
+ err=True,
143
+ )
144
+ return 1
145
+
146
+ try:
147
+ click.echo(f"Tailing logs from stream '{stream}'...")
148
+ if filter_sql:
149
+ click.echo(f"Filter: {filter_sql}")
150
+
151
+ for log_entry in tail_logs(
152
+ stream=stream,
153
+ filter_sql=filter_sql,
154
+ follow=follow,
155
+ lines=lines,
156
+ client=client,
157
+ ):
158
+ output = format_output(log_entry, format_type=format)
159
+ click.echo(output)
160
+
161
+ except KeyboardInterrupt:
162
+ click.echo("\nStopped tailing logs.")
163
+ except Exception as e:
164
+ click.echo(f"Tail failed: {e}", err=True)
165
+ return 1
166
+
167
+ @openobserve_group.command("errors")
168
+ @click.option(
169
+ "--stream",
170
+ "-s",
171
+ default="default",
172
+ help="Stream name to search",
173
+ )
174
+ @click.option(
175
+ "--start",
176
+ default="-1h",
177
+ help="Start time",
178
+ )
179
+ @click.option(
180
+ "--size",
181
+ "-n",
182
+ default=100,
183
+ type=int,
184
+ help="Number of results",
185
+ )
186
+ @click.option(
187
+ "--format",
188
+ "-f",
189
+ type=click.Choice(["json", "log", "table", "csv", "summary"]),
190
+ default="log",
191
+ help="Output format",
192
+ )
193
+ @click.pass_obj
194
+ def errors_command(client, stream, start, size, format):
195
+ """Search for error logs."""
196
+ if client is None:
197
+ click.echo("OpenObserve not configured.", err=True)
198
+ return 1
199
+
200
+ try:
201
+ from provide.foundation.observability.openobserve import search_errors
202
+
203
+ response = search_errors(
204
+ stream=stream,
205
+ start_time=start,
206
+ size=size,
207
+ client=client,
208
+ )
209
+
210
+ if response.total == 0:
211
+ click.echo("No errors found in the specified time range.")
212
+ else:
213
+ output = format_output(response, format_type=format)
214
+ click.echo(output)
215
+
216
+ except Exception as e:
217
+ click.echo(f"Search failed: {e}", err=True)
218
+ return 1
219
+
220
+ @openobserve_group.command("trace")
221
+ @click.argument("trace_id")
222
+ @click.option(
223
+ "--stream",
224
+ "-s",
225
+ default="default",
226
+ help="Stream name to search",
227
+ )
228
+ @click.option(
229
+ "--format",
230
+ "-f",
231
+ type=click.Choice(["json", "log", "table"]),
232
+ default="log",
233
+ help="Output format",
234
+ )
235
+ @click.pass_obj
236
+ def trace_command(client, trace_id, stream, format):
237
+ """Search for logs by trace ID."""
238
+ if client is None:
239
+ click.echo("OpenObserve not configured.", err=True)
240
+ return 1
241
+
242
+ try:
243
+ from provide.foundation.observability.openobserve import search_by_trace_id
244
+
245
+ response = search_by_trace_id(
246
+ trace_id=trace_id,
247
+ stream=stream,
248
+ client=client,
249
+ )
250
+
251
+ if response.total == 0:
252
+ click.echo(f"No logs found for trace ID: {trace_id}")
253
+ else:
254
+ output = format_output(response, format_type=format)
255
+ click.echo(output)
256
+
257
+ except Exception as e:
258
+ click.echo(f"Search failed: {e}", err=True)
259
+ return 1
260
+
261
+ @openobserve_group.command("streams")
262
+ @click.pass_obj
263
+ def streams_command(client):
264
+ """List available streams."""
265
+ if client is None:
266
+ click.echo("OpenObserve not configured.", err=True)
267
+ return 1
268
+
269
+ try:
270
+ streams = client.list_streams()
271
+
272
+ if not streams:
273
+ click.echo("No streams found.")
274
+ else:
275
+ click.echo("Available streams:")
276
+ for stream in streams:
277
+ click.echo(f" - {stream.name} ({stream.stream_type})")
278
+ if stream.doc_count > 0:
279
+ click.echo(f" Documents: {stream.doc_count:,}")
280
+ click.echo(f" Size: {stream.original_size:,} bytes")
281
+
282
+ except Exception as e:
283
+ click.echo(f"Failed to list streams: {e}", err=True)
284
+ return 1
285
+
286
+ @openobserve_group.command("history")
287
+ @click.option(
288
+ "--size",
289
+ "-n",
290
+ default=20,
291
+ type=int,
292
+ help="Number of history entries",
293
+ )
294
+ @click.option(
295
+ "--stream",
296
+ "-s",
297
+ help="Filter by stream name",
298
+ )
299
+ @click.pass_obj
300
+ def history_command(client, size, stream):
301
+ """View search history."""
302
+ if client is None:
303
+ click.echo("OpenObserve not configured.", err=True)
304
+ return 1
305
+
306
+ try:
307
+ response = client.get_search_history(
308
+ stream_name=stream,
309
+ size=size,
310
+ )
311
+
312
+ if response.total == 0:
313
+ click.echo("No search history found.")
314
+ else:
315
+ click.echo(f"Search history ({response.total} entries):")
316
+ for hit in response.hits:
317
+ sql = hit.get("sql", "N/A")
318
+ took = hit.get("took", 0)
319
+ records = hit.get("scan_records", 0)
320
+ click.echo(f"\n Query: {sql}")
321
+ click.echo(f" Time: {took:.2f}ms, Records: {records:,}")
322
+
323
+ except Exception as e:
324
+ click.echo(f"Failed to get history: {e}", err=True)
325
+ return 1
326
+
327
+ @openobserve_group.command("test")
328
+ @click.pass_obj
329
+ def test_command(client):
330
+ """Test connection to OpenObserve."""
331
+ if client is None:
332
+ click.echo("OpenObserve not configured.", err=True)
333
+ return 1
334
+
335
+ click.echo(f"Testing connection to {client.url}...")
336
+
337
+ if client.test_connection():
338
+ click.echo("✅ Connection successful!")
339
+ click.echo(f"Organization: {client.organization}")
340
+ click.echo(f"User: {client.username}")
341
+ else:
342
+ click.echo("❌ Connection failed!")
343
+ return 1
344
+
345
+ # Export the command group for auto-discovery
346
+ __all__ = ["openobserve_group"]
347
+
348
+ else:
349
+ # Stub when click is not available
350
+ def openobserve_group(*args, **kwargs):
351
+ """OpenObserve command stub when click is not available."""
352
+ raise ImportError(
353
+ "CLI commands require optional dependencies. "
354
+ "Install with: pip install 'provide-foundation[cli]'"
355
+ )
356
+
357
+ __all__ = []
@@ -0,0 +1,41 @@
1
+ """
2
+ Custom exceptions for OpenObserve integration.
3
+ """
4
+
5
+ from provide.foundation.errors import FoundationError
6
+
7
+
8
+ class OpenObserveError(FoundationError):
9
+ """Base exception for OpenObserve-related errors."""
10
+
11
+ pass
12
+
13
+
14
+ class OpenObserveConnectionError(OpenObserveError):
15
+ """Error connecting to OpenObserve API."""
16
+
17
+ pass
18
+
19
+
20
+ class OpenObserveAuthenticationError(OpenObserveError):
21
+ """Authentication failed with OpenObserve."""
22
+
23
+ pass
24
+
25
+
26
+ class OpenObserveQueryError(OpenObserveError):
27
+ """Error executing query in OpenObserve."""
28
+
29
+ pass
30
+
31
+
32
+ class OpenObserveStreamingError(OpenObserveError):
33
+ """Error during streaming operations."""
34
+
35
+ pass
36
+
37
+
38
+ class OpenObserveConfigError(OpenObserveError):
39
+ """Configuration error for OpenObserve."""
40
+
41
+ pass
@@ -0,0 +1,298 @@
1
+ """
2
+ Output formatting utilities for OpenObserve results.
3
+ """
4
+
5
+ import csv
6
+ from datetime import datetime
7
+ import io
8
+ import json
9
+ from typing import Any
10
+
11
+ from provide.foundation.observability.openobserve.models import SearchResponse
12
+
13
+
14
+ def format_json(response: SearchResponse | dict[str, Any], pretty: bool = True) -> str:
15
+ """Format response as JSON.
16
+
17
+ Args:
18
+ response: Search response or log entry
19
+ pretty: If True, use pretty printing
20
+
21
+ Returns:
22
+ JSON string
23
+ """
24
+ if isinstance(response, SearchResponse):
25
+ data = {
26
+ "hits": response.hits,
27
+ "total": response.total,
28
+ "took": response.took,
29
+ "scan_size": response.scan_size,
30
+ }
31
+ else:
32
+ data = response
33
+
34
+ if pretty:
35
+ return json.dumps(data, indent=2, sort_keys=False)
36
+ else:
37
+ return json.dumps(data)
38
+
39
+
40
+ def format_log_line(entry: dict[str, Any]) -> str:
41
+ """Format a log entry as a traditional log line.
42
+
43
+ Args:
44
+ entry: Log entry dictionary
45
+
46
+ Returns:
47
+ Formatted log line
48
+ """
49
+ # Extract common fields
50
+ timestamp = entry.get("_timestamp", 0)
51
+ level = entry.get("level", "INFO")
52
+ message = entry.get("message", "")
53
+ service = entry.get("service", "")
54
+
55
+ # Convert timestamp to readable format
56
+ if timestamp:
57
+ # Assuming microseconds
58
+ dt = datetime.fromtimestamp(timestamp / 1_000_000)
59
+ time_str = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
60
+ else:
61
+ time_str = "unknown"
62
+
63
+ # Build log line
64
+ parts = [time_str, f"[{level:5s}]"]
65
+
66
+ if service:
67
+ parts.append(f"[{service}]")
68
+
69
+ parts.append(message)
70
+
71
+ # Add additional fields as key=value
72
+ exclude_fields = {"_timestamp", "level", "message", "service", "_p"}
73
+ extra_fields = []
74
+ for key, value in entry.items():
75
+ if key not in exclude_fields:
76
+ extra_fields.append(f"{key}={value}")
77
+
78
+ if extra_fields:
79
+ parts.append(f"({', '.join(extra_fields)})")
80
+
81
+ return " ".join(parts)
82
+
83
+
84
+ def format_table(response: SearchResponse, columns: list[str] | None = None) -> str:
85
+ """Format response as a table.
86
+
87
+ Args:
88
+ response: Search response
89
+ columns: Specific columns to include (None for all)
90
+
91
+ Returns:
92
+ Table string
93
+ """
94
+ if not response.hits:
95
+ return "No results found"
96
+
97
+ # Determine columns
98
+ if columns is None:
99
+ # Get all unique keys from hits
100
+ all_keys = set()
101
+ for hit in response.hits:
102
+ all_keys.update(hit.keys())
103
+ # Sort columns, putting common ones first
104
+ priority_cols = ["_timestamp", "level", "service", "message"]
105
+ columns = []
106
+ for col in priority_cols:
107
+ if col in all_keys:
108
+ columns.append(col)
109
+ all_keys.remove(col)
110
+ columns.extend(sorted(all_keys))
111
+
112
+ # Filter out internal columns if not explicitly requested
113
+ if "_p" in columns and "_p" not in (columns or []):
114
+ columns = [c for c in columns if not c.startswith("_") or c == "_timestamp"]
115
+
116
+ # Try to use tabulate if available
117
+ try:
118
+ from tabulate import tabulate
119
+
120
+ # Prepare data
121
+ headers = columns
122
+ rows = []
123
+ for hit in response.hits:
124
+ row = []
125
+ for col in columns:
126
+ value = hit.get(col, "")
127
+ # Format timestamp
128
+ if col == "_timestamp" and value:
129
+ dt = datetime.fromtimestamp(value / 1_000_000)
130
+ value = dt.strftime("%Y-%m-%d %H:%M:%S")
131
+ # Truncate long values
132
+ value_str = str(value)
133
+ if len(value_str) > 50:
134
+ value_str = value_str[:47] + "..."
135
+ row.append(value_str)
136
+ rows.append(row)
137
+
138
+ return tabulate(rows, headers=headers, tablefmt="grid")
139
+
140
+ except ImportError:
141
+ # Fallback to simple formatting
142
+ lines = []
143
+
144
+ # Header
145
+ lines.append(" | ".join(columns))
146
+ lines.append("-" * (len(columns) * 15))
147
+
148
+ # Rows
149
+ for hit in response.hits:
150
+ row_values = []
151
+ for col in columns:
152
+ value = hit.get(col, "")
153
+ if col == "_timestamp" and value:
154
+ dt = datetime.fromtimestamp(value / 1_000_000)
155
+ value = dt.strftime("%H:%M:%S")
156
+ value_str = str(value)[:12]
157
+ row_values.append(value_str)
158
+ lines.append(" | ".join(row_values))
159
+
160
+ return "\n".join(lines)
161
+
162
+
163
+ def format_csv(response: SearchResponse, columns: list[str] | None = None) -> str:
164
+ """Format response as CSV.
165
+
166
+ Args:
167
+ response: Search response
168
+ columns: Specific columns to include (None for all)
169
+
170
+ Returns:
171
+ CSV string
172
+ """
173
+ if not response.hits:
174
+ return ""
175
+
176
+ # Determine columns
177
+ if columns is None:
178
+ all_keys = set()
179
+ for hit in response.hits:
180
+ all_keys.update(hit.keys())
181
+ columns = sorted(all_keys)
182
+
183
+ # Create CSV
184
+ output = io.StringIO()
185
+ writer = csv.DictWriter(output, fieldnames=columns, extrasaction="ignore")
186
+
187
+ writer.writeheader()
188
+ for hit in response.hits:
189
+ # Format timestamp for readability
190
+ if "_timestamp" in hit:
191
+ hit = hit.copy()
192
+ timestamp = hit["_timestamp"]
193
+ if timestamp:
194
+ dt = datetime.fromtimestamp(timestamp / 1_000_000)
195
+ hit["_timestamp"] = dt.isoformat()
196
+ writer.writerow(hit)
197
+
198
+ return output.getvalue()
199
+
200
+
201
+ def format_summary(response: SearchResponse) -> str:
202
+ """Format a summary of the search response.
203
+
204
+ Args:
205
+ response: Search response
206
+
207
+ Returns:
208
+ Summary string
209
+ """
210
+ lines = [
211
+ f"Total hits: {response.total}",
212
+ f"Returned: {len(response.hits)}",
213
+ f"Query time: {response.took}ms",
214
+ f"Scan size: {response.scan_size:,} bytes",
215
+ ]
216
+
217
+ if response.trace_id:
218
+ lines.append(f"Trace ID: {response.trace_id}")
219
+
220
+ if response.is_partial:
221
+ lines.append("⚠️ Results are partial")
222
+
223
+ if response.function_error:
224
+ lines.append("Errors:")
225
+ for error in response.function_error:
226
+ lines.append(f" - {error}")
227
+
228
+ # Add level distribution if available
229
+ level_counts = {}
230
+ for hit in response.hits:
231
+ level = hit.get("level", "UNKNOWN")
232
+ level_counts[level] = level_counts.get(level, 0) + 1
233
+
234
+ if level_counts:
235
+ lines.append("\nLevel distribution:")
236
+ for level, count in sorted(level_counts.items()):
237
+ lines.append(f" {level}: {count}")
238
+
239
+ return "\n".join(lines)
240
+
241
+
242
+ def format_output(
243
+ response: SearchResponse | dict[str, Any],
244
+ format_type: str = "log",
245
+ **kwargs,
246
+ ) -> str:
247
+ """Format output based on specified type.
248
+
249
+ Args:
250
+ response: Search response or log entry
251
+ format_type: Output format (json, log, table, csv, summary)
252
+ **kwargs: Additional format-specific options
253
+
254
+ Returns:
255
+ Formatted string
256
+ """
257
+ match format_type.lower():
258
+ case "json":
259
+ return format_json(response, **kwargs)
260
+ case "log":
261
+ if isinstance(response, dict):
262
+ return format_log_line(response)
263
+ else:
264
+ return "\n".join(format_log_line(hit) for hit in response.hits)
265
+ case "table":
266
+ if isinstance(response, SearchResponse):
267
+ return format_table(response, **kwargs)
268
+ else:
269
+ # Single entry as table
270
+ single_response = SearchResponse(
271
+ hits=[response],
272
+ total=1,
273
+ took=0,
274
+ scan_size=0,
275
+ )
276
+ return format_table(single_response, **kwargs)
277
+ case "csv":
278
+ if isinstance(response, SearchResponse):
279
+ return format_csv(response, **kwargs)
280
+ else:
281
+ single_response = SearchResponse(
282
+ hits=[response],
283
+ total=1,
284
+ took=0,
285
+ scan_size=0,
286
+ )
287
+ return format_csv(single_response, **kwargs)
288
+ case "summary":
289
+ if isinstance(response, SearchResponse):
290
+ return format_summary(response)
291
+ else:
292
+ return "Single log entry (use 'log' or 'json' format for details)"
293
+ case _:
294
+ # Default to log format
295
+ if isinstance(response, dict):
296
+ return format_log_line(response)
297
+ else:
298
+ return "\n".join(format_log_line(hit) for hit in response.hits)