python-dropbox-api 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-dropbox-api
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary:
5
5
  Author: Brandon Rothweiler
6
6
  Author-email: 2292715+bdr99@users.noreply.github.com
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-dropbox-api"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = ""
5
5
  authors = [
6
6
  {name = "Brandon Rothweiler",email = "2292715+bdr99@users.noreply.github.com"}
@@ -1,6 +1,10 @@
1
1
  from .auth import Auth
2
2
  from .client import DropboxAPIClient
3
- from .exceptions import DropboxAuthException, DropboxFileOrFolderNotFoundException
3
+ from .exceptions import (
4
+ DropboxAuthException,
5
+ DropboxFileOrFolderNotFoundException,
6
+ DropboxUnknownException,
7
+ )
4
8
  from .models import (
5
9
  AccountInfo,
6
10
  PropertyField,
@@ -15,6 +19,7 @@ __all__ = [
15
19
  "DropboxAPIClient",
16
20
  "DropboxAuthException",
17
21
  "DropboxFileOrFolderNotFoundException",
22
+ "DropboxUnknownException",
18
23
  "PropertyField",
19
24
  "PropertyFieldValue",
20
25
  "PropertyGroup",
@@ -0,0 +1,644 @@
1
+ from collections.abc import AsyncIterator
2
+ import json
3
+
4
+ from aiohttp import ClientResponse
5
+
6
+ from .auth import Auth
7
+ from .exceptions import (
8
+ DropboxAuthException,
9
+ DropboxFileOrFolderNotFoundException,
10
+ DropboxUnknownException,
11
+ )
12
+ from .models import (
13
+ AccountInfo,
14
+ FileOrFolderInfo,
15
+ PropertyField,
16
+ PropertyFieldValue,
17
+ PropertyGroup,
18
+ PropertyTemplate,
19
+ )
20
+
21
+ DROPBOX_API_BASE_URL = "https://api.dropboxapi.com"
22
+ CONTENT_API_BASE_URL = "https://content.dropboxapi.com"
23
+
24
+
25
+ def parse_property_groups(property_groups: list[dict]) -> list[PropertyGroup]:
26
+ """Parse the property groups from the response."""
27
+ return [
28
+ PropertyGroup(
29
+ template_id=group["template_id"],
30
+ fields=[
31
+ PropertyFieldValue(name=field["name"], value=field["value"])
32
+ for field in group["fields"]
33
+ ],
34
+ )
35
+ for group in property_groups
36
+ ]
37
+
38
+
39
+ async def handle_common_errors(response: ClientResponse) -> None:
40
+ """Handle common errors."""
41
+ response_json = await response.json()
42
+
43
+ error_summary = response_json.get("error_summary")
44
+
45
+ if error_summary is not None:
46
+ if error_summary.startswith("invalid_access_token/"):
47
+ raise DropboxAuthException("Unauthorized")
48
+ if error_summary.startswith("path/not_found/"):
49
+ raise DropboxFileOrFolderNotFoundException("File or folder not found")
50
+
51
+ response.raise_for_status()
52
+
53
+
54
+ class DropboxAPIClient:
55
+ """Lightweight Dropbox API client."""
56
+
57
+ def __init__(self, auth: Auth) -> None:
58
+ """Initialize the API client."""
59
+ self._websession = auth.websession
60
+ self._auth = auth
61
+
62
+ async def get_account_info(self) -> AccountInfo:
63
+ """Get information about the current account.
64
+
65
+ Returns:
66
+ AccountInfo containing account_id, email, and display_name.
67
+ """
68
+ try:
69
+ access_token = await self._auth.async_get_access_token()
70
+
71
+ headers = {
72
+ "Authorization": f"Bearer {access_token}",
73
+ "Content-Type": "",
74
+ }
75
+
76
+ url = f"{DROPBOX_API_BASE_URL}/2/users/get_current_account"
77
+
78
+ async with self._websession.post(url, headers=headers) as response:
79
+ await handle_common_errors(response)
80
+
81
+ response_json = await response.json()
82
+
83
+ return AccountInfo(
84
+ account_id=response_json["account_id"],
85
+ email=response_json["email"],
86
+ display_name=response_json["name"]["display_name"],
87
+ )
88
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
89
+ raise
90
+ except Exception as err:
91
+ raise DropboxUnknownException(str(err)) from err
92
+
93
+ async def list_folder(
94
+ self, path: str, include_property_groups: list[str] | None = None
95
+ ) -> list[FileOrFolderInfo]:
96
+ """List the contents of a folder with pagination support."""
97
+ try:
98
+ access_token = await self._auth.async_get_access_token()
99
+
100
+ headers = {
101
+ "Authorization": f"Bearer {access_token}",
102
+ "Content-Type": "application/json",
103
+ }
104
+
105
+ # Initial request data
106
+ data = {
107
+ "include_deleted": False,
108
+ "include_has_explicit_shared_members": False,
109
+ "include_media_info": False,
110
+ "include_mounted_folders": True,
111
+ "include_non_downloadable_files": False,
112
+ "path": path,
113
+ "recursive": False,
114
+ }
115
+
116
+ if include_property_groups is not None:
117
+ data["include_property_groups"] = {
118
+ ".tag": "filter_some",
119
+ "filter_some": include_property_groups,
120
+ }
121
+
122
+ all_entries = []
123
+ cursor = None
124
+ has_more = True
125
+
126
+ while has_more:
127
+ # Use list_folder endpoint for first request, list_folder/continue for subsequent ones
128
+ if cursor is None:
129
+ url = f"{DROPBOX_API_BASE_URL}/2/files/list_folder"
130
+ request_data = data
131
+ else:
132
+ url = f"{DROPBOX_API_BASE_URL}/2/files/list_folder/continue"
133
+ request_data = {"cursor": cursor}
134
+
135
+ async with self._websession.post(
136
+ url, headers=headers, json=request_data
137
+ ) as response:
138
+ await handle_common_errors(response)
139
+
140
+ response_json = await response.json()
141
+
142
+ # Add entries from this page to our collection
143
+ page_entries = [
144
+ FileOrFolderInfo(
145
+ is_folder=file[".tag"] == "folder",
146
+ name=file["name"],
147
+ size=file["size"] if file[".tag"] == "file" else None,
148
+ property_groups=parse_property_groups(
149
+ file["property_groups"]
150
+ )
151
+ if "property_groups" in file
152
+ else None,
153
+ )
154
+ for file in response_json["entries"]
155
+ ]
156
+ all_entries.extend(page_entries)
157
+
158
+ # Check if there are more pages
159
+ has_more = response_json.get("has_more", False)
160
+ cursor = response_json.get("cursor")
161
+
162
+ return all_entries
163
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
164
+ raise
165
+ except Exception as err:
166
+ raise DropboxUnknownException(str(err)) from err
167
+
168
+ async def get_metadata(
169
+ self, path: str, include_property_groups: list[str] | None = None
170
+ ) -> FileOrFolderInfo:
171
+ """Get metadata for a file or folder.
172
+
173
+ Args:
174
+ path: The Dropbox path to get metadata for.
175
+ include_property_groups: Optional list of property group template IDs to include.
176
+
177
+ Returns:
178
+ FileOrFolderInfo containing metadata for the file or folder.
179
+ """
180
+ try:
181
+ access_token = await self._auth.async_get_access_token()
182
+
183
+ headers = {
184
+ "Authorization": f"Bearer {access_token}",
185
+ "Content-Type": "application/json",
186
+ }
187
+
188
+ data = {
189
+ "include_deleted": False,
190
+ "include_has_explicit_shared_members": False,
191
+ "include_media_info": False,
192
+ "path": path,
193
+ }
194
+
195
+ if include_property_groups is not None:
196
+ data["include_property_groups"] = {
197
+ ".tag": "filter_some",
198
+ "filter_some": include_property_groups,
199
+ }
200
+
201
+ url = f"{DROPBOX_API_BASE_URL}/2/files/get_metadata"
202
+
203
+ async with self._websession.post(
204
+ url, headers=headers, json=data
205
+ ) as response:
206
+ await handle_common_errors(response)
207
+
208
+ response_json = await response.json()
209
+
210
+ return FileOrFolderInfo(
211
+ is_folder=response_json[".tag"] == "folder",
212
+ name=response_json["name"],
213
+ size=response_json["size"]
214
+ if response_json[".tag"] == "file"
215
+ else None,
216
+ property_groups=parse_property_groups(
217
+ response_json["property_groups"]
218
+ )
219
+ if "property_groups" in response_json
220
+ else None,
221
+ )
222
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
223
+ raise
224
+ except Exception as err:
225
+ raise DropboxUnknownException(str(err)) from err
226
+
227
+ async def upload_file(
228
+ self,
229
+ path: str,
230
+ file_stream: AsyncIterator[bytes],
231
+ property_groups: list[PropertyGroup] | None = None,
232
+ ) -> None:
233
+ """Upload a file to Dropbox using chunked upload sessions.
234
+
235
+ Args:
236
+ path: The Dropbox path where the file should be uploaded.
237
+ file_stream: An async iterator of bytes to upload.
238
+ property_groups: Optional list of property groups to attach to the uploaded file.
239
+ """
240
+ try:
241
+ CHUNK_SIZE = 16 * 1024 * 1024 # 16MB chunks
242
+
243
+ # Always use chunked upload approach
244
+ await self._upload_file_chunked(
245
+ CONTENT_API_BASE_URL, path, file_stream, CHUNK_SIZE, property_groups
246
+ )
247
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
248
+ raise
249
+ except Exception as err:
250
+ raise DropboxUnknownException(str(err)) from err
251
+
252
+ async def _upload_file_chunked(
253
+ self,
254
+ content_api_url: str,
255
+ path: str,
256
+ file_stream: AsyncIterator[bytes],
257
+ chunk_size: int,
258
+ property_groups: list[PropertyGroup] | None = None,
259
+ ) -> None:
260
+ """Upload a file using chunked upload sessions, streaming chunks without loading entire file into memory."""
261
+ session_id = None
262
+ offset = 0
263
+ is_first_chunk = True
264
+
265
+ # Buffer to accumulate chunks until we have enough for a full chunk
266
+ buffer = b""
267
+
268
+ async for data_chunk in file_stream:
269
+ buffer += data_chunk
270
+
271
+ # Process full chunks from the buffer
272
+ while len(buffer) >= chunk_size:
273
+ chunk_data = buffer[:chunk_size]
274
+ buffer = buffer[chunk_size:]
275
+
276
+ if is_first_chunk:
277
+ # Start upload session with first chunk
278
+ session_id = await self._start_upload_session(
279
+ content_api_url, chunk_data
280
+ )
281
+ is_first_chunk = False
282
+ else:
283
+ # Append chunk to existing session
284
+ await self._append_to_upload_session(
285
+ content_api_url, session_id, offset, chunk_data
286
+ )
287
+
288
+ offset += len(chunk_data)
289
+
290
+ # Handle remaining data in buffer (final partial chunk)
291
+ if buffer:
292
+ if is_first_chunk:
293
+ # If this is the only chunk, start session with it
294
+ session_id = await self._start_upload_session(content_api_url, buffer)
295
+ else:
296
+ # Append final chunk
297
+ await self._append_to_upload_session(
298
+ content_api_url, session_id, offset, buffer
299
+ )
300
+ offset += len(buffer)
301
+
302
+ # Finish upload session
303
+ await self._finish_upload_session(
304
+ content_api_url, session_id, offset, path, property_groups
305
+ )
306
+
307
+ async def _start_upload_session(
308
+ self, content_api_url: str, chunk_data: bytes
309
+ ) -> str:
310
+ """Start an upload session with the first chunk."""
311
+ access_token = await self._auth.async_get_access_token()
312
+
313
+ headers = {
314
+ "Authorization": f"Bearer {access_token}",
315
+ "Dropbox-API-Arg": json.dumps({"close": False}),
316
+ "Content-Type": "application/octet-stream",
317
+ }
318
+
319
+ async with self._websession.post(
320
+ f"{content_api_url}/2/files/upload_session/start",
321
+ headers=headers,
322
+ data=chunk_data,
323
+ ) as response:
324
+ await handle_common_errors(response)
325
+
326
+ response_json = await response.json()
327
+ return response_json["session_id"]
328
+
329
+ async def _append_to_upload_session(
330
+ self, content_api_url: str, session_id: str, offset: int, chunk_data: bytes
331
+ ) -> None:
332
+ """Append a chunk to an existing upload session."""
333
+ access_token = await self._auth.async_get_access_token()
334
+
335
+ headers = {
336
+ "Authorization": f"Bearer {access_token}",
337
+ "Dropbox-API-Arg": json.dumps(
338
+ {
339
+ "close": False,
340
+ "cursor": {
341
+ "session_id": session_id,
342
+ "offset": offset,
343
+ },
344
+ }
345
+ ),
346
+ "Content-Type": "application/octet-stream",
347
+ }
348
+
349
+ async with self._websession.post(
350
+ f"{content_api_url}/2/files/upload_session/append_v2",
351
+ headers=headers,
352
+ data=chunk_data,
353
+ ) as response:
354
+ await handle_common_errors(response)
355
+
356
+ async def _finish_upload_session(
357
+ self,
358
+ content_api_url: str,
359
+ session_id: str,
360
+ total_size: int,
361
+ path: str,
362
+ property_groups: list[PropertyGroup] | None = None,
363
+ ) -> None:
364
+ """Finish an upload session."""
365
+ # Build commit data
366
+ commit_data = {
367
+ "path": path,
368
+ "mode": "add",
369
+ "autorename": True,
370
+ "mute": True,
371
+ "strict_conflict": True,
372
+ }
373
+
374
+ # Add property groups if provided
375
+ if property_groups:
376
+ commit_data["property_groups"] = [
377
+ {
378
+ "template_id": group.template_id,
379
+ "fields": [
380
+ {"name": field.name, "value": field.value}
381
+ for field in group.fields
382
+ ],
383
+ }
384
+ for group in property_groups
385
+ ]
386
+
387
+ access_token = await self._auth.async_get_access_token()
388
+
389
+ headers = {
390
+ "Authorization": f"Bearer {access_token}",
391
+ "Dropbox-API-Arg": json.dumps(
392
+ {
393
+ "commit": commit_data,
394
+ "cursor": {
395
+ "session_id": session_id,
396
+ "offset": total_size,
397
+ },
398
+ }
399
+ ),
400
+ "Content-Type": "application/octet-stream",
401
+ }
402
+
403
+ # Send empty data for finish request
404
+ async with self._websession.post(
405
+ f"{content_api_url}/2/files/upload_session/finish",
406
+ headers=headers,
407
+ data=b"",
408
+ ) as response:
409
+ await handle_common_errors(response)
410
+
411
+ async def download_file(self, path: str) -> AsyncIterator[bytes]:
412
+ """Download a file from Dropbox as an async byte stream.
413
+
414
+ Args:
415
+ path: The Dropbox path to the file to download.
416
+
417
+ Yields:
418
+ Chunks of file content as bytes.
419
+ """
420
+ try:
421
+ access_token = await self._auth.async_get_access_token()
422
+
423
+ headers = {
424
+ "Authorization": f"Bearer {access_token}",
425
+ "Dropbox-API-Arg": json.dumps({"path": path}),
426
+ }
427
+
428
+ url = f"{CONTENT_API_BASE_URL}/2/files/download"
429
+ chunk_size = 1024 * 1024 # 1MB
430
+
431
+ async with self._websession.post(url, headers=headers) as response:
432
+ response.raise_for_status()
433
+ async for chunk in response.content.iter_chunked(chunk_size):
434
+ if chunk:
435
+ yield chunk
436
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
437
+ raise
438
+ except Exception as err:
439
+ raise DropboxUnknownException(str(err)) from err
440
+
441
+ async def create_folder(self, path: str) -> FileOrFolderInfo:
442
+ """Create a folder in Dropbox.
443
+
444
+ Args:
445
+ path: The Dropbox path where the folder should be created.
446
+
447
+ Returns:
448
+ FileOrFolderInfo containing metadata for the created folder.
449
+ """
450
+ try:
451
+ access_token = await self._auth.async_get_access_token()
452
+
453
+ headers = {
454
+ "Authorization": f"Bearer {access_token}",
455
+ "Content-Type": "application/json",
456
+ }
457
+
458
+ url = f"{DROPBOX_API_BASE_URL}/2/files/create_folder_v2"
459
+ payload = {
460
+ "path": path,
461
+ "autorename": False,
462
+ }
463
+
464
+ async with self._websession.post(
465
+ url, headers=headers, json=payload
466
+ ) as response:
467
+ await handle_common_errors(response)
468
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
469
+ raise
470
+ except Exception as err:
471
+ raise DropboxUnknownException(str(err)) from err
472
+
473
+ async def delete_file(self, path: str) -> None:
474
+ """Delete a file (moves to Dropbox trash).
475
+
476
+ Args:
477
+ path: The Dropbox path of the file to delete.
478
+ """
479
+ try:
480
+ access_token = await self._auth.async_get_access_token()
481
+
482
+ headers = {
483
+ "Authorization": f"Bearer {access_token}",
484
+ "Content-Type": "application/json",
485
+ }
486
+
487
+ url = f"{DROPBOX_API_BASE_URL}/2/files/delete_v2"
488
+ payload = {"path": path}
489
+
490
+ async with self._websession.post(
491
+ url, headers=headers, json=payload
492
+ ) as response:
493
+ await handle_common_errors(response)
494
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
495
+ raise
496
+ except Exception as err:
497
+ raise DropboxUnknownException(str(err)) from err
498
+
499
+ async def list_property_templates(self) -> list[str]:
500
+ """List property group templates for the current user.
501
+
502
+ Returns:
503
+ A list of template IDs for property group templates.
504
+ """
505
+ try:
506
+ access_token = await self._auth.async_get_access_token()
507
+
508
+ headers = {
509
+ "Authorization": f"Bearer {access_token}",
510
+ "Content-Type": "",
511
+ }
512
+
513
+ url = f"{DROPBOX_API_BASE_URL}/2/file_properties/templates/list_for_user"
514
+
515
+ async with self._websession.post(url, headers=headers) as response:
516
+ await handle_common_errors(response)
517
+
518
+ response_json = await response.json()
519
+ return response_json["template_ids"]
520
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
521
+ raise
522
+ except Exception as err:
523
+ raise DropboxUnknownException(str(err)) from err
524
+
525
+ async def add_property_template(self, template: PropertyTemplate) -> str:
526
+ """Add a property group template for the current user.
527
+
528
+ Args:
529
+ template: The property template to add.
530
+
531
+ Returns:
532
+ A string containing the template ID.
533
+ """
534
+ try:
535
+ access_token = await self._auth.async_get_access_token()
536
+
537
+ headers = {
538
+ "Authorization": f"Bearer {access_token}",
539
+ "Content-Type": "application/json",
540
+ }
541
+
542
+ # Convert the template to the format expected by the API
543
+ data = {
544
+ "name": template.name,
545
+ "description": template.description,
546
+ "fields": [
547
+ {
548
+ "name": field.name,
549
+ "description": field.description,
550
+ "type": field.type,
551
+ }
552
+ for field in template.fields
553
+ ],
554
+ }
555
+
556
+ url = f"{DROPBOX_API_BASE_URL}/2/file_properties/templates/add_for_user"
557
+
558
+ async with self._websession.post(
559
+ url, headers=headers, json=data
560
+ ) as response:
561
+ await handle_common_errors(response)
562
+
563
+ response_json = await response.json()
564
+ return response_json["template_id"]
565
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
566
+ raise
567
+ except Exception as err:
568
+ raise DropboxUnknownException(str(err)) from err
569
+
570
+ async def remove_property_template(self, template_id: str) -> None:
571
+ """Remove a property group template for the current user.
572
+
573
+ Args:
574
+ template_id: The ID of the property template to remove.
575
+ """
576
+ try:
577
+ access_token = await self._auth.async_get_access_token()
578
+
579
+ headers = {
580
+ "Authorization": f"Bearer {access_token}",
581
+ "Content-Type": "application/json",
582
+ }
583
+
584
+ data = {"template_id": template_id}
585
+
586
+ url = f"{DROPBOX_API_BASE_URL}/2/file_properties/templates/remove_for_user"
587
+
588
+ async with self._websession.post(
589
+ url, headers=headers, json=data
590
+ ) as response:
591
+ await handle_common_errors(response)
592
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
593
+ raise
594
+ except Exception as err:
595
+ raise DropboxUnknownException(str(err)) from err
596
+
597
+ async def get_property_template(self, template_id: str) -> PropertyTemplate:
598
+ """Get a property group template for the current user.
599
+
600
+ Args:
601
+ template_id: The ID of the property template to get.
602
+
603
+ Returns:
604
+ The PropertyTemplate.
605
+ """
606
+ try:
607
+ access_token = await self._auth.async_get_access_token()
608
+
609
+ headers = {
610
+ "Authorization": f"Bearer {access_token}",
611
+ "Content-Type": "application/json",
612
+ }
613
+
614
+ data = {"template_id": template_id}
615
+
616
+ url = f"{DROPBOX_API_BASE_URL}/2/file_properties/templates/get_for_user"
617
+
618
+ async with self._websession.post(
619
+ url, headers=headers, json=data
620
+ ) as response:
621
+ await handle_common_errors(response)
622
+
623
+ response_json = await response.json()
624
+
625
+ fields = [
626
+ PropertyField(
627
+ name=field["name"],
628
+ description=field["description"],
629
+ type=field["type"][".tag"]
630
+ if isinstance(field["type"], dict)
631
+ else field["type"],
632
+ )
633
+ for field in response_json["fields"]
634
+ ]
635
+
636
+ return PropertyTemplate(
637
+ name=response_json["name"],
638
+ description=response_json["description"],
639
+ fields=fields,
640
+ )
641
+ except (DropboxAuthException, DropboxFileOrFolderNotFoundException):
642
+ raise
643
+ except Exception as err:
644
+ raise DropboxUnknownException(str(err)) from err
@@ -14,3 +14,12 @@ class DropboxAuthException(Exception):
14
14
  """Initialize exception."""
15
15
  super().__init__(status)
16
16
  self.status = status
17
+
18
+
19
+ class DropboxUnknownException(Exception):
20
+ """Raised when an unknown Dropbox error occurs."""
21
+
22
+ def __init__(self, status):
23
+ """Initialize exception."""
24
+ super().__init__(status)
25
+ self.status = status
@@ -1,572 +0,0 @@
1
- from collections.abc import AsyncIterator
2
- import json
3
-
4
- from aiohttp import ClientResponse
5
-
6
- from .auth import Auth
7
- from .exceptions import DropboxAuthException, DropboxFileOrFolderNotFoundException
8
- from .models import (
9
- AccountInfo,
10
- FileOrFolderInfo,
11
- PropertyField,
12
- PropertyFieldValue,
13
- PropertyGroup,
14
- PropertyTemplate,
15
- )
16
-
17
- DROPBOX_API_BASE_URL = "https://api.dropboxapi.com"
18
- CONTENT_API_BASE_URL = "https://content.dropboxapi.com"
19
-
20
-
21
- def parse_property_groups(property_groups: list[dict]) -> list[PropertyGroup]:
22
- """Parse the property groups from the response."""
23
- return [
24
- PropertyGroup(
25
- template_id=group["template_id"],
26
- fields=[
27
- PropertyFieldValue(name=field["name"], value=field["value"])
28
- for field in group["fields"]
29
- ],
30
- )
31
- for group in property_groups
32
- ]
33
-
34
-
35
- async def handle_common_errors(response: ClientResponse) -> None:
36
- """Handle common errors."""
37
- response_json = await response.json()
38
-
39
- error_summary = response_json.get("error_summary")
40
-
41
- if error_summary is not None:
42
- if error_summary.startswith("invalid_access_token/"):
43
- raise DropboxAuthException("Unauthorized")
44
- if error_summary.startswith("path/not_found/"):
45
- raise DropboxFileOrFolderNotFoundException("File or folder not found")
46
-
47
- response.raise_for_status()
48
-
49
-
50
- class DropboxAPIClient:
51
- """Lightweight Dropbox API client."""
52
-
53
- def __init__(self, auth: Auth) -> None:
54
- """Initialize the API client."""
55
- self._websession = auth.websession
56
- self._auth = auth
57
-
58
- async def get_account_info(self) -> AccountInfo:
59
- """Get information about the current account.
60
-
61
- Returns:
62
- AccountInfo containing account_id, email, and display_name.
63
- """
64
- access_token = await self._auth.async_get_access_token()
65
-
66
- headers = {
67
- "Authorization": f"Bearer {access_token}",
68
- "Content-Type": "",
69
- }
70
-
71
- url = f"{DROPBOX_API_BASE_URL}/2/users/get_current_account"
72
-
73
- async with self._websession.post(url, headers=headers) as response:
74
- await handle_common_errors(response)
75
-
76
- response_json = await response.json()
77
-
78
- return AccountInfo(
79
- account_id=response_json["account_id"],
80
- email=response_json["email"],
81
- display_name=response_json["name"]["display_name"],
82
- )
83
-
84
- async def list_folder(
85
- self, path: str, include_property_groups: list[str] | None = None
86
- ) -> list[FileOrFolderInfo]:
87
- """List the contents of a folder with pagination support."""
88
-
89
- access_token = await self._auth.async_get_access_token()
90
-
91
- headers = {
92
- "Authorization": f"Bearer {access_token}",
93
- "Content-Type": "application/json",
94
- }
95
-
96
- # Initial request data
97
- data = {
98
- "include_deleted": False,
99
- "include_has_explicit_shared_members": False,
100
- "include_media_info": False,
101
- "include_mounted_folders": True,
102
- "include_non_downloadable_files": False,
103
- "path": path,
104
- "recursive": False,
105
- }
106
-
107
- if include_property_groups is not None:
108
- data["include_property_groups"] = {
109
- ".tag": "filter_some",
110
- "filter_some": include_property_groups,
111
- }
112
-
113
- all_entries = []
114
- cursor = None
115
- has_more = True
116
-
117
- while has_more:
118
- # Use list_folder endpoint for first request, list_folder/continue for subsequent ones
119
- if cursor is None:
120
- url = f"{DROPBOX_API_BASE_URL}/2/files/list_folder"
121
- request_data = data
122
- else:
123
- url = f"{DROPBOX_API_BASE_URL}/2/files/list_folder/continue"
124
- request_data = {"cursor": cursor}
125
-
126
- async with self._websession.post(
127
- url, headers=headers, json=request_data
128
- ) as response:
129
- await handle_common_errors(response)
130
-
131
- response_json = await response.json()
132
-
133
- # Add entries from this page to our collection
134
- page_entries = [
135
- FileOrFolderInfo(
136
- is_folder=file[".tag"] == "folder",
137
- name=file["name"],
138
- size=file["size"] if file[".tag"] == "file" else None,
139
- property_groups=parse_property_groups(file["property_groups"])
140
- if "property_groups" in file
141
- else None,
142
- )
143
- for file in response_json["entries"]
144
- ]
145
- all_entries.extend(page_entries)
146
-
147
- # Check if there are more pages
148
- has_more = response_json.get("has_more", False)
149
- cursor = response_json.get("cursor")
150
-
151
- return all_entries
152
-
153
- async def get_metadata(
154
- self, path: str, include_property_groups: list[str] | None = None
155
- ) -> FileOrFolderInfo:
156
- """Get metadata for a file or folder.
157
-
158
- Args:
159
- path: The Dropbox path to get metadata for.
160
- include_property_groups: Optional list of property group template IDs to include.
161
-
162
- Returns:
163
- FileOrFolderInfo containing metadata for the file or folder.
164
- """
165
- access_token = await self._auth.async_get_access_token()
166
-
167
- headers = {
168
- "Authorization": f"Bearer {access_token}",
169
- "Content-Type": "application/json",
170
- }
171
-
172
- data = {
173
- "include_deleted": False,
174
- "include_has_explicit_shared_members": False,
175
- "include_media_info": False,
176
- "path": path,
177
- }
178
-
179
- if include_property_groups is not None:
180
- data["include_property_groups"] = {
181
- ".tag": "filter_some",
182
- "filter_some": include_property_groups,
183
- }
184
-
185
- url = f"{DROPBOX_API_BASE_URL}/2/files/get_metadata"
186
-
187
- async with self._websession.post(url, headers=headers, json=data) as response:
188
- await handle_common_errors(response)
189
-
190
- response_json = await response.json()
191
-
192
- return FileOrFolderInfo(
193
- is_folder=response_json[".tag"] == "folder",
194
- name=response_json["name"],
195
- size=response_json["size"] if response_json[".tag"] == "file" else None,
196
- property_groups=parse_property_groups(response_json["property_groups"])
197
- if "property_groups" in response_json
198
- else None,
199
- )
200
-
201
- async def upload_file(
202
- self,
203
- path: str,
204
- file_stream: AsyncIterator[bytes],
205
- property_groups: list[PropertyGroup] | None = None,
206
- ) -> None:
207
- """Upload a file to Dropbox using chunked upload sessions.
208
-
209
- Args:
210
- path: The Dropbox path where the file should be uploaded.
211
- file_stream: An async iterator of bytes to upload.
212
- property_groups: Optional list of property groups to attach to the uploaded file.
213
- """
214
- CHUNK_SIZE = 16 * 1024 * 1024 # 16MB chunks
215
-
216
- # Always use chunked upload approach
217
- await self._upload_file_chunked(
218
- CONTENT_API_BASE_URL, path, file_stream, CHUNK_SIZE, property_groups
219
- )
220
-
221
- async def _upload_file_chunked(
222
- self,
223
- content_api_url: str,
224
- path: str,
225
- file_stream: AsyncIterator[bytes],
226
- chunk_size: int,
227
- property_groups: list[PropertyGroup] | None = None,
228
- ) -> None:
229
- """Upload a file using chunked upload sessions, streaming chunks without loading entire file into memory."""
230
- session_id = None
231
- offset = 0
232
- is_first_chunk = True
233
-
234
- # Buffer to accumulate chunks until we have enough for a full chunk
235
- buffer = b""
236
-
237
- async for data_chunk in file_stream:
238
- buffer += data_chunk
239
-
240
- # Process full chunks from the buffer
241
- while len(buffer) >= chunk_size:
242
- chunk_data = buffer[:chunk_size]
243
- buffer = buffer[chunk_size:]
244
-
245
- if is_first_chunk:
246
- # Start upload session with first chunk
247
- session_id = await self._start_upload_session(
248
- content_api_url, chunk_data
249
- )
250
- is_first_chunk = False
251
- else:
252
- # Append chunk to existing session
253
- await self._append_to_upload_session(
254
- content_api_url, session_id, offset, chunk_data
255
- )
256
-
257
- offset += len(chunk_data)
258
-
259
- # Handle remaining data in buffer (final partial chunk)
260
- if buffer:
261
- if is_first_chunk:
262
- # If this is the only chunk, start session with it
263
- session_id = await self._start_upload_session(content_api_url, buffer)
264
- else:
265
- # Append final chunk
266
- await self._append_to_upload_session(
267
- content_api_url, session_id, offset, buffer
268
- )
269
- offset += len(buffer)
270
-
271
- # Finish upload session
272
- await self._finish_upload_session(
273
- content_api_url, session_id, offset, path, property_groups
274
- )
275
-
276
- async def _start_upload_session(
277
- self, content_api_url: str, chunk_data: bytes
278
- ) -> str:
279
- """Start an upload session with the first chunk."""
280
- access_token = await self._auth.async_get_access_token()
281
-
282
- headers = {
283
- "Authorization": f"Bearer {access_token}",
284
- "Dropbox-API-Arg": json.dumps({"close": False}),
285
- "Content-Type": "application/octet-stream",
286
- }
287
-
288
- async with self._websession.post(
289
- f"{content_api_url}/2/files/upload_session/start",
290
- headers=headers,
291
- data=chunk_data,
292
- ) as response:
293
- await handle_common_errors(response)
294
-
295
- response_json = await response.json()
296
- return response_json["session_id"]
297
-
298
- async def _append_to_upload_session(
299
- self, content_api_url: str, session_id: str, offset: int, chunk_data: bytes
300
- ) -> None:
301
- """Append a chunk to an existing upload session."""
302
- access_token = await self._auth.async_get_access_token()
303
-
304
- headers = {
305
- "Authorization": f"Bearer {access_token}",
306
- "Dropbox-API-Arg": json.dumps(
307
- {
308
- "close": False,
309
- "cursor": {
310
- "session_id": session_id,
311
- "offset": offset,
312
- },
313
- }
314
- ),
315
- "Content-Type": "application/octet-stream",
316
- }
317
-
318
- async with self._websession.post(
319
- f"{content_api_url}/2/files/upload_session/append_v2",
320
- headers=headers,
321
- data=chunk_data,
322
- ) as response:
323
- await handle_common_errors(response)
324
-
325
- async def _finish_upload_session(
326
- self,
327
- content_api_url: str,
328
- session_id: str,
329
- total_size: int,
330
- path: str,
331
- property_groups: list[PropertyGroup] | None = None,
332
- ) -> None:
333
- """Finish an upload session."""
334
- # Build commit data
335
- commit_data = {
336
- "path": path,
337
- "mode": "add",
338
- "autorename": True,
339
- "mute": True,
340
- "strict_conflict": True,
341
- }
342
-
343
- # Add property groups if provided
344
- if property_groups:
345
- commit_data["property_groups"] = [
346
- {
347
- "template_id": group.template_id,
348
- "fields": [
349
- {"name": field.name, "value": field.value}
350
- for field in group.fields
351
- ],
352
- }
353
- for group in property_groups
354
- ]
355
-
356
- access_token = await self._auth.async_get_access_token()
357
-
358
- headers = {
359
- "Authorization": f"Bearer {access_token}",
360
- "Dropbox-API-Arg": json.dumps(
361
- {
362
- "commit": commit_data,
363
- "cursor": {
364
- "session_id": session_id,
365
- "offset": total_size,
366
- },
367
- }
368
- ),
369
- "Content-Type": "application/octet-stream",
370
- }
371
-
372
- # Send empty data for finish request
373
- async with self._websession.post(
374
- f"{content_api_url}/2/files/upload_session/finish",
375
- headers=headers,
376
- data=b"",
377
- ) as response:
378
- await handle_common_errors(response)
379
-
380
- async def download_file(self, path: str) -> AsyncIterator[bytes]:
381
- """Download a file from Dropbox as an async byte stream.
382
-
383
- Args:
384
- path: The Dropbox path to the file to download.
385
-
386
- Yields:
387
- Chunks of file content as bytes.
388
- """
389
- access_token = await self._auth.async_get_access_token()
390
-
391
- headers = {
392
- "Authorization": f"Bearer {access_token}",
393
- "Dropbox-API-Arg": json.dumps({"path": path}),
394
- }
395
-
396
- url = f"{CONTENT_API_BASE_URL}/2/files/download"
397
- chunk_size = 1024 * 1024 # 1MB
398
-
399
- async with self._websession.post(url, headers=headers) as response:
400
- response.raise_for_status()
401
- async for chunk in response.content.iter_chunked(chunk_size):
402
- if chunk:
403
- yield chunk
404
-
405
- async def create_folder(self, path: str) -> FileOrFolderInfo:
406
- """Create a folder in Dropbox.
407
-
408
- Args:
409
- path: The Dropbox path where the folder should be created.
410
-
411
- Returns:
412
- FileOrFolderInfo containing metadata for the created folder.
413
- """
414
- access_token = await self._auth.async_get_access_token()
415
-
416
- headers = {
417
- "Authorization": f"Bearer {access_token}",
418
- "Content-Type": "application/json",
419
- }
420
-
421
- url = f"{DROPBOX_API_BASE_URL}/2/files/create_folder_v2"
422
- payload = {
423
- "path": path,
424
- "autorename": False,
425
- }
426
-
427
- async with self._websession.post(
428
- url, headers=headers, json=payload
429
- ) as response:
430
- await handle_common_errors(response)
431
-
432
- async def delete_file(self, path: str) -> None:
433
- """Delete a file (moves to Dropbox trash).
434
-
435
- Args:
436
- path: The Dropbox path of the file to delete.
437
- """
438
- access_token = await self._auth.async_get_access_token()
439
-
440
- headers = {
441
- "Authorization": f"Bearer {access_token}",
442
- "Content-Type": "application/json",
443
- }
444
-
445
- url = f"{DROPBOX_API_BASE_URL}/2/files/delete_v2"
446
- payload = {"path": path}
447
-
448
- async with self._websession.post(
449
- url, headers=headers, json=payload
450
- ) as response:
451
- await handle_common_errors(response)
452
-
453
- async def list_property_templates(self) -> list[str]:
454
- """List property group templates for the current user.
455
-
456
- Returns:
457
- A list of template IDs for property group templates.
458
- """
459
- access_token = await self._auth.async_get_access_token()
460
-
461
- headers = {
462
- "Authorization": f"Bearer {access_token}",
463
- "Content-Type": "",
464
- }
465
-
466
- url = f"{DROPBOX_API_BASE_URL}/2/file_properties/templates/list_for_user"
467
-
468
- async with self._websession.post(url, headers=headers) as response:
469
- await handle_common_errors(response)
470
-
471
- response_json = await response.json()
472
- return response_json["template_ids"]
473
-
474
- async def add_property_template(self, template: PropertyTemplate) -> str:
475
- """Add a property group template for the current user.
476
-
477
- Args:
478
- template: The property template to add.
479
-
480
- Returns:
481
- A string containing the template ID.
482
- """
483
- access_token = await self._auth.async_get_access_token()
484
-
485
- headers = {
486
- "Authorization": f"Bearer {access_token}",
487
- "Content-Type": "application/json",
488
- }
489
-
490
- # Convert the template to the format expected by the API
491
- data = {
492
- "name": template.name,
493
- "description": template.description,
494
- "fields": [
495
- {
496
- "name": field.name,
497
- "description": field.description,
498
- "type": field.type,
499
- }
500
- for field in template.fields
501
- ],
502
- }
503
-
504
- url = f"{DROPBOX_API_BASE_URL}/2/file_properties/templates/add_for_user"
505
-
506
- async with self._websession.post(url, headers=headers, json=data) as response:
507
- await handle_common_errors(response)
508
-
509
- response_json = await response.json()
510
- return response_json["template_id"]
511
-
512
- async def remove_property_template(self, template_id: str) -> None:
513
- """Remove a property group template for the current user.
514
-
515
- Args:
516
- template_id: The ID of the property template to remove.
517
- """
518
- access_token = await self._auth.async_get_access_token()
519
-
520
- headers = {
521
- "Authorization": f"Bearer {access_token}",
522
- "Content-Type": "application/json",
523
- }
524
-
525
- data = {"template_id": template_id}
526
-
527
- url = f"{DROPBOX_API_BASE_URL}/2/file_properties/templates/remove_for_user"
528
-
529
- async with self._websession.post(url, headers=headers, json=data) as response:
530
- await handle_common_errors(response)
531
-
532
- async def get_property_template(self, template_id: str) -> PropertyTemplate:
533
- """Get a property group template for the current user.
534
-
535
- Args:
536
- template_id: The ID of the property template to get.
537
-
538
- Returns:
539
- The PropertyTemplate.
540
- """
541
- access_token = await self._auth.async_get_access_token()
542
-
543
- headers = {
544
- "Authorization": f"Bearer {access_token}",
545
- "Content-Type": "application/json",
546
- }
547
-
548
- data = {"template_id": template_id}
549
-
550
- url = f"{DROPBOX_API_BASE_URL}/2/file_properties/templates/get_for_user"
551
-
552
- async with self._websession.post(url, headers=headers, json=data) as response:
553
- await handle_common_errors(response)
554
-
555
- response_json = await response.json()
556
-
557
- fields = [
558
- PropertyField(
559
- name=field["name"],
560
- description=field["description"],
561
- type=field["type"][".tag"]
562
- if isinstance(field["type"], dict)
563
- else field["type"],
564
- )
565
- for field in response_json["fields"]
566
- ]
567
-
568
- return PropertyTemplate(
569
- name=response_json["name"],
570
- description=response_json["description"],
571
- fields=fields,
572
- )