Habiticalib 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
habiticalib/lib.py ADDED
@@ -0,0 +1,1558 @@
1
+ """Modern asynchronous Python client library for the Habitica API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from http import HTTPStatus
7
+ from io import BytesIO
8
+ import logging
9
+ from typing import IO, TYPE_CHECKING, Self
10
+
11
+ from aiohttp import ClientError, ClientResponseError, ClientSession
12
+ from PIL import Image
13
+ from yarl import URL
14
+
15
+ from .const import ASSETS_URL, BACKER_ONLY_GEAR, DEFAULT_URL
16
+ from .exceptions import (
17
+ BadRequestError,
18
+ NotAuthorizedError,
19
+ NotFoundError,
20
+ TooManyRequestsError,
21
+ )
22
+ from .helpers import (
23
+ deserialize_task,
24
+ extract_user_styles,
25
+ get_user_agent,
26
+ get_x_client,
27
+ join_fields,
28
+ )
29
+ from .types import (
30
+ Attributes,
31
+ Direction,
32
+ HabiticaClass,
33
+ HabiticaClassSystemResponse,
34
+ HabiticaErrorResponse,
35
+ HabiticaLoginResponse,
36
+ HabiticaResponse,
37
+ HabiticaScoreResponse,
38
+ HabiticaStatsResponse,
39
+ HabiticaTagResponse,
40
+ HabiticaTagsResponse,
41
+ HabiticaTaskOrderResponse,
42
+ HabiticaTaskResponse,
43
+ HabiticaTasksResponse,
44
+ HabiticaUserExport,
45
+ HabiticaUserResponse,
46
+ Language,
47
+ Skill,
48
+ Task,
49
+ TaskFilter,
50
+ UserStyles,
51
+ )
52
+
53
+ if TYPE_CHECKING:
54
+ from datetime import datetime
55
+ from uuid import UUID
56
+
57
+ _LOGGER = logging.getLogger(__package__)
58
+
59
+
60
+ class Habitica:
61
+ """Modern asynchronous Python client library for the Habitica API."""
62
+
63
+ _close_session: bool = False
64
+ _cache_size = 32
65
+
66
+ def __init__(
67
+ self,
68
+ session: ClientSession | None = None,
69
+ api_user: str | None = None,
70
+ api_key: str | None = None,
71
+ url: str | None = None,
72
+ x_client: str | None = None,
73
+ ) -> None:
74
+ """Initialize the Habitica API client."""
75
+ client_headers = {"X-CLIENT": get_x_client(x_client)}
76
+ user_agent = {"User-Agent": get_user_agent()}
77
+ self._headers: dict[str, str] = {}
78
+
79
+ if session:
80
+ self._session = session
81
+ if "User-Agent" not in session.headers:
82
+ self._headers.update(user_agent)
83
+ self._headers.update(client_headers)
84
+ else:
85
+ self._session = ClientSession(
86
+ headers={**user_agent, **client_headers},
87
+ )
88
+ self._close_session = True
89
+
90
+ if api_user and api_key:
91
+ self._headers.update(
92
+ {
93
+ "X-API-USER": api_user,
94
+ "X-API-KEY": api_key,
95
+ }
96
+ )
97
+ elif api_user or api_key:
98
+ msg = "Both 'api_user' and 'api_key' must be provided together."
99
+ raise ValueError(msg)
100
+
101
+ self.url = URL(url if url else DEFAULT_URL)
102
+
103
+ self._assets_cache: dict[str, IO[bytes]] = {}
104
+ self._cache_order: list[str] = []
105
+
106
+ async def _request(self, method: str, url: URL, **kwargs) -> str:
107
+ """Handle API request."""
108
+ async with self._session.request(
109
+ method,
110
+ url,
111
+ headers=self._headers,
112
+ **kwargs,
113
+ ) as r:
114
+ if r.status == HTTPStatus.UNAUTHORIZED:
115
+ raise NotAuthorizedError(
116
+ HabiticaErrorResponse.from_json(await r.text()), r.headers
117
+ )
118
+ if r.status == HTTPStatus.NOT_FOUND:
119
+ raise NotFoundError(
120
+ HabiticaErrorResponse.from_json(await r.text()), r.headers
121
+ )
122
+ if r.status == HTTPStatus.BAD_REQUEST:
123
+ raise BadRequestError(
124
+ HabiticaErrorResponse.from_json(await r.text()), r.headers
125
+ )
126
+ if r.status == HTTPStatus.TOO_MANY_REQUESTS:
127
+ raise TooManyRequestsError(
128
+ HabiticaErrorResponse.from_json(await r.text()), r.headers
129
+ )
130
+ r.raise_for_status()
131
+ return await r.text()
132
+
133
+ async def __aenter__(self) -> Self:
134
+ """Async enter."""
135
+ return self
136
+
137
+ async def __aexit__(self, *exc_info: object) -> None:
138
+ """Async exit."""
139
+ if self._close_session:
140
+ await self._session.close()
141
+
142
+ async def login(
143
+ self,
144
+ username: str,
145
+ password: str,
146
+ ) -> HabiticaLoginResponse:
147
+ """Log in a user using their email or username and password.
148
+
149
+ This method sends a POST request to the Habitica API to authenticate
150
+ a user. Upon successful authentication, it updates the headers with
151
+ the user's API credentials for future requests.
152
+
153
+ Parameters
154
+ ----------
155
+ username : str
156
+ The user's email or username used for logging in.
157
+ password : str
158
+ The user's password for authentication.
159
+
160
+ Returns
161
+ -------
162
+ HabiticaLoginResponse
163
+ An object containing the user's authentication details, including
164
+ user ID and API token.
165
+
166
+ Raises
167
+ ------
168
+ NotAuthorizedError
169
+ If the login request fails due to incorrect username or password (HTTP 401).
170
+ aiohttp.ClientResponseError
171
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
172
+ aiohttp.ClientConnectionError
173
+ If the connection to the API fails.
174
+ aiohttp.ClientError
175
+ For any other exceptions raised by aiohttp during the request.
176
+ TimeoutError
177
+ If the connection times out.
178
+
179
+ Examples
180
+ --------
181
+ >>> response = await habitica.login("username_or_email", "password")
182
+ >>> response.data.id
183
+ 'user-id'
184
+ >>> response.data.apiToken
185
+ 'api-token'
186
+ """
187
+ url = self.url / "api/v3/user/auth/local/login"
188
+ data = {
189
+ "username": username,
190
+ "password": password,
191
+ }
192
+
193
+ response = HabiticaLoginResponse.from_json(
194
+ await self._request("post", url=url, data=data),
195
+ )
196
+ self._headers.update(
197
+ {
198
+ "X-API-USER": str(response.data.id),
199
+ "X-API-KEY": str(response.data.apiToken),
200
+ },
201
+ )
202
+ return response
203
+
204
+ async def get_user(
205
+ self,
206
+ user_fields: str | list[str] | None = None,
207
+ *,
208
+ anonymized: bool = False,
209
+ ) -> HabiticaUserResponse:
210
+ """Get the authenticated user's profile.
211
+
212
+ Parameters
213
+ ----------
214
+ user_fields : str | list[str] | None, optional
215
+ A string or a list of fields to include in the response.
216
+ If provided as a list, the fields will be joined with commas.
217
+ If None, the full user profile document is returned. Default is None.
218
+ anonymized : bool
219
+ When True, returns the user's data without: Authentication information,
220
+ New Messages/Invitations/Inbox, Profile, Purchased information,
221
+ Contributor information, Special items, Webhooks, Notifications.
222
+ (default is False)
223
+
224
+ Returns
225
+ -------
226
+ HabiticaUserResponse
227
+ A response object containing the result of the API call.
228
+
229
+ Raises
230
+ ------
231
+ NotAuthorizedError
232
+ If the API request is unauthorized (HTTP 401).
233
+ aiohttp.ClientResponseError
234
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
235
+ aiohttp.ClientConnectionError
236
+ If the connection to the API fails.
237
+ aiohttp.ClientError
238
+ For any other exceptions raised by aiohttp during the request.
239
+ TimeoutError
240
+ If the connection times out.
241
+
242
+ Examples
243
+ --------
244
+ >>> response = await habitica.get_user(user_fields="achievements,items.mounts")
245
+ >>> response.data # Access the returned data from the response
246
+ """
247
+ url = self.url / "api/v3/user"
248
+ params = {}
249
+
250
+ if user_fields:
251
+ params = {"userFields": join_fields(user_fields)}
252
+ if anonymized:
253
+ url = url / "anonymized"
254
+
255
+ return HabiticaUserResponse.from_json(
256
+ await self._request("get", url=url, params=params),
257
+ )
258
+
259
+ async def get_tasks(
260
+ self,
261
+ task_type: TaskFilter | None = None,
262
+ due_date: datetime | None = None,
263
+ ) -> HabiticaResponse:
264
+ """Get the authenticated user's tasks.
265
+
266
+ Parameters
267
+ ----------
268
+ task_type : TaskFilter | None
269
+ The type of task to retrieve, defined in TaskFilter enum.
270
+ If `None`, all task types will be retrieved (default is None).
271
+
272
+ due_date : datetime | None
273
+ Optional date to use for computing the nextDue field for each returned task.
274
+
275
+ Returns
276
+ -------
277
+ HabiticaResponse
278
+ A response object containing the user's tasks, parsed from the JSON
279
+ response.
280
+
281
+ Raises
282
+ ------
283
+ NotAuthorizedError
284
+ If the API request is unauthorized (HTTP 401).
285
+ aiohttp.ClientResponseError
286
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
287
+ aiohttp.ClientConnectionError
288
+ If the connection to the API fails.
289
+ aiohttp.ClientError
290
+ For any other exceptions raised by aiohttp during the request.
291
+ TimeoutError
292
+ If the connection times out.
293
+
294
+ Examples
295
+ --------
296
+ Retrieve all tasks:
297
+
298
+ >>> await habitica.get_tasks()
299
+
300
+ Retrieve only todos:
301
+
302
+ >>> await habitica.get_tasks(TaskType.HABITS)
303
+
304
+ Retrieve todos with a specific due date:
305
+
306
+ >>> await habitica.get_tasks(TaskType.HABITS, due_date=datetime(2024, 10, 15))
307
+ """
308
+ url = self.url / "api/v3/tasks/user"
309
+ params = {}
310
+
311
+ if task_type:
312
+ params.update({"type": task_type.value})
313
+ if due_date:
314
+ params.update({"dueDate": due_date.isoformat()})
315
+ return HabiticaTasksResponse.from_json(
316
+ await self._request("get", url=url, params=params),
317
+ )
318
+
319
+ async def get_task(self, task_id: UUID) -> HabiticaTaskResponse:
320
+ """Retrieve a specific task from the Habitica API.
321
+
322
+ This method sends a request to the Habitica API to retrieve a specific task
323
+ identified by the given `task_id`.
324
+
325
+ Parameters
326
+ ----------
327
+ task_id : UUID
328
+ The UUID of the task to retrieve.
329
+
330
+ Returns
331
+ -------
332
+ HabiticaTaskResponse
333
+ A response object containing the data for the specified task.
334
+
335
+ Raises
336
+ ------
337
+ aiohttp.ClientResponseError
338
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
339
+ aiohttp.ClientConnectionError
340
+ If the connection to the API fails.
341
+ aiohttp.ClientError
342
+ For any other exceptions raised by aiohttp during the request.
343
+ TimeoutError
344
+ If the connection times out.
345
+
346
+ Examples
347
+ --------
348
+ >>> task_id = UUID("12345678-1234-5678-1234-567812345678")
349
+ >>> task_response = await habitica.get_task(task_id)
350
+ >>> print(task_response.data) # Displays the retrieved task information
351
+ """
352
+ url = self.url / "api/v3/tasks" / str(task_id)
353
+
354
+ return HabiticaTaskResponse.from_json(
355
+ await self._request("get", url=url),
356
+ )
357
+
358
+ async def create_task(self, task: Task) -> HabiticaTaskResponse:
359
+ """Create a new task in the Habitica API.
360
+
361
+ This method sends a request to the Habitica API to create a new task
362
+ with the specified attributes defined in the `task` object.
363
+
364
+ Parameters
365
+ ----------
366
+ task : Task
367
+ An instance of the `Task` dataclass containing the attributes for the new task.
368
+
369
+ Returns
370
+ -------
371
+ HabiticaTaskResponse
372
+ A response object containing the data for the newly created task.
373
+
374
+ Raises
375
+ ------
376
+ aiohttp.ClientResponseError
377
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
378
+ aiohttp.ClientConnectionError
379
+ If the connection to the API fails.
380
+ aiohttp.ClientError
381
+ For any other exceptions raised by aiohttp during the request.
382
+ TimeoutError
383
+ If the connection times out.
384
+
385
+ Examples
386
+ --------
387
+ >>> new_task = Task(text="New Task", type=TaskType.TODO ...)
388
+ >>> create_response = await habitica.create_task(new_task)
389
+ >>> print(create_response.data) # Displays the created task information
390
+ """
391
+ url = self.url / "api/v3/tasks/user"
392
+
393
+ json = deserialize_task(task)
394
+
395
+ return HabiticaTaskResponse.from_json(
396
+ await self._request("post", url=url, json=json),
397
+ )
398
+
399
+ async def update_task(self, task_id: UUID, task: Task) -> HabiticaTaskResponse:
400
+ """Update an existing task in the Habitica API.
401
+
402
+ This method sends a request to the Habitica API to update the attributes
403
+ of a specific task identified by the given `task_id` with the specified
404
+ attributes defined in the `task` object.
405
+
406
+ Parameters
407
+ ----------
408
+ task_id : UUID
409
+ The UUID of the task to update.
410
+ task : Task
411
+ An instance of the `Task` dataclass containing the updated attributes for the task.
412
+
413
+ Returns
414
+ -------
415
+ HabiticaTaskResponse
416
+ A response object containing the data of the updated task.
417
+
418
+ Raises
419
+ ------
420
+ aiohttp.ClientResponseError
421
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
422
+ aiohttp.ClientConnectionError
423
+ If the connection to the API fails.
424
+ aiohttp.ClientError
425
+ For any other exceptions raised by aiohttp during the request.
426
+ TimeoutError
427
+ If the connection times out.
428
+
429
+ Examples
430
+ --------
431
+ >>> task_id = UUID("12345678-1234-5678-1234-567812345678")
432
+ >>> updated_task = Task(text="Updated Task", ...)
433
+ >>> update_response = await habitica.update_task(task_id, updated_task)
434
+ >>> print(update_response.data) # Displays the updated task information
435
+ """
436
+ url = self.url / "api/v3/tasks" / str(task_id)
437
+
438
+ json = deserialize_task(task)
439
+
440
+ return HabiticaTaskResponse.from_json(
441
+ await self._request("put", url=url, json=json),
442
+ )
443
+
444
+ async def delete_task(self, task_id: UUID) -> HabiticaResponse:
445
+ """Delete a specific task.
446
+
447
+ This method sends a request to the Habitica API to delete a specific task
448
+ identified by the given `task_id`.
449
+
450
+ Parameters
451
+ ----------
452
+ task_id : UUID
453
+ The UUID of the task to delete.
454
+
455
+ Returns
456
+ -------
457
+ HabiticaTaskResponse
458
+ A response object containing the data for the deleted task.
459
+
460
+ Raises
461
+ ------
462
+ aiohttp.ClientResponseError
463
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
464
+ aiohttp.ClientConnectionError
465
+ If the connection to the API fails.
466
+ aiohttp.ClientError
467
+ For any other exceptions raised by aiohttp during the request.
468
+ TimeoutError
469
+ If the connection times out.
470
+
471
+ Examples
472
+ --------
473
+ >>> task_id = UUID("12345678-1234-5678-1234-567812345678")
474
+ >>> delete_response = await habitica.delete_task(task_id)
475
+ >>> print(delete_response.success) # True if successfully deleted
476
+ """
477
+ url = self.url / "api/v3/tasks" / str(task_id)
478
+
479
+ return HabiticaResponse.from_json(
480
+ await self._request("delete", url=url),
481
+ )
482
+
483
+ async def reorder_task(self, task_id: UUID, to: int) -> HabiticaTaskOrderResponse:
484
+ """Reorder a user's tasks.
485
+
486
+ This method sends a request to the Habitica API to reorder a specific task,
487
+ identified by the given `task_id`, to a new position specified by `to`.
488
+
489
+ Parameters
490
+ ----------
491
+ task_id : UUID
492
+ The UUID of the task to reorder.
493
+ to : int
494
+ The new position to move the task to. Use 0 to move the task to the top,
495
+ and -1 to move it to the bottom of the list.
496
+
497
+ Returns
498
+ -------
499
+ HabiticaTaskOrderResponse
500
+ A response object containing a list of task IDs in the new sort order.
501
+
502
+ Raises
503
+ ------
504
+ aiohttp.ClientResponseError
505
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
506
+ aiohttp.ClientConnectionError
507
+ If the connection to the API fails.
508
+ aiohttp.ClientError
509
+ For any other exceptions raised by aiohttp during the request.
510
+ TimeoutError
511
+ If the connection times out.
512
+
513
+ Examples
514
+ --------
515
+ >>> task_id = UUID("12345678-1234-5678-1234-567812345678")
516
+ >>> reorder_response = await habitica.reorder_task(task_id, 2)
517
+ >>> print(reorder_response.data) # Displays a list of task IDs in the new order
518
+ """
519
+ url = self.url / "api/v3/tasks" / str(task_id) / "move/to" / str(to)
520
+
521
+ return HabiticaTaskOrderResponse.from_json(
522
+ await self._request("post", url=url),
523
+ )
524
+
525
+ async def get_user_export(self) -> HabiticaUserExport:
526
+ """Export the user's data from Habitica.
527
+
528
+ Notes
529
+ -----
530
+ This endpoint is part of Habitica's private API and intended for use
531
+ on the website only. It may change at any time without notice, and
532
+ backward compatibility is not guaranteed.
533
+
534
+ Returns
535
+ -------
536
+ HabiticaUserExportResponse:
537
+ The user's exported data, containing
538
+ information such as tasks, settings, and profile details.
539
+
540
+ Raises
541
+ ------
542
+ NotAuthorizedError
543
+ If the API request is unauthorized (HTTP 401).
544
+ aiohttp.ClientResponseError
545
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
546
+ aiohttp.ClientConnectionError
547
+ If the connection to the API fails.
548
+ aiohttp.ClientError
549
+ For any other exceptions raised by aiohttp during the request.
550
+ TimeoutError
551
+ If the connection times out.
552
+ """
553
+ url = self.url / "export/userdata.json"
554
+
555
+ return HabiticaUserExport.from_json(
556
+ await self._request("get", url=url),
557
+ )
558
+
559
+ async def get_content(
560
+ self,
561
+ language: Language | None = None,
562
+ ) -> HabiticaResponse:
563
+ """
564
+ Fetch game content from the Habitica API.
565
+
566
+ This method retrieves the game content, which includes information
567
+ such as available equipment, pets, mounts, and other game elements.
568
+
569
+ Parameters
570
+ ----------
571
+ language : Language | None
572
+ Optional language code to specify the localization of the content,
573
+ possible values are defined in the `Language` enum.
574
+ If not provided, it defaults to Language.EN or the authenticated
575
+ user's language.
576
+
577
+ Available languages include: BG, CS, DA, DE, EN, EN_PIRATE, EN_GB,
578
+ ES, ES_419, FR, HE, HU, ID, IT, JA, NL, PL, PT, PT_BR, RO, RU, SK,
579
+ SR, SV, UK, ZH, ZH_TW.
580
+
581
+
582
+ Returns
583
+ -------
584
+ HabiticaResponse:
585
+ A response object containing the game content in JSON format.
586
+
587
+ Raises
588
+ ------
589
+ aiohttp.ClientResponseError
590
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
591
+ aiohttp.ClientConnectionError
592
+ If the connection to the API fails.
593
+ aiohttp.ClientError
594
+ For any other exceptions raised by aiohttp during the request.
595
+ TimeoutError
596
+ If the connection times out.
597
+ """
598
+ url = self.url / "api/v3/content"
599
+ params = {}
600
+
601
+ if language:
602
+ params.update({"language": language.value})
603
+
604
+ return HabiticaResponse.from_json(
605
+ await self._request("get", url=url, params=params),
606
+ )
607
+
608
+ async def run_cron(self) -> HabiticaResponse:
609
+ """Run the Habitica cron.
610
+
611
+ This method triggers the cron process, which applies the daily reset for the authenticated user.
612
+ It assumes that the user has already confirmed their activity for the previous day
613
+ (i.e., checked off any Dailies they completed). The cron will immediately apply
614
+ damage for incomplete Dailies that were due and handle other daily resets.
615
+
616
+ Returns
617
+ -------
618
+ HabiticaResponse
619
+ A response containing an empty data object.
620
+
621
+ Raises
622
+ ------
623
+ NotAuthorizedError
624
+ If the API request is unauthorized (HTTP 401).
625
+ aiohttp.ClientResponseError
626
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
627
+ aiohttp.ClientConnectionError
628
+ If the connection to the API fails.
629
+ aiohttp.ClientError
630
+ For any other exceptions raised by aiohttp during the request.
631
+ TimeoutError
632
+ If the connection times out.
633
+ """
634
+ url = self.url / "api/v3/cron"
635
+ return HabiticaResponse.from_json(await self._request("post", url=url))
636
+
637
+ async def allocate_single_stat_point(
638
+ self,
639
+ stat: Attributes = Attributes.STR,
640
+ ) -> HabiticaStatsResponse:
641
+ """Allocate a single stat point to the specified attribute.
642
+
643
+ If no stat is specified, the default is 'str' (strength).
644
+ If the user does not have any stat points to allocate,
645
+ a NotAuthorized error is returned.
646
+
647
+ Parameters
648
+ ----------
649
+ stat : Attributes, optional
650
+ The stat to increase, either 'str', 'con', 'int', or 'per'. Defaults to 'str'.
651
+
652
+ Returns
653
+ -------
654
+ HabiticaAllocatStatPointsResponse
655
+ A response containing the updated user stats, including points, buffs, and training data.
656
+
657
+ Raises
658
+ ------
659
+ NotAuthorizedError
660
+ If the user does not have enough stat points to allocate.
661
+ aiohttp.ClientResponseError
662
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
663
+ aiohttp.ClientConnectionError
664
+ If the connection to the API fails.
665
+ aiohttp.ClientError
666
+ For any other exceptions raised by aiohttp during the request.
667
+ TimeoutError
668
+ If the connection times out.
669
+
670
+ Examples
671
+ --------
672
+ Allocate a single stat point to Intelligence:
673
+ >>> await habitica.allocate_single_stat_point(stat=Attributes.INT)
674
+
675
+ Allocate a single stat point to Strength (default):
676
+ >>> await habitica.allocate_single_stat_point()
677
+ """
678
+ url = self.url / "api/v3/user/allocate"
679
+ params = {"stat": stat}
680
+
681
+ return HabiticaStatsResponse.from_json(
682
+ await self._request("post", url=url, params=params),
683
+ )
684
+
685
+ async def allocate_stat_points(self) -> HabiticaStatsResponse:
686
+ """Allocate all available stat points using the user's chosen automatic allocation method.
687
+
688
+ This method uses the user's configured allocation strategy to distribute any unassigned
689
+ stat points. If the user has no specific method defined, all points are allocated to
690
+ Strength (STR). If there are no points to allocate, the method will still return a
691
+ success response. The response includes updated user stats, including health, mana,
692
+ experience, and gold.
693
+
694
+ Returns
695
+ -------
696
+ HabiticaAllocatStatPointsResponse
697
+ A response containing the updated user stats, including points, buffs, and training data.
698
+
699
+ Raises
700
+ ------
701
+ NotAuthorizedError
702
+ If the user does not have enough stat points to allocate.
703
+ aiohttp.ClientResponseError
704
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
705
+ aiohttp.ClientConnectionError
706
+ If the connection to the API fails.
707
+ aiohttp.ClientError
708
+ For any other exceptions raised by aiohttp during the request.
709
+ TimeoutError
710
+ If the connection times out.
711
+ """
712
+ url = self.url / "api/v3/user/allocate-now"
713
+
714
+ return HabiticaStatsResponse.from_json(
715
+ await self._request("post", url=url),
716
+ )
717
+
718
+ async def allocate_bulk_stat_points(
719
+ self,
720
+ int_points: int = 0,
721
+ str_points: int = 0,
722
+ con_points: int = 0,
723
+ per_points: int = 0,
724
+ ) -> HabiticaStatsResponse:
725
+ """Allocate multiple stat points manually to different attributes.
726
+
727
+ This method allows the user to manually allocate their available stat points to the
728
+ desired attributes. The number of points to allocate for each attribute must be provided
729
+ as parameters. The request will fail if the user does not have enough available points.
730
+
731
+ Parameters
732
+ ----------
733
+ int_points : int, optional
734
+ The number of points to allocate to Intelligence (default is 0).
735
+ str_points : int, optional
736
+ The number of points to allocate to Strength (default is 0).
737
+ con_points : int, optional
738
+ The number of points to allocate to Constitution (default is 0).
739
+ per_points : int, optional
740
+ The number of points to allocate to Perception (default is 0).
741
+
742
+ Returns
743
+ -------
744
+ HabiticaAllocatStatPointsResponse
745
+ A response containing the updated user stats, including points, buffs, and training data.
746
+
747
+ Raises
748
+ ------
749
+ NotAuthorizedError
750
+ If the user does not have enough stat points to allocate.
751
+ aiohttp.ClientResponseError
752
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
753
+ aiohttp.ClientConnectionError
754
+ If the connection to the API fails.
755
+ aiohttp.ClientError
756
+ For any other exceptions raised by aiohttp during the request.
757
+ TimeoutError
758
+ If the connection times out.
759
+
760
+ Examples
761
+ --------
762
+ Allocate 2 points to INT and 1 point to STR:
763
+ >>> await allocate_bulk_stat_points(int_points=2, str_points=1)
764
+ """
765
+ url = self.url / "api/v3/user/allocate-bulk"
766
+ json = {
767
+ "stats": {
768
+ "int": int_points,
769
+ "str": str_points,
770
+ "con": con_points,
771
+ "per": per_points,
772
+ },
773
+ }
774
+
775
+ return HabiticaStatsResponse.from_json(
776
+ await self._request("post", url=url, json=json),
777
+ )
778
+
779
+ async def buy_health_potion(self) -> HabiticaStatsResponse:
780
+ """Purchase a health potion for the authenticated user.
781
+
782
+ If the user has enough gold and their health is not already full,
783
+ this method allows them to buy a health potion to restore health.
784
+ The user's current stats will be returned upon a successful purchase.
785
+
786
+ Returns
787
+ -------
788
+ HabiticaStatResponse
789
+ A response object containing the user's updated stats and a success message.
790
+
791
+ Raises
792
+ ------
793
+ NotAuthorizedError
794
+ If the user does not have enough gold or if the user's health is already at maximum.
795
+ aiohttp.ClientResponseError
796
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
797
+ aiohttp.ClientConnectionError
798
+ If the connection to the API fails.
799
+ aiohttp.ClientError
800
+ For any other exceptions raised by aiohttp during the request.
801
+ TimeoutError
802
+ If the connection times out.
803
+ """
804
+ url = self.url / "api/v3/user/buy-health-potion"
805
+
806
+ return HabiticaStatsResponse.from_json(
807
+ await self._request("post", url=url),
808
+ )
809
+
810
+ async def cast_skill(
811
+ self,
812
+ skill: Skill,
813
+ target_id: UUID | None = None,
814
+ ) -> HabiticaUserResponse:
815
+ """Cast a skill (spell) in Habitica, optionally targeting a specific user, task or party.
816
+
817
+ Parameters
818
+ ----------
819
+ skill : Skill
820
+ The skill (or spell) to be cast. This should be a valid `Skill` enum value.
821
+ target_id : UUID, optional
822
+ The unique identifier of the target for the skill. If the skill does not require a target,
823
+ this can be omitted.
824
+
825
+ Returns
826
+ -------
827
+ HabiticaStatResponse
828
+ A response object containing the user's updated stats and a success message.
829
+
830
+ Raises
831
+ ------
832
+ NotAuthorizedError
833
+ If the user does not have enough mana.
834
+ NotFoundError
835
+ The specified task, party or user could not be found
836
+ aiohttp.ClientResponseError
837
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
838
+ aiohttp.ClientConnectionError
839
+ If the connection to the API fails.
840
+ aiohttp.ClientError
841
+ For any other exceptions raised by aiohttp during the request.
842
+ TimeoutError
843
+ If the connection times out.
844
+ """
845
+ url = self.url / "api/v3/user/class/cast" / skill
846
+ params = {}
847
+
848
+ if target_id:
849
+ params.update({"targetId": str(target_id)})
850
+ return HabiticaUserResponse.from_json(
851
+ await self._request("post", url=url, json=params),
852
+ )
853
+
854
+ async def toggle_sleep(
855
+ self,
856
+ ) -> HabiticaResponse:
857
+ """Toggles the user's sleep mode in Habitica.
858
+
859
+ Returns
860
+ -------
861
+ HabiticaResponse
862
+ A response object containing the result of the sleep mode toggle,
863
+ and the new sleep state (True if sleeping, False if not).
864
+
865
+ Raises
866
+ ------
867
+ aiohttp.ClientResponseError
868
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
869
+ aiohttp.ClientConnectionError
870
+ If the connection to the API fails.
871
+ aiohttp.ClientError
872
+ For any other exceptions raised by aiohttp during the request.
873
+ TimeoutError
874
+ If the connection times out.
875
+ """
876
+ url = self.url / "api/v3/user/sleep"
877
+
878
+ return HabiticaResponse.from_json(await self._request("post", url=url))
879
+
880
+ async def revive(
881
+ self,
882
+ ) -> HabiticaResponse:
883
+ """Revive user from death.
884
+
885
+ Raises
886
+ ------
887
+ NotAuthorizedError
888
+ If the player is not dead and therefore cannot be revived.
889
+ aiohttp.ClientResponseError
890
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
891
+ aiohttp.ClientConnectionError
892
+ If the connection to the API fails.
893
+ aiohttp.ClientError
894
+ For any other exceptions raised by aiohttp during the request.
895
+ TimeoutError
896
+ If the connection times out.
897
+ """
898
+ url = self.url / "api/v3/user/revive"
899
+
900
+ return HabiticaResponse.from_json(await self._request("post", url=url))
901
+
902
+ async def change_class(self, Class: HabiticaClass) -> HabiticaClassSystemResponse: # noqa: N803
903
+ """Change the user's class in Habitica.
904
+
905
+ This method sends a request to the Habitica API to change the user's class
906
+ (e.g., warrior, mage, rogue, healer) to the specified class.
907
+
908
+ Parameters
909
+ ----------
910
+ Class : Class
911
+ An instance of the `Class` enum representing the new class to assign to the user.
912
+
913
+ Returns
914
+ -------
915
+ HabiticaClassSystemResponse
916
+ A response object containing stats, flags, items and preferences.
917
+
918
+ Raises
919
+ ------
920
+ NotAuthorizedError
921
+ If the player cannot change class at this time (e.g., conditions not met).
922
+ aiohttp.ClientResponseError
923
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
924
+ aiohttp.ClientConnectionError
925
+ If the connection to the API fails.
926
+ aiohttp.ClientError
927
+ For any other exceptions raised by aiohttp during the request.
928
+ TimeoutError
929
+ If the connection times out.
930
+
931
+ Examples
932
+ --------
933
+ >>> new_class = HabiticaClass.WARRIOR
934
+ >>> change_response = await habitica.change_class(new_class)
935
+ >>> print(change_response.data.stats) # Displays the user's stats after class change
936
+ """
937
+ url = self.url / "api/v3/user/change-class"
938
+ params = {"class": Class.value}
939
+
940
+ return HabiticaClassSystemResponse.from_json(
941
+ await self._request("post", url=url, params=params)
942
+ )
943
+
944
+ async def disable_classes(self) -> HabiticaClassSystemResponse:
945
+ """Disable the class system for the user in Habitica.
946
+
947
+ This method sends a request to the Habitica API to disable the class system for the user.
948
+
949
+ Returns
950
+ -------
951
+ HabiticaClassSystemResponse
952
+ A response object containing stats, flags, and preferences.
953
+
954
+ Raises
955
+ ------
956
+ aiohttp.ClientResponseError
957
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
958
+ aiohttp.ClientConnectionError
959
+ If the connection to the API fails.
960
+ aiohttp.ClientError
961
+ For any other exceptions raised by aiohttp during the request.
962
+ TimeoutError
963
+ If the connection times out.
964
+
965
+ Examples
966
+ --------
967
+ >>> disable_response = await habitica.disable_classes()
968
+ >>> print(disable_response.data.stats) # Displays the user's stats after disabling the class system
969
+ """
970
+ url = self.url / "api/v3/user/disable-classes"
971
+
972
+ return HabiticaClassSystemResponse.from_json(
973
+ await self._request("post", url=url)
974
+ )
975
+
976
+ async def delete_completed_todos(self) -> HabiticaResponse:
977
+ """Delete all completed to-dos from the user's task list.
978
+
979
+ This method sends a request to the Habitica API to delete all completed to-dos
980
+ from the user's task list.
981
+
982
+ Returns
983
+ -------
984
+ HabiticaResponse
985
+ A response object containing an empty data object.
986
+
987
+ Raises
988
+ ------
989
+ aiohttp.ClientResponseError
990
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
991
+ aiohttp.ClientConnectionError
992
+ If the connection to the API fails.
993
+ aiohttp.ClientError
994
+ For any other exceptions raised by aiohttp during the request.
995
+ TimeoutError
996
+ If the connection times out.
997
+
998
+ Examples
999
+ --------
1000
+ >>> delete_response = await habitica.delete_completed_todos()
1001
+ >>> print(delete_response.success) # True if successfully cleared completed to-dos
1002
+ """
1003
+ url = self.url / "api/v3/tasks/clearCompletedTodos"
1004
+
1005
+ return HabiticaClassSystemResponse.from_json(
1006
+ await self._request("post", url=url)
1007
+ )
1008
+
1009
+ async def update_score(
1010
+ self,
1011
+ task_id: UUID | str,
1012
+ direction: Direction,
1013
+ ) -> HabiticaScoreResponse:
1014
+ """Submit a score update for a task in Habitica.
1015
+
1016
+ This method allows scoring a task based on its type:
1017
+ - For Dailies and To-Dos: Marks the task as complete or incomplete.
1018
+ - For Habits: Increases the positive or negative habit score.
1019
+ - For Rewards: Buys the reward
1020
+
1021
+ Parameters
1022
+ ----------
1023
+ task_id : UUID | str
1024
+ The ID of the task or an alias (e.g., a slug) associated with the task.
1025
+ direction : Direction
1026
+ The direction to score the task, either `Direction.UP` to increase or complete the task,
1027
+ or `Direction.DOWN` to decrease or uncomplete it..
1028
+
1029
+ Returns
1030
+ -------
1031
+ HabiticaScoreResponse
1032
+ A response object that contains the updated stats and item drop.
1033
+
1034
+ Raises
1035
+ ------
1036
+ aiohttp.ClientResponseError
1037
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
1038
+ aiohttp.ClientConnectionError
1039
+ If the connection to the API fails.
1040
+ aiohttp.ClientError
1041
+ For any other exceptions raised by aiohttp during the request.
1042
+ TimeoutError
1043
+ If the connection times out.
1044
+ """
1045
+ url = self.url / "api/v3/tasks" / str(task_id) / "score" / direction.value
1046
+
1047
+ return HabiticaScoreResponse.from_json(
1048
+ await self._request("post", url=url),
1049
+ )
1050
+
1051
+ async def get_tags(self) -> HabiticaTagsResponse:
1052
+ """Retrieve a user's tags from the Habitica API.
1053
+
1054
+ This method sends a POST request to the Habitica API to fetch the tags
1055
+ associated with the user's account.
1056
+
1057
+ Returns
1058
+ -------
1059
+ HabiticaTagsResponse
1060
+ A response object containing the user's tags.
1061
+
1062
+ Raises
1063
+ ------
1064
+ aiohttp.ClientResponseError
1065
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
1066
+ aiohttp.ClientConnectionError
1067
+ If the connection to the API fails.
1068
+ aiohttp.ClientError
1069
+ For any other exceptions raised by aiohttp during the request.
1070
+ TimeoutError
1071
+ If the connection times out.
1072
+
1073
+ Examples
1074
+ --------
1075
+ >>> tags_response = await habitica.get_tags()
1076
+ >>> print(tags_response.data)
1077
+ """
1078
+ url = self.url / "api/v3/tags"
1079
+
1080
+ return HabiticaTagsResponse.from_json(
1081
+ await self._request("get", url=url),
1082
+ )
1083
+
1084
+ async def get_tag(self, tag_id: UUID) -> HabiticaTagResponse:
1085
+ """Retrieve a specific tag from the Habitica API.
1086
+
1087
+ This method sends a request to the Habitica API to retrieve a specific tag
1088
+ identified by the given `tag_id`.
1089
+
1090
+ Parameters
1091
+ ----------
1092
+ tag_id : UUID
1093
+ The UUID of the tag to retrieve.
1094
+
1095
+ Returns
1096
+ -------
1097
+ HabiticaTagResponse
1098
+ A response object containing the data for the specified tag.
1099
+
1100
+ Raises
1101
+ ------
1102
+ aiohttp.ClientResponseError
1103
+ For other HTTP-related errors raised by aiohttp, such as HTTP 400 or 500.
1104
+ aiohttp.ClientConnectionError
1105
+ If the connection to the API fails.
1106
+ aiohttp.ClientError
1107
+ For any other exceptions raised by aiohttp during the request.
1108
+ TimeoutError
1109
+ If the connection times out.
1110
+
1111
+ Examples
1112
+ --------
1113
+ >>> tag_response = await habitica.get_tag()
1114
+ >>> print(tag_response.data)
1115
+ """
1116
+ url = self.url / "api/v3/tags" / str(tag_id)
1117
+
1118
+ return HabiticaTagResponse.from_json(
1119
+ await self._request("get", url=url),
1120
+ )
1121
+
1122
+ async def delete_tag(self, tag_id: UUID) -> HabiticaResponse:
1123
+ """Delete a user's tag from the Habitica API.
1124
+
1125
+ This method sends a request to the Habitica API to delete a specific
1126
+ tag identified by the given `tag_id`.
1127
+
1128
+ Parameters
1129
+ ----------
1130
+ tag_id : UUID
1131
+ The UUID of the tag to delete.
1132
+
1133
+ Returns
1134
+ -------
1135
+ HabiticaTagResponse
1136
+ A response object with an empty data object.
1137
+
1138
+ Raises
1139
+ ------
1140
+ aiohttp.ClientResponseError
1141
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
1142
+ aiohttp.ClientConnectionError
1143
+ If the connection to the API fails.
1144
+ aiohttp.ClientError
1145
+ For any other exceptions raised by aiohttp during the request.
1146
+ TimeoutError
1147
+ If the connection times out.
1148
+
1149
+ Examples
1150
+ --------
1151
+ >>> tag_id = UUID("12345678-1234-5678-1234-567812345678")
1152
+ >>> delete_response = await habitica.delete_tag(tag_id)
1153
+ >>> print(delete_response.success) # True if successfully deleted
1154
+ """
1155
+ url = self.url / "api/v3/tags" / str(tag_id)
1156
+
1157
+ return HabiticaTagResponse.from_json(
1158
+ await self._request("delete", url=url),
1159
+ )
1160
+
1161
+ async def create_tag(self, name: str) -> HabiticaTagResponse:
1162
+ """Create a new tag in the Habitica API.
1163
+
1164
+ This method sends a request to the Habitica API to create a new tag
1165
+ with the specified `name`.
1166
+
1167
+ Parameters
1168
+ ----------
1169
+ name : str
1170
+ The name to assign to the new tag.
1171
+
1172
+ Returns
1173
+ -------
1174
+ HabiticaTagResponse
1175
+ A response object containing a dictionary with the newly
1176
+ created tag information, including the ID of the new tag.
1177
+
1178
+ Raises
1179
+ ------
1180
+ aiohttp.ClientResponseError
1181
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
1182
+ aiohttp.ClientConnectionError
1183
+ If the connection to the API fails.
1184
+ aiohttp.ClientError
1185
+ For any other exceptions raised by aiohttp during the request.
1186
+ TimeoutError
1187
+ If the connection times out.
1188
+
1189
+ Examples
1190
+ --------
1191
+ >>> new_tag_response = await habitica.create_tag("New Tag Name")
1192
+ >>> print(new_tag_response.data.id) # Displays the id of the new tag
1193
+ """
1194
+ url = self.url / "api/v3/tags"
1195
+ json = {"name": name}
1196
+ return HabiticaTagResponse.from_json(
1197
+ await self._request("post", url=url, json=json),
1198
+ )
1199
+
1200
+ async def update_tag(self, tag_id: UUID, name: str) -> HabiticaTagResponse:
1201
+ """Update a user's tag in the Habitica API.
1202
+
1203
+ This method sends a request to the Habitica API to update the name of a
1204
+ specific tag, identified by the given `tag_id`.
1205
+
1206
+ Parameters
1207
+ ----------
1208
+ tag_id : UUID
1209
+ The UUID of the tag to update.
1210
+ name : str
1211
+ The new name to assign to the tag.
1212
+
1213
+ Returns
1214
+ -------
1215
+ HabiticaTagResponse
1216
+ A response object containing a dictionary with the updated tag information.
1217
+
1218
+ Raises
1219
+ ------
1220
+ aiohttp.ClientResponseError
1221
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
1222
+ aiohttp.ClientConnectionError
1223
+ If the connection to the API fails.
1224
+ aiohttp.ClientError
1225
+ For any other exceptions raised by aiohttp during the request.
1226
+ TimeoutError
1227
+ If the connection times out.
1228
+
1229
+ Examples
1230
+ --------
1231
+ >>> tag_id = UUID("12345678-1234-5678-1234-567812345678")
1232
+ >>> update_response = await habitica.update_tag(tag_id, "New Tag Name")
1233
+ >>> print(update_response.data) # Displays the updated tag information
1234
+ """
1235
+ url = self.url / "api/v3/tags" / str(tag_id)
1236
+ json = {"name": name}
1237
+ return HabiticaTagResponse.from_json(
1238
+ await self._request("put", url=url, json=json),
1239
+ )
1240
+
1241
+ async def reorder_tag(self, tag_id: UUID, to: int) -> HabiticaResponse:
1242
+ """Reorder a user's tag in the Habitica API.
1243
+
1244
+ This method sends a request to the Habitica API to reorder a specific tag,
1245
+ identified by the given `tag_id`, to a new position specified by `to`.
1246
+
1247
+ Parameters
1248
+ ----------
1249
+ tag_id : UUID
1250
+ The UUID of the tag to reorder.
1251
+ to : int
1252
+ The new position to move the tag to (starting from 0).
1253
+
1254
+ Returns
1255
+ -------
1256
+ HabiticaResponse
1257
+ A response object containing an empty data object.
1258
+
1259
+ Raises
1260
+ ------
1261
+ aiohttp.ClientResponseError
1262
+ For HTTP-related errors, such as HTTP 400 or 500 response status.
1263
+ aiohttp.ClientConnectionError
1264
+ If the connection to the API fails.
1265
+ aiohttp.ClientError
1266
+ For any other exceptions raised by aiohttp during the request.
1267
+ TimeoutError
1268
+ If the connection times out.
1269
+
1270
+ Examples
1271
+ --------
1272
+ >>> tag_id = UUID("12345678-1234-5678-1234-567812345678")
1273
+ >>> reorder_response = await habitica.reorder_tag(tag_id, 2)
1274
+ >>> print(reorder_response.success) # True if reorder is successful
1275
+ """
1276
+ url = self.url / "api/v3/reorder-tags"
1277
+ json = {"tagId": str(tag_id), "to": to}
1278
+
1279
+ return HabiticaResponse.from_json(
1280
+ await self._request("post", url=url, json=json),
1281
+ )
1282
+
1283
+ def _cache_asset(self, asset: str, asset_data: IO[bytes]) -> None:
1284
+ """Cache an asset and maintain the cache size limit by removing older entries.
1285
+
1286
+ This method stores the given asset in the in-memory cache. If the cache exceeds the
1287
+ specified limit (`self._cache_size`), the oldest cached asset is removed.
1288
+ The cache is updated with the new asset, and its order of insertion is tracked.
1289
+
1290
+ Parameters
1291
+ ----------
1292
+ asset : str
1293
+ The identifier or name of the asset to be cached.
1294
+ asset_data : IO[bytes]
1295
+ The asset data as an I/O stream of bytes to be cached.
1296
+
1297
+ Notes
1298
+ -----
1299
+ If `self._cache_size` is zero or `None`, the caching operation is skipped. When
1300
+ the cache limit is exceeded, the least recently added asset is evicted to
1301
+ make space for the new asset.
1302
+ """
1303
+ if not self._cache_size:
1304
+ return
1305
+ if len(self._cache_order) > self._cache_size:
1306
+ del self._assets_cache[self._cache_order.pop(0)]
1307
+ self._assets_cache[asset] = asset_data
1308
+ self._cache_order.append(asset)
1309
+
1310
+ async def paste_image(
1311
+ self,
1312
+ image: Image.Image,
1313
+ asset: str,
1314
+ position: tuple[int, int],
1315
+ ) -> None:
1316
+ """Fetch asset and paste it onto the base image at specified position.
1317
+
1318
+ Parameters
1319
+ ----------
1320
+ image : Image
1321
+ The base image onto which the asset will be pasted.
1322
+ asset : str
1323
+ The name of the image asset to fetch (e.g., "hair_bangs_1_red.png").
1324
+ If no file extension is provided, `.png` will be added by default.
1325
+ position : tuple of int
1326
+ The (x, y) position coordinates where the asset will be pasted on the base image.
1327
+
1328
+ Returns
1329
+ -------
1330
+ None
1331
+ """
1332
+ url = URL(ASSETS_URL) / f"{asset}"
1333
+ if not url.suffix:
1334
+ url = url.with_suffix(".png")
1335
+ try:
1336
+ if not (asset_data := self._assets_cache.get(asset)):
1337
+ async with self._session.get(url) as r:
1338
+ r.raise_for_status()
1339
+ asset_data = BytesIO(await r.read())
1340
+ self._cache_asset(asset, asset_data)
1341
+ except ClientResponseError as e:
1342
+ _LOGGER.exception(
1343
+ "Failed to load %s.png due to error [%s]: %s",
1344
+ asset,
1345
+ e.status,
1346
+ e.message,
1347
+ )
1348
+ except ClientError:
1349
+ _LOGGER.exception(
1350
+ "Failed to load %s.png due to a request error",
1351
+ asset,
1352
+ )
1353
+ else:
1354
+ fetched_image = Image.open(asset_data).convert("RGBA")
1355
+ image.paste(fetched_image, position, fetched_image)
1356
+
1357
+ async def generate_avatar( # noqa: PLR0912, PLR0915
1358
+ self,
1359
+ fp: str | IO[bytes],
1360
+ user_styles: UserStyles | None = None,
1361
+ fmt: str | None = None,
1362
+ ) -> UserStyles:
1363
+ """Generate an avatar image based on the provided user styles or fetched user data.
1364
+
1365
+ If no `user_styles` object is provided, the method retrieves user preferences, items, and stats
1366
+ for the authenticated user and builds the avatar accordingly. The base image is initialized
1367
+ as a transparent RGBA image of size (141, 147). A mount offset is applied based on the user's
1368
+ current mount status.
1369
+
1370
+ Note:
1371
+ Animated avatars are not supported, animated gear and mounts will
1372
+ be pasted without animation, showing only the first sprite.
1373
+
1374
+
1375
+ Parameters
1376
+ ----------
1377
+ fp : str or IO[bytes]
1378
+ The file path or a bytes buffer to store or modify the avatar image.
1379
+ user_styles : UserStyles, optional
1380
+ The user style preferences, items, and stats. If not provided, the method will fetch
1381
+ this data.
1382
+ fmt : str
1383
+ If a file object is used instead of a filename, the format
1384
+ must be speciefied (e.g. "png").
1385
+
1386
+ Returns
1387
+ -------
1388
+ UserStyles
1389
+ The user styles used to generate the avatar.
1390
+
1391
+ Examples
1392
+ --------
1393
+ Using a bytes buffer:
1394
+ >>> avatar = BytesIO()
1395
+ >>> await habitica generate_avatar(avatar, fmt='png')
1396
+
1397
+ Using a file path:
1398
+ >>> await habitica.generate_avatar("/path/to/image/avatar.png")
1399
+ """
1400
+ if not user_styles:
1401
+ user_styles = extract_user_styles(
1402
+ await self.get_user(user_fields=["preferences", "items", "stats"]),
1403
+ )
1404
+ preferences = user_styles.preferences
1405
+ items = user_styles.items
1406
+ stats = user_styles.stats
1407
+ mount_offset_y = 0 if items.currentMount else 24
1408
+
1409
+ # Initializing the base image
1410
+ image = Image.new("RGBA", (141, 147), (255, 0, 0, 0))
1411
+
1412
+ async def paste_gear(gear_type: str) -> None:
1413
+ """Fetch and paste gear from equipped or costume gear sets."""
1414
+ gear_set = (
1415
+ items.gear.costume if preferences.costume else items.gear.equipped
1416
+ )
1417
+ gear = getattr(gear_set, gear_type)
1418
+ if gear and gear != f"{gear_type}_base_0":
1419
+ # 2019 Kickstarter gear doesn't follow name conventions
1420
+ if special_ks2019 := BACKER_ONLY_GEAR.get(gear):
1421
+ gear = special_ks2019
1422
+ # armor has slim and broad size options
1423
+ elif gear_type == "armor":
1424
+ gear = f"{preferences.size}_{gear}"
1425
+ await self.paste_image(image, gear, (24, mount_offset_y))
1426
+
1427
+ # fetch and paste the background
1428
+ if preferences.background:
1429
+ await self.paste_image(
1430
+ image,
1431
+ f"background_{preferences.background}",
1432
+ (0, 0),
1433
+ )
1434
+
1435
+ # Fetch and paste the mount body
1436
+ if items.currentMount:
1437
+ await self.paste_image(
1438
+ image,
1439
+ f"Mount_Body_{items.currentMount}",
1440
+ (24, 18),
1441
+ )
1442
+
1443
+ # Fetch and paste avatars for visual buffs
1444
+ if (
1445
+ stats.buffs.seafoam
1446
+ or stats.buffs.shinySeed
1447
+ or stats.buffs.snowball
1448
+ or stats.buffs.spookySparkles
1449
+ ):
1450
+ if stats.buffs.spookySparkles:
1451
+ await self.paste_image(image, "ghost", (24, mount_offset_y))
1452
+ if stats.buffs.shinySeed:
1453
+ await self.paste_image(
1454
+ image,
1455
+ f"avatar_snowball_{stats.Class}",
1456
+ (24, mount_offset_y),
1457
+ )
1458
+ if stats.buffs.shinySeed:
1459
+ await self.paste_image(
1460
+ image,
1461
+ f"avatar_floral_{stats.Class}",
1462
+ (24, mount_offset_y),
1463
+ )
1464
+ if stats.buffs.seafoam:
1465
+ await self.paste_image(
1466
+ image,
1467
+ "seafoam_star",
1468
+ (24, mount_offset_y),
1469
+ )
1470
+
1471
+ # Fetch and paste the hairflower
1472
+ if preferences.hair.flower:
1473
+ await self.paste_image(
1474
+ image,
1475
+ f"hair_flower_{preferences.hair.flower}",
1476
+ (24, mount_offset_y),
1477
+ )
1478
+
1479
+ else:
1480
+ # Fetch and paste the chair
1481
+ if preferences.chair and preferences.chair != "none":
1482
+ await self.paste_image(
1483
+ image,
1484
+ f"chair_{preferences.chair}",
1485
+ (24, 0),
1486
+ )
1487
+
1488
+ # Fetch and paste the back accessory
1489
+ await paste_gear("back")
1490
+
1491
+ # Fetch and paste the skin
1492
+ await self.paste_image(
1493
+ image,
1494
+ f"skin_{preferences.skin}{"_sleep" if preferences.sleep else ""}",
1495
+ (24, mount_offset_y),
1496
+ )
1497
+
1498
+ # Fetch and paste the shirt
1499
+ await self.paste_image(
1500
+ image,
1501
+ f"{preferences.size}_shirt_{preferences.shirt}",
1502
+ (24, mount_offset_y),
1503
+ )
1504
+
1505
+ # Fetch and paste the head base
1506
+ await self.paste_image(image, "head_0", (24, mount_offset_y))
1507
+
1508
+ # Fetch and paste the armor if not the base armor
1509
+ await paste_gear("armor")
1510
+
1511
+ # Fetch and paste the hair elements
1512
+ for hair_type in ("bangs", "base", "mustache", "beard"):
1513
+ if style := getattr(preferences.hair, hair_type, 0):
1514
+ await self.paste_image(
1515
+ image,
1516
+ f"hair_{hair_type}_{style}_{preferences.hair.color}",
1517
+ (24, mount_offset_y),
1518
+ )
1519
+
1520
+ # Fetch and paste body accessory, eyewear, headgear and head accessory
1521
+ for gear in ("body", "eyewear", "head", "headAccessory"):
1522
+ await paste_gear(gear)
1523
+
1524
+ # Fetch and paste the hairflower
1525
+ if preferences.hair.flower:
1526
+ await self.paste_image(
1527
+ image,
1528
+ f"hair_flower_{preferences.hair.flower}",
1529
+ (24, mount_offset_y),
1530
+ )
1531
+
1532
+ # Fetch and paste the shield
1533
+ await paste_gear("shield")
1534
+ # Fetch and paste the weapon
1535
+ await paste_gear("weapon")
1536
+
1537
+ # Fetch and paste the zzz
1538
+ if preferences.sleep:
1539
+ await self.paste_image(image, "zzz", (24, mount_offset_y))
1540
+
1541
+ # Fetch and paste the mount head
1542
+ if items.currentMount:
1543
+ await self.paste_image(
1544
+ image,
1545
+ f"Mount_Head_{items.currentMount}",
1546
+ (24, 18),
1547
+ )
1548
+ # Fetch and paste the pet
1549
+ if items.currentPet:
1550
+ await self.paste_image(image, f"Pet-{items.currentPet}", (0, 48))
1551
+
1552
+ if isinstance(fp, str):
1553
+ loop = asyncio.get_running_loop()
1554
+ loop.run_in_executor(None, image.save, fp)
1555
+ else:
1556
+ image.save(fp, fmt)
1557
+
1558
+ return user_styles