django-esi 8.1.0__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.
- django_esi-8.1.0.dist-info/METADATA +93 -0
- django_esi-8.1.0.dist-info/RECORD +100 -0
- django_esi-8.1.0.dist-info/WHEEL +4 -0
- django_esi-8.1.0.dist-info/licenses/LICENSE +674 -0
- esi/__init__.py +7 -0
- esi/admin.py +42 -0
- esi/aiopenapi3/client.py +79 -0
- esi/aiopenapi3/plugins.py +224 -0
- esi/app_settings.py +112 -0
- esi/apps.py +11 -0
- esi/checks.py +56 -0
- esi/clients.py +657 -0
- esi/decorators.py +271 -0
- esi/errors.py +22 -0
- esi/exceptions.py +51 -0
- esi/helpers.py +63 -0
- esi/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
- esi/locale/cs_CZ/LC_MESSAGES/django.po +53 -0
- esi/locale/de/LC_MESSAGES/django.mo +0 -0
- esi/locale/de/LC_MESSAGES/django.po +58 -0
- esi/locale/en/LC_MESSAGES/django.mo +0 -0
- esi/locale/en/LC_MESSAGES/django.po +54 -0
- esi/locale/es/LC_MESSAGES/django.mo +0 -0
- esi/locale/es/LC_MESSAGES/django.po +59 -0
- esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
- esi/locale/fr_FR/LC_MESSAGES/django.po +59 -0
- esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
- esi/locale/it_IT/LC_MESSAGES/django.po +59 -0
- esi/locale/ja/LC_MESSAGES/django.mo +0 -0
- esi/locale/ja/LC_MESSAGES/django.po +58 -0
- esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
- esi/locale/ko_KR/LC_MESSAGES/django.po +58 -0
- esi/locale/nl_NL/LC_MESSAGES/django.mo +0 -0
- esi/locale/nl_NL/LC_MESSAGES/django.po +53 -0
- esi/locale/pl_PL/LC_MESSAGES/django.mo +0 -0
- esi/locale/pl_PL/LC_MESSAGES/django.po +53 -0
- esi/locale/ru/LC_MESSAGES/django.mo +0 -0
- esi/locale/ru/LC_MESSAGES/django.po +61 -0
- esi/locale/sk/LC_MESSAGES/django.mo +0 -0
- esi/locale/sk/LC_MESSAGES/django.po +55 -0
- esi/locale/uk/LC_MESSAGES/django.mo +0 -0
- esi/locale/uk/LC_MESSAGES/django.po +57 -0
- esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
- esi/locale/zh_Hans/LC_MESSAGES/django.po +58 -0
- esi/management/commands/__init__.py +0 -0
- esi/management/commands/esi_clear_spec_cache.py +21 -0
- esi/management/commands/generate_esi_stubs.py +661 -0
- esi/management/commands/migrate_to_ssov2.py +188 -0
- esi/managers.py +303 -0
- esi/managers.pyi +85 -0
- esi/migrations/0001_initial.py +55 -0
- esi/migrations/0002_scopes_20161208.py +56 -0
- esi/migrations/0003_hide_tokens_from_admin_site.py +23 -0
- esi/migrations/0004_remove_unique_access_token.py +18 -0
- esi/migrations/0005_remove_token_length_limit.py +23 -0
- esi/migrations/0006_remove_url_length_limit.py +18 -0
- esi/migrations/0007_fix_mysql_8_migration.py +18 -0
- esi/migrations/0008_nullable_refresh_token.py +18 -0
- esi/migrations/0009_set_old_tokens_to_sso_v1.py +18 -0
- esi/migrations/0010_set_new_tokens_to_sso_v2.py +18 -0
- esi/migrations/0011_add_token_indices.py +28 -0
- esi/migrations/0012_fix_token_type_choices.py +18 -0
- esi/migrations/0013_squashed_0012_fix_token_type_choices.py +57 -0
- esi/migrations/__init__.py +0 -0
- esi/models.py +349 -0
- esi/openapi_clients.py +1225 -0
- esi/rate_limiting.py +107 -0
- esi/signals.py +21 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Large_Black.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Large_White.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Small_Black.png +0 -0
- esi/static/esi/img/EVE_SSO_Login_Buttons_Small_White.png +0 -0
- esi/stubs.py +2 -0
- esi/stubs.pyi +6807 -0
- esi/tasks.py +78 -0
- esi/templates/esi/select_token.html +116 -0
- esi/templatetags/__init__.py +0 -0
- esi/templatetags/scope_tags.py +8 -0
- esi/tests/__init__.py +134 -0
- esi/tests/client_authed_pilot.py +63 -0
- esi/tests/client_public_pilot.py +53 -0
- esi/tests/factories.py +47 -0
- esi/tests/factories_2.py +60 -0
- esi/tests/jwt_factory.py +135 -0
- esi/tests/test_checks.py +48 -0
- esi/tests/test_clients.py +1019 -0
- esi/tests/test_decorators.py +578 -0
- esi/tests/test_management_command.py +307 -0
- esi/tests/test_managers.py +673 -0
- esi/tests/test_models.py +403 -0
- esi/tests/test_openapi.json +854 -0
- esi/tests/test_openapi.py +1017 -0
- esi/tests/test_swagger.json +489 -0
- esi/tests/test_swagger_full.json +51112 -0
- esi/tests/test_tasks.py +116 -0
- esi/tests/test_templatetags.py +22 -0
- esi/tests/test_views.py +331 -0
- esi/tests/threading_pilot.py +69 -0
- esi/urls.py +9 -0
- esi/views.py +129 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import keyword
|
|
4
|
+
import argparse
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from esi.openapi_clients import ESIClientProvider
|
|
8
|
+
|
|
9
|
+
from django.core.management.base import BaseCommand
|
|
10
|
+
|
|
11
|
+
from esi import __title__, __url__, __version__, stubs, __build_date__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sanitize_class_name(name: str) -> str:
|
|
15
|
+
"""Convert a tag into a valid Python class name."""
|
|
16
|
+
sanitized = re.sub(r'[^0-9a-zA-Z_]', '_', name.strip())
|
|
17
|
+
if sanitized and not sanitized[0].isalpha():
|
|
18
|
+
sanitized = f"_{sanitized}"
|
|
19
|
+
return sanitized
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sanitize_operation_class(name: str) -> str:
|
|
23
|
+
return re.sub(r'[^0-9a-zA-Z_]', '', name[0].upper() + name[1:] + "Operation")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ModelGenerator:
|
|
27
|
+
"""Generate Pydantic model stubs from OpenAPI schemas and return type names.
|
|
28
|
+
|
|
29
|
+
This only affects typing in the generated stubs (pyi) and does not change runtime behavior.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, components: dict | None = None) -> None:
|
|
33
|
+
self._models: dict[str, list[str]] = {}
|
|
34
|
+
self._name_counts: dict[str, int] = {}
|
|
35
|
+
self._schema_cache: dict[int, str] = {}
|
|
36
|
+
self._components = components or {}
|
|
37
|
+
self._component_class_map: dict[str, str] = {}
|
|
38
|
+
self._aliases: dict[str, str] = {}
|
|
39
|
+
|
|
40
|
+
def _unique_class_name(self, base: str) -> str:
|
|
41
|
+
base = sanitize_class_name(base)
|
|
42
|
+
if base not in self._models and base not in self._name_counts:
|
|
43
|
+
self._name_counts[base] = 1
|
|
44
|
+
return base
|
|
45
|
+
# Ensure uniqueness by suffixing
|
|
46
|
+
n = self._name_counts.get(base, 1)
|
|
47
|
+
while True:
|
|
48
|
+
candidate = f"{base}_{n}"
|
|
49
|
+
if candidate not in self._models:
|
|
50
|
+
self._name_counts[base] = n + 1
|
|
51
|
+
return candidate
|
|
52
|
+
n += 1
|
|
53
|
+
|
|
54
|
+
def _singleton_model(self, name: str, lines: list[str]) -> None:
|
|
55
|
+
"""Only emit once per class name"""
|
|
56
|
+
if name in self._models:
|
|
57
|
+
return
|
|
58
|
+
self._models[name] = lines
|
|
59
|
+
|
|
60
|
+
def _singleton_alias(self, name: str, rhs: str) -> None:
|
|
61
|
+
"""Only emit once per alias"""
|
|
62
|
+
if name in self._models:
|
|
63
|
+
return
|
|
64
|
+
# Avoid recursive aliases like `UUID = UUID`
|
|
65
|
+
if name == rhs:
|
|
66
|
+
return
|
|
67
|
+
self._models[name] = [f"{name} = {rhs}"]
|
|
68
|
+
self._aliases[name] = rhs
|
|
69
|
+
|
|
70
|
+
def _properties_dict(self, schema) -> dict:
|
|
71
|
+
""".properties or ._properties"""
|
|
72
|
+
props = getattr(schema, "properties", None)
|
|
73
|
+
if props is None:
|
|
74
|
+
props = getattr(schema, "_properties", None)
|
|
75
|
+
return props or {}
|
|
76
|
+
|
|
77
|
+
def _required_list(self, schema) -> list[str]:
|
|
78
|
+
""".required or ._required"""
|
|
79
|
+
req = getattr(schema, "required", None)
|
|
80
|
+
if req is None:
|
|
81
|
+
req = getattr(schema, "_required", None)
|
|
82
|
+
if isinstance(req, (list, tuple, set)):
|
|
83
|
+
return list(req)
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
def _additional_properties(self, schema) -> Any | None:
|
|
87
|
+
return getattr(schema, "additionalProperties", None)
|
|
88
|
+
|
|
89
|
+
def _enum_values(self, schema) -> list[str] | None:
|
|
90
|
+
""".enum or ._enum"""
|
|
91
|
+
values = getattr(schema, "enum", None)
|
|
92
|
+
if values is None:
|
|
93
|
+
values = getattr(schema, "_enum", None)
|
|
94
|
+
return list(values) if isinstance(values, (list, tuple, set)) else None
|
|
95
|
+
|
|
96
|
+
def _format(self, schema) -> str | None:
|
|
97
|
+
""".format or ._format"""
|
|
98
|
+
fmt = getattr(schema, "format", None)
|
|
99
|
+
if fmt is None:
|
|
100
|
+
fmt = getattr(schema, "_format", None)
|
|
101
|
+
return fmt
|
|
102
|
+
|
|
103
|
+
def _ref_string(self, schema) -> str | None:
|
|
104
|
+
ref = getattr(schema, "ref", None)
|
|
105
|
+
if isinstance(ref, str):
|
|
106
|
+
return ref
|
|
107
|
+
root = getattr(schema, "root", None)
|
|
108
|
+
if isinstance(root, dict) and "$ref" in root:
|
|
109
|
+
return root.get("$ref")
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def _get_component(self, name: str) -> Any | None:
|
|
113
|
+
comps = self._components or {}
|
|
114
|
+
# components may be an object with .schemas as dict-like
|
|
115
|
+
if hasattr(comps, "get"):
|
|
116
|
+
return comps.get(name)
|
|
117
|
+
schemas = getattr(comps, "schemas", None)
|
|
118
|
+
if isinstance(schemas, dict):
|
|
119
|
+
return schemas.get(name)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def _mapping_alias_str(self, additional_schema: Any, name_hint: str) -> str:
|
|
123
|
+
"""Build a dict alias type string for additionalProperties.
|
|
124
|
+
|
|
125
|
+
Returns a string like 'dict[str, Any]' or 'dict[str, Schema]'
|
|
126
|
+
"""
|
|
127
|
+
if additional_schema is True or additional_schema is None:
|
|
128
|
+
return "dict[str, Any]"
|
|
129
|
+
if additional_schema is False:
|
|
130
|
+
# No mapping allowed; caller decides how to handle
|
|
131
|
+
return ""
|
|
132
|
+
inner = self.schema_to_type(additional_schema, f"{name_hint}Value")
|
|
133
|
+
return f"dict[str, {inner}]"
|
|
134
|
+
|
|
135
|
+
def _base_primitive_type(self, schema_type: str | None, fmt: str | None) -> str:
|
|
136
|
+
"""Mapping of OpenAPI primitive+format to Python base type."""
|
|
137
|
+
if schema_type == "string":
|
|
138
|
+
match fmt:
|
|
139
|
+
case "date-time":
|
|
140
|
+
return "datetime"
|
|
141
|
+
case "date":
|
|
142
|
+
return "date"
|
|
143
|
+
case "uuid":
|
|
144
|
+
return "UUID"
|
|
145
|
+
case _:
|
|
146
|
+
return "str"
|
|
147
|
+
if schema_type == "integer":
|
|
148
|
+
return "int"
|
|
149
|
+
if schema_type == "number":
|
|
150
|
+
return "float"
|
|
151
|
+
if schema_type == "boolean":
|
|
152
|
+
return "bool"
|
|
153
|
+
if schema_type in {"array", "object"}:
|
|
154
|
+
# handled elsewhere
|
|
155
|
+
return "Any"
|
|
156
|
+
return "Any"
|
|
157
|
+
|
|
158
|
+
def schema_to_type(self, schema: Any, name_hint: str = "Model") -> str:
|
|
159
|
+
"""Convert an OpenAPI schema to a Python type hint, generating Pydantic models for objects."""
|
|
160
|
+
if not schema:
|
|
161
|
+
return "Any"
|
|
162
|
+
|
|
163
|
+
# Cache repeated schemas (by identity) to avoid duplicate class generation
|
|
164
|
+
cache_key = id(schema)
|
|
165
|
+
if cache_key in self._schema_cache:
|
|
166
|
+
return self._schema_cache[cache_key]
|
|
167
|
+
|
|
168
|
+
# Try to resolve common cases early
|
|
169
|
+
schema_type = getattr(schema, "type", None)
|
|
170
|
+
|
|
171
|
+
# Handle nullable across styles (nullable flag OR type includes null OR anyOf/oneOf with null)
|
|
172
|
+
nullable_flag = bool(getattr(schema, "nullable", False))
|
|
173
|
+
if isinstance(schema_type, list):
|
|
174
|
+
if "null" in schema_type:
|
|
175
|
+
# Remove null and keep underlying type for processing
|
|
176
|
+
schema_type = next((t for t in schema_type if t != "null"), None)
|
|
177
|
+
nullable_flag = True
|
|
178
|
+
|
|
179
|
+
def _wrap_nullable(type_str: str) -> str:
|
|
180
|
+
if nullable_flag and type_str != "None" and not type_str.endswith(" | None"):
|
|
181
|
+
return f"{type_str} | None"
|
|
182
|
+
return type_str
|
|
183
|
+
|
|
184
|
+
# Resolve $ref components
|
|
185
|
+
ref = self._ref_string(schema)
|
|
186
|
+
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
|
|
187
|
+
comp_name = ref.split("/")[-1]
|
|
188
|
+
# If we already mapped this component, reuse
|
|
189
|
+
if comp_name in self._component_class_map:
|
|
190
|
+
result = self._component_class_map[comp_name]
|
|
191
|
+
self._schema_cache[cache_key] = result
|
|
192
|
+
return result
|
|
193
|
+
comp_schema = self._get_component(comp_name)
|
|
194
|
+
# If the component schema is available, build a proper type
|
|
195
|
+
if comp_schema is not None:
|
|
196
|
+
# Prefer using component name as class name
|
|
197
|
+
class_name = sanitize_class_name(comp_name)
|
|
198
|
+
enum_vals = self._enum_values(comp_schema)
|
|
199
|
+
comp_type = getattr(comp_schema, "type", None)
|
|
200
|
+
if enum_vals and comp_type in {"string", "integer", "number"}:
|
|
201
|
+
lines = [f"class {class_name}(Enum):"]
|
|
202
|
+
# Normalize enum member names
|
|
203
|
+
for idx, val in enumerate(enum_vals):
|
|
204
|
+
if isinstance(val, str):
|
|
205
|
+
member = re.sub(r"\W+", "_", val).upper()
|
|
206
|
+
if not member or member[0].isdigit():
|
|
207
|
+
member = f"VALUE_{idx}"
|
|
208
|
+
lines.append(f" {member} = '{val}'")
|
|
209
|
+
else:
|
|
210
|
+
lines.append(f" VALUE_{idx} = {val}")
|
|
211
|
+
self._singleton_model(class_name, lines)
|
|
212
|
+
self._component_class_map[comp_name] = class_name
|
|
213
|
+
self._schema_cache[cache_key] = class_name
|
|
214
|
+
return class_name
|
|
215
|
+
|
|
216
|
+
# For arrays and primitives, emit type aliases instead of models
|
|
217
|
+
props = self._properties_dict(comp_schema)
|
|
218
|
+
if comp_type == "array":
|
|
219
|
+
inner = self.schema_to_type(getattr(comp_schema, "items", None), f"{class_name}Item")
|
|
220
|
+
alias_rhs = f"list[{inner}]"
|
|
221
|
+
alias_rhs = alias_rhs if not getattr(comp_schema, "nullable", False) else f"{alias_rhs} | None"
|
|
222
|
+
self._singleton_alias(class_name, alias_rhs)
|
|
223
|
+
self._component_class_map[comp_name] = class_name
|
|
224
|
+
self._schema_cache[cache_key] = class_name
|
|
225
|
+
return class_name
|
|
226
|
+
if comp_type == "object" and not props:
|
|
227
|
+
# If object with no explicit properties, use a mapping alias for flexibility
|
|
228
|
+
ap = self._additional_properties(comp_schema)
|
|
229
|
+
alias_rhs = self._mapping_alias_str(ap, class_name) if ap is not False else class_name
|
|
230
|
+
if alias_rhs and alias_rhs != class_name:
|
|
231
|
+
if getattr(comp_schema, "nullable", False):
|
|
232
|
+
alias_rhs = f"{alias_rhs} | None"
|
|
233
|
+
self._singleton_alias(class_name, alias_rhs)
|
|
234
|
+
self._component_class_map[comp_name] = class_name
|
|
235
|
+
self._schema_cache[cache_key] = class_name
|
|
236
|
+
return class_name
|
|
237
|
+
if comp_type in {"integer", "number", "string", "boolean"} and not props:
|
|
238
|
+
# Respect known string formats via centralized mapper
|
|
239
|
+
fmt = self._format(comp_schema)
|
|
240
|
+
base = self._base_primitive_type(comp_type, fmt)
|
|
241
|
+
alias_rhs = base if not getattr(comp_schema, "nullable", False) else f"{base} | None"
|
|
242
|
+
self._singleton_alias(class_name, alias_rhs)
|
|
243
|
+
self._component_class_map[comp_name] = class_name
|
|
244
|
+
self._schema_cache[cache_key] = class_name
|
|
245
|
+
return class_name
|
|
246
|
+
|
|
247
|
+
# Otherwise, defer to normal handling as an object and force class name
|
|
248
|
+
t = self._object_schema_to_model(comp_schema, class_name)
|
|
249
|
+
self._component_class_map[comp_name] = t
|
|
250
|
+
self._schema_cache[cache_key] = t
|
|
251
|
+
return t
|
|
252
|
+
|
|
253
|
+
# Handle composed schemas
|
|
254
|
+
one_of = getattr(schema, "oneOf", None)
|
|
255
|
+
any_of = getattr(schema, "anyOf", None)
|
|
256
|
+
all_of = getattr(schema, "allOf", None)
|
|
257
|
+
if one_of or any_of:
|
|
258
|
+
raw_variants = one_of or any_of
|
|
259
|
+
variants = raw_variants if isinstance(raw_variants, (list, tuple)) else []
|
|
260
|
+
if not variants:
|
|
261
|
+
return "Any"
|
|
262
|
+
types: list[str] = []
|
|
263
|
+
local_nullable = nullable_flag
|
|
264
|
+
for idx, subschema in enumerate(variants):
|
|
265
|
+
sub_type = getattr(subschema, "type", None)
|
|
266
|
+
if sub_type == "null" or (isinstance(sub_type, list) and "null" in sub_type):
|
|
267
|
+
local_nullable = True
|
|
268
|
+
continue
|
|
269
|
+
t = self.schema_to_type(subschema, f"{name_hint}Alt{idx}")
|
|
270
|
+
if t not in types:
|
|
271
|
+
types.append(t)
|
|
272
|
+
if not types:
|
|
273
|
+
return "Any"
|
|
274
|
+
union = " | ".join(types)
|
|
275
|
+
if local_nullable and "None" not in union:
|
|
276
|
+
union = f"{union} | None"
|
|
277
|
+
# Add discriminator hint comment when available
|
|
278
|
+
disc = getattr(schema, "discriminator", None)
|
|
279
|
+
disc_name = None
|
|
280
|
+
if disc is not None:
|
|
281
|
+
disc_name = getattr(disc, "propertyName", None) or getattr(disc, "property_name", None)
|
|
282
|
+
if disc_name is None:
|
|
283
|
+
disc_root = getattr(disc, "root", None)
|
|
284
|
+
if isinstance(disc_root, dict):
|
|
285
|
+
disc_name = disc_root.get("propertyName")
|
|
286
|
+
if disc_name:
|
|
287
|
+
which = "oneOf" if one_of else "anyOf"
|
|
288
|
+
union = f"{union} # {which} discriminator: {disc_name}"
|
|
289
|
+
self._schema_cache[cache_key] = union
|
|
290
|
+
return union
|
|
291
|
+
if all_of:
|
|
292
|
+
# Attempt to merge object schemas in allOf
|
|
293
|
+
class_name = self._unique_class_name(f"{name_hint}")
|
|
294
|
+
props: dict = {}
|
|
295
|
+
required: set[str] = set()
|
|
296
|
+
additional_schema = None
|
|
297
|
+
for subschema in all_of:
|
|
298
|
+
sub_props = self._properties_dict(subschema)
|
|
299
|
+
if sub_props:
|
|
300
|
+
for k, v in sub_props.items():
|
|
301
|
+
props[k] = v
|
|
302
|
+
sub_required = self._required_list(subschema)
|
|
303
|
+
required.update(sub_required)
|
|
304
|
+
ap = self._additional_properties(subschema)
|
|
305
|
+
if ap is not None:
|
|
306
|
+
additional_schema = ap
|
|
307
|
+
if not props and additional_schema is None:
|
|
308
|
+
# Fall back
|
|
309
|
+
return "Any"
|
|
310
|
+
# Build a synthetic schema-like object with combined properties
|
|
311
|
+
|
|
312
|
+
class Dummy:
|
|
313
|
+
pass
|
|
314
|
+
dummy = Dummy()
|
|
315
|
+
setattr(dummy, "properties", props)
|
|
316
|
+
setattr(dummy, "required", list(required))
|
|
317
|
+
if additional_schema is not None:
|
|
318
|
+
setattr(dummy, "additionalProperties", additional_schema)
|
|
319
|
+
result = self._object_schema_to_model(dummy, class_name)
|
|
320
|
+
result = _wrap_nullable(result)
|
|
321
|
+
self._schema_cache[cache_key] = result
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
if schema_type == "array":
|
|
325
|
+
items_schema = getattr(schema, "items", None)
|
|
326
|
+
inner = self.schema_to_type(items_schema, f"{name_hint}Item")
|
|
327
|
+
return _wrap_nullable(f"list[{inner}]")
|
|
328
|
+
|
|
329
|
+
if schema_type == "object" or self._properties_dict(schema):
|
|
330
|
+
props = self._properties_dict(schema)
|
|
331
|
+
additional = self._additional_properties(schema)
|
|
332
|
+
# If no explicit properties but has additionalProperties, treat as mapping
|
|
333
|
+
if not props and additional is not None:
|
|
334
|
+
alias_rhs = self._mapping_alias_str(additional, name_hint)
|
|
335
|
+
if alias_rhs:
|
|
336
|
+
return _wrap_nullable(alias_rhs)
|
|
337
|
+
|
|
338
|
+
class_name = self._unique_class_name(f"{name_hint}")
|
|
339
|
+
result = self._object_schema_to_model(schema, class_name)
|
|
340
|
+
result = _wrap_nullable(result)
|
|
341
|
+
self._schema_cache[cache_key] = result
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
if isinstance(schema_type, str):
|
|
345
|
+
enum_vals = self._enum_values(schema)
|
|
346
|
+
if enum_vals:
|
|
347
|
+
lit_vals: list[str] = []
|
|
348
|
+
for v in enum_vals:
|
|
349
|
+
if isinstance(v, str):
|
|
350
|
+
lit_vals.append(repr(v))
|
|
351
|
+
else:
|
|
352
|
+
lit_vals.append(str(v))
|
|
353
|
+
return _wrap_nullable(f"Literal[{', '.join(lit_vals)}]")
|
|
354
|
+
fmt = self._format(schema)
|
|
355
|
+
base = self._base_primitive_type(schema_type, fmt)
|
|
356
|
+
|
|
357
|
+
# Add constrained annotations for common string constraints
|
|
358
|
+
if schema_type == "string":
|
|
359
|
+
min_len = getattr(schema, "minLength", None) or getattr(schema, "_minLength", None)
|
|
360
|
+
max_len = getattr(schema, "maxLength", None) or getattr(schema, "_maxLength", None)
|
|
361
|
+
pattern = getattr(schema, "pattern", None) or getattr(schema, "_pattern", None)
|
|
362
|
+
if pattern is None:
|
|
363
|
+
root = getattr(schema, "root", None)
|
|
364
|
+
if isinstance(root, dict):
|
|
365
|
+
min_len = min_len or root.get("minLength")
|
|
366
|
+
max_len = max_len or root.get("maxLength")
|
|
367
|
+
pattern = root.get("pattern") or pattern
|
|
368
|
+
field_args: list[str] = []
|
|
369
|
+
if isinstance(min_len, int):
|
|
370
|
+
field_args.append(f"min_length={min_len}")
|
|
371
|
+
if isinstance(max_len, int):
|
|
372
|
+
field_args.append(f"max_length={max_len}")
|
|
373
|
+
if isinstance(pattern, str) and pattern:
|
|
374
|
+
field_args.append(f"pattern={repr(pattern)}")
|
|
375
|
+
if field_args:
|
|
376
|
+
annotated = f"Annotated[{base}, Field({', '.join(field_args)})]"
|
|
377
|
+
return _wrap_nullable(annotated)
|
|
378
|
+
return _wrap_nullable(base)
|
|
379
|
+
|
|
380
|
+
return _wrap_nullable("Any") # Fallback
|
|
381
|
+
|
|
382
|
+
def write_models(self, f) -> None:
|
|
383
|
+
"""Write all collected models to file-like f in declaration order."""
|
|
384
|
+
if not self._models:
|
|
385
|
+
return
|
|
386
|
+
for name, lines in self._models.items():
|
|
387
|
+
for line in lines:
|
|
388
|
+
f.write(line + "\n")
|
|
389
|
+
f.write("\n\n")
|
|
390
|
+
|
|
391
|
+
def _object_schema_to_model(self, schema: Any, class_name: str) -> str:
|
|
392
|
+
# If class already emitted, reuse
|
|
393
|
+
if class_name in self._models:
|
|
394
|
+
return class_name
|
|
395
|
+
|
|
396
|
+
lines: list[str] = [f"class {class_name}(BaseModel):"]
|
|
397
|
+
|
|
398
|
+
props = self._properties_dict(schema)
|
|
399
|
+
if not props:
|
|
400
|
+
lines.append(" pass")
|
|
401
|
+
else:
|
|
402
|
+
required = set(self._required_list(schema))
|
|
403
|
+
for prop_name, prop_schema in props.items():
|
|
404
|
+
original = str(prop_name)
|
|
405
|
+
py_name = re.sub(r"[^0-9a-zA-Z_]", "_", original)
|
|
406
|
+
if not py_name:
|
|
407
|
+
py_name = "_field"
|
|
408
|
+
# Ensure starts with letter or underscore
|
|
409
|
+
if not (py_name[0].isalpha() or py_name[0] == "_"):
|
|
410
|
+
py_name = f"_{py_name}"
|
|
411
|
+
# Avoid keywords and invalid identifiers
|
|
412
|
+
if keyword.iskeyword(py_name) or not py_name.isidentifier():
|
|
413
|
+
py_name = f"{py_name}_"
|
|
414
|
+
sub_hint = f"{class_name}_{py_name[0].upper() + py_name[1:]}"
|
|
415
|
+
typ = self.schema_to_type(prop_schema, sub_hint)
|
|
416
|
+
if original not in required:
|
|
417
|
+
typ = f"{typ} | None"
|
|
418
|
+
# Add alias when sanitized name differs
|
|
419
|
+
if py_name != original:
|
|
420
|
+
typ_repr = f"Annotated[{typ}, Field(alias='{original}')]"
|
|
421
|
+
else:
|
|
422
|
+
typ_repr = typ
|
|
423
|
+
lines.append(f" {py_name}: {typ_repr}")
|
|
424
|
+
|
|
425
|
+
self._singleton_model(class_name, lines)
|
|
426
|
+
return class_name
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class Command(BaseCommand):
|
|
430
|
+
help = "Generate ESI Stubs from the current ESI spec with correct type hints."
|
|
431
|
+
|
|
432
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
433
|
+
parser.add_argument(
|
|
434
|
+
"--output",
|
|
435
|
+
default=None,
|
|
436
|
+
help="Custom output path for the generated ESI stub file (default: stubs.pyi next to openapi_clients.py).",
|
|
437
|
+
)
|
|
438
|
+
parser.add_argument(
|
|
439
|
+
"--compatibility_date",
|
|
440
|
+
default=__build_date__,
|
|
441
|
+
help="Compatibility Date to build ESI Stubs from.",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
445
|
+
self.stdout.write("Starting ESI stub generation...")
|
|
446
|
+
|
|
447
|
+
stub_api = ESIClientProvider(
|
|
448
|
+
ua_appname=__title__,
|
|
449
|
+
ua_url=__url__,
|
|
450
|
+
ua_version=__version__,
|
|
451
|
+
compatibility_date=options["compatibility_date"]
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
stub_api = stub_api.client.api
|
|
455
|
+
|
|
456
|
+
base_path = os.path.dirname(stubs.__file__)
|
|
457
|
+
output_path = options["output"] or os.path.join(base_path, "stubs.pyi")
|
|
458
|
+
|
|
459
|
+
self.stdout.write(f"Outputting to: {output_path}")
|
|
460
|
+
|
|
461
|
+
spec_root = stub_api._root
|
|
462
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
463
|
+
# Local helpers to reduce duplication
|
|
464
|
+
def _get_json_body_schema(op_obj: Any) -> Any | None:
|
|
465
|
+
rb = getattr(op_obj, "requestBody", None)
|
|
466
|
+
if not rb:
|
|
467
|
+
return None
|
|
468
|
+
rb_content = getattr(rb, "content", {})
|
|
469
|
+
if isinstance(rb_content, dict):
|
|
470
|
+
json_media = rb_content.get("application/json")
|
|
471
|
+
if json_media is not None:
|
|
472
|
+
return getattr(json_media, "schema_", None)
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
def _clean_doc(op_obj: Any) -> str:
|
|
476
|
+
text = getattr(op_obj, "description", None) or getattr(op_obj, "summary", None) or ""
|
|
477
|
+
return text.replace("\n", " ").strip()
|
|
478
|
+
|
|
479
|
+
def _get_response_json_schema(op_obj: Any, method: str) -> Any | None:
|
|
480
|
+
"""Return the JSON response schema object for an operation and HTTP method.
|
|
481
|
+
|
|
482
|
+
post -> 201, others -> 200, put/delete return None.
|
|
483
|
+
"""
|
|
484
|
+
try:
|
|
485
|
+
if method == "post":
|
|
486
|
+
resp = op_obj.responses.get("201")
|
|
487
|
+
elif method in ("put", "delete"):
|
|
488
|
+
return None
|
|
489
|
+
else:
|
|
490
|
+
resp = op_obj.responses.get("200")
|
|
491
|
+
content = getattr(resp, "content", {}) if resp else {}
|
|
492
|
+
if isinstance(content, dict) and "application/json" in content:
|
|
493
|
+
return content["application/json"].schema_
|
|
494
|
+
except Exception:
|
|
495
|
+
return None
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
# File headers
|
|
499
|
+
f.write("# flake8: noqa=E501\n")
|
|
500
|
+
f.write("# cSpell: disable\n")
|
|
501
|
+
f.write("# Auto Generated do not edit\n")
|
|
502
|
+
# Python Imports
|
|
503
|
+
f.write("from typing import Any, Literal, Annotated\n")
|
|
504
|
+
f.write("from datetime import datetime, date\n")
|
|
505
|
+
f.write("from enum import Enum\n")
|
|
506
|
+
f.write("from uuid import UUID\n")
|
|
507
|
+
f.write("from pydantic import BaseModel, Field\n")
|
|
508
|
+
f.write("from esi.openapi_clients import EsiOperation\n")
|
|
509
|
+
f.write("from esi.models import Token\n\n\n")
|
|
510
|
+
|
|
511
|
+
operation_classes = {}
|
|
512
|
+
# Attempt to get OpenAPI components for $ref resolution
|
|
513
|
+
components = getattr(getattr(stub_api, "_root", None), "components", None)
|
|
514
|
+
schemas = getattr(components, "schemas", None) if components else None
|
|
515
|
+
model_gen = ModelGenerator(components=schemas if isinstance(schemas, dict) else None)
|
|
516
|
+
|
|
517
|
+
# Pre-collect request body models/aliases so they are written out before client methods reference them
|
|
518
|
+
for tag in sorted(stub_api._operationindex._tags.keys()):
|
|
519
|
+
ops = stub_api._operationindex._tags[tag]
|
|
520
|
+
for nm, op in sorted(ops._operations.items()):
|
|
521
|
+
op_obj = op[2]
|
|
522
|
+
schema_obj = _get_json_body_schema(op_obj)
|
|
523
|
+
if schema_obj is not None:
|
|
524
|
+
body_name = f"{sanitize_operation_class(nm)}Body"
|
|
525
|
+
# Ensure a named model/alias is generated for request bodies
|
|
526
|
+
t = model_gen.schema_to_type(schema_obj, body_name)
|
|
527
|
+
# If schema maps to a non-object primitive/array and no class was created with body_name,
|
|
528
|
+
# make a type alias so the name exists in the stubs.
|
|
529
|
+
if t != body_name and body_name not in model_gen._models:
|
|
530
|
+
model_gen._singleton_alias(body_name, t)
|
|
531
|
+
|
|
532
|
+
for tag in sorted(stub_api._operationindex._tags.keys()):
|
|
533
|
+
# ESI Operation
|
|
534
|
+
# The various methods called on an ESI Operation
|
|
535
|
+
# result(), Results(), Results_Localized() etc. all live here
|
|
536
|
+
ops = stub_api._operationindex._tags[tag]
|
|
537
|
+
for nm, op in sorted(ops._operations.items()):
|
|
538
|
+
op_type = op[0]
|
|
539
|
+
op_obj = op[2]
|
|
540
|
+
docstring = _clean_doc(op_obj)
|
|
541
|
+
op_class_name = sanitize_operation_class(nm)
|
|
542
|
+
|
|
543
|
+
response_type = "Any"
|
|
544
|
+
schema = _get_response_json_schema(op_obj, op_type)
|
|
545
|
+
if op_type in ("put", "delete"):
|
|
546
|
+
response_type = "None"
|
|
547
|
+
elif schema is not None:
|
|
548
|
+
response_type = model_gen.schema_to_type(schema, f"{op_class_name}Response")
|
|
549
|
+
|
|
550
|
+
# If the response is an alias to a list type (e.g. Foo = list[Bar]) we want
|
|
551
|
+
# results() to return the flattened list[Bar], not list[list[Bar]]
|
|
552
|
+
if response_type.startswith("list["):
|
|
553
|
+
inner_list_type = response_type
|
|
554
|
+
# response_type already a list[...] so results() should also be list[...] (same type)
|
|
555
|
+
results_type = inner_list_type
|
|
556
|
+
else:
|
|
557
|
+
# Maybe it's an alias to list[...] recorded earlier
|
|
558
|
+
alias_rhs = getattr(model_gen, "_aliases", {}).get(response_type)
|
|
559
|
+
if isinstance(alias_rhs, str) and alias_rhs.startswith("list["):
|
|
560
|
+
results_type = alias_rhs
|
|
561
|
+
else:
|
|
562
|
+
results_type = f"list[{response_type}]"
|
|
563
|
+
|
|
564
|
+
if op_class_name not in operation_classes:
|
|
565
|
+
f.write(f"class {op_class_name}(EsiOperation):\n")
|
|
566
|
+
if response_type != "None":
|
|
567
|
+
f.write(" \"\"\"EsiOperation, use result(), results() or results_localized()\"\"\"\n")
|
|
568
|
+
else:
|
|
569
|
+
f.write(" \"\"\"EsiOperation, use result()\"\"\"\n")
|
|
570
|
+
|
|
571
|
+
# result()
|
|
572
|
+
f.write(f" def result(self, use_etag: bool = True, return_response: bool = False, force_refresh: bool = False, use_cache: bool = True, **extra) -> {response_type}:\n") # noqa: E501
|
|
573
|
+
f.write(f" \"\"\"{docstring}\"\"\"\n") if docstring else None
|
|
574
|
+
f.write(" ...\n\n")
|
|
575
|
+
if response_type != "None":
|
|
576
|
+
# We only need the extra utility functions if its actually an endpoint that returns data
|
|
577
|
+
# results()
|
|
578
|
+
f.write(f" def results(self, use_etag: bool = True, return_response: bool = False, force_refresh: bool = False, use_cache: bool = True, **extra) -> {results_type}:\n") # noqa: E501
|
|
579
|
+
f.write(f" \"\"\"{docstring}\"\"\"\n") if docstring else None
|
|
580
|
+
f.write(" ...\n\n")
|
|
581
|
+
|
|
582
|
+
# results_localized()
|
|
583
|
+
f.write(f" def results_localized(self, languages: list[str] | str | None = None, **extra) -> dict[str, {results_type}]:\n") # noqa: E501
|
|
584
|
+
f.write(f" \"\"\"{docstring}\"\"\"\n") if docstring else None
|
|
585
|
+
f.write(" ...\n\n\n")
|
|
586
|
+
|
|
587
|
+
operation_classes[op_class_name] = True
|
|
588
|
+
|
|
589
|
+
# Emit collected Pydantic models before operations/classes so types are available
|
|
590
|
+
model_gen.write_models(f)
|
|
591
|
+
|
|
592
|
+
f.write("class ESIClientStub:\n")
|
|
593
|
+
tags = sorted(stub_api._operationindex._tags.keys())
|
|
594
|
+
for idx, tag in enumerate(tags):
|
|
595
|
+
# ESI ESIClientStub
|
|
596
|
+
# The various ESI Tags and Operations
|
|
597
|
+
ops = stub_api._operationindex._tags[tag]
|
|
598
|
+
class_name = f"_{sanitize_class_name(tag)}"
|
|
599
|
+
f.write(f" class {class_name}:\n")
|
|
600
|
+
for nm, op in sorted(ops._operations.items()):
|
|
601
|
+
op_obj = op[2]
|
|
602
|
+
op_class_name = sanitize_operation_class(nm)
|
|
603
|
+
effective_security = (
|
|
604
|
+
getattr(op_obj, "security", None) or getattr(spec_root, "security", None)
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def _has_oauth2(sec: Any) -> bool:
|
|
608
|
+
data = sec if isinstance(sec, dict) else getattr(sec, "root", None)
|
|
609
|
+
if not isinstance(data, dict):
|
|
610
|
+
return False
|
|
611
|
+
return any(k.lower() == "oauth2" for k in data)
|
|
612
|
+
|
|
613
|
+
needs_oauth = any(_has_oauth2(s) for s in (effective_security or []))
|
|
614
|
+
|
|
615
|
+
params = ["self"]
|
|
616
|
+
optional_params = []
|
|
617
|
+
schema_obj = _get_json_body_schema(op_obj)
|
|
618
|
+
if schema_obj is not None:
|
|
619
|
+
type_hint = model_gen.schema_to_type(schema_obj, f"{op_class_name}Body")
|
|
620
|
+
params.append(f"body: {type_hint}")
|
|
621
|
+
|
|
622
|
+
for p in getattr(op_obj, "parameters", []):
|
|
623
|
+
required = getattr(p, "required", False)
|
|
624
|
+
schema_obj = getattr(p, "schema_", None)
|
|
625
|
+
if schema_obj is not None:
|
|
626
|
+
# Use generator for richer types (arrays/objects)
|
|
627
|
+
param_type = model_gen.schema_to_type(schema_obj, f"{op_class_name}_{p.name}")
|
|
628
|
+
else:
|
|
629
|
+
param_type = "Any"
|
|
630
|
+
default = ""
|
|
631
|
+
if not required:
|
|
632
|
+
param_type = f"{param_type} | None"
|
|
633
|
+
default = " = ..."
|
|
634
|
+
param_name = p.name.replace("-", "_")
|
|
635
|
+
if param_name == "authorization" and needs_oauth:
|
|
636
|
+
# Skip the Authorization Parameter, we inject this at HTTP Header Level
|
|
637
|
+
continue
|
|
638
|
+
if required:
|
|
639
|
+
params.append(f"{param_name}: {param_type}{default}")
|
|
640
|
+
else:
|
|
641
|
+
optional_params.append(f"{param_name}: {param_type}{default}")
|
|
642
|
+
if needs_oauth:
|
|
643
|
+
# Here, we add our own custom param instead of the earlier Authorization
|
|
644
|
+
# Our library will pick this up and use it later
|
|
645
|
+
params.append("token: Token")
|
|
646
|
+
optional_params.append("**kwargs: Any")
|
|
647
|
+
params_str = ", ".join(params + optional_params)
|
|
648
|
+
docstring = _clean_doc(op_obj)
|
|
649
|
+
|
|
650
|
+
if docstring:
|
|
651
|
+
f.write(f" def {nm}({params_str}) -> {op_class_name}:\n")
|
|
652
|
+
f.write(f" \"\"\"{docstring}\"\"\"\n")
|
|
653
|
+
f.write(" ...\n\n")
|
|
654
|
+
else:
|
|
655
|
+
f.write(f" def {nm}({params_str}) -> {op_class_name}: ...\n")
|
|
656
|
+
|
|
657
|
+
# Only add a single trailing newline after the final tag as this is the end of the file as well then.
|
|
658
|
+
end_newlines = "\n" if idx == len(tags) - 1 else "\n\n"
|
|
659
|
+
f.write(f"\n {sanitize_class_name(tag)}: {class_name} = {class_name}()" + end_newlines)
|
|
660
|
+
|
|
661
|
+
self.stdout.write(self.style.SUCCESS(f"ESI stubs written to {output_path}"))
|