python-eveonline 0.2.2__tar.gz → 0.3.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 (27) hide show
  1. python_eveonline-0.3.0/PKG-INFO +84 -0
  2. python_eveonline-0.3.0/README.md +52 -0
  3. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/pyproject.toml +13 -1
  4. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/eveonline/client.py +291 -18
  5. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/eveonline/const.py +3 -0
  6. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/eveonline/models.py +152 -0
  7. python_eveonline-0.3.0/src/python_eveonline.egg-info/PKG-INFO +84 -0
  8. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/python_eveonline.egg-info/SOURCES.txt +1 -0
  9. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/tests/test_client_authenticated.py +406 -0
  10. python_eveonline-0.3.0/tests/test_etag_caching.py +525 -0
  11. python_eveonline-0.2.2/PKG-INFO +0 -144
  12. python_eveonline-0.2.2/README.md +0 -112
  13. python_eveonline-0.2.2/src/python_eveonline.egg-info/PKG-INFO +0 -144
  14. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/LICENSE +0 -0
  15. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/setup.cfg +0 -0
  16. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/eveonline/__init__.py +0 -0
  17. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/eveonline/auth.py +0 -0
  18. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/eveonline/exceptions.py +0 -0
  19. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/eveonline/py.typed +0 -0
  20. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/python_eveonline.egg-info/dependency_links.txt +0 -0
  21. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/python_eveonline.egg-info/requires.txt +0 -0
  22. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/src/python_eveonline.egg-info/top_level.txt +0 -0
  23. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/tests/test_auth.py +0 -0
  24. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/tests/test_client_public.py +0 -0
  25. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/tests/test_const.py +0 -0
  26. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/tests/test_exceptions.py +0 -0
  27. {python_eveonline-0.2.2 → python_eveonline-0.3.0}/tests/test_models.py +0 -0
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-eveonline
3
+ Version: 0.3.0
4
+ Summary: Async Python client for the Eve Online ESI API
5
+ Author: Ronald van der Meer
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ronaldvdmeer/python-eveonline
8
+ Project-URL: Repository, https://github.com/ronaldvdmeer/python-eveonline
9
+ Project-URL: Issues, https://github.com/ronaldvdmeer/python-eveonline/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Games/Entertainment
17
+ Classifier: Typing :: Typed
18
+ Classifier: Framework :: AsyncIO
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: aiohttp>=3.9.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
26
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
27
+ Requires-Dist: aioresponses>=0.7; extra == "dev"
28
+ Requires-Dist: mypy>=1.8; extra == "dev"
29
+ Requires-Dist: pylint>=3.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.3; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # python-eveonline
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/python-eveonline.svg)](https://pypi.org/project/python-eveonline/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/python-eveonline.svg)](https://pypi.org/project/python-eveonline/)
37
+ [![License](https://img.shields.io/github/license/ronaldvdmeer/python-eveonline.svg)](https://github.com/ronaldvdmeer/python-eveonline/blob/main/LICENSE)
38
+ [![GitHub Release](https://img.shields.io/github/v/release/ronaldvdmeer/python-eveonline.svg)](https://github.com/ronaldvdmeer/python-eveonline/releases)
39
+
40
+ Async Python client library for the [Eve Online ESI API](https://esi.evetech.net/ui/).
41
+
42
+ Built for use with [Home Assistant](https://www.home-assistant.io/) but can be used standalone in any async Python project.
43
+
44
+ ## Features
45
+
46
+ - **Fully async** — built on [aiohttp](https://docs.aiohttp.org/)
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)
49
+ - **Abstract auth** — implement `AbstractAuth` to plug in any OAuth2 token source
50
+ - **Type-safe** — PEP 561 compatible (`py.typed`), strict mypy configuration
51
+ - **Tested** — 100% test coverage
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install python-eveonline
57
+ ```
58
+
59
+ ## Quick start
60
+
61
+ ```python
62
+ import asyncio
63
+ import aiohttp
64
+ from eveonline import EveOnlineClient
65
+
66
+ async def main():
67
+ async with aiohttp.ClientSession() as session:
68
+ client = EveOnlineClient(session=session)
69
+ status = await client.async_get_server_status()
70
+ print(f"{status.players} players online (v{status.server_version})")
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ ## Documentation
76
+
77
+ - [**Quickstart**](docs/quickstart.md) — public and authenticated endpoint examples
78
+ - [**Authentication**](docs/authentication.md) — implementing `AbstractAuth`, required OAuth scopes
79
+ - [**Endpoints**](docs/endpoints.md) — full reference with field tables for all 15 methods
80
+ - [**Error Handling**](docs/error-handling.md) — exception hierarchy, rate limiting, ESI cache times
81
+
82
+ ## License
83
+
84
+ [MIT](LICENSE)
@@ -0,0 +1,52 @@
1
+ # python-eveonline
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/python-eveonline.svg)](https://pypi.org/project/python-eveonline/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/python-eveonline.svg)](https://pypi.org/project/python-eveonline/)
5
+ [![License](https://img.shields.io/github/license/ronaldvdmeer/python-eveonline.svg)](https://github.com/ronaldvdmeer/python-eveonline/blob/main/LICENSE)
6
+ [![GitHub Release](https://img.shields.io/github/v/release/ronaldvdmeer/python-eveonline.svg)](https://github.com/ronaldvdmeer/python-eveonline/releases)
7
+
8
+ Async Python client library for the [Eve Online ESI API](https://esi.evetech.net/ui/).
9
+
10
+ Built for use with [Home Assistant](https://www.home-assistant.io/) but can be used standalone in any async Python project.
11
+
12
+ ## Features
13
+
14
+ - **Fully async** — built on [aiohttp](https://docs.aiohttp.org/)
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)
17
+ - **Abstract auth** — implement `AbstractAuth` to plug in any OAuth2 token source
18
+ - **Type-safe** — PEP 561 compatible (`py.typed`), strict mypy configuration
19
+ - **Tested** — 100% test coverage
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install python-eveonline
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```python
30
+ import asyncio
31
+ import aiohttp
32
+ from eveonline import EveOnlineClient
33
+
34
+ async def main():
35
+ async with aiohttp.ClientSession() as session:
36
+ client = EveOnlineClient(session=session)
37
+ status = await client.async_get_server_status()
38
+ print(f"{status.players} players online (v{status.server_version})")
39
+
40
+ asyncio.run(main())
41
+ ```
42
+
43
+ ## Documentation
44
+
45
+ - [**Quickstart**](docs/quickstart.md) — public and authenticated endpoint examples
46
+ - [**Authentication**](docs/authentication.md) — implementing `AbstractAuth`, required OAuth scopes
47
+ - [**Endpoints**](docs/endpoints.md) — full reference with field tables for all 15 methods
48
+ - [**Error Handling**](docs/error-handling.md) — exception hierarchy, rate limiting, ESI cache times
49
+
50
+ ## License
51
+
52
+ [MIT](LICENSE)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-eveonline"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Async Python client for the Eve Online ESI API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -58,6 +58,10 @@ addopts = [
58
58
  "--cov-report=term-missing",
59
59
  "--cov-fail-under=90",
60
60
  "-v",
61
+ "-m", "not integration",
62
+ ]
63
+ markers = [
64
+ "integration: live ESI API tests — require network access; authenticated tests also require ESI_TOKEN env var",
61
65
  ]
62
66
 
63
67
  # -- coverage ----------------------------------------------------------------
@@ -160,6 +164,13 @@ ignore = [
160
164
  "src/eveonline/const.py" = [
161
165
  "S105", # SSO_TOKEN_URL is a URL, not a password
162
166
  ]
167
+ "scripts/**/*.py" = [
168
+ "D", # No docstring requirements in scripts
169
+ "T20", # Allow print in scripts (CLI output)
170
+ "ANN", # No annotation requirements in scripts
171
+ "S105", # URL constants that look like passwords
172
+ "S310", # URL open is expected in these scripts
173
+ ]
163
174
 
164
175
  [tool.ruff.lint.pydocstyle]
165
176
  convention = "google"
@@ -181,3 +192,4 @@ disable = [
181
192
 
182
193
  [tool.pylint.design]
183
194
  max-args = 10
195
+ max-public-methods = 30
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime
6
- from typing import Any
6
+ from typing import Any, overload
7
7
 
8
8
  from aiohttp import ClientSession
9
9
 
@@ -17,21 +17,29 @@ from .exceptions import (
17
17
  EveOnlineRateLimitError,
18
18
  )
19
19
  from .models import (
20
+ CalendarEvent,
21
+ CharacterClones,
22
+ CharacterContact,
20
23
  CharacterLocation,
24
+ CharacterNotification,
21
25
  CharacterOnlineStatus,
22
26
  CharacterPortrait,
23
27
  CharacterPublicInfo,
24
28
  CharacterShip,
25
29
  CharacterSkillsSummary,
30
+ CloneHomeLocation,
26
31
  CorporationPublicInfo,
27
32
  IndustryJob,
33
+ JumpClone,
28
34
  JumpFatigue,
35
+ LoyaltyPoints,
29
36
  MailLabelsSummary,
30
37
  MarketOrder,
31
38
  ServerStatus,
32
39
  SkillQueueEntry,
33
40
  UniverseName,
34
41
  WalletBalance,
42
+ WalletJournalEntry,
35
43
  )
36
44
 
37
45
 
@@ -79,6 +87,8 @@ class EveOnlineClient:
79
87
  """
80
88
  self._auth = auth
81
89
  self._host = host
90
+ # ETag cache: maps cache_key -> (etag, cached_response_data)
91
+ self._etag_cache: dict[str, tuple[str, Any]] = {}
82
92
 
83
93
  if auth is not None:
84
94
  self._session = auth.websession
@@ -88,13 +98,81 @@ class EveOnlineClient:
88
98
  msg = "Either 'session' or 'auth' must be provided"
89
99
  raise EveOnlineError(msg)
90
100
 
101
+ def clear_etag_cache(self) -> None:
102
+ """Clear the ETag cache, forcing fresh responses on the next requests."""
103
+ self._etag_cache.clear()
104
+
91
105
  # -------------------------------------------------------------------------
92
106
  # Internal helpers
93
107
  # -------------------------------------------------------------------------
94
108
 
109
+ def _etag_key(self, path: str, params: dict[str, Any], authenticated: bool) -> str:
110
+ """Build a deterministic cache key for an ESI endpoint.
111
+
112
+ Args:
113
+ path: API path relative to the ESI base URL.
114
+ params: Query parameters (must already contain ``datasource``).
115
+ authenticated: Whether the request uses OAuth.
116
+
117
+ Returns:
118
+ A string key unique to this endpoint + parameter combination.
119
+ """
120
+ sorted_params = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
121
+ auth_prefix = "auth" if authenticated else "pub"
122
+ return f"{auth_prefix}:{path}?{sorted_params}"
123
+
124
+ def _build_etag_headers(self, method: str, cache_key: str) -> dict[str, str]:
125
+ """Return an ``If-None-Match`` header dict if a cached ETag exists.
126
+
127
+ Args:
128
+ method: HTTP method (only ``"GET"`` uses ETags).
129
+ cache_key: Cache key produced by :meth:`_etag_key`.
130
+
131
+ Returns:
132
+ A headers dict with ``If-None-Match`` set, or an empty dict.
133
+ """
134
+ if method == "GET" and cache_key in self._etag_cache:
135
+ return {"If-None-Match": self._etag_cache[cache_key][0]}
136
+ return {}
137
+
138
+ def _store_etag(self, method: str, cache_key: str, response: Any, data: Any) -> None:
139
+ """Cache the ETag from a successful GET response.
140
+
141
+ Args:
142
+ method: HTTP method (only ``"GET"`` responses are cached).
143
+ cache_key: Cache key produced by :meth:`_etag_key`.
144
+ response: The aiohttp response object.
145
+ data: Parsed JSON data to cache alongside the ETag.
146
+ """
147
+ etag = response.headers.get("ETag")
148
+ if method == "GET" and etag:
149
+ self._etag_cache[cache_key] = (etag, data)
150
+
151
+ @staticmethod
152
+ def _parse_retry_after(response: Any) -> int | None:
153
+ """Extract the Retry-After delay in seconds from a rate-limit response.
154
+
155
+ Args:
156
+ response: The aiohttp response object.
157
+
158
+ Returns:
159
+ The delay in seconds, or ``None`` if the header is absent or
160
+ cannot be parsed as an integer.
161
+ """
162
+ if (retry_after := response.headers.get("Retry-After")) is None:
163
+ return None
164
+ try:
165
+ return int(retry_after)
166
+ except ValueError:
167
+ return None
168
+
95
169
  async def _request(self, method: str, path: str, *, authenticated: bool = False, **kwargs: Any) -> Any:
96
170
  """Make a request to the ESI API.
97
171
 
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.
175
+
98
176
  Args:
99
177
  method: HTTP method.
100
178
  path: API path relative to ESI base URL.
@@ -114,18 +192,21 @@ class EveOnlineClient:
114
192
  """
115
193
  params: dict[str, Any] = dict(kwargs.pop("params", {}) or {})
116
194
  params.setdefault("datasource", ESI_DATASOURCE)
195
+ cache_key = self._etag_key(path, params, authenticated)
196
+ headers = {**dict(kwargs.pop("headers", {}) or {}), **self._build_etag_headers(method, cache_key)}
117
197
 
118
198
  try:
119
199
  if authenticated:
120
200
  if self._auth is None:
121
201
  msg = "Authentication required but no auth provider configured"
122
202
  raise EveOnlineAuthenticationError(msg)
123
- response = await self._auth.request(method, path, params=params, **kwargs)
203
+ response = await self._auth.request(method, path, params=params, headers=headers, **kwargs)
124
204
  else:
125
205
  response = await self._session.request(
126
206
  method,
127
207
  f"{self._host}/{path}",
128
208
  params=params,
209
+ headers=headers,
129
210
  **kwargs,
130
211
  )
131
212
  except EveOnlineError:
@@ -139,26 +220,46 @@ class EveOnlineClient:
139
220
  msg = f"Authentication failed ({response.status}): {text}"
140
221
  raise EveOnlineAuthenticationError(msg)
141
222
 
223
+ if response.status == 304:
224
+ # Not Modified — return the data we cached earlier if it still exists.
225
+ response.release()
226
+ if (cached := self._etag_cache.get(cache_key)) is None:
227
+ msg = (
228
+ f"Received 304 Not Modified from ESI, but no matching ETag "
229
+ f"cache entry exists for key {cache_key!r}."
230
+ )
231
+ raise EveOnlineError(msg)
232
+ return cached[1]
233
+
142
234
  if response.status == 404:
235
+ response.release()
143
236
  msg = f"Resource not found: {path}"
144
237
  raise EveOnlineNotFoundError(msg)
145
238
 
146
239
  if response.status in (420, 429):
147
- retry_after = response.headers.get("Retry-After")
148
- retry_after_seconds: int | None = None
149
- if retry_after is not None:
150
- try:
151
- retry_after_seconds = int(retry_after)
152
- except ValueError:
153
- retry_after_seconds = None
154
- raise EveOnlineRateLimitError(retry_after=retry_after_seconds)
240
+ response.release()
241
+ raise EveOnlineRateLimitError(retry_after=self._parse_retry_after(response))
155
242
 
156
243
  if response.status >= 400:
157
244
  text = await response.text()
158
245
  msg = f"ESI API error ({response.status}): {text}"
159
246
  raise EveOnlineError(msg)
160
247
 
161
- return await response.json()
248
+ data = await response.json()
249
+ self._store_etag(method, cache_key, response, data)
250
+ return data
251
+
252
+ @staticmethod
253
+ @overload
254
+ def _parse_datetime(value: str) -> datetime: ...
255
+
256
+ @staticmethod
257
+ @overload
258
+ def _parse_datetime(value: None) -> None: ...
259
+
260
+ @staticmethod
261
+ @overload
262
+ def _parse_datetime(value: str | None) -> datetime | None: ...
162
263
 
163
264
  @staticmethod
164
265
  def _parse_datetime(value: str | None) -> datetime | None:
@@ -169,8 +270,8 @@ class EveOnlineClient:
169
270
  ``None``.
170
271
 
171
272
  Returns:
172
- A timezone-aware :class:`~datetime.datetime`, or ``None`` when
173
- *value* is ``None``.
273
+ A :class:`~datetime.datetime` when *value* is a string, or
274
+ ``None`` when *value* is ``None``.
174
275
  """
175
276
  if value is None:
176
277
  return None
@@ -190,7 +291,7 @@ class EveOnlineClient:
190
291
  return ServerStatus(
191
292
  players=data["players"],
192
293
  server_version=data["server_version"],
193
- start_time=datetime.fromisoformat(data["start_time"]),
294
+ start_time=self._parse_datetime(data["start_time"]),
194
295
  vip=data.get("vip"),
195
296
  )
196
297
 
@@ -208,7 +309,7 @@ class EveOnlineClient:
208
309
  character_id=character_id,
209
310
  name=data["name"],
210
311
  corporation_id=data["corporation_id"],
211
- birthday=datetime.fromisoformat(data["birthday"]),
312
+ birthday=self._parse_datetime(data["birthday"]),
212
313
  gender=data["gender"],
213
314
  race_id=data["race_id"],
214
315
  bloodline_id=data["bloodline_id"],
@@ -438,8 +539,8 @@ class EveOnlineClient:
438
539
  job_id=entry["job_id"],
439
540
  activity_id=entry["activity_id"],
440
541
  status=entry["status"],
441
- start_date=datetime.fromisoformat(entry["start_date"]),
442
- end_date=datetime.fromisoformat(entry["end_date"]),
542
+ start_date=self._parse_datetime(entry["start_date"]),
543
+ end_date=self._parse_datetime(entry["end_date"]),
443
544
  blueprint_type_id=entry["blueprint_type_id"],
444
545
  output_location_id=entry["output_location_id"],
445
546
  runs=entry["runs"],
@@ -472,7 +573,7 @@ class EveOnlineClient:
472
573
  volume_total=entry["volume_total"],
473
574
  location_id=entry["location_id"],
474
575
  region_id=entry["region_id"],
475
- issued=datetime.fromisoformat(entry["issued"]),
576
+ issued=self._parse_datetime(entry["issued"]),
476
577
  duration=entry["duration"],
477
578
  range=entry["range"],
478
579
  min_volume=entry.get("min_volume"),
@@ -497,3 +598,175 @@ class EveOnlineClient:
497
598
  last_jump_date=self._parse_datetime(data.get("last_jump_date")),
498
599
  last_update_date=self._parse_datetime(data.get("last_update_date")),
499
600
  )
601
+
602
+ async def async_get_notifications(self, character_id: int) -> list[CharacterNotification]:
603
+ """Get a character's recent notifications.
604
+
605
+ Requires scope: ``esi-characters.read_notifications.v1``
606
+
607
+ Args:
608
+ character_id: The Eve Online character ID.
609
+
610
+ Returns:
611
+ List of CharacterNotification entries, newest first.
612
+ """
613
+ data = await self._request("GET", f"characters/{character_id}/notifications/", authenticated=True)
614
+ return [
615
+ CharacterNotification(
616
+ notification_id=entry["notification_id"],
617
+ sender_id=entry["sender_id"],
618
+ sender_type=entry["sender_type"],
619
+ type=entry["type"],
620
+ timestamp=self._parse_datetime(entry["timestamp"]),
621
+ is_read=entry.get("is_read"),
622
+ text=entry.get("text"),
623
+ )
624
+ for entry in data
625
+ ]
626
+
627
+ async def async_get_clones(self, character_id: int) -> CharacterClones:
628
+ """Get a character's clone information.
629
+
630
+ Requires scope: ``esi-clones.read_clones.v1``
631
+
632
+ Args:
633
+ character_id: The Eve Online character ID.
634
+
635
+ Returns:
636
+ CharacterClones with home location and jump clones.
637
+ """
638
+ data = await self._request("GET", f"characters/{character_id}/clones/", authenticated=True)
639
+
640
+ home_loc = data.get("home_location")
641
+ home_location: CloneHomeLocation | None = None
642
+ if home_loc:
643
+ home_location = CloneHomeLocation(
644
+ location_id=home_loc["location_id"],
645
+ location_type=home_loc["location_type"],
646
+ )
647
+
648
+ jump_clones = tuple(
649
+ JumpClone(
650
+ jump_clone_id=jc["jump_clone_id"],
651
+ location_id=jc["location_id"],
652
+ location_type=jc["location_type"],
653
+ implants=tuple(jc.get("implants", [])),
654
+ name=jc.get("name"),
655
+ )
656
+ for jc in data.get("jump_clones", [])
657
+ )
658
+
659
+ return CharacterClones(
660
+ home_location=home_location,
661
+ jump_clones=jump_clones,
662
+ last_clone_jump_date=self._parse_datetime(data.get("last_clone_jump_date")),
663
+ last_station_change_date=self._parse_datetime(data.get("last_station_change_date")),
664
+ )
665
+
666
+ async def async_get_implants(self, character_id: int) -> tuple[int, ...]:
667
+ """Get the type IDs of a character's active implants.
668
+
669
+ Requires scope: ``esi-clones.read_implants.v1``
670
+
671
+ Args:
672
+ character_id: The Eve Online character ID.
673
+
674
+ Returns:
675
+ Tuple of implant type IDs.
676
+ """
677
+ data = await self._request("GET", f"characters/{character_id}/implants/", authenticated=True)
678
+ return tuple(data)
679
+
680
+ async def async_get_wallet_journal(self, character_id: int) -> list[WalletJournalEntry]:
681
+ """Get a character's wallet journal (recent transactions).
682
+
683
+ Requires scope: ``esi-wallet.read_character_wallet.v1``
684
+
685
+ Args:
686
+ character_id: The Eve Online character ID.
687
+
688
+ Returns:
689
+ List of WalletJournalEntry entries, newest first.
690
+ """
691
+ data = await self._request("GET", f"characters/{character_id}/wallet/journal/", authenticated=True)
692
+ return [
693
+ WalletJournalEntry(
694
+ id=entry["id"],
695
+ date=self._parse_datetime(entry["date"]),
696
+ ref_type=entry["ref_type"],
697
+ description=entry.get("description", ""),
698
+ amount=entry.get("amount"),
699
+ balance=entry.get("balance"),
700
+ first_party_id=entry.get("first_party_id"),
701
+ second_party_id=entry.get("second_party_id"),
702
+ reason=entry.get("reason"),
703
+ )
704
+ for entry in data
705
+ ]
706
+
707
+ async def async_get_contacts(self, character_id: int) -> list[CharacterContact]:
708
+ """Get a character's contacts.
709
+
710
+ Requires scope: ``esi-characters.read_contacts.v1``
711
+
712
+ Args:
713
+ character_id: The Eve Online character ID.
714
+
715
+ Returns:
716
+ List of CharacterContact entries.
717
+ """
718
+ data = await self._request("GET", f"characters/{character_id}/contacts/", authenticated=True)
719
+ return [
720
+ CharacterContact(
721
+ contact_id=entry["contact_id"],
722
+ contact_type=entry["contact_type"],
723
+ standing=entry["standing"],
724
+ is_blocked=entry.get("is_blocked"),
725
+ is_watched=entry.get("is_watched"),
726
+ label_ids=tuple(entry["label_ids"]) if entry.get("label_ids") else None,
727
+ )
728
+ for entry in data
729
+ ]
730
+
731
+ async def async_get_calendar(self, character_id: int) -> list[CalendarEvent]:
732
+ """Get a character's upcoming calendar events.
733
+
734
+ Requires scope: ``esi-calendar.read_calendar_events.v1``
735
+
736
+ Args:
737
+ character_id: The Eve Online character ID.
738
+
739
+ Returns:
740
+ List of CalendarEvent entries.
741
+ """
742
+ data = await self._request("GET", f"characters/{character_id}/calendar/", authenticated=True)
743
+ return [
744
+ CalendarEvent(
745
+ event_id=entry["event_id"],
746
+ event_date=self._parse_datetime(entry["event_date"]),
747
+ title=entry["title"],
748
+ importance=entry.get("importance"),
749
+ event_response=entry.get("event_response"),
750
+ )
751
+ for entry in data
752
+ ]
753
+
754
+ async def async_get_loyalty_points(self, character_id: int) -> list[LoyaltyPoints]:
755
+ """Get a character's loyalty points per corporation.
756
+
757
+ Requires scope: ``esi-characters.read_loyalty.v1``
758
+
759
+ Args:
760
+ character_id: The Eve Online character ID.
761
+
762
+ Returns:
763
+ List of LoyaltyPoints entries.
764
+ """
765
+ data = await self._request("GET", f"characters/{character_id}/loyalty/points/", authenticated=True)
766
+ return [
767
+ LoyaltyPoints(
768
+ corporation_id=entry["corporation_id"],
769
+ loyalty_points=entry["loyalty_points"],
770
+ )
771
+ for entry in data
772
+ ]
@@ -28,6 +28,9 @@ SCOPE_READ_FATIGUE: Final = "esi-characters.read_fatigue.v1"
28
28
  SCOPE_READ_MAIL: Final = "esi-mail.read_mail.v1"
29
29
  SCOPE_READ_INDUSTRY_JOBS: Final = "esi-industry.read_character_jobs.v1"
30
30
  SCOPE_READ_MARKET_ORDERS: Final = "esi-markets.read_character_orders.v1"
31
+ SCOPE_READ_CONTACTS: Final = "esi-characters.read_contacts.v1"
32
+ SCOPE_READ_CALENDAR: Final = "esi-calendar.read_calendar_events.v1"
33
+ SCOPE_READ_LOYALTY: Final = "esi-characters.read_loyalty.v1"
31
34
 
32
35
  # Default scopes for a typical Home Assistant integration
33
36
  DEFAULT_SCOPES: Final = (