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