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.
Files changed (100) hide show
  1. django_esi-8.1.0.dist-info/METADATA +93 -0
  2. django_esi-8.1.0.dist-info/RECORD +100 -0
  3. django_esi-8.1.0.dist-info/WHEEL +4 -0
  4. django_esi-8.1.0.dist-info/licenses/LICENSE +674 -0
  5. esi/__init__.py +7 -0
  6. esi/admin.py +42 -0
  7. esi/aiopenapi3/client.py +79 -0
  8. esi/aiopenapi3/plugins.py +224 -0
  9. esi/app_settings.py +112 -0
  10. esi/apps.py +11 -0
  11. esi/checks.py +56 -0
  12. esi/clients.py +657 -0
  13. esi/decorators.py +271 -0
  14. esi/errors.py +22 -0
  15. esi/exceptions.py +51 -0
  16. esi/helpers.py +63 -0
  17. esi/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
  18. esi/locale/cs_CZ/LC_MESSAGES/django.po +53 -0
  19. esi/locale/de/LC_MESSAGES/django.mo +0 -0
  20. esi/locale/de/LC_MESSAGES/django.po +58 -0
  21. esi/locale/en/LC_MESSAGES/django.mo +0 -0
  22. esi/locale/en/LC_MESSAGES/django.po +54 -0
  23. esi/locale/es/LC_MESSAGES/django.mo +0 -0
  24. esi/locale/es/LC_MESSAGES/django.po +59 -0
  25. esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
  26. esi/locale/fr_FR/LC_MESSAGES/django.po +59 -0
  27. esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
  28. esi/locale/it_IT/LC_MESSAGES/django.po +59 -0
  29. esi/locale/ja/LC_MESSAGES/django.mo +0 -0
  30. esi/locale/ja/LC_MESSAGES/django.po +58 -0
  31. esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
  32. esi/locale/ko_KR/LC_MESSAGES/django.po +58 -0
  33. esi/locale/nl_NL/LC_MESSAGES/django.mo +0 -0
  34. esi/locale/nl_NL/LC_MESSAGES/django.po +53 -0
  35. esi/locale/pl_PL/LC_MESSAGES/django.mo +0 -0
  36. esi/locale/pl_PL/LC_MESSAGES/django.po +53 -0
  37. esi/locale/ru/LC_MESSAGES/django.mo +0 -0
  38. esi/locale/ru/LC_MESSAGES/django.po +61 -0
  39. esi/locale/sk/LC_MESSAGES/django.mo +0 -0
  40. esi/locale/sk/LC_MESSAGES/django.po +55 -0
  41. esi/locale/uk/LC_MESSAGES/django.mo +0 -0
  42. esi/locale/uk/LC_MESSAGES/django.po +57 -0
  43. esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  44. esi/locale/zh_Hans/LC_MESSAGES/django.po +58 -0
  45. esi/management/commands/__init__.py +0 -0
  46. esi/management/commands/esi_clear_spec_cache.py +21 -0
  47. esi/management/commands/generate_esi_stubs.py +661 -0
  48. esi/management/commands/migrate_to_ssov2.py +188 -0
  49. esi/managers.py +303 -0
  50. esi/managers.pyi +85 -0
  51. esi/migrations/0001_initial.py +55 -0
  52. esi/migrations/0002_scopes_20161208.py +56 -0
  53. esi/migrations/0003_hide_tokens_from_admin_site.py +23 -0
  54. esi/migrations/0004_remove_unique_access_token.py +18 -0
  55. esi/migrations/0005_remove_token_length_limit.py +23 -0
  56. esi/migrations/0006_remove_url_length_limit.py +18 -0
  57. esi/migrations/0007_fix_mysql_8_migration.py +18 -0
  58. esi/migrations/0008_nullable_refresh_token.py +18 -0
  59. esi/migrations/0009_set_old_tokens_to_sso_v1.py +18 -0
  60. esi/migrations/0010_set_new_tokens_to_sso_v2.py +18 -0
  61. esi/migrations/0011_add_token_indices.py +28 -0
  62. esi/migrations/0012_fix_token_type_choices.py +18 -0
  63. esi/migrations/0013_squashed_0012_fix_token_type_choices.py +57 -0
  64. esi/migrations/__init__.py +0 -0
  65. esi/models.py +349 -0
  66. esi/openapi_clients.py +1225 -0
  67. esi/rate_limiting.py +107 -0
  68. esi/signals.py +21 -0
  69. esi/static/esi/img/EVE_SSO_Login_Buttons_Large_Black.png +0 -0
  70. esi/static/esi/img/EVE_SSO_Login_Buttons_Large_White.png +0 -0
  71. esi/static/esi/img/EVE_SSO_Login_Buttons_Small_Black.png +0 -0
  72. esi/static/esi/img/EVE_SSO_Login_Buttons_Small_White.png +0 -0
  73. esi/stubs.py +2 -0
  74. esi/stubs.pyi +6807 -0
  75. esi/tasks.py +78 -0
  76. esi/templates/esi/select_token.html +116 -0
  77. esi/templatetags/__init__.py +0 -0
  78. esi/templatetags/scope_tags.py +8 -0
  79. esi/tests/__init__.py +134 -0
  80. esi/tests/client_authed_pilot.py +63 -0
  81. esi/tests/client_public_pilot.py +53 -0
  82. esi/tests/factories.py +47 -0
  83. esi/tests/factories_2.py +60 -0
  84. esi/tests/jwt_factory.py +135 -0
  85. esi/tests/test_checks.py +48 -0
  86. esi/tests/test_clients.py +1019 -0
  87. esi/tests/test_decorators.py +578 -0
  88. esi/tests/test_management_command.py +307 -0
  89. esi/tests/test_managers.py +673 -0
  90. esi/tests/test_models.py +403 -0
  91. esi/tests/test_openapi.json +854 -0
  92. esi/tests/test_openapi.py +1017 -0
  93. esi/tests/test_swagger.json +489 -0
  94. esi/tests/test_swagger_full.json +51112 -0
  95. esi/tests/test_tasks.py +116 -0
  96. esi/tests/test_templatetags.py +22 -0
  97. esi/tests/test_views.py +331 -0
  98. esi/tests/threading_pilot.py +69 -0
  99. esi/urls.py +9 -0
  100. 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}"))