qualys-cli 0.1.1__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.
- qualys_cli/__init__.py +1 -0
- qualys_cli/__main__.py +8 -0
- qualys_cli/audit.py +616 -0
- qualys_cli/auth.py +187 -0
- qualys_cli/cache.py +123 -0
- qualys_cli/cli.py +1168 -0
- qualys_cli/client.py +1043 -0
- qualys_cli/commands/__init__.py +0 -0
- qualys_cli/commands/asset.py +183 -0
- qualys_cli/commands/ca.py +403 -0
- qualys_cli/commands/cs.py +410 -0
- qualys_cli/commands/csam.py +752 -0
- qualys_cli/commands/etm.py +170 -0
- qualys_cli/commands/pc.py +255 -0
- qualys_cli/commands/pm.py +412 -0
- qualys_cli/commands/scanauth.py +291 -0
- qualys_cli/commands/sub.py +163 -0
- qualys_cli/commands/tc.py +539 -0
- qualys_cli/commands/user.py +104 -0
- qualys_cli/commands/vm.py +562 -0
- qualys_cli/commands/was.py +1278 -0
- qualys_cli/config.py +331 -0
- qualys_cli/extras.py +702 -0
- qualys_cli/flair.py +202 -0
- qualys_cli/formatters.py +896 -0
- qualys_cli/history.py +133 -0
- qualys_cli/http_server.py +126 -0
- qualys_cli/mcp_server.py +341 -0
- qualys_cli/metrics.py +106 -0
- qualys_cli/platforms.py +67 -0
- qualys_cli/queries.py +137 -0
- qualys_cli-0.1.1.data/data/share/qualys-cli/docs/usage.html +1230 -0
- qualys_cli-0.1.1.dist-info/METADATA +319 -0
- qualys_cli-0.1.1.dist-info/RECORD +37 -0
- qualys_cli-0.1.1.dist-info/WHEEL +4 -0
- qualys_cli-0.1.1.dist-info/entry_points.txt +2 -0
- qualys_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
qualys_cli/formatters.py
ADDED
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Output formatting for qualys-cli.
|
|
3
|
+
|
|
4
|
+
Default (interactive): rich table, first --limit rows + count footer.
|
|
5
|
+
Default (non-interactive / piped): JSON — auto-detected via stdout TTY check.
|
|
6
|
+
|
|
7
|
+
Format flags:
|
|
8
|
+
--format json full JSON to stdout, no decoration
|
|
9
|
+
--format jsonl one flattened JSON object per line (NDJSON)
|
|
10
|
+
--format yaml full YAML
|
|
11
|
+
--format table rich table (forced even when piped)
|
|
12
|
+
--format csv RFC-4180 CSV with header row
|
|
13
|
+
--format tsv tab-separated values with header
|
|
14
|
+
--format markdown GitHub-flavoured Markdown table
|
|
15
|
+
|
|
16
|
+
Composable post-processing flags (work with any format, on every command —
|
|
17
|
+
injected by cli.main() as click options with expose_value=False, so command
|
|
18
|
+
signatures don't need to thread them):
|
|
19
|
+
--where <predicate> Filter rows. Supported predicates:
|
|
20
|
+
field=value case-insensitive equality
|
|
21
|
+
field!=value inequality
|
|
22
|
+
field~regex case-insensitive substring
|
|
23
|
+
field>=N | >N numeric / lexicographic
|
|
24
|
+
field<=N | <N
|
|
25
|
+
Field names match row keys case-insensitively
|
|
26
|
+
(so ``severity`` matches Qualys ``SEVERITY``).
|
|
27
|
+
--columns name,id,status Override the default table columns
|
|
28
|
+
--sort field[:asc|:desc] Sort rows in memory before rendering
|
|
29
|
+
--jq '.path.to.thing' jq-lite expression evaluated on the parsed data
|
|
30
|
+
--limit 0 Remove the default 25-row cap
|
|
31
|
+
|
|
32
|
+
The jq expression accepts:
|
|
33
|
+
``.foo``, ``.foo.bar`` — dotted key access
|
|
34
|
+
``.foo[]`` — flatten a list of dicts
|
|
35
|
+
``.foo[3]`` — list index
|
|
36
|
+
No expression chaining beyond a single ``[]`` segment is supported by design;
|
|
37
|
+
for anything more complex, pipe through ``jq``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import csv
|
|
43
|
+
import io
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import sys
|
|
47
|
+
from collections.abc import Generator, Iterable
|
|
48
|
+
from contextlib import contextmanager
|
|
49
|
+
from contextvars import ContextVar
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
import yaml
|
|
54
|
+
from rich import box
|
|
55
|
+
from rich.console import Console
|
|
56
|
+
from rich.panel import Panel
|
|
57
|
+
from rich.table import Table as _Table
|
|
58
|
+
from rich.text import Text
|
|
59
|
+
|
|
60
|
+
_console = Console()
|
|
61
|
+
_err = Console(stderr=True)
|
|
62
|
+
|
|
63
|
+
DEFAULT_LIMIT = 25
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def apply_output_mode(mode: str) -> None:
|
|
67
|
+
global _console, _err
|
|
68
|
+
if mode == "agentic":
|
|
69
|
+
_console = Console(force_terminal=False, no_color=True)
|
|
70
|
+
_err = Console(stderr=True, force_terminal=False, no_color=True)
|
|
71
|
+
else:
|
|
72
|
+
_console = Console(force_terminal=True)
|
|
73
|
+
_err = Console(stderr=True, force_terminal=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_STATUS_MAP: dict[str, str] = {
|
|
77
|
+
"active": "green", "running": "bright_green", "success": "green",
|
|
78
|
+
"complete": "green", "completed": "green", "enabled": "green",
|
|
79
|
+
"finished": "green", "passed": "green", "valid": "green",
|
|
80
|
+
"error": "red", "failed": "red", "inactive": "red",
|
|
81
|
+
"disabled": "red", "canceled": "red", "cancelled": "red",
|
|
82
|
+
"deleted": "red", "invalid": "red",
|
|
83
|
+
"pending": "yellow", "queued": "yellow", "processing": "yellow",
|
|
84
|
+
"warning": "yellow", "submitted": "yellow", "scheduled": "yellow",
|
|
85
|
+
"paused": "yellow", "timeout": "yellow",
|
|
86
|
+
"new": "cyan", "draft": "cyan", "open": "cyan", "unknown": "dim",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_STATUS_COLS = {"status", "state", "phase", "result", "condition"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Module-level switches set by the CLI on startup
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
REDACT_FIELDS: list[str] = []
|
|
97
|
+
"""Per-profile output redaction list. Walks every dict in the rendered payload
|
|
98
|
+
and replaces matching field values with ``***REDACTED***``."""
|
|
99
|
+
|
|
100
|
+
LOCKED_MODE: bool = False
|
|
101
|
+
"""Set by the CLI root callback when the active profile has ``locked_mode =
|
|
102
|
+
true``. Disables free-form ``--output-file`` writes so a scope-restricted
|
|
103
|
+
profile can't be used to exfiltrate data to an arbitrary filesystem path."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Global post-processing flags (--where / --columns / --sort / --jq) are
|
|
107
|
+
# injected into every Typer command as click Options with expose_value=False.
|
|
108
|
+
# Their callbacks stash values here so output() can pick them up without
|
|
109
|
+
# requiring every command signature to thread the kwargs explicitly.
|
|
110
|
+
_POST_OPTS: ContextVar[dict[str, Any] | None] = ContextVar("post_opts", default=None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def set_post_process_opt(name: str, value: Any) -> None:
|
|
114
|
+
if value is None:
|
|
115
|
+
return
|
|
116
|
+
current = dict(_POST_OPTS.get() or {})
|
|
117
|
+
current[name] = value
|
|
118
|
+
_POST_OPTS.set(current)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _post_opt(name: str) -> Any:
|
|
122
|
+
opts = _POST_OPTS.get()
|
|
123
|
+
return opts.get(name) if opts else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def reset_post_process_opts() -> None:
|
|
127
|
+
"""Used by tests to reset the per-invocation context."""
|
|
128
|
+
_POST_OPTS.set(None)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _redact_payload(data: Any) -> Any:
|
|
132
|
+
if not REDACT_FIELDS:
|
|
133
|
+
return data
|
|
134
|
+
keys = {k.strip().lower() for k in REDACT_FIELDS if k.strip()}
|
|
135
|
+
if not keys:
|
|
136
|
+
return data
|
|
137
|
+
return _walk_redact(data, keys)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _record_to_history(data: Any) -> None:
|
|
141
|
+
"""Persist *data* against the active correlation ID so ``last`` / ``replay``
|
|
142
|
+
can re-render it later. Best-effort: any error is swallowed since the
|
|
143
|
+
user's command has already completed successfully by the time we get here.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
from . import audit as _audit
|
|
147
|
+
from . import history as _history
|
|
148
|
+
if not _audit.CORRELATION_ID or not _audit.PROFILE_NAME:
|
|
149
|
+
return
|
|
150
|
+
_history.record(
|
|
151
|
+
_audit.PROFILE_NAME,
|
|
152
|
+
_audit.CORRELATION_ID,
|
|
153
|
+
list(sys.argv),
|
|
154
|
+
data,
|
|
155
|
+
)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _walk_redact(obj: Any, keys: set[str]) -> Any:
|
|
161
|
+
if isinstance(obj, dict):
|
|
162
|
+
return {
|
|
163
|
+
k: ("***REDACTED***" if isinstance(k, str) and k.lower() in keys
|
|
164
|
+
else _walk_redact(v, keys))
|
|
165
|
+
for k, v in obj.items()
|
|
166
|
+
}
|
|
167
|
+
if isinstance(obj, list):
|
|
168
|
+
return [_walk_redact(x, keys) for x in obj]
|
|
169
|
+
return obj
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# jq-lite query
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
_QUERY_SEGMENT_RE = re.compile(r"^([A-Za-z0-9_-]+)(\[\d*\])?$")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def apply_query(data: Any, expr: str) -> Any:
|
|
180
|
+
"""Evaluate a *very* limited jq-style expression against *data*.
|
|
181
|
+
|
|
182
|
+
Supported tokens (separated by ``.``):
|
|
183
|
+
- ``foo`` — dict key
|
|
184
|
+
- ``foo[]`` — dict key, then iterate the list
|
|
185
|
+
- ``foo[3]`` — dict key, then index 3
|
|
186
|
+
|
|
187
|
+
Wildcards (``*``), pipes, and any other jq syntax are **not** supported.
|
|
188
|
+
Earlier builds accepted ``*`` in the segment regex but treated it as a
|
|
189
|
+
literal dict key, silently returning ``None``. The regex now rejects
|
|
190
|
+
such input outright; callers who need richer expressions should pipe
|
|
191
|
+
through real ``jq``.
|
|
192
|
+
"""
|
|
193
|
+
if not expr:
|
|
194
|
+
return data
|
|
195
|
+
cur: Any = data
|
|
196
|
+
body = expr.lstrip(".")
|
|
197
|
+
if not body:
|
|
198
|
+
return data
|
|
199
|
+
for raw in body.split("."):
|
|
200
|
+
if not raw:
|
|
201
|
+
continue
|
|
202
|
+
m = _QUERY_SEGMENT_RE.match(raw)
|
|
203
|
+
if not m:
|
|
204
|
+
return None
|
|
205
|
+
key, idx = m.group(1), m.group(2)
|
|
206
|
+
if isinstance(cur, dict):
|
|
207
|
+
cur = cur.get(key)
|
|
208
|
+
elif isinstance(cur, list):
|
|
209
|
+
cur = [x.get(key) if isinstance(x, dict) else None for x in cur]
|
|
210
|
+
else:
|
|
211
|
+
return None
|
|
212
|
+
if idx is not None:
|
|
213
|
+
inner = idx[1:-1]
|
|
214
|
+
if inner == "":
|
|
215
|
+
# `[]` flattens one level
|
|
216
|
+
if isinstance(cur, list):
|
|
217
|
+
flat: list[Any] = []
|
|
218
|
+
for x in cur:
|
|
219
|
+
if isinstance(x, list):
|
|
220
|
+
flat.extend(x)
|
|
221
|
+
else:
|
|
222
|
+
flat.append(x)
|
|
223
|
+
cur = flat
|
|
224
|
+
else:
|
|
225
|
+
try:
|
|
226
|
+
n = int(inner)
|
|
227
|
+
except ValueError:
|
|
228
|
+
return None
|
|
229
|
+
if isinstance(cur, list) and -len(cur) <= n < len(cur):
|
|
230
|
+
cur = cur[n]
|
|
231
|
+
else:
|
|
232
|
+
return None
|
|
233
|
+
return cur
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Public entry point
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def output(
|
|
241
|
+
data: Any,
|
|
242
|
+
*,
|
|
243
|
+
format: str = "table",
|
|
244
|
+
limit: int | None = DEFAULT_LIMIT,
|
|
245
|
+
output_file: str | None = None,
|
|
246
|
+
raw: bool = False,
|
|
247
|
+
fields: list[str] | None = None,
|
|
248
|
+
where: str | None = None,
|
|
249
|
+
columns: str | None = None,
|
|
250
|
+
sort: str | None = None,
|
|
251
|
+
query: str | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Render API response data according to user-selected format.
|
|
254
|
+
|
|
255
|
+
Composable post-processing happens in this order:
|
|
256
|
+
1. ``--query`` reshapes the payload (jq-lite path).
|
|
257
|
+
2. PII / customer-field redaction via the profile's ``redact_fields``.
|
|
258
|
+
3. ``--where`` filters rows after extraction.
|
|
259
|
+
4. ``--sort`` sorts rows.
|
|
260
|
+
5. ``--columns`` overrides the default table column set.
|
|
261
|
+
|
|
262
|
+
The fully-processed payload is also persisted to the per-profile history
|
|
263
|
+
cache (``~/.config/qualys-cli/history/<profile>/<correlation>.json``) so
|
|
264
|
+
``qualys-cli last`` and ``qualys-cli replay <id>`` can re-render it
|
|
265
|
+
without hitting the API again.
|
|
266
|
+
"""
|
|
267
|
+
# Pull post-processing flags from the per-invocation contextvar when
|
|
268
|
+
# caller didn't pass them explicitly. The CLI's main() injects --where,
|
|
269
|
+
# --columns, --sort, --jq on every command so users don't need each
|
|
270
|
+
# command signature to thread them.
|
|
271
|
+
if where is None:
|
|
272
|
+
where = _post_opt("where")
|
|
273
|
+
if columns is None:
|
|
274
|
+
columns = _post_opt("columns")
|
|
275
|
+
if sort is None:
|
|
276
|
+
sort = _post_opt("sort")
|
|
277
|
+
if sort is None:
|
|
278
|
+
sort_by = _post_opt("sort_by")
|
|
279
|
+
if sort_by:
|
|
280
|
+
sort = f"{sort_by}:{'desc' if _post_opt('sort_desc') else 'asc'}"
|
|
281
|
+
if query is None:
|
|
282
|
+
query = _post_opt("jq")
|
|
283
|
+
|
|
284
|
+
if query:
|
|
285
|
+
data = apply_query(data, query)
|
|
286
|
+
|
|
287
|
+
data = _redact_payload(data)
|
|
288
|
+
_record_to_history(data)
|
|
289
|
+
|
|
290
|
+
# Export formats default to "all rows" — the 25-row table cap is for
|
|
291
|
+
# interactive previewing. CSV / TSV / Markdown are typically piped to
|
|
292
|
+
# spreadsheets or tickets; truncating silently is a footgun. A user who
|
|
293
|
+
# *did* explicitly pass --limit gets their value through unchanged
|
|
294
|
+
# (DEFAULT_LIMIT means "no override given"; any other value is honoured).
|
|
295
|
+
if format in ("csv", "tsv", "markdown") and limit == DEFAULT_LIMIT:
|
|
296
|
+
limit = 0
|
|
297
|
+
|
|
298
|
+
if raw:
|
|
299
|
+
print(json.dumps(data, default=str) if not isinstance(data, str) else data)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
# Auto-switch table → json on non-TTY
|
|
303
|
+
if format == "table" and not _console.is_terminal:
|
|
304
|
+
format = "json"
|
|
305
|
+
|
|
306
|
+
if output_file:
|
|
307
|
+
guard_output_file()
|
|
308
|
+
_dump_file(data, output_file)
|
|
309
|
+
if _err.is_terminal:
|
|
310
|
+
_err.print(f"[dim]Full data written to[/dim] [bold]{output_file}[/bold]")
|
|
311
|
+
|
|
312
|
+
# Resolve the user's column override into the fields list
|
|
313
|
+
cols_override = (
|
|
314
|
+
[c.strip() for c in columns.split(",") if c.strip()] if columns else None
|
|
315
|
+
)
|
|
316
|
+
fields = fields or cols_override
|
|
317
|
+
|
|
318
|
+
if format == "json":
|
|
319
|
+
print(json.dumps(data, indent=2, default=str))
|
|
320
|
+
elif format == "jsonl":
|
|
321
|
+
_render_jsonl(data, where=where, sort=sort)
|
|
322
|
+
elif format == "yaml":
|
|
323
|
+
_console.print(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
|
324
|
+
elif format in ("csv", "tsv"):
|
|
325
|
+
_render_dsv(data, sep="\t" if format == "tsv" else ",",
|
|
326
|
+
where=where, sort=sort, fields=fields)
|
|
327
|
+
elif format == "markdown":
|
|
328
|
+
_render_markdown(data, where=where, sort=sort, fields=fields, limit=limit)
|
|
329
|
+
else:
|
|
330
|
+
_render_table(data, limit=limit, fields=fields, where=where, sort=sort)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def guard_output_file() -> None:
|
|
334
|
+
"""Raise a clean CLI error if the active profile is locked and the
|
|
335
|
+
caller is about to write to a free-form ``--output-file`` path.
|
|
336
|
+
|
|
337
|
+
Used by commands that stream raw response bytes directly to disk
|
|
338
|
+
(bypassing :func:`output`'s own ``--output-file`` handling), e.g.
|
|
339
|
+
``sub export`` and ``sub user-prefs export``.
|
|
340
|
+
"""
|
|
341
|
+
if not LOCKED_MODE:
|
|
342
|
+
return
|
|
343
|
+
import typer
|
|
344
|
+
output_error(
|
|
345
|
+
"This profile is in locked mode — free-form --output-file writes "
|
|
346
|
+
"are disabled. Redirect stdout instead.",
|
|
347
|
+
title="Locked mode",
|
|
348
|
+
)
|
|
349
|
+
raise typer.Exit(3)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def output_error(message: str, title: str = "Error") -> None:
|
|
353
|
+
if not _err.is_terminal:
|
|
354
|
+
print(json.dumps({"error": message, "type": title.lower().replace(" ", "_")}),
|
|
355
|
+
file=sys.stderr)
|
|
356
|
+
return
|
|
357
|
+
_err.print(
|
|
358
|
+
Panel(
|
|
359
|
+
f"[white]{message}[/white]",
|
|
360
|
+
title=f"[bold red] {title} [/bold red]",
|
|
361
|
+
border_style="red",
|
|
362
|
+
padding=(0, 1),
|
|
363
|
+
expand=False,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def output_success(message: str) -> None:
|
|
369
|
+
if _err.is_terminal:
|
|
370
|
+
_err.print(f" [bold green]✓[/bold green] {message}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def output_warn(message: str) -> None:
|
|
374
|
+
if not _err.is_terminal:
|
|
375
|
+
print(json.dumps({"warning": message}), file=sys.stderr)
|
|
376
|
+
return
|
|
377
|
+
_err.print(f" [bold yellow]⚠[/bold yellow] [yellow]{message}[/yellow]")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def output_info(message: str) -> None:
|
|
381
|
+
if _err.is_terminal:
|
|
382
|
+
_err.print(f" [bold cyan]ℹ[/bold cyan] [dim]{message}[/dim]")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@contextmanager
|
|
386
|
+
def spinner(label: str = "Fetching…") -> Generator[None, None, None]:
|
|
387
|
+
if _err.is_terminal:
|
|
388
|
+
with _err.status(f"[dim]{label}[/dim]", spinner="dots"):
|
|
389
|
+
yield
|
|
390
|
+
else:
|
|
391
|
+
yield
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
# Filter / sort
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
_WHERE_OP_RE = re.compile(r"\s*(>=|<=|!=|>|<|=|~)\s*")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _row_get_ci(row: dict[str, Any], field: str) -> Any:
|
|
402
|
+
"""Lookup *field* in *row* with an exact match first, then case-insensitive
|
|
403
|
+
fallback so users can write ``severity`` and match a column named
|
|
404
|
+
``SEVERITY`` (common in Qualys VM/PC XML responses)."""
|
|
405
|
+
if field in row:
|
|
406
|
+
return row[field]
|
|
407
|
+
lf = field.lower()
|
|
408
|
+
for k, v in row.items():
|
|
409
|
+
if isinstance(k, str) and k.lower() == lf:
|
|
410
|
+
return v
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
_CMP_OPS = {
|
|
415
|
+
">": lambda a, b: a > b,
|
|
416
|
+
">=": lambda a, b: a >= b,
|
|
417
|
+
"<": lambda a, b: a < b,
|
|
418
|
+
"<=": lambda a, b: a <= b,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _compare(left: Any, right: str, op: str) -> bool:
|
|
423
|
+
lstr = "" if left is None else str(left)
|
|
424
|
+
if op == "=":
|
|
425
|
+
return lstr.lower() == right.lower()
|
|
426
|
+
if op == "!=":
|
|
427
|
+
return lstr.lower() != right.lower()
|
|
428
|
+
cmp = _CMP_OPS.get(op)
|
|
429
|
+
if cmp is None:
|
|
430
|
+
return False
|
|
431
|
+
try:
|
|
432
|
+
return bool(cmp(float(left), float(right)))
|
|
433
|
+
except (TypeError, ValueError):
|
|
434
|
+
return bool(cmp(lstr, right))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _apply_where(rows: list[dict[str, Any]], where: str | None) -> list[dict[str, Any]]:
|
|
438
|
+
"""Filter *rows* by a single predicate.
|
|
439
|
+
|
|
440
|
+
Supports:
|
|
441
|
+
``field = value`` (case-insensitive string equality)
|
|
442
|
+
``field != value``
|
|
443
|
+
``field ~ regex`` (case-insensitive substring match)
|
|
444
|
+
``field >= value`` (numeric when both sides parse as numbers,
|
|
445
|
+
else lexicographic)
|
|
446
|
+
``field > value`` (and the obvious ``<``, ``<=`` variants)
|
|
447
|
+
|
|
448
|
+
Field names are matched case-insensitively against row keys so callers
|
|
449
|
+
can write ``severity`` for Qualys XML columns like ``SEVERITY``.
|
|
450
|
+
"""
|
|
451
|
+
if not where:
|
|
452
|
+
return rows
|
|
453
|
+
expr = where.strip()
|
|
454
|
+
m = _WHERE_OP_RE.search(expr)
|
|
455
|
+
if not m or m.start() == 0:
|
|
456
|
+
return rows
|
|
457
|
+
field = expr[:m.start()].strip()
|
|
458
|
+
op = m.group(1)
|
|
459
|
+
value = expr[m.end():].strip()
|
|
460
|
+
if op == "~":
|
|
461
|
+
try:
|
|
462
|
+
rx = re.compile(value, re.IGNORECASE)
|
|
463
|
+
except re.error:
|
|
464
|
+
return rows
|
|
465
|
+
return [r for r in rows if rx.search(str(_row_get_ci(r, field) or ""))]
|
|
466
|
+
return [r for r in rows if _compare(_row_get_ci(r, field), value, op)]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _apply_sort(rows: list[dict[str, Any]], sort: str | None) -> list[dict[str, Any]]:
|
|
470
|
+
if not sort:
|
|
471
|
+
return rows
|
|
472
|
+
spec = sort.strip()
|
|
473
|
+
desc = False
|
|
474
|
+
if ":" in spec:
|
|
475
|
+
spec, _, direction = spec.partition(":")
|
|
476
|
+
desc = direction.strip().lower().startswith("desc")
|
|
477
|
+
field = spec.strip()
|
|
478
|
+
|
|
479
|
+
def key(r: dict[str, Any]) -> tuple[int, Any]:
|
|
480
|
+
v = _row_get_ci(r, field)
|
|
481
|
+
if v is None or v == "":
|
|
482
|
+
return (1, "")
|
|
483
|
+
try:
|
|
484
|
+
return (0, float(v))
|
|
485
|
+
except (TypeError, ValueError):
|
|
486
|
+
return (0, str(v).lower())
|
|
487
|
+
|
|
488
|
+
return sorted(rows, key=key, reverse=desc)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
# Renderers
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
def _render_table(
|
|
496
|
+
data: Any, limit: int | None, fields: list[str] | None,
|
|
497
|
+
where: str | None = None, sort: str | None = None,
|
|
498
|
+
) -> None:
|
|
499
|
+
rows, total = _extract_rows(data)
|
|
500
|
+
rows = _apply_where(rows, where)
|
|
501
|
+
rows = _apply_sort(rows, sort)
|
|
502
|
+
|
|
503
|
+
if not rows:
|
|
504
|
+
if _err.is_terminal:
|
|
505
|
+
_err.print(" [dim]No results.[/dim]")
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
no_cap = limit is None or limit == 0
|
|
509
|
+
display = rows if no_cap else rows[:limit]
|
|
510
|
+
cols = fields or _infer_columns(display)
|
|
511
|
+
|
|
512
|
+
table = _Table(
|
|
513
|
+
box=box.SIMPLE_HEAD,
|
|
514
|
+
header_style="bold",
|
|
515
|
+
border_style="dim",
|
|
516
|
+
show_edge=False,
|
|
517
|
+
padding=(0, 1),
|
|
518
|
+
row_styles=["", "dim"],
|
|
519
|
+
)
|
|
520
|
+
for col in cols:
|
|
521
|
+
table.add_column(col, overflow="fold", max_width=64, no_wrap=False)
|
|
522
|
+
|
|
523
|
+
for row in display:
|
|
524
|
+
table.add_row(*[_colorize(c, str(_get(row, c))) for c in cols])
|
|
525
|
+
|
|
526
|
+
_console.print()
|
|
527
|
+
_console.print(table)
|
|
528
|
+
|
|
529
|
+
if _err.is_terminal:
|
|
530
|
+
shown = len(display)
|
|
531
|
+
if total is not None and total > shown:
|
|
532
|
+
_err.print(
|
|
533
|
+
f" [dim]Showing {shown:,} of {total:,}"
|
|
534
|
+
" · use [bold]--output-file[/bold] to export all[/dim]"
|
|
535
|
+
)
|
|
536
|
+
elif not no_cap and len(rows) > shown:
|
|
537
|
+
_err.print(
|
|
538
|
+
f" [dim]Showing {shown:,} of {len(rows):,}"
|
|
539
|
+
" · use [bold]--limit 0[/bold] or"
|
|
540
|
+
" [bold]--output-file[/bold] to see all[/dim]"
|
|
541
|
+
)
|
|
542
|
+
_console.print()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _render_jsonl(data: Any, where: str | None = None, sort: str | None = None) -> None:
|
|
546
|
+
"""Emit one flattened JSON object per line (NDJSON).
|
|
547
|
+
|
|
548
|
+
Streaming-friendly: we flush after each row so consumers (jq, awk,
|
|
549
|
+
Splunk's json sourcetype, …) can start working before the whole list
|
|
550
|
+
has been written.
|
|
551
|
+
"""
|
|
552
|
+
rows, _ = _extract_rows(data)
|
|
553
|
+
rows = _apply_where(rows, where)
|
|
554
|
+
rows = _apply_sort(rows, sort)
|
|
555
|
+
out = sys.stdout
|
|
556
|
+
for row in rows:
|
|
557
|
+
out.write(json.dumps(row, default=str))
|
|
558
|
+
out.write("\n")
|
|
559
|
+
out.flush()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _render_dsv(
|
|
563
|
+
data: Any, *, sep: str,
|
|
564
|
+
where: str | None, sort: str | None, fields: list[str] | None,
|
|
565
|
+
) -> None:
|
|
566
|
+
rows, _ = _extract_rows(data)
|
|
567
|
+
rows = _apply_where(rows, where)
|
|
568
|
+
rows = _apply_sort(rows, sort)
|
|
569
|
+
if not rows:
|
|
570
|
+
return
|
|
571
|
+
cols = fields or _infer_columns(rows)
|
|
572
|
+
buf = io.StringIO()
|
|
573
|
+
w = csv.writer(buf, delimiter=sep, quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
|
|
574
|
+
w.writerow(cols)
|
|
575
|
+
for r in rows:
|
|
576
|
+
w.writerow([str(r.get(c, "")) for c in cols])
|
|
577
|
+
sys.stdout.write(buf.getvalue())
|
|
578
|
+
sys.stdout.flush()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _render_markdown(
|
|
582
|
+
data: Any, *, where: str | None, sort: str | None,
|
|
583
|
+
fields: list[str] | None, limit: int | None,
|
|
584
|
+
) -> None:
|
|
585
|
+
rows, _ = _extract_rows(data)
|
|
586
|
+
rows = _apply_where(rows, where)
|
|
587
|
+
rows = _apply_sort(rows, sort)
|
|
588
|
+
if not rows:
|
|
589
|
+
return
|
|
590
|
+
no_cap = limit is None or limit == 0
|
|
591
|
+
display = rows if no_cap else rows[:limit]
|
|
592
|
+
cols = fields or _infer_columns(display)
|
|
593
|
+
out = sys.stdout
|
|
594
|
+
|
|
595
|
+
def cell(v: Any) -> str:
|
|
596
|
+
s = str(v).replace("|", "\\|").replace("\n", " ")
|
|
597
|
+
return s
|
|
598
|
+
|
|
599
|
+
out.write("| " + " | ".join(cols) + " |\n")
|
|
600
|
+
out.write("|" + "|".join("---" for _ in cols) + "|\n")
|
|
601
|
+
for r in display:
|
|
602
|
+
out.write("| " + " | ".join(cell(r.get(c, "")) for c in cols) + " |\n")
|
|
603
|
+
out.flush()
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _colorize(col: str, val: str) -> Text:
|
|
607
|
+
text = Text(val)
|
|
608
|
+
if any(kw in col.lower() for kw in _STATUS_COLS):
|
|
609
|
+
color = _STATUS_MAP.get(val.lower().strip())
|
|
610
|
+
if color:
|
|
611
|
+
text.stylize(f"bold {color}" if color != "dim" else color)
|
|
612
|
+
return text
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
# Streaming JSONL helper (for `qualys-cli batch` and bulk pipelines)
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
def stream_jsonl(rows: Iterable[dict[str, Any]]) -> None:
|
|
620
|
+
"""Stream an iterator of dicts as NDJSON to stdout."""
|
|
621
|
+
out = sys.stdout
|
|
622
|
+
for row in rows:
|
|
623
|
+
out.write(json.dumps(row, default=str))
|
|
624
|
+
out.write("\n")
|
|
625
|
+
out.flush()
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
# ---------------------------------------------------------------------------
|
|
629
|
+
# Data extraction
|
|
630
|
+
# ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
def _parse_int(v: Any) -> int | None:
|
|
633
|
+
if v is None:
|
|
634
|
+
return None
|
|
635
|
+
try:
|
|
636
|
+
return int(v)
|
|
637
|
+
except (TypeError, ValueError):
|
|
638
|
+
return None
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _extract_rows(data: Any) -> tuple[list[dict[str, Any]], int | None]:
|
|
642
|
+
if isinstance(data, list):
|
|
643
|
+
return [_flatten(r) for r in data], None
|
|
644
|
+
|
|
645
|
+
if not isinstance(data, dict):
|
|
646
|
+
return [], None
|
|
647
|
+
|
|
648
|
+
for list_key in ("data", "items", "content", "list", "results"):
|
|
649
|
+
if isinstance(data.get(list_key), list):
|
|
650
|
+
total = _parse_int(
|
|
651
|
+
data.get("total") or data.get("totalCount") or data.get("count")
|
|
652
|
+
)
|
|
653
|
+
return [_flatten(r) for r in data[list_key]], total
|
|
654
|
+
|
|
655
|
+
_SR_META = frozenset({
|
|
656
|
+
"responseCode", "count", "hasMoreRecords", "lastId",
|
|
657
|
+
"@xmlns:xsi", "@xsi:noNamespaceSchemaLocation",
|
|
658
|
+
})
|
|
659
|
+
sr = data.get("ServiceResponse")
|
|
660
|
+
if isinstance(sr, dict):
|
|
661
|
+
total = _parse_int(sr.get("count"))
|
|
662
|
+
sr_data = sr.get("data")
|
|
663
|
+
if isinstance(sr_data, dict):
|
|
664
|
+
for v in sr_data.values():
|
|
665
|
+
if isinstance(v, list):
|
|
666
|
+
return [_flatten(r) for r in v], total
|
|
667
|
+
if isinstance(v, dict):
|
|
668
|
+
return [_flatten(v)], total
|
|
669
|
+
elif isinstance(sr_data, list):
|
|
670
|
+
return [_flatten(r) for r in sr_data], total
|
|
671
|
+
for k, v in sr.items():
|
|
672
|
+
if k in _SR_META or k == "data":
|
|
673
|
+
continue
|
|
674
|
+
if isinstance(v, list):
|
|
675
|
+
return [_flatten(r) for r in v], total
|
|
676
|
+
if isinstance(v, dict):
|
|
677
|
+
for v2 in v.values():
|
|
678
|
+
if isinstance(v2, list):
|
|
679
|
+
return [_flatten(r) for r in v2], total
|
|
680
|
+
return [_flatten(v)], total
|
|
681
|
+
# Fall-through: SR has no rows container and no non-meta fields. This
|
|
682
|
+
# is the shape of the */count/* endpoints (e.g. `ca agent count` or
|
|
683
|
+
# `was webapp count`), whose entire payload is metadata. Surface the
|
|
684
|
+
# `count` as a synthetic single-row so the table renderer shows the
|
|
685
|
+
# value instead of an empty grid. Without this, `output()` returns
|
|
686
|
+
# `[{}]` — truthy enough to skip the "No results" branch but with no
|
|
687
|
+
# columns to render, leaving the user with a blank screen after the
|
|
688
|
+
# spinner clears.
|
|
689
|
+
remaining = {k: v for k, v in sr.items() if k not in _SR_META and k != "data"}
|
|
690
|
+
if not remaining and total is not None:
|
|
691
|
+
return [{"count": total}], total
|
|
692
|
+
return [_flatten(remaining)], None
|
|
693
|
+
|
|
694
|
+
peeled = _peel_vm_xml_envelope(data)
|
|
695
|
+
if peeled is not None:
|
|
696
|
+
return peeled
|
|
697
|
+
|
|
698
|
+
found = _find_list_key(data)
|
|
699
|
+
if found is not None:
|
|
700
|
+
return [_flatten(r) for r in found], None
|
|
701
|
+
|
|
702
|
+
deep = _find_dict_list(data)
|
|
703
|
+
if deep is not None:
|
|
704
|
+
total = _parse_int(
|
|
705
|
+
data.get("count") or data.get("total") or data.get("totalCount")
|
|
706
|
+
)
|
|
707
|
+
return [_flatten(r) for r in deep], total
|
|
708
|
+
|
|
709
|
+
return [_flatten(data)], None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# Metadata fields on a Qualys <RESPONSE> body that never carry row data and
|
|
713
|
+
# should be skipped when locating the rows container.
|
|
714
|
+
_VM_RESPONSE_META = frozenset({"DATETIME", "STATUS", "COUNT", "USER_LOGIN"})
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _peel_vm_xml_envelope(
|
|
718
|
+
data: dict[str, Any],
|
|
719
|
+
) -> tuple[list[dict[str, Any]], int | None] | None:
|
|
720
|
+
"""Extract rows from a Qualys VM/PC XML envelope.
|
|
721
|
+
|
|
722
|
+
Shape: ``{<X>_OUTPUT: {RESPONSE: {DATETIME, STATUS, COUNT, <rows>}}}``
|
|
723
|
+
(or a bare top-level ``{RESPONSE: {...}}``).
|
|
724
|
+
|
|
725
|
+
The rows container is not always ``*_LIST`` — many endpoints wrap rows
|
|
726
|
+
under a plural noun (e.g. ``AUTH_VAULTS > AUTH_VAULT``). xmltodict also
|
|
727
|
+
collapses an empty list into ``None``, which previously fell through to
|
|
728
|
+
the catch-all and rendered the entire envelope as one stringified cell.
|
|
729
|
+
Returns ``None`` when *data* is not a recognisable envelope.
|
|
730
|
+
"""
|
|
731
|
+
response: dict[str, Any] | None = None
|
|
732
|
+
if len(data) == 1:
|
|
733
|
+
only_key = next(iter(data))
|
|
734
|
+
only_val = data[only_key]
|
|
735
|
+
if isinstance(only_val, dict):
|
|
736
|
+
inner = only_val.get("RESPONSE")
|
|
737
|
+
if isinstance(inner, dict) and (
|
|
738
|
+
only_key.endswith("_OUTPUT") or only_key == "SIMPLE_RETURN"
|
|
739
|
+
or only_key == "BATCH_RETURN"
|
|
740
|
+
):
|
|
741
|
+
response = inner
|
|
742
|
+
if response is None and isinstance(data.get("RESPONSE"), dict) and len(data) == 1:
|
|
743
|
+
response = data["RESPONSE"]
|
|
744
|
+
if response is None:
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
count = _parse_int(response.get("COUNT"))
|
|
748
|
+
|
|
749
|
+
for k, v in response.items():
|
|
750
|
+
if k in _VM_RESPONSE_META:
|
|
751
|
+
continue
|
|
752
|
+
if isinstance(v, list):
|
|
753
|
+
return [_flatten(r) for r in v], count
|
|
754
|
+
if isinstance(v, dict):
|
|
755
|
+
for inner_v in v.values():
|
|
756
|
+
if isinstance(inner_v, list):
|
|
757
|
+
return [_flatten(r) for r in inner_v], count
|
|
758
|
+
if isinstance(inner_v, dict):
|
|
759
|
+
return [_flatten(inner_v)], count
|
|
760
|
+
# Empty wrapper dict — treat as no rows.
|
|
761
|
+
|
|
762
|
+
# No rows container located. Either an empty list (COUNT=0 / all-None
|
|
763
|
+
# children) or an action confirmation like SIMPLE_RETURN with just
|
|
764
|
+
# CODE/TEXT — surface those as a single flat row, omitting the
|
|
765
|
+
# DATETIME/STATUS/COUNT/USER_LOGIN metadata so the table doesn't get
|
|
766
|
+
# cluttered with envelope bookkeeping.
|
|
767
|
+
non_meta = {k: v for k, v in response.items() if k not in _VM_RESPONSE_META}
|
|
768
|
+
if count == 0 or not non_meta or all(v is None for v in non_meta.values()):
|
|
769
|
+
return [], count if count is not None else 0
|
|
770
|
+
return [_flatten(non_meta)], count
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _find_dict_list(data: dict[str, Any], depth: int = 0) -> list[Any] | None:
|
|
774
|
+
if depth > 4:
|
|
775
|
+
return None
|
|
776
|
+
for v in data.values():
|
|
777
|
+
if isinstance(v, list) and v and isinstance(v[0], dict):
|
|
778
|
+
return v
|
|
779
|
+
if isinstance(v, dict):
|
|
780
|
+
found = _find_dict_list(v, depth + 1)
|
|
781
|
+
if found is not None:
|
|
782
|
+
return found
|
|
783
|
+
return None
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _find_list_key(data: dict[str, Any], depth: int = 0) -> list[Any] | None:
|
|
787
|
+
if depth > 5:
|
|
788
|
+
return None
|
|
789
|
+
for k, v in data.items():
|
|
790
|
+
if k.endswith("_LIST"):
|
|
791
|
+
if isinstance(v, list):
|
|
792
|
+
return v
|
|
793
|
+
if isinstance(v, dict):
|
|
794
|
+
for child_v in v.values():
|
|
795
|
+
if isinstance(child_v, list):
|
|
796
|
+
return child_v
|
|
797
|
+
if isinstance(child_v, dict):
|
|
798
|
+
return [child_v]
|
|
799
|
+
return [v]
|
|
800
|
+
# xmltodict collapses ``<X_LIST/>`` to None — that's an empty
|
|
801
|
+
# list, not "no _LIST wrapper found". Returning [] here prevents
|
|
802
|
+
# the catch-all from stringifying the surrounding envelope.
|
|
803
|
+
if v is None:
|
|
804
|
+
return []
|
|
805
|
+
if isinstance(v, dict):
|
|
806
|
+
result = _find_list_key(v, depth + 1)
|
|
807
|
+
if result is not None:
|
|
808
|
+
return result
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _flatten(obj: Any, prefix: str = "", depth: int = 0) -> dict[str, str]:
|
|
813
|
+
if not isinstance(obj, dict):
|
|
814
|
+
return {"value": str(obj)}
|
|
815
|
+
result: dict[str, str] = {}
|
|
816
|
+
for k, v in obj.items():
|
|
817
|
+
key = f"{prefix}{k}" if prefix else k
|
|
818
|
+
if isinstance(v, dict) and depth < 1:
|
|
819
|
+
result.update(_flatten(v, prefix=f"{key}.", depth=depth + 1))
|
|
820
|
+
elif isinstance(v, list):
|
|
821
|
+
if v and isinstance(v[0], dict):
|
|
822
|
+
result[key] = f"[{len(v)} item{'s' if len(v) != 1 else ''}]"
|
|
823
|
+
else:
|
|
824
|
+
result[key] = ", ".join(str(i) for i in v[:5])
|
|
825
|
+
else:
|
|
826
|
+
result[key] = str(v) if v is not None else ""
|
|
827
|
+
return result
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _infer_columns(rows: list[dict[str, Any]]) -> list[str]:
|
|
831
|
+
if not rows:
|
|
832
|
+
return []
|
|
833
|
+
sample = rows[0]
|
|
834
|
+
all_keys = list(sample.keys())
|
|
835
|
+
priority = [
|
|
836
|
+
"id", "ID", "name", "NAME", "title", "TITLE", "ref", "REF",
|
|
837
|
+
"status", "STATUS", "type", "TYPE", "ip", "IP",
|
|
838
|
+
"action", "ACTION", "datetime", "DATETIME",
|
|
839
|
+
]
|
|
840
|
+
|
|
841
|
+
def looks_complex(key: str) -> bool:
|
|
842
|
+
for r in rows[:5]:
|
|
843
|
+
v = r.get(key)
|
|
844
|
+
if v is None or v == "":
|
|
845
|
+
continue
|
|
846
|
+
s = str(v).strip()
|
|
847
|
+
if s.startswith(("{", "[")) or s.startswith("[0 item") or s.endswith("items]"):
|
|
848
|
+
return True
|
|
849
|
+
return False
|
|
850
|
+
|
|
851
|
+
cols: list[str] = []
|
|
852
|
+
for p in priority:
|
|
853
|
+
if p in all_keys and p not in cols:
|
|
854
|
+
cols.append(p)
|
|
855
|
+
for k in all_keys:
|
|
856
|
+
if k not in cols and not looks_complex(k):
|
|
857
|
+
cols.append(k)
|
|
858
|
+
for k in all_keys:
|
|
859
|
+
if k not in cols:
|
|
860
|
+
cols.append(k)
|
|
861
|
+
return cols[:8]
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _get(row: dict[str, Any], col: str) -> Any:
|
|
865
|
+
return row.get(col, "")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# ---------------------------------------------------------------------------
|
|
869
|
+
# File dump
|
|
870
|
+
# ---------------------------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
def _dump_file(data: Any, path: str) -> None:
|
|
873
|
+
p = Path(path)
|
|
874
|
+
suffix = p.suffix.lower()
|
|
875
|
+
if suffix in (".yaml", ".yml"):
|
|
876
|
+
p.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
|
877
|
+
elif suffix == ".csv":
|
|
878
|
+
rows, _ = _extract_rows(data)
|
|
879
|
+
if rows:
|
|
880
|
+
cols = _infer_columns(rows)
|
|
881
|
+
buf = io.StringIO()
|
|
882
|
+
w = csv.writer(buf, lineterminator="\n")
|
|
883
|
+
w.writerow(cols)
|
|
884
|
+
for r in rows:
|
|
885
|
+
w.writerow([str(r.get(c, "")) for c in cols])
|
|
886
|
+
p.write_text(buf.getvalue())
|
|
887
|
+
else:
|
|
888
|
+
p.write_text("")
|
|
889
|
+
elif suffix in (".jsonl", ".ndjson"):
|
|
890
|
+
rows, _ = _extract_rows(data)
|
|
891
|
+
with p.open("w") as fh:
|
|
892
|
+
for r in rows:
|
|
893
|
+
fh.write(json.dumps(r, default=str))
|
|
894
|
+
fh.write("\n")
|
|
895
|
+
else:
|
|
896
|
+
p.write_text(json.dumps(data, indent=2, default=str))
|