diff --git a/.gitignore b/.gitignore index e1c52fb4c..4b5bf4459 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ # Environments .env +env .venv # mypy diff --git a/openapi_python_client/__init__.py b/openapi_python_client/__init__.py index 1f0f32d2b..77b113ce8 100644 --- a/openapi_python_client/__init__.py +++ b/openapi_python_client/__init__.py @@ -90,6 +90,7 @@ class Project: def __init__(self, *, openapi: GeneratorData) -> None: self.openapi: GeneratorData = openapi self.env: Environment = Environment(loader=PackageLoader(__package__), trim_blocks=True, lstrip_blocks=True) + self.env.filters['snake_case'] = utils.snake_case self.project_name: str = self.project_name_override or f"{utils.kebab_case(openapi.title).lower()}-client" self.project_dir: Path = Path.cwd() / self.project_name @@ -155,6 +156,10 @@ def _create_package(self) -> None: pytyped = self.package_dir / "py.typed" pytyped.write_text("# Marker file for PEP 561") + utils_template = self.env.get_template("utils.py") + utils_path = self.package_dir / "utils.py" + utils_path.write_text(utils_template.render()) + def _build_metadata(self) -> None: # Create a pyproject.toml file pyproject_template = self.env.get_template("pyproject.toml") @@ -196,6 +201,7 @@ def _build_models(self) -> None: model_template = self.env.get_template("model.pyi") for model in self.openapi.schemas.models.values(): module_path = models_dir / f"{model.reference.module_name}.py" + assert not module_path.is_file() module_path.write_text(model_template.render(model=model)) imports.append(import_string_from_reference(model.reference)) @@ -213,13 +219,13 @@ def _build_api(self) -> None: # Generate Client client_path = self.package_dir / "client.py" client_template = self.env.get_template("client.pyi") - client_path.write_text(client_template.render()) + client_path.write_text(client_template.render(package_name=self.package_name, all_collections=self.openapi.endpoint_collections_by_tag)) # Generate endpoints api_dir = self.package_dir / "api" api_dir.mkdir() api_init = api_dir / "__init__.py" - api_init.write_text('""" Contains synchronous methods for accessing the API """') + api_init.write_text('""" Contains synchronous methods for accessing the API """\n\n') async_api_dir = self.package_dir / "async_api" async_api_dir.mkdir() @@ -235,6 +241,11 @@ def _build_api(self) -> None: for tag, collection in self.openapi.endpoint_collections_by_tag.items(): tag = utils.snake_case(tag) module_path = api_dir / f"{tag}.py" + assert not module_path.is_file() module_path.write_text(endpoint_template.render(collection=collection)) async_module_path = async_api_dir / f"{tag}.py" async_module_path.write_text(async_endpoint_template.render(collection=collection)) + + for f in [api_init, async_api_init]: + with f.open('a') as add: + add.write(f'from . import {tag}\n') diff --git a/openapi_python_client/cli.py b/openapi_python_client/cli.py index e1c087b87..c2ed84064 100644 --- a/openapi_python_client/cli.py +++ b/openapi_python_client/cli.py @@ -21,6 +21,7 @@ def _process_config(path: Optional[pathlib.Path]) -> None: from .config import Config if not path: + Config.load_config() return try: diff --git a/openapi_python_client/config.py b/openapi_python_client/config.py index f0eb2214a..d80108c78 100644 --- a/openapi_python_client/config.py +++ b/openapi_python_client/config.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Dict, Optional @@ -24,6 +25,11 @@ def load_config(self) -> None: for class_name, class_data in self.class_overrides.items(): reference.class_overrides[class_name] = reference.Reference(**dict(class_data)) + if self.project_name_override is None: + self.project_name_override = os.getenv('PROJECT_NAME_OVERRIDE') + if self.package_name_override is None: + self.package_name_override = os.getenv('PACKAGE_NAME_OVERRIDE') + from openapi_python_client import Project Project.project_name_override = self.project_name_override diff --git a/openapi_python_client/parser/openapi.py b/openapi_python_client/parser/openapi.py index 1e9de52bb..e8af0ed0f 100644 --- a/openapi_python_client/parser/openapi.py +++ b/openapi_python_client/parser/openapi.py @@ -10,9 +10,9 @@ from .. import schema as oai from .. import utils from .errors import GeneratorError, ParseError, PropertyError -from .properties import EnumProperty, Property, property_from_data +from .properties import EnumProperty, Property, RefProperty, property_from_data from .reference import Reference -from .responses import ListRefResponse, RefResponse, Response, response_from_data +from .responses import ListRefResponse, RefResponse, Response, UnionResponse, response_from_data class ParameterLocation(str, Enum): @@ -38,7 +38,7 @@ class EndpointCollection: parse_errors: List[ParseError] = field(default_factory=list) @staticmethod - def from_data(*, data: Dict[str, oai.PathItem]) -> Dict[str, EndpointCollection]: + def from_data(*, data: Dict[str, oai.PathItem], base_responses: Any) -> Dict[str, EndpointCollection]: """ Parse the openapi paths data to get EndpointCollections by tag """ endpoints_by_tag: Dict[str, EndpointCollection] = {} @@ -51,7 +51,7 @@ def from_data(*, data: Dict[str, oai.PathItem]) -> Dict[str, EndpointCollection] continue tag = (operation.tags or ["default"])[0] collection = endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag)) - endpoint = Endpoint.from_data(data=operation, path=path, method=method, tag=tag) + endpoint = Endpoint.from_data(data=operation, path=path, method=method, tag=tag, base_responses=base_responses) if isinstance(endpoint, ParseError): endpoint.header = ( f"ERROR parsing {method.upper()} {path} within {tag}. Endpoint will not be generated." @@ -139,14 +139,29 @@ def _add_body(endpoint: Endpoint, data: oai.Operation) -> Union[ParseError, Endp return endpoint @staticmethod - def _add_responses(endpoint: Endpoint, data: oai.Responses) -> Union[Endpoint, ParseError]: + def _add_responses(endpoint: Endpoint, data: oai.Responses, base_responses: Any) -> Union[Endpoint, ParseError]: endpoint = deepcopy(endpoint) + + def process_response(response: Response): + if isinstance(response, (RefResponse, ListRefResponse)): + endpoint.relative_imports.add(import_string_from_reference(response.reference, prefix="..models")) + elif isinstance(response, UnionResponse): + for opt in response.options: + process_response(opt) + for code, response_data in data.items(): - response = response_from_data(status_code=int(code), data=response_data) + try: + code = int(code) + except ValueError: + assert ( + (len(code) == 3 and code.endswith('XX')) or + code == 'default' + ) + code = f'"{code}"' + response = response_from_data(status_code=code, data=response_data, base_responses=base_responses) if isinstance(response, ParseError): return ParseError(detail=f"cannot parse response of endpoint {endpoint.name}", data=response.data) - if isinstance(response, (RefResponse, ListRefResponse)): - endpoint.relative_imports.add(import_string_from_reference(response.reference, prefix="..models")) + process_response(response) endpoint.responses.append(response) return endpoint @@ -174,17 +189,19 @@ def _add_parameters(endpoint: Endpoint, data: oai.Operation) -> Union[Endpoint, return endpoint @staticmethod - def from_data(*, data: oai.Operation, path: str, method: str, tag: str) -> Union[Endpoint, ParseError]: + def from_data(*, data: oai.Operation, path: str, method: str, tag: str, base_responses: Any) -> Union[Endpoint, ParseError]: """ Construct an endpoint from the OpenAPI data """ if data.operationId is None: return ParseError(data=data, detail="Path operations with operationId are not yet supported") + name = data.operationId.split('_', 1)[1] + endpoint = Endpoint( path=path, method=method, description=utils.remove_string_escapes(data.description) if data.description else "", - name=data.operationId, + name=name, requires_security=bool(data.security), tag=tag, ) @@ -192,13 +209,14 @@ def from_data(*, data: oai.Operation, path: str, method: str, tag: str) -> Union result = Endpoint._add_parameters(endpoint, data) if isinstance(result, ParseError): return result - result = Endpoint._add_responses(result, data.responses) + result = Endpoint._add_responses(result, data.responses, base_responses=base_responses) if isinstance(result, ParseError): return result result = Endpoint._add_body(result, data) return result +ALL_MODELS: Dict[Reference, 'Model'] = {} @dataclass class Model: @@ -213,9 +231,12 @@ class Model: optional_properties: List[Property] description: str relative_imports: Set[str] + inherits: Optional[Reference] + is_error: bool + is_union: bool = False @staticmethod - def from_data(*, data: oai.Schema, name: str) -> Union[Model, ParseError]: + def from_data(*, data: oai.Schema, name: str, ref: Reference = None) -> Union[Model, ParseError]: """ A single Model from its OAI data Args: @@ -228,9 +249,23 @@ def from_data(*, data: oai.Schema, name: str) -> Union[Model, ParseError]: optional_properties: List[Property] = [] relative_imports: Set[str] = set() - ref = Reference.from_ref(data.title or name) + ref = ref or Reference.from_ref(data.title or name) + + inherits = None + props = data.properties + if not props and data.allOf: + assert len(data.allOf) == 2 + assert isinstance(data.allOf[0], oai.Reference) + assert isinstance(data.allOf[1], oai.Schema) + inherits = Reference.from_ref(ref=data.allOf[0].ref) + relative_imports.add(f"from .{inherits.module_name} import {inherits.class_name}") + props = data.allOf[1].properties + elif not props: + props = {} + # raise AssertionError('Found empty property!') + - for key, value in (data.properties or {}).items(): + for key, value in props.items(): required = key in required_set p = property_from_data(name=key, required=required, data=value) if isinstance(p, ParseError): @@ -239,6 +274,7 @@ def from_data(*, data: oai.Schema, name: str) -> Union[Model, ParseError]: required_properties.append(p) else: optional_properties.append(p) + relative_imports.update(p.get_imports(prefix="")) model = Model( @@ -247,9 +283,39 @@ def from_data(*, data: oai.Schema, name: str) -> Union[Model, ParseError]: optional_properties=optional_properties, relative_imports=relative_imports, description=data.description or "", + inherits=inherits, + is_error=getattr(data, 'x-is-error', False), ) + ALL_MODELS[ref] = model return model +@dataclass +class MyUnion: + reference: Reference + joins: List[Model] + relative_imports: t.List[str] + is_union: bool = True + + @staticmethod + def from_data(*, data: oai.Schema, name: str) -> Union[MyUnion]: + ref = Reference.from_ref(data.title or name) + name = data.title or name + + model = MyUnion( + reference=ref, + joins=[ + Model.from_data( + data=opt, + name=f'{name}_{idx + 1}', + ref=Reference( + class_name=f'{ref.class_name}{idx + 1}', + module_name=ref.module_name, + ), + ) for idx, opt in enumerate(data.anyOf)], + relative_imports=[] + ) + ALL_MODELS[ref] = model + return model @dataclass class Schemas: @@ -276,7 +342,10 @@ def build(*, schemas: Dict[str, Union[oai.Reference, oai.Schema]]) -> Schemas: nullable=data.nullable, ) continue - s = Model.from_data(data=data, name=name) + if data.anyOf: + s = MyUnion.from_data(data=data, name=name) + else: + s = Model.from_data(data=data, name=name) if isinstance(s, ParseError): result.errors.append(s) else: @@ -306,7 +375,7 @@ def from_dict(d: Dict[str, Dict[str, Any]]) -> Union[GeneratorData, GeneratorErr schemas = Schemas() else: schemas = Schemas.build(schemas=openapi.components.schemas) - endpoint_collections_by_tag = EndpointCollection.from_data(data=openapi.paths) + endpoint_collections_by_tag = EndpointCollection.from_data(data=openapi.paths, base_responses=openapi.components.responses) enums = EnumProperty.get_all_enums() return GeneratorData( diff --git a/openapi_python_client/parser/properties.py b/openapi_python_client/parser/properties.py index 4e6951bce..e6bbf481e 100644 --- a/openapi_python_client/parser/properties.py +++ b/openapi_python_client/parser/properties.py @@ -77,9 +77,9 @@ def to_string(self) -> str: default = None if default is not None: - return f"{self.python_name}: {self.get_type_string()} = {self.default}" + return f"{self.python_name}: '{self.get_type_string()}' = {self.default}" else: - return f"{self.python_name}: {self.get_type_string()}" + return f"{self.python_name}: '{self.get_type_string()}'" @dataclass @@ -290,6 +290,42 @@ def _validate_default(self, default: Any) -> Any: _existing_enums: Dict[str, EnumProperty] = {} +@dataclass +class LiteralProperty(Property): + """ A property that should use an literal""" + + value: Union[str, bool, int] + + template: ClassVar[str] = "literal_property.pyi" + + def get_type_string(self, no_optional: bool = False) -> str: + """ Get a string representation of type that should be used when declaring this property """ + if isinstance(self.value, str): + replaced_value = self.value.replace('"', '\\"') + value_as_string = f'"{replaced_value}"' + else: + value_as_string = self.value + if no_optional or (self.required and not self.nullable): + return f'Literal[{value_as_string}]' + return f'Literal[{value_as_string}, None]' + + @property + def repr_value(self) -> str: + return repr(self.value) + + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere + + Args: + prefix: A prefix to put before any relative (local) module names. + """ + return { + 'from typing_extensions import Literal', + *super().get_imports(prefix=prefix), + } + + @dataclass class EnumProperty(Property): """ A property that should use an enum """ @@ -465,6 +501,15 @@ def _property_from_data( name=name, required=required, reference=Reference.from_ref(data.ref), default=None, nullable=False, ) if data.enum: + if len(data.enum) == 1 and isinstance(data.enum[0], (str, bool, int)): + return LiteralProperty( + name=name, + value=data.enum[0], + default=data.default, + nullable=data.nullable, + required=required, + ) + return EnumProperty( name=name, required=required, @@ -483,7 +528,10 @@ def _property_from_data( return UnionProperty( name=name, required=required, default=data.default, inner_properties=sub_properties, nullable=data.nullable, ) + if data.allOf and len(data.allOf) == 1: + return _property_from_data(name, required, data.allOf[0]) if not data.type: + breakpoint() return PropertyError(data=data, detail="Schemas must either have one of enum, anyOf, or type defined.") if data.type == "string": return _string_based_property(name=name, required=required, data=data) diff --git a/openapi_python_client/parser/reference.py b/openapi_python_client/parser/reference.py index 7283201e5..3cd54fefa 100644 --- a/openapi_python_client/parser/reference.py +++ b/openapi_python_client/parser/reference.py @@ -3,20 +3,28 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Dict +from typing import TYPE_CHECKING, Dict from .. import utils +if TYPE_CHECKING: + from .openapi import Model + class_overrides: Dict[str, Reference] = {} -@dataclass + +@dataclass(frozen=True) class Reference: """ A reference to a class which will be in models """ class_name: str module_name: str + def lookup(self) -> 'Model': + from .openapi import ALL_MODELS + return ALL_MODELS[self] + @staticmethod def from_ref(ref: str) -> Reference: """ Get a Reference from the openapi #/schemas/blahblah string """ diff --git a/openapi_python_client/parser/responses.py b/openapi_python_client/parser/responses.py index d888bf3fa..e4fd46fc6 100644 --- a/openapi_python_client/parser/responses.py +++ b/openapi_python_client/parser/responses.py @@ -1,5 +1,5 @@ from dataclasses import InitVar, dataclass, field -from typing import Union +from typing import Any, List, Union from .. import schema as oai from .errors import ParseError @@ -10,7 +10,11 @@ class Response: """ Describes a single response for an endpoint """ - status_code: int + status_code: Union[int, str] + + @property + def is_error(self) -> bool: + return False def return_string(self) -> str: """ How this Response should be represented as a return type """ @@ -21,6 +25,17 @@ def constructor(self) -> str: return "None" + def __gt__(self, other: Any) -> bool: + if isinstance(self, RefResponse): + return True + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(self, RefResponse): + return False + return True + + @dataclass class ListRefResponse(Response): """ Response is a list of some ref schema """ @@ -42,6 +57,10 @@ class RefResponse(Response): reference: Reference + @property + def is_error(self) -> bool: + return self.reference.lookup().is_error + def return_string(self) -> str: """ How this Response should be represented as a return type """ return self.reference.class_name @@ -50,6 +69,11 @@ def constructor(self) -> str: """ How the return value of this response should be constructed """ return f"{self.reference.class_name}.from_dict(cast(Dict[str, Any], response.json()))" + def __lt__(self, other: Any) -> bool: + if not isinstance(other, RefResponse): + return NotImplemented + return len(self.reference.class_name) < len(other.reference.class_name) + @dataclass class ListBasicResponse(Response): @@ -70,6 +94,21 @@ def constructor(self) -> str: return f"[{self.python_type}(item) for item in cast(List[{self.python_type}], response.json())]" +@dataclass +class UnionResponse(Response): + + options: List[Response] + + def __post_init__(self) -> None: + self.options = sorted(self.options, reverse=True) + + def return_string(self) -> str: + return f'Union[{", ".join(opt.return_string() for opt in self.options)}]' + + def constructor(self) -> str: + return f'try_any([{", ".join("lambda: " + opt.constructor() for opt in self.options)}])' + + @dataclass class BasicResponse(Response): """ Response is a basic type """ @@ -89,6 +128,19 @@ def constructor(self) -> str: return f"{self.python_type}(response.text)" +@dataclass +class ObjectResponse(Response): + """ Response is a basic type """ + + def return_string(self) -> str: + """ How this Response should be represented as a return type """ + return 'Dict[str, Any]' + + def constructor(self) -> str: + """ How the return value of this response should be constructed """ + return 'response.json()' + + @dataclass class BytesResponse(Response): """ Response is a basic type """ @@ -112,10 +164,13 @@ def constructor(self) -> str: } -def response_from_data(*, status_code: int, data: Union[oai.Response, oai.Reference]) -> Union[Response, ParseError]: +def response_from_data(*, status_code: int, data: Union[oai.Response, oai.Reference], base_responses: Any) -> Union[Response, ParseError]: """ Generate a Response from the OpenAPI dictionary representation of it """ - if isinstance(data, oai.Reference) or data.content is None: + if isinstance(data, oai.Reference): + data = base_responses[Reference.from_ref(data.ref).class_name] + + if data.content is None: return Response(status_code=status_code) content = data.content @@ -133,7 +188,17 @@ def response_from_data(*, status_code: int, data: Union[oai.Response, oai.Refere if isinstance(schema_data, oai.Reference): return RefResponse(status_code=status_code, reference=Reference.from_ref(schema_data.ref),) response_type = schema_data.type + if schema_data.anyOf: + options = [] + for option in schema_data.anyOf: + if isinstance(option, oai.Reference): + options.append(RefResponse(status_code=status_code, reference=Reference.from_ref(option.ref))) + elif getattr(option, 'type', None) == 'object': + options.append(ObjectResponse(status_code=status_code)) + return UnionResponse(status_code, options) + if response_type is None: + breakpoint() return Response(status_code=status_code) if response_type == "array" and isinstance(schema_data.items, oai.Reference): return ListRefResponse(status_code=status_code, reference=Reference.from_ref(schema_data.items.ref),) diff --git a/openapi_python_client/schema/schema.py b/openapi_python_client/schema/schema.py index 5941d79f5..d42a53d9f 100644 --- a/openapi_python_client/schema/schema.py +++ b/openapi_python_client/schema/schema.py @@ -473,6 +473,7 @@ class Schema(BaseModel): class Config: allow_population_by_field_name = True + extra = 'allow' schema_extra = { "examples": [ {"type": "string", "format": "email"}, diff --git a/openapi_python_client/templates/README.md b/openapi_python_client/templates/README.md index 5272e3fd1..0b9ace051 100644 --- a/openapi_python_client/templates/README.md +++ b/openapi_python_client/templates/README.md @@ -5,57 +5,30 @@ First, create a client: ```python -from {{ package_name }} import Client - -client = Client(base_url="https://api.example.com") -``` - -If the endpoints you're going to hit require authentication, use `AuthenticatedClient` instead: - -```python -from {{ package_name }} import AuthenticatedClient - -client = AuthenticatedClient(base_url="https://api.example.com", token="SuperSecretToken") +from {{ package_name }} import setup + +# Don't store this in plaintext in your code! +client = setup( + username="my_username", + password="my_password", + host="https://app.codegra.de", +) ``` Now call your endpoint and use your models: ```python -from {{ package_name }}.models import MyDataModel -from {{ package_name }}.api.my_tag import get_my_data_model - -my_data: MyDataModel = get_my_data_model(client=client) -``` - -Or do the same thing with an async version: - -```python -from {{ package_name }}.models import MyDataModel -from {{ package_name }}.async_api.my_tag import get_my_data_model - -my_data: MyDataModel = await get_my_data_model(client=client) +from {{ package_name }}.models import PatchCourseData + +courses = client.course.get_all() +for course in courses: + client.course.patch( + PatchCourseData(name=course.name + ' (NEW)'), + course_id=course.id, + ) ``` -Things to know: -1. Every path/method combo becomes a Python function with type annotations. -1. All path/query params, and bodies become method arguments. -1. If your endpoint had any tags on it, the first tag will be used as a module name for the function (my_tag above) -1. Any endpoint which did not have a tag will be in `{{ package_name }}.api.default` -1. If the API returns a response code that was not declared in the OpenAPI document, a - `{{ package_name }}.api.errors.ApiResponseError` wil be raised - with the `response` attribute set to the `httpx.Response` that was received. - - -## Building / publishing this Client -This project uses [Poetry](https://python-poetry.org/) to manage dependencies and packaging. Here are the basics: -1. Update the metadata in pyproject.toml (e.g. authors, version) -1. If you're using a private repository, configure it with Poetry - 1. `poetry config repositories. ` - 1. `poetry config http-basic. ` -1. Publish the client with `poetry publish --build -r ` or, if for public PyPI, just `poetry publish --build` - -If you want to install this client into another project without publishing it (e.g. for development) then: -1. If that project **is using Poetry**, you can simply do `poetry add ` from that project -1. If that project is not using Poetry: - 1. Build a wheel with `poetry build -f wheel` - 1. Install that wheel from the other project `pip install ` +## Installing +This project uses [Poetry](https://python-poetry.org/) to manage dependencies +and packaging. Currently you will need to install it using poetry, but in the +future we will start releasing this package on pypi. diff --git a/openapi_python_client/templates/async_endpoint_module.pyi b/openapi_python_client/templates/async_endpoint_module.pyi index a781b8f29..1a76a5881 100644 --- a/openapi_python_client/templates/async_endpoint_module.pyi +++ b/openapi_python_client/templates/async_endpoint_module.pyi @@ -1,10 +1,13 @@ from dataclasses import asdict -from typing import Any, Dict, List, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast import httpx -from ..client import AuthenticatedClient, Client from ..errors import ApiResponseError +from ..utils import response_code_matches + +if TYPE_CHECKING: + from ..client import AuthenticatedClient, Client {% for relative in collection.relative_imports %} {{ relative }} @@ -17,9 +20,9 @@ async def {{ endpoint.name | snakecase }}( *, {# Proper client based on whether or not the endpoint requires authentication #} {% if endpoint.requires_security %} - client: AuthenticatedClient, + client: 'AuthenticatedClient', {% else %} - client: Client, + client: 'Client', {% endif %} {# path parameters #} {% for parameter in endpoint.path_parameters %} @@ -78,7 +81,7 @@ async def {{ endpoint.name | snakecase }}( ) {% for response in endpoint.responses %} - if response.status_code == {{ response.status_code }}: + if response_code_matches(response.status_code, {{ response.status_code }}): return {{ response.constructor() }} {% endfor %} else: diff --git a/openapi_python_client/templates/client.pyi b/openapi_python_client/templates/client.pyi index 59a50d2b5..d4374847e 100644 --- a/openapi_python_client/templates/client.pyi +++ b/openapi_python_client/templates/client.pyi @@ -1,5 +1,18 @@ from dataclasses import dataclass -from typing import Dict, Union +from functools import partial, wraps +from typing import Any, Dict, Union + +{% for tag, collection in all_collections.items() %} +{% set snake_tag = tag | snake_case %} +class _{{ tag }}Module: + def __init__(self, client: 'Client') -> None: + import {{ package_name }}.api.{{ snake_tag }} as {{ snake_tag }} + + {% for endpoint in collection.endpoints %} + self.{{ endpoint.name }} = wraps({{ snake_tag}}.{{ endpoint.name }})(partial({{ snake_tag }}.{{ endpoint.name }}, client=client)) + {% endfor %} +{% endfor %} + @dataclass class Client: @@ -11,6 +24,14 @@ class Client: """ Get headers to be used in all endpoints """ return {} + {% for tag in all_collections.keys() %} + {% set snake_tag = tag | snake_case %} + @property + def {{ snake_tag }}(self) -> _{{ tag }}Module: + return _{{ tag }}Module(self) + {% endfor %} + + @dataclass class AuthenticatedClient(Client): """ A Client which has been authenticated for use on secured endpoints """ diff --git a/openapi_python_client/templates/endpoint_macros.pyi b/openapi_python_client/templates/endpoint_macros.pyi index 6eecab45e..ce71d1d63 100644 --- a/openapi_python_client/templates/endpoint_macros.pyi +++ b/openapi_python_client/templates/endpoint_macros.pyi @@ -12,36 +12,38 @@ if {{ parameter.python_name }} is not None: {% endmacro %} {% macro query_params(endpoint) %} -{% if endpoint.query_parameters %} - {% for property in endpoint.query_parameters %} - {% set destination = "json_" + property.python_name %} - {% if property.template %} - {% from "property_templates/" + property.template import transform %} +{% for property in endpoint.query_parameters %} + {% set destination = "json_" + property.python_name %} + {% if property.template %} + {% from "property_templates/" + property.template import transform %} {{ transform(property, property.python_name, destination) }} - {% endif %} - {% endfor %} + {% endif %} +{% endfor %} params: Dict[str, Any] = { - {% for property in endpoint.query_parameters %} - {% if property.required %} - {% if property.template %} + 'no_course_in_assignment': 'true', + 'no_role_name': 'true', + 'no_assignment_in_case': 'true', + 'extended': 'true', +{% for property in endpoint.query_parameters %} + {% if property.required %} + {% if property.template %} "{{ property.name }}": {{ "json_" + property.python_name }}, - {% else %} + {% else %} "{{ property.name }}": {{ property.python_name }}, - {% endif %} {% endif %} - {% endfor %} + {% endif %} +{% endfor %} } - {% for property in endpoint.query_parameters %} - {% if not property.required %} +{% for property in endpoint.query_parameters %} + {% if not property.required %} if {{ property.python_name }} is not None: - {% if property.template %} + {% if property.template %} params["{{ property.name }}"] = {{ "json_" + property.python_name }} - {% else %} + {% else %} params["{{ property.name }}"] = {{ property.python_name }} - {% endif %} {% endif %} - {% endfor %} -{% endif %} + {% endif %} +{% endfor %} {% endmacro %} {% macro json_body(endpoint) %} @@ -61,7 +63,9 @@ if {{ property.python_name }} is not None: {% else %} ) -> Union[ {% for response in endpoint.responses %} + {% if not response.is_error %} {{ response.return_string() }}{{ "," if not loop.last }} + {% endif %} {% endfor %} ]: {% endif %} diff --git a/openapi_python_client/templates/endpoint_module.pyi b/openapi_python_client/templates/endpoint_module.pyi index ba913f647..c59d46b95 100644 --- a/openapi_python_client/templates/endpoint_module.pyi +++ b/openapi_python_client/templates/endpoint_module.pyi @@ -1,10 +1,13 @@ from dataclasses import asdict -from typing import Any, Dict, List, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Union, cast import httpx -from ..client import AuthenticatedClient, Client from ..errors import ApiResponseError +from ..utils import maybe_to_dict, response_code_matches, to_multipart, try_any + +if TYPE_CHECKING: + from ..client import AuthenticatedClient, Client {% for relative in collection.relative_imports %} {{ relative }} @@ -14,12 +17,19 @@ from ..errors import ApiResponseError {% from "endpoint_macros.pyi" import header_params, query_params, json_body, return_type %} def {{ endpoint.name | snakecase }}( + {% if endpoint.json_body %} + json_body: {{ endpoint.json_body.get_type_string() }}, + {% endif %} + {# Multipart data if any #} + {% if endpoint.multipart_body_reference %} + multipart_data: {{ endpoint.multipart_body_reference.class_name }}, + {% endif %} *, {# Proper client based on whether or not the endpoint requires authentication #} {% if endpoint.requires_security %} - client: AuthenticatedClient, + client: 'AuthenticatedClient', {% else %} - client: Client, + client: 'Client', {% endif %} {# path parameters #} {% for parameter in endpoint.path_parameters %} @@ -29,14 +39,6 @@ def {{ endpoint.name | snakecase }}( {% if endpoint.form_body_reference %} form_data: {{ endpoint.form_body_reference.class_name }}, {% endif %} - {# Multipart data if any #} - {% if endpoint.multipart_body_reference %} - multipart_data: {{ endpoint.multipart_body_reference.class_name }}, - {% endif %} - {# JSON body if any #} - {% if endpoint.json_body %} - json_body: {{ endpoint.json_body.get_type_string() }}, - {% endif %} {# query parameters #} {% for parameter in endpoint.query_parameters %} {{ parameter.to_string() }}, @@ -44,8 +46,9 @@ def {{ endpoint.name | snakecase }}( {% for parameter in endpoint.header_parameters %} {{ parameter.to_string() }}, {% endfor %} + extra_parameters: Mapping[str, str] = None, {{ return_type(endpoint) }} - """ {{ endpoint.description }} """ + """{{ endpoint.description }}""" url = "{}{{ endpoint.path }}".format( client.base_url {%- for parameter in endpoint.path_parameters -%} @@ -57,30 +60,33 @@ def {{ endpoint.name | snakecase }}( {{ header_params(endpoint) | indent(4) }} {{ query_params(endpoint) | indent(4) }} + if extra_parameters: + params.update(extra_parameters) {{ json_body(endpoint) | indent(4) }} - response = httpx.{{ endpoint.method }}( url=url, headers=headers, {% if endpoint.form_body_reference %} data=asdict(form_data), {% endif %} - {% if endpoint.multipart_body_reference %} - files=multipart_data.to_dict(), + {% if endpoint.multipart_body_reference %} + files=to_multipart(multipart_data.to_dict()), {% endif %} {% if endpoint.json_body %} json={{ "json_" + endpoint.json_body.python_name }}, {% endif %} - {% if endpoint.query_parameters %} params=params, - {% endif %} ) {% for response in endpoint.responses %} - if response.status_code == {{ response.status_code }}: + if response_code_matches(response.status_code, {{ response.status_code }}): + {% if response.is_error %} + raise {{ response.constructor() }} + {% else %} return {{ response.constructor() }} + {% endif %} {% endfor %} else: raise ApiResponseError(response=response) diff --git a/openapi_python_client/templates/errors.pyi b/openapi_python_client/templates/errors.pyi index b1f2059be..72d3a0bbc 100644 --- a/openapi_python_client/templates/errors.pyi +++ b/openapi_python_client/templates/errors.pyi @@ -1,8 +1,16 @@ from httpx import Response + class ApiResponseError(Exception): """ An exception raised when an unknown response occurs """ def __init__(self, *, response: Response): super().__init__() self.response: Response = response + try: + self.json_data = response.json() + except: + self.json_data = None + + def __str__(self) -> str: + return f'{super().__str__()}: {self.response!r} ({self.json_data})' diff --git a/openapi_python_client/templates/model.pyi b/openapi_python_client/templates/model.pyi index 34da14d96..05795ee25 100644 --- a/openapi_python_client/templates/model.pyi +++ b/openapi_python_client/templates/model.pyi @@ -1,21 +1,48 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Dict +import json +from dataclasses import asdict, dataclass +from io import StringIO +from typing import Any, Dict, Optional + +from ..utils import maybe_to_dict +from .types import File {% for relative in model.relative_imports %} +{% if relative != "from ." + model.reference.module_name + " import " + model.reference.class_name %} {{ relative }} +{% endif %} {% endfor %} - +{% macro make_model(model) %} @dataclass -class {{ model.reference.class_name }}: - """ {{ model.description }} """ +class {{ model.reference.class_name }}{% if model.inherits -%}( + {{ model.inherits.class_name }} +) +{%- elif model.is_error %} +( + Exception +) +{%- endif -%}: + """{{ model.description }}""" {% for property in model.required_properties + model.optional_properties %} {{ property.to_string() }} {% endfor %} + raw_data: Optional[Dict[str, Any]] = None + + {% if model.is_error %} + def __str__(self) -> str: + return repr(self) + {% endif %} + def to_dict(self) -> Dict[str, Any]: + {% if model.inherits %} + res = super().to_dict() + {% else %} + res: Dict[str, Any] = {} + {% endif %} + {% for property in model.required_properties + model.optional_properties %} {% if property.template %} {% from "property_templates/" + property.template import transform %} @@ -23,16 +50,24 @@ class {{ model.reference.class_name }}: {% else %} {{ property.python_name }} = self.{{ property.python_name }} {% endif %} + {% if property.required %} + res["{{ property.name }}"] = {{ property.python_name }} + {% else %} + if self.{{ property.python_name }} is not None: + res["{{ property.name }}"] = {{ property.python_name }} + {% endif %} {% endfor %} - return { - {% for property in model.required_properties + model.optional_properties %} - "{{ property.name }}": {{ property.python_name }}, - {% endfor %} - } + return res @staticmethod def from_dict(d: Dict[str, Any]) -> {{ model.reference.class_name }}: +{% if model.inherits %} + base = asdict({{ model.inherits.class_name }}.from_dict(d)) + base.pop('raw_data') +{% else %} + base = {} +{% endif %} {% for property in model.required_properties + model.optional_properties %} {% if property.required %} {% set property_source = 'd["' + property.name + '"]' %} @@ -48,7 +83,23 @@ class {{ model.reference.class_name }}: {% endfor %} return {{ model.reference.class_name }}( + **base, {% for property in model.required_properties + model.optional_properties %} {{ property.python_name }}={{ property.python_name }}, {% endfor %} + raw_data=d, ) +{% endmacro %} + +{% if model.is_union %} +from typing import Union + +{% for submodel in model.joins %} +{{ make_model(submodel) }} +{% endfor %} +{{ model.reference.class_name }} = Union[{% for submodel in model.joins -%} + {{ submodel.reference.class_name }}, +{%- endfor %}] +{% else %} +{{ make_model(model) }} +{% endif %} diff --git a/openapi_python_client/templates/package_init.pyi b/openapi_python_client/templates/package_init.pyi index 917cd7dde..6c400fbaf 100644 --- a/openapi_python_client/templates/package_init.pyi +++ b/openapi_python_client/templates/package_init.pyi @@ -1,2 +1,19 @@ """ {{ description }} """ +import typing as t + from .client import AuthenticatedClient, Client +from .models.base_error import BaseError + + +def setup_from_token(token: str, host: str) -> AuthenticatedClient: + return AuthenticatedClient(host, token) + + +def setup(username: str, password: str, host: str) -> t.Union[AuthenticatedClient, BaseError]: + from .models.login_user_data import LoginUserData_1 as _LoginData + client = Client(host) + data = _LoginData(username=username, password=password) + res = client.user.login(client=client, json_body=data) + if isinstance(res, BaseError): + return BaseError + return AuthenticatedClient(host, res.access_token) diff --git a/openapi_python_client/templates/property_templates/datetime_property.pyi b/openapi_python_client/templates/property_templates/datetime_property.pyi index 5b55f9c5d..a8dcc9027 100644 --- a/openapi_python_client/templates/property_templates/datetime_property.pyi +++ b/openapi_python_client/templates/property_templates/datetime_property.pyi @@ -1,5 +1,5 @@ {% macro construct(property, source) %} -{% if property.required %} +{% if property.required and not property.nullable %} {{ property.python_name }} = datetime.datetime.fromisoformat({{ source }}) {% else %} {{ property.python_name }} = None @@ -9,7 +9,7 @@ if {{ source }} is not None: {% endmacro %} {% macro transform(property, source, destination) %} -{% if property.required %} +{% if property.required and not property.nullable %} {{ destination }} = {{ source }}.isoformat() {% else %} {{ destination }} = {{ source }}.isoformat() if {{ source }} else None diff --git a/openapi_python_client/templates/property_templates/literal_property.pyi b/openapi_python_client/templates/property_templates/literal_property.pyi new file mode 100644 index 000000000..e2fb4bf5b --- /dev/null +++ b/openapi_python_client/templates/property_templates/literal_property.pyi @@ -0,0 +1,9 @@ +{% macro construct(property, source) %} +if {{ source }} != {{ property.repr_value }}: + raise ValueError('{{ "Wrong value for " + property.python_name + ": "}}' + {{ source }}) +{{ property.python_name }} = {{ source }} +{% endmacro %} + +{% macro transform(property, source, destination) %} +{{ destination }} = {{ source }} +{% endmacro %} diff --git a/openapi_python_client/templates/property_templates/ref_property.pyi b/openapi_python_client/templates/property_templates/ref_property.pyi index c38a5199c..334a11cce 100644 --- a/openapi_python_client/templates/property_templates/ref_property.pyi +++ b/openapi_python_client/templates/property_templates/ref_property.pyi @@ -1,17 +1,38 @@ +{% macro _construct(property, source) %} +{% set model = property.reference.lookup() %} +{% if model.is_union %} +from . import {{ model.joins[0].reference.module_name }} + +err = None +for opt in [{% for submodel in model.joins %}{{ submodel.reference.module_name }}.{{ submodel.reference.class_name }}, {% endfor %}]: + try: + {{ property.python_name }} = opt.from_dict(cast(Dict[str, Any], {{ source }})) + except Exception as exc: + err = exc + else: + break +else: + raise err +del err +{% else %} +{{ property.python_name }} = {{ property.reference.class_name }}.from_dict(cast(Dict[str, Any], {{ source }})) +{% endif %} +{% endmacro %} + {% macro construct(property, source) %} {% if property.required %} -{{ property.python_name }} = {{ property.reference.class_name }}.from_dict({{ source }}) +{{ _construct(property, source) }} {% else %} {{ property.python_name }} = None if {{ source }} is not None: - {{ property.python_name }} = {{ property.reference.class_name }}.from_dict(cast(Dict[str, Any], {{ source }})) + {{ _construct(property, source) | indent(4) }} {% endif %} {% endmacro %} {% macro transform(property, source, destination) %} {% if property.required %} -{{ destination }} = {{ source }}.to_dict() +{{ destination }} = maybe_to_dict({{ source }}) {% else %} -{{ destination }} = {{ source }}.to_dict() if {{ source }} else None +{{ destination }} = maybe_to_dict({{ source }}) if {{ source }} else None {% endif %} {% endmacro %} diff --git a/openapi_python_client/templates/property_templates/union_property.pyi b/openapi_python_client/templates/property_templates/union_property.pyi index cbc13e76e..d23fe1753 100644 --- a/openapi_python_client/templates/property_templates/union_property.pyi +++ b/openapi_python_client/templates/property_templates/union_property.pyi @@ -1,6 +1,16 @@ {% macro construct(property, source) %} -def _parse_{{ property.python_name }}(data: Dict[str, Any]) -> {{ property.get_type_string() }}: - {{ property.python_name }}: {{ property.get_type_string() }} +def _parse_{{ property.python_name }}(data: {% if property.nullable -%} + Optional[Dict[str, Any]] + {%- else -%} + Dict[str, Any] + {%- endif -%} +) -> {{ property.get_type_string() }}: + {% if property.nullable %} + if data is None: + return None + + {% endif %} + {{ property.python_name }}: {{ property.get_type_string() }} = {{ source }} {% for inner_property in property.inner_properties %} {% if inner_property.template and not loop.last %} try: @@ -11,10 +21,15 @@ def _parse_{{ property.python_name }}(data: Dict[str, Any]) -> {{ property.get_t pass {% elif inner_property.template and loop.last %}{# Don't do try/except for the last one #} {% from "property_templates/" + inner_property.template import construct %} - {{ construct(inner_property, source) | indent(4) }} + {{ construct(inner_property, property.python_name) | indent(4) }} return {{ property.python_name }} {% else %} - return {{ source }} + if isinstance({{ property.python_name }}, {{ inner_property.get_type_string() }}): + return {{ property.python_name }} + {% if loop.last %} + + raise AssertionError('Could not transform: {}'.format(property.python_name)) + {% endif %} {% endif %} {% endfor %} @@ -22,12 +37,12 @@ def _parse_{{ property.python_name }}(data: Dict[str, Any]) -> {{ property.get_t {% endmacro %} {% macro transform(property, source, destination) %} -{% if not property.required %} +{% if (not property.required) or property.nullable %} if {{ source }} is None: {{ destination }}: {{ property.get_type_string() }} = None {% endif %} {% for inner_property in property.inner_properties %} - {% if loop.first and property.required %}{# No if None statement before this #} + {% if loop.first and property.required and not property.nullable %}{# No if None statement before this #} if isinstance({{ source }}, {{ inner_property.get_type_string(no_optional=True) }}): {% elif not loop.last %} elif isinstance({{ source }}, {{ inner_property.get_type_string(no_optional=True) }}): diff --git a/openapi_python_client/templates/pyproject.toml b/openapi_python_client/templates/pyproject.toml index f2fc2c124..84289570f 100644 --- a/openapi_python_client/templates/pyproject.toml +++ b/openapi_python_client/templates/pyproject.toml @@ -13,7 +13,7 @@ include = ["CHANGELOG.md", "{{ package_name }}/py.typed"] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.7" httpx = "^0.13.3" [tool.black] diff --git a/openapi_python_client/templates/types.py b/openapi_python_client/templates/types.py index 6426154de..551f5460c 100644 --- a/openapi_python_client/templates/types.py +++ b/openapi_python_client/templates/types.py @@ -1,18 +1,27 @@ """ Contains some shared types for properties """ +import contextlib +import os from dataclasses import dataclass -from typing import BinaryIO, Optional, TextIO, Tuple, Union +from typing import BinaryIO, Generator, Optional, TextIO, Tuple, Union @dataclass class File: """ Contains information for file uploads """ payload: Union[BinaryIO, TextIO] - file_name: Optional[str] = None + file_name: str = None mime_type: Optional[str] = None - def to_tuple(self) -> Tuple[Optional[str], Union[BinaryIO, TextIO], Optional[str]]: + def to_tuple(self) -> Tuple[str, Union[BinaryIO, TextIO], Optional[str]]: """ Return a tuple representation that httpx will accept for multipart/form-data """ return self.file_name, self.payload, self.mime_type + @classmethod + @contextlib.contextmanager + def from_local_file(cls, path: str, mime_type: Optional[str] = None) -> Generator['File', None, None]: + with open(path, 'rb') as f: + yield cls(payload=f, file_name=os.path.basename(path), mime_type=mime_type) + + __all__ = ["File"] diff --git a/openapi_python_client/templates/utils.py b/openapi_python_client/templates/utils.py new file mode 100644 index 000000000..ba3bbbf8f --- /dev/null +++ b/openapi_python_client/templates/utils.py @@ -0,0 +1,49 @@ +import io +import json +from typing import Any, Callable, Dict, List, Union + + +def response_code_matches(code: int, expected: Union[str, int]) -> bool: + if expected == 'default': + return True + elif isinstance(expected, int) and code == expected: + return True + return isinstance(expected, str) and code > 100 and code / 100 == int( + expected[0]) + + +def try_any(lst: List[Callable]) -> Any: + err = Exception() + + for item in lst: + try: + return item() + except BaseException as exc: + err = exc + + raise err + + +def to_multipart(dct: Dict[str, Any]) -> Dict[str, Any]: + res = {} + for key, value in dct.items(): + if isinstance(value, list): + for idx, subval in enumerate(value): + assert isinstance(subval, tuple) + res[f'{key}_{idx}'] = subval + elif isinstance(value, tuple): + res[key] = value + else: + res[key] = (key, io.StringIO(json.dumps(value))) + + return res + + +def maybe_to_dict(obj: Any) -> Dict[str, Any]: + if isinstance(obj, dict): + return {k: maybe_to_dict(v) for k, v in obj.items()} + if isinstance(obj, list): + return [maybe_to_dict(sub) for sub in obj] + if isinstance(obj, (type(None), str, int, float)): + return obj + return obj.to_dict() diff --git a/openapi_python_client/utils.py b/openapi_python_client/utils.py index 22ad2c987..a709fbe81 100644 --- a/openapi_python_client/utils.py +++ b/openapi_python_client/utils.py @@ -21,7 +21,20 @@ def group_title(value: str) -> str: def snake_case(value: str) -> str: - return fix_keywords(stringcase.snakecase(group_title(sanitize(value)))) + base = stringcase.snakecase(group_title(sanitize(value))).split('_') + res = [] + prev_short = False + for item in base: + if len(item) == 1 and res: + prev_short = True + res[-1] += item + elif prev_short: + prev_short = False + res[-1] += item + else: + prev_short = False + res.append(item) + return fix_keywords('_'.join(res)) def pascal_case(value: str) -> str: