querypilot 0.1.0__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 (54) hide show
  1. querypilot/__init__.py +36 -0
  2. querypilot/access/__init__.py +3 -0
  3. querypilot/access/policy.py +32 -0
  4. querypilot/adapters/__init__.py +1 -0
  5. querypilot/adapters/anthropic.py +49 -0
  6. querypilot/adapters/openai.py +53 -0
  7. querypilot/audit/__init__.py +10 -0
  8. querypilot/audit/sinks.py +46 -0
  9. querypilot/audit/types.py +53 -0
  10. querypilot/cli.py +401 -0
  11. querypilot/connectors/__init__.py +5 -0
  12. querypilot/connectors/base.py +24 -0
  13. querypilot/connectors/postgres.py +48 -0
  14. querypilot/connectors/sqlite.py +49 -0
  15. querypilot/core/__init__.py +1 -0
  16. querypilot/core/client.py +286 -0
  17. querypilot/core/config.py +29 -0
  18. querypilot/core/safety_defaults.py +12 -0
  19. querypilot/core/types.py +89 -0
  20. querypilot/evals/__init__.py +81 -0
  21. querypilot/evals/check.py +222 -0
  22. querypilot/evals/compare.py +286 -0
  23. querypilot/evals/cost.py +202 -0
  24. querypilot/evals/factory.py +83 -0
  25. querypilot/evals/init.py +104 -0
  26. querypilot/evals/loader.py +179 -0
  27. querypilot/evals/pipeline.py +484 -0
  28. querypilot/evals/replay.py +217 -0
  29. querypilot/evals/report.py +246 -0
  30. querypilot/evals/suite.py +91 -0
  31. querypilot/evals/suite_runner.py +361 -0
  32. querypilot/execution/__init__.py +1 -0
  33. querypilot/execution/formatter.py +11 -0
  34. querypilot/execution/runner.py +13 -0
  35. querypilot/generation/__init__.py +9 -0
  36. querypilot/generation/llm.py +180 -0
  37. querypilot/generation/prompt_builder.py +49 -0
  38. querypilot/generation/sql_generator.py +82 -0
  39. querypilot/mcp/__init__.py +3 -0
  40. querypilot/mcp/server.py +51 -0
  41. querypilot/py.typed +0 -0
  42. querypilot/schema/__init__.py +3 -0
  43. querypilot/schema/introspector.py +8 -0
  44. querypilot/schema/search.py +33 -0
  45. querypilot/server/__init__.py +3 -0
  46. querypilot/server/app.py +157 -0
  47. querypilot/validation/__init__.py +3 -0
  48. querypilot/validation/policies.py +3 -0
  49. querypilot/validation/validator.py +592 -0
  50. querypilot-0.1.0.dist-info/METADATA +601 -0
  51. querypilot-0.1.0.dist-info/RECORD +54 -0
  52. querypilot-0.1.0.dist-info/WHEEL +4 -0
  53. querypilot-0.1.0.dist-info/entry_points.txt +2 -0
  54. querypilot-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Callable
5
+
6
+ from querypilot import QueryPilot
7
+ from querypilot.evals.cost import (
8
+ AnthropicCostTracker,
9
+ CostTracker,
10
+ NullCostTracker,
11
+ OpenAICostTracker,
12
+ )
13
+ from querypilot.evals.loader import load_suite, load_suite_dir
14
+ from querypilot.evals.pipeline import QueryPilotFactory
15
+ from querypilot.evals.suite import BenchmarkCase, BenchmarkSuite
16
+ from querypilot.generation.sql_generator import DemoSQLGenerator, SQLGenerator
17
+
18
+
19
+ GENERATOR_NAMES = ("demo", "openai", "anthropic")
20
+
21
+ DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
22
+ DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"
23
+
24
+
25
+ def build_generator(name: str, *, model: str | None = None) -> SQLGenerator:
26
+ normalized = name.lower()
27
+ if normalized == "demo":
28
+ return DemoSQLGenerator()
29
+ if normalized == "openai":
30
+ from querypilot.generation.llm import OpenAISQLGenerator
31
+
32
+ return OpenAISQLGenerator(model=model or DEFAULT_OPENAI_MODEL)
33
+ if normalized == "anthropic":
34
+ from querypilot.generation.llm import AnthropicSQLGenerator
35
+
36
+ return AnthropicSQLGenerator(model=model or DEFAULT_ANTHROPIC_MODEL)
37
+ raise ValueError(
38
+ f"Unknown generator {name!r}. Supported: {', '.join(GENERATOR_NAMES)}."
39
+ )
40
+
41
+
42
+ def build_cost_tracker_factory(name: str) -> Callable[[], CostTracker]:
43
+ normalized = name.lower()
44
+ if normalized == "demo":
45
+ return NullCostTracker
46
+ if normalized == "openai":
47
+ return OpenAICostTracker
48
+ if normalized == "anthropic":
49
+ return AnthropicCostTracker
50
+ raise ValueError(
51
+ f"Unknown generator {name!r}. Supported: {', '.join(GENERATOR_NAMES)}."
52
+ )
53
+
54
+
55
+ def build_qp_factory(
56
+ *,
57
+ database_url: str,
58
+ dialect: str = "sqlite",
59
+ generator: SQLGenerator,
60
+ max_rows: int = 100,
61
+ timeout_seconds: int = 10,
62
+ max_generation_attempts: int = 2,
63
+ ) -> QueryPilotFactory:
64
+ def _make(case: BenchmarkCase) -> QueryPilot:
65
+ url = case.fixture_db or database_url
66
+ case_dialect = case.fixture_dialect or dialect
67
+ return QueryPilot.connect(
68
+ database_url=url,
69
+ dialect=case_dialect,
70
+ max_rows=max_rows,
71
+ timeout_seconds=timeout_seconds,
72
+ max_generation_attempts=max_generation_attempts,
73
+ generator=generator,
74
+ )
75
+
76
+ return _make
77
+
78
+
79
+ def load_suite_or_dir(path: str | Path) -> BenchmarkSuite:
80
+ target = Path(path)
81
+ if target.is_dir():
82
+ return load_suite_dir(target)
83
+ return load_suite(target)
@@ -0,0 +1,104 @@
1
+ """Scaffold a starter eval layout (suites/ and .eval/) for a project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ _SMOKE_YAML = """name: smoke
9
+
10
+ # Update fixture_db to point at your own SQLite or Postgres test database.
11
+ # Relative sqlite:/// paths are resolved against this file's directory.
12
+ fixture_db: sqlite:///REPLACE_ME_WITH_YOUR_DB.db
13
+ fixture_dialect: sqlite
14
+
15
+ thresholds:
16
+ pass_rate: 0.95
17
+ safety_pass_rate: 1.0
18
+ correctness_rate: 0.9
19
+ max_p95_latency_ms: 5000
20
+
21
+ comparison:
22
+ ignore_row_order: true
23
+ ignore_column_order: true
24
+ float_tolerance: 0.001
25
+ normalize_datetimes: true
26
+ case_insensitive_strings: false
27
+
28
+ # Replace these with cases that exercise your own schema. gold_sql is
29
+ # the SQL we expect to be functionally equivalent to what the generator
30
+ # produces; results are compared row-by-row after normalization.
31
+ cases:
32
+ - id: example_count
33
+ question: "Count of customers"
34
+ gold_sql: "SELECT COUNT(*) AS count FROM customers"
35
+ expected_tables: [customers]
36
+ must_not_contain: [DELETE, UPDATE, DROP, INSERT]
37
+ tags: [smoke, aggregation]
38
+ """
39
+
40
+ _SAFETY_YAML = """name: safety
41
+
42
+ # Safety cases run raw SQL strings through the validator and assert it
43
+ # blocks them. Update fixture_db to match your smoke suite.
44
+ fixture_db: sqlite:///REPLACE_ME_WITH_YOUR_DB.db
45
+ fixture_dialect: sqlite
46
+
47
+ thresholds:
48
+ safety_pass_rate: 1.0
49
+
50
+ cases:
51
+ - id: blocks_drop_table
52
+ sql: "DROP TABLE customers"
53
+ should_pass: false
54
+ expected_failure_kind: validation
55
+ expected_error_contains: ["Only SELECT queries are allowed"]
56
+ tags: [safety, ddl]
57
+
58
+ - id: blocks_update
59
+ sql: "UPDATE customers SET revenue = 0"
60
+ should_pass: false
61
+ expected_failure_kind: validation
62
+ tags: [safety, mutation]
63
+
64
+ - id: blocks_multi_statement
65
+ sql: "SELECT * FROM customers; DROP TABLE customers"
66
+ should_pass: false
67
+ expected_failure_kind: validation
68
+ tags: [safety, multi_statement]
69
+ """
70
+
71
+ _BASELINE_README = """# .eval/
72
+
73
+ This directory holds the committed baseline `SuiteReport` JSON used by
74
+ `querypilot eval check` to detect regressions.
75
+
76
+ To refresh on `main` after a deliberate change:
77
+
78
+ querypilot eval run --suite suites/smoke.yaml --report .eval/baseline.json
79
+ git commit -am "Refresh eval baseline"
80
+ """
81
+
82
+
83
+ def scaffold(target: Path, *, force: bool = False) -> list[Path]:
84
+ written: list[Path] = []
85
+ suites_dir = target / "suites"
86
+ eval_dir = target / ".eval"
87
+ suites_dir.mkdir(parents=True, exist_ok=True)
88
+ eval_dir.mkdir(parents=True, exist_ok=True)
89
+
90
+ smoke_path = suites_dir / "smoke.yaml"
91
+ safety_path = suites_dir / "safety.yaml"
92
+ readme_path = eval_dir / "README.md"
93
+
94
+ for path, contents in (
95
+ (smoke_path, _SMOKE_YAML),
96
+ (safety_path, _SAFETY_YAML),
97
+ (readme_path, _BASELINE_README),
98
+ ):
99
+ if path.exists() and not force:
100
+ continue
101
+ path.write_text(contents, encoding="utf-8")
102
+ written.append(path)
103
+
104
+ return written
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from querypilot.evals.suite import BenchmarkCase, BenchmarkSuite
8
+
9
+
10
+ class SuiteLoadError(ValueError):
11
+ """Raised when a benchmark suite cannot be loaded or parsed."""
12
+
13
+
14
+ _SQLITE_PREFIX = "sqlite:///"
15
+
16
+
17
+ def write_suite(suite: BenchmarkSuite, path: str | Path) -> Path:
18
+ target = Path(path)
19
+ target.parent.mkdir(parents=True, exist_ok=True)
20
+ payload = suite.model_dump(mode="json", exclude_none=True)
21
+ text = _dump(payload, target.suffix.lower())
22
+ target.write_text(text, encoding="utf-8")
23
+ return target
24
+
25
+
26
+ def _dump(payload: dict[str, Any], suffix: str) -> str:
27
+ if suffix == ".json":
28
+ return json.dumps(payload, indent=2, sort_keys=False) + "\n"
29
+ if suffix in {".yaml", ".yml"}:
30
+ try:
31
+ import yaml
32
+ except ImportError as exc:
33
+ raise SuiteLoadError(
34
+ "PyYAML is required to write YAML suites. Install with `pip install querypilot[eval]`."
35
+ ) from exc
36
+ return yaml.safe_dump(payload, sort_keys=False)
37
+ raise SuiteLoadError(f"Unsupported suite file extension for write: {suffix}")
38
+
39
+
40
+ def load_suite(path: str | Path) -> BenchmarkSuite:
41
+ suite_path = Path(path).resolve()
42
+ if not suite_path.is_file():
43
+ raise SuiteLoadError(f"Suite file not found: {suite_path}")
44
+
45
+ payload = _parse_file(suite_path)
46
+ if not isinstance(payload, dict):
47
+ raise SuiteLoadError(f"Suite file must contain a mapping at the top level: {suite_path}")
48
+
49
+ suite = _build_suite(payload, suite_path.parent)
50
+ return suite
51
+
52
+
53
+ def load_suite_dir(path: str | Path) -> BenchmarkSuite:
54
+ suite_dir = Path(path).resolve()
55
+ if not suite_dir.is_dir():
56
+ raise SuiteLoadError(f"Suite directory not found: {suite_dir}")
57
+
58
+ suite_files = sorted(
59
+ p for p in suite_dir.iterdir() if p.suffix.lower() in {".yaml", ".yml", ".json"}
60
+ )
61
+ if not suite_files:
62
+ raise SuiteLoadError(f"No .yaml/.yml/.json suite files found in: {suite_dir}")
63
+
64
+ sub_suites = [load_suite(suite_file) for suite_file in suite_files]
65
+ head, *rest = sub_suites
66
+ head_path = suite_files[0]
67
+
68
+ for sub, sub_path in zip(rest, suite_files[1:]):
69
+ _require_dir_match(head, head_path, sub, sub_path, "fixture_db")
70
+ _require_dir_match(head, head_path, sub, sub_path, "fixture_dialect")
71
+ _require_dir_match(head, head_path, sub, sub_path, "thresholds")
72
+ _require_dir_match(head, head_path, sub, sub_path, "comparison")
73
+
74
+ merged_cases: list[BenchmarkCase] = []
75
+ seen_ids: set[str] = set()
76
+ for sub in sub_suites:
77
+ for case in sub.cases:
78
+ if case.id in seen_ids:
79
+ raise SuiteLoadError(
80
+ f"Duplicate case id across suite directory {suite_dir}: {case.id!r}"
81
+ )
82
+ seen_ids.add(case.id)
83
+ merged_cases.append(case)
84
+
85
+ return BenchmarkSuite(
86
+ name=head.name,
87
+ fixture_db=head.fixture_db,
88
+ fixture_dialect=head.fixture_dialect,
89
+ thresholds=head.thresholds,
90
+ comparison=head.comparison,
91
+ cases=merged_cases,
92
+ )
93
+
94
+
95
+ def _require_dir_match(
96
+ head: BenchmarkSuite,
97
+ head_path: Path,
98
+ sub: BenchmarkSuite,
99
+ sub_path: Path,
100
+ field: str,
101
+ ) -> None:
102
+ head_value = getattr(head, field)
103
+ sub_value = getattr(sub, field)
104
+ if head_value != sub_value:
105
+ raise SuiteLoadError(
106
+ f"{field!r} differs across suite directory: "
107
+ f"{head_path.name} has {head_value!r} but {sub_path.name} has {sub_value!r}. "
108
+ "All suite files in a directory must share fixture_db, fixture_dialect, "
109
+ "thresholds, and comparison settings."
110
+ )
111
+
112
+
113
+ def _parse_file(path: Path) -> Any:
114
+ text = path.read_text(encoding="utf-8")
115
+ suffix = path.suffix.lower()
116
+
117
+ if suffix == ".json":
118
+ try:
119
+ return json.loads(text)
120
+ except json.JSONDecodeError as exc:
121
+ raise SuiteLoadError(f"Invalid JSON in {path}: {exc}") from exc
122
+
123
+ if suffix in {".yaml", ".yml"}:
124
+ try:
125
+ import yaml
126
+ except ImportError as exc:
127
+ raise SuiteLoadError(
128
+ "PyYAML is required to load YAML suites. Install with `pip install querypilot[eval]`."
129
+ ) from exc
130
+ try:
131
+ return yaml.safe_load(text)
132
+ except yaml.YAMLError as exc:
133
+ raise SuiteLoadError(f"Invalid YAML in {path}: {exc}") from exc
134
+
135
+ raise SuiteLoadError(f"Unsupported suite file extension: {path.suffix} ({path})")
136
+
137
+
138
+ def _build_suite(payload: dict[str, Any], suite_dir: Path) -> BenchmarkSuite:
139
+ payload = dict(payload)
140
+
141
+ suite_fixture_db = payload.get("fixture_db")
142
+ if suite_fixture_db is not None:
143
+ payload["fixture_db"] = _resolve_fixture_db(str(suite_fixture_db), suite_dir)
144
+
145
+ raw_cases = payload.get("cases") or []
146
+ if not isinstance(raw_cases, list):
147
+ raise SuiteLoadError("Suite 'cases' must be a list.")
148
+
149
+ resolved_cases: list[dict[str, Any]] = []
150
+ for raw in raw_cases:
151
+ if not isinstance(raw, dict):
152
+ raise SuiteLoadError(f"Each case must be a mapping; got {type(raw).__name__}.")
153
+ case = dict(raw)
154
+ case_fixture_db = case.get("fixture_db")
155
+ if case_fixture_db is not None:
156
+ case["fixture_db"] = _resolve_fixture_db(str(case_fixture_db), suite_dir)
157
+ resolved_cases.append(case)
158
+ payload["cases"] = resolved_cases
159
+
160
+ try:
161
+ return BenchmarkSuite.model_validate(payload)
162
+ except ValueError as exc:
163
+ raise SuiteLoadError(f"Invalid suite definition: {exc}") from exc
164
+
165
+
166
+ def _resolve_fixture_db(fixture_db: str, suite_dir: Path) -> str:
167
+ if not fixture_db.startswith(_SQLITE_PREFIX):
168
+ return fixture_db
169
+
170
+ raw_path = fixture_db[len(_SQLITE_PREFIX):]
171
+ if not raw_path or raw_path.startswith(":memory:"):
172
+ return fixture_db
173
+
174
+ candidate = Path(raw_path)
175
+ if candidate.is_absolute():
176
+ return fixture_db
177
+
178
+ resolved = (suite_dir / candidate).resolve()
179
+ return f"{_SQLITE_PREFIX}{resolved}"