standardize packaging, tooling, docs, CI, and licensing

This commit is contained in:
2026-09-10 18:49:04 +05:30
parent c95de5a6e9
commit 0efe68b052
25 changed files with 667 additions and 74 deletions

View File

@@ -1,9 +1,9 @@
from . import app as app
from . import binder as binder
from . import loader as loader
from . import client as client
from . import errors as errors
from . import codegen as codegen
from . import codegen_routes as codegen_routes
from . import errors as errors
from . import loader as loader
__all__ = ["app", "binder", "loader", "client", "errors", "codegen", "codegen_routes"]

View File

@@ -46,8 +46,10 @@ _env_pattern = re.compile(r"\{(\w+)\}")
def _resolve_env(value: str) -> str:
"""Replace {ENV_VAR} placeholders with values from os.environ."""
def _replace(m: re.Match) -> str:
return os.environ.get(m.group(1), m.group(0))
return _env_pattern.sub(_replace, value)

View File

@@ -1,5 +1,8 @@
from fastapi import FastAPI
from typing import Any
from fastapi import FastAPI
class OpenAPIFirstApp(FastAPI):
def __init__(self, *, openapi_path: str, routes_module: Any, **fastapi_kwargs: Any) -> None: ...
def __init__(
self, *, openapi_path: str, routes_module: Any, **fastapi_kwargs: Any
) -> None: ...

View File

@@ -85,8 +85,14 @@ def bind_routes(
paths = spec.get("paths", {})
security_deps = security_deps or {}
http_methods = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
for path, methods in paths.items():
for http_method, operation in methods.items():
if http_method.lower() not in http_methods or not isinstance(
operation, dict
):
continue
operation_id = operation.get("operationId")
if not operation_id:
@@ -102,7 +108,7 @@ def bind_routes(
path=path,
method=http_method,
operation_id=operation_id,
)
) from None
key = f"{http_method.upper()}:{path}"
deps = security_deps.get(key, [])

View File

@@ -1,4 +1,5 @@
from typing import Any, Dict
from typing import Any
from fastapi import FastAPI
def bind_routes(app: FastAPI, spec: Dict[str, Any], routes_module: Any) -> None: ...
def bind_routes(app: FastAPI, spec: dict[str, Any], routes_module: Any) -> None: ...

View File

@@ -11,9 +11,8 @@ bundled templates packaged with the library.
import argparse
import shutil
from pathlib import Path
from importlib import resources
from pathlib import Path
DEFAULT_TEMPLATE = "health_app"
@@ -52,14 +51,15 @@ def copy_template(template: str, target_dir: Path) -> None:
target_dir.mkdir(parents=True, exist_ok=True)
root = resources.files("openapi_first.templates")
src = root / template
if not src.exists():
if template not in available_templates():
raise FileNotFoundError(
f"Template '{template}' not found. "
f"Available templates: {', '.join(available_templates())}"
)
src = root / template
with resources.as_file(src) as path:
shutil.copytree(path, target_dir, dirs_exist_ok=True)
@@ -168,11 +168,11 @@ def main() -> None:
# Handle the case where someone uses the old CLI style (openapi-first template path)
# argparse with subparsers might not automatically handle this if positional args are passed
# but let's assume we want to encourage the new style.
# If no command was provided but positional args were, they might be for scaffolding
# This is a bit tricky with argparse subparsers.
# This is a bit tricky with argparse subparsers.
# For simplicity, let's just support the new explicit commands.
if args.command is None and not any(vars(args).values()):
parser.print_help()
return

View File

@@ -31,7 +31,8 @@ Notes:
- This module intentionally does NOT: Generate client code, validate request/response schemas, deserialize responses, retry requests, implement authentication helpers, or assume non-2xx responses are failures.
"""
from typing import Any, Callable, Dict, Optional
from collections.abc import Callable
from typing import Any
from urllib.parse import urljoin
import httpx
@@ -88,8 +89,8 @@ class OpenAPIClient:
def __init__(
self,
spec: dict[str, Any],
base_url: Optional[str] = None,
client: Optional[httpx.Client] = None,
base_url: str | None = None,
client: httpx.Client | None = None,
) -> None:
"""
Initialize the OpenAPI client.
@@ -110,7 +111,7 @@ class OpenAPIClient:
self.base_url = base_url or self._resolve_base_url(spec)
self.client = client or httpx.Client(base_url=self.base_url)
self._operations: Dict[str, Callable[..., httpx.Response]] = {}
self._operations: dict[str, Callable[..., httpx.Response]] = {}
self._build_operations()
# ------------------------------------------------------------------ #
@@ -123,7 +124,7 @@ class OpenAPIClient:
except KeyError:
raise AttributeError(f"No such operationId: {name}") from None
def operations(self) -> Dict[str, Callable[..., httpx.Response]]:
def operations(self) -> dict[str, Callable[..., httpx.Response]]:
return dict(self._operations)
# ------------------------------------------------------------------ #
@@ -149,7 +150,13 @@ class OpenAPIClient:
for path, path_item in paths.items():
for method, operation in path_item.items():
if method.lower() not in {
"get", "post", "put", "patch", "delete", "head", "options"
"get",
"post",
"put",
"patch",
"delete",
"head",
"options",
}:
continue
@@ -181,11 +188,11 @@ class OpenAPIClient:
def call(
*,
path_params: Optional[dict[str, Any]] = None,
query: Optional[dict[str, Any]] = None,
headers: Optional[dict[str, str]] = None,
body: Optional[Any] = None,
timeout: Optional[float] = None,
path_params: dict[str, Any] | None = None,
query: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
body: Any | None = None,
timeout: float | None = None,
) -> httpx.Response:
url = self._build_url(path, path_params or {})
@@ -228,9 +235,7 @@ class OpenAPIClient:
try:
formatted_path = path.format(**path_params)
except KeyError as exc:
raise OpenAPIClientError(
f"Missing path parameter: {exc.args[0]}"
) from exc
raise OpenAPIClientError(f"Missing path parameter: {exc.args[0]}") from exc
return urljoin(self.base_url, formatted_path.lstrip("/"))

View File

@@ -1,13 +1,21 @@
from typing import Any, Callable, Dict, Optional
from collections.abc import Callable
from typing import Any
import httpx
from .errors import OpenAPIFirstError
class OpenAPIClientError(OpenAPIFirstError): ...
class OpenAPIClient:
spec: Dict[str, Any]
spec: dict[str, Any]
base_url: str
client: httpx.Client
def __init__(self, spec: Dict[str, Any], base_url: Optional[str] = ..., client: Optional[httpx.Client] = ...) -> None: ...
def __init__(
self,
spec: dict[str, Any],
base_url: str | None = ...,
client: httpx.Client | None = ...,
) -> None: ...
def __getattr__(self, name: str) -> Callable[..., httpx.Response]: ...
def operations(self) -> Dict[str, Callable[..., httpx.Response]]: ...
def operations(self) -> dict[str, Callable[..., httpx.Response]]: ...

View File

@@ -9,6 +9,7 @@ definitions.
"""
from pathlib import Path
from datamodel_code_generator import (
InputFileType,
PythonVersion,

View File

@@ -66,7 +66,7 @@ def generate_routes(
output_dir.mkdir(parents=True, exist_ok=True)
# Group paths by resource (first non-param path segment)
resources: dict[str, list[tuple[str, str, dict]]] = {}
resources_map: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
paths = spec.get("paths", {})
for path, methods in paths.items():
@@ -74,18 +74,18 @@ def generate_routes(
if not segments:
continue
resource = segments[0]
if resource not in resources:
resources[resource] = []
if resource not in resources_map:
resources_map[resource] = []
for http_method, operation in methods.items():
if http_method.startswith("x-"):
continue
resources[resource].append((path, http_method, operation))
resources_map[resource].append((path, http_method, operation))
generated_files: list[Path] = []
for resource in sorted(resources):
operations = resources[resource]
for resource in sorted(resources_map):
operations = resources_map[resource]
_validate_operations(resource, operations)
file_path = output_dir / f"{resource}.py"
@@ -116,7 +116,9 @@ _TYPE_MAP: dict[str, str] = {
}
def _validate_operations(resource: str, operations: list[tuple[str, str, dict]]) -> None:
def _validate_operations(
resource: str, operations: list[tuple[str, str, dict[str, Any]]]
) -> None:
"""Ensure every operation has an operationId."""
for path, http_method, operation in operations:
if not operation.get("operationId"):
@@ -144,7 +146,7 @@ def _get_request_body_schema(operation: dict[str, Any]) -> str | None:
schema = media_info.get("schema", {})
ref = schema.get("$ref", "")
if ref:
return ref.rsplit("/", 1)[-1]
return str(ref.rsplit("/", 1)[-1])
return None
@@ -159,7 +161,7 @@ def _get_success_status(operation: dict[str, Any]) -> str:
return "200"
def _needs_any(operations: list[tuple[str, str, dict]]) -> bool:
def _needs_any(operations: list[tuple[str, str, dict[str, Any]]]) -> bool:
"""Check if any operation uses a type that requires `from typing import Any`."""
for _, _, op in operations:
for param in op.get("parameters", []):
@@ -176,7 +178,7 @@ def _needs_any(operations: list[tuple[str, str, dict]]) -> bool:
def _generate_resource_file(
resource: str,
operations: list[tuple[str, str, dict]],
operations: list[tuple[str, str, dict[str, Any]]],
spec_path: str,
use_models: bool,
models_module: str,
@@ -205,7 +207,9 @@ def _generate_resource_file(
if schema:
schemas_needed.add(schema)
if schemas_needed:
lines.append(f"from {models_module} import {', '.join(sorted(schemas_needed))}")
lines.append(
f"from {models_module} import {', '.join(sorted(schemas_needed))}"
)
lines.append("")
@@ -237,7 +241,6 @@ def _generate_handler(
schema: dict[str, Any] = param.get("schema", {})
param_type: str = _resolve_type(schema)
required: bool = param.get("required", False)
description: str = schema.get("description", schema.get("x-description", ""))
default_raw = schema.get("default")
if param_in == "path":
@@ -277,13 +280,15 @@ def _generate_handler(
# Document parameters
doc_params: list[tuple[str, str, str]] = []
for param in operation.get("parameters", []):
name: str = param.get("name", "")
param_in: str = param.get("in", "")
schema: dict[str, Any] = param.get("schema", {})
param_type: str = _resolve_type(schema)
description: str = param.get("description", schema.get("x-description", ""))
if param_in in ("path", "query"):
doc_params.append((name, param_type, description))
doc_name: str = param.get("name", "")
doc_in: str = param.get("in", "")
doc_schema: dict[str, Any] = param.get("schema", {})
doc_type: str = _resolve_type(doc_schema)
doc_description: str = param.get(
"description", doc_schema.get("x-description", "")
)
if doc_in in ("path", "query"):
doc_params.append((doc_name, doc_type, doc_description))
if doc_params:
lines.append("")

View File

@@ -18,6 +18,7 @@ Notes:
These errors should normally cause immediate application failure.
"""
class OpenAPIFirstError(Exception):
"""
Base exception for all OpenAPI-first enforcement errors.
@@ -31,6 +32,7 @@ class OpenAPIFirstError(Exception):
- All exceptions raised by the OpenAPI-first core should inherit
from this type.
"""
pass
@@ -70,9 +72,6 @@ class MissingOperationHandler(OpenAPIFirstError):
f"({method.upper()} {path})"
)
else:
message = (
f"Missing operationId for operation "
f"({method.upper()} {path})"
)
message = f"Missing operationId for operation " f"({method.upper()} {path})"
super().__init__(message)

View File

@@ -1,6 +1,6 @@
from typing import Optional
class OpenAPIFirstError(Exception): ...
class MissingOperationHandler(OpenAPIFirstError):
def __init__(self, *, path: str, method: str, operation_id: Optional[str] = ...) -> None: ...
def __init__(
self, *, path: str, method: str, operation_id: str | None = ...
) -> None: ...

View File

@@ -46,6 +46,7 @@ class OpenAPISpecLoadError(OpenAPIFirstError):
- This error indicates that the OpenAPI document is unreadable,
malformed, or violates the OpenAPI 3.x specification.
"""
pass

View File

@@ -1,8 +1,8 @@
from pathlib import Path
from typing import Any, Dict, Union
from typing import Any
from .errors import OpenAPIFirstError
class OpenAPISpecLoadError(OpenAPIFirstError): ...
def load_openapi(path: Union[str, Path]) -> Dict[str, Any]: ...
def load_openapi(path: str | Path) -> dict[str, Any]: ...

0
openapi_first/py.typed Normal file
View File

View File

@@ -8,10 +8,11 @@ FastAPI dependencies for token validation (e.g., Bearer JWT introspection).
import os
import re
from typing import Any, Callable
from collections.abc import Callable
from typing import Any
import httpx
from fastapi import Depends, Request, HTTPException
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
_env_pattern = re.compile(r"\{(\w+)\}")
@@ -19,14 +20,16 @@ _env_pattern = re.compile(r"\{(\w+)\}")
def _resolve_env(value: str) -> str:
"""Replace {ENV_VAR} placeholders with values from os.environ."""
def _replace(m: re.Match) -> str:
def _replace(m: re.Match[str]) -> str:
return os.environ.get(m.group(1), "")
return _env_pattern.sub(_replace, value)
def _resolve_scheme(scheme: dict) -> dict:
def _resolve_scheme(scheme: dict[str, Any]) -> dict[str, Any]:
"""Recursively resolve env vars in all string-valued fields of a scheme."""
resolved = {}
resolved: dict[str, Any] = {}
for key, value in scheme.items():
if isinstance(value, str):
resolved[key] = _resolve_env(value)
@@ -37,13 +40,13 @@ def _resolve_scheme(scheme: dict) -> dict:
return resolved
def parse_security_schemes(spec: dict) -> dict[str, dict]:
def parse_security_schemes(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Extract and resolve environment variables in security schemes."""
raw = spec.get("components", {}).get("securitySchemes", {})
return {name: _resolve_scheme(scheme) for name, scheme in raw.items()}
def _make_bearer_dependency(introspect_url: str | None) -> Callable:
def _make_bearer_dependency(introspect_url: str | None) -> Callable[..., Any]:
"""
Create a FastAPI dependency that validates a Bearer JWT.
@@ -76,7 +79,7 @@ def _make_bearer_dependency(introspect_url: str | None) -> Callable:
raise HTTPException(
status_code=503,
detail="Authentication service unavailable",
)
) from None
if resp.status_code != 200:
raise HTTPException(status_code=401, detail="Invalid or expired token")
@@ -92,7 +95,9 @@ def _make_bearer_dependency(introspect_url: str | None) -> Callable:
return _bearer_dep
def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
def _build_dependency(
scheme_name: str, scheme: dict[str, Any]
) -> Callable[..., Any] | None:
"""Return a FastAPI dependency callable for *scheme*, or ``None``."""
scheme_type = scheme.get("type")
@@ -106,8 +111,8 @@ def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
def make_security_dependencies(
spec: dict,
security_schemes: dict[str, dict],
spec: dict[str, Any],
security_schemes: dict[str, dict[str, Any]],
) -> dict[str, list[Any]]:
"""
Build a mapping of ``METHOD:/path`` → list of ``Depends(...)``.