django-esi 8.0.0a4__tar.gz → 8.0.0b2__tar.gz

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.

Potentially problematic release.


This version of django-esi might be problematic. Click here for more details.

Files changed (101) hide show
  1. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/PKG-INFO +5 -3
  2. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/__init__.py +1 -1
  3. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/aiopenapi3/plugins.py +99 -3
  4. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/clients.py +56 -7
  5. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/decorators.py +26 -10
  6. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/exceptions.py +7 -3
  7. django_esi-8.0.0b2/esi/helpers.py +63 -0
  8. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/cs_CZ/LC_MESSAGES/django.mo +0 -0
  9. {django_esi-8.0.0a4/esi/locale/pl_PL → django_esi-8.0.0b2/esi/locale/cs_CZ}/LC_MESSAGES/django.po +2 -2
  10. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/de/LC_MESSAGES/django.mo +0 -0
  11. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/de/LC_MESSAGES/django.po +2 -2
  12. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/en/LC_MESSAGES/django.mo +0 -0
  13. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/en/LC_MESSAGES/django.po +2 -2
  14. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/es/LC_MESSAGES/django.mo +0 -0
  15. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/es/LC_MESSAGES/django.po +2 -2
  16. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/fr_FR/LC_MESSAGES/django.mo +0 -0
  17. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/fr_FR/LC_MESSAGES/django.po +2 -2
  18. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/it_IT/LC_MESSAGES/django.mo +0 -0
  19. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/it_IT/LC_MESSAGES/django.po +2 -2
  20. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/ja/LC_MESSAGES/django.mo +0 -0
  21. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/ja/LC_MESSAGES/django.po +2 -2
  22. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/ko_KR/LC_MESSAGES/django.mo +0 -0
  23. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/ko_KR/LC_MESSAGES/django.po +2 -2
  24. {django_esi-8.0.0a4/esi/locale/pl_PL → django_esi-8.0.0b2/esi/locale/nl_NL}/LC_MESSAGES/django.mo +0 -0
  25. {django_esi-8.0.0a4/esi/locale/cs_CZ → django_esi-8.0.0b2/esi/locale/nl_NL}/LC_MESSAGES/django.po +2 -2
  26. {django_esi-8.0.0a4/esi/locale/nl_NL → django_esi-8.0.0b2/esi/locale/pl_PL}/LC_MESSAGES/django.mo +0 -0
  27. {django_esi-8.0.0a4/esi/locale/nl_NL → django_esi-8.0.0b2/esi/locale/pl_PL}/LC_MESSAGES/django.po +2 -2
  28. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/ru/LC_MESSAGES/django.mo +0 -0
  29. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/ru/LC_MESSAGES/django.po +2 -2
  30. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/sk/LC_MESSAGES/django.mo +0 -0
  31. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/sk/LC_MESSAGES/django.po +2 -2
  32. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/uk/LC_MESSAGES/django.mo +0 -0
  33. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/uk/LC_MESSAGES/django.po +2 -2
  34. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  35. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/locale/zh_Hans/LC_MESSAGES/django.po +2 -2
  36. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/managers.pyi +3 -0
  37. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/openapi_clients.py +188 -44
  38. django_esi-8.0.0b2/esi/rate_limiting.py +107 -0
  39. django_esi-8.0.0b2/esi/signals.py +21 -0
  40. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/stubs.pyi +9 -9
  41. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/__init__.py +33 -11
  42. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_clients.py +77 -19
  43. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_decorators.py +61 -1
  44. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_openapi.json +65 -2
  45. django_esi-8.0.0b2/esi/tests/test_openapi.py +755 -0
  46. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/pyproject.toml +4 -2
  47. django_esi-8.0.0a4/esi/helpers.py +0 -25
  48. django_esi-8.0.0a4/esi/rate_limiting.py +0 -78
  49. django_esi-8.0.0a4/esi/tests/test_openapi.py +0 -261
  50. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/LICENSE +0 -0
  51. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/README.md +0 -0
  52. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/admin.py +0 -0
  53. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/app_settings.py +0 -0
  54. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/apps.py +0 -0
  55. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/checks.py +0 -0
  56. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/errors.py +0 -0
  57. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/management/commands/__init__.py +0 -0
  58. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/management/commands/generate_esi_stubs.py +0 -0
  59. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/management/commands/migrate_to_ssov2.py +0 -0
  60. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/managers.py +0 -0
  61. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0001_initial.py +0 -0
  62. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0002_scopes_20161208.py +0 -0
  63. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0003_hide_tokens_from_admin_site.py +0 -0
  64. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0004_remove_unique_access_token.py +0 -0
  65. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0005_remove_token_length_limit.py +0 -0
  66. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0006_remove_url_length_limit.py +0 -0
  67. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0007_fix_mysql_8_migration.py +0 -0
  68. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0008_nullable_refresh_token.py +0 -0
  69. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0009_set_old_tokens_to_sso_v1.py +0 -0
  70. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0010_set_new_tokens_to_sso_v2.py +0 -0
  71. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0011_add_token_indices.py +0 -0
  72. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0012_fix_token_type_choices.py +0 -0
  73. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/0013_squashed_0012_fix_token_type_choices.py +0 -0
  74. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/migrations/__init__.py +0 -0
  75. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/models.py +0 -0
  76. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/static/esi/img/EVE_SSO_Login_Buttons_Large_Black.png +0 -0
  77. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/static/esi/img/EVE_SSO_Login_Buttons_Large_White.png +0 -0
  78. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/static/esi/img/EVE_SSO_Login_Buttons_Small_Black.png +0 -0
  79. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/static/esi/img/EVE_SSO_Login_Buttons_Small_White.png +0 -0
  80. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/stubs.py +0 -0
  81. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tasks.py +0 -0
  82. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/templates/esi/select_token.html +0 -0
  83. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/templatetags/__init__.py +0 -0
  84. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/templatetags/scope_tags.py +0 -0
  85. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/client_authed_pilot.py +0 -0
  86. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/client_public_pilot.py +0 -0
  87. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/factories.py +0 -0
  88. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/factories_2.py +0 -0
  89. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/jwt_factory.py +0 -0
  90. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_checks.py +0 -0
  91. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_management_command.py +0 -0
  92. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_managers.py +0 -0
  93. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_models.py +0 -0
  94. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_swagger.json +0 -0
  95. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_swagger_full.json +0 -0
  96. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_tasks.py +0 -0
  97. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_templatetags.py +0 -0
  98. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/test_views.py +0 -0
  99. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/tests/threading_pilot.py +0 -0
  100. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/urls.py +0 -0
  101. {django_esi-8.0.0a4 → django_esi-8.0.0b2}/esi/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-esi
3
- Version: 8.0.0a4
3
+ Version: 8.0.0b2
4
4
  Summary: Django app for accessing the EVE Swagger Interface (ESI).
5
5
  Keywords: eveonline
6
6
  Author-email: Alliance Auth <adarnof@gmail.com>
@@ -9,7 +9,6 @@ Description-Content-Type: text/markdown
9
9
  Classifier: Environment :: Web Environment
10
10
  Classifier: Framework :: Django
11
11
  Classifier: Framework :: Django :: 4.2
12
- Classifier: Framework :: Django :: 5.1
13
12
  Classifier: Framework :: Django :: 5.2
14
13
  Classifier: Intended Audience :: Developers
15
14
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
@@ -20,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.10
20
19
  Classifier: Programming Language :: Python :: 3.11
21
20
  Classifier: Programming Language :: Python :: 3.12
22
21
  Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
23
  Classifier: Topic :: Internet :: WWW/HTTP
24
24
  Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
25
25
  License-File: LICENSE
@@ -27,8 +27,10 @@ Requires-Dist: aiopenapi3
27
27
  Requires-Dist: bravado>=10.6,<12
28
28
  Requires-Dist: celery>=4.0.2
29
29
  Requires-Dist: django>=4.2,<6
30
- Requires-Dist: httpx[http2, brotli, zstd]
30
+ Requires-Dist: django-redis>=5.2
31
+ Requires-Dist: httpx[brotli, http2, zstd]
31
32
  Requires-Dist: jsonschema<4
33
+ Requires-Dist: pydantic>=2.12.3
32
34
  Requires-Dist: python-jose>=3.3
33
35
  Requires-Dist: requests>=2.26
34
36
  Requires-Dist: requests-oauthlib>=0.8
@@ -1,6 +1,6 @@
1
1
  """Django app for accessing the EVE Swagger Interface (ESI)."""
2
2
 
3
- __version__ = "8.0.0-alpha.4"
3
+ __version__ = "8.0.0-beta.2"
4
4
  __title__ = 'Django-ESI'
5
5
  __url__ = 'https://gitlab.com/allianceauth/django-esi'
6
6
  __build_date__ = "2025-09-18"
@@ -1,10 +1,20 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from collections.abc import Generator
5
+ from django.conf import settings
6
+
1
7
  from aiopenapi3.plugin import Document, Init
2
8
 
9
+ logger = logging.getLogger(__name__)
10
+
11
+
3
12
  class Trim204ContentType(Document):
4
13
  """
5
14
  Removes and content-type from responses on a 204 reponses
6
15
  A 204 never has content...
7
16
  """
17
+
8
18
  def parsed(self, ctx: Document.Context) -> Document.Context:
9
19
  spec = ctx.document
10
20
  # Patch all paths
@@ -18,12 +28,95 @@ class Trim204ContentType(Document):
18
28
  return ctx
19
29
 
20
30
 
31
+ def find_refs_recursively(data: Any, parent: Any = None) -> Generator[Any, None, None]:
32
+ """
33
+ Recursively searches for all instances of "#ref" in a dict+children and returns schemas.
34
+ """
35
+ if isinstance(data, dict):
36
+ for key, value in data.items():
37
+ if key == "$ref":
38
+ if "#/components/schemas/" in value:
39
+ next = value.split("/")[-1]
40
+ yield next
41
+ if parent:
42
+ yield from find_refs_recursively(parent[next], parent)
43
+ yield from find_refs_recursively(value, parent)
44
+ elif isinstance(data, list):
45
+ for item in data:
46
+ yield from find_refs_recursively(item, parent)
47
+
48
+
49
+ class MinifySpec(Document):
50
+ """
51
+ Removes operations and schemas from spec to limit memory spam
52
+ """
53
+
54
+ def __init__(self, tags: list[str], operations: list[str]):
55
+ super().__init__()
56
+ self.keep_tags = set(tags)
57
+ self.keep_ops = operations
58
+
59
+ def parsed(self, ctx: Document.Context) -> Document.Context:
60
+
61
+ if len(self.keep_tags) == 0 and self.keep_ops == []:
62
+ logger.error("No tag/path filtering supplied to ESI Client. Using all tags. This throw an error with `DEBUG=False`!", stack_info=True)
63
+ if not getattr(settings, "DEBUG", False):
64
+ # we are in production mode throw error to protect RAM.
65
+ raise AttributeError("No tag/path filtering supplied to ESI Client.")
66
+ # We are in debug mode allow an unfiltered client.
67
+ return ctx
68
+
69
+ # filter the client.
70
+ spec = ctx.document
71
+
72
+ remove_paths = set()
73
+ keep_schema = set()
74
+ logger.debug("Filtering Paths/Tags: ")
75
+ for name, path_item in spec.get("paths", {}).items():
76
+ keep = False
77
+ for method_name in ("get", "post", "put", "delete", "patch", "options", "head"):
78
+ method = path_item.get(method_name)
79
+ if not method:
80
+ continue
81
+ if len(self.keep_tags.intersection(method['tags'])) or method["operationId"] in self.keep_ops:
82
+ keep = True
83
+ schemas = find_refs_recursively(
84
+ path_item,
85
+ spec["components"]["schemas"] # find all sub schema's
86
+ )
87
+ for s in schemas:
88
+ keep_schema.add(s)
89
+
90
+ if not keep:
91
+ remove_paths.add(name)
92
+ else:
93
+ logger.debug(f" - {name}")
94
+
95
+ # remove the paths we don't care for
96
+ for name in remove_paths:
97
+ spec["paths"].pop(name)
98
+
99
+ # build new schema from what we need
100
+ logger.debug("Rebuilding Schema: ")
101
+ new_schema = {}
102
+ for name, data in spec["components"]["schemas"].items():
103
+ if name in keep_schema:
104
+ logger.debug(f" - {name}")
105
+ new_schema[name] = data
106
+
107
+ # replace schemas with the new schema
108
+ ctx.document["components"]["schemas"] = new_schema
109
+
110
+ return ctx
111
+
112
+
21
113
  class Add304ContentType(Document):
22
114
  """
23
115
  Adds 304 content-type to responses
24
116
  A 304 never has content. ESI defualt has application/json
25
117
  This is a hack for now
26
118
  """
119
+
27
120
  def parsed(self, ctx: Document.Context) -> Document.Context:
28
121
  spec = ctx.document
29
122
  # Patch all paths
@@ -33,7 +126,7 @@ class Add304ContentType(Document):
33
126
  if not method:
34
127
  continue
35
128
  if "304" not in method['responses']:
36
- method['responses']["304"]={
129
+ method['responses']["304"] = {
37
130
  "description": "Not Modified"
38
131
  }
39
132
  return ctx
@@ -43,6 +136,7 @@ class RemoveSecurityParameter(Document):
43
136
  """
44
137
  Removes the whole OAuth2 securityScheme
45
138
  """
139
+
46
140
  def parsed(self, ctx: Document.Context) -> Document.Context:
47
141
  print("RemoveSecurityParameterPlugin: Removing OAuth2 securityScheme")
48
142
  spec = ctx.document
@@ -63,6 +157,7 @@ class TrimSecurityParameter(Document):
63
157
  Trims out of Spec OAuth2 attributes. CCP have fixed this.
64
158
  Leaving in place in case we need a quick reference again.
65
159
  """
160
+
66
161
  def parsed(self, ctx: Document.Context) -> Document.Context:
67
162
  print("TrimSecurityParameter: Trimming out of spec attributes")
68
163
  spec = ctx.document
@@ -80,8 +175,9 @@ class PatchCompatibilityDatePlugin(Document):
80
175
  This is because WE specifically add it in the library to the HTTP requests,
81
176
  but without this, it will be a required parameter during request generation before it hits the HTTP library.
82
177
  """
178
+
83
179
  def parsed(self, ctx: Document.Context) -> Document.Context:
84
- print("PatchCompatibilityDatePlugin: making compatibility date optional")
180
+ logger.debug("PatchCompatibilityDatePlugin: making compatibility date optional")
85
181
  spec = ctx.document
86
182
 
87
183
  def patch_param(param):
@@ -124,5 +220,5 @@ class DjangoESIInit(Init):
124
220
 
125
221
  def initialized(self, ctx: Init.Context) -> Init.Context:
126
222
  # Force the app_name into the api client class for etags
127
- self.api.app_name=self.app_name
223
+ self.api.app_name = self.app_name
128
224
  return ctx # noqa
@@ -1,7 +1,8 @@
1
1
  import json
2
2
  import logging
3
+ from timeit import default_timer
3
4
  import warnings
4
- from datetime import datetime
5
+ import datetime as dt
5
6
  from hashlib import md5
6
7
  from time import sleep
7
8
  from typing import Any
@@ -10,7 +11,7 @@ from urllib import parse as urlparse
10
11
  from bravado import requests_client
11
12
  from bravado.client import SwaggerClient
12
13
  from bravado.exception import (
13
- HTTPBadGateway, HTTPGatewayTimeout, HTTPServiceUnavailable,
14
+ HTTPBadGateway, HTTPGatewayTimeout, HTTPServiceUnavailable, HTTPError,
14
15
  )
15
16
  from bravado.http_future import HttpFuture
16
17
  from bravado.swagger_model import Loader
@@ -22,6 +23,7 @@ from django.core.cache import cache
22
23
 
23
24
  from . import __title__, __url__, __version__, app_settings
24
25
  from .errors import TokenExpiredError
26
+ from .signals import esi_request_statistics
25
27
 
26
28
  logger = logging.getLogger(__name__)
27
29
 
@@ -68,8 +70,10 @@ class CachingHttpFuture(HttpFuture):
68
70
  seconds until "Expires" time
69
71
  """
70
72
  try:
71
- expires_dt = datetime.strptime(str(expires), '%a, %d %b %Y %H:%M:%S %Z')
72
- delta = expires_dt - datetime.utcnow()
73
+ expires_dt = dt.datetime.strptime(str(expires), '%a, %d %b %Y %H:%M:%S %Z')
74
+ if expires_dt.tzinfo is None:
75
+ expires_dt = expires_dt.replace(tzinfo=dt.timezone.utc)
76
+ delta = expires_dt - dt.datetime.now(dt.timezone.utc)
73
77
  return delta.total_seconds()
74
78
  except ValueError:
75
79
  return 0
@@ -142,6 +146,19 @@ class CachingHttpFuture(HttpFuture):
142
146
  for language in my_languages
143
147
  }
144
148
 
149
+ def _send_signal(self, status_code: int, headers: dict = {}, latency: float = 0) -> None:
150
+ """
151
+ Dispatch the esi request statistics signal
152
+ """
153
+ esi_request_statistics.send(
154
+ sender=self.__class__,
155
+ operation=self.operation.path_name,
156
+ status_code=status_code,
157
+ headers=headers,
158
+ latency=latency,
159
+ bucket=""
160
+ )
161
+
145
162
  def result(self, **kwargs) -> Any | tuple[Any, IncomingResponse]:
146
163
  """Executes the request and returns the response from ESI. Response will
147
164
  include the requested / first page only if there are more pages available.
@@ -189,6 +206,9 @@ class CachingHttpFuture(HttpFuture):
189
206
  )
190
207
 
191
208
  if cached:
209
+ self._send_signal(
210
+ status_code=0
211
+ )
192
212
  result, response = cached
193
213
  expiry = self._time_to_expiry(str(response.headers.get('Expires')))
194
214
  if expiry < 0:
@@ -243,6 +263,7 @@ class CachingHttpFuture(HttpFuture):
243
263
 
244
264
  retries = 0
245
265
  while retries <= max_retries:
266
+ _t = default_timer()
246
267
  try:
247
268
  if app_settings.ESI_INFO_LOGGING_ENABLED:
248
269
  params = self.future.request.params
@@ -267,6 +288,11 @@ class CachingHttpFuture(HttpFuture):
267
288
  logger.debug('ESI response content: %s', response.text)
268
289
  break
269
290
  except (HTTPBadGateway, HTTPGatewayTimeout, HTTPServiceUnavailable) as ex:
291
+ self._send_signal(
292
+ status_code=ex.status_code,
293
+ headers=ex.response.headers,
294
+ latency=default_timer() - _t
295
+ )
270
296
  if retries < max_retries:
271
297
  retries += 1
272
298
  logger.warning(
@@ -283,7 +309,23 @@ class CachingHttpFuture(HttpFuture):
283
309
  sleep(wait_secs)
284
310
  else:
285
311
  raise ex
312
+ except HTTPError as ex:
313
+ """
314
+ Throw any other error into the signal
315
+ then just re-raise
316
+ """
317
+ self._send_signal(
318
+ status_code=ex.status_code,
319
+ headers=ex.response.headers,
320
+ latency=default_timer() - _t
321
+ )
322
+ raise ex
286
323
 
324
+ self._send_signal(
325
+ status_code=response.status_code,
326
+ headers=response.headers,
327
+ latency=default_timer() - _t
328
+ )
287
329
  # restore original value
288
330
  self.request_config.also_return_response = _also_return_response
289
331
  return result, response
@@ -482,19 +524,26 @@ def esi_client_factory(
482
524
 
483
525
  client = RequestsClientPlus()
484
526
 
527
+ from esi.helpers import pascal_case_string
528
+ sanitized_appname = pascal_case_string(__title__)
529
+
485
530
  if app_info_text:
486
531
  # app_info_text (email@example) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
487
532
  # Deprecated
488
- user_agent = f"{app_info_text} ({app_settings.ESI_USER_CONTACT_EMAIL}) {__title__}/{__version__} (+{__url__})"
533
+ user_agent = f"{app_info_text} ({app_settings.ESI_USER_CONTACT_EMAIL}) {sanitized_appname}/{__version__} (+{__url__})"
489
534
  elif ua_appname is None or ua_version is None:
490
535
  # Django-ESI/1.2.3 () (email@example; +https://gitlab.com/allianceauth/django-esi)
491
536
  # Deprecated
492
- user_agent = f"{__title__}/{__version__} ({app_settings.ESI_USER_CONTACT_EMAIL}; +{__url__})"
537
+ user_agent = f"{sanitized_appname}/{__version__} ({app_settings.ESI_USER_CONTACT_EMAIL}; +{__url__})"
493
538
  else:
494
539
  # AppName/1.2.3 (email@example.com) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi)
495
540
  # or AppName/1.2.3 (email@example.com; +https://gitlab.com/) Django-ESI/1.2.3 (+https://gitlab.com/allianceauth/django-esi) (+https://gitlab.com/allianceauth/django-esi)
496
541
  # Preferred
497
- user_agent = f"{ua_appname}/{ua_version} ({app_settings.ESI_USER_CONTACT_EMAIL}{f'; +{ua_url})' if ua_url else ')'} {__title__}/{__version__} (+{__url__})"
542
+
543
+ # Enforce PascalCase for `ua_appname` and strip whitespace
544
+ sanitized_ua_appname = pascal_case_string(ua_appname)
545
+
546
+ user_agent = f"{sanitized_ua_appname}/{ua_version} ({app_settings.ESI_USER_CONTACT_EMAIL}{f'; +{ua_url})' if ua_url else ')'} {sanitized_appname}/{__version__} (+{__url__})"
498
547
 
499
548
  client.user_agent = user_agent
500
549
 
@@ -1,7 +1,8 @@
1
- import functools
2
1
  import logging
3
2
  import time
4
3
  from functools import wraps
4
+ from typing import Any
5
+ from collections.abc import Callable
5
6
 
6
7
  from django.core.cache import cache
7
8
 
@@ -227,12 +228,20 @@ def single_use_token(scopes='', new=False):
227
228
  return decorator
228
229
 
229
230
 
230
- def wait_for_esi_errorlimit_reset(cache_key="esi_error_limit_reset", poll_interval=1):
231
+ def wait_for_esi_errorlimit_reset(cache_key="esi_error_limit_reset", poll_interval=1) -> Callable[..., Callable[..., Any]]:
232
+ """
233
+ Decorator to apply a polling sleep while the ESI Server/Client is in an Error Limit state
234
+ The preferred non-blocking method is to retry your tasks after the limit reset time has passed
235
+
236
+ Args:
237
+ cache_key (str, optional): NOT USUALLY CHANGED. Defaults to "esi_error_limit_reset".
238
+ poll_interval (int, optional): Interval in seconds to poll redis. Defaults to 1.
239
+ """
231
240
  def decorator(func):
232
241
  def wrapper(*args, **kwargs):
233
242
  reset = cache.get(cache_key)
234
243
  if reset is not None:
235
- print(f"ESI Error Limited, waiting {reset}s before retrying...")
244
+ logger.error(f"ESI Error Limited, waiting {reset}s before retrying...")
236
245
  while cache.get(cache_key):
237
246
  time.sleep(poll_interval)
238
247
  return func(*args, **kwargs)
@@ -240,16 +249,23 @@ def wait_for_esi_errorlimit_reset(cache_key="esi_error_limit_reset", poll_interv
240
249
  return decorator
241
250
 
242
251
 
243
- def esi_rate_limiter_bucketed(
244
- bucket: ESIRateLimitBucket,
245
- raise_on_limit: bool = True,
246
- ):
247
- # TODO Investigate esi cache hits.
252
+ def esi_rate_limiter_bucketed(bucket: ESIRateLimitBucket, raise_on_limit: bool = True):
253
+ """
254
+ Decorator for custom manual rate limits on some endpoints to apply a polling sleep while the bucket is exhausted.
255
+ MARKET_DATA_HISTORY
256
+ CHARACTER_CORPORATION_HISTORY
257
+ The preferred non-blocking method is to retry your tasks after the limit reset time has passed
258
+
248
259
 
260
+ Args:
261
+ bucket (ESIRateLimitBucket): The Bucket to rate limit against
262
+ raise_on_limit (bool, optional): Whether to raise an Exception when the limit is reached. Defaults to True.
263
+ """
264
+ # TODO Investigate esi cache hits.
249
265
  def decorator(func):
250
- @functools.wraps(func)
266
+ @wraps(func)
251
267
  def wrapper(*args, **kwargs):
252
- ESIRateLimits.check_bucket(bucket, raise_on_limit)
268
+ ESIRateLimits.check_decr_bucket(bucket, raise_on_limit)
253
269
  return func(*args, **kwargs)
254
270
  return wrapper
255
271
  return decorator
@@ -4,10 +4,12 @@ from aiopenapi3.errors import HTTPServerError as base_HTTPServerError
4
4
  from aiopenapi3.errors import HTTPClientError as base_HTTPClientError
5
5
  from aiopenapi3.errors import HTTPError
6
6
 
7
+
7
8
  class ESIErrorLimitException(Exception):
8
9
  """ESI Global Error Limit Exceeded
9
10
  https://developers.eveonline.com/docs/services/esi/best-practices/#error-limit
10
11
  """
12
+
11
13
  def __init__(self, reset=None, *args, **kwargs) -> None:
12
14
  self.reset = reset
13
15
  msg = kwargs.get("message") or (
@@ -18,8 +20,10 @@ class ESIErrorLimitException(Exception):
18
20
 
19
21
  class ESIBucketLimitException(Exception):
20
22
  """Endpoint (Bucket) Specific Rate Limit Exceeded"""
21
- def __init__(self, bucket, *args, **kwargs) -> None:
23
+
24
+ def __init__(self, bucket, reset: float = 0, *args, **kwargs) -> None:
22
25
  self.bucket = bucket
26
+ self.reset = reset
23
27
  msg = kwargs.get("message") or f"ESI bucket limit reached for {bucket}."
24
28
  super().__init__(msg, *args)
25
29
 
@@ -37,11 +41,11 @@ class HTTPNotModified(HTTPError):
37
41
 
38
42
  @dataclasses.dataclass(repr=False)
39
43
  class HTTPClientError(base_HTTPClientError):
40
- """response code 4xx"""
44
+ """HTTP Response Code 4xx"""
41
45
  pass
42
46
 
43
47
 
44
48
  @dataclasses.dataclass(repr=False)
45
49
  class HTTPServerError(base_HTTPServerError):
46
- """response code 5xx"""
50
+ """HTTP Response Code 5xx"""
47
51
  pass
@@ -0,0 +1,63 @@
1
+ from esi.models import Token
2
+ from string import capwords
3
+
4
+
5
+ def get_token(character_id: int, scopes: list) -> Token:
6
+ """Helper method to get a valid token for a specific character with specific scopes.
7
+
8
+ Args:
9
+ character_id: Character to filter on.
10
+ scopes: array of ESI scope strings to search for.
11
+
12
+ Returns:
13
+ Matching Token
14
+ """
15
+ qs = (
16
+ Token.objects
17
+ .filter(character_id=character_id)
18
+ .require_scopes(scopes)
19
+ .require_valid()
20
+ )
21
+ token = qs.first()
22
+ if token is None:
23
+ raise Token.DoesNotExist(
24
+ f"No valid token found for character_id={character_id} with required scopes."
25
+ )
26
+ return token
27
+
28
+
29
+ def pascal_case_string(string: str) -> str:
30
+ """
31
+ Convert a string to PascalCase by capitalizing the first letter of each word and removing spaces,
32
+ but only if the string contains spaces or hyphens.
33
+
34
+ This function checks if the input string contains spaces or hyphens. If so, it replaces hyphens with spaces,
35
+ capitalizes the first letter of each word, removes the spaces, and returns the resulting PascalCase string.
36
+ If the input string does not contain spaces or hyphens, it is returned unchanged.
37
+
38
+ Behaviour:
39
+ Any string containing spaces or hyphens will be converted to PascalCase.
40
+ Strings without spaces or hyphens will be returned unchanged.
41
+ This gives you the opportunity to use already formatted strings as needed.
42
+
43
+ Examples:
44
+ - "app name" -> "AppName"
45
+ - "app-name" -> "AppName"
46
+ - "appname" -> "appname"
47
+ - "AppName" -> "AppName"
48
+ - "appName" -> "appName"
49
+ - "app_name" -> "app_name"
50
+
51
+ :param string: The input string to be converted to PascalCase.
52
+ :type string: str
53
+ :return: The PascalCase formatted string, or the original string if no spaces or hyphens are present.
54
+ :rtype: str
55
+ """
56
+
57
+ # Check if the string contains spaces or hyphens
58
+ if any(c in string for c in (" ", "-")):
59
+ # Replace hyphens with spaces, capitalize each word, and remove spaces
60
+ return capwords(string.replace("-", " ")).replace(" ", "")
61
+
62
+ # Return the original string if no spaces or hyphens are present
63
+ return string
@@ -6,9 +6,9 @@
6
6
  #, fuzzy
7
7
  msgid ""
8
8
  msgstr ""
9
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
9
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
10
10
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
11
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
11
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -9,9 +9,9 @@
9
9
  #, fuzzy
10
10
  msgid ""
11
11
  msgstr ""
12
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
12
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
13
13
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
14
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
14
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
15
15
  "PO-Revision-Date: 2023-10-25 11:04+0000\n"
16
16
  "Last-Translator: Peter Pfeufer, 2023\n"
17
17
  "Language-Team: German (https://app.transifex.com/alliance-auth/teams/107430/"
@@ -6,9 +6,9 @@
6
6
  #, fuzzy
7
7
  msgid ""
8
8
  msgstr ""
9
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
9
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
10
10
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
11
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
11
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -9,9 +9,9 @@
9
9
  #, fuzzy
10
10
  msgid ""
11
11
  msgstr ""
12
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
12
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
13
13
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
14
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
14
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
15
15
  "PO-Revision-Date: 2023-10-25 11:04+0000\n"
16
16
  "Last-Translator: trenus, 2023\n"
17
17
  "Language-Team: Spanish (https://app.transifex.com/alliance-auth/teams/107430/"
@@ -9,9 +9,9 @@
9
9
  #, fuzzy
10
10
  msgid ""
11
11
  msgstr ""
12
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
12
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
13
13
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
14
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
14
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
15
15
  "PO-Revision-Date: 2020-12-28 06:44+0000\n"
16
16
  "Last-Translator: rockclodbuster, 2023\n"
17
17
  "Language-Team: French (France) (https://app.transifex.com/alliance-auth/"
@@ -9,9 +9,9 @@
9
9
  #, fuzzy
10
10
  msgid ""
11
11
  msgstr ""
12
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
12
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
13
13
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
14
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
14
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
15
15
  "PO-Revision-Date: 2023-10-25 11:04+0000\n"
16
16
  "Last-Translator: Thomas Turini, 2024\n"
17
17
  "Language-Team: Italian (Italy) (https://app.transifex.com/alliance-auth/"
@@ -9,9 +9,9 @@
9
9
  #, fuzzy
10
10
  msgid ""
11
11
  msgstr ""
12
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
12
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
13
13
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
14
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
14
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
15
15
  "PO-Revision-Date: 2023-10-25 11:04+0000\n"
16
16
  "Last-Translator: kotaneko, 2023\n"
17
17
  "Language-Team: Japanese (https://app.transifex.com/alliance-auth/"
@@ -9,9 +9,9 @@
9
9
  #, fuzzy
10
10
  msgid ""
11
11
  msgstr ""
12
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
12
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
13
13
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
14
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
14
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
15
15
  "PO-Revision-Date: 2023-10-25 11:04+0000\n"
16
16
  "Last-Translator: Seowon Jung <seowon@hawaii.edu>, 2023\n"
17
17
  "Language-Team: Korean (Korea) (https://app.transifex.com/alliance-auth/"
@@ -6,9 +6,9 @@
6
6
  #, fuzzy
7
7
  msgid ""
8
8
  msgstr ""
9
- "Project-Id-Version: Django ESI 8.0.0-alpha.4\n"
9
+ "Project-Id-Version: Django ESI 8.0.0-beta.2\n"
10
10
  "Report-Msgid-Bugs-To: https://gitlab.com/allianceauth/django-esi/-/issues\n"
11
- "POT-Creation-Date: 2025-09-21 13:23+1000\n"
11
+ "POT-Creation-Date: 2025-10-31 15:18+1000\n"
12
12
  "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13
13
  "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14
14
  "Language-Team: LANGUAGE <LL@li.org>\n"