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/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 new file mode 100644 index 000000000000..1ee702fcdbc6 --- /dev/null +++ b/samples/openapi3/client/petstore/python-aiohttp/tests/test_configuration.py @@ -0,0 +1,192 @@ +# 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() + + 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-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/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 af9fa6eef153..eaf5489b70c5 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,125 @@ 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 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() + 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)