From d7cc4e1d8c6965c9671f57f0a50f6c2cb341e491 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Fri, 21 Feb 2025 11:31:37 +1000 Subject: [PATCH 1/2] fix: generating models with empty or missing `properties` key (#25) --- python_client_generator/generate_models.py | 6 ++++-- tests/expected/fastapi_app_client/models.py | 5 +++++ tests/expected/swagger_petstore_client/models.py | 8 ++++++++ tests/inputs/fastapi_app.py | 5 +++++ tests/inputs/swagger-petstore.json | 7 +++++++ 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/python_client_generator/generate_models.py b/python_client_generator/generate_models.py index 238e17d..2a35b11 100755 --- a/python_client_generator/generate_models.py +++ b/python_client_generator/generate_models.py @@ -96,7 +96,7 @@ def get_references(model: Dict[str, Any]) -> List[str]: union_keys = list(set(["allOf", "anyOf", "oneOf"]) & set(model.keys())) if union_keys: return _get_schema_references(model) - else: + elif "properties" in model: # Must have properties for p_schema in model["properties"].values(): refs += _get_schema_references(p_schema) @@ -109,7 +109,7 @@ def get_fields(schema: Dict[str, Any]) -> List[Dict[str, Any]]: if union_keys: # Handle union cases by creating a __root__ defined model return [{"name": "__root__", "type": resolve_type(schema)}] - else: + elif "properties" in schema: return [ { "name": k, @@ -119,6 +119,8 @@ def get_fields(schema: Dict[str, Any]) -> List[Dict[str, Any]]: } for k, v in schema["properties"].items() ] + else: + return [] def _strip_nonexistant_refs(objects: List[Dict[str, Any]]) -> None: diff --git a/tests/expected/fastapi_app_client/models.py b/tests/expected/fastapi_app_client/models.py index 3b6b03d..e4c9b61 100644 --- a/tests/expected/fastapi_app_client/models.py +++ b/tests/expected/fastapi_app_client/models.py @@ -11,9 +11,14 @@ class FooEnum(str, Enum): OPTION_2 = "option_2" +class EmptyObject(BaseModel): + pass + + class Bar(BaseModel): field_1: str field_2: Optional[bool] + field_3: Optional[EmptyObject] class Document(BaseModel): diff --git a/tests/expected/swagger_petstore_client/models.py b/tests/expected/swagger_petstore_client/models.py index ece062f..818e32b 100644 --- a/tests/expected/swagger_petstore_client/models.py +++ b/tests/expected/swagger_petstore_client/models.py @@ -64,3 +64,11 @@ class ApiResponse(BaseModel): message: Optional[str] +class EmptyObjectWithEmptyProperties(BaseModel): + pass + + +class EmptyObjectWithNoProperties(BaseModel): + pass + + diff --git a/tests/inputs/fastapi_app.py b/tests/inputs/fastapi_app.py index e6f3b66..ec92f4d 100644 --- a/tests/inputs/fastapi_app.py +++ b/tests/inputs/fastapi_app.py @@ -18,9 +18,14 @@ class FooEnum(str, Enum): OPTION_2 = "option_2" +class EmptyObject(BaseModel): + pass + + class Bar(BaseModel): field_1: str field_2: Optional[bool] + field_3: Optional[EmptyObject] class Foo(BaseModel): diff --git a/tests/inputs/swagger-petstore.json b/tests/inputs/swagger-petstore.json index 1573eb8..5339165 100644 --- a/tests/inputs/swagger-petstore.json +++ b/tests/inputs/swagger-petstore.json @@ -1176,6 +1176,13 @@ "xml": { "name": "##default" } + }, + "EmptyObjectWithEmptyProperties": { + "type": "object", + "properties": {} + }, + "EmptyObjectWithNoProperties": { + "type": "object" } }, "requestBodies": { From 95dd0e45f87b3a49592ce6f59965a1465eea57c6 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Fri, 21 Feb 2025 12:44:30 +1000 Subject: [PATCH 2/2] fix!: use standard pyproject.toml attributes (#28), fix namespaced packages (#27) This allows generated projects to be built with tools other than Poetry, such as `uv`. This has been tested with `poetry build` and `uv build`. BREAKING CHANGE: Author name and email address are now optional arguments and not populated by default. However, this does not seem to be required by Poetry or `uv`. --- python_client_generator/generate_pyproject.py | 21 ++++++++++--- python_client_generator/main.py | 18 +++++++++-- .../templates/pyproject.toml.mustache | 28 ++++++++++------- .../{ => fastapi_project}/apis.py | 0 .../{ => fastapi_project}/base_client.py | 0 .../{ => fastapi_project}/models.py | 0 .../fastapi_app_client/pyproject.toml | 23 +++++++------- .../swagger_petstore_client/pyproject.toml | 26 +++++++++------- .../{ => test_project}/__init__.py | 0 .../{ => test_project}/apis.py | 0 .../{ => test_project}/base_client.py | 0 .../{ => test_project}/models.py | 0 tests/test_fastapi_client.py | 4 +-- tests/test_fastapi_client_generator.py | 27 +++++++++++++--- tests/test_swagger_file_generator.py | 31 ++++++++++++++++--- 15 files changed, 126 insertions(+), 52 deletions(-) rename tests/expected/fastapi_app_client/{ => fastapi_project}/apis.py (100%) rename tests/expected/fastapi_app_client/{ => fastapi_project}/base_client.py (100%) rename tests/expected/fastapi_app_client/{ => fastapi_project}/models.py (100%) rename tests/expected/swagger_petstore_client/{ => test_project}/__init__.py (100%) rename tests/expected/swagger_petstore_client/{ => test_project}/apis.py (100%) rename tests/expected/swagger_petstore_client/{ => test_project}/base_client.py (100%) rename tests/expected/swagger_petstore_client/{ => test_project}/models.py (100%) diff --git a/python_client_generator/generate_pyproject.py b/python_client_generator/generate_pyproject.py index c528a00..226f971 100644 --- a/python_client_generator/generate_pyproject.py +++ b/python_client_generator/generate_pyproject.py @@ -1,7 +1,7 @@ import os from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Optional import chevron @@ -10,14 +10,27 @@ templates_path = dir_path / "templates" -def generate_pyproject(swagger: Dict[str, Any], out_file: Path, project_name: str) -> None: +def generate_pyproject( + swagger: Dict[str, Any], + out_file: Path, + project_name: str, + project_path_first: str, + author_name: Optional[str] = None, + author_email: Optional[str] = None, +) -> None: """ Generate `pyproject.toml` file. """ - version = swagger["info"]["version"] + data = { + "version": swagger["info"]["version"], + "project_name": project_name, + "project_path_first": project_path_first, + "has_author": bool(author_name or author_email), + "author": [author_name, author_email], + } with open(templates_path / "pyproject.toml.mustache", "r") as f: - toml_str = chevron.render(f, {"version": version, "project_name": project_name}) + toml_str = chevron.render(f, data) with open(out_file, "w+") as f: f.write(toml_str) diff --git a/python_client_generator/main.py b/python_client_generator/main.py index 6135b54..c06b54d 100644 --- a/python_client_generator/main.py +++ b/python_client_generator/main.py @@ -26,12 +26,18 @@ def main() -> None: parser.add_argument("--open-api", type=str) parser.add_argument("--package-name", type=str) parser.add_argument("--project-name", type=str) + parser.add_argument("--author-name", type=str, required=False) + parser.add_argument("--author-email", type=str, required=False) parser.add_argument("--outdir", type=str, default="clients/") parser.add_argument("--group-by-tags", action="store_true") parser.add_argument("--sync", action="store_true") args = parser.parse_args() + if os.sep in args.package_name or (os.altsep is not None and os.altsep in args.package_name): + raise ValueError("package-name must not contain directory separators") + if "-" in args.package_name: + raise ValueError("package-name must not contain dashes") with open(args.open_api, "r") as f: swagger = json.load(f) @@ -43,10 +49,18 @@ def main() -> None: path = Path(args.outdir) path.mkdir(parents=True, exist_ok=True) - generate_pyproject(dereferenced_swagger, path / "pyproject.toml", args.project_name) + project_path_first = args.package_name.split(".", maxsplit=1)[0] + generate_pyproject( + dereferenced_swagger, + path / "pyproject.toml", + args.project_name, + project_path_first, + args.author_name, + args.author_email, + ) # Create package directory - package_path = path / Path(args.package_name) + package_path = path / Path(args.package_name.replace(".", os.sep)) package_path.mkdir(parents=True, exist_ok=True) # Generate package files diff --git a/python_client_generator/templates/pyproject.toml.mustache b/python_client_generator/templates/pyproject.toml.mustache index c88e488..a3452b4 100644 --- a/python_client_generator/templates/pyproject.toml.mustache +++ b/python_client_generator/templates/pyproject.toml.mustache @@ -1,17 +1,21 @@ -[tool.poetry] +[project] name = "{{project_name}}" version = "{{version}}" description = "Autogenerated httpx async client for {{project_name}}" -authors = ["Autogenerated Client "] - -[tool.poetry.dependencies] -python = "^3.7" -httpx = ">=0.22, <1" -pydantic = "^1" - -[tool.poetry.scripts] -poetry = "poetry.console:main" +{{#has_author}} +authors = [ + { {{#author.0}}name = "{{author.0}}"{{#author.1}}, {{/author.1}}{{/author.0}}{{#author.1}}email = "{{author.1}}"{{/author.1}} }, +] +{{/has_author}} +requires-python = ">=3.8" +dependencies = [ + "httpx>=0.22,<1", + "pydantic>=2,<3", +] [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["{{project_path_first}}"] diff --git a/tests/expected/fastapi_app_client/apis.py b/tests/expected/fastapi_app_client/fastapi_project/apis.py similarity index 100% rename from tests/expected/fastapi_app_client/apis.py rename to tests/expected/fastapi_app_client/fastapi_project/apis.py diff --git a/tests/expected/fastapi_app_client/base_client.py b/tests/expected/fastapi_app_client/fastapi_project/base_client.py similarity index 100% rename from tests/expected/fastapi_app_client/base_client.py rename to tests/expected/fastapi_app_client/fastapi_project/base_client.py diff --git a/tests/expected/fastapi_app_client/models.py b/tests/expected/fastapi_app_client/fastapi_project/models.py similarity index 100% rename from tests/expected/fastapi_app_client/models.py rename to tests/expected/fastapi_app_client/fastapi_project/models.py diff --git a/tests/expected/fastapi_app_client/pyproject.toml b/tests/expected/fastapi_app_client/pyproject.toml index 38fdc9e..deefddb 100644 --- a/tests/expected/fastapi_app_client/pyproject.toml +++ b/tests/expected/fastapi_app_client/pyproject.toml @@ -1,17 +1,16 @@ -[tool.poetry] +[project] name = "fastapi-project" version = "0.1.0" description = "Autogenerated httpx async client for fastapi-project" -authors = ["Autogenerated Client "] - -[tool.poetry.dependencies] -python = "^3.7" -httpx = ">=0.22, <1" -pydantic = "^1" - -[tool.poetry.scripts] -poetry = "poetry.console:main" +requires-python = ">=3.8" +dependencies = [ + "httpx>=0.22,<1", + "pydantic>=2,<3", +] [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["fastapi_project"] diff --git a/tests/expected/swagger_petstore_client/pyproject.toml b/tests/expected/swagger_petstore_client/pyproject.toml index c4db91e..8c027bf 100644 --- a/tests/expected/swagger_petstore_client/pyproject.toml +++ b/tests/expected/swagger_petstore_client/pyproject.toml @@ -1,17 +1,19 @@ -[tool.poetry] +[project] 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" +authors = [ + { name = "Test User", email = "test@example.com" }, +] +requires-python = ">=3.8" +dependencies = [ + "httpx>=0.22,<1", + "pydantic>=2,<3", +] [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["test_project"] diff --git a/tests/expected/swagger_petstore_client/__init__.py b/tests/expected/swagger_petstore_client/test_project/__init__.py similarity index 100% rename from tests/expected/swagger_petstore_client/__init__.py rename to tests/expected/swagger_petstore_client/test_project/__init__.py diff --git a/tests/expected/swagger_petstore_client/apis.py b/tests/expected/swagger_petstore_client/test_project/apis.py similarity index 100% rename from tests/expected/swagger_petstore_client/apis.py rename to tests/expected/swagger_petstore_client/test_project/apis.py diff --git a/tests/expected/swagger_petstore_client/base_client.py b/tests/expected/swagger_petstore_client/test_project/base_client.py similarity index 100% rename from tests/expected/swagger_petstore_client/base_client.py rename to tests/expected/swagger_petstore_client/test_project/base_client.py diff --git a/tests/expected/swagger_petstore_client/models.py b/tests/expected/swagger_petstore_client/test_project/models.py similarity index 100% rename from tests/expected/swagger_petstore_client/models.py rename to tests/expected/swagger_petstore_client/test_project/models.py diff --git a/tests/test_fastapi_client.py b/tests/test_fastapi_client.py index fa7d5d6..992665e 100644 --- a/tests/test_fastapi_client.py +++ b/tests/test_fastapi_client.py @@ -7,8 +7,8 @@ from tests.utils import does_not_raise -from .expected.fastapi_app_client.apis import Api as FastApiAppClient -from .expected.fastapi_app_client.models import Document, Foo, PaginatedFoo +from .expected.fastapi_app_client.fastapi_project.apis import Api as FastApiAppClient +from .expected.fastapi_app_client.fastapi_project.models import Document, Foo, PaginatedFoo client_base_url = "https://domain.tld" diff --git a/tests/test_fastapi_client_generator.py b/tests/test_fastapi_client_generator.py index 6083c45..66c4043 100644 --- a/tests/test_fastapi_client_generator.py +++ b/tests/test_fastapi_client_generator.py @@ -15,25 +15,44 @@ def test_models(fastapi_app_openapi: Dict[str, Any], tmp_path: Path) -> None: generate_models(fastapi_app_openapi, tmp_path / "models.py") - assert filecmp.cmp(EXPECTED_PATH / "models.py", tmp_path / "models.py", shallow=False) is True + assert ( + filecmp.cmp( + EXPECTED_PATH / "fastapi_project" / "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) + filecmp.cmp( + EXPECTED_PATH / "fastapi_project" / "base_client.py", + tmp_path / "base_client.py", + shallow=False, + ) is True ) def test_apis(fastapi_app_openapi: Dict[str, Any], tmp_path: Path) -> None: generate_apis(fastapi_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 + assert ( + filecmp.cmp( + EXPECTED_PATH / "fastapi_project" / "apis.py", tmp_path / "apis.py", shallow=False + ) + is True + ) def test_pyproject(fastapi_app_openapi: Dict[str, Any], tmp_path: Path) -> None: generate_pyproject( - fastapi_app_openapi, tmp_path / "pyproject.toml", project_name="fastapi-project" + fastapi_app_openapi, + tmp_path / "pyproject.toml", + project_name="fastapi-project", + project_path_first="fastapi_project", ) assert ( filecmp.cmp(EXPECTED_PATH / "pyproject.toml", tmp_path / "pyproject.toml", shallow=False) diff --git a/tests/test_swagger_file_generator.py b/tests/test_swagger_file_generator.py index dc55814..c38eaa7 100644 --- a/tests/test_swagger_file_generator.py +++ b/tests/test_swagger_file_generator.py @@ -17,25 +17,48 @@ def test_models(swagger_petstore_openapi: Dict[str, Any], tmp_path: Path) -> None: generate_models(swagger_petstore_openapi, tmp_path / "models.py") - assert filecmp.cmp(EXPECTED_PATH / "models.py", tmp_path / "models.py", shallow=False) is True + assert ( + filecmp.cmp( + EXPECTED_PATH / "test_project" / "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) + filecmp.cmp( + EXPECTED_PATH / "test_project" / "base_client.py", + tmp_path / "base_client.py", + shallow=False, + ) is True ) def test_apis(swagger_petstore_openapi: Dict[str, Any], tmp_path: Path) -> None: generate_apis(swagger_petstore_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 + assert ( + filecmp.cmp( + EXPECTED_PATH / "test_project" / "apis.py", + tmp_path / "apis.py", + shallow=False, + ) + is True + ) def test_pyproject(swagger_petstore_openapi: Dict[str, Any], tmp_path: Path) -> None: generate_pyproject( - swagger_petstore_openapi, tmp_path / "pyproject.toml", project_name="test-project" + swagger_petstore_openapi, + tmp_path / "pyproject.toml", + project_name="test-project", + project_path_first="test_project", + author_name="Test User", + author_email="test@example.com", ) assert ( filecmp.cmp(EXPECTED_PATH / "pyproject.toml", tmp_path / "pyproject.toml", shallow=False)