Skip to content

Commit 2c1d17f

Browse files
committed
Refactor emitters/parsers to pure LibCST AST IR, decompose cli.py, and harden security/typing
- Eliminate embedded __arazzo_*_spec__ snapshot dicts across workflows, cli_sdk, fastapi, and tests; reconstruct Arazzo 1.1.0 IR directly from LibCST AST nodes, docstrings, decorators, and type annotations - Refactor fastapi/emit.py, dao/emit.py, db/emit.py, and seeder/emit.py from raw string concatenation to LibCST AST construction with SQLAlchemy 2.0 select/Session.get DAOs, topological FK/$ref seeding, and live FastAPI DAO dependency wiring - Centralize OpenAPI 3.2.0 / JSON Schema 2020-12 <-> Python type mapping and identifier sanitization in openapi_client.functions.utils; enforce required-before-optional parameter ordering in Client methods - Decompose openapi_client.cli into scaffolding.py, docs_json.py, sync.py, servers/json_rpc.py (defaulting to 127.0.0.1), and servers/mcp_stdio.py (adding prompts/list and prompts/get handlers and fixing MCP tool input_dir/output_path mismatches) - Harden resolve_external_refs against path traversal and invalid external JSON, isolate parse_openapi_dict via deep copy, and add quote-aware Arazzo simple condition tokenization and SelectorObject.type AST round-tripping - Introduce unified CDDError exception hierarchy in openapi_client.exceptions, emit/parse Pydantic Field() validation constraints, and enforce 100% line/branch test coverage and 100% structured docstring coverage across src/, scripts/, tests/, and test/
1 parent 7e202b7 commit 2c1d17f

53 files changed

Lines changed: 5673 additions & 1956 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎pyproject.toml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dev = [
2323
"pytest-cov",
2424
"interrogate",
2525
"build",
26+
"mypy",
2627
"pdoc",
2728
"py2wasm",
2829
]
@@ -37,6 +38,12 @@ packages = ["src/openapi_client"]
3738
[tool.pytest.ini_options]
3839
testpaths = ["tests", "test"]
3940

41+
[tool.mypy]
42+
strict = true
43+
disallow_any_generics = true
44+
warn_return_any = true
45+
warn_unused_ignores = true
46+
4047
[dependency-groups]
4148
dev = [
4249
"pre-commit>=4.3.0",
@@ -45,4 +52,5 @@ dev = [
4552
"pytest-cov>=4.1.0",
4653
"interrogate>=1.7.0",
4754
"build>=1.0.0",
55+
"mypy>=1.10.0",
4856
]

‎scripts/doc_coverage.py‎

Lines changed: 94 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,67 +4,116 @@
44

55
import ast
66
import os
7+
import re
78

89

9-
def calculate_doc_coverage(directory: str) -> tuple[float, int, int]:
10+
def _collect_function_args(
11+
node: ast.FunctionDef | ast.AsyncFunctionDef,
12+
) -> list[ast.arg]:
13+
"""Collect all positional-only, regular, keyword-only, `*args`, and `**kwargs` arguments.
14+
15+
Args:
16+
node: The AST FunctionDef or AsyncFunctionDef node.
17+
18+
Returns:
19+
List of `ast.arg` objects for the function signature.
20+
"""
21+
all_args: list[ast.arg] = [
22+
*node.args.posonlyargs,
23+
*node.args.args,
24+
*node.args.kwonlyargs,
25+
]
26+
if node.args.vararg is not None:
27+
all_args.append(node.args.vararg)
28+
if node.args.kwarg is not None:
29+
all_args.append(node.args.kwarg)
30+
return all_args
31+
32+
33+
def _has_structured_arg_doc(doc: str | None, arg_name: str) -> bool:
34+
"""Check whether `doc` documents `arg_name` in a structured `Args:` or `:param:` entry.
35+
36+
Args:
37+
doc: Extracted docstring text or `None`.
38+
arg_name: Parameter name to verify.
39+
40+
Returns:
41+
`True` if `arg_name` has a structured docstring entry, `False` otherwise.
42+
"""
43+
if not doc:
44+
return False
45+
pattern = rf"(?:^\s*\*{{0,2}}{re.escape(arg_name)}\s*(?:\([^)]*\))?\s*:|:param\s+{re.escape(arg_name)}\s*:)"
46+
return re.search(pattern, doc, re.MULTILINE) is not None
47+
48+
49+
def calculate_doc_coverage(
50+
directory: str | tuple[str, ...] = (
51+
"src/openapi_client",
52+
"scripts",
53+
"tests",
54+
"test",
55+
),
56+
) -> tuple[float, int, int]:
1057
"""Calculate the percentage of modules, classes, functions, and arguments with docstrings.
1158
1259
Args:
13-
directory: Root directory to scan recursively for `.py` files.
60+
directory: Root directory (or tuple of directories) to scan recursively for `.py` files.
1461
1562
Returns:
1663
A tuple of `(coverage_percentage, documented_count, total_count)`.
64+
65+
Raises:
66+
SyntaxError: If any `.py` file under `directory` contains invalid Python syntax.
1767
"""
68+
directories = (directory,) if isinstance(directory, str) else directory
1869
total = 0
1970
with_docs = 0
2071

21-
for root, _, files in os.walk(directory):
22-
for file in files:
23-
if not file.endswith(".py"):
24-
continue
25-
path = os.path.join(root, file)
26-
with open(path, encoding="utf-8") as f:
27-
content = f.read()
28-
try:
29-
tree = ast.parse(content)
30-
except SyntaxError:
31-
continue
32-
33-
total += 1
34-
if ast.get_docstring(tree):
35-
with_docs += 1
36-
else:
37-
print(f"Missing docstring: Module {path}")
38-
39-
for node in ast.walk(tree):
40-
if isinstance(
41-
node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
42-
):
43-
if node.name.startswith("__") and node.name != "__init__":
44-
continue
45-
total += 1
46-
doc = ast.get_docstring(node)
47-
if doc:
48-
with_docs += 1
49-
else:
50-
print(f"Missing docstring: {path} :: {node.name}")
51-
52-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
53-
for arg in node.args.args:
54-
if arg.arg in ("self", "cls"):
55-
continue
56-
total += 1
57-
if doc and arg.arg in doc:
58-
with_docs += 1
59-
else:
60-
print(
61-
f"Missing arg docstring: {path} :: {node.name}({arg.arg})"
62-
)
72+
for single_dir in directories:
73+
for root, _, files in os.walk(single_dir):
74+
for file in sorted(files):
75+
if not file.endswith(".py"):
76+
continue
77+
path = os.path.join(root, file)
78+
with open(path, encoding="utf-8") as f:
79+
content = f.read()
80+
tree = ast.parse(content, filename=path)
81+
82+
total += 1
83+
if ast.get_docstring(tree):
84+
with_docs += 1
85+
else:
86+
print(f"Missing docstring: Module {path}")
87+
88+
for node in ast.walk(tree):
89+
if isinstance(
90+
node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
91+
):
92+
if node.name.startswith("__") and node.name != "__init__":
93+
continue
94+
total += 1
95+
doc = ast.get_docstring(node)
96+
if doc:
97+
with_docs += 1
98+
else:
99+
print(f"Missing docstring: {path} :: {node.name}")
100+
101+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
102+
for arg in _collect_function_args(node):
103+
if arg.arg in ("self", "cls"):
104+
continue
105+
total += 1
106+
if _has_structured_arg_doc(doc, arg.arg):
107+
with_docs += 1
108+
else:
109+
print(
110+
f"Missing arg docstring: {path} :: {node.name}({arg.arg})"
111+
)
63112

64113
percent = (with_docs / total * 100) if total > 0 else 0.0
65114
return percent, with_docs, total
66115

67116

68117
if __name__ == "__main__": # pragma: no cover
69-
p, w, t = calculate_doc_coverage("src/openapi_client") # pragma: no cover
118+
p, w, t = calculate_doc_coverage() # pragma: no cover
70119
print(f"{p:.2f}") # pragma: no cover

‎scripts/update_badges.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,14 @@ def main() -> None:
5757

5858
print("Running interrogate for doc coverage...")
5959
doc_result = subprocess.run(
60-
["interrogate", "--fail-under=0", "src/openapi_client"],
60+
[
61+
"interrogate",
62+
"--fail-under=0",
63+
"src/openapi_client",
64+
"scripts",
65+
"tests",
66+
"test",
67+
],
6168
check=False,
6269
stdout=subprocess.PIPE,
6370
stderr=subprocess.STDOUT,

‎src/openapi_client/arazzo/expressions.py‎

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,55 @@ def _parse_literal_or_expr(token: str, context_data: dict[str, Any]) -> Any:
372372
return tok
373373

374374

375+
def _split_outside_quotes(text: str, sep: str, maxsplit: int = -1) -> list[str]:
376+
"""Split `text` by `sep` only when `sep` occurs outside single or double quotes.
377+
378+
Args:
379+
text: The raw condition string to split.
380+
sep: The operator delimiter string (e.g. `'||'`, `'&&'`, `'=='`).
381+
maxsplit: Maximum number of splits to perform (`-1` for unlimited).
382+
383+
Returns:
384+
List of substrings split outside quoted string literals.
385+
"""
386+
parts: list[str] = []
387+
buf: list[str] = []
388+
quote_char: str | None = None
389+
i = 0
390+
n = len(text)
391+
sep_len = len(sep)
392+
splits = 0
393+
bs = chr(92)
394+
395+
while i < n:
396+
ch = text[i]
397+
if quote_char is not None:
398+
if ch == bs and i + 1 < n:
399+
buf.append(text[i : i + 2])
400+
i += 2
401+
continue
402+
if ch == quote_char:
403+
quote_char = None
404+
buf.append(ch)
405+
i += 1
406+
else:
407+
if ch in ("'", '"'):
408+
quote_char = ch
409+
buf.append(ch)
410+
i += 1
411+
elif (maxsplit < 0 or splits < maxsplit) and text.startswith(sep, i):
412+
parts.append("".join(buf))
413+
buf = []
414+
splits += 1
415+
i += sep_len
416+
else:
417+
buf.append(ch)
418+
i += 1
419+
420+
parts.append("".join(buf))
421+
return parts
422+
423+
375424
def _evaluate_simple_condition(
376425
condition: str, context_val: Any, context_data: dict[str, Any]
377426
) -> bool:
@@ -385,21 +434,24 @@ def _evaluate_simple_condition(
385434
Returns:
386435
`True` if the condition holds, `False` otherwise.
387436
"""
388-
if "||" in condition:
437+
or_parts = _split_outside_quotes(condition, "||")
438+
if len(or_parts) > 1:
389439
return any(
390440
_evaluate_simple_condition(part, context_val, context_data)
391-
for part in condition.split("||")
441+
for part in or_parts
392442
)
393-
if "&&" in condition:
443+
and_parts = _split_outside_quotes(condition, "&&")
444+
if len(and_parts) > 1:
394445
return all(
395446
_evaluate_simple_condition(part, context_val, context_data)
396-
for part in condition.split("&&")
447+
for part in and_parts
397448
)
398449

399450
cond = condition.strip()
400451
for op_str in ("==", "!=", "<=", ">=", "<", ">"):
401-
if op_str in cond:
402-
lhs_raw, rhs_raw = cond.split(op_str, 1)
452+
op_parts = _split_outside_quotes(cond, op_str, maxsplit=1)
453+
if len(op_parts) == 2:
454+
lhs_raw, rhs_raw = op_parts
403455
lhs_tok = lhs_raw.strip()
404456
if lhs_tok.startswith("$.") and context_val is not None:
405457
lhs = evaluate_jsonpath(context_val, lhs_tok)
@@ -549,8 +601,13 @@ def compile_expression_to_cst(expr: Any) -> cst.BaseExpression:
549601
A `libcst.BaseExpression` node representing the runtime lookup in generated Python code.
550602
"""
551603
if isinstance(expr, SelectorObject):
604+
type_arg = ""
605+
if isinstance(expr.type, ExpressionTypeObject):
606+
type_arg = f", type=ExpressionTypeObject(type={expr.type.type!r}, version={expr.type.version!r})"
607+
elif isinstance(expr.type, str):
608+
type_arg = f", type={expr.type!r}"
552609
return cst.parse_expression(
553-
f"evaluate_selector(SelectorObject(context={expr.context!r}, selector={expr.selector!r}), context)"
610+
f"evaluate_selector(SelectorObject(context={expr.context!r}, selector={expr.selector!r}{type_arg}), context)"
554611
)
555612
if not isinstance(expr, str):
556613
return cst.parse_expression(repr(expr))
@@ -571,10 +628,12 @@ def compile_expression_to_cst(expr: Any) -> cst.BaseExpression:
571628
)
572629
if stripped.startswith("$steps."):
573630
parts = stripped[len("$steps.") :].split(".")
574-
step_id, _, out_key = parts
575-
return cst.parse_expression(
576-
f"steps_context[{step_id!r}]['outputs'][{out_key!r}]"
577-
)
631+
if len(parts) == 3 and parts[1] == "outputs":
632+
step_id, _, out_key = parts
633+
return cst.parse_expression(
634+
f"steps_context[{step_id!r}]['outputs'][{out_key!r}]"
635+
)
636+
return cst.parse_expression(f"evaluate_runtime_expression({expr!r}, context)")
578637
if stripped == "$statusCode":
579638
return cst.parse_expression("response.status")
580639
if stripped.startswith("$response.body."):
@@ -600,14 +659,34 @@ def extract_expression_from_cst(node: cst.BaseExpression) -> Any:
600659
if m_eval:
601660
return str(cst.SimpleString(m_eval.group(1)).evaluated_value)
602661

662+
m_sel_eto = re.match(
663+
r"""^evaluate_selector\(\s*SelectorObject\(\s*context=(['"].*?['"])\s*,\s*selector=(['"].*?['"])\s*,\s*type=ExpressionTypeObject\(\s*type=(['"].*?['"])\s*,\s*version=(['"].*?['"])\s*\)\s*\)""",
664+
code,
665+
)
666+
if m_sel_eto:
667+
return SelectorObject(
668+
context=str(cst.SimpleString(m_sel_eto.group(1)).evaluated_value),
669+
selector=str(cst.SimpleString(m_sel_eto.group(2)).evaluated_value),
670+
type=ExpressionTypeObject(
671+
type=str(cst.SimpleString(m_sel_eto.group(3)).evaluated_value),
672+
version=str(cst.SimpleString(m_sel_eto.group(4)).evaluated_value),
673+
),
674+
)
675+
603676
m_sel = re.match(
604-
r"""^evaluate_selector\(\s*SelectorObject\(\s*context=(['"].*?['"])\s*,\s*selector=(['"].*?['"])\s*\)""",
677+
r"""^evaluate_selector\(\s*SelectorObject\(\s*context=(['"].*?['"])\s*,\s*selector=(['"].*?['"])(?:\s*,\s*type=(['"].*?['"]))?\s*\)""",
605678
code,
606679
)
607680
if m_sel:
681+
sel_type = (
682+
str(cst.SimpleString(m_sel.group(3)).evaluated_value)
683+
if m_sel.group(3)
684+
else None
685+
)
608686
return SelectorObject(
609687
context=str(cst.SimpleString(m_sel.group(1)).evaluated_value),
610688
selector=str(cst.SimpleString(m_sel.group(2)).evaluated_value),
689+
type=sel_type,
611690
)
612691

613692
m_in = re.match(r"""^inputs\[\s*(['"])([^'"]+)\1\s*\]$""", code)

0 commit comments

Comments
 (0)