async-mega-py 2.2.1__tar.gz → 2.3.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.
Files changed (24) hide show
  1. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/PKG-INFO +25 -26
  2. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/README.md +24 -25
  3. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/pyproject.toml +1 -1
  4. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/__main__.py +1 -1
  5. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/client.py +2 -2
  6. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/core.py +13 -4
  7. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/data_structures.py +52 -20
  8. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/vault.py +19 -6
  9. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/LICENSE +0 -0
  10. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/__init__.py +0 -0
  11. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/api.py +0 -0
  12. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/auth.py +0 -0
  13. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/chunker.py +0 -0
  14. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/crypto.py +0 -0
  15. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/download.py +0 -0
  16. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/env.py +0 -0
  17. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/errors.py +0 -0
  18. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/filesystem.py +0 -0
  19. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/progress/__init__.py +0 -0
  20. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/progress/rich_progress.py +0 -0
  21. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/py.typed +0 -0
  22. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/transfer_it.py +0 -0
  23. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/upload.py +0 -0
  24. {async_mega_py-2.2.1 → async_mega_py-2.3.0}/src/mega/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: async-mega.py
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: Python library for the Mega.nz and Transfer.it API
5
5
  Keywords: api,downloader,mega,mega.nz,transfer.it
6
6
  Author: NTFSvolume
@@ -79,7 +79,7 @@ Please check [`src/mega/data_structures.py`](src/mega/data_structures.py) for de
79
79
  > [!TIP]
80
80
  > You can run the commands bellow in an interactive python (the asyncio REPL). Try them in real time as is, using `async/await` keywords!
81
81
  >
82
- > ```uv run -p3.12 --with async-mega-py python -m asyncio```
82
+ > `uv run -p3.12 --with async-mega-py python -m asyncio`
83
83
 
84
84
  ```python
85
85
  from mega.client import MegaNzClient
@@ -99,7 +99,7 @@ from mega.client import MegaNzClient
99
99
  # Also works without using it as a context manager, but you have the responsability to close the session at the end
100
100
  mega = MegaNzClient()
101
101
  await mega.login() # login using a temporary anonymous account
102
- # await mega.close()
102
+ # await mega.close()
103
103
 
104
104
  ```
105
105
 
@@ -163,7 +163,7 @@ await mega.download(uploaded_file, output_dir)
163
163
 
164
164
  # Download a public file
165
165
  file_url = "https://mega.nz/file/vJ9EBJBC#vA1XpbngXcnN7lqXEWGQFPDc5ZlG6yltCHwGN-0O2LQ"
166
- public_handle, public_key = mega.parse_file_url(file_url)
166
+ public_handle, public_key = mega.parse_file_url(file_url)
167
167
  await mega.download_public_file(public_handle, public_key, output_dir)
168
168
 
169
169
  # Download a public folder
@@ -186,7 +186,7 @@ print (result.is_folder)
186
186
  > You can show a progress bar on the terminal for each download/upload by calling them within the `progress_bar` context manager (needs optional dependency `rich` to be installed):
187
187
 
188
188
  ```python
189
- with mega.progress_bar:
189
+ with mega.progress_bar:
190
190
  results = await mega.download_public_folder(public_handle, public_key, output_dir / "with_bar")
191
191
  ```
192
192
 
@@ -264,16 +264,14 @@ The output will be:
264
264
 
265
265
  ```json
266
266
  {
267
- "oImwb6nN": "/tests/script.js",
268
- "FIXitv4F": "/tests/scripts",
269
- "0fPFklV3": "/tests/scripts/notes.txt",
270
- "l9zkz1GU": "/tests/scripts/script.js",
271
- "E4LqT4EF": "/tests/scripts/styles.css",
267
+ "oImwb6nN": "/tests/script.js",
268
+ "FIXitv4F": "/tests/scripts",
269
+ "0fPFklV3": "/tests/scripts/notes.txt",
270
+ "l9zkz1GU": "/tests/scripts/script.js",
271
+ "E4LqT4EF": "/tests/scripts/styles.css"
272
272
  }
273
273
  ```
274
274
 
275
-
276
-
277
275
  ```python
278
276
  # Get the path to a node
279
277
  fs.absolute_path("0fPFklV3") # /tests/scripts/notes.txt
@@ -294,24 +292,25 @@ for node in fs.iterdir(folder.id, recursive=True):
294
292
  print(fs.absolute_path(node.id))
295
293
 
296
294
  ```
295
+
297
296
  Output will be:
298
297
 
299
298
  ```json
300
299
  [
301
- "/tests/logo.png",
302
- "/tests/logo.png",
303
- "/tests/script.js",
304
- "/tests/scripts",
305
- "/tests/scripts/notes.txt",
306
- "/tests/scripts/script.js",
307
- "/tests/scripts/styles.css",
308
- "/tests/setup.sh",
309
- "/tests/utils.py",
300
+ "/tests/logo.png",
301
+ "/tests/logo.png",
302
+ "/tests/script.js",
303
+ "/tests/scripts",
304
+ "/tests/scripts/notes.txt",
305
+ "/tests/scripts/script.js",
306
+ "/tests/scripts/styles.css",
307
+ "/tests/setup.sh",
308
+ "/tests/utils.py"
310
309
  ]
311
310
  ```
312
311
 
313
312
  > [!IMPORTANT]
314
- > Mega's filesystem is *not* POSIX-compliant: multiple nodes may have the same path.
313
+ > Mega's filesystem is _not_ POSIX-compliant: multiple nodes may have the same path.
315
314
  >
316
315
  > If 2 nodes have the same path, `find` will throw an error.
317
316
 
@@ -352,10 +351,10 @@ mega-py --help
352
351
  ```
353
352
 
354
353
  ```powershell
355
- Usage: mega-py [OPTIONS] COMMAND [ARGS]...
354
+ Usage: mega-py [OPTIONS] COMMAND [ARGS]...
356
355
 
357
- CLI app for the Mega.nz and Transfer.it. Set MEGA_NZ_EMAIL and MEGA_NZ_PASSWORD
358
- enviroment variables to use them as credentials for Mega
356
+ CLI app for the Mega.nz and Transfer.it. Set MEGA_EMAIL and MEGA_PWD
357
+ enviroment variables to use them as credentials for Mega
359
358
 
360
359
  ╭─ Options ──────────────────────────────────────────────────────────────────────╮
361
360
  │ --verbose -v Increase verbosity (-v shows debug logs, -vv │
@@ -371,6 +370,6 @@ Usage: mega-py [OPTIONS] COMMAND [ARGS]...
371
370
  ```
372
371
 
373
372
  > [!TIP]
374
- > The CLI app does *not* accept login credentials, but you can still use your account by setting up the `MEGA_EMAIL` and `MEGA_PWD` enviroment variables
373
+ > The CLI app does _not_ accept login credentials, but you can still use your account by setting up the `MEGA_EMAIL` and `MEGA_PWD` enviroment variables
375
374
  >
376
375
  > It will also read them from an `.env` file (if found)
@@ -46,7 +46,7 @@ Please check [`src/mega/data_structures.py`](src/mega/data_structures.py) for de
46
46
  > [!TIP]
47
47
  > You can run the commands bellow in an interactive python (the asyncio REPL). Try them in real time as is, using `async/await` keywords!
48
48
  >
49
- > ```uv run -p3.12 --with async-mega-py python -m asyncio```
49
+ > `uv run -p3.12 --with async-mega-py python -m asyncio`
50
50
 
51
51
  ```python
52
52
  from mega.client import MegaNzClient
@@ -66,7 +66,7 @@ from mega.client import MegaNzClient
66
66
  # Also works without using it as a context manager, but you have the responsability to close the session at the end
67
67
  mega = MegaNzClient()
68
68
  await mega.login() # login using a temporary anonymous account
69
- # await mega.close()
69
+ # await mega.close()
70
70
 
71
71
  ```
72
72
 
@@ -130,7 +130,7 @@ await mega.download(uploaded_file, output_dir)
130
130
 
131
131
  # Download a public file
132
132
  file_url = "https://mega.nz/file/vJ9EBJBC#vA1XpbngXcnN7lqXEWGQFPDc5ZlG6yltCHwGN-0O2LQ"
133
- public_handle, public_key = mega.parse_file_url(file_url)
133
+ public_handle, public_key = mega.parse_file_url(file_url)
134
134
  await mega.download_public_file(public_handle, public_key, output_dir)
135
135
 
136
136
  # Download a public folder
@@ -153,7 +153,7 @@ print (result.is_folder)
153
153
  > You can show a progress bar on the terminal for each download/upload by calling them within the `progress_bar` context manager (needs optional dependency `rich` to be installed):
154
154
 
155
155
  ```python
156
- with mega.progress_bar:
156
+ with mega.progress_bar:
157
157
  results = await mega.download_public_folder(public_handle, public_key, output_dir / "with_bar")
158
158
  ```
159
159
 
@@ -231,16 +231,14 @@ The output will be:
231
231
 
232
232
  ```json
233
233
  {
234
- "oImwb6nN": "/tests/script.js",
235
- "FIXitv4F": "/tests/scripts",
236
- "0fPFklV3": "/tests/scripts/notes.txt",
237
- "l9zkz1GU": "/tests/scripts/script.js",
238
- "E4LqT4EF": "/tests/scripts/styles.css",
234
+ "oImwb6nN": "/tests/script.js",
235
+ "FIXitv4F": "/tests/scripts",
236
+ "0fPFklV3": "/tests/scripts/notes.txt",
237
+ "l9zkz1GU": "/tests/scripts/script.js",
238
+ "E4LqT4EF": "/tests/scripts/styles.css"
239
239
  }
240
240
  ```
241
241
 
242
-
243
-
244
242
  ```python
245
243
  # Get the path to a node
246
244
  fs.absolute_path("0fPFklV3") # /tests/scripts/notes.txt
@@ -261,24 +259,25 @@ for node in fs.iterdir(folder.id, recursive=True):
261
259
  print(fs.absolute_path(node.id))
262
260
 
263
261
  ```
262
+
264
263
  Output will be:
265
264
 
266
265
  ```json
267
266
  [
268
- "/tests/logo.png",
269
- "/tests/logo.png",
270
- "/tests/script.js",
271
- "/tests/scripts",
272
- "/tests/scripts/notes.txt",
273
- "/tests/scripts/script.js",
274
- "/tests/scripts/styles.css",
275
- "/tests/setup.sh",
276
- "/tests/utils.py",
267
+ "/tests/logo.png",
268
+ "/tests/logo.png",
269
+ "/tests/script.js",
270
+ "/tests/scripts",
271
+ "/tests/scripts/notes.txt",
272
+ "/tests/scripts/script.js",
273
+ "/tests/scripts/styles.css",
274
+ "/tests/setup.sh",
275
+ "/tests/utils.py"
277
276
  ]
278
277
  ```
279
278
 
280
279
  > [!IMPORTANT]
281
- > Mega's filesystem is *not* POSIX-compliant: multiple nodes may have the same path.
280
+ > Mega's filesystem is _not_ POSIX-compliant: multiple nodes may have the same path.
282
281
  >
283
282
  > If 2 nodes have the same path, `find` will throw an error.
284
283
 
@@ -319,10 +318,10 @@ mega-py --help
319
318
  ```
320
319
 
321
320
  ```powershell
322
- Usage: mega-py [OPTIONS] COMMAND [ARGS]...
321
+ Usage: mega-py [OPTIONS] COMMAND [ARGS]...
323
322
 
324
- CLI app for the Mega.nz and Transfer.it. Set MEGA_NZ_EMAIL and MEGA_NZ_PASSWORD
325
- enviroment variables to use them as credentials for Mega
323
+ CLI app for the Mega.nz and Transfer.it. Set MEGA_EMAIL and MEGA_PWD
324
+ enviroment variables to use them as credentials for Mega
326
325
 
327
326
  ╭─ Options ──────────────────────────────────────────────────────────────────────╮
328
327
  │ --verbose -v Increase verbosity (-v shows debug logs, -vv │
@@ -338,6 +337,6 @@ Usage: mega-py [OPTIONS] COMMAND [ARGS]...
338
337
  ```
339
338
 
340
339
  > [!TIP]
341
- > The CLI app does *not* accept login credentials, but you can still use your account by setting up the `MEGA_EMAIL` and `MEGA_PWD` enviroment variables
340
+ > The CLI app does _not_ accept login credentials, but you can still use your account by setting up the `MEGA_EMAIL` and `MEGA_PWD` enviroment variables
342
341
  >
343
342
  > It will also read them from an `.env` file (if found)
@@ -34,7 +34,7 @@ license = "Apache-2.0"
34
34
  license-files = ["LICENSE"]
35
35
  readme = "README.md"
36
36
  requires-python = ">=3.11"
37
- version = "2.2.1"
37
+ version = "2.3.0"
38
38
 
39
39
  [project.optional-dependencies]
40
40
  cli = [
@@ -100,8 +100,8 @@ async def dump(output_dir: Path = CWD) -> None:
100
100
  fs = await mega.get_filesystem()
101
101
  out = output_dir / "filesystem.json"
102
102
  out.parent.mkdir(exist_ok=True)
103
- logger.info(f"Creating filesystem dump at '{out!s}'")
104
103
  out.write_text(json.dumps(fs.dump(), indent=2, ensure_ascii=False))
104
+ logger.info(f"Created filesystem dump at '{out!s}'")
105
105
 
106
106
 
107
107
  @app.command()
@@ -192,7 +192,7 @@ class MegaNzClient(APIContextManager[MegaAPI]):
192
192
  full_key = b64_to_a32(public_key)
193
193
  crypto = Crypto.decompose(full_key)
194
194
  file_info = await self._core.request_file_info(public_handle, is_public=True)
195
- output_name = self._core.decrypt_attrs(file_info._at, crypto.key).name
195
+ output_name = self._core.decrypt_attrs(file_info._at, crypto.key, public_handle).name
196
196
  output_path = Path(output_dir or ".") / output_name
197
197
  return await self._core.download_file(file_info, crypto, output_path)
198
198
 
@@ -277,7 +277,7 @@ class MegaNzClient(APIContextManager[MegaAPI]):
277
277
  full_key = b64_to_a32(public_key)
278
278
  key = Crypto.decompose(full_key).key
279
279
  file_info = await self._core.request_file_info(public_handle, is_public=True)
280
- name = self._core.decrypt_attrs(file_info._at, key).name
280
+ name = self._core.decrypt_attrs(file_info._at, key, public_handle).name
281
281
  return dataclasses.replace(file_info, name=name)
282
282
 
283
283
  async def import_public_file(
@@ -6,7 +6,7 @@ import logging
6
6
  import random
7
7
  import re
8
8
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any, NamedTuple
9
+ from typing import TYPE_CHECKING, Any, NamedTuple, final
10
10
 
11
11
  import yarl
12
12
  from Crypto.Cipher import AES
@@ -23,6 +23,7 @@ from mega.crypto import (
23
23
  encrypt_key,
24
24
  )
25
25
  from mega.data_structures import (
26
+ _EMPTY_ATTRS,
26
27
  AccountStatsSerialized,
27
28
  Attributes,
28
29
  Crypto,
@@ -62,6 +63,7 @@ class PublicURLInfo(NamedTuple):
62
63
  return self.selected_folder or self.selected_file
63
64
 
64
65
 
66
+ @final
65
67
  @dataclasses.dataclass(slots=True)
66
68
  class MegaCore:
67
69
  api: MegaAPI
@@ -226,7 +228,7 @@ class MegaCore:
226
228
  if node.type in (NodeType.FILE, NodeType.FOLDER):
227
229
  full_key, share_key = self.vault[node]
228
230
  crypto = Crypto.decompose(full_key, node.type, share_key)
229
- attributes = self.decrypt_attrs(node._a, crypto.key)
231
+ attributes = self.decrypt_attrs(node._a, crypto.key, node.id)
230
232
 
231
233
  else:
232
234
  name = {
@@ -402,9 +404,16 @@ class MegaCore:
402
404
  self.clear_cache()
403
405
 
404
406
  @staticmethod
405
- def decrypt_attrs(attrs: str, key: tuple[int, ...]) -> Attributes:
407
+ def _decrypt_attrs(attrs: str, key: tuple[int, ...]) -> Attributes:
406
408
  return Attributes.parse(decrypt_attr(b64_url_decode(attrs), key))
407
409
 
410
+ @classmethod
411
+ def decrypt_attrs(cls, attrs: str, key: tuple[int, ...], node_id: NodeID) -> Attributes:
412
+ attributes = cls._decrypt_attrs(attrs, key)
413
+ if attributes is _EMPTY_ATTRS:
414
+ logger.warning("Unable to decrypt attributes of node %s", node_id)
415
+ return attributes
416
+
408
417
  async def import_file(
409
418
  self,
410
419
  public_handle: NodeID,
@@ -415,7 +424,7 @@ class MegaCore:
415
424
  full_key = b64_to_a32(public_key)
416
425
  key = Crypto.decompose(full_key).key
417
426
  file_info = await self.request_file_info(public_handle, is_public=True)
418
- name = self.decrypt_attrs(file_info._at, key).name
427
+ name = self.decrypt_attrs(file_info._at, key, public_handle).name
419
428
  encrypted_key = a32_to_base64(encrypt_key(full_key, self.vault.master_key))
420
429
  attributes = b64_url_encode(encrypt_attr({"n": name}, key))
421
430
 
@@ -13,7 +13,7 @@ import dataclasses
13
13
  import time
14
14
  from enum import IntEnum
15
15
  from types import MappingProxyType
16
- from typing import TYPE_CHECKING, Any, ClassVar, Final, Self, TypeAlias, TypedDict
16
+ from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Self, TypeAlias, TypedDict
17
17
 
18
18
  if TYPE_CHECKING:
19
19
  from collections.abc import Mapping
@@ -27,6 +27,10 @@ TimeStamp: TypeAlias = int
27
27
 
28
28
  SharedKeys: TypeAlias = dict[NodeID, tuple[int, ...]]
29
29
 
30
+ NodeKey = tuple[int, int, int, int]
31
+ ComposedFileKey = tuple[int, int, int, int, int, int, int, int]
32
+ ComposedFolderKey = tuple[int, int, int, int]
33
+
30
34
 
31
35
  class ByteSize(int):
32
36
  def human_readable(self) -> str:
@@ -67,7 +71,7 @@ class NodeSerialized(TypedDict):
67
71
  h: NodeID # ID
68
72
  p: NodeID # Parent ID
69
73
  u: NotRequired[UserID] # Owner (user ID), not present in transfer.it nodes
70
- t: ReadOnly[NodeType]
74
+ t: ReadOnly[Literal[0, 1, 2, 3, 4]] # Node type
71
75
  a: str # Serialized attributes
72
76
  ts: TimeStamp # creation date
73
77
 
@@ -76,6 +80,7 @@ class NodeSerialized(TypedDict):
76
80
  sk: NotRequired[str] # Share key, only present present in shared (public) files / folder
77
81
 
78
82
  s: NotRequired[int] # size (files only)
83
+ fa: NotRequired[str] # additional file attributes for multimedia files (ex: thumbail)
79
84
 
80
85
 
81
86
  class OwnerKeys(TypedDict):
@@ -173,23 +178,23 @@ class FileInfo(_DictDumper):
173
178
 
174
179
  @dataclasses.dataclass(slots=True, frozen=True)
175
180
  class Crypto(_DictDumper):
176
- key: tuple[int, int, int, int]
181
+ key: NodeKey
177
182
  iv: tuple[int, int]
178
183
  meta_mac: tuple[int, int]
179
184
 
180
- full_key: tuple[int, int, int, int, int, int, int, int]
181
- share_key: tuple[int, ...] | None
185
+ full_key: ComposedFileKey | ComposedFolderKey
186
+ share_key: NodeKey | None
182
187
 
183
188
  @classmethod
184
189
  def compose(
185
190
  cls,
186
- key: tuple[int, ...],
187
- iv: tuple[int, ...],
188
- meta_mac: tuple[int, ...],
191
+ key: NodeKey,
192
+ iv: tuple[int, int],
193
+ meta_mac: tuple[int, int],
189
194
  node_type: NodeType = NodeType.FILE,
190
195
  ) -> Crypto:
191
196
  if node_type is NodeType.FILE:
192
- full_key = (
197
+ composed_key = (
193
198
  key[0] ^ iv[0],
194
199
  key[1] ^ iv[1],
195
200
  key[2] ^ meta_mac[0],
@@ -197,30 +202,47 @@ class Crypto(_DictDumper):
197
202
  *iv,
198
203
  *meta_mac,
199
204
  )
205
+ if len(composed_key) != 8:
206
+ raise RuntimeError(f"Invalid key, expected key len for files is 8, got {len(composed_key) = }")
207
+
200
208
  else:
201
- full_key = *key, *iv, *meta_mac
209
+ if len(key) != 4:
210
+ raise RuntimeError(f"Invalid key, expected key len for folders is 4, got {len(key) = }")
202
211
 
203
- return Crypto(key, iv, meta_mac, full_key, None) # pyright: ignore[reportArgumentType]
212
+ iv = ()
213
+ meta_mac = ()
214
+ composed_key = key
215
+
216
+ return Crypto(key, iv, meta_mac, composed_key, None) # pyright: ignore[reportArgumentType]
204
217
 
205
218
  @classmethod
206
219
  def decompose(
207
220
  cls,
208
- full_key: tuple[int, ...],
221
+ composed_key: ComposedFileKey | ComposedFolderKey,
209
222
  node_type: NodeType = NodeType.FILE,
210
- share_key: tuple[int, ...] | None = None,
223
+ share_key: NodeKey | None = None,
211
224
  ) -> Crypto:
212
- iv = full_key[4:6]
213
- meta_mac = full_key[6:8]
214
- key = full_key
225
+
226
+ key = composed_key[:4]
227
+ iv = composed_key[4:6]
228
+ meta_mac = composed_key[6:8]
215
229
  if node_type is NodeType.FILE:
230
+ if len(composed_key) != 8:
231
+ raise RuntimeError(f"Invalid key, expected key len for files is 8, got {len(composed_key) = }")
232
+
216
233
  key = (
217
234
  key[0] ^ iv[0],
218
235
  key[1] ^ iv[1],
219
236
  key[2] ^ meta_mac[0],
220
237
  key[3] ^ meta_mac[1],
221
238
  )
239
+ assert iv
240
+ assert meta_mac
241
+ else:
242
+ if len(composed_key) != 4:
243
+ raise RuntimeError(f"Invalid key, expected key len for folders is 4, got {len(composed_key) = }")
222
244
 
223
- return Crypto(key, iv, meta_mac, full_key, share_key) # pyright: ignore[reportArgumentType]
245
+ return Crypto(key, iv, meta_mac, composed_key, share_key) # pyright: ignore[reportArgumentType]
224
246
 
225
247
  @classmethod
226
248
  def from_dump(cls, dump: dict[str, Any]) -> Self:
@@ -267,6 +289,7 @@ class Node(_DictDumper):
267
289
  keys: dict[str, str] = {}
268
290
 
269
291
  size = node.get("s")
292
+
270
293
  return Node(
271
294
  id=node["h"],
272
295
  parent_id=node["p"],
@@ -316,6 +339,8 @@ class Attributes(_DictDumper):
316
339
 
317
340
  @classmethod
318
341
  def parse(cls, attrs: AttributesSerialized) -> Self:
342
+ if not attrs:
343
+ return _EMPTY_ATTRS
319
344
  return cls(
320
345
  name=attrs.get("n", ""),
321
346
  label=_LABELS[attrs.get("lbl", 0)],
@@ -334,6 +359,9 @@ class Attributes(_DictDumper):
334
359
  }
335
360
 
336
361
 
362
+ _EMPTY_ATTRS = Attributes("")
363
+
364
+
337
365
  @dataclasses.dataclass(slots=True, frozen=True)
338
366
  class AccountBalance(_DictDumper):
339
367
  amount: float
@@ -375,6 +403,7 @@ class AccountStatsSerialized(TypedDict):
375
403
  mxfer: NotRequired[int] # maximum transfer allowance
376
404
  srvratio: float # Ratio of PRO transfer quota that is able to be served to others
377
405
  suntil: NotRequired[TimeStamp] # Expiration time of the currently active plan
406
+ rtt: NotRequired[int] # ???
378
407
 
379
408
 
380
409
  @dataclasses.dataclass(slots=True, frozen=True)
@@ -393,10 +422,13 @@ class StorageMetrics(_DictDumper):
393
422
  class StorageQuota(_DictDumper):
394
423
  used: ByteSize
395
424
  max: ByteSize
396
-
397
- percent: int
398
425
  threshold: int
399
426
 
427
+ percent: int = dataclasses.field(init=False)
428
+
429
+ def __post_init__(self) -> None:
430
+ object.__setattr__(self, "percent", round(self.ratio * 100))
431
+
400
432
  @property
401
433
  def ratio(self) -> float:
402
434
  return self.used / self.max
@@ -416,7 +448,6 @@ class StorageQuota(_DictDumper):
416
448
  return cls(
417
449
  used=used,
418
450
  max=max,
419
- percent=int(used / max * 100),
420
451
  threshold=threshold // 100,
421
452
  )
422
453
 
@@ -449,6 +480,7 @@ class AccountStats(_DictParser, _DictDumper):
449
480
  @classmethod
450
481
  def parse(cls, data: AccountStatsSerialized) -> Self: # pyright: ignore[reportIncompatibleMethodOverride]
451
482
  transfer = None
483
+
452
484
  if max_transfer := data.get("mxfer"):
453
485
  assert "caxfer" in data
454
486
  assert "csxfer" in data
@@ -1,12 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
- from typing import TYPE_CHECKING, Final
4
+ from typing import TYPE_CHECKING, Final, cast
5
5
 
6
6
  from mega.crypto import b64_to_a32, decrypt_key
7
7
 
8
8
  if TYPE_CHECKING:
9
- from mega.data_structures import GetNodesResponse, Node, NodeID, SharedKeys, UserID
9
+ from mega.data_structures import (
10
+ ComposedFileKey,
11
+ ComposedFolderKey,
12
+ GetNodesResponse,
13
+ Node,
14
+ NodeID,
15
+ SharedKeys,
16
+ UserID,
17
+ )
10
18
 
11
19
 
12
20
  _EXPORTED: Final = "EXP"
@@ -35,7 +43,7 @@ class MegaVault:
35
43
  if key := new_keys.get(node_id):
36
44
  self._shared_keys.setdefault(owner, {})[node_id] = key
37
45
 
38
- def __getitem__(self, node: Node) -> tuple[tuple[int, ...], tuple[int, ...] | None]:
46
+ def __getitem__(self, node: Node) -> tuple[ComposedFileKey | ComposedFolderKey, tuple[int, ...] | None]:
39
47
  share_key: tuple[int, ...] | None = None
40
48
 
41
49
  # my files/folders
@@ -50,18 +58,23 @@ class MegaVault:
50
58
 
51
59
  # files shared with me
52
60
  elif node.owner in self._shared_keys:
53
- real_node_id, share_key = next(p for p in self._shared_keys[node.owner].items() if p[0] in node.keys)
61
+ real_node_id = next(node_id for node_id in node.keys if node_id in self._shared_keys[node.owner])
62
+ share_key = self._shared_keys[node.owner][real_node_id]
54
63
  full_key = decrypt_key(b64_to_a32(node.keys[real_node_id]), share_key)
55
64
 
56
65
  # public files/folders
57
66
  elif share_key := self._shared_keys[_EXPORTED].get(node.id):
58
- encrypted_key = b64_to_a32(next(iter(node.keys.values())))
67
+ real_node_id = next(node_id for node_id in node.keys if node_id in self._shared_keys[_EXPORTED])
68
+ encrypted_key = b64_to_a32(node.keys[real_node_id])
59
69
  full_key = decrypt_key(encrypted_key, share_key)
60
70
 
61
71
  else:
62
72
  raise RuntimeError(f"We do not have keys for {node = }")
63
73
 
64
- return full_key, share_key
74
+ if len(full_key) not in (4, 8):
75
+ raise RuntimeError(f"Invalid key len found for node {node.id} ({len(full_key)})")
76
+
77
+ return cast("ComposedFileKey | ComposedFolderKey", full_key), share_key
65
78
 
66
79
  def save_public_key(self, node_id: NodeID, share_key: tuple[int, ...]) -> None:
67
80
  self._shared_keys[_EXPORTED][node_id] = share_key
File without changes