indexpilot 1.1.0a2__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 (107) hide show
  1. indexpilot/__init__.py +40 -0
  2. indexpilot/__main__.py +6 -0
  3. indexpilot/cli.py +502 -0
  4. indexpilot-1.1.0a2.dist-info/METADATA +389 -0
  5. indexpilot-1.1.0a2.dist-info/RECORD +107 -0
  6. indexpilot-1.1.0a2.dist-info/WHEEL +5 -0
  7. indexpilot-1.1.0a2.dist-info/entry_points.txt +8 -0
  8. indexpilot-1.1.0a2.dist-info/licenses/LICENSE +21 -0
  9. indexpilot-1.1.0a2.dist-info/top_level.txt +2 -0
  10. src/__init__.py +1 -0
  11. src/adapters.py +805 -0
  12. src/adaptive_safeguards.py +369 -0
  13. src/algorithm_tracking.py +131 -0
  14. src/algorithms/__init__.py +85 -0
  15. src/algorithms/alex.py +398 -0
  16. src/algorithms/bx_tree.py +340 -0
  17. src/algorithms/cert.py +157 -0
  18. src/algorithms/constraint_optimizer.py +609 -0
  19. src/algorithms/cortex.py +417 -0
  20. src/algorithms/fractal_tree.py +374 -0
  21. src/algorithms/idistance.py +479 -0
  22. src/algorithms/pgm_index.py +378 -0
  23. src/algorithms/predictive_indexing.py +675 -0
  24. src/algorithms/qpg.py +512 -0
  25. src/algorithms/radix_string_spline.py +480 -0
  26. src/algorithms/xgboost_classifier.py +713 -0
  27. src/api_auth.py +62 -0
  28. src/api_server.py +891 -0
  29. src/approval_workflow.py +396 -0
  30. src/audit.py +357 -0
  31. src/auto_indexer.py +3011 -0
  32. src/before_after_validation.py +207 -0
  33. src/bypass_config.py +61 -0
  34. src/bypass_status.py +171 -0
  35. src/composite_index_detection.py +465 -0
  36. src/concurrent_index_monitoring.py +299 -0
  37. src/config_loader.py +398 -0
  38. src/cpu_throttle.py +413 -0
  39. src/database/__init__.py +31 -0
  40. src/database/adapters/__init__.py +9 -0
  41. src/database/adapters/base.py +115 -0
  42. src/database/adapters/postgresql.py +89 -0
  43. src/database/detector.py +48 -0
  44. src/database/type_detector.py +160 -0
  45. src/db.py +450 -0
  46. src/error_handler.py +271 -0
  47. src/expression.py +188 -0
  48. src/foreign_key_suggestions.py +315 -0
  49. src/genome.py +188 -0
  50. src/graceful_shutdown.py +212 -0
  51. src/health_check.py +251 -0
  52. src/index_cleanup.py +223 -0
  53. src/index_health.py +204 -0
  54. src/index_lifecycle_advanced.py +722 -0
  55. src/index_lifecycle_manager.py +971 -0
  56. src/index_retry.py +244 -0
  57. src/index_type_selection.py +750 -0
  58. src/lock_manager.py +332 -0
  59. src/maintenance.py +1211 -0
  60. src/maintenance_window.py +274 -0
  61. src/materialized_view_support.py +270 -0
  62. src/memory_config.py +234 -0
  63. src/ml_query_interception.py +324 -0
  64. src/monitoring.py +267 -0
  65. src/paths.py +27 -0
  66. src/pattern_detection.py +521 -0
  67. src/per_tenant_config.py +246 -0
  68. src/production_cache.py +421 -0
  69. src/production_config.py +222 -0
  70. src/query_analyzer.py +1041 -0
  71. src/query_executor.py +354 -0
  72. src/query_interceptor.py +1044 -0
  73. src/query_pattern_learning.py +474 -0
  74. src/query_patterns.py +183 -0
  75. src/query_timeout.py +114 -0
  76. src/rate_limiter.py +257 -0
  77. src/redundant_index_detection.py +89 -0
  78. src/resilience.py +510 -0
  79. src/rollback.py +435 -0
  80. src/safeguard_monitoring.py +163 -0
  81. src/scaled_reporting.py +895 -0
  82. src/schema/__init__.py +38 -0
  83. src/schema/auto_discovery.py +404 -0
  84. src/schema/change_detection.py +226 -0
  85. src/schema/discovery.py +129 -0
  86. src/schema/initialization.py +468 -0
  87. src/schema/loader.py +282 -0
  88. src/schema/validator.py +195 -0
  89. src/schema_evolution.py +1463 -0
  90. src/simulation/__init__.py +35 -0
  91. src/simulation/advanced_simulation.py +369 -0
  92. src/simulation/simulation_enhancements.py +197 -0
  93. src/simulation/simulation_verification.py +979 -0
  94. src/simulation/simulator.py +2373 -0
  95. src/simulation/stock_simulator.py +309 -0
  96. src/sql_parser.py +532 -0
  97. src/statistics_refresh.py +425 -0
  98. src/stats.py +355 -0
  99. src/stock_data_loader.py +368 -0
  100. src/stock_genome.py +136 -0
  101. src/storage_budget.py +247 -0
  102. src/structured_logging.py +180 -0
  103. src/type_definitions.py +449 -0
  104. src/validation.py +341 -0
  105. src/workload_analysis.py +847 -0
  106. src/workload_dna.py +2177 -0
  107. src/write_performance.py +228 -0
indexpilot/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Stable public package surface for IndexPilot."""
2
+
3
+ from src.workload_dna import (
4
+ analyze_workload_snapshot,
5
+ build_index_review_report,
6
+ build_index_sprawl_report,
7
+ build_migration_review_report,
8
+ build_workload_dna_report,
9
+ build_workload_readiness_report,
10
+ compare_index_review_reports,
11
+ extract_query_pattern,
12
+ render_index_observation_markdown,
13
+ render_index_sprawl_markdown,
14
+ render_migration_review_markdown,
15
+ render_readiness_markdown,
16
+ render_review_markdown,
17
+ render_review_sarif,
18
+ validate_report_with_hypopg,
19
+ )
20
+
21
+ __version__ = "1.1.0a2"
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "analyze_workload_snapshot",
26
+ "build_index_sprawl_report",
27
+ "build_index_review_report",
28
+ "build_migration_review_report",
29
+ "build_workload_readiness_report",
30
+ "build_workload_dna_report",
31
+ "compare_index_review_reports",
32
+ "extract_query_pattern",
33
+ "render_index_observation_markdown",
34
+ "render_index_sprawl_markdown",
35
+ "render_migration_review_markdown",
36
+ "render_readiness_markdown",
37
+ "render_review_markdown",
38
+ "render_review_sarif",
39
+ "validate_report_with_hypopg",
40
+ ]
indexpilot/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support ``python -m indexpilot`` in source and installed environments."""
2
+
3
+ from indexpilot.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
indexpilot/cli.py ADDED
@@ -0,0 +1,502 @@
1
+ """Installed command-line entry points for IndexPilot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from psycopg2 import Error as PsycopgError
12
+
13
+ from indexpilot import __version__
14
+
15
+ ROOT_HELP = """usage: indexpilot [--version] <command> [options]
16
+
17
+ Read-only PostgreSQL index review against observed workload evidence.
18
+
19
+ commands:
20
+ review Review workload candidates or one proposed CREATE INDEX
21
+ doctor Check whether PostgreSQL can provide useful review evidence
22
+ audit Find possible existing-index overlap without drop advice
23
+ compare Compare before/after exact-index reports for recorded usage
24
+ dna Compatibility alias for the original JSON workload report
25
+ api Run the authenticated, single-operator dashboard API
26
+
27
+ Run 'indexpilot <command> --help' for command-specific options.
28
+ """
29
+
30
+
31
+ def _add_report_arguments(parser: argparse.ArgumentParser) -> None:
32
+ parser.add_argument("--schema", default="public", help="Schema to inspect (default: public)")
33
+ parser.add_argument("--min-calls", type=int, default=100)
34
+ parser.add_argument("--min-table-rows", type=int, default=10_000)
35
+ parser.add_argument("--limit", type=int, default=200)
36
+ parser.add_argument(
37
+ "--hypopg",
38
+ action="store_true",
39
+ help="Use an already-installed HypoPG extension for read-only planner comparison.",
40
+ )
41
+ parser.add_argument("--stdout", action="store_true", help="Also print the report to stdout.")
42
+
43
+
44
+ def _dna_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(
46
+ prog="indexpilot dna",
47
+ description=(
48
+ "Write the compatibility workload-DNA JSON report. The command is read-only and "
49
+ "never applies DDL."
50
+ ),
51
+ )
52
+ _add_report_arguments(parser)
53
+ parser.add_argument("--output", type=Path)
54
+ return parser
55
+
56
+
57
+ def _review_parser() -> argparse.ArgumentParser:
58
+ parser = argparse.ArgumentParser(
59
+ prog="indexpilot review",
60
+ description=(
61
+ "Review PostgreSQL index opportunities against pg_stat_statements. Optionally supply "
62
+ "one simple CREATE INDEX to review its exact B-tree shape. No DDL is executed."
63
+ ),
64
+ )
65
+ _add_report_arguments(parser)
66
+ candidate = parser.add_mutually_exclusive_group()
67
+ candidate.add_argument(
68
+ "--candidate-sql",
69
+ help="One simple, non-unique B-tree CREATE INDEX statement to review.",
70
+ )
71
+ candidate.add_argument(
72
+ "--candidate-file",
73
+ type=Path,
74
+ help="File containing exactly one supported CREATE INDEX statement.",
75
+ )
76
+ candidate.add_argument(
77
+ "--migration-file",
78
+ type=Path,
79
+ help=(
80
+ "PostgreSQL migration containing one or more supported CREATE INDEX statements; "
81
+ "other statements are counted but not reviewed."
82
+ ),
83
+ )
84
+ parser.add_argument(
85
+ "--output",
86
+ type=Path,
87
+ default=Path("indexpilot-review.json"),
88
+ help="JSON report path (default: ./indexpilot-review.json).",
89
+ )
90
+ parser.add_argument(
91
+ "--markdown-output",
92
+ type=Path,
93
+ default=Path("indexpilot-review.md"),
94
+ help="Markdown report path (default: ./indexpilot-review.md).",
95
+ )
96
+ parser.add_argument(
97
+ "--sarif-output",
98
+ type=Path,
99
+ help="Optional SARIF 2.1.0 path for CI and code-scanning integrations.",
100
+ )
101
+ parser.add_argument(
102
+ "--fail-on",
103
+ action="append",
104
+ choices=(
105
+ "worth_benchmarking",
106
+ "existing_overlap",
107
+ "not_supported_by_current_planner_evidence",
108
+ "inconclusive",
109
+ ),
110
+ default=[],
111
+ help="Return exit code 3 when this verdict appears; repeat for multiple verdicts.",
112
+ )
113
+ return parser
114
+
115
+
116
+ def _doctor_parser() -> argparse.ArgumentParser:
117
+ parser = argparse.ArgumentParser(
118
+ prog="indexpilot doctor",
119
+ description=(
120
+ "Check the read-only connection, workload statistics, catalog visibility, and "
121
+ "optional HypoPG planner support."
122
+ ),
123
+ )
124
+ parser.add_argument("--schema", default="public")
125
+ parser.add_argument("--min-calls", type=int, default=100)
126
+ parser.add_argument("--limit", type=int, default=20)
127
+ parser.add_argument("--output", type=Path, default=Path("indexpilot-readiness.json"))
128
+ parser.add_argument("--markdown-output", type=Path, default=Path("indexpilot-readiness.md"))
129
+ parser.add_argument("--stdout", action="store_true")
130
+ return parser
131
+
132
+
133
+ def _audit_parser() -> argparse.ArgumentParser:
134
+ parser = argparse.ArgumentParser(
135
+ prog="indexpilot audit",
136
+ description=(
137
+ "Inspect existing ordinary B-tree shapes and usage counters for possible overlap. "
138
+ "Never generates DROP INDEX advice."
139
+ ),
140
+ )
141
+ parser.add_argument("--schema", default="public")
142
+ parser.add_argument("--output", type=Path, default=Path("indexpilot-index-audit.json"))
143
+ parser.add_argument("--markdown-output", type=Path, default=Path("indexpilot-index-audit.md"))
144
+ parser.add_argument("--stdout", action="store_true")
145
+ return parser
146
+
147
+
148
+ def _compare_parser() -> argparse.ArgumentParser:
149
+ parser = argparse.ArgumentParser(
150
+ prog="indexpilot compare",
151
+ description=(
152
+ "Compare two exact-index JSON reports and show recorded post-deployment usage. "
153
+ "This does not prove a latency improvement or safe removal."
154
+ ),
155
+ )
156
+ parser.add_argument("before", type=Path, help="Exact-index report captured before deployment.")
157
+ parser.add_argument("after", type=Path, help="Exact-index report captured after deployment.")
158
+ parser.add_argument("--output", type=Path, default=Path("indexpilot-index-observation.json"))
159
+ parser.add_argument(
160
+ "--markdown-output",
161
+ type=Path,
162
+ default=Path("indexpilot-index-observation.md"),
163
+ )
164
+ parser.add_argument("--stdout", action="store_true")
165
+ return parser
166
+
167
+
168
+ def _write_text(path: Path, content: str) -> None:
169
+ path.parent.mkdir(parents=True, exist_ok=True)
170
+ path.write_text(content, encoding="utf-8")
171
+
172
+
173
+ def _print_report_summary(report: dict[str, Any]) -> None:
174
+ if report.get("report_type") == "indexpilot_migration_review":
175
+ summary = report.get("summary", {})
176
+ print("IndexPilot migration review complete (advisory only).")
177
+ print(f"Index statements reviewed: {summary.get('reviewed_indexes', 0)}")
178
+ print(f"Verdicts: {summary.get('verdict_counts', {})}")
179
+ print(f"In-migration overlap findings: {summary.get('migration_overlap_findings', 0)}")
180
+ return
181
+
182
+ summary = report["summary"]
183
+ verdict = report.get("verdict", {})
184
+ print("IndexPilot review complete (advisory only).")
185
+ if verdict:
186
+ print(f"Verdict: {verdict['status']}")
187
+ print(f"Reason: {verdict['reason']}")
188
+ print(f"Parser: {report['parser']['backend']}")
189
+ print(f"Workload rows read: {summary['workload_rows_read']}")
190
+ if summary["workload_stats_empty"]:
191
+ print("Warning: the selected pg_stat_statements window is empty.")
192
+ print(f"Candidate mutations: {summary['candidate_mutations']}")
193
+ if report.get("planner_validation", {}).get("requested"):
194
+ print(f"HypoPG validation: {report['planner_validation']['status']}")
195
+ print(f"Planner-validated mutations: {summary['planner_validated_mutations']}")
196
+
197
+
198
+ def _report_verdict_statuses(report: dict[str, Any]) -> set[str]:
199
+ if report.get("report_type") == "indexpilot_migration_review":
200
+ statuses = {
201
+ str(review.get("verdict", {}).get("status", "inconclusive"))
202
+ for review in report.get("reviews", [])
203
+ }
204
+ if report.get("migration_overlap_findings"):
205
+ statuses.add("existing_overlap")
206
+ return statuses
207
+ return {str(report.get("verdict", {}).get("status", "inconclusive"))}
208
+
209
+
210
+ def _report_runtime_error(exc: Exception) -> int:
211
+ print(f"IndexPilot could not complete the review: {exc}", file=sys.stderr)
212
+ return 1
213
+
214
+
215
+ def dna_main(argv: list[str] | None = None) -> int:
216
+ """Run the original workload report as a compatibility command."""
217
+ from src.db import close_connection_pool
218
+ from src.paths import get_report_path
219
+ from src.workload_dna import build_workload_dna_report
220
+
221
+ args = _dna_parser().parse_args(argv)
222
+ output_path = args.output or get_report_path("workload_dna.json")
223
+
224
+ try:
225
+ report = build_workload_dna_report(
226
+ schema=args.schema,
227
+ min_calls=args.min_calls,
228
+ min_table_rows=args.min_table_rows,
229
+ limit=args.limit,
230
+ validate_hypopg=args.hypopg,
231
+ )
232
+ serialized = json.dumps(report, indent=2)
233
+ _write_text(output_path, serialized)
234
+ _print_report_summary(report)
235
+ print(f"JSON report: {output_path.resolve()}")
236
+ if args.stdout:
237
+ print(serialized)
238
+ return 0
239
+ except (OSError, PsycopgError, RuntimeError, ValueError) as exc:
240
+ return _report_runtime_error(exc)
241
+ finally:
242
+ close_connection_pool()
243
+
244
+
245
+ def review_main(argv: list[str] | None = None) -> int:
246
+ """Run the public workload or proposed-index review command."""
247
+ from src.db import close_connection_pool
248
+ from src.sql_parser import ProposedIndexError
249
+ from src.workload_dna import (
250
+ build_index_review_report,
251
+ build_migration_review_report,
252
+ build_workload_dna_report,
253
+ render_review_markdown,
254
+ render_review_sarif,
255
+ )
256
+
257
+ args = _review_parser().parse_args(argv)
258
+ try:
259
+ output_paths = [args.output.resolve(), args.markdown_output.resolve()]
260
+ if args.sarif_output is not None:
261
+ output_paths.append(args.sarif_output.resolve())
262
+ if len(set(output_paths)) != len(output_paths):
263
+ print("JSON, Markdown, and SARIF output paths must be different.", file=sys.stderr)
264
+ return 2
265
+
266
+ candidate_requested = (
267
+ args.candidate_sql is not None
268
+ or args.candidate_file is not None
269
+ or args.migration_file is not None
270
+ )
271
+ candidate_sql = args.candidate_sql
272
+ if args.candidate_file is not None:
273
+ candidate_sql = args.candidate_file.read_text(encoding="utf-8")
274
+
275
+ if args.migration_file is not None:
276
+ report = build_migration_review_report(
277
+ args.migration_file.read_text(encoding="utf-8"),
278
+ default_schema=args.schema,
279
+ min_calls=args.min_calls,
280
+ limit=args.limit,
281
+ validate_hypopg=args.hypopg,
282
+ )
283
+ elif candidate_requested:
284
+ report = build_index_review_report(
285
+ candidate_sql or "",
286
+ default_schema=args.schema,
287
+ min_calls=args.min_calls,
288
+ limit=args.limit,
289
+ validate_hypopg=args.hypopg,
290
+ )
291
+ else:
292
+ report = build_workload_dna_report(
293
+ schema=args.schema,
294
+ min_calls=args.min_calls,
295
+ min_table_rows=args.min_table_rows,
296
+ limit=args.limit,
297
+ validate_hypopg=args.hypopg,
298
+ )
299
+
300
+ serialized = json.dumps(report, indent=2)
301
+ markdown = render_review_markdown(report)
302
+ _write_text(args.output, serialized)
303
+ _write_text(args.markdown_output, markdown)
304
+ if args.sarif_output is not None:
305
+ artifact_uri = (
306
+ args.migration_file.as_posix()
307
+ if args.migration_file is not None
308
+ else args.candidate_file.as_posix()
309
+ if args.candidate_file is not None
310
+ else None
311
+ )
312
+ _write_text(
313
+ args.sarif_output,
314
+ json.dumps(render_review_sarif(report, artifact_uri=artifact_uri), indent=2),
315
+ )
316
+ _print_report_summary(report)
317
+ print(f"JSON report: {args.output.resolve()}")
318
+ print(f"Markdown report: {args.markdown_output.resolve()}")
319
+ if args.sarif_output is not None:
320
+ print(f"SARIF report: {args.sarif_output.resolve()}")
321
+ if args.stdout:
322
+ print(markdown)
323
+ matched_failures = sorted(_report_verdict_statuses(report).intersection(args.fail_on))
324
+ if matched_failures:
325
+ print(
326
+ "Configured verdict gate matched: " + ", ".join(matched_failures),
327
+ file=sys.stderr,
328
+ )
329
+ return 3
330
+ return 0
331
+ except ProposedIndexError as exc:
332
+ print(f"Unsupported candidate index: {exc}", file=sys.stderr)
333
+ return 2
334
+ except (OSError, PsycopgError, RuntimeError, ValueError) as exc:
335
+ return _report_runtime_error(exc)
336
+ finally:
337
+ close_connection_pool()
338
+
339
+
340
+ def doctor_main(argv: list[str] | None = None) -> int:
341
+ """Check whether the configured database is ready for evidence review."""
342
+ from src.db import close_connection_pool
343
+ from src.workload_dna import (
344
+ build_workload_readiness_report,
345
+ render_readiness_markdown,
346
+ )
347
+
348
+ args = _doctor_parser().parse_args(argv)
349
+ try:
350
+ if args.output.resolve() == args.markdown_output.resolve():
351
+ print("JSON and Markdown output paths must be different.", file=sys.stderr)
352
+ return 2
353
+ report = build_workload_readiness_report(
354
+ schema=args.schema,
355
+ min_calls=args.min_calls,
356
+ limit=args.limit,
357
+ )
358
+ markdown = render_readiness_markdown(report)
359
+ _write_text(args.output, json.dumps(report, indent=2))
360
+ _write_text(args.markdown_output, markdown)
361
+ summary = report["summary"]
362
+ print(f"IndexPilot readiness: {summary['status']}")
363
+ print(f"Reason: {summary['reason']}")
364
+ print(f"JSON report: {args.output.resolve()}")
365
+ print(f"Markdown report: {args.markdown_output.resolve()}")
366
+ if args.stdout:
367
+ print(markdown)
368
+ return 0 if summary["status"] != "not_ready" else 1
369
+ except (OSError, PsycopgError, RuntimeError, ValueError) as exc:
370
+ return _report_runtime_error(exc)
371
+ finally:
372
+ close_connection_pool()
373
+
374
+
375
+ def audit_main(argv: list[str] | None = None) -> int:
376
+ """Report possible existing-index overlap without suggesting deletion."""
377
+ from src.db import close_connection_pool
378
+ from src.workload_dna import build_index_sprawl_report, render_index_sprawl_markdown
379
+
380
+ args = _audit_parser().parse_args(argv)
381
+ try:
382
+ if args.output.resolve() == args.markdown_output.resolve():
383
+ print("JSON and Markdown output paths must be different.", file=sys.stderr)
384
+ return 2
385
+ report = build_index_sprawl_report(schema=args.schema)
386
+ markdown = render_index_sprawl_markdown(report)
387
+ _write_text(args.output, json.dumps(report, indent=2))
388
+ _write_text(args.markdown_output, markdown)
389
+ summary = report["summary"]
390
+ print(f"IndexPilot index audit: {summary['status']}")
391
+ print(f"Indexes inspected: {summary['indexes_inspected']}")
392
+ print(f"Possible overlap findings: {summary['overlap_findings']}")
393
+ print(f"JSON report: {args.output.resolve()}")
394
+ print(f"Markdown report: {args.markdown_output.resolve()}")
395
+ if args.stdout:
396
+ print(markdown)
397
+ return 0
398
+ except (OSError, PsycopgError, RuntimeError, ValueError) as exc:
399
+ return _report_runtime_error(exc)
400
+ finally:
401
+ close_connection_pool()
402
+
403
+
404
+ def compare_main(argv: list[str] | None = None) -> int:
405
+ """Compare two previously captured exact-index reports offline."""
406
+ from src.workload_dna import (
407
+ compare_index_review_reports,
408
+ render_index_observation_markdown,
409
+ )
410
+
411
+ args = _compare_parser().parse_args(argv)
412
+ try:
413
+ output_paths = {
414
+ args.before.resolve(),
415
+ args.after.resolve(),
416
+ args.output.resolve(),
417
+ args.markdown_output.resolve(),
418
+ }
419
+ if len(output_paths) != 4:
420
+ print("Input and output report paths must all be different.", file=sys.stderr)
421
+ return 2
422
+ before = json.loads(args.before.read_text(encoding="utf-8"))
423
+ after = json.loads(args.after.read_text(encoding="utf-8"))
424
+ if not isinstance(before, dict) or not isinstance(after, dict):
425
+ raise ValueError("comparison_inputs_must_be_json_objects")
426
+ report = compare_index_review_reports(before, after)
427
+ markdown = render_index_observation_markdown(report)
428
+ _write_text(args.output, json.dumps(report, indent=2))
429
+ _write_text(args.markdown_output, markdown)
430
+ verdict = report["verdict"]
431
+ print(f"IndexPilot post-deployment observation: {verdict['status']}")
432
+ print(f"Reason: {verdict['reason']}")
433
+ print(f"JSON report: {args.output.resolve()}")
434
+ print(f"Markdown report: {args.markdown_output.resolve()}")
435
+ if args.stdout:
436
+ print(markdown)
437
+ return 0
438
+ except (OSError, json.JSONDecodeError, RuntimeError, ValueError) as exc:
439
+ return _report_runtime_error(exc)
440
+
441
+
442
+ def _is_loopback_host(host: str) -> bool:
443
+ return host.strip().lower() in {"127.0.0.1", "::1", "localhost"}
444
+
445
+
446
+ def api_main(argv: list[str] | None = None) -> int:
447
+ parser = argparse.ArgumentParser(description="Run the authenticated IndexPilot API.")
448
+ parser.add_argument("--host", default="127.0.0.1")
449
+ parser.add_argument("--port", type=int, default=8000)
450
+ parser.add_argument("--reload", action="store_true")
451
+ args = parser.parse_args(argv)
452
+
453
+ try:
454
+ import uvicorn
455
+
456
+ from src.api_auth import AUTH_TOKEN_ENV, api_auth_is_configured, get_api_auth_mode
457
+ except ImportError as exc:
458
+ raise SystemExit("Install API support with: pip install 'indexpilot[api]'") from exc
459
+
460
+ if not _is_loopback_host(args.host) and (
461
+ get_api_auth_mode() != "required" or not api_auth_is_configured()
462
+ ):
463
+ parser.error(f"non-loopback hosting requires {AUTH_TOKEN_ENV} and required auth mode")
464
+
465
+ uvicorn.run(
466
+ "src.api_server:app",
467
+ host=args.host,
468
+ port=args.port,
469
+ reload=args.reload,
470
+ log_level="info",
471
+ )
472
+ return 0
473
+
474
+
475
+ def main(argv: list[str] | None = None) -> int:
476
+ arguments = list(sys.argv[1:] if argv is None else argv)
477
+ if not arguments or arguments[0] in {"-h", "--help"}:
478
+ print(ROOT_HELP)
479
+ return 0
480
+ if arguments[0] == "--version":
481
+ print(f"indexpilot {__version__}")
482
+ return 0
483
+
484
+ command, *rest = arguments
485
+ commands = {
486
+ "review": review_main,
487
+ "doctor": doctor_main,
488
+ "audit": audit_main,
489
+ "compare": compare_main,
490
+ "dna": dna_main,
491
+ "api": api_main,
492
+ }
493
+ handler = commands.get(command)
494
+ if handler is None:
495
+ print(f"Unknown command: {command}\n", file=sys.stderr)
496
+ print(ROOT_HELP, file=sys.stderr)
497
+ return 2
498
+ return handler(rest)
499
+
500
+
501
+ if __name__ == "__main__":
502
+ raise SystemExit(main())