pySplash.py 1.0.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.
pySplash/api_async.py ADDED
@@ -0,0 +1,704 @@
1
+ """Asynchronous pySplash.py API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from .base import PySplashBase
9
+ from .config import AVAILABLE_ORDERS, AVAILABLE_ORIENTATIONS, URL_CONFIG
10
+ from .errors import PySplashError
11
+ from .http_client_async import AsyncHttpClient
12
+ from .models import (
13
+ BearerTokenResponse,
14
+ Collection,
15
+ DeleteResponse,
16
+ Photo,
17
+ PhotoLinks,
18
+ PhotoStatistics,
19
+ SearchResult,
20
+ User,
21
+ UserPortfolio,
22
+ UserStatistics,
23
+ )
24
+
25
+ logger = logging.getLogger("pySplash.py")
26
+
27
+ _BASE = URL_CONFIG["API_LOCATION"]
28
+ _TOKEN_URL = URL_CONFIG["BEARER_TOKEN_URL"]
29
+
30
+
31
+ class PySplashApiAsync(PySplashBase):
32
+ """Async wrapper for the Unsplash API.
33
+
34
+ Supports async context manager protocol::
35
+
36
+ async with PySplashApiAsync() as api:
37
+ api.init(bearer_token='...')
38
+ result = await api.get_photo('id')
39
+ """
40
+
41
+ def __init__(self) -> None:
42
+ self._access_key: str = ""
43
+ self._secret_key: str = ""
44
+ self._redirect_uri: str = ""
45
+ self._code: str = ""
46
+ self._grant_type: str = "authorization_code"
47
+ self._bearer_token: str = ""
48
+ self._timeout: int = 10
49
+ self._retries: int = 2
50
+ self._retry_delay: float = 0.1
51
+ self._headers: dict[str, str] = {}
52
+ self._client: AsyncHttpClient | None = None
53
+
54
+ async def close(self) -> None:
55
+ if self._client is not None:
56
+ await self._client.close()
57
+ self._client = None
58
+
59
+ async def __aenter__(self) -> PySplashApiAsync:
60
+ return self
61
+
62
+ async def __aexit__(self, *args: Any) -> None:
63
+ await self.close()
64
+
65
+ def _get_client(self) -> AsyncHttpClient:
66
+ if self._client is None:
67
+ self._client = AsyncHttpClient(
68
+ headers=self._headers,
69
+ timeout=self._timeout,
70
+ retries=self._retries,
71
+ retry_delay=self._retry_delay,
72
+ )
73
+ return self._client
74
+
75
+ async def _fetch_url(
76
+ self,
77
+ url: str,
78
+ method: str,
79
+ query_parameters: dict[str, Any] | None = None,
80
+ body: Any | None = None,
81
+ ) -> Any:
82
+ client = self._get_client()
83
+ try:
84
+ res = await client.make_request(url, method, self._build_query_parameters(query_parameters or {}), body)
85
+ except Exception as exc:
86
+ raise self._create_pysplash_error(exc) from exc
87
+
88
+ return self._handle_response_status(res.data, res.status, res.statusText)
89
+
90
+ def init(
91
+ self,
92
+ access_key: str | None = None,
93
+ secret_key: str | None = None,
94
+ redirect_uri: str | None = None,
95
+ code: str | None = None,
96
+ bearer_token: str | None = None,
97
+ timeout: int = 10,
98
+ retries: int = 2,
99
+ retry_delay: float = 0.1,
100
+ ) -> None:
101
+ """Initialize the client with API credentials or a bearer token."""
102
+ if timeout > 0:
103
+ self._timeout = timeout
104
+ if retries >= 0:
105
+ self._retries = retries
106
+ if retry_delay >= 0:
107
+ self._retry_delay = retry_delay
108
+
109
+ self._bearer_token = bearer_token or ""
110
+ self._headers = self._default_headers(bearer_token=bearer_token, access_key=access_key)
111
+
112
+ if bearer_token:
113
+ return
114
+
115
+ if not access_key:
116
+ raise PySplashError("Access Key missing!")
117
+ if not secret_key:
118
+ raise PySplashError("Secret Key missing!")
119
+ if not redirect_uri:
120
+ raise PySplashError("Redirect URI missing!")
121
+ if not code:
122
+ raise PySplashError("Authorization Code missing!")
123
+
124
+ self._access_key = access_key
125
+ self._secret_key = secret_key
126
+ self._redirect_uri = redirect_uri
127
+ self._code = code
128
+
129
+ # ── Auth ──────────────────────────────────────────────────────────
130
+
131
+ async def generate_bearer_token(self) -> BearerTokenResponse:
132
+ self._validate_required(self._access_key, "access_key")
133
+ self._validate_required(self._secret_key, "secret_key")
134
+ self._validate_required(self._redirect_uri, "redirect_uri")
135
+ self._validate_required(self._code, "code")
136
+ return await self._fetch_url(
137
+ _TOKEN_URL,
138
+ "POST",
139
+ {
140
+ "client_id": self._access_key,
141
+ "client_secret": self._secret_key,
142
+ "redirect_uri": self._redirect_uri,
143
+ "code": self._code,
144
+ "grant_type": self._grant_type,
145
+ },
146
+ )
147
+
148
+ # ── Current User ──────────────────────────────────────────────────
149
+
150
+ async def get_current_user_profile(self) -> User:
151
+ return await self._fetch_url(_BASE + URL_CONFIG["CURRENT_USER_PROFILE"], "GET")
152
+
153
+ async def update_current_user_profile(
154
+ self,
155
+ username: str | None = None,
156
+ first_name: str | None = None,
157
+ last_name: str | None = None,
158
+ email: str | None = None,
159
+ url: str | None = None,
160
+ location: str | None = None,
161
+ bio: str | None = None,
162
+ instagram_username: str | None = None,
163
+ ) -> User:
164
+ return await self._fetch_url(
165
+ _BASE + URL_CONFIG["UPDATE_CURRENT_USER_PROFILE"],
166
+ "PUT",
167
+ {
168
+ "username": username,
169
+ "first_name": first_name,
170
+ "last_name": last_name,
171
+ "email": email,
172
+ "url": url,
173
+ "location": location,
174
+ "bio": bio,
175
+ "instagram_username": instagram_username,
176
+ },
177
+ )
178
+
179
+ # ── Users ─────────────────────────────────────────────────────────
180
+
181
+ async def get_public_profile(
182
+ self,
183
+ username: str,
184
+ width: int | None = None,
185
+ height: int | None = None,
186
+ ) -> User:
187
+ self._validate_required(username, "username")
188
+ self._validate_int(width, "width", minimum=1)
189
+ self._validate_int(height, "height", minimum=1)
190
+ return await self._fetch_url(
191
+ _BASE + URL_CONFIG["USERS_PUBLIC_PROFILE"] + username,
192
+ "GET",
193
+ {"w": width, "h": height},
194
+ )
195
+
196
+ async def get_user_portfolio(self, username: str) -> UserPortfolio:
197
+ self._validate_required(username, "username")
198
+ return await self._fetch_url(
199
+ self._build_url(_BASE, URL_CONFIG["USERS_PORTFOLIO"], username=username),
200
+ "GET",
201
+ )
202
+
203
+ async def get_user_photos(
204
+ self,
205
+ username: str,
206
+ page: int | None = None,
207
+ per_page: int | None = None,
208
+ stats: bool | None = None,
209
+ resolution: str | None = None,
210
+ quantity: int | None = None,
211
+ order_by: str | None = None,
212
+ ) -> list[Photo]:
213
+ self._validate_required(username, "username")
214
+ self._validate_supported_value(order_by, AVAILABLE_ORDERS, "order_by")
215
+ self._validate_bool(stats, "stats")
216
+ self._validate_int(page, "page", minimum=1)
217
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
218
+ self._validate_int(quantity, "quantity", minimum=1, maximum=30)
219
+ return await self._fetch_url(
220
+ self._build_url(_BASE, URL_CONFIG["USERS_PHOTOS"], username=username),
221
+ "GET",
222
+ {
223
+ "page": page if page is not None else 1,
224
+ "per_page": per_page if per_page is not None else 10,
225
+ "order_by": order_by if order_by is not None else "latest",
226
+ "stats": stats if stats is not None else False,
227
+ "resolution": resolution if resolution is not None else "days",
228
+ "quantity": quantity if quantity is not None else 30,
229
+ },
230
+ )
231
+
232
+ async def get_user_liked_photos(
233
+ self,
234
+ username: str,
235
+ page: int | None = None,
236
+ per_page: int | None = None,
237
+ order_by: str | None = None,
238
+ ) -> list[Photo]:
239
+ self._validate_required(username, "username")
240
+ self._validate_supported_value(order_by, AVAILABLE_ORDERS, "order_by")
241
+ self._validate_int(page, "page", minimum=1)
242
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
243
+ return await self._fetch_url(
244
+ self._build_url(_BASE, URL_CONFIG["USERS_LIKED_PHOTOS"], username=username),
245
+ "GET",
246
+ {
247
+ "page": page if page is not None else 1,
248
+ "per_page": per_page if per_page is not None else 10,
249
+ "order_by": order_by if order_by is not None else "latest",
250
+ },
251
+ )
252
+
253
+ async def get_user_collections(
254
+ self,
255
+ username: str,
256
+ page: int | None = None,
257
+ per_page: int | None = None,
258
+ ) -> list[Collection]:
259
+ self._validate_required(username, "username")
260
+ self._validate_int(page, "page", minimum=1)
261
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
262
+ return await self._fetch_url(
263
+ self._build_url(_BASE, URL_CONFIG["USERS_COLLECTIONS"], username=username),
264
+ "GET",
265
+ {
266
+ "page": page if page is not None else 1,
267
+ "per_page": per_page if per_page is not None else 10,
268
+ },
269
+ )
270
+
271
+ async def get_user_statistics(
272
+ self,
273
+ username: str,
274
+ resolution: str | None = None,
275
+ quantity: int | None = None,
276
+ ) -> UserStatistics:
277
+ self._validate_required(username, "username")
278
+ self._validate_int(quantity, "quantity", minimum=1, maximum=30)
279
+ return await self._fetch_url(
280
+ self._build_url(_BASE, URL_CONFIG["USERS_STATISTICS"], username=username),
281
+ "GET",
282
+ {
283
+ "resolution": resolution if resolution is not None else "days",
284
+ "quantity": quantity if quantity is not None else 30,
285
+ },
286
+ )
287
+
288
+ # ── Photos ────────────────────────────────────────────────────────
289
+
290
+ async def list_photos(
291
+ self,
292
+ page: int | None = None,
293
+ per_page: int | None = None,
294
+ order_by: str | None = None,
295
+ ) -> list[Photo]:
296
+ self._validate_supported_value(order_by, AVAILABLE_ORDERS, "order_by")
297
+ self._validate_int(page, "page", minimum=1)
298
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
299
+ return await self._fetch_url(
300
+ _BASE + URL_CONFIG["LIST_PHOTOS"],
301
+ "GET",
302
+ {
303
+ "page": page if page is not None else 1,
304
+ "per_page": per_page if per_page is not None else 10,
305
+ "order_by": order_by if order_by is not None else "latest",
306
+ },
307
+ )
308
+
309
+ async def list_curated_photos(
310
+ self,
311
+ page: int | None = None,
312
+ per_page: int | None = None,
313
+ order_by: str | None = None,
314
+ ) -> list[Photo]:
315
+ self._validate_supported_value(order_by, AVAILABLE_ORDERS, "order_by")
316
+ self._validate_int(page, "page", minimum=1)
317
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
318
+ return await self._fetch_url(
319
+ _BASE + URL_CONFIG["LIST_CURATED_PHOTOS"],
320
+ "GET",
321
+ {
322
+ "page": page if page is not None else 1,
323
+ "per_page": per_page if per_page is not None else 10,
324
+ "order_by": order_by if order_by is not None else "latest",
325
+ },
326
+ )
327
+
328
+ async def get_photo(
329
+ self,
330
+ id: str,
331
+ width: int | None = None,
332
+ height: int | None = None,
333
+ rect: str | None = None,
334
+ ) -> Photo:
335
+ self._validate_required(id, "id")
336
+ self._validate_int(width, "width", minimum=1)
337
+ self._validate_int(height, "height", minimum=1)
338
+ return await self._fetch_url(
339
+ self._build_url(_BASE, URL_CONFIG["GET_A_PHOTO"], id=id),
340
+ "GET",
341
+ {"w": width, "h": height, "rect": rect},
342
+ )
343
+
344
+ async def get_a_photo(
345
+ self,
346
+ id: str,
347
+ width: int | None = None,
348
+ height: int | None = None,
349
+ rect: str | None = None,
350
+ ) -> Photo:
351
+ return await self.get_photo(id, width, height, rect)
352
+
353
+ async def get_random_photo(
354
+ self,
355
+ collections: str | int | None = None,
356
+ featured: bool | None = None,
357
+ username: str | None = None,
358
+ query: str | None = None,
359
+ width: int | None = None,
360
+ height: int | None = None,
361
+ orientation: str | None = None,
362
+ count: int | None = None,
363
+ ) -> Photo | list[Photo]:
364
+ self._validate_supported_value(orientation, AVAILABLE_ORIENTATIONS, "orientation")
365
+ self._validate_bool(featured, "featured")
366
+ self._validate_int(width, "width", minimum=1)
367
+ self._validate_int(height, "height", minimum=1)
368
+ self._validate_int(count, "count", minimum=1, maximum=30)
369
+ return await self._fetch_url(
370
+ _BASE + URL_CONFIG["GET_A_RANDOM_PHOTO"],
371
+ "GET",
372
+ {
373
+ "collections": str(collections) if collections is not None else None,
374
+ "featured": featured if featured is not None else False,
375
+ "username": username,
376
+ "query": query,
377
+ "width": width,
378
+ "height": height,
379
+ "orientation": orientation if orientation is not None else "landscape",
380
+ "count": count if count is not None else 1,
381
+ },
382
+ )
383
+
384
+ async def get_a_random_photo(
385
+ self,
386
+ collections: str | int | None = None,
387
+ featured: bool | None = None,
388
+ username: str | None = None,
389
+ query: str | None = None,
390
+ width: int | None = None,
391
+ height: int | None = None,
392
+ orientation: str | None = None,
393
+ count: int | None = None,
394
+ ) -> Photo | list[Photo]:
395
+ return await self.get_random_photo(collections, featured, username, query, width, height, orientation, count)
396
+
397
+ async def get_photo_statistics(
398
+ self,
399
+ id: str,
400
+ resolution: str | None = None,
401
+ quantity: int | None = None,
402
+ ) -> PhotoStatistics:
403
+ self._validate_required(id, "id")
404
+ self._validate_int(quantity, "quantity", minimum=1, maximum=30)
405
+ return await self._fetch_url(
406
+ self._build_url(_BASE, URL_CONFIG["GET_A_PHOTO_STATISTICS"], id=id),
407
+ "GET",
408
+ {
409
+ "resolution": resolution if resolution is not None else "days",
410
+ "quantity": quantity if quantity is not None else 30,
411
+ },
412
+ )
413
+
414
+ async def get_photo_link(self, id: str) -> PhotoLinks:
415
+ self._validate_required(id, "id")
416
+ return await self._fetch_url(
417
+ self._build_url(_BASE, URL_CONFIG["GET_A_PHOTO_DOWNLOAD_LINK"], id=id),
418
+ "GET",
419
+ )
420
+
421
+ async def update_photo(
422
+ self,
423
+ id: str,
424
+ location: dict[str, Any] | None = None,
425
+ exif: dict[str, Any] | None = None,
426
+ ) -> Photo:
427
+ self._validate_required(id, "id")
428
+ params = {**self._extract_location_params(location), **self._extract_exif_params(exif)}
429
+ return await self._fetch_url(
430
+ self._build_url(_BASE, URL_CONFIG["UPDATE_A_PHOTO"], id=id),
431
+ "PUT",
432
+ params,
433
+ )
434
+
435
+ async def like_photo(self, id: str) -> Photo:
436
+ self._validate_required(id, "id")
437
+ return await self._fetch_url(
438
+ self._build_url(_BASE, URL_CONFIG["LIKE_A_PHOTO"], id=id),
439
+ "POST",
440
+ )
441
+
442
+ async def unlike_photo(self, id: str) -> Photo:
443
+ self._validate_required(id, "id")
444
+ return await self._fetch_url(
445
+ self._build_url(_BASE, URL_CONFIG["UNLIKE_A_PHOTO"], id=id),
446
+ "DELETE",
447
+ )
448
+
449
+ # ── Search ────────────────────────────────────────────────────────
450
+
451
+ async def search(
452
+ self,
453
+ query: str,
454
+ page: int | None = None,
455
+ per_page: int | None = None,
456
+ collections: str | int | None = None,
457
+ orientation: str | None = None,
458
+ ) -> SearchResult:
459
+ self._validate_required(query, "query")
460
+ self._validate_supported_value(orientation, AVAILABLE_ORIENTATIONS, "orientation")
461
+ self._validate_int(page, "page", minimum=1)
462
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
463
+ return await self._fetch_url(
464
+ _BASE + URL_CONFIG["SEARCH_PHOTOS"],
465
+ "GET",
466
+ {
467
+ "query": query,
468
+ "page": page if page is not None else 1,
469
+ "per_page": per_page if per_page is not None else 10,
470
+ "collections": str(collections) if collections is not None else None,
471
+ "orientation": orientation,
472
+ },
473
+ )
474
+
475
+ async def search_collections(
476
+ self,
477
+ query: str,
478
+ page: int | None = None,
479
+ per_page: int | None = None,
480
+ ) -> SearchResult:
481
+ self._validate_required(query, "query")
482
+ self._validate_int(page, "page", minimum=1)
483
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
484
+ return await self._fetch_url(
485
+ _BASE + URL_CONFIG["SEARCH_COLLECTIONS"],
486
+ "GET",
487
+ {
488
+ "query": query,
489
+ "page": page if page is not None else 1,
490
+ "per_page": per_page if per_page is not None else 10,
491
+ },
492
+ )
493
+
494
+ async def search_users(
495
+ self,
496
+ query: str,
497
+ page: int | None = None,
498
+ per_page: int | None = None,
499
+ ) -> SearchResult:
500
+ self._validate_required(query, "query")
501
+ self._validate_int(page, "page", minimum=1)
502
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
503
+ return await self._fetch_url(
504
+ _BASE + URL_CONFIG["SEARCH_USERS"],
505
+ "GET",
506
+ {
507
+ "query": query,
508
+ "page": page if page is not None else 1,
509
+ "per_page": per_page if per_page is not None else 10,
510
+ },
511
+ )
512
+
513
+ # ── Stats ─────────────────────────────────────────────────────────
514
+
515
+ async def get_stats_totals(self) -> dict[str, Any]:
516
+ return await self._fetch_url(_BASE + URL_CONFIG["STATS_TOTALS"], "GET")
517
+
518
+ async def get_stats_month(self) -> dict[str, Any]:
519
+ return await self._fetch_url(_BASE + URL_CONFIG["STATS_MONTH"], "GET")
520
+
521
+ # ── Collections ───────────────────────────────────────────────────
522
+
523
+ async def list_collections(
524
+ self,
525
+ page: int | None = None,
526
+ per_page: int | None = None,
527
+ ) -> list[Collection]:
528
+ self._validate_int(page, "page", minimum=1)
529
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
530
+ return await self._fetch_url(
531
+ _BASE + URL_CONFIG["LIST_COLLECTIONS"],
532
+ "GET",
533
+ {
534
+ "page": page if page is not None else 1,
535
+ "per_page": per_page if per_page is not None else 10,
536
+ },
537
+ )
538
+
539
+ async def list_featured_collections(
540
+ self,
541
+ page: int | None = None,
542
+ per_page: int | None = None,
543
+ ) -> list[Collection]:
544
+ self._validate_int(page, "page", minimum=1)
545
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
546
+ return await self._fetch_url(
547
+ _BASE + URL_CONFIG["LIST_FEATURED_COLLECTIONS"],
548
+ "GET",
549
+ {
550
+ "page": page if page is not None else 1,
551
+ "per_page": per_page if per_page is not None else 10,
552
+ },
553
+ )
554
+
555
+ async def list_curated_collections(
556
+ self,
557
+ page: int | None = None,
558
+ per_page: int | None = None,
559
+ ) -> list[Collection]:
560
+ self._validate_int(page, "page", minimum=1)
561
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
562
+ return await self._fetch_url(
563
+ _BASE + URL_CONFIG["LIST_CURATED_COLLECTIONS"],
564
+ "GET",
565
+ {
566
+ "page": page if page is not None else 1,
567
+ "per_page": per_page if per_page is not None else 10,
568
+ },
569
+ )
570
+
571
+ async def get_collection(self, id: str) -> Collection:
572
+ self._validate_required(id, "id")
573
+ return await self._fetch_url(
574
+ self._build_url(_BASE, URL_CONFIG["GET_COLLECTION"], id=id),
575
+ "GET",
576
+ )
577
+
578
+ async def get_curated_collection(self, id: str) -> Collection:
579
+ self._validate_required(id, "id")
580
+ return await self._fetch_url(
581
+ self._build_url(_BASE, URL_CONFIG["GET_CURATED_COLLECTION"], id=id),
582
+ "GET",
583
+ )
584
+
585
+ async def get_collection_photos(
586
+ self,
587
+ id: str,
588
+ page: int | None = None,
589
+ per_page: int | None = None,
590
+ ) -> list[Photo]:
591
+ self._validate_required(id, "id")
592
+ self._validate_int(page, "page", minimum=1)
593
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
594
+ return await self._fetch_url(
595
+ self._build_url(_BASE, URL_CONFIG["GET_COLLECTION_PHOTOS"], id=id),
596
+ "GET",
597
+ {
598
+ "page": page if page is not None else 1,
599
+ "per_page": per_page if per_page is not None else 10,
600
+ },
601
+ )
602
+
603
+ async def get_curated_collection_photos(
604
+ self,
605
+ id: str,
606
+ page: int | None = None,
607
+ per_page: int | None = None,
608
+ ) -> list[Photo]:
609
+ self._validate_required(id, "id")
610
+ self._validate_int(page, "page", minimum=1)
611
+ self._validate_int(per_page, "per_page", minimum=1, maximum=30)
612
+ return await self._fetch_url(
613
+ self._build_url(_BASE, URL_CONFIG["GET_CURATED_COLLECTION_PHOTOS"], id=id),
614
+ "GET",
615
+ {
616
+ "page": page if page is not None else 1,
617
+ "per_page": per_page if per_page is not None else 10,
618
+ },
619
+ )
620
+
621
+ async def list_related_collections(self, id: str) -> list[Collection]:
622
+ self._validate_required(id, "id")
623
+ return await self._fetch_url(
624
+ self._build_url(_BASE, URL_CONFIG["LIST_RELATED_COLLECTION"], id=id),
625
+ "GET",
626
+ )
627
+
628
+ async def create_collection(
629
+ self,
630
+ title: str,
631
+ description: str | None = None,
632
+ private_collection: bool = False,
633
+ ) -> Collection:
634
+ self._validate_required(title, "title")
635
+ return await self._fetch_url(
636
+ _BASE + URL_CONFIG["CREATE_NEW_COLLECTION"],
637
+ "POST",
638
+ {
639
+ "title": title,
640
+ "description": description,
641
+ "private": private_collection,
642
+ },
643
+ )
644
+
645
+ async def create_new_collection(
646
+ self,
647
+ title: str,
648
+ description: str | None = None,
649
+ private_collection: bool = False,
650
+ ) -> Collection:
651
+ return await self.create_collection(title, description, private_collection)
652
+
653
+ async def update_collection(
654
+ self,
655
+ id: str,
656
+ title: str,
657
+ description: str | None = None,
658
+ private_collection: bool = False,
659
+ ) -> Collection:
660
+ self._validate_required(id, "id")
661
+ self._validate_required(title, "title")
662
+ return await self._fetch_url(
663
+ self._build_url(_BASE, URL_CONFIG["UPDATE_EXISTING_COLLECTION"], id=id),
664
+ "PUT",
665
+ {
666
+ "title": title,
667
+ "description": description,
668
+ "private": private_collection,
669
+ },
670
+ )
671
+
672
+ async def update_existing_collection(
673
+ self,
674
+ id: str,
675
+ title: str,
676
+ description: str | None = None,
677
+ private_collection: bool = False,
678
+ ) -> Collection:
679
+ return await self.update_collection(id, title, description, private_collection)
680
+
681
+ async def delete_collection(self, id: str) -> DeleteResponse:
682
+ self._validate_required(id, "id")
683
+ return await self._fetch_url(
684
+ self._build_url(_BASE, URL_CONFIG["DELETE_COLLECTION"], id=id),
685
+ "DELETE",
686
+ )
687
+
688
+ async def add_photo_to_collection(self, collection_id: str, photo_id: str) -> Collection:
689
+ self._validate_required(collection_id, "collection_id")
690
+ self._validate_required(photo_id, "photo_id")
691
+ return await self._fetch_url(
692
+ self._build_url(_BASE, URL_CONFIG["ADD_PHOTO_TO_COLLECTION"], collection_id=collection_id),
693
+ "POST",
694
+ {"photo_id": photo_id},
695
+ )
696
+
697
+ async def remove_photo_from_collection(self, collection_id: str, photo_id: str) -> DeleteResponse:
698
+ self._validate_required(collection_id, "collection_id")
699
+ self._validate_required(photo_id, "photo_id")
700
+ return await self._fetch_url(
701
+ self._build_url(_BASE, URL_CONFIG["REMOVE_PHOTO_FROM_COLLECTION"], collection_id=collection_id),
702
+ "DELETE",
703
+ {"photo_id": photo_id},
704
+ )