Skip to content

Commit 362c38c

Browse files
committed
Unify CLI/SQLAlchemy AST round-tripping, enforce strict typing, and eliminate tautological tests
- Consolidate CLI SDK operation extraction in openapi_client.cli_sdk.emit.extract_cli_operations and share it with cli_sdk_cdd, emitting Client(base_url=os.environ.get('API_BASE_URL', ...)) and supporting requestBody (--body) and non-primitive argument types. - Enhance CLIExtractor in openapi_client.cli_sdk.parse to synthesize PathItem, Operation, Parameter, and RequestBody nodes when extracting from standalone CLI scripts where spec.paths starts empty. - Implement SQLAlchemyExtractor and extract_sqlalchemy_column in openapi_client.sqlalchemy_cdd.parse to parse declarative Column/mapped_column AST assignments into OpenAPI component schemas and wire models/ extraction into sync_dir(--truth sqlalchemy) and generate_to_openapi. - Break top-level circular imports between cli, sdk, sync, servers.json_rpc, and servers.mcp_stdio; route MCP stdio tools/call through mcp.execute_tool with normalized argument aliases and add sync_dir to mcp.get_tools(). - Standardize OpenAPI 3.2.0 HTTP_METHODS (including options, head, trace, query) and PathItem.additionalOperations across emitters and AST extractors, and add deterministic PoolManager lifecycle methods (close, __enter__, __exit__) to generated Client classes. - Introduce build_cst_module to normalize LibCST EmptyLine nodes into statement leading_lines/footer, add PEP 561 py.typed marker, and achieve zero mypy --strict errors across all 99 source and test files. - Replace tautological and mock-only assertions in test/ and tests/ with behavioral round-trip verification while maintaining 100.00% statement/branch test coverage and 100.00% docstring/argument coverage.
1 parent 2c1d17f commit 362c38c

61 files changed

Lines changed: 2204 additions & 768 deletions

Some content is hidden

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

‎.pre-commit-config.yaml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ repos:
2020
language: system
2121
pass_filenames: false
2222
always_run: true
23+
- id: mypy
24+
name: Mypy Strict Type Check
25+
entry: uv run mypy
26+
language: system
27+
pass_filenames: false
28+
always_run: true
29+
- id: doc-coverage
30+
name: Docstring & Argument Coverage (100%)
31+
entry: uv run python scripts/doc_coverage.py
32+
language: system
33+
pass_filenames: false
34+
always_run: true
2335
- id: test-and-coverage-badges
2436
name: Run Tests and Update Coverage Badges
2537
entry: uv run python scripts/update_badges.py

‎pyproject.toml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,21 @@ packages = ["src/openapi_client"]
3939
testpaths = ["tests", "test"]
4040

4141
[tool.mypy]
42+
packages = ["openapi_client", "scripts", "tests", "test"]
43+
plugins = ["pydantic.mypy"]
4244
strict = true
4345
disallow_any_generics = true
4446
warn_return_any = true
4547
warn_unused_ignores = true
4648

49+
[[tool.mypy.overrides]]
50+
module = ["cdd.*", "fastapi", "fastapi.*"]
51+
ignore_missing_imports = true
52+
53+
[[tool.mypy.overrides]]
54+
module = ["test.mock_server"]
55+
disallow_untyped_decorators = false
56+
4757
[dependency-groups]
4858
dev = [
4959
"pre-commit>=4.3.0",

‎scripts/run_generated_server_test.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ def find_free_port() -> int:
1818
An available port number.
1919
"""
2020
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
21-
s.bind(("", 0))
2221
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
22+
s.bind(("", 0))
2323
port: int = s.getsockname()[1]
2424
return port
2525

‎scripts/run_petstore_test.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ def find_free_port() -> int:
1818
An available port number.
1919
"""
2020
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
21-
s.bind(("", 0))
2221
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
22+
s.bind(("", 0))
2323
port: int = s.getsockname()[1]
2424
return port
2525

‎src/openapi_client/arazzo/keywords.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"Arazzo Specification Object",
77
"arazzo",
88
"$self",
9+
"self_",
910
"info",
1011
"sourceDescriptions",
1112
"workflows",
@@ -42,6 +43,7 @@
4243
"action",
4344
"Parameter Object",
4445
"in",
46+
"in_",
4547
"value",
4648
"Success Action Object",
4749
"criteria",

‎src/openapi_client/arazzo/parse.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
ReusableObject,
1515
SuccessAction,
1616
)
17+
from openapi_client.exceptions import SpecificationParseError
1718
from openapi_client.models import OpenAPI, Operation
1819
from openapi_client.openapi.parse import parse_openapi_dict
1920

21+
SourceDocument = OpenAPI | Arazzo | dict[str, Any]
22+
2023

2124
def decode_json_pointer_token(token: str) -> str:
2225
"""Decode an RFC 6901 JSON Pointer reference token (`~1` -> `/`, `~0` -> `~`).
@@ -142,7 +145,7 @@ def resolve_source_descriptions(
142145
arazzo_spec: Arazzo,
143146
base_path: Path = Path("."),
144147
visited: frozenset[Path] = frozenset(),
145-
) -> dict[str, OpenAPI | Arazzo | dict[str, Any]]:
148+
) -> dict[str, SourceDocument]:
146149
"""Resolve and load external documents listed in `arazzo_spec.sourceDescriptions`.
147150
148151
Args:
@@ -154,7 +157,7 @@ def resolve_source_descriptions(
154157
A dictionary mapping each `sourceDescription.name` to its parsed `OpenAPI`,
155158
`Arazzo`, or raw `dict` representation.
156159
"""
157-
resolved: dict[str, OpenAPI | Arazzo | dict[str, Any]] = {}
160+
resolved: dict[str, SourceDocument] = {}
158161
effective_base = base_path
159162
if arazzo_spec.self_ and not arazzo_spec.self_.startswith(("http://", "https://")):
160163
self_path = (base_path / arazzo_spec.self_).resolve()
@@ -192,7 +195,7 @@ def resolve_source_descriptions(
192195

193196
def resolve_operation_path(
194197
operation_path: str,
195-
sources: dict[str, OpenAPI | Arazzo | dict[str, Any]],
198+
sources: dict[str, SourceDocument],
196199
) -> tuple[str | None, str | None, str | None, Operation | None]:
197200
"""Resolve an Arazzo `operationPath` expression to `(source_name, http_method, path, Operation)`.
198201
@@ -275,6 +278,12 @@ def parse_arazzo_json(spec_text: str, base_path: Path = Path(".")) -> Arazzo:
275278
276279
Returns:
277280
The validated `Arazzo` model instance.
281+
282+
Raises:
283+
SpecificationParseError: If `spec_text` is not valid JSON.
278284
"""
279-
spec_dict: dict[str, Any] = json.loads(spec_text)
285+
try:
286+
spec_dict: dict[str, Any] = json.loads(spec_text)
287+
except json.JSONDecodeError as err:
288+
raise SpecificationParseError(f"Invalid Arazzo JSON: {err}") from err
280289
return parse_arazzo_dict(spec_dict, base_path)

‎src/openapi_client/classes/emit.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
6+
57
import libcst as cst
68

79
from openapi_client.docstrings.emit import emit_class_docstring
8-
from openapi_client.functions.utils import get_annotation_for_schema
10+
from openapi_client.functions.utils import build_cst_module, get_annotation_for_schema
911
from openapi_client.models import Schema, SchemaOrReference
1012

1113

@@ -127,7 +129,7 @@ def emit_class(name: str, schema: Schema) -> cst.ClassDef:
127129
)
128130

129131

130-
def emit_classes(schemas: dict[str, SchemaOrReference]) -> list[cst.ClassDef]:
132+
def emit_classes(schemas: Mapping[str, SchemaOrReference]) -> list[cst.ClassDef]:
131133
"""Emit a list of Pydantic BaseModel classes from a dictionary of OpenAPI Schema objects.
132134
133135
Args:
@@ -144,7 +146,7 @@ def emit_classes(schemas: dict[str, SchemaOrReference]) -> list[cst.ClassDef]:
144146
return class_defs
145147

146148

147-
def emit_models_module(schemas: dict[str, SchemaOrReference]) -> str:
149+
def emit_models_module(schemas: Mapping[str, SchemaOrReference]) -> str:
148150
"""Emit a complete Python module containing Pydantic models.
149151
150152
Args:
@@ -199,5 +201,5 @@ def emit_models_module(schemas: dict[str, SchemaOrReference]) -> str:
199201
body.append(class_def)
200202
body.append(cst.EmptyLine())
201203

202-
module = cst.Module(body=body)
204+
module = build_cst_module(body)
203205
return module.code

‎src/openapi_client/cli.py‎

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
from __future__ import annotations
44

55
import argparse
6+
import json
67
import os
78
import sys
89
from importlib.metadata import PackageNotFoundError, version
910
from pathlib import Path
10-
from typing import Any
1111

1212
import libcst as cst
1313

1414
from openapi_client.arazzo.emit import emit_arazzo_json
1515
from openapi_client.arazzo.parse import parse_arazzo_json, resolve_source_descriptions
16+
from openapi_client.arazzo_models import Arazzo
1617
from openapi_client.classes.emit import emit_models_module
1718
from openapi_client.classes.parse import extract_classes_from_ast
1819
from openapi_client.cli_sdk.emit_arazzo import emit_arazzo_cli_code
@@ -46,6 +47,7 @@
4647
from openapi_client.servers.json_rpc import JSONRPCRequestHandler, serve_json_rpc
4748
from openapi_client.servers.mcp_stdio import run_mcp_server
4849
from openapi_client.sqlalchemy_cdd.emit import emit_sqlalchemy
50+
from openapi_client.sqlalchemy_cdd.parse import parse_sqlalchemy_cdd
4951
from openapi_client.sync import sync_dir
5052
from openapi_client.tests.emit import emit_tests
5153
from openapi_client.tests.emit_arazzo_tests import emit_arazzo_tests_code
@@ -55,6 +57,40 @@
5557
from openapi_client.workflows.emit import emit_workflows_code
5658
from openapi_client.workflows.parse import extract_arazzo_from_code
5759

60+
__all__ = [
61+
"JSONRPCRequestHandler",
62+
"apply_env_vars_to_parser",
63+
"emit_dao",
64+
"emit_seeder",
65+
"generate_docs_json",
66+
"generate_from_arazzo",
67+
"generate_from_openapi",
68+
"generate_to_arazzo",
69+
"generate_to_openapi",
70+
"get_version",
71+
"main",
72+
"run_mcp_server",
73+
"serve_json_rpc",
74+
"sync_dir",
75+
]
76+
77+
78+
def _resolve_output_dir(output_dir: str) -> Path:
79+
"""Resolve an output directory or file path to its target directory.
80+
81+
Args:
82+
output_dir: Destination directory or file path string.
83+
84+
Returns:
85+
The resolved directory `Path`.
86+
"""
87+
if not output_dir:
88+
output_dir = "."
89+
out_path = Path(output_dir)
90+
if out_path.suffix.lower() in (".py", ".json", ".yaml", ".yml"):
91+
return out_path.parent
92+
return out_path
93+
5894

5995
def get_version() -> str:
6096
"""Get the installed package version of the CLI.
@@ -86,6 +122,8 @@ def apply_env_vars_to_parser(
86122
action.default = val.lower() in ("true", "1", "yes")
87123
elif isinstance(action, argparse._StoreFalseAction):
88124
action.default = val.lower() not in ("true", "1", "yes")
125+
elif action.type in (int, float) and callable(action.type):
126+
action.default = action.type(val)
89127
else:
90128
action.default = val
91129
if isinstance(action, argparse._SubParsersAction):
@@ -117,14 +155,7 @@ def generate_from_openapi(
117155
mcp: If True, generate MCP server, SSE server, and adapter modules.
118156
arazzo_path: Optional path to a companion Arazzo 1.1.0 specification to bundle.
119157
"""
120-
if not output_dir:
121-
output_dir = "."
122-
out_path = Path(output_dir)
123-
if out_path.suffix:
124-
out_dir = out_path.parent
125-
else:
126-
out_dir = out_path
127-
158+
out_dir = _resolve_output_dir(output_dir)
128159
out_dir.mkdir(parents=True, exist_ok=True)
129160

130161
if input_path:
@@ -149,8 +180,7 @@ def generate_from_openapi(
149180
(src_dir / "client.py").write_text(
150181
generator.generate_code(), encoding="utf-8"
151182
)
152-
153-
if spec.components and getattr(spec.components, "schemas", None):
183+
if spec.components and spec.components.schemas:
154184
(src_dir / "models.py").write_text(
155185
emit_models_module(spec.components.schemas), encoding="utf-8"
156186
)
@@ -181,7 +211,7 @@ def generate_from_openapi(
181211
generator.generate_code(), encoding="utf-8"
182212
)
183213

184-
if spec.components and getattr(spec.components, "schemas", None):
214+
if spec.components and spec.components.schemas:
185215
(src_dir / "models.py").write_text(
186216
emit_models_module(spec.components.schemas), encoding="utf-8"
187217
)
@@ -278,13 +308,10 @@ def generate_from_arazzo(
278308
no_installable_package: If True, skip `pyproject.toml` generation.
279309
tests: If True, generate end-to-end workflow pytest suites.
280310
"""
281-
if not output_dir:
282-
output_dir = "."
283-
out_path = Path(output_dir)
284-
out_dir = out_path.parent if out_path.suffix else out_path
311+
out_dir = _resolve_output_dir(output_dir)
285312
out_dir.mkdir(parents=True, exist_ok=True)
286313

287-
specs: list[tuple[Path, Any]] = []
314+
specs: list[tuple[Path, Arazzo]] = []
288315
if input_path:
289316
spec_path = Path(input_path)
290317
specs.append(
@@ -298,7 +325,8 @@ def generate_from_arazzo(
298325
elif input_dir:
299326
for p in Path(input_dir).glob("*.json"):
300327
raw_text = p.read_text(encoding="utf-8")
301-
if '"arazzo"' in raw_text:
328+
raw_obj = json.loads(raw_text)
329+
if isinstance(raw_obj, dict) and "arazzo" in raw_obj:
302330
specs.append((p.parent, parse_arazzo_json(raw_text, p.parent)))
303331
else:
304332
print("Either --input or --input-dir is required.")
@@ -423,7 +451,7 @@ def generate_to_arazzo(input_path: str, output_path: str) -> None:
423451
print(f"Successfully extracted Arazzo spec to {out_path}")
424452

425453

426-
def generate_to_openapi(input_path: str, output_path: str) -> None:
454+
def generate_to_openapi(input_path: str | Path, output_path: str | Path) -> None:
427455
"""Extract an OpenAPI spec from a Python module or directory.
428456
429457
Args:
@@ -458,6 +486,12 @@ def generate_to_openapi(input_path: str, output_path: str) -> None:
458486
if not models_py.exists():
459487
models_py = in_path / "models.py"
460488

489+
models_dir = (
490+
in_path / "src" / "models"
491+
if (in_path / "src" / "models").is_dir()
492+
else in_path / "models"
493+
)
494+
461495
mock_py = in_path / "test" / "mock_server.py"
462496
if not mock_py.exists():
463497
mock_py = in_path / "mock_server.py"
@@ -475,9 +509,15 @@ def generate_to_openapi(input_path: str, output_path: str) -> None:
475509
extract_classes_from_ast(mod, spec)
476510
extract_functions_from_ast(mod, spec)
477511

478-
if models_py.exists():
512+
if models_py.is_file():
479513
mod = cst.parse_module(models_py.read_text(encoding="utf-8"))
480-
extract_classes_from_ast(mod, spec)
514+
parse_sqlalchemy_cdd(mod, spec)
515+
516+
if models_dir.is_dir():
517+
for sa_file in sorted(models_dir.glob("*.py")):
518+
if sa_file.name != "__init__.py":
519+
mod = cst.parse_module(sa_file.read_text(encoding="utf-8"))
520+
parse_sqlalchemy_cdd(mod, spec)
481521

482522
if mock_py.exists():
483523
mod = cst.parse_module(mock_py.read_text(encoding="utf-8"))
@@ -516,7 +556,6 @@ def generate_to_openapi(input_path: str, output_path: str) -> None:
516556

517557
def main() -> None:
518558
"""CLI entrypoint."""
519-
_ = JSONRPCRequestHandler
520559
parser = argparse.ArgumentParser(
521560
description="Compiler Driven Development (CDD) compiler and transpiler."
522561
)

0 commit comments

Comments
 (0)