Skip to content

Commit c61c3a2

Browse files
aegeigercdoern
andauthored
fix: make AsyncOgxClient functional by inheriting from sync ApiClient (#6372)
# What does this PR do? The AsyncOgxClient was completely non-functional. Any API call through it would immediately crash with AttributeError: 'AsyncApiClient' object has no attribute 'select_header_accept' because AsyncApiClient was a standalone class missing 15+ methods (select_header_accept, param_serialize, call_api, response_deserialize, etc.) that the generated API classes depend on. Additionally, all API methods wired into AsyncOgxClient were synchronous and could not be awaited, and nested API wiring (e.g. client.chat.completions) was never applied. This PR fixes the async client by: - Making AsyncApiClient inherit from ApiClient, overriding only the HTTP transport layer (__init__, call_api, close, context managers). All serialization/deserialization methods are inherited unchanged. - Generating proper Async*Api subclasses (e.g. AsyncInspectApi(InspectApi)) in the same file via the api.mustache template. These inherit _serialize helpers and override the operation methods with async def / await. - Updating AsyncOgxClient to wire Async*Api classes instead of sync ones. - Fixing patch_hierarchy.py to apply nested API wiring to both OgxClient and AsyncOgxClient. - Adding RESTResponse.aread() for async response body reading. - Updating export templates (exports_api.mustache, exports_package.mustache, __init__package.mustache) to include the new Async*Api classes. ## Test Plan Ran integration test to validate no regression for sync client Ran this simple script to validate async is at least somewhat working; ```python import asyncio from ogx_client import AsyncOgxClient async def main(): async with AsyncOgxClient(base_url="http://localhost:8321") as client: health = await client.inspect.health() models = await client.models.list() print(f"Health {health}") print(f"Models {models}") asyncio.run(main()) ``` --------- Signed-off-by: Eitan Geiger <egeiger@redhat.com> Co-authored-by: Charlie Doern <cdoern@redhat.com>
1 parent 4c34ce1 commit c61c3a2

9 files changed

Lines changed: 643 additions & 296 deletions

File tree

client-sdks/openapi/patch_hierarchy.py

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,13 @@ def patch_optional_import(api_file: Path) -> bool:
152152

153153

154154
def patch_ogx_client(client_file: Path, pairs: list[tuple[str, str]]) -> bool:
155-
"""Patch OgxClient to wire up parent-child API relationships.
155+
"""Patch OgxClient and AsyncOgxClient to wire up parent-child API relationships.
156156
157-
Looks for the '# Nested API structure' comment and inserts assignments like:
157+
Looks for all '# Nested API structure' comment anchors and inserts assignments like:
158158
self.chat.completions = self.completions
159159
160+
This handles both OgxClient and AsyncOgxClient in the same file.
161+
160162
Returns True if the file was modified.
161163
"""
162164
if not client_file.exists():
@@ -166,14 +168,13 @@ def patch_ogx_client(client_file: Path, pairs: list[tuple[str, str]]) -> bool:
166168
with open(client_file) as f:
167169
lines = f.readlines()
168170

169-
# Find the anchor comment
170-
comment_idx = None
171+
# Find all anchor comment indices
172+
comment_indices = []
171173
for i, line in enumerate(lines):
172174
if "# Nested API structure" in line:
173-
comment_idx = i
174-
break
175+
comment_indices.append(i)
175176

176-
if comment_idx is None:
177+
if not comment_indices:
177178
print(f" Warning: '# Nested API structure' comment not found in {client_file}")
178179
return False
179180

@@ -185,30 +186,32 @@ def patch_ogx_client(client_file: Path, pairs: list[tuple[str, str]]) -> bool:
185186
if any(test_line in line for line in lines):
186187
return False
187188

188-
comment_line = lines[comment_idx]
189-
indent = len(comment_line) - len(comment_line.lstrip())
190-
191-
patch_lines = [f"{' ' * indent}# Wire up parent-child API relationships\n"]
192-
for parent_tag, child_tag in pairs:
193-
parent_snake = to_snake_case(parent_tag)
194-
child_snake = to_snake_case(child_tag)
195-
patch_lines.append(f"{' ' * indent}self.{parent_snake}.{child_snake} = self.{child_snake}\n")
196-
# Also add a short alias if the child name is prefixed with the parent name
197-
# e.g., chat_completions -> chat.completions
198-
if child_snake.startswith(f"{parent_snake}_"):
199-
subresource_name = child_snake.removeprefix(f"{parent_snake}_")
200-
patch_lines.append(
201-
f"{' ' * indent}self.{parent_snake}.__dict__['{subresource_name}'] = self.{child_snake}\n"
202-
)
203-
204-
insert_idx = comment_idx + 1
205-
for line in reversed(patch_lines):
206-
lines.insert(insert_idx, line)
189+
# Patch each anchor (process in reverse so line indices remain valid)
190+
for comment_idx in reversed(comment_indices):
191+
comment_line = lines[comment_idx]
192+
indent = len(comment_line) - len(comment_line.lstrip())
193+
194+
patch_lines = [f"{' ' * indent}# Wire up parent-child API relationships\n"]
195+
for parent_tag, child_tag in pairs:
196+
parent_snake = to_snake_case(parent_tag)
197+
child_snake = to_snake_case(child_tag)
198+
patch_lines.append(f"{' ' * indent}self.{parent_snake}.{child_snake} = self.{child_snake}\n")
199+
# Also add a short alias if the child name is prefixed with the parent name
200+
# e.g., chat_completions -> chat.completions
201+
if child_snake.startswith(f"{parent_snake}_"):
202+
subresource_name = child_snake.removeprefix(f"{parent_snake}_")
203+
patch_lines.append(
204+
f"{' ' * indent}self.{parent_snake}.__dict__['{subresource_name}'] = self.{child_snake}\n"
205+
)
206+
207+
insert_idx = comment_idx + 1
208+
for line in reversed(patch_lines):
209+
lines.insert(insert_idx, line)
207210

208211
with open(client_file, "w") as f:
209212
f.writelines(lines)
210213

211-
print(f" Patched OgxClient with {len(pairs)} parent-child relationships")
214+
print(f" Patched OgxClient and AsyncOgxClient with {len(pairs)} parent-child relationships each")
212215
return True
213216

214217

client-sdks/openapi/templates/python/__init__package.mustache

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ __all__ = [
2929
"OgxClient",
3030
"AsyncOgxClient",
3131
{{#apiInfo}}{{#apis}}"{{classname}}",
32+
"Async{{classname}}",
3233
{{/apis}}{{/apiInfo}}"ApiResponse",
3334
"APIResponse",
3435
"ApiClient",

client-sdks/openapi/templates/python/api.mustache

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ from typing_extensions import Annotated
1818
{{/imports}}
1919

2020
from {{packageName}}.api_client import ApiClient, RequestSerialized
21+
from {{packageName}}.async_api_client import AsyncApiClient
2122
from {{packageName}}.api_response import ApiResponse
2223
from {{packageName}}.rest import RESTResponseType
2324
from {{packageName}}.stream import Stream
25+
from {{packageName}}.async_stream import AsyncStream
2426
from {{packageName}}.lib._utils import pascal_to_snake_case
2527

2628

@@ -339,5 +341,148 @@ class {{classname}}:
339341
{{alias}} = {{target}}
340342
{{/vendorExtensions.x-operation-aliases}}
341343

344+
{{/operation}}
345+
346+
347+
class Async{{classname}}({{classname}}):
348+
"""Async version of {{classname}}.
349+
350+
Inherits serialization and deserialization from {{classname}}.
351+
Overrides HTTP methods to use async/await with AsyncApiClient.
352+
"""
353+
354+
def __init__(self, api_client=None) -> None:
355+
if api_client is None:
356+
api_client = AsyncApiClient.get_default()
357+
self.api_client = api_client
358+
self.logger = logging.getLogger(Async{{classname}}.__name__)
359+
360+
# Child API attributes (set by AsyncOgxClient based on x-nesting-path)
361+
{{#operation}}{{#vendorExtensions.x-nesting-path}}{{#-first}}{{#tags}}{{#-first}}{{#vendorExtensions.x-nesting-path}}{{^-last}} self.{{.}}: Any | None = None # Next in nesting path
362+
{{/-last}}{{/vendorExtensions.x-nesting-path}}{{/-first}}{{/tags}}{{/-first}}{{/vendorExtensions.x-nesting-path}}{{/operation}}
363+
364+
def _create_event_stream(self, response_data: RESTResponseType, stream_type_name: str) -> AsyncStream[Any]:
365+
"""Create a typed async event stream from an SSE response.
366+
367+
Args:
368+
response_data: The raw HTTP response containing the SSE stream.
369+
stream_type_name: The schema name for the streaming response type.
370+
"""
371+
stream_class = None
372+
discriminator_map = None
373+
discriminator_property = None
374+
try:
375+
module_name = pascal_to_snake_case(stream_type_name)
376+
model_module = importlib.import_module('{{packageName}}.models.' + module_name)
377+
stream_class = getattr(model_module, stream_type_name)
378+
discriminator_map = getattr(stream_class, 'discriminator_value_class_map', None)
379+
discriminator_property = getattr(stream_class, 'discriminator_property_name', None)
380+
except (ImportError, ModuleNotFoundError, AttributeError) as e:
381+
self.logger.debug(f"Could not import stream type {stream_type_name}: {e}")
382+
383+
def stream_decoder(data_str: str) -> Any:
384+
if not data_str:
385+
return None
386+
try:
387+
data = json.loads(data_str)
388+
except json.JSONDecodeError:
389+
return data_str
390+
391+
if discriminator_map and discriminator_property:
392+
disc_value = data.get(discriminator_property, '')
393+
variant_class_name = discriminator_map.get(disc_value)
394+
if variant_class_name:
395+
try:
396+
variant_module_name = pascal_to_snake_case(variant_class_name)
397+
variant_module = importlib.import_module('{{packageName}}.models.' + variant_module_name)
398+
variant_class = getattr(variant_module, variant_class_name)
399+
return variant_class.from_dict(data)
400+
except Exception as e:
401+
self.logger.debug(f"Failed to deserialize as {variant_class_name}: {e}")
402+
403+
if stream_class is not None:
404+
try:
405+
result = stream_class.from_dict(data)
406+
if hasattr(result, 'actual_instance') and result.actual_instance is not None:
407+
return result.actual_instance
408+
return result
409+
except Exception as e:
410+
self.logger.debug(f"Failed to deserialize as {stream_type_name}: {e}")
411+
412+
return data
413+
414+
return AsyncStream(
415+
response=response_data.response,
416+
client=self.api_client,
417+
decoder=stream_decoder,
418+
)
419+
420+
{{#operation}}
421+
422+
@validate_call
423+
async def {{#vendorExtensions.x-operation-name}}{{vendorExtensions.x-operation-name}}{{/vendorExtensions.x-operation-name}}{{^vendorExtensions.x-operation-name}}{{operationId}}{{/vendorExtensions.x-operation-name}}{{>partial_api_args}} -> {{#vendorExtensions.x-streaming}}{{#vendorExtensions.x-unwrap-list-response}}{{#returnType}}{{#returnContainer}}{{{returnBaseType}}} | AsyncStream[{{{returnBaseType}}}]{{/returnContainer}}{{^returnContainer}}{{{returnType}}} | AsyncStream[{{{returnType}}}]{{/returnContainer}}{{/returnType}}{{^returnType}}None | AsyncStream[None]{{/returnType}}{{/vendorExtensions.x-unwrap-list-response}}{{^vendorExtensions.x-unwrap-list-response}}{{{returnType}}}{{^returnType}}None{{/returnType}} | AsyncStream[{{{returnType}}}{{^returnType}}None{{/returnType}}]{{/vendorExtensions.x-unwrap-list-response}}{{/vendorExtensions.x-streaming}}{{^vendorExtensions.x-streaming}}{{#vendorExtensions.x-unwrap-list-response}}{{#returnType}}{{#returnContainer}}{{{returnBaseType}}}{{/returnContainer}}{{^returnContainer}}{{{returnType}}}{{/returnContainer}}{{/returnType}}{{^returnType}}None{{/returnType}}{{/vendorExtensions.x-unwrap-list-response}}{{^vendorExtensions.x-unwrap-list-response}}{{{returnType}}}{{^returnType}}None{{/returnType}}{{/vendorExtensions.x-unwrap-list-response}}{{/vendorExtensions.x-streaming}}:
424+
{{>partial_api}}
425+
426+
response_data = await self.api_client.call_api(
427+
*_param,
428+
_request_timeout=_request_timeout
429+
)
430+
431+
{{#vendorExtensions.x-streaming}}
432+
# Check if this is a streaming response
433+
content_type = response_data.response.headers.get('Content-Type', '')
434+
if 'text/event-stream' in content_type:
435+
return self._create_event_stream(response_data, "{{vendorExtensions.x-streaming-type}}")
436+
437+
{{/vendorExtensions.x-streaming}}
438+
await response_data.aread()
439+
_deserialized = self.api_client.response_deserialize(
440+
response_data=response_data,
441+
response_types_map=_response_types_map,
442+
).data
443+
# Unwrap List*Response wrappers to return the data field directly
444+
{{#vendorExtensions.x-unwrap-list-response}}
445+
if _deserialized is not None and hasattr(_deserialized, 'data'):
446+
return _deserialized.data
447+
{{/vendorExtensions.x-unwrap-list-response}}
448+
return _deserialized
449+
450+
@validate_call
451+
async def {{#vendorExtensions.x-operation-name}}{{vendorExtensions.x-operation-name}}{{/vendorExtensions.x-operation-name}}{{^vendorExtensions.x-operation-name}}{{operationId}}{{/vendorExtensions.x-operation-name}}_with_http_info{{>partial_api_args}} -> {{#vendorExtensions.x-streaming}}ApiResponse[{{{returnType}}}{{^returnType}}None{{/returnType}}] | AsyncStream[{{{returnType}}}{{^returnType}}None{{/returnType}}]{{/vendorExtensions.x-streaming}}{{^vendorExtensions.x-streaming}}ApiResponse[{{{returnType}}}{{^returnType}}None{{/returnType}}]{{/vendorExtensions.x-streaming}}:
452+
{{>partial_api}}
453+
454+
response_data = await self.api_client.call_api(
455+
*_param,
456+
_request_timeout=_request_timeout
457+
)
458+
459+
{{#vendorExtensions.x-streaming}}
460+
# Check if this is a streaming response
461+
content_type = response_data.response.headers.get('Content-Type', '')
462+
if 'text/event-stream' in content_type:
463+
return self._create_event_stream(response_data, "{{vendorExtensions.x-streaming-type}}")
464+
465+
{{/vendorExtensions.x-streaming}}
466+
await response_data.aread()
467+
return self.api_client.response_deserialize(
468+
response_data=response_data,
469+
response_types_map=_response_types_map,
470+
)
471+
472+
@validate_call
473+
async def {{#vendorExtensions.x-operation-name}}{{vendorExtensions.x-operation-name}}{{/vendorExtensions.x-operation-name}}{{^vendorExtensions.x-operation-name}}{{operationId}}{{/vendorExtensions.x-operation-name}}_without_preload_content{{>partial_api_args}} -> RESTResponseType:
474+
{{>partial_api}}
475+
476+
response_data = await self.api_client.call_api(
477+
*_param,
478+
_request_timeout=_request_timeout
479+
)
480+
return response_data.response
481+
482+
483+
{{#vendorExtensions.x-operation-aliases}}
484+
{{alias}} = {{target}}
485+
{{/vendorExtensions.x-operation-aliases}}
486+
342487
{{/operation}}
343488
{{/operations}}

0 commit comments

Comments
 (0)