From 01a2bfc261b255a2fbe7c0dac6c5f65e6452ef54 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Tue, 23 Jun 2026 16:59:10 -0400 Subject: [PATCH 1/2] [python] isolate implicit API clients Generated API instances currently share the cached default ApiClient and its Configuration. Mutating credentials, headers, cookies, or transport settings through one implicit API can therefore affect another. Add the default-off useIndependentImplicitClients option. Treat isolation as one ownership contract rather than separate configuration copy and client-cache switches: an API constructed without an explicit or registered default client owns a private client and copied configuration. Its close method and context manager release that client. Explicit and registered clients remain shared and caller-owned. Make Configuration.set_default() copy configuration-owned containers and make get_default_copy() return another copy. Keep process-global logging resources and event-loop-bound transport objects shared, but copy the containers around those objects. Copy proxy-header mappings through their mapping protocol so mutable dictionaries and immutable multidict proxies both produce independent defaults. For aiohttp, preserve connector=None as an explicit request for ClientSession's default connector. Non-None caller-supplied connectors remain caller-owned. Keep the option disabled by default because existing clients may rely on mutating the shared defaults. Rename normalized operation IDs that would overwrite lifecycle methods only when the corresponding lifecycle is generated. Closes #24124 --- bin/configs/python-aiohttp.yaml | 1 + bin/configs/python-lazyImports.yaml | 1 + docs/generators/python.md | 1 + .../languages/PythonClientCodegen.java | 104 ++++++++++++++ .../src/main/resources/python/api.mustache | 54 ++++++++ .../main/resources/python/api_client.mustache | 29 ++++ .../resources/python/asyncio/rest.mustache | 23 +++- .../resources/python/common_README.mustache | 25 ++++ .../resources/python/configuration.mustache | 74 ++++++++++ .../src/main/resources/python/rest.mustache | 5 + .../resources/python/tornado/rest.mustache | 10 ++ .../python/PythonClientCodegenTest.java | 130 ++++++++++++++++++ .../independent-client-operation-names.yaml | 23 ++++ .../tests/test_configuration.py | 114 +++++++++++++++ .../python-aiohttp/tests/test_pet_api.py | 19 +-- .../tests/test_configuration.py | 100 +++++++++++++- .../python-lazyImports/tests/test_pet_api.py | 12 +- 17 files changed, 703 insertions(+), 22 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/python/independent-client-operation-names.yaml create mode 100644 samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py diff --git a/bin/configs/python-aiohttp.yaml b/bin/configs/python-aiohttp.yaml index 14a1adeffd39..5d10f558ee57 100644 --- a/bin/configs/python-aiohttp.yaml +++ b/bin/configs/python-aiohttp.yaml @@ -4,6 +4,7 @@ inputSpec: modules/openapi-generator/src/test/resources/3_0/python/petstore-with templateDir: modules/openapi-generator/src/main/resources/python library: asyncio additionalProperties: + useIndependentImplicitClients: true packageName: petstore_api mapNumberTo: float poetry1: true diff --git a/bin/configs/python-lazyImports.yaml b/bin/configs/python-lazyImports.yaml index 88c0b631cafe..0b236de8b8ad 100644 --- a/bin/configs/python-lazyImports.yaml +++ b/bin/configs/python-lazyImports.yaml @@ -3,6 +3,7 @@ outputDir: samples/openapi3/client/petstore/python-lazyImports inputSpec: modules/openapi-generator/src/test/resources/3_0/python/petstore-with-fake-endpoints-models-for-testing.yaml templateDir: modules/openapi-generator/src/main/resources/python additionalProperties: + useIndependentImplicitClients: true packageName: petstore_api useOneOfDiscriminatorLookup: "true" disallowAdditionalPropertiesIfNotPresent: false diff --git a/docs/generators/python.md b/docs/generators/python.md index f62f396f2786..35099afa3836 100644 --- a/docs/generators/python.md +++ b/docs/generators/python.md @@ -36,6 +36,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |recursionLimit|Set the recursion limit. If not set, use the system default value.| |null| |setEnsureAsciiToFalse|When set to true, add `ensure_ascii=False` in json.dumps when creating the HTTP request body.| |false| |supportHttpxSync|Generate synchronous '_sync' variants of each API method (httpx library only). Each '_sync' method simply calls the corresponding async method and waits for its completion, so both synchronous and asynchronous methods are available from the same API class.| |false| +|useIndependentImplicitClients|Give API instances without an explicit or registered default ApiClient an owned client with a copied Configuration.| |false| |useOneOfDiscriminatorLookup|Use the discriminator's mapping in oneOf to speed up the model lookup. IMPORTANT: Validation (e.g. one and only one match in oneOf's schemas) will be skipped.| |false| ## IMPORT MAPPING 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 c12cab69b103..3933c5d946bc 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 @@ -25,6 +25,8 @@ import org.openapitools.codegen.meta.GeneratorMetadata; import org.openapitools.codegen.meta.Stability; import org.openapitools.codegen.meta.features.*; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.OperationsMap; import org.openapitools.codegen.utils.ModelUtils; import org.openapitools.codegen.utils.ProcessUtils; import org.slf4j.Logger; @@ -33,9 +35,13 @@ import java.io.File; import java.util.EnumSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import static org.openapitools.codegen.utils.StringUtils.camelize; import static org.openapitools.codegen.utils.StringUtils.underscore; /** @@ -54,6 +60,15 @@ public class PythonClientCodegen extends AbstractPythonCodegen implements Codege public static final String LAZY_IMPORTS = "lazyImports"; public static final String BUILD_SYSTEM = "buildSystem"; public static final String SUPPORT_HTTPX_SYNC = "supportHttpxSync"; + public static final String USE_INDEPENDENT_IMPLICIT_CLIENTS = "useIndependentImplicitClients"; + private static final Set SYNC_API_LIFECYCLE_METHODS = + Set.of("close", "__enter__", "__exit__"); + private static final Set ASYNC_API_LIFECYCLE_METHODS = + Set.of("close", "__aenter__", "__aexit__"); + private static final Set HTTPX_SYNC_API_LIFECYCLE_METHODS = + Set.of("close_sync", "__enter__", "__exit__"); + private static final Set INDEPENDENT_API_MEMBER_NAMES = + Set.of("_owned_api_client"); @Setter protected String packageUrl; protected String apiDocPath = "docs/"; @@ -62,6 +77,7 @@ public class PythonClientCodegen extends AbstractPythonCodegen implements Codege @Setter protected String datetimeFormat = "%Y-%m-%dT%H:%M:%S.%f%z"; @Setter protected String dateFormat = "%Y-%m-%d"; @Setter protected boolean setEnsureAsciiToFalse = false; + @Setter protected boolean useIndependentImplicitClients = false; private String testFolder; @@ -162,6 +178,10 @@ public PythonClientCodegen() { cliOptions.add(CliOption.newBoolean(SUPPORT_HTTPX_SYNC, "Generate synchronous '_sync' variants of each API method (httpx library only). " + "Each '_sync' method simply calls the corresponding async method and waits for its completion, " + "so both synchronous and asynchronous methods are available from the same API class.").defaultValue(Boolean.FALSE.toString())); + cliOptions.add(CliOption.newBoolean(USE_INDEPENDENT_IMPLICIT_CLIENTS, + "Give API instances without an explicit or registered default ApiClient " + + "an owned client with a copied Configuration.") + .defaultValue(Boolean.FALSE.toString())); supportedLibraries.put("urllib3", "urllib3-based client"); supportedLibraries.put("asyncio", "asyncio-based client"); @@ -287,6 +307,14 @@ public void processOpts() { } } + if (additionalProperties.containsKey(USE_INDEPENDENT_IMPLICIT_CLIENTS)) { + setUseIndependentImplicitClients( + convertPropertyToBooleanAndWriteBack(USE_INDEPENDENT_IMPLICIT_CLIENTS)); + } else { + additionalProperties.put( + USE_INDEPENDENT_IMPLICIT_CLIENTS, useIndependentImplicitClients); + } + String modelPath = packagePath() + File.separatorChar + modelPackage.replace('.', File.separatorChar); String apiPath = packagePath() + File.separatorChar + apiPackage.replace('.', File.separatorChar); @@ -485,6 +513,82 @@ protected void addAdditionPropertiesToCodeGenModel(CodegenModel codegenModel, Sc } } + @Override + public OperationsMap postProcessOperationsWithModels( + OperationsMap objs, List allModels) { + if (useIndependentImplicitClients) { + renameIndependentClientOperationMembers( + objs.getOperations().getOperation()); + } + return super.postProcessOperationsWithModels(objs, allModels); + } + + private void renameIndependentClientOperationMembers( + List operations) { + Set apiMembers = independentClientApiMembers(); + Set occupiedMembers = new HashSet<>(apiMembers); + for (CodegenOperation operation : operations) { + if (!apiMembers.contains(operation.operationId)) { + occupiedMembers.addAll(generatedOperationMembers(operation.operationId)); + } + } + + for (CodegenOperation operation : operations) { + String originalName = operation.operationId; + if (!apiMembers.contains(originalName)) { + continue; + } + + String candidate = "call_" + originalName; + int suffix = 2; + while (generatedOperationMembers(candidate).stream() + .anyMatch(occupiedMembers::contains)) { + candidate = "call_" + originalName + "_" + suffix; + suffix++; + } + + LOGGER.warn("{} conflicts with a generated API member. Renamed to {}", + originalName, candidate); + operation.operationId = candidate; + operation.operationIdLowerCase = candidate.toLowerCase(Locale.ROOT); + operation.operationIdCamelCase = camelize(candidate); + operation.operationIdSnakeCase = underscore(candidate); + occupiedMembers.addAll(generatedOperationMembers(candidate)); + } + } + + private Set independentClientApiMembers() { + Set members = new HashSet<>(INDEPENDENT_API_MEMBER_NAMES); + boolean async = "asyncio".equals(getLibrary()) || "httpx".equals(getLibrary()); + members.addAll(async + ? ASYNC_API_LIFECYCLE_METHODS + : SYNC_API_LIFECYCLE_METHODS); + if (supportsHttpxSync()) { + members.addAll(HTTPX_SYNC_API_LIFECYCLE_METHODS); + } + return members; + } + + private Set generatedOperationMembers(String operationId) { + Set members = new HashSet<>(Set.of( + operationId, + operationId + "_with_http_info", + operationId + "_without_preload_content", + "_" + operationId + "_serialize")); + if (supportsHttpxSync()) { + members.add(operationId + "_sync"); + members.add(operationId + "_sync_with_http_info"); + members.add(operationId + "_sync_without_preload_content"); + } + return members; + } + + private boolean supportsHttpxSync() { + return "httpx".equals(getLibrary()) + && Boolean.parseBoolean(String.valueOf( + additionalProperties.get(SUPPORT_HTTPX_SYNC))); + } + @Override public String escapeReservedWord(String name) { if (this.reservedWordsMappings().containsKey(name)) { diff --git a/modules/openapi-generator/src/main/resources/python/api.mustache b/modules/openapi-generator/src/main/resources/python/api.mustache index 826b25393c5b..709f38a48250 100644 --- a/modules/openapi-generator/src/main/resources/python/api.mustache +++ b/modules/openapi-generator/src/main/resources/python/api.mustache @@ -26,9 +26,63 @@ class {{classname}}: """ def __init__(self, api_client=None) -> None: +{{#useIndependentImplicitClients}} + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. + if api_client is None: + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} if api_client is None: api_client = ApiClient.get_default() +{{/useIndependentImplicitClients}} self.api_client = api_client +{{#useIndependentImplicitClients}} + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + +{{#async}} + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() +{{#supportHttpxSync}} + + def close_sync(self) -> None: + run_sync(self.close()) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close_sync() +{{/supportHttpxSync}} +{{/async}} +{{^async}} + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() +{{/async}} +{{/useIndependentImplicitClients}} {{#operation}} diff --git a/modules/openapi-generator/src/main/resources/python/api_client.mustache b/modules/openapi-generator/src/main/resources/python/api_client.mustache index e414300cd095..84a15bbb51bb 100644 --- a/modules/openapi-generator/src/main/resources/python/api_client.mustache +++ b/modules/openapi-generator/src/main/resources/python/api_client.mustache @@ -75,7 +75,12 @@ class ApiClient: ) -> None: # use default configuration if none is provided if configuration is None: +{{#useIndependentImplicitClients}} + configuration = Configuration.get_default_copy() +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} configuration = Configuration.get_default() +{{/useIndependentImplicitClients}} self.configuration = configuration self.rest_client = rest.RESTClientObject(configuration) @@ -98,11 +103,21 @@ class ApiClient: await self.rest_client.close() {{/async}} {{^async}} +{{#useIndependentImplicitClients}} + def close(self): + self.rest_client.close() + +{{/useIndependentImplicitClients}} def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): +{{#useIndependentImplicitClients}} + self.close() +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} pass +{{/useIndependentImplicitClients}} {{/async}} @property @@ -120,8 +135,21 @@ class ApiClient: _default = None +{{#useIndependentImplicitClients}} + @classmethod + def _get_default_or_new(cls): + if cls._default is not None: + return cls._default, False + return cls(), True + +{{/useIndependentImplicitClients}} @classmethod def get_default(cls): +{{#useIndependentImplicitClients}} + """Return the registered default ApiClient, or a new client.""" + return cls._get_default_or_new()[0] +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} """Return new instance of ApiClient. This method returns newly created, based on default constructor, @@ -133,6 +161,7 @@ class ApiClient: if cls._default is None: cls._default = ApiClient() return cls._default +{{/useIndependentImplicitClients}} @classmethod def set_default(cls, default): diff --git a/modules/openapi-generator/src/main/resources/python/asyncio/rest.mustache b/modules/openapi-generator/src/main/resources/python/asyncio/rest.mustache index 952d3f99bb43..fefd5e2af81d 100644 --- a/modules/openapi-generator/src/main/resources/python/asyncio/rest.mustache +++ b/modules/openapi-generator/src/main/resources/python/asyncio/rest.mustache @@ -118,16 +118,37 @@ class RESTClientObject: fields (trace_configs / client_session_kwargs) are read via getattr so older Configuration objects remain compatible. """ + extra = getattr(self.configuration, "client_session_kwargs", None) kwargs: Dict[str, Any] = { +{{#useIndependentImplicitClients}} + # Preserve an explicit connector=None override. ClientSession then + # creates and owns its default connector instead of using ours: + # https://github.com/aio-libs/aiohttp/blob/v3.8.4/aiohttp/client.py#L238-L257 + "connector": ( + extra["connector"] + if extra is not None and "connector" in extra + else self._create_connector() + ), +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} "connector": self._create_connector(), +{{/useIndependentImplicitClients}} "trust_env": True, } trace_configs = getattr(self.configuration, "trace_configs", None) if trace_configs is not None: kwargs["trace_configs"] = trace_configs - extra = getattr(self.configuration, "client_session_kwargs", None) if extra: kwargs.update(extra) +{{#useIndependentImplicitClients}} + if extra.get("connector") is not None: + # Copied configurations can share a caller-supplied connector. + # aiohttp requires connector_owner=False for shared connectors: + # https://docs.aiohttp.org/en/stable/client_advanced.html#connectors + kwargs["connector_owner"] = False + else: + kwargs["connector_owner"] = True +{{/useIndependentImplicitClients}} return aiohttp.ClientSession(**kwargs) async def request( diff --git a/modules/openapi-generator/src/main/resources/python/common_README.mustache b/modules/openapi-generator/src/main/resources/python/common_README.mustache index 0b0798098673..0001c1be1c03 100644 --- a/modules/openapi-generator/src/main/resources/python/common_README.mustache +++ b/modules/openapi-generator/src/main/resources/python/common_README.mustache @@ -29,6 +29,31 @@ from pprint import pprint {{/-first}}{{/operation}}{{/operations}}{{/-first}}{{/apis}}{{/apiInfo}} ``` +{{#useIndependentImplicitClients}} +An API instance constructed without an explicit or registered default +`ApiClient` owns its client. Use the API as a context manager: + +```python +{{#apiInfo}}{{#apis}}{{#-first}}{{#operations}}{{#async}}async {{/async}}with {{{packageName}}}.{{{classname}}}() as api_instance: + # Call API methods through api_instance. + pass +{{/operations}}{{/-first}}{{/apis}}{{/apiInfo}} +``` + +Direct construction requires +`{{#async}}await {{/async}}api_instance.close()`. Closing an API constructed +with an explicit or registered default client is a no-op; the caller remains +responsible for that client. Configuration-owned values are copied for an +implicit client. Logging resources retain their identity. Configuration +containers and proxy-header mappings are copied; other stateful transport +extension objects remain caller-owned. + +`Configuration.set_default()` stores a copy, so later changes to the supplied +object do not affect new implicit clients. `Configuration.get_default()` +returns that stored baseline, while `Configuration.get_default_copy()` returns +a copy. + +{{/useIndependentImplicitClients}} ## Documentation for API Endpoints All URIs are relative to *{{{basePath}}}* diff --git a/modules/openapi-generator/src/main/resources/python/configuration.mustache b/modules/openapi-generator/src/main/resources/python/configuration.mustache index 6ee3283dd72a..b29c831e2de2 100644 --- a/modules/openapi-generator/src/main/resources/python/configuration.mustache +++ b/modules/openapi-generator/src/main/resources/python/configuration.mustache @@ -204,6 +204,10 @@ class Configuration: :param client_session_kwargs: Extra keyword arguments merged into aiohttp.ClientSession(**kwargs) (e.g. json_serialize=orjson.dumps, cookie_jar=aiohttp.DummyCookieJar()). +{{#useIndependentImplicitClients}} + A non-None connector remains caller-owned because copied configurations + may share it. +{{/useIndependentImplicitClients}} {{/asyncio}} {{#httpx}} :param retries: int - Retry configuration. @@ -516,6 +520,10 @@ conf = {{{packageName}}}.Configuration( """ self.client_session_kwargs = client_session_kwargs """Extra kwargs merged into aiohttp.ClientSession(**kwargs). + +{{#useIndependentImplicitClients}} + A non-None connector remains caller-owned. +{{/useIndependentImplicitClients}} """ {{/asyncio}} # Enable client side validation @@ -538,12 +546,45 @@ conf = {{{packageName}}}.Configuration( result = cls.__new__(cls) memo[id(self)] = result for k, v in self.__dict__.items(): +{{#useIndependentImplicitClients}} + if k in ('logger', 'logger_file_handler', 'logger_stream_handler'): + continue + if k == 'proxy_headers': + # MultiDictProxy rejects generic copying, but copy() returns an + # independent mutable multidict and preserves duplicate headers: + # https://multidict.aio-libs.org/en/stable/multidict/ + copy_method = getattr(v, 'copy', None) + if callable(copy_method): + setattr(result, k, copy_method()) + continue +{{#asyncio}} + if k in ('client_session_kwargs', 'trace_configs'): + setattr(result, k, copy.copy(v)) + continue + if k == 'retries': + setattr(result, k, v) + continue +{{/asyncio}} + setattr(result, k, copy.deepcopy(v, memo)) + + # Loggers and their handlers are process-global. +{{#asyncio}} + # Aiohttp retry, trace, and session extension objects may contain + # event-loop state. Their containers are copied above, but the objects + # remain caller-owned. +{{/asyncio}} + result.logger = copy.copy(self.logger) + result.logger_file_handler = self.logger_file_handler + result.logger_stream_handler = self.logger_stream_handler +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} if k not in ('logger', 'logger_file_handler'): setattr(result, k, copy.deepcopy(v, memo)) # shallow copy of loggers result.logger = copy.copy(self.logger) # use setter to re-create the file handler (excluded from __dict__ copy) result.logger_file = self.logger_file +{{/useIndependentImplicitClients}} return result @@ -558,6 +599,17 @@ conf = {{{packageName}}}.Configuration( @classmethod def set_default(cls, default: Optional[Self]) -> None: +{{#useIndependentImplicitClients}} + """Store a copy as the default configuration. + + Later changes to ``default`` do not affect the stored configuration. + ``get_default`` returns the stored object, while ``get_default_copy`` + returns a copy of it. + + :param default: object of Configuration + """ +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} """Set default instance of configuration. It stores default configuration, which can be @@ -565,10 +617,23 @@ conf = {{{packageName}}}.Configuration( :param default: object of Configuration """ +{{/useIndependentImplicitClients}} +{{#useIndependentImplicitClients}} + cls._default = copy.deepcopy(default) +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} cls._default = default +{{/useIndependentImplicitClients}} @classmethod def get_default_copy(cls) -> Self: +{{#useIndependentImplicitClients}} + """Return a copy of the configured default, or a new configuration.""" + if cls._default is not None: + return copy.deepcopy(cls._default) + return cls() +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} """Deprecated. Please use `get_default` instead. Deprecated. Please use `get_default` instead. @@ -576,9 +641,17 @@ conf = {{{packageName}}}.Configuration( :return: The configuration object. """ return cls.get_default() +{{/useIndependentImplicitClients}} @classmethod def get_default(cls) -> Self: +{{#useIndependentImplicitClients}} + """Return the shared default configuration, creating it if needed. + + :return: The configuration object. + """ +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} """Return the default configuration. This method returns newly created, based on default constructor, @@ -587,6 +660,7 @@ conf = {{{packageName}}}.Configuration( :return: The configuration object. """ +{{/useIndependentImplicitClients}} if cls._default is None: cls._default = cls() return cls._default diff --git a/modules/openapi-generator/src/main/resources/python/rest.mustache b/modules/openapi-generator/src/main/resources/python/rest.mustache index 5d852b28d92b..2d53b82eadc6 100644 --- a/modules/openapi-generator/src/main/resources/python/rest.mustache +++ b/modules/openapi-generator/src/main/resources/python/rest.mustache @@ -150,6 +150,11 @@ class RESTClientObject: else: self.pool_manager = urllib3.PoolManager(**pool_args) +{{#useIndependentImplicitClients}} + def close(self) -> None: + self.pool_manager.clear() + +{{/useIndependentImplicitClients}} def request( self, method, diff --git a/modules/openapi-generator/src/main/resources/python/tornado/rest.mustache b/modules/openapi-generator/src/main/resources/python/tornado/rest.mustache index 713defa55423..a0d4cb6ae02c 100644 --- a/modules/openapi-generator/src/main/resources/python/tornado/rest.mustache +++ b/modules/openapi-generator/src/main/resources/python/tornado/rest.mustache @@ -59,8 +59,18 @@ class RESTClientObject: self.proxy_port = 80 self.proxy_host = configuration.proxy +{{#useIndependentImplicitClients}} + self.pool_manager = httpclient.AsyncHTTPClient(force_instance=True) +{{/useIndependentImplicitClients}} +{{^useIndependentImplicitClients}} self.pool_manager = httpclient.AsyncHTTPClient() +{{/useIndependentImplicitClients}} +{{#useIndependentImplicitClients}} + def close(self) -> None: + self.pool_manager.close() + +{{/useIndependentImplicitClients}} @tornado.gen.coroutine def request( self, 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 1ac8d68d4303..a502557fc406 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 @@ -56,6 +56,10 @@ public void testInitialConfigValues() throws Exception { codegen.processOpts(); Assert.assertEquals(codegen.additionalProperties().get(CodegenConstants.HIDE_GENERATION_TIMESTAMP), Boolean.TRUE); + Assert.assertEquals( + codegen.additionalProperties().get( + PythonClientCodegen.USE_INDEPENDENT_IMPLICIT_CLIENTS), + Boolean.FALSE); Assert.assertEquals(codegen.isHideGenerationTimestamp(), true); } @@ -771,4 +775,130 @@ public void testModelNameMappingDoesNotRenameParameters() { codegen.parameterNameMapping().put("some-value", "parameter_value"); Assert.assertEquals(codegen.toParamName("some-value"), "parameter_value"); } + + @Test + public void testIndependentImplicitClientLifecycleOperationNames() + throws IOException { + final PythonClientCodegen disabled = new PythonClientCodegen(); + disabled.processOpts(); + Assert.assertEquals(disabled.toOperationId("close"), "close"); + + final PythonClientCodegen httpxSync = new PythonClientCodegen(); + httpxSync.setLibrary("httpx"); + httpxSync.additionalProperties().put( + PythonClientCodegen.USE_INDEPENDENT_IMPLICIT_CLIENTS, true); + httpxSync.additionalProperties().put(PythonClientCodegen.SUPPORT_HTTPX_SYNC, true); + final String outputPath = generateFiles(httpxSync, + "src/test/resources/3_0/python/independent-client-operation-names.yaml"); + final Path api = Paths.get(outputPath + "openapi_client/api/default_api.py"); + + assertFileContains(api, + "async def call_close_2(", + "async def call_close_sync_2("); + List methodNames = Files.readAllLines(api).stream() + .filter(line -> line.startsWith(" def ") + || line.startsWith(" async def ")) + .map(line -> line.substring( + line.indexOf("def ") + 4, line.indexOf('('))) + .collect(Collectors.toList()); + Assert.assertEquals( + methodNames.stream().distinct().count(), + methodNames.size(), + "generated API method names must be unique"); + } + + @Test + public void testIndependentImplicitClients() throws IOException { + final DefaultCodegen codegen = new PythonClientCodegen(); + codegen.additionalProperties().put( + PythonClientCodegen.USE_INDEPENDENT_IMPLICIT_CLIENTS, true); + final String outputPath = generateFiles(codegen, + "src/test/resources/3_0/generic.yaml"); + final Path configuration = Paths.get(outputPath + "openapi_client/configuration.py"); + final Path apiClient = Paths.get(outputPath + "openapi_client/api_client.py"); + final Path api = Paths.get(outputPath + "openapi_client/api/default_api.py"); + final Path rest = Paths.get(outputPath + "openapi_client/rest.py"); + + assertFileContains(configuration, + "cls._default = copy.deepcopy(default)", + "if cls._default is not None:", + "return copy.deepcopy(cls._default)", + "return cls()", + "if k == 'proxy_headers':", + "copy_method = getattr(v, 'copy', None)", + "if callable(copy_method):", + "result.logger_file_handler = self.logger_file_handler"); + assertFileContains(apiClient, + "configuration = Configuration.get_default_copy()", + "def _get_default_or_new(cls):", + "if cls._default is not None:", + "return cls._default, False", + "return cls(), True", + "self.rest_client.close()"); + assertFileContains(api, + "api_client, owns_api_client = ApiClient._get_default_or_new()", + "owns_api_client = False", + "self._owned_api_client: Optional[ApiClient] = (", + "api_client if owns_api_client else None", + "def close(self) -> None:", + "owned_api_client = self._owned_api_client", + "self._owned_api_client = None", + "if owned_api_client is not None:", + "owned_api_client.close()", + "def __enter__(self):", + "def __exit__(self, exc_type, exc_value, traceback):"); + assertFileContains(rest, + "def close(self) -> None:", + "self.pool_manager.clear()"); + + final PythonClientCodegen asyncioCodegen = new PythonClientCodegen(); + asyncioCodegen.setLibrary("asyncio"); + asyncioCodegen.additionalProperties().put( + PythonClientCodegen.USE_INDEPENDENT_IMPLICIT_CLIENTS, true); + final String asyncioOutputPath = generateFiles(asyncioCodegen, + "src/test/resources/3_0/generic.yaml"); + assertFileContains( + Paths.get(asyncioOutputPath + "openapi_client/rest.py"), + "if extra is not None and \"connector\" in extra", + "if extra.get(\"connector\") is not None:", + "kwargs[\"connector_owner\"] = False", + "kwargs[\"connector_owner\"] = True"); + + final PythonClientCodegen tornadoCodegen = new PythonClientCodegen(); + tornadoCodegen.setLibrary("tornado"); + tornadoCodegen.additionalProperties().put( + PythonClientCodegen.USE_INDEPENDENT_IMPLICIT_CLIENTS, true); + final String tornadoOutputPath = generateFiles(tornadoCodegen, + "src/test/resources/3_0/generic.yaml"); + assertFileContains( + Paths.get(tornadoOutputPath + "openapi_client/rest.py"), + "httpclient.AsyncHTTPClient(force_instance=True)", + "def close(self) -> None:", + "self.pool_manager.close()"); + } + + @Test + public void testIndependentImplicitClientsPreserveDefaultsWhenDisabled() throws IOException { + final String outputPath = generateFiles(new PythonClientCodegen(), + "src/test/resources/3_0/generic.yaml"); + final Path configuration = Paths.get(outputPath + "openapi_client/configuration.py"); + final Path apiClient = Paths.get(outputPath + "openapi_client/api_client.py"); + final Path api = Paths.get(outputPath + "openapi_client/api/default_api.py"); + final Path rest = Paths.get(outputPath + "openapi_client/rest.py"); + + assertFileContains(configuration, + "if k not in ('logger', 'logger_file_handler'):", + "result.logger_file = self.logger_file"); + TestUtils.assertFileNotContains(configuration, + "if k == 'proxy_headers':", + "result.logger_file_handler = self.logger_file_handler"); + TestUtils.assertFileNotContains(apiClient, + "configuration = Configuration.get_default_copy()", + "def _get_default_or_new(cls):", + "self.rest_client.close()"); + TestUtils.assertFileNotContains(api, + "_owned_api_client", + "def close(self) -> None:"); + TestUtils.assertFileNotContains(rest, "def close(self) -> None:"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/python/independent-client-operation-names.yaml b/modules/openapi-generator/src/test/resources/3_0/python/independent-client-operation-names.yaml new file mode 100644 index 000000000000..2bc4cc825e0c --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/python/independent-client-operation-names.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.0 +info: + title: Independent client operation names + version: 1.0.0 +paths: + /close: + get: + operationId: close + responses: + '204': + description: No content + /call-close: + get: + operationId: callClose + responses: + '204': + description: No content + /close-sync: + get: + operationId: closeSync + responses: + '204': + description: No content diff --git a/samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py b/samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py new file mode 100644 index 000000000000..f4053cef2bb0 --- /dev/null +++ b/samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py @@ -0,0 +1,114 @@ +# coding: utf-8 + +# flake8: noqa + +import unittest + +import aiohttp +import aiohttp_retry +import petstore_api +from multidict import CIMultiDict, CIMultiDictProxy + + +class TestConfiguration(unittest.IsolatedAsyncioTestCase): + def tearDown(self): + petstore_api.Configuration.set_default(None) + petstore_api.ApiClient.set_default(None) + + async def testCopiesPreserveTransportObjects(self): + cookie_jar = aiohttp.DummyCookieJar() + trace_config = aiohttp.TraceConfig() + retries = aiohttp_retry.ExponentialRetry() + proxy_headers = {"X-Test": "value"} + configuration = petstore_api.Configuration( + retries=retries, + trace_configs=[trace_config], + client_session_kwargs={"cookie_jar": cookie_jar}, + proxy_headers=proxy_headers, + ) + + petstore_api.Configuration.set_default(configuration) + stored = petstore_api.Configuration.get_default() + copied = petstore_api.Configuration.get_default_copy() + + self.assertIsNot(stored.client_session_kwargs, + configuration.client_session_kwargs) + self.assertIsNot(copied.client_session_kwargs, + stored.client_session_kwargs) + assert copied.client_session_kwargs is not None + self.assertIs(copied.client_session_kwargs["cookie_jar"], cookie_jar) + self.assertIsNot(stored.trace_configs, configuration.trace_configs) + self.assertIsNot(copied.trace_configs, stored.trace_configs) + assert copied.trace_configs is not None + self.assertIs(copied.trace_configs[0], trace_config) + self.assertIs(copied.retries, retries) + assert stored.proxy_headers is not None + assert copied.proxy_headers is not None + self.assertIsNot(stored.proxy_headers, proxy_headers) + self.assertIsNot(copied.proxy_headers, stored.proxy_headers) + proxy_headers["X-Test"] = "source mutation" + stored.proxy_headers["X-Test"] = "stored mutation" + self.assertEqual(copied.proxy_headers, {"X-Test": "value"}) + + async def testCopiesProxyHeaderMultidict(self): + source_headers = CIMultiDict([ + ("X-Test", "first"), + ("X-Test", "second"), + ]) + proxy_headers = CIMultiDictProxy(source_headers) + configuration = petstore_api.Configuration( + proxy_headers=proxy_headers, + ) + + petstore_api.Configuration.set_default(configuration) + stored = petstore_api.Configuration.get_default() + copied = petstore_api.Configuration.get_default_copy() + + assert stored.proxy_headers is not None + assert copied.proxy_headers is not None + self.assertIsNot(stored.proxy_headers, proxy_headers) + self.assertIsNot(copied.proxy_headers, stored.proxy_headers) + source_headers.add("X-Test", "source mutation") + stored.proxy_headers.add("X-Test", "stored mutation") + self.assertEqual(proxy_headers.getall("X-Test"), + ["first", "second", "source mutation"]) + self.assertEqual(stored.proxy_headers.getall("X-Test"), + ["first", "second", "stored mutation"]) + self.assertEqual(copied.proxy_headers.getall("X-Test"), + ["first", "second"]) + + async def testImplicitApiClientLifecycle(self): + implicit_api = petstore_api.PetApi() + implicit_rest_client = implicit_api.api_client.rest_client + implicit_session = implicit_rest_client._create_pool_manager() + implicit_rest_client.pool_manager = implicit_session + + async with implicit_api as entered: + self.assertIs(entered, implicit_api) + + self.assertTrue(implicit_session.closed) + + explicit_client = petstore_api.ApiClient() + explicit_rest_client = explicit_client.rest_client + explicit_session = explicit_rest_client._create_pool_manager() + explicit_rest_client.pool_manager = explicit_session + try: + async with petstore_api.PetApi(explicit_client): + pass + + self.assertFalse(explicit_session.closed) + finally: + await explicit_client.close() + + default_client = petstore_api.ApiClient() + default_rest_client = default_client.rest_client + default_session = default_rest_client._create_pool_manager() + default_rest_client.pool_manager = default_session + petstore_api.ApiClient.set_default(default_client) + try: + async with petstore_api.PetApi() as default_api: + self.assertIs(default_api.api_client, default_client) + + self.assertFalse(default_session.closed) + finally: + await default_client.close() diff --git a/samples/openapi3/client/petstore/python-aiohttp/tests/test_pet_api.py b/samples/openapi3/client/petstore/python-aiohttp/tests/test_pet_api.py index 2d4f64f4f38c..32cee97a6c40 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/tests/test_pet_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/tests/test_pet_api.py @@ -79,15 +79,16 @@ def test_accept_header_serialization(self): self.assertEqual(headers_overwritten['Accept'], 'text/plain') - def test_separate_default_client_instances(self): - pet_api = petstore_api.PetApi() - pet_api2 = petstore_api.PetApi() - self.assertEqual(id(pet_api.api_client), id(pet_api2.api_client)) - - def test_separate_default_config_instances(self): - pet_api = petstore_api.PetApi() - pet_api2 = petstore_api.PetApi() - self.assertEqual(id(pet_api.api_client.configuration), id(pet_api2.api_client.configuration)) + async def test_separate_default_client_instances(self): + async with petstore_api.PetApi() as pet_api, petstore_api.PetApi() as pet_api2: + self.assertIsNot(pet_api.api_client, pet_api2.api_client) + + async def test_separate_default_config_instances(self): + async with petstore_api.PetApi() as pet_api, petstore_api.PetApi() as pet_api2: + self.assertIsNot( + pet_api.api_client.configuration, + pet_api2.api_client.configuration, + ) async def test_async_with_result(self): await self.pet_api.add_pet(self.pet) diff --git a/samples/openapi3/client/petstore/python-lazyImports/tests/test_configuration.py b/samples/openapi3/client/petstore/python-lazyImports/tests/test_configuration.py index af9fa6eef153..9d9ac55d191b 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/tests/test_configuration.py +++ b/samples/openapi3/client/petstore/python-lazyImports/tests/test_configuration.py @@ -10,6 +10,8 @@ """ from __future__ import absolute_import +import os +import tempfile import unittest import petstore_api @@ -24,6 +26,7 @@ def setUp(self): def tearDown(self): # reset Configuration petstore_api.Configuration.set_default(None) + petstore_api.ApiClient.set_default(None) def testConfiguration(self): # check that different instances use different dictionaries @@ -37,22 +40,107 @@ def testDefaultConfiguration(self): # prepare default configuration c1 = petstore_api.Configuration(host="example.com") c1.debug = True + c1.api_key["key"] = "value" petstore_api.Configuration.set_default(c1) + c1.host = "changed.example.com" + c1.api_key["key"] = "changed" - # get default configuration + # get independent copies of the registered configuration c2 = petstore_api.Configuration.get_default_copy() + c3 = petstore_api.Configuration.get_default_copy() self.assertEqual(c2.host, "example.com") self.assertTrue(c2.debug) + self.assertEqual(c2.api_key["key"], "value") - self.assertEqual(id(c1), id(c2)) - self.assertEqual(id(c1.api_key), id(c2.api_key)) - self.assertEqual(id(c1.api_key_prefix), id(c2.api_key_prefix)) + self.assertNotEqual(id(c1), id(c2)) + self.assertNotEqual(id(c2), id(c3)) + self.assertNotEqual(id(c1.api_key), id(c2.api_key)) + self.assertNotEqual(id(c2.api_key), id(c3.api_key)) + self.assertNotEqual(id(c1.api_key_prefix), id(c2.api_key_prefix)) + self.assertNotEqual(id(c2.api_key_prefix), id(c3.api_key_prefix)) + + shared1 = petstore_api.Configuration.get_default() + shared2 = petstore_api.Configuration.get_default() + self.assertEqual(id(shared1), id(shared2)) + self.assertNotEqual(id(c1), id(shared1)) def testApiClientDefaultConfiguration(self): - # ensure the default configuration is the same + configuration = petstore_api.Configuration(host="example.com") + petstore_api.Configuration.set_default(configuration) + + # each implicit client receives its own configuration copy p1 = petstore_api.PetApi() p2 = petstore_api.PetApi() - self.assertEqual(id(p1.api_client.configuration), id(p2.api_client.configuration)) + self.assertEqual(p1.api_client.configuration.host, "example.com") + self.assertEqual(p2.api_client.configuration.host, "example.com") + self.assertNotEqual(id(p1.api_client), id(p2.api_client)) + self.assertNotEqual(id(p1.api_client.configuration), id(p2.api_client.configuration)) + + unregistered_client = petstore_api.ApiClient.get_default() + p3 = petstore_api.PetApi() + self.assertIsNot(p3.api_client, unregistered_client) + unregistered_client.close() + + default_client = petstore_api.ApiClient( + petstore_api.Configuration(host="default-client.example.com") + ) + petstore_api.ApiClient.set_default(default_client) + p4 = petstore_api.PetApi() + self.assertEqual(id(p4.api_client), id(default_client)) + + def testImplicitApiClientLifecycle(self): + implicit_api = petstore_api.PetApi() + implicit_pool = implicit_api.api_client.rest_client.pool_manager + implicit_pool.connection_from_url("http://example.com") + self.assertGreater(len(implicit_pool.pools), 0) + + with implicit_api as entered: + self.assertIs(entered, implicit_api) + + self.assertEqual(len(implicit_pool.pools), 0) + + explicit_client = petstore_api.ApiClient() + explicit_pool = explicit_client.rest_client.pool_manager + explicit_pool.connection_from_url("http://example.com") + try: + with petstore_api.PetApi(explicit_client): + pass + + self.assertGreater(len(explicit_pool.pools), 0) + finally: + explicit_client.close() + + def testConfigurationCopiesReuseLoggerHandlers(self): + with tempfile.TemporaryDirectory() as temp_dir: + configuration = petstore_api.Configuration() + loggers = tuple(configuration.logger.values()) + original_handlers = { + logger: tuple(logger.handlers) for logger in loggers + } + configuration.logger_file = os.path.join(temp_dir, "client.log") + handler = configuration.logger_file_handler + self.assertIsNotNone(handler) + configured_handlers = { + logger: tuple(logger.handlers) for logger in loggers + } + + try: + petstore_api.Configuration.set_default(configuration) + copied = petstore_api.Configuration.get_default_copy() + + self.assertIs(copied.logger_file_handler, handler) + for logger in loggers: + self.assertEqual(tuple(logger.handlers), + configured_handlers[logger]) + finally: + added_handlers = set() + for logger in loggers: + for added_handler in tuple(logger.handlers): + if added_handler not in original_handlers[logger]: + logger.removeHandler(added_handler) + added_handlers.add(added_handler) + for added_handler in added_handlers: + added_handler.close() def testAccessTokenWhenConstructingConfiguration(self): c1 = petstore_api.Configuration(access_token="12345") diff --git a/samples/openapi3/client/petstore/python-lazyImports/tests/test_pet_api.py b/samples/openapi3/client/petstore/python-lazyImports/tests/test_pet_api.py index addfa801821e..c534cda9627f 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/tests/test_pet_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/tests/test_pet_api.py @@ -127,12 +127,12 @@ def test_accept_header_serialization(self): self.assertEqual(headers_overwritten['Accept'], 'text/plain') def test_separate_default_config_instances(self): - # ensure the default api client is used - pet_api = petstore_api.PetApi() - pet_api2 = petstore_api.PetApi() - self.assertEqual(id(pet_api.api_client), id(pet_api2.api_client)) - # ensure the default configuration is used - self.assertEqual(id(pet_api.api_client.configuration), id(pet_api2.api_client.configuration)) + with petstore_api.PetApi() as pet_api, petstore_api.PetApi() as pet_api2: + self.assertIsNot(pet_api.api_client, pet_api2.api_client) + self.assertIsNot( + pet_api.api_client.configuration, + pet_api2.api_client.configuration, + ) def test_add_pet_and_get_pet_by_id(self): self.pet_api.add_pet(self.pet) From c059690a7a4886fe9aa2fcdb98e39c6b52c0aaeb Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Tue, 23 Jun 2026 17:00:12 -0400 Subject: [PATCH 2/2] [python] regenerate client samples Regenerate the aiohttp and lazy-import Python clients with copied defaults and independent implicit clients. --- .../client/petstore/python-aiohttp/README.md | 23 ++++++ .../petstore_api/api/another_fake_api.py | 21 ++++- .../petstore_api/api/default_api.py | 21 ++++- .../petstore_api/api/fake_api.py | 21 ++++- .../api/fake_classname_tags123_api.py | 21 ++++- .../api/import_test_datetime_api.py | 21 ++++- .../petstore_api/api/pet_api.py | 21 ++++- .../petstore_api/api/store_api.py | 21 ++++- .../petstore_api/api/user_api.py | 21 ++++- .../python-aiohttp/petstore_api/api_client.py | 21 +++-- .../petstore_api/configuration.py | 59 +++++++++----- .../python-aiohttp/petstore_api/rest.py | 18 ++++- .../tests/test_configuration.py | 78 +++++++++++++++++++ .../petstore/python-lazyImports/README.md | 23 ++++++ .../petstore_api/api/another_fake_api.py | 22 +++++- .../petstore_api/api/default_api.py | 22 +++++- .../petstore_api/api/fake_api.py | 22 +++++- .../api/fake_classname_tags123_api.py | 22 +++++- .../api/import_test_datetime_api.py | 22 +++++- .../petstore_api/api/pet_api.py | 22 +++++- .../petstore_api/api/store_api.py | 22 +++++- .../petstore_api/api/user_api.py | 22 +++++- .../petstore_api/api_client.py | 26 +++---- .../petstore_api/configuration.py | 46 ++++++----- .../python-lazyImports/petstore_api/rest.py | 3 + .../tests/test_configuration.py | 18 +++++ 26 files changed, 574 insertions(+), 85 deletions(-) diff --git a/samples/openapi3/client/petstore/python-aiohttp/README.md b/samples/openapi3/client/petstore/python-aiohttp/README.md index 0a4c55c4dabc..0e6d4ca1a712 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/README.md +++ b/samples/openapi3/client/petstore/python-aiohttp/README.md @@ -79,6 +79,29 @@ async with petstore_api.ApiClient(configuration) as api_client: ``` +An API instance constructed without an explicit or registered default +`ApiClient` owns its client. Use the API as a context manager: + +```python +async with petstore_api.AnotherFakeApi() as api_instance: + # Call API methods through api_instance. + pass + +``` + +Direct construction requires +`await api_instance.close()`. Closing an API constructed +with an explicit or registered default client is a no-op; the caller remains +responsible for that client. Configuration-owned values are copied for an +implicit client. Logging resources retain their identity. Configuration +containers and proxy-header mappings are copied; other stateful transport +extension objects remain caller-owned. + +`Configuration.set_default()` stores a copy, so later changes to the supplied +object do not affect new implicit clients. `Configuration.get_default()` +returns that stored baseline, while `Configuration.get_default_copy()` returns +a copy. + ## Documentation for API Endpoints All URIs are relative to *http://petstore.swagger.io:80/v2* diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/another_fake_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/another_fake_api.py index 9ceb307bbb24..d840f94840b5 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/another_fake_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/another_fake_api.py @@ -32,9 +32,28 @@ class AnotherFakeApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/default_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/default_api.py index 13aec70c14ed..3210594cbd92 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/default_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/default_api.py @@ -30,9 +30,28 @@ class DefaultApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py index 0d435906bfba..6ea5da465aeb 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py @@ -47,9 +47,28 @@ class FakeApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_classname_tags123_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_classname_tags123_api.py index 08cf85d488bb..3bdf586ba12d 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_classname_tags123_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_classname_tags123_api.py @@ -32,9 +32,28 @@ class FakeClassnameTags123Api: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/import_test_datetime_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/import_test_datetime_api.py index 47876e858687..d2470196f063 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/import_test_datetime_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/import_test_datetime_api.py @@ -30,9 +30,28 @@ class ImportTestDatetimeApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/pet_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/pet_api.py index 5da83f4a686a..8f290212ddbb 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/pet_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/pet_api.py @@ -34,9 +34,28 @@ class PetApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/store_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/store_api.py index 79116ca74ca8..e47aba3dc9f0 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/store_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/store_api.py @@ -33,9 +33,28 @@ class StoreApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/user_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/user_api.py index 9e4ff6771db8..1246baedd369 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/user_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/user_api.py @@ -33,9 +33,28 @@ class UserApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + async def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + await owned_api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py index 3d0170779a39..d5112851b460 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py @@ -82,7 +82,7 @@ def __init__( ) -> None: # use default configuration if none is provided if configuration is None: - configuration = Configuration.get_default() + configuration = Configuration.get_default_copy() self.configuration = configuration self.rest_client = rest.RESTClientObject(configuration) @@ -119,18 +119,15 @@ def set_default_header(self, header_name, header_value): _default = None @classmethod - def get_default(cls): - """Return new instance of ApiClient. - - This method returns newly created, based on default constructor, - object of ApiClient class or returns a copy of default - ApiClient. + def _get_default_or_new(cls): + if cls._default is not None: + return cls._default, False + return cls(), True - :return: The ApiClient object. - """ - if cls._default is None: - cls._default = ApiClient() - return cls._default + @classmethod + def get_default(cls): + """Return the registered default ApiClient, or a new client.""" + return cls._get_default_or_new()[0] @classmethod def set_default(cls, default): diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/configuration.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/configuration.py index 59d1690dce67..84c124fbd0c2 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/configuration.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/configuration.py @@ -177,6 +177,8 @@ class Configuration: :param client_session_kwargs: Extra keyword arguments merged into aiohttp.ClientSession(**kwargs) (e.g. json_serialize=orjson.dumps, cookie_jar=aiohttp.DummyCookieJar()). + A non-None connector remains caller-owned because copied configurations + may share it. :param ca_cert_data: verify the peer using concatenated CA certificate data in PEM (str) or DER (bytes) format. :param cert_file: the path to a client certificate file, for mTLS. @@ -428,6 +430,8 @@ def __init__( """ self.client_session_kwargs = client_session_kwargs """Extra kwargs merged into aiohttp.ClientSession(**kwargs). + + A non-None connector remains caller-owned. """ # Enable client side validation self.client_side_validation = client_side_validation @@ -449,12 +453,31 @@ def __deepcopy__(self, memo: Dict[int, Any]) -> Self: result = cls.__new__(cls) memo[id(self)] = result for k, v in self.__dict__.items(): - if k not in ('logger', 'logger_file_handler'): - setattr(result, k, copy.deepcopy(v, memo)) - # shallow copy of loggers + if k in ('logger', 'logger_file_handler', 'logger_stream_handler'): + continue + if k == 'proxy_headers': + # MultiDictProxy rejects generic copying, but copy() returns an + # independent mutable multidict and preserves duplicate headers: + # https://multidict.aio-libs.org/en/stable/multidict/ + copy_method = getattr(v, 'copy', None) + if callable(copy_method): + setattr(result, k, copy_method()) + continue + if k in ('client_session_kwargs', 'trace_configs'): + setattr(result, k, copy.copy(v)) + continue + if k == 'retries': + setattr(result, k, v) + continue + setattr(result, k, copy.deepcopy(v, memo)) + + # Loggers and their handlers are process-global. + # Aiohttp retry, trace, and session extension objects may contain + # event-loop state. Their containers are copied above, but the objects + # remain caller-owned. result.logger = copy.copy(self.logger) - # use setter to re-create the file handler (excluded from __dict__ copy) - result.logger_file = self.logger_file + result.logger_file_handler = self.logger_file_handler + result.logger_stream_handler = self.logger_stream_handler return result @@ -467,32 +490,26 @@ def __setattr__(self, name: str, value: Any) -> None: @classmethod def set_default(cls, default: Optional[Self]) -> None: - """Set default instance of configuration. + """Store a copy as the default configuration. - It stores default configuration, which can be - returned by get_default_copy method. + Later changes to ``default`` do not affect the stored configuration. + ``get_default`` returns the stored object, while ``get_default_copy`` + returns a copy of it. :param default: object of Configuration """ - cls._default = default + cls._default = copy.deepcopy(default) @classmethod def get_default_copy(cls) -> Self: - """Deprecated. Please use `get_default` instead. - - Deprecated. Please use `get_default` instead. - - :return: The configuration object. - """ - return cls.get_default() + """Return a copy of the configured default, or a new configuration.""" + if cls._default is not None: + return copy.deepcopy(cls._default) + return cls() @classmethod def get_default(cls) -> Self: - """Return the default configuration. - - This method returns newly created, based on default constructor, - object of Configuration class or returns a copy of default - configuration. + """Return the shared default configuration, creating it if needed. :return: The configuration object. """ diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/rest.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/rest.py index 085d16acb62f..374aeeb993fa 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/rest.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/rest.py @@ -127,16 +127,30 @@ def _create_pool_manager(self) -> aiohttp.ClientSession: fields (trace_configs / client_session_kwargs) are read via getattr so older Configuration objects remain compatible. """ + extra = getattr(self.configuration, "client_session_kwargs", None) kwargs: Dict[str, Any] = { - "connector": self._create_connector(), + # Preserve an explicit connector=None override. ClientSession then + # creates and owns its default connector instead of using ours: + # https://github.com/aio-libs/aiohttp/blob/v3.8.4/aiohttp/client.py#L238-L257 + "connector": ( + extra["connector"] + if extra is not None and "connector" in extra + else self._create_connector() + ), "trust_env": True, } trace_configs = getattr(self.configuration, "trace_configs", None) if trace_configs is not None: kwargs["trace_configs"] = trace_configs - extra = getattr(self.configuration, "client_session_kwargs", None) if extra: kwargs.update(extra) + if extra.get("connector") is not None: + # Copied configurations can share a caller-supplied connector. + # aiohttp requires connector_owner=False for shared connectors: + # https://docs.aiohttp.org/en/stable/client_advanced.html#connectors + kwargs["connector_owner"] = False + else: + kwargs["connector_owner"] = True return aiohttp.ClientSession(**kwargs) async def request( diff --git a/samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py b/samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py index f4053cef2bb0..1ee702fcdbc6 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py +++ b/samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py @@ -112,3 +112,81 @@ async def testImplicitApiClientLifecycle(self): self.assertFalse(default_session.closed) finally: await default_client.close() + + async def testSuppliedConnectorRemainsCallerOwned(self): + connector = aiohttp.TCPConnector() + configuration = petstore_api.Configuration( + client_session_kwargs={ + "connector": connector, + "connector_owner": True, + }, + ) + petstore_api.Configuration.set_default(configuration) + first_api = petstore_api.PetApi() + second_api = petstore_api.PetApi() + first_rest_client = first_api.api_client.rest_client + second_rest_client = second_api.api_client.rest_client + first_session = first_rest_client._create_pool_manager() + second_session = second_rest_client._create_pool_manager() + first_rest_client.pool_manager = first_session + second_rest_client.pool_manager = second_session + + try: + self.assertIs(first_session.connector, connector) + self.assertIs(second_session.connector, connector) + self.assertFalse(first_session.connector_owner) + self.assertFalse(second_session.connector_owner) + + await first_api.close() + self.assertTrue(first_session.closed) + self.assertFalse(second_session.closed) + self.assertFalse(connector.closed) + await second_api.close() + self.assertTrue(second_session.closed) + self.assertFalse(connector.closed) + finally: + await first_api.close() + await second_api.close() + await connector.close() + + async def testNoneConnectorRemainsSessionOwned(self): + configuration = petstore_api.Configuration( + client_session_kwargs={ + "connector": None, + "connector_owner": False, + }, + ) + petstore_api.Configuration.set_default(configuration) + api = petstore_api.PetApi() + rest_client = api.api_client.rest_client + session = rest_client._create_pool_manager() + rest_client.pool_manager = session + connector = session.connector + + try: + self.assertTrue(session.connector_owner) + self.assertIsNotNone(connector) + await api.close() + self.assertTrue(session.closed) + self.assertTrue(connector.closed) + finally: + await api.close() + + async def testImplicitApiClientClosesOwnedClientAfterReassignment(self): + implicit_api = petstore_api.PetApi() + owned_rest_client = implicit_api.api_client.rest_client + owned_session = owned_rest_client._create_pool_manager() + owned_rest_client.pool_manager = owned_session + replacement_client = petstore_api.ApiClient() + replacement_rest_client = replacement_client.rest_client + replacement_session = replacement_rest_client._create_pool_manager() + replacement_rest_client.pool_manager = replacement_session + implicit_api.api_client = replacement_client + + try: + await implicit_api.close() + + self.assertTrue(owned_session.closed) + self.assertFalse(replacement_session.closed) + finally: + await replacement_client.close() diff --git a/samples/openapi3/client/petstore/python-lazyImports/README.md b/samples/openapi3/client/petstore/python-lazyImports/README.md index 7c3e47704094..1b5366000485 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/README.md +++ b/samples/openapi3/client/petstore/python-lazyImports/README.md @@ -79,6 +79,29 @@ with petstore_api.ApiClient(configuration) as api_client: ``` +An API instance constructed without an explicit or registered default +`ApiClient` owns its client. Use the API as a context manager: + +```python +with petstore_api.AnotherFakeApi() as api_instance: + # Call API methods through api_instance. + pass + +``` + +Direct construction requires +`api_instance.close()`. Closing an API constructed +with an explicit or registered default client is a no-op; the caller remains +responsible for that client. Configuration-owned values are copied for an +implicit client. Logging resources retain their identity. Configuration +containers and proxy-header mappings are copied; other stateful transport +extension objects remain caller-owned. + +`Configuration.set_default()` stores a copy, so later changes to the supplied +object do not affect new implicit clients. `Configuration.get_default()` +returns that stored baseline, while `Configuration.get_default_copy()` returns +a copy. + ## Documentation for API Endpoints All URIs are relative to *http://petstore.swagger.io:80/v2* diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/another_fake_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/another_fake_api.py index 56f2ab2cd1cd..128465621cbc 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/another_fake_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/another_fake_api.py @@ -32,9 +32,29 @@ class AnotherFakeApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/default_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/default_api.py index 2d007b2f26d3..5397a1872f14 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/default_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/default_api.py @@ -30,9 +30,29 @@ class DefaultApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py index e3f86661e566..a52b90a156dd 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py @@ -47,9 +47,29 @@ class FakeApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_classname_tags123_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_classname_tags123_api.py index e373462e5f5b..d9f5f5a570be 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_classname_tags123_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_classname_tags123_api.py @@ -32,9 +32,29 @@ class FakeClassnameTags123Api: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/import_test_datetime_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/import_test_datetime_api.py index ae01fa137fa1..1045da8de511 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/import_test_datetime_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/import_test_datetime_api.py @@ -30,9 +30,29 @@ class ImportTestDatetimeApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/pet_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/pet_api.py index 7d4cf604eb15..2dfc90005d39 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/pet_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/pet_api.py @@ -34,9 +34,29 @@ class PetApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/store_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/store_api.py index 73471217df00..6e3df4213355 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/store_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/store_api.py @@ -33,9 +33,29 @@ class StoreApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/user_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/user_api.py index be61d536e31c..59ce49f2f797 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/user_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/user_api.py @@ -33,9 +33,29 @@ class UserApi: """ def __init__(self, api_client=None) -> None: + # api_client remains publicly assignable. Retain the client acquired at + # construction so reassignment cannot transfer or discard ownership. if api_client is None: - api_client = ApiClient.get_default() + api_client, owns_api_client = ApiClient._get_default_or_new() + else: + owns_api_client = False self.api_client = api_client + self._owned_api_client: Optional[ApiClient] = ( + api_client if owns_api_client else None + ) + + + def close(self) -> None: + owned_api_client = self._owned_api_client + self._owned_api_client = None + if owned_api_client is not None: + owned_api_client.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() @validate_call diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py index d388566cbbe2..cfcf63136609 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py @@ -82,7 +82,7 @@ def __init__( ) -> None: # use default configuration if none is provided if configuration is None: - configuration = Configuration.get_default() + configuration = Configuration.get_default_copy() self.configuration = configuration self.rest_client = rest.RESTClientObject(configuration) @@ -94,11 +94,14 @@ def __init__( self.user_agent = 'OpenAPI-Generator/1.0.0/python' self.client_side_validation = configuration.client_side_validation + def close(self): + self.rest_client.close() + def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): - pass + self.close() @property def user_agent(self): @@ -116,18 +119,15 @@ def set_default_header(self, header_name, header_value): _default = None @classmethod - def get_default(cls): - """Return new instance of ApiClient. - - This method returns newly created, based on default constructor, - object of ApiClient class or returns a copy of default - ApiClient. + def _get_default_or_new(cls): + if cls._default is not None: + return cls._default, False + return cls(), True - :return: The ApiClient object. - """ - if cls._default is None: - cls._default = ApiClient() - return cls._default + @classmethod + def get_default(cls): + """Return the registered default ApiClient, or a new client.""" + return cls._get_default_or_new()[0] @classmethod def set_default(cls, default): diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/configuration.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/configuration.py index 74e89e0946f4..a25a4aa05cb1 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/configuration.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/configuration.py @@ -446,12 +446,22 @@ def __deepcopy__(self, memo: Dict[int, Any]) -> Self: result = cls.__new__(cls) memo[id(self)] = result for k, v in self.__dict__.items(): - if k not in ('logger', 'logger_file_handler'): - setattr(result, k, copy.deepcopy(v, memo)) - # shallow copy of loggers + if k in ('logger', 'logger_file_handler', 'logger_stream_handler'): + continue + if k == 'proxy_headers': + # MultiDictProxy rejects generic copying, but copy() returns an + # independent mutable multidict and preserves duplicate headers: + # https://multidict.aio-libs.org/en/stable/multidict/ + copy_method = getattr(v, 'copy', None) + if callable(copy_method): + setattr(result, k, copy_method()) + continue + setattr(result, k, copy.deepcopy(v, memo)) + + # Loggers and their handlers are process-global. result.logger = copy.copy(self.logger) - # use setter to re-create the file handler (excluded from __dict__ copy) - result.logger_file = self.logger_file + result.logger_file_handler = self.logger_file_handler + result.logger_stream_handler = self.logger_stream_handler return result @@ -464,32 +474,26 @@ def __setattr__(self, name: str, value: Any) -> None: @classmethod def set_default(cls, default: Optional[Self]) -> None: - """Set default instance of configuration. + """Store a copy as the default configuration. - It stores default configuration, which can be - returned by get_default_copy method. + Later changes to ``default`` do not affect the stored configuration. + ``get_default`` returns the stored object, while ``get_default_copy`` + returns a copy of it. :param default: object of Configuration """ - cls._default = default + cls._default = copy.deepcopy(default) @classmethod def get_default_copy(cls) -> Self: - """Deprecated. Please use `get_default` instead. - - Deprecated. Please use `get_default` instead. - - :return: The configuration object. - """ - return cls.get_default() + """Return a copy of the configured default, or a new configuration.""" + if cls._default is not None: + return copy.deepcopy(cls._default) + return cls() @classmethod def get_default(cls) -> Self: - """Return the default configuration. - - This method returns newly created, based on default constructor, - object of Configuration class or returns a copy of default - configuration. + """Return the shared default configuration, creating it if needed. :return: The configuration object. """ diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/rest.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/rest.py index 5df0581723cc..7f5adc075422 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/rest.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/rest.py @@ -159,6 +159,9 @@ def __init__(self, configuration) -> None: else: self.pool_manager = urllib3.PoolManager(**pool_args) + def close(self) -> None: + self.pool_manager.clear() + def request( self, method, diff --git a/samples/openapi3/client/petstore/python-lazyImports/tests/test_configuration.py b/samples/openapi3/client/petstore/python-lazyImports/tests/test_configuration.py index 9d9ac55d191b..eaf5489b70c5 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/tests/test_configuration.py +++ b/samples/openapi3/client/petstore/python-lazyImports/tests/test_configuration.py @@ -110,6 +110,24 @@ def testImplicitApiClientLifecycle(self): finally: explicit_client.close() + def testImplicitApiClientClosesOwnedClientAfterReassignment(self): + implicit_api = petstore_api.PetApi() + owned_client = implicit_api.api_client + owned_pool = owned_client.rest_client.pool_manager + owned_pool.connection_from_url("http://example.com") + replacement_client = petstore_api.ApiClient() + replacement_pool = replacement_client.rest_client.pool_manager + replacement_pool.connection_from_url("http://example.com") + implicit_api.api_client = replacement_client + + try: + implicit_api.close() + + self.assertEqual(len(owned_pool.pools), 0) + self.assertGreater(len(replacement_pool.pools), 0) + finally: + replacement_client.close() + def testConfigurationCopiesReuseLoggerHandlers(self): with tempfile.TemporaryDirectory() as temp_dir: configuration = petstore_api.Configuration()