raindrop-cli 0.5.2__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.
rd_cli/client.py ADDED
@@ -0,0 +1,727 @@
1
+ """``RaindropClient`` — a thin, dependency-free wrapper over the Raindrop.io
2
+ REST API v1 (https://api.raindrop.io/rest/v1).
3
+
4
+ Everything goes through :meth:`RaindropClient._request`, which is the single
5
+ place that attaches the auth header, applies a timeout, JSON-encodes bodies,
6
+ lowercases boolean query params (the API rejects Python's ``True``/``False``),
7
+ retries on ``429``/``5xx``, and maps error responses to the typed exceptions in
8
+ :mod:`rd_cli.errors`. The per-endpoint methods below are deliberately small so
9
+ this class can later graduate into a standalone client library.
10
+
11
+ The constructor accepts an ``opener`` and ``sleep`` so tests can inject a fake
12
+ transport and avoid real network / real waiting.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import calendar
18
+ import email.utils
19
+ import json
20
+ import os
21
+ import sys
22
+ import time
23
+ import urllib.error
24
+ import urllib.parse
25
+ import urllib.request
26
+ from collections.abc import Iterator
27
+ from typing import Any
28
+
29
+ from . import __version__
30
+ from .errors import APIError, AuthError, NotFoundError, RateLimitError
31
+
32
+ BASE_URL = "https://api.raindrop.io/rest/v1"
33
+ USER_AGENT = f"rd-cli/{__version__} (+https://github.com/VirInvictus/rd-cli)"
34
+
35
+ # System collection ids (see CLAUDE.md).
36
+ ALL = 0
37
+ UNSORTED = -1
38
+ TRASH = -99
39
+
40
+ PERPAGE_MAX = 50
41
+
42
+
43
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
44
+ """Redirect handler that declines to follow. Returning ``None`` from
45
+ ``redirect_request`` makes urllib raise the 3xx as an ``HTTPError``, which
46
+ is how ``_request`` gets at the ``Location`` header."""
47
+
48
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
49
+ return None
50
+
51
+
52
+ class RaindropClient:
53
+ def __init__(
54
+ self,
55
+ token: str,
56
+ *,
57
+ base_url: str = BASE_URL,
58
+ timeout: float = 30.0,
59
+ max_retries: int = 3,
60
+ dry_run: bool = False,
61
+ opener: urllib.request.OpenerDirector | None = None,
62
+ sleep=time.sleep,
63
+ ) -> None:
64
+ self.token = token
65
+ self.base_url = base_url.rstrip("/")
66
+ self.timeout = timeout
67
+ self.max_retries = max_retries
68
+ self.dry_run = dry_run
69
+ self._opener = opener or urllib.request.build_opener()
70
+ # The permanent-copy endpoint answers 307 with the real location in a
71
+ # header; urllib's default opener would silently follow it and hand back
72
+ # the copy's bytes instead of its URL. An injected opener (tests) is used
73
+ # for both so a fake transport still sees every call.
74
+ self._noredirect_opener = opener or urllib.request.build_opener(_NoRedirect)
75
+ self._sleep = sleep
76
+
77
+ # -- core -----------------------------------------------------------------
78
+
79
+ def _request(
80
+ self,
81
+ method: str,
82
+ path: str,
83
+ *,
84
+ params: dict[str, Any] | None = None,
85
+ json_body: Any = None,
86
+ files: dict[str, tuple[str, bytes, str]] | None = None,
87
+ form: dict[str, str] | None = None,
88
+ expect_json: bool = True,
89
+ allow_redirects: bool = True,
90
+ ) -> Any:
91
+ url = self.base_url + path
92
+ query = _encode_params(params)
93
+ if query:
94
+ url = f"{url}?{query}"
95
+
96
+ data: bytes | None = None
97
+ headers = {
98
+ "Authorization": f"Bearer {self.token}",
99
+ "User-Agent": USER_AGENT,
100
+ "Accept": "application/json",
101
+ }
102
+ if files is not None:
103
+ data, content_type = _multipart(files, form or {})
104
+ headers["Content-Type"] = content_type
105
+ elif json_body is not None:
106
+ data = json.dumps(json_body).encode("utf-8")
107
+ headers["Content-Type"] = "application/json"
108
+
109
+ if self.dry_run and method != "GET":
110
+ print(
111
+ f"DRY RUN {method} {path} {_dry_run_preview(json_body, files, form)}",
112
+ file=sys.stderr,
113
+ )
114
+ return {"result": True, "item": {}, "items": [], "modified": 0, "count": 0}
115
+
116
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
117
+
118
+ opener = self._opener if allow_redirects else self._noredirect_opener
119
+
120
+ attempt = 0
121
+ while True:
122
+ try:
123
+ with opener.open(req, timeout=self.timeout) as resp:
124
+ body = resp.read()
125
+ if not expect_json:
126
+ return body
127
+ return json.loads(body) if body else {}
128
+ except urllib.error.HTTPError as exc:
129
+ # With redirects suppressed a 3xx is the answer, not an error:
130
+ # the caller wants the target URL, so hand back Location.
131
+ if not allow_redirects and exc.code in (301, 302, 303, 307, 308):
132
+ return exc.headers.get("Location")
133
+ retry_after = self._retry_wait(exc, attempt)
134
+ if retry_after is not None and attempt < self.max_retries:
135
+ self._sleep(retry_after)
136
+ attempt += 1
137
+ continue
138
+ raise _to_api_error(exc) from exc
139
+ except urllib.error.URLError as exc:
140
+ if attempt < self.max_retries:
141
+ self._sleep(_backoff(attempt))
142
+ attempt += 1
143
+ continue
144
+ raise APIError(f"Network error: {exc.reason}") from exc
145
+
146
+ def _retry_wait(self, exc: urllib.error.HTTPError, attempt: int) -> float | None:
147
+ """Return seconds to wait before retrying, or ``None`` if not retryable."""
148
+ if exc.code == 429:
149
+ header = exc.headers.get("Retry-After")
150
+ if header:
151
+ if header.strip().isdigit():
152
+ return min(float(header), 60.0)
153
+ # Retry-After may also be an HTTP-date.
154
+ parsed = email.utils.parsedate(header)
155
+ if parsed:
156
+ wait = calendar.timegm(parsed) - time.time()
157
+ return max(0.0, min(wait, 60.0))
158
+ reset = exc.headers.get("X-RateLimit-Reset")
159
+ if reset and reset.isdigit():
160
+ return max(0.0, min(float(reset) - time.time(), 60.0))
161
+ return _backoff(attempt)
162
+ if 500 <= exc.code < 600:
163
+ return _backoff(attempt)
164
+ return None
165
+
166
+ # -- raindrops: single ----------------------------------------------------
167
+
168
+ def get_raindrop(self, raindrop_id: int) -> dict:
169
+ return self._request("GET", f"/raindrop/{raindrop_id}").get("item", {})
170
+
171
+ def create_raindrop(self, link: str, **fields: Any) -> dict:
172
+ payload = _raindrop_payload(link=link, **fields)
173
+ return self._request("POST", "/raindrop", json_body=payload).get("item", {})
174
+
175
+ def update_raindrop(self, raindrop_id: int, **fields: Any) -> dict:
176
+ payload = _raindrop_payload(**fields)
177
+ return self._request("PUT", f"/raindrop/{raindrop_id}", json_body=payload).get(
178
+ "item", {}
179
+ )
180
+
181
+ def delete_raindrop(self, raindrop_id: int, *, permanent: bool = False) -> bool:
182
+ """Delete a raindrop. It moves to Trash first; with ``permanent=True`` a
183
+ second delete removes it from Trash for good. (The undocumented
184
+ ``?permanent=true`` query param was tested and does *not* one-shot a live
185
+ raindrop, so we use the documented two-step instead.)"""
186
+ ok = bool(
187
+ self._request("DELETE", f"/raindrop/{raindrop_id}").get("result", False)
188
+ )
189
+ if permanent:
190
+ try:
191
+ self._request("DELETE", f"/raindrop/{raindrop_id}")
192
+ except NotFoundError:
193
+ pass # already gone (it was in Trash before the first delete)
194
+ return ok
195
+
196
+ def upload_file(
197
+ self, filename: str, content: bytes, mime: str, collection_id: int = UNSORTED
198
+ ) -> dict:
199
+ files = {"file": (filename, content, mime)}
200
+ form = {"collectionId": str(collection_id)}
201
+ return self._request("PUT", "/raindrop/file", files=files, form=form).get(
202
+ "item", {}
203
+ )
204
+
205
+ def upload_cover(
206
+ self, raindrop_id: int, filename: str, content: bytes, mime: str
207
+ ) -> dict:
208
+ files = {"cover": (filename, content, mime)}
209
+ return self._request("PUT", f"/raindrop/{raindrop_id}/cover", files=files).get(
210
+ "item", {}
211
+ )
212
+
213
+ def suggest_new(self, link: str) -> dict:
214
+ return self._request("POST", "/raindrop/suggest", json_body={"link": link}).get(
215
+ "item", {}
216
+ )
217
+
218
+ def suggest_existing(self, raindrop_id: int) -> dict:
219
+ return self._request("GET", f"/raindrop/{raindrop_id}/suggest").get("item", {})
220
+
221
+ def get_permanent_copy_url(self, raindrop_id: int) -> str | None:
222
+ """URL of the raindrop's permanent copy, or ``None`` if there isn't one.
223
+
224
+ The endpoint answers 307 with the storage URL in ``Location`` (the copy
225
+ itself is a PRO feature, so a free account or an unarchived link yields
226
+ no redirect). The returned URL is pre-signed and short-lived, which is
227
+ why it is fetched on demand rather than cached.
228
+ """
229
+ result = self._request(
230
+ "GET", f"/raindrop/{raindrop_id}/cache", allow_redirects=False
231
+ )
232
+ return result if isinstance(result, str) and result else None
233
+
234
+ # -- raindrops: multiple --------------------------------------------------
235
+
236
+ def get_raindrops(
237
+ self,
238
+ collection_id: int = ALL,
239
+ *,
240
+ search: str = "",
241
+ sort: str = "-created",
242
+ page: int = 0,
243
+ perpage: int = PERPAGE_MAX,
244
+ nested: bool = False,
245
+ ) -> dict:
246
+ params: dict[str, Any] = {
247
+ "sort": sort,
248
+ "page": page,
249
+ "perpage": min(perpage, PERPAGE_MAX),
250
+ "nested": nested,
251
+ }
252
+ if search:
253
+ params["search"] = search
254
+ return self._request("GET", f"/raindrops/{collection_id}", params=params)
255
+
256
+ def iter_raindrops(
257
+ self,
258
+ collection_id: int = ALL,
259
+ *,
260
+ search: str = "",
261
+ sort: str = "-created",
262
+ nested: bool = False,
263
+ perpage: int = PERPAGE_MAX,
264
+ ) -> Iterator[dict]:
265
+ """Yield every raindrop across all pages."""
266
+ page = 0
267
+ while True:
268
+ data = self.get_raindrops(
269
+ collection_id,
270
+ search=search,
271
+ sort=sort,
272
+ page=page,
273
+ perpage=perpage,
274
+ nested=nested,
275
+ )
276
+ items = data.get("items", [])
277
+ yield from items
278
+ if len(items) < perpage:
279
+ return
280
+ page += 1
281
+
282
+ def create_raindrops(self, items: list[dict]) -> list[dict]:
283
+ return self._request("POST", "/raindrops", json_body={"items": items}).get(
284
+ "items", []
285
+ )
286
+
287
+ def update_raindrops(
288
+ self,
289
+ collection_id: int,
290
+ *,
291
+ ids: list[int] | None = None,
292
+ search: str = "",
293
+ nested: bool = False,
294
+ move_to: int | None = None,
295
+ **fields: Any,
296
+ ) -> int:
297
+ """Batch-update raindrops. ``collection_id`` is the path scope (the
298
+ collection the raindrops currently live in; system ``0`` is *not*
299
+ supported here per the API). ``move_to`` sets the destination
300
+ collection; other fields (``important``, ``tags`` (append; ``[]``
301
+ clears), ``cover``) pass through."""
302
+ params = {"nested": nested} if nested else None
303
+ payload = _raindrop_payload(**fields)
304
+ if move_to is not None:
305
+ payload["collection"] = {"$id": move_to}
306
+ if ids:
307
+ payload["ids"] = ids
308
+ if search:
309
+ payload["search"] = search
310
+ data = self._request(
311
+ "PUT", f"/raindrops/{collection_id}", params=params, json_body=payload
312
+ )
313
+ return int(data.get("modified", 0))
314
+
315
+ def delete_raindrops(
316
+ self,
317
+ collection_id: int,
318
+ *,
319
+ ids: list[int] | None = None,
320
+ search: str = "",
321
+ nested: bool = False,
322
+ ) -> int:
323
+ params: dict[str, Any] = {}
324
+ if nested:
325
+ params["nested"] = nested
326
+ if search:
327
+ params["search"] = search
328
+ payload = {"ids": ids} if ids else None
329
+ data = self._request(
330
+ "DELETE",
331
+ f"/raindrops/{collection_id}",
332
+ params=params or None,
333
+ json_body=payload,
334
+ )
335
+ return int(data.get("modified", 0))
336
+
337
+ def export(
338
+ self,
339
+ collection_id: int = ALL,
340
+ *,
341
+ fmt: str = "csv",
342
+ sort: str = "-created",
343
+ search: str = "",
344
+ ) -> bytes:
345
+ params: dict[str, Any] = {"sort": sort}
346
+ if search:
347
+ params["search"] = search
348
+ return self._request(
349
+ "GET",
350
+ f"/raindrops/{collection_id}/export.{fmt}",
351
+ params=params,
352
+ expect_json=False,
353
+ )
354
+
355
+ # -- collections ----------------------------------------------------------
356
+
357
+ def get_collections(self) -> list[dict]:
358
+ return self._request("GET", "/collections").get("items", [])
359
+
360
+ def get_child_collections(self) -> list[dict]:
361
+ return self._request("GET", "/collections/childrens").get("items", [])
362
+
363
+ def get_collection(self, collection_id: int) -> dict:
364
+ return self._request("GET", f"/collection/{collection_id}").get("item", {})
365
+
366
+ def create_collection(
367
+ self,
368
+ title: str,
369
+ *,
370
+ view: str | None = None,
371
+ sort: int | None = None,
372
+ public: bool | None = None,
373
+ parent_id: int | None = None,
374
+ ) -> dict:
375
+ payload = _collection_payload(
376
+ title=title, view=view, sort=sort, public=public, parent_id=parent_id
377
+ )
378
+ return self._request("POST", "/collection", json_body=payload).get("item", {})
379
+
380
+ def update_collection(self, collection_id: int, **fields: Any) -> dict:
381
+ payload = _collection_payload(**fields)
382
+ return self._request(
383
+ "PUT", f"/collection/{collection_id}", json_body=payload
384
+ ).get("item", {})
385
+
386
+ def delete_collection(self, collection_id: int) -> bool:
387
+ return bool(
388
+ self._request("DELETE", f"/collection/{collection_id}").get("result", False)
389
+ )
390
+
391
+ def delete_collections(self, ids: list[int]) -> bool:
392
+ return bool(
393
+ self._request("DELETE", "/collections", json_body={"ids": ids}).get(
394
+ "result", False
395
+ )
396
+ )
397
+
398
+ def reorder_collections(self, sort: str) -> bool:
399
+ return bool(
400
+ self._request("PUT", "/collections", json_body={"sort": sort}).get(
401
+ "result", False
402
+ )
403
+ )
404
+
405
+ def expand_collections(self, expanded: bool) -> bool:
406
+ return bool(
407
+ self._request("PUT", "/collections", json_body={"expanded": expanded}).get(
408
+ "result", False
409
+ )
410
+ )
411
+
412
+ def merge_collections(self, to: int, ids: list[int]) -> bool:
413
+ return bool(
414
+ self._request(
415
+ "PUT", "/collections/merge", json_body={"to": to, "ids": ids}
416
+ ).get("result", False)
417
+ )
418
+
419
+ def clean_collections(self) -> int:
420
+ """Remove all empty collections; return how many were removed."""
421
+ return int(self._request("PUT", "/collections/clean").get("count", 0))
422
+
423
+ def empty_trash(self) -> bool:
424
+ return bool(
425
+ self._request("DELETE", f"/collection/{TRASH}").get("result", False)
426
+ )
427
+
428
+ def upload_collection_cover(
429
+ self, collection_id: int, filename: str, content: bytes, mime: str
430
+ ) -> dict:
431
+ files = {"cover": (filename, content, mime)}
432
+ return self._request(
433
+ "PUT", f"/collection/{collection_id}/cover", files=files
434
+ ).get("item", {})
435
+
436
+ def search_covers(self, text: str) -> list[dict]:
437
+ """Search the icon/cover library (grouped by provider)."""
438
+ return self._request(
439
+ "GET", f"/collections/covers/{urllib.parse.quote(text)}"
440
+ ).get("items", [])
441
+
442
+ def featured_covers(self) -> list[dict]:
443
+ return self._request("GET", "/collections/covers").get("items", [])
444
+
445
+ # -- tags -----------------------------------------------------------------
446
+
447
+ def get_tags(self, collection_id: int | None = None) -> list[dict]:
448
+ path = "/tags" + (f"/{collection_id}" if collection_id is not None else "")
449
+ return self._request("GET", path).get("items", [])
450
+
451
+ def rename_tag(self, old: str, new: str, collection_id: int | None = None) -> bool:
452
+ return self.merge_tags([old], new, collection_id)
453
+
454
+ def merge_tags(
455
+ self, tags: list[str], new: str, collection_id: int | None = None
456
+ ) -> bool:
457
+ path = "/tags" + (f"/{collection_id}" if collection_id is not None else "")
458
+ payload = {"replace": new, "tags": tags}
459
+ return bool(self._request("PUT", path, json_body=payload).get("result", False))
460
+
461
+ def delete_tags(self, tags: list[str], collection_id: int | None = None) -> bool:
462
+ path = "/tags" + (f"/{collection_id}" if collection_id is not None else "")
463
+ return bool(
464
+ self._request("DELETE", path, json_body={"tags": tags}).get("result", False)
465
+ )
466
+
467
+ # -- highlights -----------------------------------------------------------
468
+
469
+ def get_all_highlights(self, *, page: int = 0, perpage: int = 25) -> list[dict]:
470
+ params = {"page": page, "perpage": min(perpage, PERPAGE_MAX)}
471
+ return self._request("GET", "/highlights", params=params).get("items", [])
472
+
473
+ def get_collection_highlights(
474
+ self, collection_id: int, *, page: int = 0, perpage: int = 25
475
+ ) -> list[dict]:
476
+ params = {"page": page, "perpage": min(perpage, PERPAGE_MAX)}
477
+ return self._request("GET", f"/highlights/{collection_id}", params=params).get(
478
+ "items", []
479
+ )
480
+
481
+ def iter_highlights(self, *, perpage: int = PERPAGE_MAX) -> Iterator[dict]:
482
+ page = 0
483
+ while True:
484
+ items = self.get_all_highlights(page=page, perpage=perpage)
485
+ yield from items
486
+ if len(items) < perpage:
487
+ return
488
+ page += 1
489
+
490
+ def get_raindrop_highlights(self, raindrop_id: int) -> list[dict]:
491
+ return self.get_raindrop(raindrop_id).get("highlights", [])
492
+
493
+ def add_highlight(
494
+ self, raindrop_id: int, text: str, *, color: str = "yellow", note: str = ""
495
+ ) -> list[dict]:
496
+ highlight = {"text": text, "color": color}
497
+ if note:
498
+ highlight["note"] = note
499
+ item = self._request(
500
+ "PUT", f"/raindrop/{raindrop_id}", json_body={"highlights": [highlight]}
501
+ ).get("item", {})
502
+ return item.get("highlights", [])
503
+
504
+ def update_highlight(
505
+ self,
506
+ raindrop_id: int,
507
+ highlight_id: str,
508
+ *,
509
+ text: str | None = None,
510
+ color: str | None = None,
511
+ note: str | None = None,
512
+ ) -> list[dict]:
513
+ highlight: dict[str, Any] = {"_id": highlight_id}
514
+ if text is not None:
515
+ highlight["text"] = text
516
+ if color is not None:
517
+ highlight["color"] = color
518
+ if note is not None:
519
+ highlight["note"] = note
520
+ item = self._request(
521
+ "PUT", f"/raindrop/{raindrop_id}", json_body={"highlights": [highlight]}
522
+ ).get("item", {})
523
+ return item.get("highlights", [])
524
+
525
+ def delete_highlight(self, raindrop_id: int, highlight_id: str) -> list[dict]:
526
+ """Remove a highlight (empty ``text`` signals deletion). Returns the
527
+ remaining highlights."""
528
+ item = self._request(
529
+ "PUT",
530
+ f"/raindrop/{raindrop_id}",
531
+ json_body={"highlights": [{"_id": highlight_id, "text": ""}]},
532
+ ).get("item", {})
533
+ return item.get("highlights", [])
534
+
535
+ # -- user / filters / stats ----------------------------------------------
536
+
537
+ def get_user(self) -> dict:
538
+ return self._request("GET", "/user").get("user", {})
539
+
540
+ def get_user_by_name(self, name: str) -> dict:
541
+ return self._request("GET", f"/user/{name}").get("user", {})
542
+
543
+ def update_user(self, **fields: Any) -> dict:
544
+ """Update the authenticated user. Accepts ``fullName``, ``email``,
545
+ ``config`` (dict), ``groups`` (list), and ``newpassword`` +
546
+ ``oldpassword``. Only non-``None`` fields are sent."""
547
+ payload = {k: v for k, v in fields.items() if v is not None}
548
+ return self._request("PUT", "/user", json_body=payload).get("user", {})
549
+
550
+ def get_stats(self) -> dict:
551
+ """System collection counts plus meta (pro, duplicates, broken)."""
552
+ return self._request("GET", "/user/stats")
553
+
554
+ def get_filters(
555
+ self, collection_id: int = ALL, *, tags_sort: str = "-count", search: str = ""
556
+ ) -> dict:
557
+ params: dict[str, Any] = {"tagsSort": tags_sort}
558
+ if search:
559
+ params["search"] = search
560
+ return self._request("GET", f"/filters/{collection_id}", params=params)
561
+
562
+ # -- import ---------------------------------------------------------------
563
+
564
+ def parse_url(self, url: str) -> dict:
565
+ return self._request("GET", "/import/url/parse", params={"url": url}).get(
566
+ "item", {}
567
+ )
568
+
569
+ def check_urls_exist(self, urls: list[str]) -> dict:
570
+ """Return ``{"result": bool, "ids": [...]}`` for already-saved URLs."""
571
+ return self._request("POST", "/import/url/exists", json_body={"urls": urls})
572
+
573
+ def parse_import_file(self, filename: str, content: bytes, mime: str) -> list[dict]:
574
+ """Convert a Netscape/Pocket/Instapaper HTML export to structured JSON
575
+ (folders + bookmarks). Does not create anything; feed the result to
576
+ ``create_raindrops`` to import."""
577
+ files = {"import": (filename, content, mime)}
578
+ return self._request("POST", "/import/file", files=files).get("items", [])
579
+
580
+ # -- backups --------------------------------------------------------------
581
+
582
+ def get_backups(self) -> list[dict]:
583
+ return self._request("GET", "/backups").get("items", [])
584
+
585
+ def generate_backup(self) -> bytes:
586
+ return self._request("GET", "/backup", expect_json=False)
587
+
588
+ def download_backup(self, backup_id: str, fmt: str = "csv") -> bytes:
589
+ return self._request("GET", f"/backup/{backup_id}.{fmt}", expect_json=False)
590
+
591
+
592
+ # -- payload builders ---------------------------------------------------------
593
+
594
+
595
+ def _raindrop_payload(**fields: Any) -> dict:
596
+ """Build a raindrop create/update body from keyword fields, dropping ``None``.
597
+
598
+ ``collection_id`` is translated to the API's ``{"collection": {"$id": id}}``
599
+ shape; ``please_parse`` sends an empty object to trigger background parsing.
600
+ """
601
+ payload: dict[str, Any] = {}
602
+ simple = (
603
+ "link",
604
+ "title",
605
+ "excerpt",
606
+ "note",
607
+ "important",
608
+ "tags",
609
+ "cover",
610
+ "type",
611
+ "order",
612
+ "media",
613
+ "created",
614
+ "lastUpdate",
615
+ "highlights",
616
+ "reminder",
617
+ )
618
+ for key in simple:
619
+ if key in fields and fields[key] is not None:
620
+ payload[key] = fields[key]
621
+ collection_id = fields.get("collection_id")
622
+ if collection_id is not None:
623
+ payload["collection"] = {"$id": collection_id}
624
+ if fields.get("please_parse"):
625
+ payload["pleaseParse"] = {}
626
+ return payload
627
+
628
+
629
+ def _collection_payload(**fields: Any) -> dict:
630
+ payload: dict[str, Any] = {}
631
+ for key in ("title", "view", "sort", "public", "expanded", "cover"):
632
+ if key in fields and fields[key] is not None:
633
+ payload[key] = fields[key]
634
+ parent_id = fields.get("parent_id")
635
+ if parent_id is not None:
636
+ payload["parent"] = {"$id": parent_id}
637
+ return payload
638
+
639
+
640
+ # -- request helpers ----------------------------------------------------------
641
+
642
+
643
+ def _encode_params(params: dict[str, Any] | None) -> str:
644
+ if not params:
645
+ return ""
646
+ clean: dict[str, str] = {}
647
+ for key, value in params.items():
648
+ if value is None:
649
+ continue
650
+ if isinstance(value, bool):
651
+ clean[key] = "true" if value else "false"
652
+ else:
653
+ clean[key] = str(value)
654
+ return urllib.parse.urlencode(clean)
655
+
656
+
657
+ def _multipart(
658
+ files: dict[str, tuple[str, bytes, str]], form: dict[str, str]
659
+ ) -> tuple[bytes, str]:
660
+ """Encode a ``multipart/form-data`` body without external deps."""
661
+ boundary = "----rdcli" + os.urandom(8).hex()
662
+ crlf = b"\r\n"
663
+ parts: list[bytes] = []
664
+ for name, value in form.items():
665
+ parts.append(f"--{boundary}".encode())
666
+ parts.append(f'Content-Disposition: form-data; name="{name}"'.encode())
667
+ parts.append(b"")
668
+ parts.append(value.encode("utf-8"))
669
+ for name, (filename, content, mime) in files.items():
670
+ parts.append(f"--{boundary}".encode())
671
+ parts.append(
672
+ f'Content-Disposition: form-data; name="{name}"; '
673
+ f'filename="{filename}"'.encode()
674
+ )
675
+ parts.append(f"Content-Type: {mime}".encode())
676
+ parts.append(b"")
677
+ parts.append(content)
678
+ parts.append(f"--{boundary}--".encode())
679
+ parts.append(b"")
680
+ body = crlf.join(parts)
681
+ return body, f"multipart/form-data; boundary={boundary}"
682
+
683
+
684
+ def _dry_run_preview(
685
+ json_body: Any,
686
+ files: dict[str, tuple[str, bytes, str]] | None,
687
+ form: dict[str, str] | None,
688
+ ) -> str:
689
+ """Human-readable body preview for ``--dry-run``. Distinguishes a JSON body,
690
+ a multipart upload (whose raw bytes we never dump), and a bodyless request
691
+ (a plain DELETE/PUT) — the last used to be mislabeled ``<multipart>``."""
692
+ if json_body is not None:
693
+ return json.dumps(json_body)
694
+ if files is not None:
695
+ parts = ", ".join(files)
696
+ return (
697
+ f"<multipart {json.dumps(form)} files=[{parts}]>"
698
+ if form
699
+ else (f"<multipart files=[{parts}]>")
700
+ )
701
+ return "<no body>"
702
+
703
+
704
+ def _backoff(attempt: int) -> float:
705
+ """Exponential backoff: 0.5s, 1s, 2s, ... capped at 30s."""
706
+ return min(0.5 * (2**attempt), 30.0)
707
+
708
+
709
+ def _to_api_error(exc: urllib.error.HTTPError) -> APIError:
710
+ message = exc.reason or "request failed"
711
+ payload: dict | None = None
712
+ try:
713
+ raw = exc.read()
714
+ if raw:
715
+ decoded = json.loads(raw)
716
+ if isinstance(decoded, dict):
717
+ payload = decoded
718
+ message = decoded.get("errorMessage") or decoded.get("error") or message
719
+ except (ValueError, OSError):
720
+ pass
721
+ if exc.code in (401, 403):
722
+ return AuthError(message, status=exc.code, payload=payload)
723
+ if exc.code == 404:
724
+ return NotFoundError(message, status=exc.code, payload=payload)
725
+ if exc.code == 429:
726
+ return RateLimitError(message, status=exc.code, payload=payload)
727
+ return APIError(message, status=exc.code, payload=payload)