Skip to content

Commit 1de4586

Browse files
committed
Add api coverage script
1 parent 6cfc50b commit 1de4586

3 files changed

Lines changed: 242 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ jobs:
5050
- name: Type check
5151
run: uv run --extra dev mypy paradedb
5252

53+
- name: Check API coverage
54+
run: uv run scripts/check_api_coverage.py
55+
5356
matrix-tests:
5457
name: Python ${{ matrix.python-version }}
5558
runs-on: ubuntu-latest

api.json

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,15 @@
1111

1212
"functions": {
1313
"FN_ALL": "pdb.all",
14-
"FN_TERM": "pdb.term",
1514
"FN_PARSE": "pdb.parse",
1615
"FN_PHRASE_PREFIX": "pdb.phrase_prefix",
1716
"FN_REGEX_PHRASE": "pdb.regex_phrase",
1817
"FN_RANGE_TERM": "pdb.range_term",
1918
"FN_REGEX": "pdb.regex",
20-
"FN_PROXIMITY": "pdb.proximity",
2119
"FN_PROX_REGEX": "pdb.prox_regex",
2220
"FN_PROX_ARRAY": "pdb.prox_array",
2321
"FN_MORE_LIKE_THIS": "pdb.more_like_this",
24-
"FN_EMPTY": "pdb.empty",
2522
"FN_EXISTS": "pdb.exists",
26-
"FN_FUZZY_TERM": "pdb.fuzzy_term",
27-
"FN_PARSE_WITH_FIELD": "pdb.parse_with_field",
28-
"FN_RANGE": "pdb.range",
29-
"FN_TERM_SET": "pdb.term_set",
3023

3124
"FN_SCORE": "pdb.score",
3225
"FN_SNIPPET": "pdb.snippet",

scripts/check_api_coverage.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
#!/usr/bin/env python3
2+
"""Validate that api.json matches the SQLAlchemy wrapper surface."""
3+
4+
from __future__ import annotations
5+
6+
import ast
7+
import json
8+
import re
9+
import sys
10+
from pathlib import Path
11+
12+
ROOT = Path(__file__).resolve().parents[1]
13+
API_JSON = ROOT / "api.json"
14+
APIIGNORE_JSON = ROOT / "apiignore.json"
15+
PDB_SYMBOL_RE = re.compile(r"\bpdb\.[A-Za-z_][A-Za-z0-9_]*\b")
16+
17+
18+
def load_json(path: Path) -> object:
19+
try:
20+
return json.loads(path.read_text(encoding="utf-8"))
21+
except FileNotFoundError as exc:
22+
raise FileNotFoundError(f"{path} not found") from exc
23+
except json.JSONDecodeError as exc:
24+
raise ValueError(f"invalid JSON in {path}: {exc}") from exc
25+
26+
27+
def flatten_ignore(section: object, *, kind: str) -> set[str]:
28+
if section is None:
29+
return set()
30+
if isinstance(section, list):
31+
return {str(item) for item in section}
32+
if isinstance(section, dict):
33+
flattened: set[str] = set()
34+
for values in section.values():
35+
if not isinstance(values, list):
36+
raise ValueError(f"apiignore {kind} section values must be arrays when grouped.")
37+
flattened.update(str(item) for item in values)
38+
return flattened
39+
raise ValueError(f"apiignore {kind} section must be an array or object of arrays.")
40+
41+
42+
def source_paths() -> list[Path]:
43+
return sorted(path for path in ROOT.glob("paradedb/**/*.py") if path.name != "api.py")
44+
45+
46+
def parse_module(path: Path) -> ast.AST:
47+
try:
48+
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
49+
except SyntaxError as exc:
50+
raise SyntaxError(f"failed to parse {path}: {exc}") from exc
51+
52+
53+
def _attribute_chain(node: ast.AST) -> str | None:
54+
parts: list[str] = []
55+
current: ast.AST | None = node
56+
while isinstance(current, ast.Attribute):
57+
parts.append(current.attr)
58+
current = current.value
59+
if isinstance(current, ast.Name):
60+
parts.append(current.id)
61+
return ".".join(reversed(parts))
62+
return None
63+
64+
65+
def _string_literal(node: ast.AST | None) -> str | None:
66+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
67+
return node.value
68+
return None
69+
70+
71+
def _call_name(node: ast.Call) -> str | None:
72+
return _attribute_chain(node.func)
73+
74+
75+
class APIReferenceCollector(ast.NodeVisitor):
76+
def __init__(self) -> None:
77+
self.pdb_symbols: set[str] = set()
78+
self.operator_symbols: set[str] = set()
79+
80+
def visit_Constant(self, node: ast.Constant) -> None:
81+
if isinstance(node.value, str):
82+
self.pdb_symbols.update(PDB_SYMBOL_RE.findall(node.value))
83+
self.generic_visit(node)
84+
85+
def visit_Attribute(self, node: ast.Attribute) -> None:
86+
chain = _attribute_chain(node)
87+
if chain and chain.startswith("func.pdb."):
88+
self.pdb_symbols.add("pdb." + chain.removeprefix("func.pdb."))
89+
self.generic_visit(node)
90+
91+
def visit_Call(self, node: ast.Call) -> None:
92+
call_name = _call_name(node)
93+
94+
if call_name == "PDBFunctionWithNamedArgs" and node.args:
95+
function_name = _string_literal(node.args[0])
96+
if function_name is not None:
97+
self.pdb_symbols.add(f"pdb.{function_name}")
98+
99+
elif call_name == "PDBCast" and len(node.args) >= 2:
100+
type_name = _string_literal(node.args[1])
101+
if type_name is not None:
102+
self.pdb_symbols.add(f"pdb.{type_name}")
103+
104+
elif call_name == "_build_spec" and node.args:
105+
tokenizer_name = _string_literal(node.args[0])
106+
if tokenizer_name is not None:
107+
self.pdb_symbols.add(f"pdb.{tokenizer_name}")
108+
109+
elif call_name == "TokenizerSpec":
110+
tokenizer_name: str | None = None
111+
if node.args:
112+
tokenizer_name = _string_literal(node.args[0])
113+
if tokenizer_name is None:
114+
for keyword in node.keywords:
115+
if keyword.arg == "name":
116+
tokenizer_name = _string_literal(keyword.value)
117+
break
118+
if tokenizer_name is not None:
119+
self.pdb_symbols.add(f"pdb.{tokenizer_name}")
120+
121+
elif call_name == "operators.custom_op" and node.args:
122+
operator_symbol = _string_literal(node.args[0])
123+
if operator_symbol is not None:
124+
self.operator_symbols.add(operator_symbol)
125+
126+
self.generic_visit(node)
127+
128+
129+
def collect_api_references(module: ast.AST) -> tuple[set[str], set[str]]:
130+
collector = APIReferenceCollector()
131+
collector.visit(module)
132+
return collector.pdb_symbols, collector.operator_symbols
133+
134+
135+
def main() -> int:
136+
try:
137+
api = load_json(API_JSON)
138+
apiignore = load_json(APIIGNORE_JSON) if APIIGNORE_JSON.is_file() else {}
139+
except (FileNotFoundError, ValueError) as exc:
140+
print(f"❌ {exc}", file=sys.stderr)
141+
return 1
142+
143+
if not isinstance(api, dict):
144+
print("❌ api.json must contain a JSON object.", file=sys.stderr)
145+
return 1
146+
if not isinstance(apiignore, dict):
147+
print("❌ apiignore.json must contain a JSON object.", file=sys.stderr)
148+
return 1
149+
150+
try:
151+
operators = api["operators"]
152+
functions = api["functions"]
153+
types = api["types"]
154+
except KeyError as exc:
155+
print(f"❌ api.json missing required section: {exc}", file=sys.stderr)
156+
return 1
157+
158+
if not all(isinstance(section, dict) for section in (operators, functions, types)):
159+
print(
160+
"❌ api.json sections operators/functions/types must all be objects.",
161+
file=sys.stderr,
162+
)
163+
return 1
164+
165+
expected_operator_symbols = {str(value) for value in operators.values()}
166+
expected_pdb_symbols = {
167+
*(str(value) for value in functions.values()),
168+
*(str(value) for value in types.values()),
169+
}
170+
171+
try:
172+
ignored_functions = flatten_ignore(apiignore.get("functions"), kind="functions")
173+
ignored_types = flatten_ignore(apiignore.get("types"), kind="types")
174+
except ValueError as exc:
175+
print(f"❌ {exc}", file=sys.stderr)
176+
return 1
177+
178+
referenced_pdb_symbols: set[str] = set()
179+
referenced_operator_symbols: set[str] = set()
180+
181+
try:
182+
paths = source_paths()
183+
for path in paths:
184+
module = parse_module(path)
185+
file_pdb_symbols, file_operator_symbols = collect_api_references(module)
186+
referenced_pdb_symbols.update(file_pdb_symbols)
187+
referenced_operator_symbols.update(file_operator_symbols)
188+
except (SyntaxError, OSError) as exc:
189+
print(f"❌ {exc}", file=sys.stderr)
190+
return 1
191+
192+
missing_pdb_symbols = sorted(expected_pdb_symbols - referenced_pdb_symbols)
193+
missing_operator_symbols = sorted(expected_operator_symbols - referenced_operator_symbols)
194+
195+
allowed_symbols = {
196+
*expected_pdb_symbols,
197+
*ignored_functions,
198+
*ignored_types,
199+
}
200+
untracked_symbols = sorted(referenced_pdb_symbols - allowed_symbols)
201+
202+
issues: list[str] = []
203+
if missing_pdb_symbols:
204+
issues.append("api.json pdb.* symbols not referenced by SQLAlchemy wrappers: " + ", ".join(missing_pdb_symbols))
205+
if missing_operator_symbols:
206+
issues.append(
207+
"api.json operators not referenced by SQLAlchemy wrappers: " + ", ".join(missing_operator_symbols)
208+
)
209+
if untracked_symbols:
210+
issues.append(
211+
"pdb.* symbols used in package source but missing from api.json/apiignore.json: "
212+
+ ", ".join(untracked_symbols)
213+
)
214+
215+
if issues:
216+
print("❌ API coverage check failed:", file=sys.stderr)
217+
for issue in issues:
218+
print(f" - {issue}", file=sys.stderr)
219+
print(
220+
"\nUpdate api.json, apiignore.json, or the SQLAlchemy wrappers so they stay in sync.",
221+
file=sys.stderr,
222+
)
223+
return 1
224+
225+
print("✅ API coverage check passed.")
226+
covered_total = len(expected_pdb_symbols & referenced_pdb_symbols) + len(
227+
expected_operator_symbols & referenced_operator_symbols
228+
)
229+
expected_total = len(expected_pdb_symbols) + len(expected_operator_symbols)
230+
print(f" api symbols referenced: {covered_total}/{expected_total}")
231+
print(
232+
f" source files: {len(paths)}, concrete API references checked: "
233+
f"{len(referenced_pdb_symbols) + len(referenced_operator_symbols)}"
234+
)
235+
return 0
236+
237+
238+
if __name__ == "__main__":
239+
raise SystemExit(main())

0 commit comments

Comments
 (0)