loghunter-cli 0.1.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 (122) hide show
  1. loghunter/__init__.py +3 -0
  2. loghunter/cli.py +1108 -0
  3. loghunter/cli_init.py +567 -0
  4. loghunter/common/__init__.py +1 -0
  5. loghunter/common/allowlist.py +436 -0
  6. loghunter/common/clustering.py +326 -0
  7. loghunter/common/config.py +221 -0
  8. loghunter/common/display.py +323 -0
  9. loghunter/common/errors.py +45 -0
  10. loghunter/common/finding.py +239 -0
  11. loghunter/common/loader/__init__.py +136 -0
  12. loghunter/common/loader/diagnostics.py +94 -0
  13. loghunter/common/loader/discovery.py +335 -0
  14. loghunter/common/loader/io.py +76 -0
  15. loghunter/common/loader/pipeline.py +1010 -0
  16. loghunter/common/loader/sniff.py +184 -0
  17. loghunter/common/loader/types.py +207 -0
  18. loghunter/common/loader/windowing.py +523 -0
  19. loghunter/common/output.py +93 -0
  20. loghunter/common/paths.py +105 -0
  21. loghunter/common/sources.py +392 -0
  22. loghunter/data/allowlist/connections.txt +50 -0
  23. loghunter/data/allowlist/domains_devices.txt +5 -0
  24. loghunter/data/allowlist/domains_homelab.txt +5 -0
  25. loghunter/data/allowlist/domains_universal.txt +125 -0
  26. loghunter/data/config_example.toml +144 -0
  27. loghunter/detectors/__init__.py +5 -0
  28. loghunter/detectors/auth.py +27 -0
  29. loghunter/detectors/aws.py +671 -0
  30. loghunter/detectors/beacon.py +258 -0
  31. loghunter/detectors/dns.py +778 -0
  32. loghunter/detectors/dnsblock.py +29 -0
  33. loghunter/detectors/duration.py +178 -0
  34. loghunter/detectors/protocol.py +26 -0
  35. loghunter/detectors/scan.py +735 -0
  36. loghunter/detectors/ssl.py +25 -0
  37. loghunter/detectors/syslog.py +266 -0
  38. loghunter/detectors/weird.py +27 -0
  39. loghunter/digest/__init__.py +43 -0
  40. loghunter/digest/_stats.py +182 -0
  41. loghunter/digest/blob.py +698 -0
  42. loghunter/digest/cloudtrail.py +341 -0
  43. loghunter/digest/conn.py +367 -0
  44. loghunter/digest/dns.py +364 -0
  45. loghunter/digest/syslog.py +269 -0
  46. loghunter/exporters/__init__.py +534 -0
  47. loghunter/exporters/cloudtrail.py +499 -0
  48. loghunter/exporters/splunk.py +222 -0
  49. loghunter/outputs/__init__.py +1 -0
  50. loghunter/outputs/allowlist.py +75 -0
  51. loghunter/outputs/csv.py +70 -0
  52. loghunter/outputs/email.py +44 -0
  53. loghunter/outputs/html.py +99 -0
  54. loghunter/outputs/json.py +77 -0
  55. loghunter/outputs/text.py +1422 -0
  56. loghunter/parsers/__init__.py +1 -0
  57. loghunter/parsers/cloudtrail.py +287 -0
  58. loghunter/parsers/dnsmasq.py +331 -0
  59. loghunter/parsers/syslog.py +150 -0
  60. loghunter/parsers/zeek.py +294 -0
  61. loghunter/parsers/zeek_tsv.py +310 -0
  62. loghunter/runner.py +1895 -0
  63. loghunter_cli-0.1.0.dev0.dist-info/METADATA +336 -0
  64. loghunter_cli-0.1.0.dev0.dist-info/RECORD +122 -0
  65. loghunter_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
  66. loghunter_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  67. loghunter_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  68. loghunter_cli-0.1.0.dev0.dist-info/top_level.txt +4 -0
  69. migrations/cloudtrail_parquet.py +59 -0
  70. migrations/conn_fft.py +550 -0
  71. migrations/conn_scan.py +1097 -0
  72. migrations/dns_dbscan.py +520 -0
  73. migrations/get_syslog.py +402 -0
  74. migrations/syslog_drain3.py +479 -0
  75. scratch/junk/parquet.py +59 -0
  76. tests/__init__.py +1 -0
  77. tests/_cloudtrail_fakes.py +116 -0
  78. tests/conftest.py +17 -0
  79. tests/test_allowlist_defaults_accessor.py +90 -0
  80. tests/test_architecture_spine.py +302 -0
  81. tests/test_aws_detector.py +504 -0
  82. tests/test_be_like_water.py +106 -0
  83. tests/test_cli_help.py +342 -0
  84. tests/test_cli_multi_positional.py +458 -0
  85. tests/test_cloudtrail_exporter.py +631 -0
  86. tests/test_cloudtrail_exporter_botocore.py +207 -0
  87. tests/test_cloudtrail_parser.py +393 -0
  88. tests/test_clustering.py +85 -0
  89. tests/test_clustering_interruptible.py +404 -0
  90. tests/test_config_cli.py +1006 -0
  91. tests/test_config_example_drift.py +164 -0
  92. tests/test_digest_blob.py +1237 -0
  93. tests/test_digest_cli.py +1040 -0
  94. tests/test_digest_cloudtrail.py +980 -0
  95. tests/test_digest_conn.py +1189 -0
  96. tests/test_digest_dns.py +770 -0
  97. tests/test_digest_stats.py +282 -0
  98. tests/test_digest_syslog.py +724 -0
  99. tests/test_display.py +370 -0
  100. tests/test_dns_detector.py +1010 -0
  101. tests/test_dnsmasq_parser.py +467 -0
  102. tests/test_duration_detector.py +491 -0
  103. tests/test_export_orchestrator_shape.py +153 -0
  104. tests/test_init_wizard.py +707 -0
  105. tests/test_loader.py +3639 -0
  106. tests/test_loader_package_surface.py +115 -0
  107. tests/test_loader_window_model.py +215 -0
  108. tests/test_output_path_cascade.py +575 -0
  109. tests/test_resolve_path.py +111 -0
  110. tests/test_root_provenance.py +212 -0
  111. tests/test_runner.py +2599 -0
  112. tests/test_scan_detector.py +455 -0
  113. tests/test_search_paths.py +50 -0
  114. tests/test_sniff_orchestrator.py +373 -0
  115. tests/test_sniff_recognizers.py +573 -0
  116. tests/test_source_resolution_seam.py +471 -0
  117. tests/test_sources.py +648 -0
  118. tests/test_splunk_exporter.py +351 -0
  119. tests/test_syslog_detector.py +458 -0
  120. tests/test_syslog_parser.py +582 -0
  121. tests/test_text_output.py +1225 -0
  122. tests/test_zeek_tsv_parser.py +580 -0
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ get_syslog.py — Pull raw syslog from Splunk and write to a flat text file.
4
+
5
+ This is a data acquisition utility, not part of the analysis pipeline.
6
+ Its output is a plain syslog text file that the analysis tools consume
7
+ directly — no Splunk dependency required downstream.
8
+
9
+ Usage:
10
+ python get_syslog.py # pulls last 1 full day (yesterday)
11
+ python get_syslog.py --days 3
12
+ python get_syslog.py --days 3 --out /tmp/syslog.log
13
+ python get_syslog.py --days 7 --max 200000
14
+
15
+ --days N covers the N most recent complete calendar days in local time.
16
+ --days 1 = yesterday 00:00:00 → 23:59:59 local
17
+ --days 3 = three days ago 00:00:00 → yesterday 23:59:59 local
18
+
19
+ --days M-N (or N-M) covers the inclusive range of complete days between
20
+ M and N days ago. The larger offset is the earlier bound.
21
+ --days 3-5 = five days ago 00:00:00 → three days ago 23:59:59 local (3 days)
22
+ --days 5-3 = same as above (order doesn't matter)
23
+ --days 3-3 = same as --days 3 (single day, three days ago)
24
+
25
+ Environment variables (override defaults):
26
+ SPLUNK_HOST Splunk host IP or hostname (default: 192.0.2.20)
27
+ SPLUNK_PORT Splunk management port (default: 8089)
28
+ SPLUNK_USER Splunk username (default: prompt)
29
+ SPLUNK_PASS Splunk password (default: prompt)
30
+
31
+ Output format:
32
+ One raw syslog line per line, exactly as received from Splunk _raw field.
33
+ RFC 3164 PRI prefix (<N>) is stripped if present.
34
+ Lines are sorted by timestamp ascending.
35
+
36
+ Notes:
37
+ Data is pulled in hourly chunks to stay under Splunk's per-query result
38
+ cap (enforced at the binary level on developer/free licenses). For a 7-day
39
+ pull this means 168 queries; expect 10-15 minutes total runtime.
40
+ """
41
+
42
+ import argparse
43
+ import getpass
44
+ import os
45
+ import re
46
+ import sys
47
+ from collections import Counter
48
+ from datetime import datetime, timedelta, timezone
49
+ from pathlib import Path
50
+
51
+ # ── Dependency check ──────────────────────────────────────────────────────────
52
+ try:
53
+ import splunklib.client as splunk_client
54
+ import splunklib.results as splunk_results
55
+ except ImportError:
56
+ print("ERROR: splunk-sdk not installed. Run: pip install splunk-sdk")
57
+ sys.exit(1)
58
+
59
+ # ── Compiled patterns ─────────────────────────────────────────────────────────
60
+ # RFC 3164 PRI field: <N> or <NN> or <NNN> at start of line
61
+ PRI_RE = re.compile(r'^<\d+>')
62
+
63
+ # ── Fleet configuration ───────────────────────────────────────────────────────
64
+ # Hosts to include in the pull. Edit this list to match your fleet.
65
+ # Workstations and high-noise hosts should be excluded here so the
66
+ # analysis pipeline operates on a clean server-only baseline.
67
+
68
+ INCLUDE_HOSTS = [
69
+ "server1.example.com",
70
+ "server2.example.com",
71
+ "server3.example.com",
72
+ "server4.example.com",
73
+ "server5.example.com",
74
+ # "server6.example.com",
75
+ # "server7.example.com",
76
+ # "server8.example.com",
77
+ "router.example.com",
78
+ ]
79
+
80
+ # ── Splunk connection defaults ────────────────────────────────────────────────
81
+
82
+ SPLUNK_HOST = os.environ.get("SPLUNK_HOST", "192.0.2.20")
83
+ SPLUNK_PORT = int(os.environ.get("SPLUNK_PORT", 8089))
84
+ SPLUNK_USER = os.environ.get("SPLUNK_USER", "")
85
+ SPLUNK_PASS = os.environ.get("SPLUNK_PASS", "")
86
+
87
+ # ── Helpers ───────────────────────────────────────────────────────────────────
88
+
89
+ def get_credentials() -> tuple[str, str]:
90
+ """Resolve credentials from env vars or interactive prompt."""
91
+ user = SPLUNK_USER or input("Splunk username: ").strip()
92
+ passwd = SPLUNK_PASS or getpass.getpass("Splunk password: ")
93
+ return user, passwd
94
+
95
+
96
+ def connect(user: str, passwd: str):
97
+ """Connect to Splunk and return a service handle."""
98
+ print(f"Connecting to {SPLUNK_HOST}:{SPLUNK_PORT} as {user}...")
99
+ service = splunk_client.connect(
100
+ host=SPLUNK_HOST,
101
+ port=SPLUNK_PORT,
102
+ username=user,
103
+ password=passwd,
104
+ )
105
+ print(f"Connected. Splunk version: {service.info['version']}")
106
+ return service
107
+
108
+
109
+ def build_hour_windows(far: int, near: int) -> list[tuple[datetime, datetime]]:
110
+ """Return a list of (chunk_start, chunk_end) pairs in local time.
111
+
112
+ far -- the larger day offset (earlier boundary), e.g. 5 for "5 days ago"
113
+ near -- the smaller day offset (later boundary), e.g. 3 for "3 days ago"
114
+ near=0 is special: upper bound is the top of the last completed hour.
115
+
116
+ Normal upper bound (near >= 1):
117
+ today_midnight - near_days + 1 day (i.e. end of that calendar day)
118
+
119
+ Partial-day upper bound (near == 0):
120
+ now truncated to the hour (never a partial hour chunk)
121
+
122
+ Examples (today = 2026-05-16 14:37 local):
123
+ far=1, near=1 -> 2026-05-15 00:00 -> 2026-05-16 00:00 (yesterday, 24h)
124
+ far=3, near=1 -> 2026-05-13 00:00 -> 2026-05-16 00:00 (3 days, 72h)
125
+ far=5, near=3 -> 2026-05-11 00:00 -> 2026-05-13 00:00 (48h)
126
+ far=0, near=0 -> 2026-05-16 00:00 -> 2026-05-16 14:00 (today so far, 14h)
127
+ far=3, near=0 -> 2026-05-13 00:00 -> 2026-05-16 14:00 (3 days + partial, 86h)
128
+
129
+ Each pair spans exactly one hour. The list is ordered chronologically
130
+ (oldest first) so progress output reads naturally.
131
+ """
132
+ local_now = datetime.now().astimezone()
133
+ today_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
134
+
135
+ window_start = today_midnight - timedelta(days=far)
136
+
137
+ if near == 0:
138
+ # Truncate to the top of the last completed hour.
139
+ window_end = local_now.replace(minute=0, second=0, microsecond=0)
140
+ else:
141
+ window_end = today_midnight - timedelta(days=near) + timedelta(days=1)
142
+
143
+ total_hours = int((window_end - window_start).total_seconds() // 3600)
144
+ windows = []
145
+ for i in range(total_hours):
146
+ h_start = window_start + timedelta(hours=i)
147
+ h_end = window_start + timedelta(hours=i + 1)
148
+ windows.append((h_start, h_end))
149
+
150
+ return windows
151
+
152
+
153
+ def fetch_chunked(service, far: int, near: int, hosts: list[str], max_results: int) -> list[dict]:
154
+ """Pull data in hourly chunks to stay under Splunk per-query row limits.
155
+
156
+ Splunk developer/free licenses enforce a hard per-query result cap at the
157
+ binary level (~50k rows) that limits.conf cannot override. Hourly chunking
158
+ keeps each query well under this ceiling even on high-volume hosts.
159
+
160
+ Time bounds are passed as Unix timestamps so Splunk interprets them
161
+ unambiguously regardless of the server's own timezone setting.
162
+ """
163
+ all_rows = []
164
+ host_filter = " OR ".join(f'host="{h}"' for h in hosts)
165
+
166
+ spl = f"""
167
+ search index=main sourcetype=syslog
168
+ NOT sourcetype="zeek:json"
169
+ NOT (process=dnsmasq)
170
+ NOT (process=unbound)
171
+ ({host_filter})
172
+ | table _time, host, _raw
173
+ """.strip()
174
+
175
+ windows = build_hour_windows(far, near)
176
+ total_hours = len(windows)
177
+
178
+ for i, (chunk_start, chunk_end) in enumerate(windows):
179
+ earliest = str(int(chunk_start.timestamp()))
180
+ latest = str(int(chunk_end.timestamp()))
181
+
182
+ label = chunk_start.strftime("%Y-%m-%d %H:%M %Z")
183
+ print(f" [{i+1:>4}/{total_hours}] {label}", end=" ... ", flush=True)
184
+
185
+ job = service.jobs.oneshot(
186
+ spl,
187
+ count=0,
188
+ output_mode="json",
189
+ earliest_time=earliest,
190
+ latest_time=latest,
191
+ )
192
+ chunk = [r for r in splunk_results.JSONResultsReader(job)
193
+ if isinstance(r, dict)]
194
+ print(f"{len(chunk):,} rows")
195
+ all_rows.extend(chunk)
196
+
197
+ if max_results and len(all_rows) >= max_results:
198
+ print(f" Reached max_results cap ({max_results:,}), stopping.")
199
+ break
200
+
201
+ return all_rows
202
+
203
+
204
+ def make_auto_name(window_start: datetime, window_end: datetime,
205
+ far: int, near: int, partial: bool, total_days: int) -> str:
206
+ """Return the default output filename derived from the time window."""
207
+ start_str = window_start.strftime("%Y%m%d")
208
+ if partial:
209
+ end_str = window_end.strftime("%Y%m%d_%Hh")
210
+ return f"syslog_{start_str}_to_{end_str}.log"
211
+ elif far == near:
212
+ return f"syslog_{start_str}_1d.log"
213
+ elif near == 1:
214
+ return f"syslog_{start_str}_{total_days}d.log"
215
+ else:
216
+ end_str = (window_end - timedelta(days=1)).strftime("%Y%m%d")
217
+ return f"syslog_{start_str}_to_{end_str}.log"
218
+
219
+
220
+ def resolve_outpath(arg_out: Path | None, auto_name: str) -> Path:
221
+ """Resolve the final output path from the --out argument.
222
+
223
+ Rules:
224
+ no --out -> auto_name in CWD
225
+ --out <dir> -> auto_name inside that directory
226
+ --out <new path> -> use as-is (parent dir must exist)
227
+ --out <file> -> prompt to overwrite; exit if declined
228
+ """
229
+ if arg_out is None:
230
+ return Path(auto_name)
231
+
232
+ if arg_out.is_dir():
233
+ return arg_out / auto_name
234
+
235
+ if arg_out.exists():
236
+ answer = input(f"{arg_out} already exists. Overwrite? [y/N] ").strip().lower()
237
+ if answer != "y":
238
+ print("Aborted.")
239
+ sys.exit(0)
240
+ return arg_out
241
+
242
+ # Path doesn't exist — treat as an explicit file path.
243
+ if not arg_out.parent.exists():
244
+ print(f"ERROR: Parent directory does not exist: {arg_out.parent}")
245
+ sys.exit(1)
246
+ return arg_out
247
+
248
+
249
+ def write_output(rows: list[dict], outpath: Path) -> None:
250
+ """Write raw syslog lines to a flat text file, one line per event."""
251
+ rows_sorted = sorted(rows, key=lambda r: r.get("_time", ""))
252
+
253
+ with open(outpath, "w", encoding="utf-8") as f:
254
+ for row in rows_sorted:
255
+ raw = PRI_RE.sub("", row.get("_raw", "").strip())
256
+ if raw:
257
+ f.write(raw + "\n")
258
+
259
+ size_kb = outpath.stat().st_size / 1024
260
+ print(f"\nWritten: {outpath} ({len(rows_sorted):,} lines, {size_kb:.1f} KB)")
261
+
262
+
263
+ def parse_days_arg(value: str) -> tuple[int, int]:
264
+ """Parse the --days argument into a (far, near) pair of day offsets.
265
+
266
+ Accepted forms:
267
+ "N" -> (N, 1) -- N complete days ending at last midnight
268
+ "M-N" -> (max, min) where max/min are the two values; order is ignored
269
+ "N-N" -> (N, N) -- single day N days ago
270
+ "0" -> (0, 0) -- today so far, up to the last completed hour
271
+ "N-0" -> (N, 0) -- N days ago midnight through last completed hour
272
+
273
+ near=0 means the upper bound is the top of the most recently completed
274
+ hour (now truncated to the hour), not a calendar-day boundary.
275
+ far must be >= near; both must be >= 0.
276
+ """
277
+ value = value.strip()
278
+ if "-" in value:
279
+ parts = value.split("-", 1)
280
+ try:
281
+ a, b = int(parts[0]), int(parts[1])
282
+ except ValueError:
283
+ raise argparse.ArgumentTypeError(
284
+ f"Invalid range '{value}': both values must be integers (e.g. '3-5')"
285
+ )
286
+ far, near = max(a, b), min(a, b)
287
+ else:
288
+ try:
289
+ n = int(value)
290
+ except ValueError:
291
+ raise argparse.ArgumentTypeError(
292
+ f"Invalid value '{value}': must be an integer or a range like '3-5'"
293
+ )
294
+ # Single "0" = today so far; single "N" = N trailing complete days.
295
+ far, near = n, (0 if n == 0 else 1)
296
+
297
+ if far < 0 or near < 0:
298
+ raise argparse.ArgumentTypeError(
299
+ "Day offsets must be >= 0 (0 = today so far, up to last completed hour)"
300
+ )
301
+ return far, near
302
+
303
+
304
+ # ── Main ──────────────────────────────────────────────────────────────────────
305
+
306
+ def main():
307
+ parser = argparse.ArgumentParser(
308
+ description="Pull syslog from Splunk and write to a flat text file.",
309
+ formatter_class=argparse.RawDescriptionHelpFormatter,
310
+ epilog=__doc__,
311
+ )
312
+ parser.add_argument(
313
+ "--days", "-d",
314
+ type=parse_days_arg,
315
+ default="1",
316
+ metavar="N or M-N",
317
+ help=(
318
+ "Days to pull. Single number N: the N most recent complete days "
319
+ "(e.g. --days 3 = three days ago through yesterday). "
320
+ "Range M-N: the inclusive span of days M through N days ago "
321
+ "(e.g. --days 3-5 = five days ago through three days ago). "
322
+ "Order doesn't matter; M-M is the single day M days ago. "
323
+ "Default: 1 (yesterday)."
324
+ ),
325
+ )
326
+ parser.add_argument(
327
+ "--out", "-o",
328
+ type=Path,
329
+ default=None,
330
+ help=(
331
+ "Output destination. "
332
+ "If omitted: auto-named file in CWD. "
333
+ "If a directory: auto-named file placed inside it. "
334
+ "If a new path: used as the file path (parent dir must exist). "
335
+ "If an existing file: prompts before overwriting."
336
+ ),
337
+ )
338
+ parser.add_argument(
339
+ "--max",
340
+ type=int,
341
+ default=0,
342
+ help="Maximum number of events to pull (default: 0 = unlimited)",
343
+ )
344
+ parser.add_argument(
345
+ "--hosts",
346
+ nargs="+",
347
+ default=INCLUDE_HOSTS,
348
+ metavar="HOST",
349
+ help="Override the default host list",
350
+ )
351
+ args = parser.parse_args()
352
+
353
+ far, near = args.days # unpacked from parse_days_arg
354
+
355
+ # Compute the actual window boundaries for display and default filename.
356
+ # Mirror the logic in build_hour_windows so the summary is always accurate.
357
+ local_now = datetime.now().astimezone()
358
+ today_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
359
+ window_start = today_midnight - timedelta(days=far)
360
+ if near == 0:
361
+ window_end = local_now.replace(minute=0, second=0, microsecond=0)
362
+ else:
363
+ window_end = today_midnight - timedelta(days=near) + timedelta(days=1)
364
+ total_hours = int((window_end - window_start).total_seconds() // 3600)
365
+ # For display: partial-day windows show hours, whole-day windows show days.
366
+ partial = (near == 0)
367
+ total_days = total_hours // 24
368
+
369
+ auto_name = make_auto_name(window_start, window_end, far, near, partial, total_days)
370
+ outpath = resolve_outpath(args.out, auto_name)
371
+
372
+ span_str = f"{total_hours}h partial" if partial else f"{total_days} day(s)"
373
+ print(f"\nget_syslog.py")
374
+ print(f" Window : {window_start.strftime('%Y-%m-%d %H:%M %Z')} -> {window_end.strftime('%Y-%m-%d %H:%M %Z')} ({span_str})")
375
+ print(f" Chunks : {total_hours} hourly")
376
+ print(f" Hosts : {', '.join(args.hosts)}")
377
+ print(f" Max rows: {'unlimited' if not args.max else f'{args.max:,}'}")
378
+ print(f" Output : {outpath}\n")
379
+
380
+ user, passwd = get_credentials()
381
+ service = connect(user, passwd)
382
+
383
+ print(f"\nFetching {total_hours} hourly chunk(s)...")
384
+ rows = fetch_chunked(service, far, near, args.hosts, args.max)
385
+
386
+ if not rows:
387
+ print("No results returned. Check your Splunk connection and index.")
388
+ sys.exit(1)
389
+
390
+ # Report host breakdown before writing
391
+ host_counts = Counter(r.get("host", "unknown") for r in rows)
392
+ print(f"\nTotal rows: {len(rows):,}")
393
+ print("Host breakdown:")
394
+ for host, count in sorted(host_counts.items(), key=lambda x: -x[1]):
395
+ print(f" {host:<35} {count:>8,}")
396
+
397
+ write_output(rows, outpath)
398
+ print("Done.")
399
+
400
+
401
+ if __name__ == "__main__":
402
+ main()