async-mega-py 2.3.1__tar.gz → 2.4.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.
Files changed (24) hide show
  1. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/PKG-INFO +2 -2
  2. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/pyproject.toml +3 -3
  3. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/core.py +14 -5
  4. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/filesystem.py +47 -113
  5. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/LICENSE +0 -0
  6. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/README.md +0 -0
  7. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/__init__.py +0 -0
  8. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/__main__.py +0 -0
  9. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/api.py +0 -0
  10. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/auth.py +0 -0
  11. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/chunker.py +0 -0
  12. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/client.py +0 -0
  13. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/crypto.py +0 -0
  14. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/data_structures.py +0 -0
  15. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/download.py +0 -0
  16. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/env.py +0 -0
  17. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/errors.py +0 -0
  18. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/progress/__init__.py +0 -0
  19. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/progress/rich_progress.py +0 -0
  20. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/py.typed +0 -0
  21. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/transfer_it.py +0 -0
  22. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/upload.py +0 -0
  23. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/utils.py +0 -0
  24. {async_mega_py-2.3.1 → async_mega_py-2.4.1}/src/mega/vault.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: async-mega.py
3
- Version: 2.3.1
3
+ Version: 2.4.1
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
7
7
  Author-email: NTFSvolume <NTFSvolume@proton.me>
8
8
  License-Expression: Apache-2.0
9
9
  License-File: LICENSE
10
- Classifier: Development Status :: 4 - Beta
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Framework :: AsyncIO
13
13
  Classifier: Intended Audience :: Developers
@@ -2,7 +2,7 @@
2
2
  name = "async-mega.py"
3
3
  authors = [{name = "NTFSvolume", email = "NTFSvolume@proton.me"}]
4
4
  classifiers = [
5
- "Development Status :: 4 - Beta",
5
+ "Development Status :: 5 - Production/Stable",
6
6
  "Environment :: Console",
7
7
  "Framework :: AsyncIO",
8
8
  "Intended Audience :: Developers",
@@ -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.3.1"
37
+ version = "2.4.1"
38
38
 
39
39
  [project.optional-dependencies]
40
40
  cli = [
@@ -123,7 +123,7 @@ requires = ["uv_build<=0.10.11"]
123
123
 
124
124
  [dependency-groups]
125
125
  dev = [
126
- "prek>=0.3.6",
126
+ "prek>=0.4.0",
127
127
  "pytest-asyncio>=0.25.0",
128
128
  "pytest>=8.3.5",
129
129
  "ruff==0.15.6"
@@ -106,18 +106,27 @@ class MegaCore:
106
106
  return result.public_handle, result.public_key, result.selected_node
107
107
 
108
108
  @staticmethod
109
- def parse_url(url: str | yarl.URL) -> PublicURLInfo:
110
- """Parse a public URL"""
109
+ def ensure_v2_url(url: str | yarl.URL) -> yarl.URL:
110
+ """Transform an URL in V1 format to v2"""
111
111
  url = yarl.URL(url)
112
112
  Site.MEGA.check_host(url)
113
113
  new_url = transform_v1_url(url)
114
114
  if new_url != url:
115
115
  logger.info(f"Transformed v1 URL from {url} to {new_url}")
116
+ return new_url
116
117
 
117
- url = new_url
118
- if not url.fragment:
118
+ @staticmethod
119
+ def parse_url(url: str | yarl.URL, *, check_key: bool = True) -> PublicURLInfo:
120
+ """Parse a public URL"""
121
+ url = MegaCore.ensure_v2_url(url)
122
+ info = MegaCore._parse_public_v2_url(url)
123
+ if check_key and not info.public_key:
119
124
  raise ValidationError(f"Public key missing from {url}")
125
+ return info
120
126
 
127
+ @staticmethod
128
+ def _parse_public_v2_url(url: yarl.URL, /) -> PublicURLInfo:
129
+ """Parse a public URL"""
121
130
  match url.parts[1:]:
122
131
  case ["file", public_handle]:
123
132
  return PublicURLInfo(False, public_handle, url.fragment)
@@ -422,7 +431,7 @@ class MegaCore:
422
431
  ) -> Node:
423
432
  """Import the public file into user account"""
424
433
  full_key = b64_to_a32(public_key)
425
- key = Crypto.decompose(full_key).key
434
+ key = Crypto.decompose(full_key).key # pyright: ignore[reportArgumentType]
426
435
  file_info = await self.request_file_info(public_handle, is_public=True)
427
436
  name = self.decrypt_attrs(file_info._at, key, public_handle).name
428
437
  encrypted_key = a32_to_base64(encrypt_key(full_key, self.vault.master_key))
@@ -12,7 +12,7 @@ from types import MappingProxyType
12
12
  from typing import TYPE_CHECKING, Any, Final, NamedTuple, Self
13
13
 
14
14
  from mega.data_structures import Node, NodeID, NodeType, _DictDumper
15
- from mega.errors import MultipleNodesFoundError
15
+ from mega.errors import MultipleNodesFoundError, ValidationError
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from collections.abc import Generator, Iterable, Iterator
@@ -48,12 +48,15 @@ def _resolve_paths(walker: _NodeWalker, *roots: Node) -> Generator[_NodeLookup]:
48
48
 
49
49
  @dataclasses.dataclass(slots=True, frozen=True, kw_only=True, weakref_slot=True)
50
50
  class _NodeWalker:
51
- _nodes: MappingProxyType[NodeID, Node] = dataclasses.field(repr=False)
52
- _children: MappingProxyType[NodeID, tuple[NodeID, ...]] = dataclasses.field(repr=False)
51
+ nodes: MappingProxyType[NodeID, Node] = dataclasses.field(repr=False)
52
+ "A mapping of every node"
53
+
54
+ children: MappingProxyType[NodeID, tuple[NodeID, ...]] = dataclasses.field(repr=False)
55
+ "A mapping of nodes to their inmediate children"
53
56
 
54
57
  def _ls(self, node_id: NodeID, *, recursive: bool) -> Iterable[NodeID]:
55
58
  """Get ID of every child of this node"""
56
- for child_id in self._children.get(node_id, ()):
59
+ for child_id in self.children.get(node_id, ()):
57
60
  yield child_id
58
61
  if recursive:
59
62
  yield from self._ls(child_id, recursive=recursive)
@@ -61,14 +64,14 @@ class _NodeWalker:
61
64
  def iterdir(self, node_id: NodeID, *, recursive: bool = False) -> Iterable[Node]:
62
65
  """Iterate over the children in this node"""
63
66
  for child_id in self._ls(node_id, recursive=recursive):
64
- yield self._nodes[child_id]
67
+ yield self.nodes[child_id]
65
68
 
66
69
  def listdir(self, node_id: NodeID) -> list[Node]:
67
70
  """Get a list of children of this node (non recursive)"""
68
71
  return list(self.iterdir(node_id))
69
72
 
70
73
 
71
- @dataclasses.dataclass(slots=True, frozen=True, kw_only=True, weakref_slot=True)
74
+ @dataclasses.dataclass(slots=True, frozen=True, kw_only=True, weakref_slot=True, repr=False)
72
75
  class SimpleFileSystem(_NodeWalker, _DictDumper):
73
76
  """A simple representation of Mega.nz's file system.
74
77
 
@@ -82,6 +85,16 @@ class SimpleFileSystem(_NodeWalker, _DictDumper):
82
85
  file_count: int
83
86
  folder_count: int
84
87
 
88
+ def __repr__(self) -> str:
89
+ def fields():
90
+ for name, node in [("root", self.root), ("inbox", self.inbox), ("trash_bin", self.trash_bin)]:
91
+ yield name, node.id if node is not None else None
92
+ yield "files", self.file_count
93
+ yield "folders", self.folder_count
94
+
95
+ all_fields = ", ".join(f"{name}={value!r}" for name, value in fields())
96
+ return f"<{type(self).__name__}({all_fields})>"
97
+
85
98
  def __len__(self) -> int:
86
99
  return len(self.nodes)
87
100
 
@@ -95,16 +108,6 @@ class SimpleFileSystem(_NodeWalker, _DictDumper):
95
108
  """Get the node with this ID"""
96
109
  return self.nodes[node_id]
97
110
 
98
- @property
99
- def nodes(self) -> MappingProxyType[NodeID, Node]:
100
- """A mapping of every node"""
101
- return self._nodes
102
-
103
- @property
104
- def children(self) -> MappingProxyType[NodeID, tuple[NodeID, ...]]:
105
- """A mapping of nodes to their inmediate children"""
106
- return self._children
107
-
108
111
  @property
109
112
  def deleted(self) -> Iterable[Node]:
110
113
  """Files or folders currently on the trash bin (Non recursive)"""
@@ -119,6 +122,11 @@ class SimpleFileSystem(_NodeWalker, _DictDumper):
119
122
  nodes_map: dict[NodeID, Node] = {}
120
123
  children: dict[NodeID, list[NodeID]] = {}
121
124
 
125
+ def sanity_check(current: Node | None, found: Node) -> None:
126
+ if current is not None:
127
+ ids = current.id, found.id
128
+ raise ValidationError(f"Multiple nodes of type {found.type.name} found: {ids}")
129
+
122
130
  for node in nodes:
123
131
  nodes_map[node.id] = node
124
132
  if node.parent_id:
@@ -129,10 +137,13 @@ class SimpleFileSystem(_NodeWalker, _DictDumper):
129
137
  case NodeType.FOLDER:
130
138
  folder_count += 1
131
139
  case NodeType.ROOT_FOLDER:
140
+ sanity_check(root, node)
132
141
  root = node
133
142
  case NodeType.INBOX:
143
+ sanity_check(inbox, node)
134
144
  inbox = node
135
145
  case NodeType.TRASH:
146
+ sanity_check(trash_bin, node)
136
147
  trash_bin = node
137
148
  case _:
138
149
  raise RuntimeError # pyright: ignore[reportUnreachable]
@@ -143,8 +154,8 @@ class SimpleFileSystem(_NodeWalker, _DictDumper):
143
154
  trash_bin=trash_bin,
144
155
  file_count=file_count,
145
156
  folder_count=folder_count,
146
- _nodes=MappingProxyType(nodes_map),
147
- _children=MappingProxyType({node_id: tuple(nodes) for node_id, nodes in children.items()}),
157
+ nodes=MappingProxyType(nodes_map),
158
+ children=MappingProxyType({node_id: tuple(nodes) for node_id, nodes in children.items()}),
148
159
  )
149
160
 
150
161
  def dump(self) -> dict[str, Any]:
@@ -163,7 +174,7 @@ class SimpleFileSystem(_NodeWalker, _DictDumper):
163
174
  return cls.build(Node.from_dump(node) for node in dump["nodes"].values())
164
175
 
165
176
 
166
- @dataclasses.dataclass(slots=True, frozen=True, kw_only=True, weakref_slot=True)
177
+ @dataclasses.dataclass(slots=True, frozen=True, kw_only=True, weakref_slot=True, repr=False)
167
178
  class FileSystem(SimpleFileSystem):
168
179
  """Mega.nz's file system.
169
180
 
@@ -173,9 +184,13 @@ class FileSystem(SimpleFileSystem):
173
184
  NOTE: Mega's filesystem is **not POSIX-compliant**: multiple nodes may have the same path
174
185
  """
175
186
 
176
- _paths: MappingProxyType[NodeID, PurePosixPath] = dataclasses.field(repr=False)
177
- _inv_paths: MappingProxyType[PurePosixPath, tuple[NodeID, ...]] = dataclasses.field(repr=False)
178
- _deleted: frozenset[NodeID] = dataclasses.field(repr=False)
187
+ paths: MappingProxyType[NodeID, PurePosixPath]
188
+ """A mapping of every node to its absolute path within the filesystem"""
189
+
190
+ inv_paths: MappingProxyType[PurePosixPath, tuple[NodeID, ...]]
191
+ """A mapping of paths to every node located at that path"""
192
+
193
+ _deleted: frozenset[NodeID]
179
194
 
180
195
  @classmethod
181
196
  def build(cls, nodes: Iterable[Node]) -> Self:
@@ -209,26 +224,13 @@ class FileSystem(SimpleFileSystem):
209
224
  trash_bin=self.trash_bin,
210
225
  file_count=self.file_count,
211
226
  folder_count=self.folder_count,
212
- _nodes=self.nodes,
213
- _children=self.children,
214
- _paths=MappingProxyType(paths),
215
- _inv_paths=MappingProxyType({path: tuple(nodes) for path, nodes in inv_paths.items()}),
227
+ nodes=self.nodes,
228
+ children=self.children,
229
+ paths=MappingProxyType(paths),
230
+ inv_paths=MappingProxyType({path: tuple(nodes) for path, nodes in inv_paths.items()}),
216
231
  _deleted=frozenset(deleted_ids),
217
232
  )
218
233
 
219
- @property
220
- def paths(self) -> MappingProxyType[NodeID, PurePosixPath]:
221
- """A mapping of every node to its absolute path within the filesystem"""
222
- return self._paths
223
-
224
- @property
225
- def inv_paths(self) -> MappingProxyType[PurePosixPath, tuple[NodeID, ...]]:
226
- """A mapping of paths to every node located at that path
227
-
228
- Mega's filesystem is **not POSIX-compliant**: multiple nodes may have the same path
229
- """
230
- return self._inv_paths
231
-
232
234
  @property
233
235
  def files(self) -> Iterable[Node]:
234
236
  """All files that are NOT deleted (recursive)"""
@@ -260,7 +262,7 @@ class FileSystem(SimpleFileSystem):
260
262
 
261
263
  def absolute_path(self, node_id: NodeID) -> PurePosixPath:
262
264
  """Get the absolute path of this node"""
263
- return self._paths[node_id]
265
+ return self.paths[node_id]
264
266
 
265
267
  def search(
266
268
  self,
@@ -271,7 +273,7 @@ class FileSystem(SimpleFileSystem):
271
273
  """Returns nodes that have "query" as a substring on their path"""
272
274
  query = PurePosixPath(query).as_posix()
273
275
 
274
- for node_id, path in self._paths.items():
276
+ for node_id, path in self.paths.items():
275
277
  if query not in path.as_posix():
276
278
  continue
277
279
 
@@ -292,7 +294,7 @@ class FileSystem(SimpleFileSystem):
292
294
  """
293
295
  path = POSIX_ROOT / PurePosixPath(path)
294
296
  try:
295
- nodes = self._inv_paths[path]
297
+ nodes = self.inv_paths[path]
296
298
  except LookupError:
297
299
  msg = f'A node with path "{path!s}" does not exists'
298
300
  raise FileNotFoundError(errno.ENOENT, msg) from None
@@ -332,84 +334,18 @@ class FileSystem(SimpleFileSystem):
332
334
 
333
335
  return dump | dict( # noqa: C408
334
336
  deleted=sorted(self._deleted),
335
- paths={node_id: str(path) for node_id, path in self._paths.items()},
336
- inv_paths={str(path): node_id for path, node_id in self._inv_paths.items()},
337
+ paths={node_id: str(path) for node_id, path in self.paths.items()},
338
+ inv_paths={str(path): node_id for path, node_id in self.inv_paths.items()},
337
339
  children=dict(self.children),
338
340
  )
339
341
 
340
342
 
341
- @dataclasses.dataclass(slots=True, frozen=True, kw_only=True, weakref_slot=True)
343
+ @dataclasses.dataclass(slots=True, frozen=True, kw_only=True, weakref_slot=True, repr=False)
342
344
  class UserFileSystem(FileSystem):
343
345
  root: Node
344
346
  inbox: Node
345
347
  trash_bin: Node
346
348
 
347
- @classmethod
348
- def build_unsafe(cls, nodes: Iterable[Node]) -> Self:
349
- """Build a filesystem from a pre-ordered node stream without validation
350
-
351
- Nodes MUST be topologically sorted:
352
- - The first three nodes are ROOT, INBOX and TRASH_BIN.
353
- - Every parent appears before its descendants.
354
- """
355
- # Build fs using only 3 loops:
356
- # - 1. Create a map to all nodes and resolve their paths
357
- # - 2. Freeze children (list -> tuple)
358
- # - 3. Freeze inv paths (list -> tuple)
359
- root = inbox = trash_bin = None
360
- file_count = folder_count = 0
361
-
362
- paths: dict[NodeID, PurePosixPath] = {}
363
- inv_paths: dict[PurePosixPath, list[NodeID]] = {}
364
- deleted_ids: set[NodeID] = set()
365
- nodes_map: dict[NodeID, Node] = {}
366
- children: dict[NodeID, list[NodeID]] = {}
367
- trash_bin_id = None
368
-
369
- for node in nodes:
370
- nodes_map[node.id] = node
371
- if node.parent_id:
372
- children.setdefault(node.parent_id, []).append(node.id)
373
-
374
- path = None
375
- match node.type:
376
- case NodeType.FILE:
377
- file_count += 1
378
- case NodeType.FOLDER:
379
- folder_count += 1
380
- case NodeType.ROOT_FOLDER:
381
- path = POSIX_ROOT
382
- root = node
383
- case NodeType.INBOX:
384
- path = POSIX_ROOT / node.attributes.name
385
- inbox = node
386
- case NodeType.TRASH:
387
- path = POSIX_ROOT / node.attributes.name
388
- trash_bin_id = node.id
389
- trash_bin = node
390
- case _:
391
- raise RuntimeError # pyright: ignore[reportUnreachable]
392
-
393
- path = path or (paths[node.parent_id] / node.attributes.name)
394
- paths[node.id] = path
395
- inv_paths.setdefault(path, []).append(node.id)
396
- if node.parent_id == trash_bin_id or node.parent_id in deleted_ids:
397
- deleted_ids.add(node.id)
398
-
399
- assert root and inbox and trash_bin
400
- return cls(
401
- root=root,
402
- inbox=inbox,
403
- trash_bin=trash_bin,
404
- file_count=file_count,
405
- folder_count=folder_count,
406
- _nodes=MappingProxyType(nodes_map),
407
- _children=MappingProxyType({node_id: tuple(nodes) for node_id, nodes in children.items()}),
408
- _paths=MappingProxyType(paths),
409
- _inv_paths=MappingProxyType({path: tuple(nodes) for path, nodes in inv_paths.items()}),
410
- _deleted=frozenset(deleted_ids),
411
- )
412
-
413
349
 
414
350
  if __name__ == "__main__":
415
351
  import json
@@ -423,7 +359,5 @@ if __name__ == "__main__":
423
359
  print(f"Testing filesystem build of {len(nodes):_} nodes") # noqa: T201
424
360
 
425
361
  iterations = 1_000
426
- unsafe = timeit.timeit(lambda: UserFileSystem.build_unsafe(nodes), number=iterations)
427
- print(f"{iterations} [UNSAFE] calls took {unsafe:.4f}s") # noqa: T201
428
362
  safe = timeit.timeit(lambda: UserFileSystem.build(nodes), number=iterations)
429
363
  print(f"{iterations} [SAFE] calls took {safe:.4f}s") # noqa: T201
File without changes
File without changes