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.
Files changed (24) hide show
  1. {python_eveonline-0.3.0/src/python_eveonline.egg-info → python_eveonline-0.4.0}/PKG-INFO +4 -4
  2. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/README.md +3 -3
  3. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/pyproject.toml +1 -1
  4. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/__init__.py +2 -0
  5. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/client.py +184 -19
  6. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/models.py +17 -0
  7. {python_eveonline-0.3.0 → python_eveonline-0.4.0/src/python_eveonline.egg-info}/PKG-INFO +4 -4
  8. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_client_authenticated.py +167 -6
  9. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_etag_caching.py +128 -0
  10. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/LICENSE +0 -0
  11. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/setup.cfg +0 -0
  12. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/auth.py +0 -0
  13. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/const.py +0 -0
  14. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/exceptions.py +0 -0
  15. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/eveonline/py.typed +0 -0
  16. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/SOURCES.txt +0 -0
  17. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/dependency_links.txt +0 -0
  18. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/requires.txt +0 -0
  19. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/src/python_eveonline.egg-info/top_level.txt +0 -0
  20. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_auth.py +0 -0
  21. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_client_public.py +0 -0
  22. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_const.py +0 -0
  23. {python_eveonline-0.3.0 → python_eveonline-0.4.0}/tests/test_exceptions.py +0 -0
  24. {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.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
- - **15 endpoints** — public (server, character, corporation, universe) and authenticated (wallet, skills, location, industry, market, mail, fatigue)
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** — 100% test coverage
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 15 methods
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
- - **15 endpoints** — public (server, character, corporation, universe) and authenticated (wallet, skills, location, industry, market, mail, fatigue)
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** — 100% test coverage
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 15 methods
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-eveonline"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Async Python client for the Eve Online ESI API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- from datetime import datetime
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] = (etag, data)
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
- async def _request(self, method: str, path: str, *, authenticated: bool = False, **kwargs: Any) -> Any:
170
- """Make a request to the ESI API.
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
- GET requests use ETag caching: a cached ``ETag`` is sent as
173
- ``If-None-Match``; a ``304 Not Modified`` response returns the
174
- previously cached data without consuming bandwidth.
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
- Parsed JSON response.
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
- return cached[1]
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
- data = await response.json()
249
- self._store_etag(method, cache_key, response, data)
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._request("GET", f"characters/{character_id}/wallet/journal/", authenticated=True)
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._request("GET", f"characters/{character_id}/contacts/", authenticated=True)
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.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
- - **15 endpoints** — public (server, character, corporation, universe) and authenticated (wallet, skills, location, industry, market, mail, fatigue)
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** — 100% test coverage
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 15 methods
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