python-eveonline 0.3.0__tar.gz → 0.4.0__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.
- {python_eveonline-0.3.0/src/python_eveonline.egg-info → python_eveonline-0.4.0}/PKG-INFO +4 -4
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/README.md +3 -3
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/pyproject.toml +1 -1
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/__init__.py +2 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/client.py +184 -19
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/models.py +17 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0/src/python_eveonline.egg-info}/PKG-INFO +4 -4
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_client_authenticated.py +167 -6
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_etag_caching.py +128 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/LICENSE +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/setup.cfg +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/auth.py +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/const.py +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/exceptions.py +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/py.typed +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/SOURCES.txt +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/dependency_links.txt +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/requires.txt +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/top_level.txt +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_auth.py +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_client_public.py +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_const.py +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_exceptions.py +0 -0
- {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-eveonline
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Async Python client for the Eve Online ESI API
|
|
5
5
|
Author: Ronald van der Meer
|
|
6
6
|
License-Expression: MIT
|
|
@@ -45,10 +45,10 @@ Built for use with [Home Assistant](https://www.home-assistant.io/) but can be u
|
|
|
45
45
|
|
|
46
46
|
- **Fully async** — built on [aiohttp](https://docs.aiohttp.org/)
|
|
47
47
|
- **Typed models** — all API responses are frozen dataclasses with full type annotations
|
|
48
|
-
- **
|
|
48
|
+
- **23 endpoints** — public (server, character, corporation, universe) and authenticated (wallet, skills, location, industry, market, mail, notifications, clones, fatigue, contacts, calendar, loyalty, killmails)
|
|
49
49
|
- **Abstract auth** — implement `AbstractAuth` to plug in any OAuth2 token source
|
|
50
50
|
- **Type-safe** — PEP 561 compatible (`py.typed`), strict mypy configuration
|
|
51
|
-
- **Tested** —
|
|
51
|
+
- **Tested** — ≥98% test coverage
|
|
52
52
|
|
|
53
53
|
## Installation
|
|
54
54
|
|
|
@@ -76,7 +76,7 @@ asyncio.run(main())
|
|
|
76
76
|
|
|
77
77
|
- [**Quickstart**](docs/quickstart.md) — public and authenticated endpoint examples
|
|
78
78
|
- [**Authentication**](docs/authentication.md) — implementing `AbstractAuth`, required OAuth scopes
|
|
79
|
-
- [**Endpoints**](docs/endpoints.md) — full reference with field tables for all
|
|
79
|
+
- [**Endpoints**](docs/endpoints.md) — full reference with field tables for all 23 methods
|
|
80
80
|
- [**Error Handling**](docs/error-handling.md) — exception hierarchy, rate limiting, ESI cache times
|
|
81
81
|
|
|
82
82
|
## License
|
|
@@ -13,10 +13,10 @@ Built for use with [Home Assistant](https://www.home-assistant.io/) but can be u
|
|
|
13
13
|
|
|
14
14
|
- **Fully async** — built on [aiohttp](https://docs.aiohttp.org/)
|
|
15
15
|
- **Typed models** — all API responses are frozen dataclasses with full type annotations
|
|
16
|
-
- **
|
|
16
|
+
- **23 endpoints** — public (server, character, corporation, universe) and authenticated (wallet, skills, location, industry, market, mail, notifications, clones, fatigue, contacts, calendar, loyalty, killmails)
|
|
17
17
|
- **Abstract auth** — implement `AbstractAuth` to plug in any OAuth2 token source
|
|
18
18
|
- **Type-safe** — PEP 561 compatible (`py.typed`), strict mypy configuration
|
|
19
|
-
- **Tested** —
|
|
19
|
+
- **Tested** — ≥98% test coverage
|
|
20
20
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
@@ -44,7 +44,7 @@ asyncio.run(main())
|
|
|
44
44
|
|
|
45
45
|
- [**Quickstart**](docs/quickstart.md) — public and authenticated endpoint examples
|
|
46
46
|
- [**Authentication**](docs/authentication.md) — implementing `AbstractAuth`, required OAuth scopes
|
|
47
|
-
- [**Endpoints**](docs/endpoints.md) — full reference with field tables for all
|
|
47
|
+
- [**Endpoints**](docs/endpoints.md) — full reference with field tables for all 23 methods
|
|
48
48
|
- [**Error Handling**](docs/error-handling.md) — exception hierarchy, rate limiting, ESI cache times
|
|
49
49
|
|
|
50
50
|
## License
|
|
@@ -13,6 +13,7 @@ from .exceptions import (
|
|
|
13
13
|
EveOnlineNotFoundError,
|
|
14
14
|
EveOnlineRateLimitError,
|
|
15
15
|
)
|
|
16
|
+
from .models import CharacterKillmail
|
|
16
17
|
|
|
17
18
|
try:
|
|
18
19
|
__version__ = version("python-eveonline")
|
|
@@ -21,6 +22,7 @@ except PackageNotFoundError:
|
|
|
21
22
|
|
|
22
23
|
__all__ = [
|
|
23
24
|
"AbstractAuth",
|
|
25
|
+
"CharacterKillmail",
|
|
24
26
|
"EveOnlineAuthenticationError",
|
|
25
27
|
"EveOnlineClient",
|
|
26
28
|
"EveOnlineConnectionError",
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import contextlib
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
6
8
|
from typing import Any, overload
|
|
7
9
|
|
|
8
10
|
from aiohttp import ClientSession
|
|
@@ -20,6 +22,7 @@ from .models import (
|
|
|
20
22
|
CalendarEvent,
|
|
21
23
|
CharacterClones,
|
|
22
24
|
CharacterContact,
|
|
25
|
+
CharacterKillmail,
|
|
23
26
|
CharacterLocation,
|
|
24
27
|
CharacterNotification,
|
|
25
28
|
CharacterOnlineStatus,
|
|
@@ -87,8 +90,8 @@ class EveOnlineClient:
|
|
|
87
90
|
"""
|
|
88
91
|
self._auth = auth
|
|
89
92
|
self._host = host
|
|
90
|
-
# ETag cache: maps cache_key -> (etag, cached_response_data)
|
|
91
|
-
self._etag_cache: dict[str, tuple[str, Any]] = {}
|
|
93
|
+
# ETag cache: maps cache_key -> (etag, cached_response_data, x_pages, expires_at)
|
|
94
|
+
self._etag_cache: dict[str, tuple[str, Any, int, datetime | None]] = {}
|
|
92
95
|
|
|
93
96
|
if auth is not None:
|
|
94
97
|
self._session = auth.websession
|
|
@@ -135,18 +138,44 @@ class EveOnlineClient:
|
|
|
135
138
|
return {"If-None-Match": self._etag_cache[cache_key][0]}
|
|
136
139
|
return {}
|
|
137
140
|
|
|
138
|
-
def _store_etag(self, method: str, cache_key: str, response: Any, data: Any) -> None:
|
|
139
|
-
"""Cache the ETag from a successful GET response.
|
|
141
|
+
def _store_etag(self, method: str, cache_key: str, response: Any, data: Any, *, x_pages: int = 1) -> None:
|
|
142
|
+
"""Cache the ETag and expiry from a successful GET response.
|
|
140
143
|
|
|
141
144
|
Args:
|
|
142
145
|
method: HTTP method (only ``"GET"`` responses are cached).
|
|
143
146
|
cache_key: Cache key produced by :meth:`_etag_key`.
|
|
144
147
|
response: The aiohttp response object.
|
|
145
148
|
data: Parsed JSON data to cache alongside the ETag.
|
|
149
|
+
x_pages: Total number of pages from the ``X-Pages`` header.
|
|
146
150
|
"""
|
|
147
151
|
etag = response.headers.get("ETag")
|
|
148
152
|
if method == "GET" and etag:
|
|
149
|
-
self._etag_cache[cache_key] = (
|
|
153
|
+
self._etag_cache[cache_key] = (
|
|
154
|
+
etag,
|
|
155
|
+
data,
|
|
156
|
+
x_pages,
|
|
157
|
+
self._parse_expires(response),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _parse_expires(response: Any) -> datetime | None:
|
|
162
|
+
"""Parse the ``Expires`` header into a timezone-aware datetime.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
response: The aiohttp response object.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
A timezone-aware :class:`datetime` from the ``Expires`` header, or
|
|
169
|
+
``None`` if the header is absent or cannot be parsed.
|
|
170
|
+
"""
|
|
171
|
+
if not (expires_str := response.headers.get("Expires")):
|
|
172
|
+
return None
|
|
173
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
174
|
+
parsed: datetime = parsedate_to_datetime(expires_str)
|
|
175
|
+
if parsed.tzinfo is None:
|
|
176
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
177
|
+
return parsed
|
|
178
|
+
return None
|
|
150
179
|
|
|
151
180
|
@staticmethod
|
|
152
181
|
def _parse_retry_after(response: Any) -> int | None:
|
|
@@ -166,12 +195,62 @@ class EveOnlineClient:
|
|
|
166
195
|
except ValueError:
|
|
167
196
|
return None
|
|
168
197
|
|
|
169
|
-
|
|
170
|
-
"""
|
|
198
|
+
def _get_fresh_cached(self, method: str, cache_key: str) -> tuple[Any, int] | None:
|
|
199
|
+
"""Return cached ``(data, x_pages)`` if the entry is still within its TTL.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
method: HTTP method (only ``"GET"`` cache entries are considered).
|
|
203
|
+
cache_key: Cache key produced by :meth:`_etag_key`.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
A ``(data, x_pages)`` tuple if a fresh cache entry exists,
|
|
207
|
+
or ``None`` if the cache is empty, expired, or the method is not GET.
|
|
208
|
+
"""
|
|
209
|
+
if method != "GET":
|
|
210
|
+
return None
|
|
211
|
+
cached = self._etag_cache.get(cache_key)
|
|
212
|
+
if cached is None or cached[3] is None:
|
|
213
|
+
return None
|
|
214
|
+
if datetime.now(UTC) < cached[3]:
|
|
215
|
+
return cached[1], cached[2]
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
async def _finalize_response(self, method: str, cache_key: str, response: Any) -> tuple[Any, int]:
|
|
219
|
+
"""Parse the response JSON, store ETag/Expires, and return ``(data, x_pages)``.
|
|
220
|
+
|
|
221
|
+
Used for any successful 2xx response. ETag/Expires-based caching is only
|
|
222
|
+
applied for ``GET`` requests (see :meth:`_store_etag`).
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
method: HTTP method used for the request.
|
|
226
|
+
cache_key: Cache key produced by :meth:`_etag_key`.
|
|
227
|
+
response: The aiohttp response object for a successful 2xx response.
|
|
171
228
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
229
|
+
Returns:
|
|
230
|
+
A ``(data, x_pages)`` tuple ready to be returned by the caller.
|
|
231
|
+
"""
|
|
232
|
+
data = await response.json()
|
|
233
|
+
try:
|
|
234
|
+
x_pages = int(response.headers.get("X-Pages", "1"))
|
|
235
|
+
except (TypeError, ValueError):
|
|
236
|
+
x_pages = 1
|
|
237
|
+
self._store_etag(method, cache_key, response, data, x_pages=x_pages)
|
|
238
|
+
return data, x_pages
|
|
239
|
+
|
|
240
|
+
async def _request_full(
|
|
241
|
+
self, method: str, path: str, *, authenticated: bool = False, **kwargs: Any
|
|
242
|
+
) -> tuple[Any, int]:
|
|
243
|
+
"""Make a request to the ESI API and return the data with pagination info.
|
|
244
|
+
|
|
245
|
+
Two caching layers are applied for GET requests:
|
|
246
|
+
|
|
247
|
+
1. **TTL caching** — if a cache entry exists with an ``Expires`` value
|
|
248
|
+
that has not passed, the cached data is returned immediately without
|
|
249
|
+
making any HTTP request.
|
|
250
|
+
2. **ETag caching** — when the TTL has expired (or no ``Expires`` was
|
|
251
|
+
stored), a ``If-None-Match`` header is sent if a cached ETag exists.
|
|
252
|
+
A ``304 Not Modified`` response returns the previously cached data
|
|
253
|
+
without downloading a response body.
|
|
175
254
|
|
|
176
255
|
Args:
|
|
177
256
|
method: HTTP method.
|
|
@@ -180,7 +259,9 @@ class EveOnlineClient:
|
|
|
180
259
|
**kwargs: Additional arguments for the HTTP request.
|
|
181
260
|
|
|
182
261
|
Returns:
|
|
183
|
-
|
|
262
|
+
A ``(data, x_pages)`` tuple where *data* is the parsed JSON
|
|
263
|
+
response and *x_pages* is the total number of pages from the
|
|
264
|
+
``X-Pages`` response header (``1`` if the header is absent).
|
|
184
265
|
|
|
185
266
|
Raises:
|
|
186
267
|
EveOnlineAuthenticationError: If auth is required but not provided,
|
|
@@ -193,6 +274,11 @@ class EveOnlineClient:
|
|
|
193
274
|
params: dict[str, Any] = dict(kwargs.pop("params", {}) or {})
|
|
194
275
|
params.setdefault("datasource", ESI_DATASOURCE)
|
|
195
276
|
cache_key = self._etag_key(path, params, authenticated)
|
|
277
|
+
|
|
278
|
+
# Short-circuit if the cached data is still fresh (Expires not yet reached).
|
|
279
|
+
if (fresh := self._get_fresh_cached(method, cache_key)) is not None:
|
|
280
|
+
return fresh
|
|
281
|
+
|
|
196
282
|
headers = {**dict(kwargs.pop("headers", {}) or {}), **self._build_etag_headers(method, cache_key)}
|
|
197
283
|
|
|
198
284
|
try:
|
|
@@ -222,6 +308,7 @@ class EveOnlineClient:
|
|
|
222
308
|
|
|
223
309
|
if response.status == 304:
|
|
224
310
|
# Not Modified — return the data we cached earlier if it still exists.
|
|
311
|
+
# Also refresh the Expires timestamp if the server sent an updated one.
|
|
225
312
|
response.release()
|
|
226
313
|
if (cached := self._etag_cache.get(cache_key)) is None:
|
|
227
314
|
msg = (
|
|
@@ -229,7 +316,9 @@ class EveOnlineClient:
|
|
|
229
316
|
f"cache entry exists for key {cache_key!r}."
|
|
230
317
|
)
|
|
231
318
|
raise EveOnlineError(msg)
|
|
232
|
-
|
|
319
|
+
expires_at = self._parse_expires(response) or cached[3]
|
|
320
|
+
self._etag_cache[cache_key] = (cached[0], cached[1], cached[2], expires_at)
|
|
321
|
+
return cached[1], cached[2]
|
|
233
322
|
|
|
234
323
|
if response.status == 404:
|
|
235
324
|
response.release()
|
|
@@ -245,10 +334,56 @@ class EveOnlineClient:
|
|
|
245
334
|
msg = f"ESI API error ({response.status}): {text}"
|
|
246
335
|
raise EveOnlineError(msg)
|
|
247
336
|
|
|
248
|
-
|
|
249
|
-
|
|
337
|
+
return await self._finalize_response(method, cache_key, response)
|
|
338
|
+
|
|
339
|
+
async def _request(self, method: str, path: str, *, authenticated: bool = False, **kwargs: Any) -> Any:
|
|
340
|
+
"""Make a request to the ESI API.
|
|
341
|
+
|
|
342
|
+
Thin wrapper around :meth:`_request_full` that discards pagination
|
|
343
|
+
metadata. Use for non-paginated endpoints.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
method: HTTP method.
|
|
347
|
+
path: API path relative to ESI base URL.
|
|
348
|
+
authenticated: Whether this request requires authentication.
|
|
349
|
+
**kwargs: Additional arguments for the HTTP request.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Parsed JSON response.
|
|
353
|
+
"""
|
|
354
|
+
data, _ = await self._request_full(method, path, authenticated=authenticated, **kwargs)
|
|
250
355
|
return data
|
|
251
356
|
|
|
357
|
+
async def _request_all_pages(self, path: str, *, authenticated: bool = False, **kwargs: Any) -> list[Any]:
|
|
358
|
+
"""Fetch all pages of a paginated ESI GET endpoint.
|
|
359
|
+
|
|
360
|
+
Sends ``?page=1``, reads the ``X-Pages`` response header to determine
|
|
361
|
+
the total page count, then fetches any remaining pages sequentially.
|
|
362
|
+
Results from all pages are combined into a single flat list.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
path: API path relative to ESI base URL.
|
|
366
|
+
authenticated: Whether this request requires authentication.
|
|
367
|
+
**kwargs: Additional arguments forwarded to each page request.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
A flat list containing the combined JSON objects from all pages.
|
|
371
|
+
"""
|
|
372
|
+
base_params: dict[str, Any] = dict(kwargs.pop("params", {}) or {})
|
|
373
|
+
|
|
374
|
+
page1_data, total_pages = await self._request_full(
|
|
375
|
+
"GET", path, authenticated=authenticated, params={**base_params, "page": 1}, **kwargs
|
|
376
|
+
)
|
|
377
|
+
all_data: list[Any] = list(page1_data)
|
|
378
|
+
|
|
379
|
+
for page in range(2, total_pages + 1):
|
|
380
|
+
page_data, _ = await self._request_full(
|
|
381
|
+
"GET", path, authenticated=authenticated, params={**base_params, "page": page}, **kwargs
|
|
382
|
+
)
|
|
383
|
+
all_data.extend(page_data)
|
|
384
|
+
|
|
385
|
+
return all_data
|
|
386
|
+
|
|
252
387
|
@staticmethod
|
|
253
388
|
@overload
|
|
254
389
|
def _parse_datetime(value: str) -> datetime: ...
|
|
@@ -680,15 +815,19 @@ class EveOnlineClient:
|
|
|
680
815
|
async def async_get_wallet_journal(self, character_id: int) -> list[WalletJournalEntry]:
|
|
681
816
|
"""Get a character's wallet journal (recent transactions).
|
|
682
817
|
|
|
818
|
+
All pages are fetched automatically. ESI returns up to 50 entries
|
|
819
|
+
per page; characters with long transaction histories will require
|
|
820
|
+
multiple pages.
|
|
821
|
+
|
|
683
822
|
Requires scope: ``esi-wallet.read_character_wallet.v1``
|
|
684
823
|
|
|
685
824
|
Args:
|
|
686
825
|
character_id: The Eve Online character ID.
|
|
687
826
|
|
|
688
827
|
Returns:
|
|
689
|
-
List of WalletJournalEntry entries, newest first.
|
|
828
|
+
List of WalletJournalEntry entries across all pages, newest first.
|
|
690
829
|
"""
|
|
691
|
-
data = await self.
|
|
830
|
+
data = await self._request_all_pages(f"characters/{character_id}/wallet/journal/", authenticated=True)
|
|
692
831
|
return [
|
|
693
832
|
WalletJournalEntry(
|
|
694
833
|
id=entry["id"],
|
|
@@ -707,15 +846,18 @@ class EveOnlineClient:
|
|
|
707
846
|
async def async_get_contacts(self, character_id: int) -> list[CharacterContact]:
|
|
708
847
|
"""Get a character's contacts.
|
|
709
848
|
|
|
849
|
+
All pages are fetched automatically. ESI returns up to 500 contacts
|
|
850
|
+
per page.
|
|
851
|
+
|
|
710
852
|
Requires scope: ``esi-characters.read_contacts.v1``
|
|
711
853
|
|
|
712
854
|
Args:
|
|
713
855
|
character_id: The Eve Online character ID.
|
|
714
856
|
|
|
715
857
|
Returns:
|
|
716
|
-
List of CharacterContact entries.
|
|
858
|
+
List of CharacterContact entries across all pages.
|
|
717
859
|
"""
|
|
718
|
-
data = await self.
|
|
860
|
+
data = await self._request_all_pages(f"characters/{character_id}/contacts/", authenticated=True)
|
|
719
861
|
return [
|
|
720
862
|
CharacterContact(
|
|
721
863
|
contact_id=entry["contact_id"],
|
|
@@ -770,3 +912,26 @@ class EveOnlineClient:
|
|
|
770
912
|
)
|
|
771
913
|
for entry in data
|
|
772
914
|
]
|
|
915
|
+
|
|
916
|
+
async def async_get_killmails(self, character_id: int) -> list[CharacterKillmail]:
|
|
917
|
+
"""Get a character's recent killmail references.
|
|
918
|
+
|
|
919
|
+
All pages are fetched automatically. ESI returns up to 50 entries
|
|
920
|
+
per page; characters with high kill activity will require multiple pages.
|
|
921
|
+
|
|
922
|
+
Requires scope: ``esi-killmails.read_killmails.v1``
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
character_id: The Eve Online character ID.
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
List of CharacterKillmail entries across all pages.
|
|
929
|
+
"""
|
|
930
|
+
data = await self._request_all_pages(f"characters/{character_id}/killmails/recent/", authenticated=True)
|
|
931
|
+
return [
|
|
932
|
+
CharacterKillmail(
|
|
933
|
+
killmail_id=entry["killmail_id"],
|
|
934
|
+
killmail_hash=entry["killmail_hash"],
|
|
935
|
+
)
|
|
936
|
+
for entry in data
|
|
937
|
+
]
|
|
@@ -455,3 +455,20 @@ class LoyaltyPoints:
|
|
|
455
455
|
|
|
456
456
|
corporation_id: int
|
|
457
457
|
loyalty_points: int
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@dataclass(frozen=True, slots=True)
|
|
461
|
+
class CharacterKillmail:
|
|
462
|
+
"""A killmail reference from a character's recent kill/loss history (requires auth).
|
|
463
|
+
|
|
464
|
+
This represents a reference to a killmail, not the full killmail detail.
|
|
465
|
+
Use the ``killmail_id`` and ``killmail_hash`` to fetch the full killmail
|
|
466
|
+
from ``GET /killmails/{killmail_id}/{killmail_hash}/`` if needed.
|
|
467
|
+
|
|
468
|
+
Attributes:
|
|
469
|
+
killmail_id: Unique killmail identifier.
|
|
470
|
+
killmail_hash: Hash string required to fetch the full killmail detail.
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
killmail_id: int
|
|
474
|
+
killmail_hash: str
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-eveonline
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Async Python client for the Eve Online ESI API
|
|
5
5
|
Author: Ronald van der Meer
|
|
6
6
|
License-Expression: MIT
|
|
@@ -45,10 +45,10 @@ Built for use with [Home Assistant](https://www.home-assistant.io/) but can be u
|
|
|
45
45
|
|
|
46
46
|
- **Fully async** — built on [aiohttp](https://docs.aiohttp.org/)
|
|
47
47
|
- **Typed models** — all API responses are frozen dataclasses with full type annotations
|
|
48
|
-
- **
|
|
48
|
+
- **23 endpoints** — public (server, character, corporation, universe) and authenticated (wallet, skills, location, industry, market, mail, notifications, clones, fatigue, contacts, calendar, loyalty, killmails)
|
|
49
49
|
- **Abstract auth** — implement `AbstractAuth` to plug in any OAuth2 token source
|
|
50
50
|
- **Type-safe** — PEP 561 compatible (`py.typed`), strict mypy configuration
|
|
51
|
-
- **Tested** —
|
|
51
|
+
- **Tested** — ≥98% test coverage
|
|
52
52
|
|
|
53
53
|
## Installation
|
|
54
54
|
|
|
@@ -76,7 +76,7 @@ asyncio.run(main())
|
|
|
76
76
|
|
|
77
77
|
- [**Quickstart**](docs/quickstart.md) — public and authenticated endpoint examples
|
|
78
78
|
- [**Authentication**](docs/authentication.md) — implementing `AbstractAuth`, required OAuth scopes
|
|
79
|
-
- [**Endpoints**](docs/endpoints.md) — full reference with field tables for all
|
|
79
|
+
- [**Endpoints**](docs/endpoints.md) — full reference with field tables for all 23 methods
|
|
80
80
|
- [**Error Handling**](docs/error-handling.md) — exception hierarchy, rate limiting, ESI cache times
|
|
81
81
|
|
|
82
82
|
## License
|
|
@@ -15,6 +15,7 @@ from eveonline.models import (
|
|
|
15
15
|
CalendarEvent,
|
|
16
16
|
CharacterClones,
|
|
17
17
|
CharacterContact,
|
|
18
|
+
CharacterKillmail,
|
|
18
19
|
CharacterLocation,
|
|
19
20
|
CharacterNotification,
|
|
20
21
|
CharacterOnlineStatus,
|
|
@@ -194,6 +195,14 @@ class TestAuthRequired:
|
|
|
194
195
|
with pytest.raises(EveOnlineAuthenticationError):
|
|
195
196
|
await client.async_get_loyalty_points(CHARACTER_ID)
|
|
196
197
|
|
|
198
|
+
@pytest.mark.asyncio
|
|
199
|
+
async def test_killmails_without_auth_raises(self):
|
|
200
|
+
"""Calling killmails endpoint without auth raises error."""
|
|
201
|
+
async with aiohttp.ClientSession() as session:
|
|
202
|
+
client = EveOnlineClient(session=session)
|
|
203
|
+
with pytest.raises(EveOnlineAuthenticationError):
|
|
204
|
+
await client.async_get_killmails(CHARACTER_ID)
|
|
205
|
+
|
|
197
206
|
|
|
198
207
|
class TestCharacterOnline:
|
|
199
208
|
"""Test GET /characters/{character_id}/online/ endpoint."""
|
|
@@ -848,10 +857,10 @@ class TestWalletJournal:
|
|
|
848
857
|
|
|
849
858
|
@pytest.mark.asyncio
|
|
850
859
|
async def test_get_wallet_journal_success(self, wallet_journal_data):
|
|
851
|
-
"""Successful wallet journal fetch."""
|
|
860
|
+
"""Successful wallet journal fetch (single page)."""
|
|
852
861
|
with aioresponses() as mocked:
|
|
853
862
|
mocked.get(
|
|
854
|
-
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/wallet/journal/?datasource=tranquility",
|
|
863
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/wallet/journal/?datasource=tranquility&page=1",
|
|
855
864
|
payload=wallet_journal_data,
|
|
856
865
|
)
|
|
857
866
|
async with aiohttp.ClientSession() as session:
|
|
@@ -881,7 +890,7 @@ class TestWalletJournal:
|
|
|
881
890
|
"""No journal entries returns empty list."""
|
|
882
891
|
with aioresponses() as mocked:
|
|
883
892
|
mocked.get(
|
|
884
|
-
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/wallet/journal/?datasource=tranquility",
|
|
893
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/wallet/journal/?datasource=tranquility&page=1",
|
|
885
894
|
payload=[],
|
|
886
895
|
)
|
|
887
896
|
async with aiohttp.ClientSession() as session:
|
|
@@ -891,16 +900,58 @@ class TestWalletJournal:
|
|
|
891
900
|
|
|
892
901
|
assert journal == []
|
|
893
902
|
|
|
903
|
+
@pytest.mark.asyncio
|
|
904
|
+
async def test_get_wallet_journal_multiple_pages(self):
|
|
905
|
+
"""Wallet journal spanning two pages returns combined results."""
|
|
906
|
+
page1 = [
|
|
907
|
+
{
|
|
908
|
+
"id": 1,
|
|
909
|
+
"date": "2026-03-27T12:00:00Z",
|
|
910
|
+
"ref_type": "bounty_prizes",
|
|
911
|
+
"description": "Bounties",
|
|
912
|
+
"amount": 1000000.0,
|
|
913
|
+
"balance": 2000000.0,
|
|
914
|
+
}
|
|
915
|
+
]
|
|
916
|
+
page2 = [
|
|
917
|
+
{
|
|
918
|
+
"id": 2,
|
|
919
|
+
"date": "2026-03-26T10:00:00Z",
|
|
920
|
+
"ref_type": "market_escrow",
|
|
921
|
+
"description": "Escrow",
|
|
922
|
+
"amount": -500000.0,
|
|
923
|
+
"balance": 1000000.0,
|
|
924
|
+
}
|
|
925
|
+
]
|
|
926
|
+
with aioresponses() as mocked:
|
|
927
|
+
mocked.get(
|
|
928
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/wallet/journal/?datasource=tranquility&page=1",
|
|
929
|
+
payload=page1,
|
|
930
|
+
headers={"X-Pages": "2"},
|
|
931
|
+
)
|
|
932
|
+
mocked.get(
|
|
933
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/wallet/journal/?datasource=tranquility&page=2",
|
|
934
|
+
payload=page2,
|
|
935
|
+
)
|
|
936
|
+
async with aiohttp.ClientSession() as session:
|
|
937
|
+
auth = MockAuth(session)
|
|
938
|
+
client = EveOnlineClient(auth=auth)
|
|
939
|
+
journal = await client.async_get_wallet_journal(CHARACTER_ID)
|
|
940
|
+
|
|
941
|
+
assert len(journal) == 2
|
|
942
|
+
assert journal[0].id == 1
|
|
943
|
+
assert journal[1].id == 2
|
|
944
|
+
|
|
894
945
|
|
|
895
946
|
class TestContacts:
|
|
896
947
|
"""Test GET /characters/{character_id}/contacts/ endpoint."""
|
|
897
948
|
|
|
898
949
|
@pytest.mark.asyncio
|
|
899
950
|
async def test_get_contacts_success(self, contacts_data):
|
|
900
|
-
"""Successful contacts fetch."""
|
|
951
|
+
"""Successful contacts fetch (single page)."""
|
|
901
952
|
with aioresponses() as mocked:
|
|
902
953
|
mocked.get(
|
|
903
|
-
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/contacts/?datasource=tranquility",
|
|
954
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/contacts/?datasource=tranquility&page=1",
|
|
904
955
|
payload=contacts_data,
|
|
905
956
|
)
|
|
906
957
|
async with aiohttp.ClientSession() as session:
|
|
@@ -930,7 +981,7 @@ class TestContacts:
|
|
|
930
981
|
"""No contacts returns empty list."""
|
|
931
982
|
with aioresponses() as mocked:
|
|
932
983
|
mocked.get(
|
|
933
|
-
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/contacts/?datasource=tranquility",
|
|
984
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/contacts/?datasource=tranquility&page=1",
|
|
934
985
|
payload=[],
|
|
935
986
|
)
|
|
936
987
|
async with aiohttp.ClientSession() as session:
|
|
@@ -940,6 +991,34 @@ class TestContacts:
|
|
|
940
991
|
|
|
941
992
|
assert contacts == []
|
|
942
993
|
|
|
994
|
+
@pytest.mark.asyncio
|
|
995
|
+
async def test_get_contacts_multiple_pages(self):
|
|
996
|
+
"""Contacts spanning two pages returns combined results."""
|
|
997
|
+
page1 = [
|
|
998
|
+
{"contact_id": 111, "contact_type": "character", "standing": 5.0},
|
|
999
|
+
]
|
|
1000
|
+
page2 = [
|
|
1001
|
+
{"contact_id": 222, "contact_type": "corporation", "standing": -5.0},
|
|
1002
|
+
]
|
|
1003
|
+
with aioresponses() as mocked:
|
|
1004
|
+
mocked.get(
|
|
1005
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/contacts/?datasource=tranquility&page=1",
|
|
1006
|
+
payload=page1,
|
|
1007
|
+
headers={"X-Pages": "2"},
|
|
1008
|
+
)
|
|
1009
|
+
mocked.get(
|
|
1010
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/contacts/?datasource=tranquility&page=2",
|
|
1011
|
+
payload=page2,
|
|
1012
|
+
)
|
|
1013
|
+
async with aiohttp.ClientSession() as session:
|
|
1014
|
+
auth = MockAuth(session)
|
|
1015
|
+
client = EveOnlineClient(auth=auth)
|
|
1016
|
+
contacts = await client.async_get_contacts(CHARACTER_ID)
|
|
1017
|
+
|
|
1018
|
+
assert len(contacts) == 2
|
|
1019
|
+
assert contacts[0].contact_id == 111
|
|
1020
|
+
assert contacts[1].contact_id == 222
|
|
1021
|
+
|
|
943
1022
|
|
|
944
1023
|
class TestCalendar:
|
|
945
1024
|
"""Test GET /characters/{character_id}/calendar/ endpoint."""
|
|
@@ -1028,3 +1107,85 @@ class TestLoyaltyPoints:
|
|
|
1028
1107
|
lp = await client.async_get_loyalty_points(CHARACTER_ID)
|
|
1029
1108
|
|
|
1030
1109
|
assert lp == []
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
class TestKillmails:
|
|
1113
|
+
"""Test GET /characters/{character_id}/killmails/recent/ endpoint."""
|
|
1114
|
+
|
|
1115
|
+
@pytest.mark.asyncio
|
|
1116
|
+
async def test_get_killmails_success(self, killmails_data):
|
|
1117
|
+
"""Successful killmails fetch with multiple entries (single page)."""
|
|
1118
|
+
with aioresponses() as mocked:
|
|
1119
|
+
mocked.get(
|
|
1120
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/killmails/recent/?datasource=tranquility&page=1",
|
|
1121
|
+
payload=killmails_data,
|
|
1122
|
+
)
|
|
1123
|
+
async with aiohttp.ClientSession() as session:
|
|
1124
|
+
auth = MockAuth(session)
|
|
1125
|
+
client = EveOnlineClient(auth=auth)
|
|
1126
|
+
killmails = await client.async_get_killmails(CHARACTER_ID)
|
|
1127
|
+
|
|
1128
|
+
assert len(killmails) == 2
|
|
1129
|
+
assert all(isinstance(km, CharacterKillmail) for km in killmails)
|
|
1130
|
+
|
|
1131
|
+
first = killmails[0]
|
|
1132
|
+
assert first.killmail_id == 112830692
|
|
1133
|
+
assert first.killmail_hash == "a01f278642069a91c09009da6d4b0e0e4c6f9b20"
|
|
1134
|
+
|
|
1135
|
+
second = killmails[1]
|
|
1136
|
+
assert second.killmail_id == 112830693
|
|
1137
|
+
assert second.killmail_hash == "b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0"
|
|
1138
|
+
|
|
1139
|
+
@pytest.mark.asyncio
|
|
1140
|
+
async def test_get_killmails_empty(self):
|
|
1141
|
+
"""No recent killmails returns empty list."""
|
|
1142
|
+
with aioresponses() as mocked:
|
|
1143
|
+
mocked.get(
|
|
1144
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/killmails/recent/?datasource=tranquility&page=1",
|
|
1145
|
+
payload=[],
|
|
1146
|
+
)
|
|
1147
|
+
async with aiohttp.ClientSession() as session:
|
|
1148
|
+
auth = MockAuth(session)
|
|
1149
|
+
client = EveOnlineClient(auth=auth)
|
|
1150
|
+
killmails = await client.async_get_killmails(CHARACTER_ID)
|
|
1151
|
+
|
|
1152
|
+
assert killmails == []
|
|
1153
|
+
|
|
1154
|
+
@pytest.mark.asyncio
|
|
1155
|
+
async def test_get_killmails_not_found_raises(self):
|
|
1156
|
+
"""HTTP 404 raises EveOnlineNotFoundError."""
|
|
1157
|
+
with aioresponses() as mocked:
|
|
1158
|
+
mocked.get(
|
|
1159
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/killmails/recent/?datasource=tranquility&page=1",
|
|
1160
|
+
status=404,
|
|
1161
|
+
body="Character not found",
|
|
1162
|
+
)
|
|
1163
|
+
async with aiohttp.ClientSession() as session:
|
|
1164
|
+
auth = MockAuth(session)
|
|
1165
|
+
client = EveOnlineClient(auth=auth)
|
|
1166
|
+
with pytest.raises(EveOnlineNotFoundError):
|
|
1167
|
+
await client.async_get_killmails(CHARACTER_ID)
|
|
1168
|
+
|
|
1169
|
+
@pytest.mark.asyncio
|
|
1170
|
+
async def test_get_killmails_multiple_pages(self):
|
|
1171
|
+
"""Killmails spanning two pages returns combined results."""
|
|
1172
|
+
page1 = [{"killmail_id": 100, "killmail_hash": "aaa"}]
|
|
1173
|
+
page2 = [{"killmail_id": 101, "killmail_hash": "bbb"}]
|
|
1174
|
+
with aioresponses() as mocked:
|
|
1175
|
+
mocked.get(
|
|
1176
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/killmails/recent/?datasource=tranquility&page=1",
|
|
1177
|
+
payload=page1,
|
|
1178
|
+
headers={"X-Pages": "2"},
|
|
1179
|
+
)
|
|
1180
|
+
mocked.get(
|
|
1181
|
+
f"{ESI_BASE_URL}/characters/{CHARACTER_ID}/killmails/recent/?datasource=tranquility&page=2",
|
|
1182
|
+
payload=page2,
|
|
1183
|
+
)
|
|
1184
|
+
async with aiohttp.ClientSession() as session:
|
|
1185
|
+
auth = MockAuth(session)
|
|
1186
|
+
client = EveOnlineClient(auth=auth)
|
|
1187
|
+
killmails = await client.async_get_killmails(CHARACTER_ID)
|
|
1188
|
+
|
|
1189
|
+
assert len(killmails) == 2
|
|
1190
|
+
assert killmails[0].killmail_id == 100
|
|
1191
|
+
assert killmails[1].killmail_id == 101
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import time
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from email.utils import formatdate
|
|
8
|
+
|
|
5
9
|
import aiohttp
|
|
6
10
|
import pytest
|
|
7
11
|
from aioresponses import aioresponses
|
|
@@ -523,3 +527,127 @@ class TestETagEdgeCases:
|
|
|
523
527
|
assert len(client._etag_cache) == 1
|
|
524
528
|
client.clear_etag_cache()
|
|
525
529
|
assert client._etag_cache == {}
|
|
530
|
+
|
|
531
|
+
@pytest.mark.asyncio
|
|
532
|
+
async def test_malformed_x_pages_header_defaults_to_single_page(self):
|
|
533
|
+
"""A non-integer X-Pages header falls back to 1 without raising."""
|
|
534
|
+
with aioresponses() as mocked:
|
|
535
|
+
mocked.get(
|
|
536
|
+
f"{ESI_BASE_URL}/status/?datasource=tranquility",
|
|
537
|
+
payload={"players": 5000, "server_version": "DEADSPACE", "start_time": "2025-01-01T00:00:00Z"},
|
|
538
|
+
headers={"ETag": '"abc"', "X-Pages": "not-a-number"},
|
|
539
|
+
)
|
|
540
|
+
async with aiohttp.ClientSession() as session:
|
|
541
|
+
client = EveOnlineClient(session=session)
|
|
542
|
+
result = await client.async_get_server_status()
|
|
543
|
+
|
|
544
|
+
assert result.players == 5000
|
|
545
|
+
cache_key = client._etag_key("status/", {"datasource": "tranquility"}, authenticated=False)
|
|
546
|
+
assert client._etag_cache[cache_key][2] == 1
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class TestExpiresTTLCaching:
|
|
550
|
+
"""Expires/TTL caching: skip HTTP when cached data is still fresh."""
|
|
551
|
+
|
|
552
|
+
@pytest.mark.asyncio
|
|
553
|
+
async def test_expires_stored_in_cache(self):
|
|
554
|
+
"""A valid Expires header is parsed and stored as expires_at in the cache."""
|
|
555
|
+
future_expires = formatdate(time.time() + 3600, usegmt=True)
|
|
556
|
+
with aioresponses() as mocked:
|
|
557
|
+
mocked.get(
|
|
558
|
+
f"{ESI_BASE_URL}/status/?datasource=tranquility",
|
|
559
|
+
payload=_SERVER_STATUS,
|
|
560
|
+
headers={"ETag": '"abc"', "Expires": future_expires},
|
|
561
|
+
)
|
|
562
|
+
async with aiohttp.ClientSession() as session:
|
|
563
|
+
client = EveOnlineClient(session=session)
|
|
564
|
+
await client.async_get_server_status()
|
|
565
|
+
|
|
566
|
+
cache_key = client._etag_key("status/", {"datasource": "tranquility"}, authenticated=False)
|
|
567
|
+
expires_at = client._etag_cache[cache_key][3]
|
|
568
|
+
assert isinstance(expires_at, datetime)
|
|
569
|
+
assert expires_at > datetime.now(UTC)
|
|
570
|
+
|
|
571
|
+
@pytest.mark.asyncio
|
|
572
|
+
async def test_fresh_cache_skips_http_request(self):
|
|
573
|
+
"""A cached entry with Expires in the future is returned without making an HTTP request."""
|
|
574
|
+
future_expires = formatdate(time.time() + 3600, usegmt=True)
|
|
575
|
+
url = f"{ESI_BASE_URL}/status/?datasource=tranquility"
|
|
576
|
+
async with aiohttp.ClientSession() as session:
|
|
577
|
+
client = EveOnlineClient(session=session)
|
|
578
|
+
|
|
579
|
+
with aioresponses() as mocked:
|
|
580
|
+
mocked.get(
|
|
581
|
+
url,
|
|
582
|
+
payload=_SERVER_STATUS,
|
|
583
|
+
headers={"ETag": '"abc"', "Expires": future_expires},
|
|
584
|
+
)
|
|
585
|
+
result1 = await client.async_get_server_status()
|
|
586
|
+
# Second call — no additional mock registered; must be served from cache.
|
|
587
|
+
result2 = await client.async_get_server_status()
|
|
588
|
+
|
|
589
|
+
assert result1 == result2
|
|
590
|
+
|
|
591
|
+
@pytest.mark.asyncio
|
|
592
|
+
async def test_expired_cache_sends_etag_request(self):
|
|
593
|
+
"""A cache entry with an expired Expires falls through to the ETag/If-None-Match flow."""
|
|
594
|
+
past_expires = formatdate(time.time() - 3600, usegmt=True)
|
|
595
|
+
async with aiohttp.ClientSession() as session:
|
|
596
|
+
client = EveOnlineClient(session=session)
|
|
597
|
+
|
|
598
|
+
with aioresponses() as mocked:
|
|
599
|
+
mocked.get(
|
|
600
|
+
f"{ESI_BASE_URL}/status/?datasource=tranquility",
|
|
601
|
+
payload=_SERVER_STATUS,
|
|
602
|
+
headers={"ETag": '"abc"', "Expires": past_expires},
|
|
603
|
+
)
|
|
604
|
+
await client.async_get_server_status()
|
|
605
|
+
|
|
606
|
+
# TTL expired — expect an ETag request; ESI responds 304.
|
|
607
|
+
with aioresponses() as mocked:
|
|
608
|
+
mocked.get(
|
|
609
|
+
f"{ESI_BASE_URL}/status/?datasource=tranquility",
|
|
610
|
+
status=304,
|
|
611
|
+
)
|
|
612
|
+
result = await client.async_get_server_status()
|
|
613
|
+
|
|
614
|
+
# Verify that an HTTP request was made with the stored ETag.
|
|
615
|
+
assert mocked.requests, "Expected an HTTP request after cache expiry"
|
|
616
|
+
(_method, _url), calls = next(iter(mocked.requests.items()))
|
|
617
|
+
assert _method == "GET"
|
|
618
|
+
sent_headers = calls[0].kwargs.get("headers") or {}
|
|
619
|
+
assert sent_headers.get("If-None-Match") == '"abc"'
|
|
620
|
+
|
|
621
|
+
assert result.players == _SERVER_STATUS["players"]
|
|
622
|
+
|
|
623
|
+
@pytest.mark.asyncio
|
|
624
|
+
async def test_malformed_expires_stores_none(self):
|
|
625
|
+
"""An unparseable Expires header stores None rather than raising."""
|
|
626
|
+
with aioresponses() as mocked:
|
|
627
|
+
mocked.get(
|
|
628
|
+
f"{ESI_BASE_URL}/status/?datasource=tranquility",
|
|
629
|
+
payload=_SERVER_STATUS,
|
|
630
|
+
headers={"ETag": '"abc"', "Expires": "not-a-valid-date"},
|
|
631
|
+
)
|
|
632
|
+
async with aiohttp.ClientSession() as session:
|
|
633
|
+
client = EveOnlineClient(session=session)
|
|
634
|
+
await client.async_get_server_status()
|
|
635
|
+
|
|
636
|
+
cache_key = client._etag_key("status/", {"datasource": "tranquility"}, authenticated=False)
|
|
637
|
+
assert client._etag_cache[cache_key][3] is None
|
|
638
|
+
|
|
639
|
+
@pytest.mark.asyncio
|
|
640
|
+
async def test_no_expires_header_stores_none(self):
|
|
641
|
+
"""A response without an Expires header stores None as expires_at."""
|
|
642
|
+
with aioresponses() as mocked:
|
|
643
|
+
mocked.get(
|
|
644
|
+
f"{ESI_BASE_URL}/status/?datasource=tranquility",
|
|
645
|
+
payload=_SERVER_STATUS,
|
|
646
|
+
headers={"ETag": '"abc"'},
|
|
647
|
+
)
|
|
648
|
+
async with aiohttp.ClientSession() as session:
|
|
649
|
+
client = EveOnlineClient(session=session)
|
|
650
|
+
await client.async_get_server_status()
|
|
651
|
+
|
|
652
|
+
cache_key = client._etag_key("status/", {"datasource": "tranquility"}, authenticated=False)
|
|
653
|
+
assert client._etag_cache[cache_key][3] is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/requires.txt
RENAMED
|
File without changes
|
{python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|