main
py 689 lines 24.9 KB
Raw
1 #!/usr/bin/env python3
2 """RSS matrix fan-in: per-source artifact emission and deterministic merge.
3
4 This module implements the fan-in mechanism for matrix-based RSS crawling.
5 Each RSS source emits a per-source artifact with shared run context, and
6 the merge step combines them deterministically into the canonical
7 external-news artifact consumed by downstream analysis.
8
9 The default in-process RSS crawl path remains unchanged. This fan-in path
10 activates only when the matrix crawl mode is explicitly enabled.
11
12 Usage (emit per-source artifact):
13 python -m scripts.rss_fan_in emit \
14 --source techcrunch \
15 --articles articles.json \
16 --output artifacts/techcrunch.json \
17 --run-context run-context.json
18
19 Usage (merge per-source artifacts):
20 python -m scripts.rss_fan_in merge \
21 --artifacts-dir artifacts/ \
22 --output data/raw/general/2026-W24-external-news.json \
23 --run-context run-context.json
24
25 References:
26 - Issue #436: Implement RSS matrix fan-in
27 - Issue #356: Matrix Crawl & Map/Reduce PRD
28 - Issue #333: Make canonical artifacts matrix-ready
29 """
30
31 from __future__ import annotations
32
33 import argparse
34 import hashlib
35 import json
36 import sys
37 from datetime import UTC, datetime
38 from pathlib import Path
39 from typing import Any
40
41 from scripts.techcrunch_crawler import (
42 CANONICAL_SCHEMA_VERSION,
43 artifact_checksum,
44 dedupe_articles,
45 iso_timestamp,
46 source_content_checksum,
47 validate_canonical_output,
48 )
49
50 # Per-source artifact schema version (tracks independently of canonical)
51 SOURCE_ARTIFACT_SCHEMA_VERSION = 1
52
53
54 class FanInValidationError(Exception):
55 """Raised when fan-in validation detects an unrecoverable error."""
56
57 pass
58
59
60 class FanInWarning:
61 """Represents a non-fatal fan-in issue that allows merge to proceed."""
62
63 def __init__(self, source_id: str, category: str, message: str) -> None:
64 self.source_id = source_id
65 self.category = category
66 self.message = message
67
68 def to_dict(self) -> dict[str, str]:
69 return {
70 "source_id": self.source_id,
71 "category": self.category,
72 "message": self.message,
73 }
74
75
76 # ---------------------------------------------------------------------------
77 # Run Context
78 # ---------------------------------------------------------------------------
79
80
81 def build_run_context(
82 *,
83 run_id: str,
84 week: str,
85 crawl_window: dict[str, str],
86 source_config_checksum_value: str,
87 schema_checksum_value: str,
88 sources_requested: list[str],
89 required_sources: list[str] | None = None,
90 optional_sources: list[str] | None = None,
91 started_at: str | None = None,
92 crawler_code_sha: str | None = None,
93 ) -> dict[str, Any]:
94 """Build the shared run context distributed to all matrix jobs."""
95 all_requested = sorted(sources_requested)
96 required = sorted(required_sources or all_requested)
97 optional = sorted(optional_sources or [])
98 return {
99 "schema_version": SOURCE_ARTIFACT_SCHEMA_VERSION,
100 "run_id": run_id,
101 "week": week,
102 "crawl_window": crawl_window,
103 "source_config_checksum": source_config_checksum_value,
104 "schema_checksum": schema_checksum_value,
105 "sources_requested": all_requested,
106 "required_sources": required,
107 "optional_sources": optional,
108 "started_at": started_at or iso_timestamp(datetime.now(UTC)),
109 "crawler_code_sha": crawler_code_sha or "",
110 }
111
112
113 def validate_run_context(ctx: dict[str, Any]) -> None:
114 """Validate run context structure."""
115 required_keys = {
116 "schema_version",
117 "run_id",
118 "week",
119 "crawl_window",
120 "source_config_checksum",
121 "schema_checksum",
122 "sources_requested",
123 "required_sources",
124 "started_at",
125 }
126 missing = sorted(required_keys - set(ctx))
127 if missing:
128 raise FanInValidationError(f"Run context missing keys: {missing}")
129 if ctx["schema_version"] != SOURCE_ARTIFACT_SCHEMA_VERSION:
130 raise FanInValidationError(
131 f"Run context schema_version mismatch: expected {SOURCE_ARTIFACT_SCHEMA_VERSION}, "
132 f"got {ctx['schema_version']}"
133 )
134 window = ctx.get("crawl_window")
135 if not isinstance(window, dict) or "since" not in window or "until" not in window:
136 raise FanInValidationError("Run context crawl_window must have 'since' and 'until'")
137
138
139 # ---------------------------------------------------------------------------
140 # Per-Source Artifact
141 # ---------------------------------------------------------------------------
142
143
144 def build_source_artifact(
145 *,
146 source_id: str,
147 articles: list[dict[str, Any]],
148 status: dict[str, Any],
149 run_context: dict[str, Any],
150 crawled_at: datetime | None = None,
151 ) -> dict[str, Any]:
152 """Build a per-source artifact for one RSS source's crawl results.
153
154 Each per-source artifact contains enough context for the fan-in merge
155 to validate provenance, detect staleness, and produce deterministic output.
156 """
157 validate_run_context(run_context)
158 now = crawled_at or datetime.now(UTC)
159
160 # Sort articles deterministically
161 sorted_articles = sorted(
162 articles,
163 key=lambda a: (
164 a.get("published_at", ""),
165 a.get("url", ""),
166 a.get("title", ""),
167 ),
168 reverse=True,
169 )
170
171 content_checksum = source_content_checksum(source_id, sorted_articles)
172 relevant_count = sum(1 for a in sorted_articles if a.get("relevance_score", 0) >= 0.4)
173
174 artifact: dict[str, Any] = {
175 "source_artifact_schema_version": SOURCE_ARTIFACT_SCHEMA_VERSION,
176 "source_id": source_id,
177 "crawled_at": iso_timestamp(now),
178 "run_context": {
179 "run_id": run_context["run_id"],
180 "week": run_context["week"],
181 "crawl_window": run_context["crawl_window"],
182 "source_config_checksum": run_context["source_config_checksum"],
183 "schema_checksum": run_context["schema_checksum"],
184 "started_at": run_context["started_at"],
185 "crawler_code_sha": run_context.get("crawler_code_sha", ""),
186 },
187 "status": status,
188 "metrics": {
189 "total_articles": len(sorted_articles),
190 "relevant_articles": relevant_count,
191 "content_checksum": content_checksum,
192 },
193 "articles": sorted_articles,
194 }
195
196 # Compute artifact-level checksum over deterministic content
197 artifact["artifact_checksum"] = _source_artifact_checksum(artifact)
198 return artifact
199
200
201 def _source_artifact_checksum(artifact: dict[str, Any]) -> str:
202 """Compute a checksum for the per-source artifact content."""
203 payload = {
204 "source_id": artifact["source_id"],
205 "run_context": artifact["run_context"],
206 "articles": artifact["articles"],
207 }
208 serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
209 return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
210
211
212 def validate_source_artifact(artifact: dict[str, Any]) -> None:
213 """Validate a per-source artifact structure."""
214 if not isinstance(artifact, dict):
215 raise FanInValidationError("Source artifact must be a JSON object")
216 if artifact.get("source_artifact_schema_version") != SOURCE_ARTIFACT_SCHEMA_VERSION:
217 raise FanInValidationError(
218 f"Source artifact schema version mismatch: expected {SOURCE_ARTIFACT_SCHEMA_VERSION}, "
219 f"got {artifact.get('source_artifact_schema_version')}"
220 )
221 required = {
222 "source_id",
223 "crawled_at",
224 "run_context",
225 "status",
226 "metrics",
227 "articles",
228 "artifact_checksum",
229 }
230 missing = sorted(required - set(artifact))
231 if missing:
232 raise FanInValidationError(f"Source artifact missing keys: {missing}")
233 # Verify checksum integrity
234 expected = _source_artifact_checksum(artifact)
235 if artifact["artifact_checksum"] != expected:
236 raise FanInValidationError(
237 f"Source artifact checksum mismatch for {artifact.get('source_id', '?')}: "
238 f"expected {expected}, got {artifact['artifact_checksum']}"
239 )
240
241
242 # ---------------------------------------------------------------------------
243 # Fan-In Merge
244 # ---------------------------------------------------------------------------
245
246
247 def validate_fan_in_compatibility(
248 artifacts: list[dict[str, Any]],
249 run_context: dict[str, Any],
250 ) -> list[FanInWarning]:
251 """Validate that all per-source artifacts are compatible for merge.
252
253 Returns warnings for non-fatal issues. Raises FanInValidationError for
254 unrecoverable problems (schema mismatch, window mismatch, etc.).
255 """
256 warnings: list[FanInWarning] = []
257
258 if not artifacts:
259 raise FanInValidationError("No source artifacts provided for fan-in merge")
260
261 validate_run_context(run_context)
262
263 for artifact in artifacts:
264 validate_source_artifact(artifact)
265 ctx = artifact["run_context"]
266 source_id = artifact["source_id"]
267
268 # Schema version must match
269 if ctx.get("schema_checksum") != run_context["schema_checksum"]:
270 raise FanInValidationError(
271 f"Schema checksum mismatch for source '{source_id}': "
272 f"artifact has {ctx.get('schema_checksum')}, "
273 f"run context has {run_context['schema_checksum']}"
274 )
275
276 # Crawl window must match
277 if ctx.get("crawl_window") != run_context["crawl_window"]:
278 raise FanInValidationError(
279 f"Crawl window mismatch for source '{source_id}': "
280 f"artifact has {ctx.get('crawl_window')}, "
281 f"run context has {run_context['crawl_window']}"
282 )
283
284 # Source config checksum must match
285 if ctx.get("source_config_checksum") != run_context["source_config_checksum"]:
286 raise FanInValidationError(
287 f"Source config checksum mismatch for source '{source_id}': "
288 f"artifact has {ctx.get('source_config_checksum')}, "
289 f"run context has {run_context['source_config_checksum']}"
290 )
291
292 # Run ID must match
293 if ctx.get("run_id") != run_context["run_id"]:
294 raise FanInValidationError(
295 f"Run ID mismatch for source '{source_id}': "
296 f"artifact has {ctx.get('run_id')}, "
297 f"run context has {run_context['run_id']}"
298 )
299
300 # Check for required sources
301 provided_sources = {a["source_id"] for a in artifacts}
302 required_sources = set(run_context.get("required_sources", []))
303 missing_required = sorted(required_sources - provided_sources)
304 if missing_required:
305 raise FanInValidationError(f"Missing required source artifacts: {missing_required}")
306
307 # Check for optional missing sources (warning, not error)
308 optional_sources = set(run_context.get("optional_sources", []))
309 missing_optional = sorted(optional_sources - provided_sources)
310 for source_id in missing_optional:
311 warnings.append(
312 FanInWarning(
313 source_id=source_id,
314 category="missing_optional_source",
315 message=f"Optional source '{source_id}' artifact not found",
316 )
317 )
318
319 # Duplicate source check
320 source_ids = [a["source_id"] for a in artifacts]
321 seen: set[str] = set()
322 for sid in source_ids:
323 if sid in seen:
324 raise FanInValidationError(f"Duplicate source artifact for '{sid}'")
325 seen.add(sid)
326
327 return warnings
328
329
330 def merge_source_artifacts(
331 artifacts: list[dict[str, Any]],
332 run_context: dict[str, Any],
333 *,
334 merged_at: datetime | None = None,
335 ) -> tuple[dict[str, Any], list[FanInWarning]]:
336 """Deterministically merge per-source artifacts into the canonical output.
337
338 The merge is deterministic: given the same set of per-source artifacts
339 and run context, it always produces the same canonical output (minus
340 the crawled_at timestamp which is excluded from the checksum).
341
342 Returns (canonical_output, warnings).
343 """
344 warnings = validate_fan_in_compatibility(artifacts, run_context)
345 now = merged_at or datetime.now(UTC)
346
347 # Sort artifacts by source_id for deterministic processing
348 sorted_artifacts = sorted(artifacts, key=lambda a: a["source_id"])
349
350 # Collect all articles from all sources
351 all_articles: list[dict[str, Any]] = []
352 source_statuses: list[dict[str, Any]] = []
353 source_provenance: list[dict[str, Any]] = []
354 errors: list[dict[str, str]] = []
355
356 for artifact in sorted_artifacts:
357 source_id = artifact["source_id"]
358 status = artifact.get("status", {})
359
360 all_articles.extend(artifact.get("articles", []))
361 source_statuses.append(status)
362
363 if not status.get("success", False):
364 warnings.append(
365 FanInWarning(
366 source_id=source_id,
367 category="source_failure",
368 message=f"Source '{source_id}' reported failure: {status.get('error_message', 'unknown')}",
369 )
370 )
371 if status.get("error_class") or status.get("error_message"):
372 errors.append(
373 {
374 "source": source_id,
375 "error_class": status.get("error_class", "Unknown"),
376 "error": status.get("error_message", "unknown error"),
377 }
378 )
379
380 provenance_entry = {
381 "source_id": source_id,
382 "action": "matrix_fan_in",
383 "artifact_checksum": artifact["artifact_checksum"],
384 "content_checksum": artifact["metrics"]["content_checksum"],
385 "original_run_id": run_context["run_id"],
386 "original_crawled_at": artifact["crawled_at"],
387 "evaluated_at": iso_timestamp(now),
388 "date": now.astimezone(UTC).date().isoformat(),
389 "week": run_context["week"],
390 "crawl_window": run_context["crawl_window"],
391 "source_config_checksum": run_context["source_config_checksum"],
392 "schema_checksum": run_context["schema_checksum"],
393 "reasons": [],
394 }
395 source_provenance.append(provenance_entry)
396
397 # Build reuse summary entries for fan-in sources
398 reuse_summary = [
399 {
400 "source": artifact["source_id"],
401 "action": "matrix_fan_in",
402 "reused": False,
403 "refreshed": True,
404 "reasons": [],
405 }
406 for artifact in sorted_artifacts
407 ]
408
409 # Build canonical output using the shared build_output function
410 requested_sources = sorted(run_context.get("sources_requested", []))
411 succeeded = sorted(a["source_id"] for a in sorted_artifacts if a["status"].get("success"))
412 failed = sorted(a["source_id"] for a in sorted_artifacts if not a["status"].get("success"))
413
414 output = build_canonical_merged_output(
415 articles=all_articles,
416 crawled_at=now,
417 run_context=run_context,
418 source_statuses=source_statuses,
419 source_provenance=source_provenance,
420 reuse_summary=reuse_summary,
421 errors=errors,
422 requested_sources=requested_sources,
423 succeeded_sources=succeeded,
424 failed_sources=failed,
425 )
426
427 return output, warnings
428
429
430 def build_canonical_merged_output(
431 *,
432 articles: list[dict[str, Any]],
433 crawled_at: datetime,
434 run_context: dict[str, Any],
435 source_statuses: list[dict[str, Any]],
436 source_provenance: list[dict[str, Any]],
437 reuse_summary: list[dict[str, Any]],
438 errors: list[dict[str, str]],
439 requested_sources: list[str],
440 succeeded_sources: list[str],
441 failed_sources: list[str],
442 ) -> dict[str, Any]:
443 """Build the canonical merged external-news artifact from fan-in results.
444
445 This uses the same schema as the non-matrix path to ensure downstream
446 compatibility.
447 """
448 # Deduplicate articles (same logic as non-matrix path)
449 deduped_articles, dedupe_count = dedupe_articles(articles)
450 relevant = [a for a in deduped_articles if a.get("relevance_score", 0) >= 0.4]
451
452 all_github_links: set[str] = set()
453 for a in deduped_articles:
454 all_github_links.update(a.get("github_links", []))
455
456 output: dict[str, Any] = {
457 "schema_version": CANONICAL_SCHEMA_VERSION,
458 "week": run_context["week"],
459 "source": "external_news",
460 "crawled_at": iso_timestamp(crawled_at),
461 "crawl_window": run_context["crawl_window"],
462 "articles": deduped_articles,
463 "metadata": {
464 "run_id": run_context["run_id"],
465 "source_count": len(requested_sources),
466 "source_config_checksum": run_context["source_config_checksum"],
467 "schema_checksum": run_context["schema_checksum"],
468 "sources_requested": sorted(requested_sources),
469 "sources_succeeded": sorted(succeeded_sources),
470 "sources_failed": sorted(failed_sources),
471 "source_status": sorted(source_statuses, key=lambda s: s.get("source", "")),
472 "source_reuse_summary": sorted(reuse_summary, key=lambda s: s.get("source", "")),
473 "source_artifact_provenance": sorted(
474 source_provenance, key=lambda s: s.get("source_id", "")
475 ),
476 "sources_with_articles": dict(
477 sorted({str(a.get("source", "unknown")): 0 for a in deduped_articles}.items())
478 ),
479 "total_articles": len(deduped_articles),
480 "relevant_articles": len(relevant),
481 "github_links_found": len(all_github_links),
482 "dedupe_count": dedupe_count,
483 "errors": sorted(errors, key=lambda e: e.get("source", "")),
484 "fan_in_mode": "matrix",
485 "crawler_code_sha": run_context.get("crawler_code_sha", ""),
486 },
487 }
488
489 # Compute per-source article counts
490 from collections import Counter
491
492 by_source = Counter(str(a.get("source", "unknown")) for a in deduped_articles)
493 output["metadata"]["sources_with_articles"] = dict(sorted(by_source.items()))
494
495 # Compute and set artifact checksum
496 output["metadata"]["artifact_checksum"] = artifact_checksum(output)
497
498 # Fill in provenance checksums that reference the merged artifact
499 for entry in output["metadata"]["source_artifact_provenance"]:
500 if not entry.get("artifact_checksum"):
501 entry["artifact_checksum"] = output["metadata"]["artifact_checksum"]
502
503 validate_canonical_output(output)
504 return output
505
506
507 # ---------------------------------------------------------------------------
508 # CLI
509 # ---------------------------------------------------------------------------
510
511
512 def cmd_emit(args: argparse.Namespace) -> int:
513 """Emit a per-source artifact from crawl results."""
514 run_context = json.loads(Path(args.run_context).read_text(encoding="utf-8"))
515 validate_run_context(run_context)
516
517 articles = json.loads(Path(args.articles).read_text(encoding="utf-8"))
518 if not isinstance(articles, list):
519 print("ERROR: articles file must contain a JSON array", file=sys.stderr)
520 return 1
521
522 status = (
523 json.loads(Path(args.status).read_text(encoding="utf-8"))
524 if args.status
525 else {
526 "source": args.source,
527 "success": True,
528 }
529 )
530
531 artifact = build_source_artifact(
532 source_id=args.source,
533 articles=articles,
534 status=status,
535 run_context=run_context,
536 )
537
538 out_path = Path(args.output)
539 out_path.parent.mkdir(parents=True, exist_ok=True)
540 with open(out_path, "w", encoding="utf-8") as f:
541 json.dump(artifact, f, indent=2, ensure_ascii=False)
542
543 print(f"Emitted per-source artifact for '{args.source}'{out_path}", file=sys.stderr)
544 return 0
545
546
547 def cmd_merge(args: argparse.Namespace) -> int:
548 """Merge per-source artifacts into canonical output."""
549 run_context = json.loads(Path(args.run_context).read_text(encoding="utf-8"))
550 validate_run_context(run_context)
551
552 artifacts_dir = Path(args.artifacts_dir)
553 if not artifacts_dir.is_dir():
554 print(f"ERROR: artifacts directory not found: {artifacts_dir}", file=sys.stderr)
555 return 1
556
557 # Load all per-source artifact files
558 artifact_files = sorted(artifacts_dir.glob("*.json"))
559 if not artifact_files:
560 print(f"ERROR: no .json artifacts found in {artifacts_dir}", file=sys.stderr)
561 return 1
562
563 artifacts: list[dict[str, Any]] = []
564 for path in artifact_files:
565 try:
566 data = json.loads(path.read_text(encoding="utf-8"))
567 except (json.JSONDecodeError, OSError) as exc:
568 print(f"ERROR: failed to read artifact {path}: {exc}", file=sys.stderr)
569 return 1
570 # Skip non-source-artifact files (e.g., run-context.json in same dir)
571 if not isinstance(data, dict) or "source_artifact_schema_version" not in data:
572 continue
573 artifacts.append(data)
574
575 if not artifacts:
576 print(f"ERROR: no valid source artifacts found in {artifacts_dir}", file=sys.stderr)
577 return 1
578
579 try:
580 output, warnings = merge_source_artifacts(artifacts, run_context)
581 except FanInValidationError as exc:
582 print(f"ERROR: fan-in validation failed: {exc}", file=sys.stderr)
583 return 1
584
585 for w in warnings:
586 print(f"WARNING [{w.category}] {w.source_id}: {w.message}", file=sys.stderr)
587
588 out_path = Path(args.output)
589 out_path.parent.mkdir(parents=True, exist_ok=True)
590 with open(out_path, "w", encoding="utf-8") as f:
591 json.dump(output, f, indent=2, ensure_ascii=False)
592
593 total = output["metadata"]["total_articles"]
594 relevant = output["metadata"]["relevant_articles"]
595 dedupe = output["metadata"]["dedupe_count"]
596 sources = len(artifacts)
597 print(
598 f"Merged {total} articles from {sources} sources "
599 f"({relevant} relevant, {dedupe} deduped) → {out_path}",
600 file=sys.stderr,
601 )
602 return 0
603
604
605 def cmd_validate(args: argparse.Namespace) -> int:
606 """Validate per-source artifacts against run context without merging."""
607 run_context = json.loads(Path(args.run_context).read_text(encoding="utf-8"))
608 validate_run_context(run_context)
609
610 artifacts_dir = Path(args.artifacts_dir)
611 artifact_files = sorted(artifacts_dir.glob("*.json"))
612 artifacts: list[dict[str, Any]] = []
613 for path in artifact_files:
614 try:
615 data = json.loads(path.read_text(encoding="utf-8"))
616 except (json.JSONDecodeError, OSError) as exc:
617 print(f"ERROR: failed to read {path}: {exc}", file=sys.stderr)
618 return 1
619 if isinstance(data, dict) and "source_artifact_schema_version" in data:
620 artifacts.append(data)
621
622 if not artifacts:
623 print(f"ERROR: no valid source artifacts in {artifacts_dir}", file=sys.stderr)
624 return 1
625
626 try:
627 warnings = validate_fan_in_compatibility(artifacts, run_context)
628 except FanInValidationError as exc:
629 print(f"FAIL: {exc}", file=sys.stderr)
630 return 1
631
632 for w in warnings:
633 print(f"WARNING [{w.category}] {w.source_id}: {w.message}", file=sys.stderr)
634
635 print(f"OK: {len(artifacts)} source artifacts validated successfully", file=sys.stderr)
636 return 0
637
638
639 def main(argv: list[str] | None = None) -> int:
640 parser = argparse.ArgumentParser(
641 description="RSS matrix fan-in: per-source artifact emission and deterministic merge"
642 )
643 subparsers = parser.add_subparsers(dest="command")
644
645 # emit subcommand
646 emit_parser = subparsers.add_parser("emit", help="Emit a per-source artifact")
647 emit_parser.add_argument("--source", required=True, help="Source ID (e.g., techcrunch)")
648 emit_parser.add_argument("--articles", required=True, help="Path to articles JSON array")
649 emit_parser.add_argument("--status", default=None, help="Path to source status JSON (optional)")
650 emit_parser.add_argument("--run-context", required=True, help="Path to shared run context JSON")
651 emit_parser.add_argument("--output", required=True, help="Output path for per-source artifact")
652
653 # merge subcommand
654 merge_parser = subparsers.add_parser("merge", help="Merge per-source artifacts")
655 merge_parser.add_argument(
656 "--artifacts-dir", required=True, help="Directory containing per-source artifacts"
657 )
658 merge_parser.add_argument(
659 "--run-context", required=True, help="Path to shared run context JSON"
660 )
661 merge_parser.add_argument(
662 "--output", required=True, help="Output path for merged canonical artifact"
663 )
664
665 # validate subcommand
666 validate_parser = subparsers.add_parser("validate", help="Validate artifacts without merging")
667 validate_parser.add_argument(
668 "--artifacts-dir", required=True, help="Directory containing per-source artifacts"
669 )
670 validate_parser.add_argument(
671 "--run-context", required=True, help="Path to shared run context JSON"
672 )
673
674 args = parser.parse_args(argv)
675 if not args.command:
676 parser.print_help()
677 return 1
678
679 if args.command == "emit":
680 return cmd_emit(args)
681 elif args.command == "merge":
682 return cmd_merge(args)
683 elif args.command == "validate":
684 return cmd_validate(args)
685 return 1
686
687
688 if __name__ == "__main__":
689 sys.exit(main())