diff --git a/.github/workflows/samples-python-lazy-imports.yaml b/.github/workflows/samples-python-lazy-imports.yaml new file mode 100644 index 000000000000..3f9b7bc11ea8 --- /dev/null +++ b/.github/workflows/samples-python-lazy-imports.yaml @@ -0,0 +1,51 @@ +name: "Python Client: Native lazy imports" + +on: + pull_request: + paths: + - .github/workflows/samples-python-lazy-imports.yaml + - .mvn/** + - pom.xml + - modules/openapi-generator-core/** + - modules/openapi-generator/pom.xml + - modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java + - modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java + - modules/openapi-generator/src/main/resources/python/** + - modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java + - modules/openapi-generator/src/test/resources/3_1/python/lazy* + +jobs: + runtime: + name: Native lazy imports runtime contracts (Python 3.15) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Set up JDK 11 + uses: actions/setup-java@v6 + with: + java-version: 11 + distribution: temurin + cache: maven + - name: Set up Python 3.15 + uses: actions/setup-python@v7 + id: py + with: + python-version: "3.15" + - name: Install runtime dependencies + run: >- + python -m pip install + "pydantic>=2.14" + "python-dateutil>=2.8.2" + "typing-extensions>=4.7.1" + "urllib3>=2.7.0,<3" + "aiohttp>=3.14.1" + "aiohttp-retry>=2.8.3" + "httpx>=0.28.1" + "httpx2>=2.13.0,<3" + - name: Test native lazy imports + run: >- + ./mvnw --batch-mode --no-snapshot-updates + -pl modules/openapi-generator -am test + -Dtest=PythonClientCodegenTest + -Dsurefire.failIfNoSpecifiedTests=false + -DpythonExecutable="${{ steps.py.outputs.python-path }}" diff --git a/docs/generators/python.md b/docs/generators/python.md index deeba9c77a20..1ea1eca7c00c 100644 --- a/docs/generators/python.md +++ b/docs/generators/python.md @@ -27,6 +27,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |generateSourceCodeOnly|Specifies that only a library source code is to be generated.| |false| |hideGenerationTimestamp|Hides the generation timestamp when files are generated.| |true| |lazyImports|Enable lazy imports.| |false| +|lazyNativeImport|Use native lazy imports for generated APIs and models. Requires Python 3.15 or newer and Pydantic 2.14 or newer. Lazy imports load a module on first use rather than when the import statement is executed. Large clients can contain many Pydantic models that an application never uses; avoiding their eager loading can reduce startup work and initial memory use. The cost moves to first use, import errors and module initialization side effects may occur later, and gains depend on which APIs and models are used. Cannot be combined with lazyImports=true. Native imports are not guaranteed to use less memory than lazyImports.| |false| |library|library template (sub-template) to use: asyncio, urllib3, httpx, httpx2| |urllib3| |mapNumberTo|Map number to Union[StrictFloat, StrictInt], StrictFloat, float or Decimal.| |Union[StrictFloat, StrictInt]| |packageName|python package name (convention: snake_case).| |openapi_client| diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java index 1d71eeb80ae4..6980904c9072 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonClientCodegen.java @@ -63,6 +63,7 @@ public class PythonClientCodegen extends AbstractPythonCodegen implements Codege public static final String SET_ENSURE_ASCII_TO_FALSE = "setEnsureAsciiToFalse"; public static final String POETRY1_FALLBACK = "poetry1"; public static final String LAZY_IMPORTS = "lazyImports"; + public static final String LAZY_NATIVE_IMPORT = "lazyNativeImport"; public static final String BUILD_SYSTEM = "buildSystem"; public static final String SUPPORT_HTTPX_SYNC = "supportHttpxSync"; public static final String USE_INDEPENDENT_IMPLICIT_CLIENTS = "useIndependentImplicitClients"; @@ -122,6 +123,7 @@ public class PythonClientCodegen extends AbstractPythonCodegen implements Codege @Setter protected boolean setEnsureAsciiToFalse = false; @Setter protected boolean useIndependentImplicitClients = false; @Setter protected boolean compatibleWithPythonLegacy = false; + @Setter protected boolean lazyNativeImport = false; private String sourceFolder; private String testFolder; @@ -231,6 +233,14 @@ public PythonClientCodegen() { cliOptions.add(new CliOption(CodegenConstants.USE_ONEOF_DISCRIMINATOR_LOOKUP, CodegenConstants.USE_ONEOF_DISCRIMINATOR_LOOKUP_DESC).defaultValue("false")); cliOptions.add(new CliOption(POETRY1_FALLBACK, "Fallback to formatting pyproject.toml to Poetry 1.x format.")); cliOptions.add(new CliOption(LAZY_IMPORTS, "Enable lazy imports.").defaultValue(Boolean.FALSE.toString())); + cliOptions.add(CliOption.newBoolean(LAZY_NATIVE_IMPORT, + "Use native lazy imports for generated APIs and models. Requires Python 3.15 or newer and Pydantic 2.14 or newer. " + + "Lazy imports load a module on first use rather than when the import statement is executed. " + + "Large clients can contain many Pydantic models that an application never uses; avoiding their eager loading " + + "can reduce startup work and initial memory use. The cost moves to first use, import errors and module " + + "initialization side effects may occur later, and gains depend on which APIs and models are used. " + + "Cannot be combined with lazyImports=true. Native imports are not guaranteed to use less memory than lazyImports.") + .defaultValue(Boolean.FALSE.toString())); cliOptions.add(new CliOption(BUILD_SYSTEM, "Build system to use in pyproject.toml (setuptools, hatchling).").defaultValue("setuptools")); cliOptions.add(new CliOption(CodegenConstants.SOURCE_FOLDER, "directory for generated python source code; 'src' for _src layout_, unset (or '.') for _flat layout_.")); cliOptions.add(CliOption.newBoolean(SUPPORT_HTTPX_SYNC, "Generate synchronous '_sync' variants of each API method (httpx and httpx2 libraries only). " + @@ -372,6 +382,14 @@ public void processOpts() { if (additionalProperties.containsKey(LAZY_IMPORTS)) { additionalProperties.put(LAZY_IMPORTS, Boolean.valueOf(additionalProperties.get(LAZY_IMPORTS).toString())); } + if (additionalProperties.containsKey(LAZY_NATIVE_IMPORT)) { + setLazyNativeImport(convertPropertyToBooleanAndWriteBack(LAZY_NATIVE_IMPORT)); + } else { + additionalProperties.put(LAZY_NATIVE_IMPORT, lazyNativeImport); + } + if (lazyNativeImport && Boolean.TRUE.equals(additionalProperties.get(LAZY_IMPORTS))) { + throw new IllegalArgumentException("lazyNativeImport=true cannot be combined with lazyImports=true. Choose one lazy import strategy."); + } if (additionalProperties.containsKey(COMPATIBLE_WITH_PYTHON_LEGACY)) { setCompatibleWithPythonLegacy(convertPropertyToBooleanAndWriteBack(COMPATIBLE_WITH_PYTHON_LEGACY)); @@ -615,7 +633,7 @@ public String generatePackageName(String packageName) { @Override public String generatorLanguageVersion() { - return "3.11+"; + return lazyNativeImport ? "3.15+" : "3.11+"; } @Override @@ -645,7 +663,16 @@ public OperationsMap postProcessOperationsWithModels( objs.put("x-python-has-json-header", true); } - return super.postProcessOperationsWithModels(objs, allModels); + OperationsMap result = super.postProcessOperationsWithModels(objs, allModels); + if (lazyNativeImport) { + for (Map modelImport : result.getImports()) { + // Other imports in this list include stdlib and Pydantic types. + if (modelImport.get("import").startsWith("from " + packageName + ".models.")) { + modelImport.put("x-python-lazy-import", "true"); + } + } + } + return result; } private void renameIndependentClientOperationMembers( diff --git a/modules/openapi-generator/src/main/resources/python/README.mustache b/modules/openapi-generator/src/main/resources/python/README.mustache index 172c0c4738c6..c8ebece7f124 100644 --- a/modules/openapi-generator/src/main/resources/python/README.mustache +++ b/modules/openapi-generator/src/main/resources/python/README.mustache @@ -20,6 +20,7 @@ For more information, please visit [{{{infoUrl}}}]({{{infoUrl}}}) Python {{{generatorLanguageVersion}}} +{{>lazy_configuration}} {{#poetry1}} Packaging retains the deprecated `[tool.poetry.dev-dependencies]` table for compatibility with legacy Poetry 1 releases. Generate with `poetry1=false` to use diff --git a/modules/openapi-generator/src/main/resources/python/README_onlypackage.mustache b/modules/openapi-generator/src/main/resources/python/README_onlypackage.mustache index d205966d0bc5..91add2fd13c0 100644 --- a/modules/openapi-generator/src/main/resources/python/README_onlypackage.mustache +++ b/modules/openapi-generator/src/main/resources/python/README_onlypackage.mustache @@ -20,6 +20,7 @@ For more information, please visit [{{{infoUrl}}}]({{{infoUrl}}}) Python {{{generatorLanguageVersion}}} +{{>lazy_configuration}} ## Installation & Usage This python library package is generated without supporting files like setup.py or requirements files @@ -36,7 +37,7 @@ To be able to use it, you will need these dependencies in your own package that * pem >= 19.3.0 * pycryptodome >= 3.9.0 {{/hasHttpSignatureMethods}} -* pydantic >= 2.11 +* pydantic >= {{#lazyNativeImport}}2.14{{/lazyNativeImport}}{{^lazyNativeImport}}2.11{{/lazyNativeImport}} * typing-extensions >= 4.7.1 ## Getting Started diff --git a/modules/openapi-generator/src/main/resources/python/api.mustache b/modules/openapi-generator/src/main/resources/python/api.mustache index c771516a8a49..7ff763d96c85 100644 --- a/modules/openapi-generator/src/main/resources/python/api.mustache +++ b/modules/openapi-generator/src/main/resources/python/api.mustache @@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated {{#imports}} -{{import}} +{{#x-python-lazy-import}}lazy {{/x-python-lazy-import}}{{import}} {{/imports}} from {{packageName}}.api_client import ApiClient, RequestSerialized diff --git a/modules/openapi-generator/src/main/resources/python/exports_api.mustache b/modules/openapi-generator/src/main/resources/python/exports_api.mustache index 8eead04ab0fd..e0ce0c6f6e1d 100644 --- a/modules/openapi-generator/src/main/resources/python/exports_api.mustache +++ b/modules/openapi-generator/src/main/resources/python/exports_api.mustache @@ -1,3 +1,3 @@ # import apis into api package -{{#apiInfo}}{{#apis}}from {{apiPackage}}.{{classFilename}} import {{classname}} +{{#apiInfo}}{{#apis}}{{#lazyNativeImport}}lazy {{/lazyNativeImport}}from {{apiPackage}}.{{classFilename}} import {{classname}} {{/apis}}{{/apiInfo}} diff --git a/modules/openapi-generator/src/main/resources/python/exports_model.mustache b/modules/openapi-generator/src/main/resources/python/exports_model.mustache index 60dc9c65775c..8b2d500627e4 100644 --- a/modules/openapi-generator/src/main/resources/python/exports_model.mustache +++ b/modules/openapi-generator/src/main/resources/python/exports_model.mustache @@ -1,3 +1,3 @@ # import models into model package -{{#models}}{{#model}}from {{modelPackage}}.{{classFilename}} import {{classname}} +{{#models}}{{#model}}{{#lazyNativeImport}}lazy {{/lazyNativeImport}}from {{modelPackage}}.{{classFilename}} import {{classname}} {{/model}}{{/models}} diff --git a/modules/openapi-generator/src/main/resources/python/exports_package.mustache b/modules/openapi-generator/src/main/resources/python/exports_package.mustache index 96bd44ecb1f8..d551aaac851a 100644 --- a/modules/openapi-generator/src/main/resources/python/exports_package.mustache +++ b/modules/openapi-generator/src/main/resources/python/exports_package.mustache @@ -1,5 +1,5 @@ # import apis into sdk package -{{#apiInfo}}{{#apis}}from {{apiPackage}}.{{classFilename}} import {{classname}} as {{classname}} +{{#apiInfo}}{{#apis}}{{#lazyNativeImport}}lazy {{/lazyNativeImport}}from {{apiPackage}}.{{classFilename}} import {{classname}} as {{classname}} {{/apis}}{{/apiInfo}} # import ApiClient from {{packageName}}.api_response import ApiResponse as ApiResponse @@ -16,5 +16,5 @@ from {{packageName}}.signing import HttpSigningConfiguration as HttpSigningConfi {{/hasHttpSignatureMethods}} # import models into sdk package -{{#models}}{{#model}}from {{modelPackage}}.{{classFilename}} import {{classname}} as {{classname}} +{{#models}}{{#model}}{{#lazyNativeImport}}lazy {{/lazyNativeImport}}from {{modelPackage}}.{{classFilename}} import {{classname}} as {{classname}} {{/model}}{{/models}} diff --git a/modules/openapi-generator/src/main/resources/python/github-workflow.mustache b/modules/openapi-generator/src/main/resources/python/github-workflow.mustache index 2a39a0294ff1..516db43401d4 100644 --- a/modules/openapi-generator/src/main/resources/python/github-workflow.mustache +++ b/modules/openapi-generator/src/main/resources/python/github-workflow.mustache @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] + python-version: [<%^lazyNativeImport%>"3.11", "3.12", "3.13", "3.14", <%/lazyNativeImport%>"3.15"] steps: - uses: actions/checkout@v4 diff --git a/modules/openapi-generator/src/main/resources/python/gitlab-ci.mustache b/modules/openapi-generator/src/main/resources/python/gitlab-ci.mustache index b9e2fde25088..d97cb6abaa21 100644 --- a/modules/openapi-generator/src/main/resources/python/gitlab-ci.mustache +++ b/modules/openapi-generator/src/main/resources/python/gitlab-ci.mustache @@ -14,6 +14,7 @@ stages: - pip install -r test-requirements.txt - pytest --cov={{{packageName}}} +{{^lazyNativeImport}} pytest-3.11: extends: .pytest image: python:3.11-alpine @@ -26,6 +27,7 @@ pytest-3.13: pytest-3.14: extends: .pytest image: python:3.14-alpine +{{/lazyNativeImport}} pytest-3.15: extends: .pytest image: python:3.15-alpine diff --git a/modules/openapi-generator/src/main/resources/python/lazy_configuration.mustache b/modules/openapi-generator/src/main/resources/python/lazy_configuration.mustache new file mode 100644 index 000000000000..994b6dc4fb95 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/python/lazy_configuration.mustache @@ -0,0 +1,15 @@ +{{#lazyNativeImport}} +### Native lazy imports + +This client was generated with `lazyNativeImport=true` and requires Python 3.15+ +and Pydantic 2.14+. Its generated API and model imports use `lazy from`: modules +load on first use, so applications that use only a subset of a large client's +Pydantic models can avoid loading unused modules and reduce startup work and +initial memory use. + +Import costs move to first use; import failures and module initialization side +effects can happen later. Evaluating type annotations, constructing schemas, or +using models can resolve lazy imports. Gains depend on the workload, and native +imports are not guaranteed to use less memory than `lazyImports=true`. +The two import options cannot be enabled together. +{{/lazyNativeImport}} diff --git a/modules/openapi-generator/src/main/resources/python/model_anyof.mustache b/modules/openapi-generator/src/main/resources/python/model_anyof.mustache index ff77f479f37a..06a44edf3ecf 100644 --- a/modules/openapi-generator/src/main/resources/python/model_anyof.mustache +++ b/modules/openapi-generator/src/main/resources/python/model_anyof.mustache @@ -7,7 +7,7 @@ import re # noqa: F401 {{{.}}} {{/vendorExtensions.x-py-other-imports}} {{#vendorExtensions.x-py-model-imports}} -{{{.}}} +{{#lazyNativeImport}}lazy {{/lazyNativeImport}}{{{.}}} {{/vendorExtensions.x-py-model-imports}} from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict from typing_extensions import Literal, Self @@ -220,7 +220,7 @@ class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}} {{#vendorExtensions.x-py-postponed-model-imports.size}} {{#vendorExtensions.x-py-postponed-model-imports}} -{{{.}}} +{{#lazyNativeImport}}lazy {{/lazyNativeImport}}{{{.}}} {{/vendorExtensions.x-py-postponed-model-imports}} # TODO: Rewrite to not use raise_errors {{classname}}.model_rebuild(raise_errors=False) diff --git a/modules/openapi-generator/src/main/resources/python/model_generic.mustache b/modules/openapi-generator/src/main/resources/python/model_generic.mustache index 6f3b20789979..7bf2ac4b6503 100644 --- a/modules/openapi-generator/src/main/resources/python/model_generic.mustache +++ b/modules/openapi-generator/src/main/resources/python/model_generic.mustache @@ -7,7 +7,7 @@ import json {{{.}}} {{/vendorExtensions.x-py-other-imports}} {{#vendorExtensions.x-py-model-imports}} -{{{.}}} +{{#lazyNativeImport}}lazy {{/lazyNativeImport}}{{{.}}} {{/vendorExtensions.x-py-model-imports}} from typing import Optional, Set from typing_extensions import Self @@ -660,7 +660,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb {{#vendorExtensions.x-py-postponed-model-imports.size}} {{#vendorExtensions.x-py-postponed-model-imports}} -{{{.}}} +{{#lazyNativeImport}}lazy {{/lazyNativeImport}}{{{.}}} {{/vendorExtensions.x-py-postponed-model-imports}} # TODO: Rewrite to not use raise_errors {{classname}}.model_rebuild(raise_errors=False) diff --git a/modules/openapi-generator/src/main/resources/python/model_oneof.mustache b/modules/openapi-generator/src/main/resources/python/model_oneof.mustache index 696d3ff4be6f..142708cbdb10 100644 --- a/modules/openapi-generator/src/main/resources/python/model_oneof.mustache +++ b/modules/openapi-generator/src/main/resources/python/model_oneof.mustache @@ -5,7 +5,7 @@ import pprint {{{.}}} {{/vendorExtensions.x-py-other-imports}} {{#vendorExtensions.x-py-model-imports}} -{{{.}}} +{{#lazyNativeImport}}lazy {{/lazyNativeImport}}{{{.}}} {{/vendorExtensions.x-py-model-imports}} from pydantic import StrictStr, Field from typing import Union, List, Set, Optional, Dict @@ -248,7 +248,7 @@ class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}} {{#vendorExtensions.x-py-postponed-model-imports.size}} {{#vendorExtensions.x-py-postponed-model-imports}} -{{{.}}} +{{#lazyNativeImport}}lazy {{/lazyNativeImport}}{{{.}}} {{/vendorExtensions.x-py-postponed-model-imports}} # TODO: Rewrite to not use raise_errors {{classname}}.model_rebuild(raise_errors=False) diff --git a/modules/openapi-generator/src/main/resources/python/pyproject.mustache b/modules/openapi-generator/src/main/resources/python/pyproject.mustache index 109cff467caa..dedc0d24ef92 100644 --- a/modules/openapi-generator/src/main/resources/python/pyproject.mustache +++ b/modules/openapi-generator/src/main/resources/python/pyproject.mustache @@ -37,7 +37,7 @@ packages = [ include = ["{{#sourceFolder}}{{{sourceFolder}}}/{{/sourceFolder}}{{packageName}}/py.typed"] [tool.poetry.dependencies] -python = "^3.11" +python = "^{{#lazyNativeImport}}3.15{{/lazyNativeImport}}{{^lazyNativeImport}}3.11{{/lazyNativeImport}}" {{^async}} urllib3 = ">= 2.7.0, < 3.0.0" {{/async}} @@ -56,11 +56,11 @@ httpx2 = ">= 2.13.0, < 3" pem = ">= 19.3.0" pycryptodome = ">= 3.9.0" {{/hasHttpSignatureMethods}} -pydantic = ">= 2.11" +pydantic = ">= {{#lazyNativeImport}}2.14{{/lazyNativeImport}}{{^lazyNativeImport}}2.11{{/lazyNativeImport}}" typing-extensions = ">= 4.7.1" {{/poetry1}} {{^poetry1}} -requires-python = ">=3.11" +requires-python = ">={{#lazyNativeImport}}3.15{{/lazyNativeImport}}{{^lazyNativeImport}}3.11{{/lazyNativeImport}}" dependencies = [ {{^async}} @@ -81,7 +81,7 @@ dependencies = [ "pem (>=19.3.0)", "pycryptodome (>=3.9.0)", {{/hasHttpSignatureMethods}} - "pydantic (>=2.11)", + "pydantic (>={{#lazyNativeImport}}2.14{{/lazyNativeImport}}{{^lazyNativeImport}}2.11{{/lazyNativeImport}})", "typing-extensions (>=4.7.1)", ] diff --git a/modules/openapi-generator/src/main/resources/python/requirements.mustache b/modules/openapi-generator/src/main/resources/python/requirements.mustache index 36f07decbdbf..1265d6cfad8f 100644 --- a/modules/openapi-generator/src/main/resources/python/requirements.mustache +++ b/modules/openapi-generator/src/main/resources/python/requirements.mustache @@ -16,5 +16,5 @@ httpx2 >= 2.13.0, < 3 pem >= 19.3.0 pycryptodome >= 3.9.0 {{/hasHttpSignatureMethods}} -pydantic >= 2.11 +pydantic >= {{#lazyNativeImport}}2.14{{/lazyNativeImport}}{{^lazyNativeImport}}2.11{{/lazyNativeImport}} typing-extensions >= 4.7.1 diff --git a/modules/openapi-generator/src/main/resources/python/setup.mustache b/modules/openapi-generator/src/main/resources/python/setup.mustache index 8f0da3eee18d..487cedc0180a 100644 --- a/modules/openapi-generator/src/main/resources/python/setup.mustache +++ b/modules/openapi-generator/src/main/resources/python/setup.mustache @@ -10,7 +10,7 @@ from setuptools import setup, find_packages # noqa: H301 # http://pypi.python.org/pypi/setuptools NAME = "{{{projectName}}}" VERSION = "{{packageVersion}}" -PYTHON_REQUIRES = ">= 3.11" +PYTHON_REQUIRES = ">= {{#lazyNativeImport}}3.15{{/lazyNativeImport}}{{^lazyNativeImport}}3.11{{/lazyNativeImport}}" REQUIRES = [ {{^async}} "urllib3 >= 2.7.0, < 3.0.0", @@ -30,7 +30,7 @@ REQUIRES = [ "pem >= 19.3.0", "pycryptodome >= 3.9.0", {{/hasHttpSignatureMethods}} - "pydantic >= 2.11", + "pydantic >= {{#lazyNativeImport}}2.14{{/lazyNativeImport}}{{^lazyNativeImport}}2.11{{/lazyNativeImport}}", "typing-extensions >= 4.7.1", ] @@ -43,6 +43,9 @@ setup( url="{{packageUrl}}", keywords=["OpenAPI", "OpenAPI-Generator", "{{{appName}}}"], install_requires=REQUIRES, +{{#lazyNativeImport}} + python_requires=PYTHON_REQUIRES, +{{/lazyNativeImport}} {{#sourceFolder}} package_dir={"": "{{{sourceFolder}}}"}, {{/sourceFolder}} diff --git a/modules/openapi-generator/src/main/resources/python/travis.mustache b/modules/openapi-generator/src/main/resources/python/travis.mustache index 401fbf7dcc46..fa8576d6883c 100644 --- a/modules/openapi-generator/src/main/resources/python/travis.mustache +++ b/modules/openapi-generator/src/main/resources/python/travis.mustache @@ -1,10 +1,12 @@ # ref: https://docs.travis-ci.com/user/languages/python language: python python: +{{^lazyNativeImport}} - "3.11" - "3.12" - "3.13" - "3.14" +{{/lazyNativeImport}} - "3.15" # uncomment the following if needed #- "3.14-dev" # 3.14 development branch diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index 34e060fac4e9..2d30ea0ded08 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -39,6 +39,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -49,6 +50,98 @@ public class PythonClientCodegenTest { + @DataProvider(name = "pythonNativeLazyOptions") + public Object[][] pythonNativeLazyOptions() { + List options = new ArrayList<>(); + for (String library : List.of("urllib3", "asyncio", "httpx", "httpx2")) { + for (boolean poetry1 : List.of(false, true)) { + options.add(new Object[] {library, poetry1}); + } + } + return options.toArray(new Object[0][]); + } + + @Test(dataProvider = "pythonNativeLazyOptions") + public void testPythonNativeLazyOptions(String library, boolean poetry1) throws Exception { + PythonClientCodegen codegen = new PythonClientCodegen(); + codegen.setLibrary(library); + codegen.additionalProperties().put(PythonClientCodegen.LAZY_NATIVE_IMPORT, "true"); + // Exercise both packaging formats, and the sync operation decorators too. + codegen.additionalProperties().put(PythonClientCodegen.POETRY1_FALLBACK, poetry1); + if ("httpx".equals(library) || "httpx2".equals(library)) { + codegen.additionalProperties().put(PythonClientCodegen.SUPPORT_HTTPX_SYNC, true); + } + Path output = Paths.get(generateFiles(codegen, "src/test/resources/3_1/python/lazy-imports.yaml")); + Path api = output.resolve("openapi_client/api/parents_api.py"); + assertFileContains(api, "from pydantic import validate_call", "from openapi_client.api_client import ApiClient"); + assertFileContains(api, "@validate_call\n"); + Path exports = output.resolve("openapi_client/__init__.py"); + assertFileContains(output.resolve(".gitlab-ci.yml"), "pytest-3.15:", "image: python:3.15-alpine"); + assertFileContains(output.resolve(".travis.yml"), " - \"3.15\""); + assertFileContains(output.resolve(".github/workflows/python.yml"), "python-version: [\"3.15\"]"); + for (String version : List.of("3.11", "3.12", "3.13", "3.14")) { + Assert.assertFalse(Files.readString(output.resolve(".gitlab-ci.yml")).contains("pytest-" + version + ":")); + Assert.assertFalse(Files.readString(output.resolve(".travis.yml")).contains(" - \"" + version + "\"")); + } + assertFileContains(api, "lazy from openapi_client.models.parent import Parent"); + assertFileContains(exports, "lazy from openapi_client.api.parents_api import ParentsApi", + "lazy from openapi_client.models.parent import Parent", "from openapi_client.api_client import ApiClient"); + TestUtils.assertFileNotContains(exports, "def __getattr__"); + assertFileContains(output.resolve("openapi_client/api/__init__.py"), "lazy from openapi_client.api.parents_api"); + assertFileContains(output.resolve("openapi_client/models/__init__.py"), "lazy from openapi_client.models.parent"); + assertFileContains(output.resolve("openapi_client/models/parent.py"), "lazy from openapi_client.models.child"); + assertFileContains(output.resolve("openapi_client/models/choice.py"), "lazy from openapi_client.models.child"); + assertFileContains(output.resolve("openapi_client/models/any_choice.py"), "lazy from openapi_client.models.child"); + assertFileContains(output.resolve("openapi_client/models/left.py"), "lazy from openapi_client.models.right"); + assertFileContains(output.resolve("setup.py"), "PYTHON_REQUIRES = \">= 3.15\"", "python_requires=PYTHON_REQUIRES", "pydantic >= 2.14"); + assertFileContains(output.resolve("requirements.txt"), "pydantic >= 2.14"); + assertFileContains(output.resolve("pyproject.toml"), poetry1 ? "python = \"^3.15\"" : "requires-python = \">=3.15\""); + assertFileContains(output.resolve("pyproject.toml"), poetry1 ? "pydantic = \">= 2.14\"" : "pydantic (>=2.14)"); + assertFileContains(output.resolve("README.md"), "Python 3.15+", "Native lazy imports"); + + // Optional runtime checks use an interpreter with installed client dependencies. + // The Python suite skips native imports below Python 3.15 before loading generated code. + String python = System.getProperty("pythonExecutable"); + if (python != null) { + Path log = Files.createTempFile("python-lazy-runtime-", ".log"); + Process process = new ProcessBuilder(python, "src/test/resources/3_1/python/lazy_imports_runtime.py", + output.toString(), library) + .redirectErrorStream(true).redirectOutput(log.toFile()).start(); + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly(); + Assert.fail("Python native lazy imports runtime check timed out: " + log); + } + Assert.assertEquals(process.exitValue(), 0, Files.readString(log)); + } + } + + @Test + public void testLazyImportStrategiesAreMutuallyExclusive() { + for (Object enabled : List.of(true, "true")) { + PythonClientCodegen codegen = new PythonClientCodegen(); + codegen.additionalProperties().put(PythonClientCodegen.LAZY_IMPORTS, enabled); + codegen.additionalProperties().put(PythonClientCodegen.LAZY_NATIVE_IMPORT, enabled); + IllegalArgumentException exception = Assert.expectThrows(IllegalArgumentException.class, codegen::processOpts); + Assert.assertTrue(exception.getMessage().contains("cannot be combined")); + } + } + + @Test + public void testNativeLazySourceOnlyWithCustomPackage() throws IOException { + PythonClientCodegen codegen = new PythonClientCodegen(); + codegen.setLazyNativeImport(true); + codegen.additionalProperties().put(CodegenConstants.PACKAGE_NAME, "nested.client"); + codegen.additionalProperties().put(CodegenConstants.SOURCE_FOLDER, "src"); + codegen.additionalProperties().put(CodegenConstants.SOURCECODEONLY_GENERATION, true); + Path output = Paths.get(generateFiles(codegen, "src/test/resources/3_1/python/lazy-imports.yaml")); + assertFileContains(output.resolve("src/nested/client/api/parents_api.py"), + "lazy from nested.client.models.parent import Parent", "@validate_call\n"); + assertFileContains(output.resolve("src/nested/client/models/parent.py"), "lazy from nested.client.models.child"); + assertFileContains(output.resolve("nested/client_README.md"), "Python 3.15+", "pydantic >= 2.14", + "Native lazy imports"); + Assert.assertFalse(Files.exists(output.resolve("pyproject.toml"))); + } + @DataProvider(name = "pythonPackagingOptions") public Object[][] pythonPackagingOptions() { List options = new ArrayList<>(); @@ -196,6 +289,8 @@ public void testInitialConfigValues() throws Exception { Boolean.FALSE); Assert.assertEquals(codegen.isHideGenerationTimestamp(), true); Assert.assertNull(codegen.additionalProperties().get(CodegenConstants.SOURCE_FOLDER)); + Assert.assertEquals(codegen.additionalProperties().get(PythonClientCodegen.LAZY_NATIVE_IMPORT), Boolean.FALSE); + Assert.assertEquals(codegen.generatorLanguageVersion(), "3.11+"); } @Test diff --git a/modules/openapi-generator/src/test/resources/3_1/python/lazy-imports.yaml b/modules/openapi-generator/src/test/resources/3_1/python/lazy-imports.yaml new file mode 100644 index 000000000000..460ee3e871fe --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/python/lazy-imports.yaml @@ -0,0 +1,74 @@ +openapi: 3.0.3 +info: + title: Lazy imports regression + version: 1.0.0 +paths: + /parents/{id}: + get: + tags: [Parents] + operationId: getParent + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: A parent with nested children + content: + application/json: + schema: + $ref: '#/components/schemas/Parent' + /unused: + get: + tags: [Unused] + operationId: getUnused + responses: + '200': + description: An unused model + content: + application/json: + schema: + $ref: '#/components/schemas/Unused' +components: + schemas: + Child: + type: object + required: [id, name] + properties: + id: + type: integer + name: + type: string + Parent: + type: object + required: [children] + properties: + children: + type: array + items: + $ref: '#/components/schemas/Child' + Unused: + type: object + properties: + value: + type: string + Choice: + oneOf: + - $ref: '#/components/schemas/Child' + - type: string + AnyChoice: + anyOf: + - $ref: '#/components/schemas/Child' + - type: integer + Left: + type: object + properties: + right: + $ref: '#/components/schemas/Right' + Right: + type: object + properties: + left: + $ref: '#/components/schemas/Left' diff --git a/modules/openapi-generator/src/test/resources/3_1/python/lazy_imports_runtime.py b/modules/openapi-generator/src/test/resources/3_1/python/lazy_imports_runtime.py new file mode 100644 index 000000000000..7c92141ac155 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/python/lazy_imports_runtime.py @@ -0,0 +1,125 @@ +"""Runtime contracts for native lazy clients generated from lazy-imports.yaml. + +Run through PythonClientCodegenTest with -DpythonExecutable=/path/to/python. +Native import checks are skipped on Python versions below 3.15. +The interpreter needs the dependencies for all four Python libraries. +""" + +import asyncio +import compileall +import importlib +import inspect +import json +from pathlib import Path +import sys +import unittest + + +class Response: + status = 200 + reason = "OK" + headers = {"content-type": "application/json"} + data = json.dumps({"children": [{"id": 7, "name": "child"}]}).encode() + + def getheader(self, name, default=None): + return self.headers.get(name, default) + + def read(self): + return self.data + + +class AsyncResponse(Response): + async def read(self): + return self.data + + +async def exercise_api(package, library): + from pydantic import ValidationError + + client = package.ApiClient(package.Configuration(host="https://example.invalid")) + calls = [] + + def call(*args, **kwargs): + calls.append(args) + assert "/parents/7" in args[1] + return Response() + + async def async_call(*args, **kwargs): + call(*args, **kwargs) + return AsyncResponse() + + client.call_api = call if library == "urllib3" else async_call + api = package.ParentsApi(client) + + async def invoke(**kwargs): + value = api.get_parent(**kwargs) + return await value if inspect.isawaitable(value) else value + + try: + # Argument errors must still be raised before any HTTP request. + for arguments in ({}, {"id": "invalid"}): + try: + await invoke(**arguments) + except ValidationError: + pass + else: + raise AssertionError("Invalid operation arguments were accepted") + assert not calls + + for _ in range(2): + result = await invoke(id=7) + assert result.children[0].id == 7 + assert result.to_dict() == {"children": [{"id": 7, "name": "child"}]} + assert client.sanitize_for_serialization(result) == result.to_dict() + assert len(calls) == 2 + + from openapi_client import Parent + from openapi_client.models import Parent as ModelExport + from openapi_client.api import ParentsApi + + assert isinstance(result, Parent) + assert Parent is ModelExport + assert isinstance(api, ParentsApi) + assert "children" in Parent.model_json_schema()["properties"] + try: + Parent.from_dict({"children": [{"id": "invalid", "name": "child"}]}) + except ValidationError: + pass + else: + raise AssertionError("Invalid nested model was accepted") + assert Parent.from_json(result.to_json()) == result + + from openapi_client import AnyChoice, Choice, Left, Right + + for composed in (Choice, AnyChoice): + value = composed.from_dict({"id": 7, "name": "child"}) + assert value.actual_instance.id == 7 + assert composed.model_json_schema() + circular = Left.from_dict({"right": {"left": None}}) + assert isinstance(circular.right, Right) + assert circular.to_dict() == {"right": {}} + assert Left.model_json_schema() + finally: + if library == "urllib3": + client.rest_client.pool_manager.clear() + else: + await client.close() + + +class TestGeneratedClient(unittest.TestCase): + @unittest.skipIf( + sys.version_info < (3, 15), "Native lazy imports require Python 3.15 or newer" + ) + def test_native_lazy_imports(self): + sys.path.insert(0, self.output) + self.assertTrue(compileall.compile_dir(Path(self.output) / "openapi_client", quiet=1)) + package = importlib.import_module("openapi_client") + self.assertTrue(Path(package.__file__).resolve().is_relative_to(Path(self.output).resolve())) + self.assertNotIn("openapi_client.api.unused_api", sys.modules) + self.assertNotIn("openapi_client.models.unused", sys.modules) + asyncio.run(exercise_api(package, self.library)) + + +if __name__ == "__main__": + TestGeneratedClient.output, TestGeneratedClient.library = sys.argv[1:] + unittest.main(argv=[sys.argv[0]], verbosity=2)