api: add OpenAPI model generator

parent 7b9b66d1
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import keyword
import re
from pathlib import Path
from typing import Any
PYTHON_ALIASES = {"json", "try", "del"}
def field_name(name: str) -> str:
if name in PYTHON_ALIASES or keyword.iskeyword(name):
return f"{name}_"
return name
def enum_member(value: Any) -> str:
name = re.sub(r"\W+", "_", str(value)).strip("_") or "value"
if name[0].isdigit():
name = f"v_{name}"
if keyword.iskeyword(name):
name = f"{name}_"
return name
def py_type(schema: dict[str, Any]) -> str:
if "$ref" in schema:
return schema["$ref"].split("/")[-1]
if "anyOf" in schema:
parts: list[str] = []
nullable = False
for item in schema["anyOf"]:
item_type = py_type(item)
if item_type == "None":
nullable = True
elif item_type not in parts:
parts.append(item_type)
result = "Any" if not parts else " | ".join(parts)
if nullable:
result = f"{result} | None"
return result
schema_type = schema.get("type")
if schema_type == "string":
return "str"
if schema_type == "integer":
return "int"
if schema_type == "number":
return "float"
if schema_type == "boolean":
return "bool"
if schema_type == "null":
return "None"
if schema_type == "array":
return f"list[{py_type(schema.get('items', {'type': 'object'}))}]"
if schema_type == "object" or "additionalProperties" in schema:
additional = schema.get("additionalProperties")
if isinstance(additional, dict):
return f"dict[str, {py_type(additional)}]"
return "dict[str, Any]"
return "Any"
def default_expr(schema: dict[str, Any], required: bool) -> str | None:
if required:
return None
if "default" in schema:
return repr(schema["default"])
schema_type = schema.get("type")
if schema_type == "array":
return "Field(default_factory=list)"
if schema_type == "object" or "additionalProperties" in schema:
return "Field(default_factory=dict)"
return "None"
def render_models(openapi: dict[str, Any]) -> str:
schemas = openapi["components"]["schemas"]
lines = [
"from __future__ import annotations",
"",
"from enum import StrEnum",
"from typing import Any",
"",
"from pydantic import BaseModel, Field",
"",
"",
]
for name, schema in schemas.items():
if "enum" in schema:
lines.append(f"class {name}(StrEnum):")
for value in schema["enum"]:
lines.append(f" {enum_member(value)} = {value!r}")
lines.extend(["", ""])
continue
lines.append(f"class {name}(BaseModel):")
properties = schema.get("properties", {})
required = set(schema.get("required", []))
if any(field_name(prop_name) != prop_name for prop_name in properties):
lines.append(' model_config = {"populate_by_name": True}')
if not properties:
lines.append(" pass")
for prop_name, prop_schema in properties.items():
name_for_python = field_name(prop_name)
annotation = py_type(prop_schema)
default = default_expr(prop_schema, prop_name in required)
if name_for_python != prop_name:
if default is None:
value = f"Field(alias={prop_name!r})"
elif default.startswith("Field("):
value = f"{default[:-1]}, alias={prop_name!r})"
else:
value = f"Field({default}, alias={prop_name!r})"
lines.append(f" {name_for_python}: {annotation} = {value}")
elif default is None:
lines.append(f" {name_for_python}: {annotation}")
else:
lines.append(f" {name_for_python}: {annotation} = {default}")
lines.extend(["", ""])
return "\n".join(lines).rstrip() + "\n"
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate altrepo.api.models from an OpenAPI schema."
)
parser.add_argument(
"openapi",
nargs="?",
default="openapi.json",
type=Path,
help="Path to OpenAPI JSON schema. Default: openapi.json",
)
parser.add_argument(
"-o",
"--output",
type=Path,
default=Path("altrepo/api/models.py"),
help="Output Python file. Default: altrepo/api/models.py",
)
args = parser.parse_args()
openapi = json.loads(args.openapi.read_text(encoding="utf-8"))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(render_models(openapi), encoding="utf-8")
if __name__ == "__main__":
main()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment