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.
- indexpilot/__init__.py +40 -0
- indexpilot/__main__.py +6 -0
- indexpilot/cli.py +502 -0
- indexpilot-1.1.0a2.dist-info/METADATA +389 -0
- indexpilot-1.1.0a2.dist-info/RECORD +107 -0
- indexpilot-1.1.0a2.dist-info/WHEEL +5 -0
- indexpilot-1.1.0a2.dist-info/entry_points.txt +8 -0
- indexpilot-1.1.0a2.dist-info/licenses/LICENSE +21 -0
- indexpilot-1.1.0a2.dist-info/top_level.txt +2 -0
- src/__init__.py +1 -0
- src/adapters.py +805 -0
- src/adaptive_safeguards.py +369 -0
- src/algorithm_tracking.py +131 -0
- src/algorithms/__init__.py +85 -0
- src/algorithms/alex.py +398 -0
- src/algorithms/bx_tree.py +340 -0
- src/algorithms/cert.py +157 -0
- src/algorithms/constraint_optimizer.py +609 -0
- src/algorithms/cortex.py +417 -0
- src/algorithms/fractal_tree.py +374 -0
- src/algorithms/idistance.py +479 -0
- src/algorithms/pgm_index.py +378 -0
- src/algorithms/predictive_indexing.py +675 -0
- src/algorithms/qpg.py +512 -0
- src/algorithms/radix_string_spline.py +480 -0
- src/algorithms/xgboost_classifier.py +713 -0
- src/api_auth.py +62 -0
- src/api_server.py +891 -0
- src/approval_workflow.py +396 -0
- src/audit.py +357 -0
- src/auto_indexer.py +3011 -0
- src/before_after_validation.py +207 -0
- src/bypass_config.py +61 -0
- src/bypass_status.py +171 -0
- src/composite_index_detection.py +465 -0
- src/concurrent_index_monitoring.py +299 -0
- src/config_loader.py +398 -0
- src/cpu_throttle.py +413 -0
- src/database/__init__.py +31 -0
- src/database/adapters/__init__.py +9 -0
- src/database/adapters/base.py +115 -0
- src/database/adapters/postgresql.py +89 -0
- src/database/detector.py +48 -0
- src/database/type_detector.py +160 -0
- src/db.py +450 -0
- src/error_handler.py +271 -0
- src/expression.py +188 -0
- src/foreign_key_suggestions.py +315 -0
- src/genome.py +188 -0
- src/graceful_shutdown.py +212 -0
- src/health_check.py +251 -0
- src/index_cleanup.py +223 -0
- src/index_health.py +204 -0
- src/index_lifecycle_advanced.py +722 -0
- src/index_lifecycle_manager.py +971 -0
- src/index_retry.py +244 -0
- src/index_type_selection.py +750 -0
- src/lock_manager.py +332 -0
- src/maintenance.py +1211 -0
- src/maintenance_window.py +274 -0
- src/materialized_view_support.py +270 -0
- src/memory_config.py +234 -0
- src/ml_query_interception.py +324 -0
- src/monitoring.py +267 -0
- src/paths.py +27 -0
- src/pattern_detection.py +521 -0
- src/per_tenant_config.py +246 -0
- src/production_cache.py +421 -0
- src/production_config.py +222 -0
- src/query_analyzer.py +1041 -0
- src/query_executor.py +354 -0
- src/query_interceptor.py +1044 -0
- src/query_pattern_learning.py +474 -0
- src/query_patterns.py +183 -0
- src/query_timeout.py +114 -0
- src/rate_limiter.py +257 -0
- src/redundant_index_detection.py +89 -0
- src/resilience.py +510 -0
- src/rollback.py +435 -0
- src/safeguard_monitoring.py +163 -0
- src/scaled_reporting.py +895 -0
- src/schema/__init__.py +38 -0
- src/schema/auto_discovery.py +404 -0
- src/schema/change_detection.py +226 -0
- src/schema/discovery.py +129 -0
- src/schema/initialization.py +468 -0
- src/schema/loader.py +282 -0
- src/schema/validator.py +195 -0
- src/schema_evolution.py +1463 -0
- src/simulation/__init__.py +35 -0
- src/simulation/advanced_simulation.py +369 -0
- src/simulation/simulation_enhancements.py +197 -0
- src/simulation/simulation_verification.py +979 -0
- src/simulation/simulator.py +2373 -0
- src/simulation/stock_simulator.py +309 -0
- src/sql_parser.py +532 -0
- src/statistics_refresh.py +425 -0
- src/stats.py +355 -0
- src/stock_data_loader.py +368 -0
- src/stock_genome.py +136 -0
- src/storage_budget.py +247 -0
- src/structured_logging.py +180 -0
- src/type_definitions.py +449 -0
- src/validation.py +341 -0
- src/workload_analysis.py +847 -0
- src/workload_dna.py +2177 -0
- 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
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())
|