diff --git a/README.md b/README.md index 82c94b0..b2a6c54 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,36 @@ # python-client-generator Python package to generate an [httpx](https://github.com/encode/httpx)- and -[pydantic](https://github.com/pydantic/pydantic)-based async (or sync) -client off an OpenAPI spec >= 3.0. +[pydantic](https://github.com/pydantic/pydantic)-based async (or sync) client off an OpenAPI spec >= 3.0. ```mermaid flowchart LR generator["python-client-generator"] - app["REST API app"] - package["app HTTP client"] + app["REST API App"] + package["App HTTP client"] - app -- "OpenAPI json" --> generator - generator -- "generates" --> package + app -- "OpenAPI JSON" --> generator + generator -- "Generates" --> package ``` -> :warning: **Currently does not support OpenAPI 2.0**: PR for < 3.0 support are welcome +> :warning: **Currently does not support OpenAPI 2.x** (PRs for < 3.0 support are welcome). ## Using the generator +Install the package (see on [PyPi](https://pypi.org/project/python-client-generator/)): + +```bash +pip install python-client-generator +``` + +Run: + ```bash python -m python_client_generator --open-api openapi.json --package-name foo_bar --project-name foo-bar --outdir clients ``` This will produce a Python package with the following structure: + ```bash clients ├── foo_bar @@ -41,12 +49,13 @@ will ensure that only the fields that are set in the update object are sent to t ```python await api_client.update_contact_v1_contacts__contact_id__patch( - body=patch_body, - contact_id=contact.id, - tenant=tenant, - body_serializer_args={"exclude_unset": True} + body=patch_body, + contact_id=contact.id, + tenant=tenant, + body_serializer_args={"exclude_unset": True} ) ``` ## Contributing + Please refer to [CONTRIBUTING.md](.github/CONTRIBUTING.md). diff --git a/python_client_generator/generate_apis.py b/python_client_generator/generate_apis.py index a38350b..927b00a 100755 --- a/python_client_generator/generate_apis.py +++ b/python_client_generator/generate_apis.py @@ -33,21 +33,67 @@ def resolve_property_default(property: Dict[str, Any]) -> Optional[str]: def get_return_type(responses: Dict[str, Any]) -> Optional[str]: - successful_responses = [v for k, v in responses.items() if int(k) >= 200 and int(k) < 300] - if len(successful_responses) != 1: - raise Exception("Incorrect number of successful responses:", len(successful_responses)) + def check_if_valid_success_response(key: str) -> bool: + if key == "default": + return True - schema = successful_responses[0]["content"]["application/json"]["schema"] - if not schema: + if key == "2XX": + return True + + if int(key) >= 200 and int(key) < 300: + return True + + return False + + # Only consider successful responses + successful_responses_raw = { + k: v for k, v in responses.items() if check_if_valid_success_response(k) + } + + if len(successful_responses_raw) == 0: + return None + + # Pop the default response if there are multiple successful responses (e.g. 200, 201, 204) + if len(successful_responses_raw) > 1: + successful_responses_raw.pop("default", None) + + # Map the responses to a list + successful_responses = [v for _, v in successful_responses_raw.items()] + + # Not all successful responses have a content key, see: https://spec.openapis.org/oas/v3.0.3#responses-object + if "content" not in successful_responses[0]: + return None + + content = successful_responses[0]["content"] + + if "application/json" not in content: + return None + + schema = successful_responses[0]["content"]["application/json"].get("schema") + if schema is None: return None - return sanitize_name(schema["title"]) + if "type" not in schema: + return sanitize_name(schema["title"]) if "title" in schema else None + + if schema["type"] == "array": + return f"List[{resolve_type(schema['items'])}]" + if schema["type"] == "object": + return sanitize_name(schema.get("title", "Dict[str, Any]")) def _get_request_body_params(method: Dict[str, Any]) -> List[Dict[str, Any]]: - args = [] + """ + Only handful of media types are supported: + - application/json + - multipart/form-data + - */* (all types)(only if schema is an object and defined with properties) + Media types' ranges are not supported at the moment (e.g. 'application/*'). + """ + args = [] content = method["requestBody"]["content"] + if "application/json" in content: schema = content["application/json"]["schema"] args.append( @@ -56,9 +102,25 @@ def _get_request_body_params(method: Dict[str, Any]) -> List[Dict[str, Any]]: "schema": schema, } ) - else: + elif "multipart/form-data" in content: + schema = content["multipart/form-data"].get("schema") + + # If schema is not defined with properties, we can't generate arguments + if schema is None or "properties" not in schema: + return args + # Create argument for each multipart upload property - schema = content["multipart/form-data"]["schema"] + for k, v in schema["properties"].items(): + args.append({"name": k, "schema": v}) + elif "*/*" in content: + # Attempt to create argument for all types of request bodies if the schema is of an object and defined with properties + # See: https://swagger.io/docs/specification/describing-request-body/ + + schema = content["*/*"].get("schema") + + if schema is None or "properties" not in schema: + return args + for k, v in schema["properties"].items(): args.append({"name": k, "schema": v}) @@ -76,9 +138,9 @@ def get_function_args(method: Dict[str, Any]) -> List[Dict[str, Any]]: keys = ["path", "query", "header"] parameters = method.get("parameters", []) for k in keys: - params += [p for p in parameters if p["in"] == k and p["required"]] + params += [p for p in parameters if p["in"] == k and p.get("required", False)] for k in keys: - params += [p for p in parameters if p["in"] == k and not p["required"]] + params += [p for p in parameters if p["in"] == k and not p.get("required", False)] # Convert params to args format required for templating return [ diff --git a/python_client_generator/generate_models.py b/python_client_generator/generate_models.py index da53e66..238e17d 100755 --- a/python_client_generator/generate_models.py +++ b/python_client_generator/generate_models.py @@ -47,8 +47,41 @@ def _get_schema_references(schema: Dict[str, Any]) -> List[str]: return [] elif schema["type"] == "array": return _get_schema_references(schema["items"]) - elif schema["type"] == "object" or "enum" in schema: - return [schema["title"]] + elif schema["type"] == "object" or (schema["type"] == "string" and "enum" in schema): + # As some nested enums may not have a title, we need to check for it. + # This is observed to happen inside the properties of a schema that uses an enum with referencing to another enum schema (raw values instead) # noqa E501 + # Example: + # "properties": { # properties of an object schema (type is object) + # "status": { + # "type": "string", + # "enum": [ + # "active", + # "inactive" + # ] + # } + # } + # In this case, the enum values are not defined in a schema with a title, so we need to check for it. # noqa E501 + # For the case where the enum values are defined in a schema with a title, the title will be used. # noqa E501 + # Example: + # "schemas": { + # ..., # other schemas + # "Status": { + # "title": "Status", + # "type": "string", + # "enum": [ + # "active", + # "inactive" + # ] + # } + # } + # And then it will be referenced in the properties like this: + # "properties": { # properties of an object schema (type is object) + # "status": { + # "$ref": "#/components/schemas/Status" + # } + # } + + return [schema.get("title", "")] else: return [] diff --git a/python_client_generator/main.py b/python_client_generator/main.py index ce4101d..6135b54 100644 --- a/python_client_generator/main.py +++ b/python_client_generator/main.py @@ -5,7 +5,11 @@ from pathlib import Path -from python_client_generator.utils import assert_openapi_version, dereference_swagger +from python_client_generator.utils import ( + add_schema_title_if_missing, + assert_openapi_version, + dereference_swagger, +) from .generate_apis import generate_apis from .generate_base_client import generate_base_client @@ -32,6 +36,7 @@ def main() -> None: swagger = json.load(f) assert_openapi_version(swagger) + add_schema_title_if_missing(swagger["components"]["schemas"]) dereferenced_swagger = dereference_swagger(swagger, swagger) # Create root directory diff --git a/python_client_generator/utils.py b/python_client_generator/utils.py index fa2916e..f724a33 100644 --- a/python_client_generator/utils.py +++ b/python_client_generator/utils.py @@ -81,7 +81,28 @@ def resolve_type(schema: Dict[str, Any], depth: int = 0, use_literals: bool = Fa elif "type" not in schema: return "Any" elif schema["type"] == "object": - if "properties" in schema: + # If a schema has properties and a title, we can use the title as the type + # name. Otherwise, we just return a generic Dict[str, Any] + # This happens when a schema has an object in the properties that doesn't reference another schema. # noqa E501 + # Example: + # { + # "Schema_Name": { + # "title": "Schema_Name", + # "type": "object", + # "properties": { + # "property_name": { + # "type": "object", + # "properties": { + # "nested_property": { + # "type": "string" + # } + # } + # } + # } + # } + # } + + if "properties" in schema and "title" in schema: return sanitize_name(schema["title"]) else: return "Dict[str, Any]" @@ -111,3 +132,30 @@ def resolve_type(schema: Dict[str, Any], depth: int = 0, use_literals: bool = Fa def assert_openapi_version(schema: Dict[str, Any]) -> None: if not schema.get("openapi") or semver.Version.parse(schema.get("openapi")).major != 3: # type: ignore # noqa: E501 raise UnsupportedOpenAPISpec("OpenAPI file provided is not version 3.x") + + +def add_schema_title_if_missing(schemas: Dict[str, Any]) -> Dict[str, Any]: + """ + Add 'title' key to schemas if missing to prevent issues with type resolution. + Only adds title to object and enum schemas. + + Args: + schemas (Dict[str, Any]): Swagger schemas under components.schemas + Returns: + Dict[str, Any]: Schemas with 'title' key added if missing + + Raises: + ValueError: If schema is missing 'type' key + """ + + for k, v in schemas.items(): + if "title" not in v and isinstance(v, dict): + schema_type = v.get("type") + + if not schema_type: + raise ValueError(f"Schema {k} is missing 'type' key") + + if schema_type == "object" or (schema_type == "string" and "enum" in v): + v["title"] = k + + return schemas diff --git a/tests/conftest.py b/tests/conftest.py index 290dd8e..b638b05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,24 +6,35 @@ import pytest -from python_client_generator.utils import dereference_swagger +from python_client_generator.utils import dereference_swagger, add_schema_title_if_missing -from .input_app import app +from .test_inputs.input_app import app PATH = Path(os.path.dirname(os.path.realpath(__file__))) @pytest.fixture(scope="session") -def openapi_file(tmp_path_factory: pytest.TempPathFactory) -> Path: - path = tmp_path_factory.mktemp("input") / "input_openapi.json" +def input_app_openapi_file(tmp_path_factory: pytest.TempPathFactory) -> Path: + path = tmp_path_factory.mktemp("input") / "input_app_openapi_file.json" with open(path, "w") as f: json.dump(app.openapi(), f) return path @pytest.fixture() -def openapi(openapi_file: Path) -> Dict[str, Any]: - with open(openapi_file, "r") as f: +def input_app_openapi(input_app_openapi_file: Path) -> Dict[str, Any]: + with open(input_app_openapi_file, "r") as f: swagger = json.load(f) + + add_schema_title_if_missing(swagger["components"]["schemas"]) + return dereference_swagger(swagger, swagger) + + +@pytest.fixture() +def input_file_openapi() -> Dict[str, Any]: + with open(PATH / "test_inputs" / "input_openapi_petstore_file.json", "r") as f: + swagger = json.load(f) + + add_schema_title_if_missing(swagger["components"]["schemas"]) return dereference_swagger(swagger, swagger) diff --git a/tests/expected/apis.py b/tests/expected/input_app/apis.py similarity index 100% rename from tests/expected/apis.py rename to tests/expected/input_app/apis.py diff --git a/tests/expected/base_client.py b/tests/expected/input_app/base_client.py similarity index 100% rename from tests/expected/base_client.py rename to tests/expected/input_app/base_client.py diff --git a/tests/expected/models.py b/tests/expected/input_app/models.py similarity index 100% rename from tests/expected/models.py rename to tests/expected/input_app/models.py diff --git a/tests/expected/pyproject.toml b/tests/expected/input_app/pyproject.toml similarity index 100% rename from tests/expected/pyproject.toml rename to tests/expected/input_app/pyproject.toml diff --git a/tests/expected/input_openapi_file/__init__.py b/tests/expected/input_openapi_file/__init__.py new file mode 100644 index 0000000..f33de32 --- /dev/null +++ b/tests/expected/input_openapi_file/__init__.py @@ -0,0 +1,2 @@ +from .apis import * +from .models import * diff --git a/tests/expected/input_openapi_file/apis.py b/tests/expected/input_openapi_file/apis.py new file mode 100644 index 0000000..69fe071 --- /dev/null +++ b/tests/expected/input_openapi_file/apis.py @@ -0,0 +1,383 @@ +from typing import Any, Dict, Optional, Union + +import httpx + +from .base_client import BaseClient +from .models import * + + +class Api(BaseClient): + """ + Autogenerated httpx async client + """ + async def updatePet( + self, + body: Pet, + body_serializer_args: Dict[str, Any] = {}, + **kwargs: Any + ) -> Pet: + """ + Update an existing pet by Id + """ # noqa + + response = await self._request( + "PUT", + "/pet", + _body=body, + body_serializer_args=body_serializer_args, + **kwargs + ) + response.raise_for_status() + return Pet.parse_raw(response.content) + + async def addPet( + self, + body: Pet, + body_serializer_args: Dict[str, Any] = {}, + **kwargs: Any + ) -> Pet: + """ + Add a new pet to the store + """ # noqa + + response = await self._request( + "POST", + "/pet", + _body=body, + body_serializer_args=body_serializer_args, + **kwargs + ) + response.raise_for_status() + return Pet.parse_raw(response.content) + + async def findPetsByStatus( + self, + status: Optional[str] = "available", + **kwargs: Any + ) -> List[Pet]: + """ + Multiple status values can be provided with comma separated strings + """ # noqa + + _query_params = { + "status": status, + } + + response = await self._request( + "GET", + "/pet/findByStatus", + _query_params=_query_params, + **kwargs + ) + response.raise_for_status() + return List[Pet].parse_raw(response.content) + + async def findPetsByTags( + self, + tags: Optional[List[str]] = None, + **kwargs: Any + ) -> List[Pet]: + """ + Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + """ # noqa + + _query_params = { + "tags": tags, + } + + response = await self._request( + "GET", + "/pet/findByTags", + _query_params=_query_params, + **kwargs + ) + response.raise_for_status() + return List[Pet].parse_raw(response.content) + + async def getPetById( + self, + petId: int, + **kwargs: Any + ) -> Pet: + """ + Returns a single pet + """ # noqa + + response = await self._request( + "GET", + f"/pet/{petId}", + **kwargs + ) + response.raise_for_status() + return Pet.parse_raw(response.content) + + async def updatePetWithForm( + self, + petId: int, + name: str, + status: str, + **kwargs: Any + ) -> None: + """ + + """ # noqa + + _query_params = { + "name": name, + "status": status, + } + + response = await self._request( + "POST", + f"/pet/{petId}", + _query_params=_query_params, + **kwargs + ) + response.raise_for_status() + + async def deletePet( + self, + petId: int, + api_key: Optional[str] = None, + **kwargs: Any + ) -> None: + """ + delete a pet + """ # noqa + + _headers = { + "api_key": api_key, + } + + response = await self._request( + "DELETE", + f"/pet/{petId}", + _headers=_headers, + **kwargs + ) + response.raise_for_status() + + async def uploadFile( + self, + petId: int, + additionalMetadata: Optional[str] = None, + **kwargs: Any + ) -> ApiResponse: + """ + + """ # noqa + + _query_params = { + "additionalMetadata": additionalMetadata, + } + + response = await self._request( + "POST", + f"/pet/{petId}/uploadImage", + _query_params=_query_params, + **kwargs + ) + response.raise_for_status() + return ApiResponse.parse_raw(response.content) + + async def getInventory( + self, + **kwargs: Any + ) -> DictstrAny: + """ + Returns a map of status codes to quantities + """ # noqa + + response = await self._request( + "GET", + "/store/inventory", + **kwargs + ) + response.raise_for_status() + return DictstrAny.parse_raw(response.content) + + async def placeOrder( + self, + body: Order, + body_serializer_args: Dict[str, Any] = {}, + **kwargs: Any + ) -> Order: + """ + Place a new order in the store + """ # noqa + + response = await self._request( + "POST", + "/store/order", + _body=body, + body_serializer_args=body_serializer_args, + **kwargs + ) + response.raise_for_status() + return Order.parse_raw(response.content) + + async def getOrderById( + self, + orderId: int, + **kwargs: Any + ) -> Order: + """ + For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. + """ # noqa + + response = await self._request( + "GET", + f"/store/order/{orderId}", + **kwargs + ) + response.raise_for_status() + return Order.parse_raw(response.content) + + async def deleteOrder( + self, + orderId: int, + **kwargs: Any + ) -> None: + """ + For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors + """ # noqa + + response = await self._request( + "DELETE", + f"/store/order/{orderId}", + **kwargs + ) + response.raise_for_status() + + async def createUser( + self, + body: User, + body_serializer_args: Dict[str, Any] = {}, + **kwargs: Any + ) -> User: + """ + This can only be done by the logged in user. + """ # noqa + + response = await self._request( + "POST", + "/user", + _body=body, + body_serializer_args=body_serializer_args, + **kwargs + ) + response.raise_for_status() + return User.parse_raw(response.content) + + async def createUsersWithListInput( + self, + body: List[User], + body_serializer_args: Dict[str, Any] = {}, + **kwargs: Any + ) -> User: + """ + Creates list of users with given input array + """ # noqa + + response = await self._request( + "POST", + "/user/createWithList", + _body=body, + body_serializer_args=body_serializer_args, + **kwargs + ) + response.raise_for_status() + return User.parse_raw(response.content) + + async def loginUser( + self, + username: Optional[str] = None, + password: Optional[str] = None, + **kwargs: Any + ) -> None: + """ + + """ # noqa + + _query_params = { + "username": username, + "password": password, + } + + response = await self._request( + "GET", + "/user/login", + _query_params=_query_params, + **kwargs + ) + response.raise_for_status() + + async def logoutUser( + self, + **kwargs: Any + ) -> None: + """ + + """ # noqa + + response = await self._request( + "GET", + "/user/logout", + **kwargs + ) + response.raise_for_status() + + async def getUserByName( + self, + username: str, + **kwargs: Any + ) -> User: + """ + + """ # noqa + + response = await self._request( + "GET", + f"/user/{username}", + **kwargs + ) + response.raise_for_status() + return User.parse_raw(response.content) + + async def updateUser( + self, + body: User, + username: str, + body_serializer_args: Dict[str, Any] = {}, + **kwargs: Any + ) -> None: + """ + This can only be done by the logged in user. + """ # noqa + + response = await self._request( + "PUT", + f"/user/{username}", + _body=body, + body_serializer_args=body_serializer_args, + **kwargs + ) + response.raise_for_status() + + async def deleteUser( + self, + username: str, + **kwargs: Any + ) -> None: + """ + This can only be done by the logged in user. + """ # noqa + + response = await self._request( + "DELETE", + f"/user/{username}", + **kwargs + ) + response.raise_for_status() + + diff --git a/tests/expected/input_openapi_file/base_client.py b/tests/expected/input_openapi_file/base_client.py new file mode 100644 index 0000000..a31a066 --- /dev/null +++ b/tests/expected/input_openapi_file/base_client.py @@ -0,0 +1,61 @@ +from enum import Enum +from typing import Any, Dict, Optional +from uuid import UUID + +import httpx + +from pydantic import BaseModel + + +class BaseClient(httpx.AsyncClient): + """ + Base client for serializing Pydantic models and enums into httpx requests + """ + + @staticmethod + def _serialize_param(v: Any) -> Any: + if isinstance(v, Enum): + return v.value + elif isinstance(v, UUID): + return str(v) + else: + return v + + async def _request( + self, + *args: Any, + _query_params: Dict[str, Any] = {}, + _headers: Dict[str, Any] = {}, + _multipart_data: Dict[str, Any] = {}, + _body: Optional[BaseModel] = None, + body_serializer_args: Dict[str, Any] = {}, + **kwargs: Any + ) -> httpx.Response: + """ + Wrapper class for serializing pydantic models and enums into params/header/body + and sending a request + """ + + kwargs["params"] = { + **{k: self._serialize_param(v) for k, v in _query_params.items() if v is not None}, + **kwargs.get("params", {}), + } + + kwargs["headers"] = { + **{k: self._serialize_param(v) for k, v in _headers.items() if v is not None}, + **kwargs.get("headers", {}), + } + + kwargs["data"] = { + **{k: self._serialize_param(v) for k, v in _multipart_data.items() if v is not None}, + **kwargs.get("data", {}), + } + + if _body: + kwargs["content"] = _body.json(**body_serializer_args) + kwargs["headers"] = { + **{"Content-Type": "application/json"}, + **kwargs.get("headers", {}), + } + + return await self.request(*args, **kwargs) diff --git a/tests/expected/input_openapi_file/models.py b/tests/expected/input_openapi_file/models.py new file mode 100644 index 0000000..ece062f --- /dev/null +++ b/tests/expected/input_openapi_file/models.py @@ -0,0 +1,66 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Literal, Optional, Union +from uuid import UUID + +from pydantic import BaseModel, Field + + +class Order(BaseModel): + id: Optional[int] + petId: Optional[int] + quantity: Optional[int] + shipDate: Optional[datetime] + status: Optional[str] + complete: Optional[bool] + + +class Address(BaseModel): + street: Optional[str] + city: Optional[str] + state: Optional[str] + zip: Optional[str] + + +class Customer(BaseModel): + id: Optional[int] + username: Optional[str] + address: Optional[List[Address]] + + +class Category(BaseModel): + id: Optional[int] + name: Optional[str] + + +class User(BaseModel): + id: Optional[int] + username: Optional[str] + firstName: Optional[str] + lastName: Optional[str] + email: Optional[str] + password: Optional[str] + phone: Optional[str] + userStatus: Optional[int] + + +class Tag(BaseModel): + id: Optional[int] + name: Optional[str] + + +class Pet(BaseModel): + id: Optional[int] + name: str + category: Optional[Category] + photoUrls: List[str] + tags: Optional[List[Tag]] + status: Optional[str] + + +class ApiResponse(BaseModel): + code: Optional[int] + type: Optional[str] + message: Optional[str] + + diff --git a/tests/expected/input_openapi_file/pyproject.toml b/tests/expected/input_openapi_file/pyproject.toml new file mode 100644 index 0000000..c4db91e --- /dev/null +++ b/tests/expected/input_openapi_file/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "test-project" +version = "1.0.11" +description = "Autogenerated httpx async client for test-project" +authors = ["Autogenerated Client "] + +[tool.poetry.dependencies] +python = "^3.7" +httpx = ">=0.22, <1" +pydantic = "^1" + +[tool.poetry.scripts] +poetry = "poetry.console:main" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/test_generator.py b/tests/test_input_app_generator.py similarity index 67% rename from tests/test_generator.py rename to tests/test_input_app_generator.py index 9de29ba..facf78d 100644 --- a/tests/test_generator.py +++ b/tests/test_input_app_generator.py @@ -15,11 +15,11 @@ from python_client_generator.utils import assert_openapi_version -EXPECTED_PATH = Path(os.path.dirname(os.path.realpath(__file__))) / "expected" +EXPECTED_PATH = Path(os.path.dirname(os.path.realpath(__file__))) / "expected/input_app" -def test_models(openapi: Dict[str, Any], tmp_path: Path) -> None: - generate_models(openapi, tmp_path / "models.py") +def test_models(input_app_openapi: Dict[str, Any], tmp_path: Path) -> None: + generate_models(input_app_openapi, tmp_path / "models.py") assert filecmp.cmp(EXPECTED_PATH / "models.py", tmp_path / "models.py", shallow=False) is True @@ -31,21 +31,21 @@ def test_base_client(tmp_path: Path) -> None: ) -def test_apis(openapi: Dict[str, Any], tmp_path: Path) -> None: - generate_apis(openapi, tmp_path / "apis.py", group_by_tags=False, sync=False) +def test_apis(input_app_openapi: Dict[str, Any], tmp_path: Path) -> None: + generate_apis(input_app_openapi, tmp_path / "apis.py", group_by_tags=False, sync=False) assert filecmp.cmp(EXPECTED_PATH / "apis.py", tmp_path / "apis.py", shallow=False) is True -def test_pyproject(openapi: Dict[str, Any], tmp_path: Path) -> None: - generate_pyproject(openapi, tmp_path / "pyproject.toml", project_name="test-project") +def test_pyproject(input_app_openapi: Dict[str, Any], tmp_path: Path) -> None: + generate_pyproject(input_app_openapi, tmp_path / "pyproject.toml", project_name="test-project") assert ( filecmp.cmp(EXPECTED_PATH / "pyproject.toml", tmp_path / "pyproject.toml", shallow=False) is True ) -def test_assert_openapi_version(openapi: Dict[str, Any]) -> None: - openapi_copy = deepcopy(openapi) +def test_assert_openapi_version(input_app_openapi: Dict[str, Any]) -> None: + openapi_copy = deepcopy(input_app_openapi) openapi_copy["openapi"] = "2.0.0" with pytest.raises(UnsupportedOpenAPISpec): assert_openapi_version(openapi_copy) diff --git a/tests/test_input_file_generator.py b/tests/test_input_file_generator.py new file mode 100644 index 0000000..671cf6d --- /dev/null +++ b/tests/test_input_file_generator.py @@ -0,0 +1,51 @@ +import filecmp +import os + +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict + +import pytest + +from python_client_generator.exceptions import UnsupportedOpenAPISpec +from python_client_generator.generate_apis import generate_apis +from python_client_generator.generate_base_client import generate_base_client +from python_client_generator.generate_models import generate_models +from python_client_generator.generate_pyproject import generate_pyproject +from python_client_generator.utils import assert_openapi_version + + +EXPECTED_PATH = Path(os.path.dirname(os.path.realpath(__file__))) / "expected/input_openapi_file" + + +def test_models(input_file_openapi: Dict[str, Any], tmp_path: Path) -> None: + generate_models(input_file_openapi, tmp_path / "models.py") + assert filecmp.cmp(EXPECTED_PATH / "models.py", tmp_path / "models.py", shallow=False) is True + + +def test_base_client(tmp_path: Path) -> None: + generate_base_client(tmp_path / "base_client.py", sync=False) + assert ( + filecmp.cmp(EXPECTED_PATH / "base_client.py", tmp_path / "base_client.py", shallow=False) + is True + ) + + +def test_apis(input_file_openapi: Dict[str, Any], tmp_path: Path) -> None: + generate_apis(input_file_openapi, tmp_path / "apis.py", group_by_tags=False, sync=False) + assert filecmp.cmp(EXPECTED_PATH / "apis.py", tmp_path / "apis.py", shallow=False) is True + + +def test_pyproject(input_file_openapi: Dict[str, Any], tmp_path: Path) -> None: + generate_pyproject(input_file_openapi, tmp_path / "pyproject.toml", project_name="test-project") + assert ( + filecmp.cmp(EXPECTED_PATH / "pyproject.toml", tmp_path / "pyproject.toml", shallow=False) + is True + ) + + +def test_assert_openapi_version(input_file_openapi: Dict[str, Any]) -> None: + openapi_copy = deepcopy(input_file_openapi) + openapi_copy["openapi"] = "2.0.0" + with pytest.raises(UnsupportedOpenAPISpec): + assert_openapi_version(openapi_copy) diff --git a/tests/test_inputs/README.md b/tests/test_inputs/README.md new file mode 100644 index 0000000..5c294d6 --- /dev/null +++ b/tests/test_inputs/README.md @@ -0,0 +1,13 @@ +# Test input files and expected clients + +The `test_inputs` directory contains the different types of OpenAPI JSON files' sources. + +The `test_openapi_petstore_file.json` file is generated from Swagger UI preset data (Found under 'Edit > Load Petstore OAS 3.0', [here](https://editor.swagger.io/)) + +The expected result/client from the `test_input.py` FASTApi app is generated on the fly as the tests starts running (see `conftest.py`). + +The expected result/client from the `test_openapi_petstore_file.json` is generated using the below command and reviewed by us, making sure that what is generated complies with what we support. + +```shell +python -m python_client_generator --open-api input_openapi_petstore_file.json --package-name test_project --project-name test-project --outdir clients +``` diff --git a/tests/input_app.py b/tests/test_inputs/input_app.py similarity index 100% rename from tests/input_app.py rename to tests/test_inputs/input_app.py diff --git a/tests/test_inputs/input_openapi_petstore_file.json b/tests/test_inputs/input_openapi_petstore_file.json new file mode 100644 index 0000000..1573eb8 --- /dev/null +++ b/tests/test_inputs/input_openapi_petstore_file.json @@ -0,0 +1,1231 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Swagger Petstore - OpenAPI 3.0", + "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\n_If you're looking for the Swagger 2.0/OAS 2.0 version of Petstore, then click [here](https://editor.swagger.io/?url=https://petstore.swagger.io/v2/swagger.yaml). Alternatively, you can load via the `Edit > Load Petstore OAS 2.0` menu option!_\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.11" + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "http://swagger.io" + }, + "servers": [ + { + "url": "https://petstore3.swagger.io/api/v3" + } + ], + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "http://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "description": "Find out more about our store", + "url": "http://swagger.io" + } + }, + { + "name": "user", + "description": "Operations about user" + } + ], + "paths": { + "/pet": { + "put": { + "tags": [ + "pet" + ], + "summary": "Update an existing pet", + "description": "Update an existing pet by Id", + "operationId": "updatePet", + "requestBody": { + "description": "Update an existent pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "422": { + "description": "Validation exception" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Add a new pet to the store", + "description": "Add a new pet to the store", + "operationId": "addPet", + "requestBody": { + "description": "Create a new pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Validation exception" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByStatus": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by status", + "description": "Multiple status values can be provided with comma separated strings", + "operationId": "findPetsByStatus", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": false, + "explode": true, + "schema": { + "type": "string", + "default": "available", + "enum": [ + "available", + "pending", + "sold" + ] + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid status value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByTags": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by tags", + "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": false, + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid tag value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pet by ID", + "description": "Returns a single pet", + "operationId": "getPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "security": [ + { + "api_key": [] + }, + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Updates a pet in the store with form data", + "description": "", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "name", + "in": "query", + "description": "Name of pet that needs to be updated", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Status of pet that needs to be updated", + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "delete": { + "tags": [ + "pet" + ], + "summary": "Deletes a pet", + "description": "delete a pet", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "description": "", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "400": { + "description": "Invalid pet value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": [ + "pet" + ], + "summary": "uploads an image", + "description": "", + "operationId": "uploadFile", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "additionalMetadata", + "in": "query", + "description": "Additional Metadata", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/store/inventory": { + "get": { + "tags": [ + "store" + ], + "summary": "Returns pet inventories by status", + "description": "Returns a map of status codes to quantities", + "operationId": "getInventory", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/store/order": { + "post": { + "tags": [ + "store" + ], + "summary": "Place an order for a pet", + "description": "Place a new order in the store", + "operationId": "placeOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Validation exception" + } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": [ + "store" + ], + "summary": "Find purchase order by ID", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + "operationId": "getOrderById", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of order that needs to be fetched", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Delete purchase order by ID", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + } + }, + "/user": { + "post": { + "tags": [ + "user" + ], + "summary": "Create user", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "requestBody": { + "description": "Created user object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "default": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/createWithList": { + "post": { + "tags": [ + "user" + ], + "summary": "Creates list of users with given input array", + "description": "Creates list of users with given input array", + "operationId": "createUsersWithListInput", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "successful operation" + } + } + } + }, + "/user/login": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs user into the system", + "description": "", + "operationId": "loginUser", + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "description": "calls per hour allowed by the user", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "X-Expires-After": { + "description": "date in UTC when token expires", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/xml": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid username/password supplied" + } + } + } + }, + "/user/logout": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs out current logged in user session", + "description": "", + "operationId": "logoutUser", + "parameters": [], + "responses": { + "default": { + "description": "successful operation" + } + } + } + }, + "/user/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get user by user name", + "description": "", + "operationId": "getUserByName", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing. ", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Update user", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Update an existent user in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "default": { + "description": "successful operation" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete user", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "petId": { + "type": "integer", + "format": "int64", + "example": 198772 + }, + "quantity": { + "type": "integer", + "format": "int32", + "example": 7 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "example": "approved", + "enum": [ + "placed", + "approved", + "delivered" + ] + }, + "complete": { + "type": "boolean" + } + }, + "xml": { + "name": "order" + } + }, + "Customer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 100000 + }, + "username": { + "type": "string", + "example": "fehguy" + }, + "address": { + "type": "array", + "xml": { + "name": "addresses", + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Address" + } + } + }, + "xml": { + "name": "customer" + } + }, + "Address": { + "type": "object", + "properties": { + "street": { + "type": "string", + "example": "437 Lytton" + }, + "city": { + "type": "string", + "example": "Palo Alto" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip": { + "type": "string", + "example": "94301" + } + }, + "xml": { + "name": "address" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "name": { + "type": "string", + "example": "Dogs" + } + }, + "xml": { + "name": "category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "username": { + "type": "string", + "example": "theUser" + }, + "firstName": { + "type": "string", + "example": "John" + }, + "lastName": { + "type": "string", + "example": "James" + }, + "email": { + "type": "string", + "example": "john@email.com" + }, + "password": { + "type": "string", + "example": "12345" + }, + "phone": { + "type": "string", + "example": "12345" + }, + "userStatus": { + "type": "integer", + "description": "User Status", + "format": "int32", + "example": 1 + } + }, + "xml": { + "name": "user" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "tag" + } + }, + "Pet": { + "required": [ + "name", + "photoUrls" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "name": { + "type": "string", + "example": "doggie" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "type": "string", + "xml": { + "name": "photoUrl" + } + } + }, + "tags": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ] + } + }, + "xml": { + "name": "pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "xml": { + "name": "##default" + } + } + }, + "requestBodies": { + "Pet": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "UserArray": { + "description": "List of user object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + } + } +} \ No newline at end of file