python-dropbox-api 0.1.0__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.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-dropbox-api
3
+ Version: 0.1.0
4
+ Summary:
5
+ Author: Brandon Rothweiler
6
+ Author-email: 2292715+bdr99@users.noreply.github.com
7
+ Requires-Python: >=3.13
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Dist: aiohttp (>=3.13.0,<4.0.0)
12
+ Description-Content-Type: text/markdown
13
+
14
+ # python-dropbox-api
15
+
16
+ This is a lightweight wrapper for the Dropbox API intended for use in Home Assistant.
@@ -0,0 +1,3 @@
1
+ # python-dropbox-api
2
+
3
+ This is a lightweight wrapper for the Dropbox API intended for use in Home Assistant.
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "python-dropbox-api"
3
+ version = "0.1.0"
4
+ description = ""
5
+ authors = [
6
+ {name = "Brandon Rothweiler",email = "2292715+bdr99@users.noreply.github.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "aiohttp (>=3.13.0,<4.0.0)"
12
+ ]
13
+
14
+ [tool.poetry]
15
+ packages = [
16
+ {include = "python_dropbox_api"},
17
+ {include = "python_dropbox_api/py.typed"},
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
22
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,22 @@
1
+ from .auth import Auth
2
+ from .client import DropboxAPIClient
3
+ from .exceptions import DropboxAuthException, DropboxFileOrFolderNotFoundException
4
+ from .models import (
5
+ AccountInfo,
6
+ PropertyField,
7
+ PropertyFieldValue,
8
+ PropertyGroup,
9
+ PropertyTemplate,
10
+ )
11
+
12
+ __all__ = [
13
+ "AccountInfo",
14
+ "Auth",
15
+ "DropboxAPIClient",
16
+ "DropboxAuthException",
17
+ "DropboxFileOrFolderNotFoundException",
18
+ "PropertyField",
19
+ "PropertyFieldValue",
20
+ "PropertyGroup",
21
+ "PropertyTemplate",
22
+ ]
@@ -0,0 +1,17 @@
1
+ from abc import ABC, abstractmethod
2
+ import logging
3
+
4
+ from aiohttp import ClientSession
5
+
6
+
7
+ class Auth(ABC):
8
+ """Class to get a valid access token."""
9
+
10
+ def __init__(self, websession: ClientSession) -> None:
11
+ """Initialize the auth."""
12
+ self._logger = logging.getLogger(__name__)
13
+ self.websession = websession
14
+
15
+ @abstractmethod
16
+ async def async_get_access_token(self) -> str:
17
+ """Return a valid access token."""
@@ -0,0 +1,572 @@
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
+ )
@@ -0,0 +1,16 @@
1
+ class DropboxFileOrFolderNotFoundException(Exception):
2
+ """Raised when a file or folder is not found."""
3
+
4
+ def __init__(self, status):
5
+ """Initialize exception."""
6
+ super().__init__(status)
7
+ self.status = status
8
+
9
+
10
+ class DropboxAuthException(Exception):
11
+ """Raised when a Dropbox authentication exception occurs."""
12
+
13
+ def __init__(self, status):
14
+ """Initialize exception."""
15
+ super().__init__(status)
16
+ self.status = status
@@ -0,0 +1,78 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+
5
+ @dataclass(frozen=True, kw_only=True)
6
+ class UploadSessionStartResponse:
7
+ """Response from upload session start."""
8
+
9
+ session_id: str
10
+
11
+
12
+ @dataclass(frozen=True, kw_only=True)
13
+ class PropertyField:
14
+ """A field in a property template."""
15
+
16
+ name: str
17
+ description: str
18
+ type: Literal["string", "number", "boolean", "date", "list", "multi_select"]
19
+
20
+
21
+ @dataclass(frozen=True, kw_only=True)
22
+ class PropertyTemplate:
23
+ """A property template for files and folders."""
24
+
25
+ name: str
26
+ description: str
27
+ fields: list[PropertyField]
28
+
29
+
30
+ @dataclass(frozen=True, kw_only=True)
31
+ class PropertyTemplateResponse:
32
+ """Response from creating a property template."""
33
+
34
+ template_id: str
35
+
36
+
37
+ @dataclass(frozen=True, kw_only=True)
38
+ class PropertyTemplateGetResponse:
39
+ """Response from getting a property template."""
40
+
41
+ name: str
42
+ description: str
43
+ fields: list[PropertyField]
44
+
45
+
46
+ @dataclass(frozen=True, kw_only=True)
47
+ class PropertyFieldValue:
48
+ """A field value in a property group."""
49
+
50
+ name: str
51
+ value: str
52
+
53
+
54
+ @dataclass(frozen=True, kw_only=True)
55
+ class PropertyGroup:
56
+ """A property group containing fields."""
57
+
58
+ fields: list[PropertyFieldValue]
59
+ template_id: str
60
+
61
+
62
+ @dataclass(frozen=True, kw_only=True)
63
+ class FileOrFolderInfo:
64
+ """File information."""
65
+
66
+ is_folder: bool
67
+ name: str
68
+ size: int | None
69
+ property_groups: list[PropertyGroup] | None
70
+
71
+
72
+ @dataclass(frozen=True, kw_only=True)
73
+ class AccountInfo:
74
+ """Info about an account."""
75
+
76
+ account_id: str
77
+ email: str
78
+ display_name: str
File without changes