async-mega-py 2.2.2__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.2 → async_mega_py-2.3.0}/PKG-INFO +1 -1
  2. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/pyproject.toml +1 -1
  3. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/__main__.py +1 -1
  4. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/client.py +2 -2
  5. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/core.py +13 -4
  6. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/data_structures.py +49 -19
  7. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/vault.py +15 -4
  8. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/LICENSE +0 -0
  9. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/README.md +0 -0
  10. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/__init__.py +0 -0
  11. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/api.py +0 -0
  12. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/auth.py +0 -0
  13. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/chunker.py +0 -0
  14. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/crypto.py +0 -0
  15. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/download.py +0 -0
  16. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/env.py +0 -0
  17. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/errors.py +0 -0
  18. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/filesystem.py +0 -0
  19. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/progress/__init__.py +0 -0
  20. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/progress/rich_progress.py +0 -0
  21. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/py.typed +0 -0
  22. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/transfer_it.py +0 -0
  23. {async_mega_py-2.2.2 → async_mega_py-2.3.0}/src/mega/upload.py +0 -0
  24. {async_mega_py-2.2.2 → 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.2
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
@@ -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.2"
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
 
@@ -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[Literal[0, 1, 2, 3, 4, 5]] # Node type
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
 
@@ -174,23 +178,23 @@ class FileInfo(_DictDumper):
174
178
 
175
179
  @dataclasses.dataclass(slots=True, frozen=True)
176
180
  class Crypto(_DictDumper):
177
- key: tuple[int, int, int, int]
181
+ key: NodeKey
178
182
  iv: tuple[int, int]
179
183
  meta_mac: tuple[int, int]
180
184
 
181
- full_key: tuple[int, int, int, int, int, int, int, int]
182
- share_key: tuple[int, ...] | None
185
+ full_key: ComposedFileKey | ComposedFolderKey
186
+ share_key: NodeKey | None
183
187
 
184
188
  @classmethod
185
189
  def compose(
186
190
  cls,
187
- key: tuple[int, ...],
188
- iv: tuple[int, ...],
189
- meta_mac: tuple[int, ...],
191
+ key: NodeKey,
192
+ iv: tuple[int, int],
193
+ meta_mac: tuple[int, int],
190
194
  node_type: NodeType = NodeType.FILE,
191
195
  ) -> Crypto:
192
196
  if node_type is NodeType.FILE:
193
- full_key = (
197
+ composed_key = (
194
198
  key[0] ^ iv[0],
195
199
  key[1] ^ iv[1],
196
200
  key[2] ^ meta_mac[0],
@@ -198,30 +202,47 @@ class Crypto(_DictDumper):
198
202
  *iv,
199
203
  *meta_mac,
200
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
+
201
208
  else:
202
- 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) = }")
211
+
212
+ iv = ()
213
+ meta_mac = ()
214
+ composed_key = key
203
215
 
204
- return Crypto(key, iv, meta_mac, full_key, None) # pyright: ignore[reportArgumentType]
216
+ return Crypto(key, iv, meta_mac, composed_key, None) # pyright: ignore[reportArgumentType]
205
217
 
206
218
  @classmethod
207
219
  def decompose(
208
220
  cls,
209
- full_key: tuple[int, ...],
221
+ composed_key: ComposedFileKey | ComposedFolderKey,
210
222
  node_type: NodeType = NodeType.FILE,
211
- share_key: tuple[int, ...] | None = None,
223
+ share_key: NodeKey | None = None,
212
224
  ) -> Crypto:
213
- iv = full_key[4:6]
214
- meta_mac = full_key[6:8]
215
- key = full_key
225
+
226
+ key = composed_key[:4]
227
+ iv = composed_key[4:6]
228
+ meta_mac = composed_key[6:8]
216
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
+
217
233
  key = (
218
234
  key[0] ^ iv[0],
219
235
  key[1] ^ iv[1],
220
236
  key[2] ^ meta_mac[0],
221
237
  key[3] ^ meta_mac[1],
222
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) = }")
223
244
 
224
- 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]
225
246
 
226
247
  @classmethod
227
248
  def from_dump(cls, dump: dict[str, Any]) -> Self:
@@ -318,6 +339,8 @@ class Attributes(_DictDumper):
318
339
 
319
340
  @classmethod
320
341
  def parse(cls, attrs: AttributesSerialized) -> Self:
342
+ if not attrs:
343
+ return _EMPTY_ATTRS
321
344
  return cls(
322
345
  name=attrs.get("n", ""),
323
346
  label=_LABELS[attrs.get("lbl", 0)],
@@ -336,6 +359,9 @@ class Attributes(_DictDumper):
336
359
  }
337
360
 
338
361
 
362
+ _EMPTY_ATTRS = Attributes("")
363
+
364
+
339
365
  @dataclasses.dataclass(slots=True, frozen=True)
340
366
  class AccountBalance(_DictDumper):
341
367
  amount: float
@@ -377,6 +403,7 @@ class AccountStatsSerialized(TypedDict):
377
403
  mxfer: NotRequired[int] # maximum transfer allowance
378
404
  srvratio: float # Ratio of PRO transfer quota that is able to be served to others
379
405
  suntil: NotRequired[TimeStamp] # Expiration time of the currently active plan
406
+ rtt: NotRequired[int] # ???
380
407
 
381
408
 
382
409
  @dataclasses.dataclass(slots=True, frozen=True)
@@ -395,10 +422,13 @@ class StorageMetrics(_DictDumper):
395
422
  class StorageQuota(_DictDumper):
396
423
  used: ByteSize
397
424
  max: ByteSize
398
-
399
- percent: int
400
425
  threshold: int
401
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
+
402
432
  @property
403
433
  def ratio(self) -> float:
404
434
  return self.used / self.max
@@ -418,7 +448,6 @@ class StorageQuota(_DictDumper):
418
448
  return cls(
419
449
  used=used,
420
450
  max=max,
421
- percent=int(used / max * 100),
422
451
  threshold=threshold // 100,
423
452
  )
424
453
 
@@ -451,6 +480,7 @@ class AccountStats(_DictParser, _DictDumper):
451
480
  @classmethod
452
481
  def parse(cls, data: AccountStatsSerialized) -> Self: # pyright: ignore[reportIncompatibleMethodOverride]
453
482
  transfer = None
483
+
454
484
  if max_transfer := data.get("mxfer"):
455
485
  assert "caxfer" in data
456
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
@@ -63,7 +71,10 @@ class MegaVault:
63
71
  else:
64
72
  raise RuntimeError(f"We do not have keys for {node = }")
65
73
 
66
- 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
67
78
 
68
79
  def save_public_key(self, node_id: NodeID, share_key: tuple[int, ...]) -> None:
69
80
  self._shared_keys[_EXPORTED][node_id] = share_key
File without changes
File without changes