pyopenapi-gen 2.7.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyopenapi_gen/__init__.py +224 -0
- pyopenapi_gen/__main__.py +6 -0
- pyopenapi_gen/cli.py +62 -0
- pyopenapi_gen/context/CLAUDE.md +284 -0
- pyopenapi_gen/context/file_manager.py +52 -0
- pyopenapi_gen/context/import_collector.py +382 -0
- pyopenapi_gen/context/render_context.py +726 -0
- pyopenapi_gen/core/CLAUDE.md +224 -0
- pyopenapi_gen/core/__init__.py +0 -0
- pyopenapi_gen/core/auth/base.py +22 -0
- pyopenapi_gen/core/auth/plugins.py +89 -0
- pyopenapi_gen/core/cattrs_converter.py +810 -0
- pyopenapi_gen/core/exceptions.py +20 -0
- pyopenapi_gen/core/http_status_codes.py +218 -0
- pyopenapi_gen/core/http_transport.py +222 -0
- pyopenapi_gen/core/loader/__init__.py +12 -0
- pyopenapi_gen/core/loader/loader.py +174 -0
- pyopenapi_gen/core/loader/operations/__init__.py +12 -0
- pyopenapi_gen/core/loader/operations/parser.py +161 -0
- pyopenapi_gen/core/loader/operations/post_processor.py +62 -0
- pyopenapi_gen/core/loader/operations/request_body.py +90 -0
- pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
- pyopenapi_gen/core/loader/parameters/parser.py +186 -0
- pyopenapi_gen/core/loader/responses/__init__.py +10 -0
- pyopenapi_gen/core/loader/responses/parser.py +111 -0
- pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
- pyopenapi_gen/core/loader/schemas/extractor.py +275 -0
- pyopenapi_gen/core/pagination.py +64 -0
- pyopenapi_gen/core/parsing/__init__.py +13 -0
- pyopenapi_gen/core/parsing/common/__init__.py +1 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
- pyopenapi_gen/core/parsing/common/type_parser.py +73 -0
- pyopenapi_gen/core/parsing/context.py +187 -0
- pyopenapi_gen/core/parsing/cycle_helpers.py +126 -0
- pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
- pyopenapi_gen/core/parsing/keywords/all_of_parser.py +81 -0
- pyopenapi_gen/core/parsing/keywords/any_of_parser.py +84 -0
- pyopenapi_gen/core/parsing/keywords/array_items_parser.py +72 -0
- pyopenapi_gen/core/parsing/keywords/one_of_parser.py +77 -0
- pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
- pyopenapi_gen/core/parsing/schema_finalizer.py +169 -0
- pyopenapi_gen/core/parsing/schema_parser.py +804 -0
- pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
- pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +120 -0
- pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
- pyopenapi_gen/core/postprocess_manager.py +260 -0
- pyopenapi_gen/core/spec_fetcher.py +148 -0
- pyopenapi_gen/core/streaming_helpers.py +84 -0
- pyopenapi_gen/core/telemetry.py +69 -0
- pyopenapi_gen/core/utils.py +456 -0
- pyopenapi_gen/core/warning_collector.py +83 -0
- pyopenapi_gen/core/writers/code_writer.py +135 -0
- pyopenapi_gen/core/writers/documentation_writer.py +222 -0
- pyopenapi_gen/core/writers/line_writer.py +217 -0
- pyopenapi_gen/core/writers/python_construct_renderer.py +321 -0
- pyopenapi_gen/core_package_template/README.md +21 -0
- pyopenapi_gen/emit/models_emitter.py +143 -0
- pyopenapi_gen/emitters/CLAUDE.md +286 -0
- pyopenapi_gen/emitters/client_emitter.py +51 -0
- pyopenapi_gen/emitters/core_emitter.py +181 -0
- pyopenapi_gen/emitters/docs_emitter.py +44 -0
- pyopenapi_gen/emitters/endpoints_emitter.py +247 -0
- pyopenapi_gen/emitters/exceptions_emitter.py +187 -0
- pyopenapi_gen/emitters/mocks_emitter.py +185 -0
- pyopenapi_gen/emitters/models_emitter.py +426 -0
- pyopenapi_gen/generator/CLAUDE.md +352 -0
- pyopenapi_gen/generator/client_generator.py +567 -0
- pyopenapi_gen/generator/exceptions.py +7 -0
- pyopenapi_gen/helpers/CLAUDE.md +325 -0
- pyopenapi_gen/helpers/__init__.py +1 -0
- pyopenapi_gen/helpers/endpoint_utils.py +532 -0
- pyopenapi_gen/helpers/type_cleaner.py +334 -0
- pyopenapi_gen/helpers/type_helper.py +112 -0
- pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
- pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
- pyopenapi_gen/helpers/type_resolution/finalizer.py +105 -0
- pyopenapi_gen/helpers/type_resolution/named_resolver.py +172 -0
- pyopenapi_gen/helpers/type_resolution/object_resolver.py +216 -0
- pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +109 -0
- pyopenapi_gen/helpers/type_resolution/resolver.py +47 -0
- pyopenapi_gen/helpers/url_utils.py +14 -0
- pyopenapi_gen/http_types.py +20 -0
- pyopenapi_gen/ir.py +165 -0
- pyopenapi_gen/py.typed +1 -0
- pyopenapi_gen/types/CLAUDE.md +140 -0
- pyopenapi_gen/types/__init__.py +11 -0
- pyopenapi_gen/types/contracts/__init__.py +13 -0
- pyopenapi_gen/types/contracts/protocols.py +106 -0
- pyopenapi_gen/types/contracts/types.py +28 -0
- pyopenapi_gen/types/resolvers/__init__.py +7 -0
- pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
- pyopenapi_gen/types/resolvers/response_resolver.py +177 -0
- pyopenapi_gen/types/resolvers/schema_resolver.py +498 -0
- pyopenapi_gen/types/services/__init__.py +5 -0
- pyopenapi_gen/types/services/type_service.py +165 -0
- pyopenapi_gen/types/strategies/__init__.py +5 -0
- pyopenapi_gen/types/strategies/response_strategy.py +310 -0
- pyopenapi_gen/visit/CLAUDE.md +272 -0
- pyopenapi_gen/visit/client_visitor.py +477 -0
- pyopenapi_gen/visit/docs_visitor.py +38 -0
- pyopenapi_gen/visit/endpoint/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/endpoint_visitor.py +292 -0
- pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +123 -0
- pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +222 -0
- pyopenapi_gen/visit/endpoint/generators/mock_generator.py +140 -0
- pyopenapi_gen/visit/endpoint/generators/overload_generator.py +252 -0
- pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +705 -0
- pyopenapi_gen/visit/endpoint/generators/signature_generator.py +83 -0
- pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +207 -0
- pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +78 -0
- pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
- pyopenapi_gen/visit/exception_visitor.py +90 -0
- pyopenapi_gen/visit/model/__init__.py +0 -0
- pyopenapi_gen/visit/model/alias_generator.py +93 -0
- pyopenapi_gen/visit/model/dataclass_generator.py +553 -0
- pyopenapi_gen/visit/model/enum_generator.py +212 -0
- pyopenapi_gen/visit/model/model_visitor.py +198 -0
- pyopenapi_gen/visit/visitor.py +97 -0
- pyopenapi_gen-2.7.2.dist-info/METADATA +1169 -0
- pyopenapi_gen-2.7.2.dist-info/RECORD +137 -0
- pyopenapi_gen-2.7.2.dist-info/WHEEL +4 -0
- pyopenapi_gen-2.7.2.dist-info/entry_points.txt +2 -0
- pyopenapi_gen-2.7.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, AsyncIterator, List
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SSEEvent:
|
|
8
|
+
def __init__(self, data: str, event: str | None = None, id: str | None = None, retry: int | None = None) -> None:
|
|
9
|
+
self.data: str = data
|
|
10
|
+
self.event: str | None = event
|
|
11
|
+
self.id: str | None = id
|
|
12
|
+
self.retry: int | None = retry
|
|
13
|
+
|
|
14
|
+
def __repr__(self) -> str:
|
|
15
|
+
return f"SSEEvent(data={self.data!r}, event={self.event!r}, id={self.id!r}, retry={self.retry!r})"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def iter_bytes(response: httpx.Response) -> AsyncIterator[bytes]:
|
|
19
|
+
async for chunk in response.aiter_bytes():
|
|
20
|
+
yield chunk
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def iter_ndjson(response: httpx.Response) -> AsyncIterator[Any]:
|
|
24
|
+
async for line in response.aiter_lines():
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if line:
|
|
27
|
+
yield json.loads(line)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def iter_sse(response: httpx.Response) -> AsyncIterator[SSEEvent]:
|
|
31
|
+
"""Parse Server-Sent Events (SSE) from a streaming response."""
|
|
32
|
+
event_lines: list[str] = []
|
|
33
|
+
async for line in response.aiter_lines():
|
|
34
|
+
if line == "":
|
|
35
|
+
# End of event
|
|
36
|
+
if event_lines:
|
|
37
|
+
event = _parse_sse_event(event_lines)
|
|
38
|
+
if event:
|
|
39
|
+
yield event
|
|
40
|
+
event_lines = []
|
|
41
|
+
else:
|
|
42
|
+
event_lines.append(line)
|
|
43
|
+
# Last event (if any)
|
|
44
|
+
if event_lines:
|
|
45
|
+
event = _parse_sse_event(event_lines)
|
|
46
|
+
if event:
|
|
47
|
+
yield event
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_sse_event(lines: List[str]) -> SSEEvent:
|
|
51
|
+
data = []
|
|
52
|
+
event = None
|
|
53
|
+
id = None
|
|
54
|
+
retry = None
|
|
55
|
+
for line in lines:
|
|
56
|
+
if line.startswith(":"):
|
|
57
|
+
continue # comment
|
|
58
|
+
if ":" in line:
|
|
59
|
+
field, value = line.split(":", 1)
|
|
60
|
+
value = value.lstrip()
|
|
61
|
+
if field == "data":
|
|
62
|
+
data.append(value)
|
|
63
|
+
elif field == "event":
|
|
64
|
+
event = value
|
|
65
|
+
elif field == "id":
|
|
66
|
+
id = value
|
|
67
|
+
elif field == "retry":
|
|
68
|
+
try:
|
|
69
|
+
retry = int(value)
|
|
70
|
+
except ValueError:
|
|
71
|
+
pass
|
|
72
|
+
return SSEEvent(data="\n".join(data), event=event, id=id, retry=retry)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def iter_sse_events_text(response: httpx.Response) -> AsyncIterator[str]:
|
|
76
|
+
"""
|
|
77
|
+
Parses a Server-Sent Events (SSE) stream and yields the `data` field content
|
|
78
|
+
as a string for each event.
|
|
79
|
+
This is specifically for cases where the event data is expected to be a
|
|
80
|
+
single text payload (e.g., a JSON string) per event.
|
|
81
|
+
"""
|
|
82
|
+
async for sse_event in iter_sse(response):
|
|
83
|
+
if sse_event.data: # Ensure data is not empty
|
|
84
|
+
yield sse_event.data
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telemetry client for usage tracking and analytics.
|
|
3
|
+
|
|
4
|
+
This module provides the TelemetryClient class, which handles anonymous
|
|
5
|
+
usage telemetry for PyOpenAPI Generator. Telemetry is opt-in only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TelemetryClient:
|
|
15
|
+
"""
|
|
16
|
+
Client for sending opt-in telemetry events.
|
|
17
|
+
|
|
18
|
+
This class handles emitting usage events to understand how the generator
|
|
19
|
+
is being used. Telemetry is disabled by default and must be explicitly
|
|
20
|
+
enabled either through the PYOPENAPI_TELEMETRY_ENABLED environment
|
|
21
|
+
variable or by passing enabled=True to the constructor.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
enabled: Whether telemetry is currently enabled
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, enabled: bool | None = None) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Initialize a new TelemetryClient.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
enabled: Explicitly enable or disable telemetry. If None, the environment
|
|
33
|
+
variable PYOPENAPI_TELEMETRY_ENABLED is checked.
|
|
34
|
+
"""
|
|
35
|
+
if enabled is None:
|
|
36
|
+
env = os.getenv("PYOPENAPI_TELEMETRY_ENABLED", "false").lower()
|
|
37
|
+
self.enabled = env in ("1", "true", "yes")
|
|
38
|
+
else:
|
|
39
|
+
self.enabled = enabled
|
|
40
|
+
|
|
41
|
+
def track_event(self, event: str, properties: dict[str, Any] | None = None) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Track a telemetry event if telemetry is enabled.
|
|
44
|
+
|
|
45
|
+
This method sends a telemetry event with additional properties.
|
|
46
|
+
Events are silently dropped if telemetry is disabled.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
event: The name of the event to track
|
|
50
|
+
properties: Optional dictionary of additional properties to include
|
|
51
|
+
"""
|
|
52
|
+
if not self.enabled:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
data: dict[str, Any] = {
|
|
56
|
+
"event": event,
|
|
57
|
+
"properties": properties or {},
|
|
58
|
+
"timestamp": time.time(),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
# Using print as a stub for actual telemetry transport
|
|
63
|
+
# In production, this would be replaced with a proper telemetry client
|
|
64
|
+
print("TELEMETRY", json.dumps(data))
|
|
65
|
+
except Exception as e:
|
|
66
|
+
# Telemetry failures should not affect execution, but log for debugging
|
|
67
|
+
import logging
|
|
68
|
+
|
|
69
|
+
logging.getLogger(__name__).debug(f"Telemetry event failed: {e}")
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Utilities for pyopenapi_gen.
|
|
2
|
+
|
|
3
|
+
This module contains utility classes and functions used across the code generation process.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import dataclasses
|
|
8
|
+
import keyword
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Set, Type, TypeVar, cast
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NameSanitizer:
|
|
20
|
+
"""Helper to sanitize spec names and tags into valid Python identifiers and filenames."""
|
|
21
|
+
|
|
22
|
+
# Python built-ins and common problematic names that should be avoided in module names
|
|
23
|
+
RESERVED_NAMES = {
|
|
24
|
+
# Built-in types
|
|
25
|
+
"type",
|
|
26
|
+
"int",
|
|
27
|
+
"str",
|
|
28
|
+
"float",
|
|
29
|
+
"bool",
|
|
30
|
+
"list",
|
|
31
|
+
"dict",
|
|
32
|
+
"set",
|
|
33
|
+
"tuple",
|
|
34
|
+
"bytes",
|
|
35
|
+
"object",
|
|
36
|
+
"complex",
|
|
37
|
+
"frozenset",
|
|
38
|
+
"bytearray",
|
|
39
|
+
"memoryview",
|
|
40
|
+
"range",
|
|
41
|
+
# Built-in functions
|
|
42
|
+
"abs",
|
|
43
|
+
"all",
|
|
44
|
+
"any",
|
|
45
|
+
"bin",
|
|
46
|
+
"callable",
|
|
47
|
+
"chr",
|
|
48
|
+
"classmethod",
|
|
49
|
+
"compile",
|
|
50
|
+
"delattr",
|
|
51
|
+
"dir",
|
|
52
|
+
"divmod",
|
|
53
|
+
"enumerate",
|
|
54
|
+
"eval",
|
|
55
|
+
"exec",
|
|
56
|
+
"filter",
|
|
57
|
+
"format",
|
|
58
|
+
"getattr",
|
|
59
|
+
"globals",
|
|
60
|
+
"hasattr",
|
|
61
|
+
"hash",
|
|
62
|
+
"help",
|
|
63
|
+
"hex",
|
|
64
|
+
"id",
|
|
65
|
+
"input",
|
|
66
|
+
"isinstance",
|
|
67
|
+
"issubclass",
|
|
68
|
+
"iter",
|
|
69
|
+
"len",
|
|
70
|
+
"locals",
|
|
71
|
+
"map",
|
|
72
|
+
"max",
|
|
73
|
+
"min",
|
|
74
|
+
"next",
|
|
75
|
+
"oct",
|
|
76
|
+
"open",
|
|
77
|
+
"ord",
|
|
78
|
+
"pow",
|
|
79
|
+
"print",
|
|
80
|
+
"property",
|
|
81
|
+
"repr",
|
|
82
|
+
"reversed",
|
|
83
|
+
"round",
|
|
84
|
+
"setattr",
|
|
85
|
+
"slice",
|
|
86
|
+
"sorted",
|
|
87
|
+
"staticmethod",
|
|
88
|
+
"sum",
|
|
89
|
+
"super",
|
|
90
|
+
"vars",
|
|
91
|
+
"zip",
|
|
92
|
+
# Common standard library modules
|
|
93
|
+
"os",
|
|
94
|
+
"sys",
|
|
95
|
+
"json",
|
|
96
|
+
"time",
|
|
97
|
+
"datetime",
|
|
98
|
+
"math",
|
|
99
|
+
"random",
|
|
100
|
+
"string",
|
|
101
|
+
"collections",
|
|
102
|
+
"itertools",
|
|
103
|
+
"functools",
|
|
104
|
+
"typing",
|
|
105
|
+
"pathlib",
|
|
106
|
+
"logging",
|
|
107
|
+
"urllib",
|
|
108
|
+
"http",
|
|
109
|
+
"email",
|
|
110
|
+
"uuid",
|
|
111
|
+
"hashlib",
|
|
112
|
+
"base64",
|
|
113
|
+
"copy",
|
|
114
|
+
"re",
|
|
115
|
+
# Other problematic names
|
|
116
|
+
"data",
|
|
117
|
+
"model",
|
|
118
|
+
"models",
|
|
119
|
+
"client",
|
|
120
|
+
"api",
|
|
121
|
+
"config",
|
|
122
|
+
"utils",
|
|
123
|
+
"helpers",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def sanitize_module_name(name: str) -> str:
|
|
128
|
+
"""Convert a raw name into a valid Python module name in snake_case, splitting camel case and PascalCase."""
|
|
129
|
+
# # <<< Add Check for problematic input >>>
|
|
130
|
+
# if '[' in name or ']' in name or ',' in name:
|
|
131
|
+
# logger.error(f"sanitize_module_name received potentially invalid input: '{name}'")
|
|
132
|
+
# # Optionally, return a default/error value or raise exception
|
|
133
|
+
# # For now, just log and continue
|
|
134
|
+
# # <<< End Check >>>
|
|
135
|
+
|
|
136
|
+
# Split on non-alphanumeric and camel case boundaries
|
|
137
|
+
words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", name)
|
|
138
|
+
if not words:
|
|
139
|
+
# fallback: split on non-alphanumerics
|
|
140
|
+
words = re.split(r"\W+", name)
|
|
141
|
+
module = "_".join(word.lower() for word in words if word)
|
|
142
|
+
# If it starts with a digit, prefix with underscore
|
|
143
|
+
if module and module[0].isdigit():
|
|
144
|
+
module = "_" + module
|
|
145
|
+
# Avoid Python keywords and reserved names
|
|
146
|
+
if keyword.iskeyword(module) or module in NameSanitizer.RESERVED_NAMES:
|
|
147
|
+
module += "_"
|
|
148
|
+
return module
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def sanitize_class_name(name: str) -> str:
|
|
152
|
+
"""Convert a raw name into a valid Python class name in PascalCase."""
|
|
153
|
+
# Split on non-alphanumeric and camel case boundaries
|
|
154
|
+
words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", name)
|
|
155
|
+
if not words: # Fallback if findall is empty (e.g. if name was all symbols)
|
|
156
|
+
# Basic split on non-alphanumeric as a last resort if findall yields nothing
|
|
157
|
+
words = [part for part in re.split(r"[^a-zA-Z0-9]+", name) if part]
|
|
158
|
+
|
|
159
|
+
# Capitalize each word and join
|
|
160
|
+
cls_name = "".join(word.capitalize() for word in words if word)
|
|
161
|
+
|
|
162
|
+
if not cls_name: # If name was e.g. "-" or "_"
|
|
163
|
+
cls_name = "UnnamedClass" # Or some other default
|
|
164
|
+
|
|
165
|
+
# If it starts with a digit, prefix with underscore
|
|
166
|
+
if cls_name[0].isdigit(): # Check after ensuring cls_name is not empty
|
|
167
|
+
cls_name = "_" + cls_name
|
|
168
|
+
# Avoid Python keywords and reserved names (case-insensitive)
|
|
169
|
+
if keyword.iskeyword(cls_name.lower()) or cls_name.lower() in NameSanitizer.RESERVED_NAMES:
|
|
170
|
+
cls_name += "_"
|
|
171
|
+
return cls_name
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def sanitize_tag_class_name(tag: str) -> str:
|
|
175
|
+
"""Sanitize a tag for use as a PascalCase client class name (e.g., DataSourcesClient)."""
|
|
176
|
+
words = re.split(r"[\W_]+", tag)
|
|
177
|
+
return "".join(word.capitalize() for word in words if word) + "Client"
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def sanitize_tag_attr_name(tag: str) -> str:
|
|
181
|
+
"""Sanitize a tag for use as a snake_case attribute name (e.g., data_sources)."""
|
|
182
|
+
attr = re.sub(r"[\W]+", "_", tag).lower()
|
|
183
|
+
return attr.strip("_")
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def normalize_tag_key(tag: str) -> str:
|
|
187
|
+
"""Normalize a tag for case-insensitive uniqueness (e.g., datasources)."""
|
|
188
|
+
return re.sub(r"[\W_]+", "", tag).lower()
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def sanitize_filename(name: str, suffix: str = ".py") -> str:
|
|
192
|
+
"""Generate a valid Python filename from raw name in snake_case."""
|
|
193
|
+
module = NameSanitizer.sanitize_module_name(name)
|
|
194
|
+
return module + suffix
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def sanitize_method_name(name: str) -> str:
|
|
198
|
+
"""Convert a raw name into a valid Python method name in snake_case, splitting camelCase and PascalCase."""
|
|
199
|
+
# Remove curly braces
|
|
200
|
+
name = re.sub(r"[{}]", "", name)
|
|
201
|
+
# Split camelCase and PascalCase to snake_case
|
|
202
|
+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
|
203
|
+
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
204
|
+
# Replace non-alphanumerics with underscores
|
|
205
|
+
name = re.sub(r"[^0-9a-zA-Z_]", "_", name)
|
|
206
|
+
# Lowercase and collapse multiple underscores
|
|
207
|
+
name = re.sub(r"_+", "_", name).strip("_").lower()
|
|
208
|
+
# If it starts with a digit, prefix with underscore
|
|
209
|
+
if name and name[0].isdigit():
|
|
210
|
+
name = "_" + name
|
|
211
|
+
# Avoid Python keywords and reserved names
|
|
212
|
+
if keyword.iskeyword(name) or name in NameSanitizer.RESERVED_NAMES:
|
|
213
|
+
name += "_"
|
|
214
|
+
return name
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def is_valid_python_identifier(name: str) -> bool:
|
|
218
|
+
"""Check if a string is a valid Python identifier."""
|
|
219
|
+
if not isinstance(name, str) or not name:
|
|
220
|
+
return False
|
|
221
|
+
# Check if it's a keyword
|
|
222
|
+
if keyword.iskeyword(name):
|
|
223
|
+
return False
|
|
224
|
+
# Check pattern: starts with letter/underscore, then letter/digit/underscore
|
|
225
|
+
return re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name) is not None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ParamSubstitutor:
|
|
229
|
+
"""Helper for rendering path templates with path parameters."""
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def render_path(template: str, values: dict[str, Any]) -> str:
|
|
233
|
+
"""Replace placeholders in a URL path template using provided values."""
|
|
234
|
+
rendered = template
|
|
235
|
+
for key, val in values.items():
|
|
236
|
+
rendered = rendered.replace(f"{{{key}}}", str(val))
|
|
237
|
+
return rendered
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class KwargsBuilder:
|
|
241
|
+
"""Builder for assembling HTTP request keyword arguments."""
|
|
242
|
+
|
|
243
|
+
def __init__(self) -> None:
|
|
244
|
+
self._kwargs: dict[str, Any] = {}
|
|
245
|
+
|
|
246
|
+
def with_params(self, **params: Any) -> "KwargsBuilder":
|
|
247
|
+
"""Add query parameters, skipping None values."""
|
|
248
|
+
filtered = {k: v for k, v in params.items() if v is not None}
|
|
249
|
+
if filtered:
|
|
250
|
+
self._kwargs["params"] = filtered
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def with_json(self, body: Any) -> "KwargsBuilder":
|
|
254
|
+
"""Add a JSON body to the request."""
|
|
255
|
+
self._kwargs["json"] = body
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def build(self) -> dict[str, Any]:
|
|
259
|
+
"""Return the assembled kwargs dictionary."""
|
|
260
|
+
return self._kwargs
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Formatter:
|
|
264
|
+
"""Helper to format code using Black, falling back to unformatted content if Black is unavailable or errors."""
|
|
265
|
+
|
|
266
|
+
def __init__(self) -> None:
|
|
267
|
+
from typing import Any, Callable
|
|
268
|
+
|
|
269
|
+
self._file_mode: Any | None = None
|
|
270
|
+
self._format_str: Callable[..., str] | None = None
|
|
271
|
+
try:
|
|
272
|
+
from black import FileMode, format_str
|
|
273
|
+
|
|
274
|
+
# Suppress blib2to3 debug logging that floods output during formatting
|
|
275
|
+
blib2to3_logger = logging.getLogger("blib2to3")
|
|
276
|
+
blib2to3_logger.setLevel(logging.WARNING)
|
|
277
|
+
|
|
278
|
+
# Also suppress the driver logger specifically
|
|
279
|
+
driver_logger = logging.getLogger("blib2to3.pgen2.driver")
|
|
280
|
+
driver_logger.setLevel(logging.WARNING)
|
|
281
|
+
|
|
282
|
+
# Initialize Black formatter
|
|
283
|
+
self._file_mode = FileMode()
|
|
284
|
+
self._format_str = format_str
|
|
285
|
+
except ImportError:
|
|
286
|
+
self._file_mode = None
|
|
287
|
+
self._format_str = None
|
|
288
|
+
|
|
289
|
+
def format(self, code: str) -> str:
|
|
290
|
+
"""Format the given code string with Black if possible."""
|
|
291
|
+
if self._format_str is not None and self._file_mode is not None:
|
|
292
|
+
try:
|
|
293
|
+
formatted: str = self._format_str(code, mode=self._file_mode)
|
|
294
|
+
return formatted
|
|
295
|
+
except Exception:
|
|
296
|
+
# On any Black formatting error, return original code
|
|
297
|
+
return code
|
|
298
|
+
return code
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# --- Casting Helper ---
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def safe_cast(expected_type: Type[T], data: Any) -> T:
|
|
305
|
+
"""
|
|
306
|
+
Performs a cast for the type checker using object cast.
|
|
307
|
+
(Validation temporarily removed).
|
|
308
|
+
"""
|
|
309
|
+
# No validation for now
|
|
310
|
+
# Cast to object first, then to expected_type
|
|
311
|
+
return cast(expected_type, cast(object, data)) # type: ignore[valid-type]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class DataclassSerializer:
|
|
315
|
+
"""Utility for converting dataclass instances to dictionaries for API serialization.
|
|
316
|
+
|
|
317
|
+
This enables automatic conversion of dataclass request bodies to JSON-compatible
|
|
318
|
+
dictionaries in generated client code, providing a better developer experience.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def serialize(obj: Any) -> Any:
|
|
323
|
+
"""Convert dataclass instances to dictionaries recursively.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
obj: The object to serialize. Can be a dataclass, list, dict, or primitive.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
The serialized object with dataclasses converted to dictionaries.
|
|
330
|
+
|
|
331
|
+
Handles:
|
|
332
|
+
- Dataclass instances with Meta.key_transform_with_dump: Applies field name mapping (snake_case → camelCase)
|
|
333
|
+
- Legacy BaseSchema instances: Falls back to to_dict() if present (backward compatibility)
|
|
334
|
+
- Regular dataclass instances: Converted to dictionaries using field names
|
|
335
|
+
- Lists: Recursively serialize each item
|
|
336
|
+
- Dictionaries: Recursively serialize values
|
|
337
|
+
- datetime: Convert to ISO format string
|
|
338
|
+
- Enums: Convert to their value
|
|
339
|
+
- bytes/bytearray: Convert to base64-encoded ASCII string
|
|
340
|
+
- Primitives: Return unchanged
|
|
341
|
+
- None values: Excluded from output
|
|
342
|
+
"""
|
|
343
|
+
# Track visited objects to handle circular references
|
|
344
|
+
return DataclassSerializer._serialize_with_tracking(obj, set())
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _serialize_with_tracking(obj: Any, visited: Set[int]) -> Any:
|
|
348
|
+
"""Internal serialization method with circular reference tracking."""
|
|
349
|
+
from enum import Enum
|
|
350
|
+
|
|
351
|
+
# Handle None values by excluding them
|
|
352
|
+
if obj is None:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
# Handle circular references
|
|
356
|
+
obj_id = id(obj)
|
|
357
|
+
if obj_id in visited:
|
|
358
|
+
# For circular references, return a simple representation
|
|
359
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
360
|
+
return f"<Circular reference to {obj.__class__.__name__}>"
|
|
361
|
+
return obj
|
|
362
|
+
|
|
363
|
+
# Handle datetime objects
|
|
364
|
+
if isinstance(obj, datetime):
|
|
365
|
+
return obj.isoformat()
|
|
366
|
+
|
|
367
|
+
# Handle enum instances
|
|
368
|
+
if isinstance(obj, Enum) and not isinstance(obj, type):
|
|
369
|
+
return obj.value
|
|
370
|
+
|
|
371
|
+
# Handle legacy BaseSchema instances (backward compatibility)
|
|
372
|
+
# Check for BaseSchema by looking for both to_dict and _get_field_mappings methods
|
|
373
|
+
if hasattr(obj, "to_dict") and hasattr(obj, "_get_field_mappings") and callable(obj.to_dict):
|
|
374
|
+
visited.add(obj_id)
|
|
375
|
+
try:
|
|
376
|
+
# Use legacy BaseSchema's to_dict() which handles field name mapping
|
|
377
|
+
result_dict = obj.to_dict(exclude_none=True)
|
|
378
|
+
# Recursively serialize nested objects in the result
|
|
379
|
+
serialized_result = {}
|
|
380
|
+
for key, value in result_dict.items():
|
|
381
|
+
serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
|
|
382
|
+
if serialized_value is not None:
|
|
383
|
+
serialized_result[key] = serialized_value
|
|
384
|
+
return serialized_result
|
|
385
|
+
finally:
|
|
386
|
+
visited.discard(obj_id)
|
|
387
|
+
|
|
388
|
+
# Handle regular dataclass instances (with cattrs field mapping support)
|
|
389
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
390
|
+
visited.add(obj_id)
|
|
391
|
+
try:
|
|
392
|
+
# Build dict from dataclass fields without recursive unstructuring
|
|
393
|
+
data: dict[str, Any] = {}
|
|
394
|
+
for field in dataclasses.fields(obj):
|
|
395
|
+
value = getattr(obj, field.name)
|
|
396
|
+
data[field.name] = value
|
|
397
|
+
|
|
398
|
+
# Apply field name mapping if Meta class exists
|
|
399
|
+
if hasattr(obj, "Meta") and hasattr(obj.Meta, "key_transform_with_dump"):
|
|
400
|
+
mappings = obj.Meta.key_transform_with_dump
|
|
401
|
+
# Transform keys according to mapping
|
|
402
|
+
data = {mappings.get(k, k): v for k, v in data.items()}
|
|
403
|
+
|
|
404
|
+
# Recursively serialize nested objects and skip None values
|
|
405
|
+
result = {}
|
|
406
|
+
for key, value in data.items():
|
|
407
|
+
if value is not None:
|
|
408
|
+
serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
|
|
409
|
+
if serialized_value is not None:
|
|
410
|
+
result[key] = serialized_value
|
|
411
|
+
return result
|
|
412
|
+
finally:
|
|
413
|
+
visited.discard(obj_id)
|
|
414
|
+
|
|
415
|
+
# Handle lists and tuples
|
|
416
|
+
if isinstance(obj, (list, tuple)):
|
|
417
|
+
return [DataclassSerializer._serialize_with_tracking(item, visited) for item in obj]
|
|
418
|
+
|
|
419
|
+
# Handle dictionaries
|
|
420
|
+
if isinstance(obj, dict):
|
|
421
|
+
result = {}
|
|
422
|
+
for key, value in obj.items():
|
|
423
|
+
serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
|
|
424
|
+
if serialized_value is not None:
|
|
425
|
+
result[key] = serialized_value
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
# Handle primitive types and unknown objects
|
|
429
|
+
if isinstance(obj, (str, int, float, bool)):
|
|
430
|
+
return obj
|
|
431
|
+
|
|
432
|
+
# Handle bytes - encode as base64 for JSON serialization
|
|
433
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
434
|
+
return base64.b64encode(bytes(obj)).decode("ascii")
|
|
435
|
+
|
|
436
|
+
# For unknown types, try to convert to string as fallback
|
|
437
|
+
try:
|
|
438
|
+
# If the object has a __dict__, try to serialize it like a dataclass
|
|
439
|
+
if hasattr(obj, "__dict__"):
|
|
440
|
+
visited.add(obj_id)
|
|
441
|
+
try:
|
|
442
|
+
result = {}
|
|
443
|
+
for key, value in obj.__dict__.items():
|
|
444
|
+
if not key.startswith("_"): # Skip private attributes
|
|
445
|
+
serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
|
|
446
|
+
if serialized_value is not None:
|
|
447
|
+
result[key] = serialized_value
|
|
448
|
+
return result
|
|
449
|
+
finally:
|
|
450
|
+
visited.discard(obj_id)
|
|
451
|
+
else:
|
|
452
|
+
# Fallback to string representation
|
|
453
|
+
return str(obj)
|
|
454
|
+
except Exception:
|
|
455
|
+
# Ultimate fallback
|
|
456
|
+
return str(obj)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Warning collector for the IR layer.
|
|
3
|
+
|
|
4
|
+
This module provides utilities to collect actionable warnings for incomplete
|
|
5
|
+
metadata in the IR (Intermediate Representation) objects, such as missing tags,
|
|
6
|
+
descriptions, or other quality issues in the OpenAPI spec that may lead to
|
|
7
|
+
suboptimal generated code.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from pyopenapi_gen import IRSpec
|
|
14
|
+
|
|
15
|
+
__all__ = ["WarningReport", "WarningCollector"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class WarningReport:
|
|
20
|
+
"""
|
|
21
|
+
Structured warning with a code, human-readable message, and remediation hint.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
code: A machine-readable warning code (e.g., "missing_tags")
|
|
25
|
+
message: A human-readable description of the warning
|
|
26
|
+
hint: A suggestion for how to fix or improve the issue
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
code: str
|
|
30
|
+
message: str
|
|
31
|
+
hint: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WarningCollector:
|
|
35
|
+
"""
|
|
36
|
+
Collects warnings about missing or incomplete information in an IRSpec.
|
|
37
|
+
|
|
38
|
+
This class analyzes an IRSpec object and identifies potential issues or
|
|
39
|
+
missing information that might lead to lower quality generated code or
|
|
40
|
+
documentation. It provides actionable warnings with hints for improvement.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
warnings: List of collected WarningReport objects
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
"""Initialize a new WarningCollector with an empty warning list."""
|
|
48
|
+
self.warnings: List[WarningReport] = []
|
|
49
|
+
|
|
50
|
+
def collect(self, spec: IRSpec) -> List[WarningReport]:
|
|
51
|
+
"""
|
|
52
|
+
Analyze an IRSpec and collect warnings about potential issues.
|
|
53
|
+
|
|
54
|
+
This method traverses the IRSpec and checks for common issues like
|
|
55
|
+
missing tags, descriptions, or other metadata that would improve
|
|
56
|
+
the quality of the generated code.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
spec: The IRSpec object to analyze
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A list of WarningReport objects describing identified issues
|
|
63
|
+
"""
|
|
64
|
+
# Operations without tags
|
|
65
|
+
for op in spec.operations:
|
|
66
|
+
if not op.tags:
|
|
67
|
+
self.warnings.append(
|
|
68
|
+
WarningReport(
|
|
69
|
+
code="missing_tags",
|
|
70
|
+
message=f"Operation '{op.operation_id}' has no tags.",
|
|
71
|
+
hint="Add tags to operations in the OpenAPI spec.",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
# Missing summary and description
|
|
75
|
+
if not op.summary and not op.description:
|
|
76
|
+
self.warnings.append(
|
|
77
|
+
WarningReport(
|
|
78
|
+
code="missing_description",
|
|
79
|
+
message=f"Operation '{op.operation_id}' missing summary/description.",
|
|
80
|
+
hint="Provide a summary or description for the operation.",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return self.warnings
|