Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
17 changes: 17 additions & 0 deletions bin/configs/python-httpx-sync.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
generatorName: python
outputDir: samples/openapi3/client/petstore/python-httpx-sync
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
library: httpx
additionalProperties:
buildSystem: hatchling
packageName: petstore_api
mapNumberTo: float
poetry1: false
supportHttpxSync: true
nameMappings:
_type: underscore_type
type_: type_with_underscore
modelNameMappings:
# The OpenAPI spec ApiResponse conflicts with the internal ApiResponse
ApiResponse: ModelApiResponse
1 change: 1 addition & 0 deletions docs/generators/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|projectName|python project name in setup.py (e.g. petstore-api).| |null|
|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|
|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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public class PythonClientCodegen extends AbstractPythonCodegen implements Codege
public static final String POETRY1_FALLBACK = "poetry1";
public static final String LAZY_IMPORTS = "lazyImports";
public static final String BUILD_SYSTEM = "buildSystem";
public static final String SUPPORT_HTTPX_SYNC = "supportHttpxSync";

@Setter protected String packageUrl;
protected String apiDocPath = "docs/";
Expand Down Expand Up @@ -158,6 +159,9 @@ public PythonClientCodegen() {
cliOptions.add(new CliOption(POETRY1_FALLBACK, "Fallback to formatting pyproject.toml to Poetry 1.x format."));
cliOptions.add(new CliOption(LAZY_IMPORTS, "Enable lazy imports.").defaultValue(Boolean.FALSE.toString()));
cliOptions.add(new CliOption(BUILD_SYSTEM, "Build system to use in pyproject.toml (setuptools, hatchling).").defaultValue("setuptools"));
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()));

supportedLibraries.put("urllib3", "urllib3-based client");
supportedLibraries.put("asyncio", "asyncio-based client");
Expand Down Expand Up @@ -352,10 +356,25 @@ public void processOpts() {
supportingFiles.add(new SupportingFile("httpx/rest.mustache", packagePath(), "rest.py"));
additionalProperties.put("async", "true");
additionalProperties.put("httpx", "true");
if (Boolean.parseBoolean(String.valueOf(additionalProperties.get(SUPPORT_HTTPX_SYNC)))) {
// generate synchronous '_sync' method variants alongside the async ones
additionalProperties.put(SUPPORT_HTTPX_SYNC, true);
supportingFiles.add(new SupportingFile("httpx/sync_helper.mustache", packagePath(), "sync_helper.py"));
} else {
additionalProperties.remove(SUPPORT_HTTPX_SYNC);
}
} else {
supportingFiles.add(new SupportingFile("rest.mustache", packagePath(), "rest.py"));
}

// 'supportHttpxSync' only makes sense for the (async) httpx library
if (!"httpx".equals(getLibrary())) {
if (Boolean.parseBoolean(String.valueOf(additionalProperties.get(SUPPORT_HTTPX_SYNC)))) {
LOGGER.warn("'{}' is only supported with the 'httpx' library and will be ignored.", SUPPORT_HTTPX_SYNC);
}
additionalProperties.remove(SUPPORT_HTTPX_SYNC);
}

modelPackage = this.packageName + "." + modelPackage;
apiPackage = this.packageName + "." + apiPackage;
}
Expand Down
68 changes: 68 additions & 0 deletions modules/openapi-generator/src/main/resources/python/api.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ from typing_extensions import Annotated
from {{packageName}}.api_client import ApiClient, RequestSerialized
from {{packageName}}.api_response import ApiResponse
from {{packageName}}.rest import RESTResponseType
{{#supportHttpxSync}}
from {{packageName}}.sync_helper import run_sync
{{/supportHttpxSync}}


{{#operations}}
Expand Down Expand Up @@ -70,6 +73,71 @@ class {{classname}}:
return response_data.response


{{#supportHttpxSync}}
@validate_call
def {{operationId}}_sync{{>partial_api_args}} -> {{{returnType}}}{{^returnType}}None{{/returnType}}:
"""{{#isDeprecated}}(Deprecated) {{/isDeprecated}}{{{summary}}}{{^summary}}{{operationId}}{{/summary}} (synchronous)

Synchronous variant of :meth:`{{operationId}}`. It calls the asynchronous
method and blocks until it completes.
""" # noqa: E501
return run_sync(
self.{{operationId}}(
{{#allParams}}
{{paramName}}={{paramName}},
{{/allParams}}
_request_timeout=_request_timeout,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index,
)
)


@validate_call
def {{operationId}}_sync_with_http_info{{>partial_api_args}} -> ApiResponse[{{{returnType}}}{{^returnType}}None{{/returnType}}]:
"""{{#isDeprecated}}(Deprecated) {{/isDeprecated}}{{{summary}}}{{^summary}}{{operationId}}{{/summary}} (synchronous)

Synchronous variant of :meth:`{{operationId}}_with_http_info`. It calls the
asynchronous method and blocks until it completes.
""" # noqa: E501
return run_sync(
self.{{operationId}}_with_http_info(
{{#allParams}}
{{paramName}}={{paramName}},
{{/allParams}}
_request_timeout=_request_timeout,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index,
)
)


@validate_call
def {{operationId}}_sync_without_preload_content{{>partial_api_args}} -> RESTResponseType:
"""{{#isDeprecated}}(Deprecated) {{/isDeprecated}}{{{summary}}}{{^summary}}{{operationId}}{{/summary}} (synchronous)

Synchronous variant of :meth:`{{operationId}}_without_preload_content`. It calls
the asynchronous method and blocks until it completes.
""" # noqa: E501
return run_sync(
self.{{operationId}}_without_preload_content(
{{#allParams}}
{{paramName}}={{paramName}},
{{/allParams}}
_request_timeout=_request_timeout,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index,
)
)


{{/supportHttpxSync}}
def _{{operationId}}_serialize(
self,
{{#allParams}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Method | HTTP request | Description
{{#operation}}
# **{{{operationId}}}**
> {{#returnType}}{{{.}}} {{/returnType}}{{{operationId}}}({{#allParams}}{{#required}}{{{paramName}}}{{/required}}{{^required}}{{{paramName}}}={{{paramName}}}{{/required}}{{^-last}}, {{/-last}}{{/allParams}})
{{#supportHttpxSync}}

**Synchronous variant:** `{{{operationId}}}_sync(...)` — same parameters and return type, but blocks until completion instead of requiring `await`.
{{/supportHttpxSync}}

{{#summary}}
{{{summary}}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ class {{#operations}}Test{{classname}}(unittest.{{#async}}IsolatedAsyncio{{/asyn
"""
pass

{{#supportHttpxSync}}
def test_{{operationId}}_sync(self) -> None:
"""Test case for {{{operationId}}} (synchronous)

{{#summary}}
{{{.}}}
{{/summary}}
"""
pass

{{/supportHttpxSync}}
{{/operation}}
{{/operations}}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# coding: utf-8

{{>partial_header}}

import asyncio
import threading
from typing import Any, Coroutine, Optional, TypeVar

T = TypeVar("T")

_loop: Optional[asyncio.AbstractEventLoop] = None
_loop_lock = threading.Lock()


def _get_sync_loop() -> asyncio.AbstractEventLoop:
"""Return a dedicated event loop running in a background daemon thread.

A single, long-lived loop is reused for every synchronous call so that the
underlying ``httpx.AsyncClient`` (and its connection pool) stays bound to one
event loop across calls. The loop is created lazily and in a thread-safe way.
"""
global _loop
if _loop is None:
with _loop_lock:
if _loop is None:
loop = asyncio.new_event_loop()
thread = threading.Thread(
target=loop.run_forever,
name="{{packageName}}-sync-runner",
daemon=True,
)
thread.start()
_loop = loop
return _loop


def run_sync(coro: Coroutine[Any, Any, T]) -> T:
"""Run an async coroutine to completion and return its result synchronously.

The coroutine is scheduled on a dedicated background event loop and the
calling thread blocks until it finishes. This is used to expose synchronous
``_sync`` variants of the asynchronous API methods.

Note: this must not be called from within a running event loop (i.e. from
``async`` code) as it would block that loop; use the ``await``-able async
methods directly in that case.
"""
return asyncio.run_coroutine_threadsafe(coro, _get_sync_loop()).result()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: run_sync lacks runtime protection against being called from within an active event loop, risking event-loop freeze or deadlock

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/python/httpx/sync_helper.mustache, line 48:

<comment>`run_sync` lacks runtime protection against being called from within an active event loop, risking event-loop freeze or deadlock</comment>

<file context>
@@ -0,0 +1,48 @@
+    ``async`` code) as it would block that loop; use the ``await``-able async
+    methods directly in that case.
+    """
+    return asyncio.run_coroutine_threadsafe(coro, _get_sync_loop()).result()
</file context>

Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# NOTE: This file is auto generated by OpenAPI Generator.
# URL: https://openapi-generator.tech
#
# ref: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: petstore_api Python package

on: [push, pull_request]

permissions:
contents: read

jobs:
build:

runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r test-requirements.txt
- name: Test with pytest
run: |
pytest --cov=petstore_api
66 changes: 66 additions & 0 deletions samples/openapi3/client/petstore/python-httpx-sync/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
venv/
.venv/
.python-version
.pytest_cache

# Translations
*.mo
*.pot

# Django stuff:
*.log

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Ipython Notebook
.ipynb_checkpoints
31 changes: 31 additions & 0 deletions samples/openapi3/client/petstore/python-httpx-sync/.gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# NOTE: This file is auto generated by OpenAPI Generator.
# URL: https://openapi-generator.tech
#
# ref: https://docs.gitlab.com/ee/ci/README.html
# ref: https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Python.gitlab-ci.yml

stages:
- test

.pytest:
stage: test
script:
- pip install -r requirements.txt
- pip install -r test-requirements.txt
- pytest --cov=petstore_api

pytest-3.10:
extends: .pytest
image: python:3.10-alpine
pytest-3.11:
extends: .pytest
image: python:3.11-alpine
pytest-3.12:
extends: .pytest
image: python:3.12-alpine
pytest-3.13:
extends: .pytest
image: python:3.13-alpine
pytest-3.14:
extends: .pytest
image: python:3.14-alpine
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator

# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.

# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs

# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux

# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux

# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md
Loading
Loading