async-mega-py 2.0.4.dev0__tar.gz → 2.0.5.dev0__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 (25) hide show
  1. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/PKG-INFO +1 -1
  2. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/pyproject.toml +1 -1
  3. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/chunker.py +25 -2
  4. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/client.py +3 -2
  5. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/core.py +6 -3
  6. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/crypto.py +2 -19
  7. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/download.py +6 -7
  8. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/transfer_it.py +4 -1
  9. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/upload.py +27 -22
  10. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/utils.py +15 -0
  11. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/LICENSE +0 -0
  12. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/README.md +0 -0
  13. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/__init__.py +0 -0
  14. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/__main__.py +0 -0
  15. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/api.py +0 -0
  16. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/auth.py +0 -0
  17. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/cli/__init__.py +0 -0
  18. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/cli/app.py +0 -0
  19. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/data_structures.py +0 -0
  20. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/env.py +0 -0
  21. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/errors.py +0 -0
  22. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/filesystem.py +0 -0
  23. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/progress.py +0 -0
  24. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/py.typed +0 -0
  25. {async_mega_py-2.0.4.dev0 → async_mega_py-2.0.5.dev0}/src/mega/vault.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: async-mega-py
3
- Version: 2.0.4.dev0
3
+ Version: 2.0.5.dev0
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.0.4.dev"
37
+ version = "2.0.5.dev"
38
38
 
39
39
  [project.optional-dependencies]
40
40
  cli = [
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import dataclasses
4
4
  import logging
5
5
  from collections.abc import Generator
6
- from typing import TYPE_CHECKING
6
+ from typing import TYPE_CHECKING, NamedTuple
7
7
 
8
8
  from Crypto.Cipher import AES
9
9
  from Crypto.Util import Counter
@@ -17,9 +17,32 @@ if TYPE_CHECKING:
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
+ class ChunkBoundary(NamedTuple):
21
+ offset: int
22
+ size: int
23
+
24
+
25
+ def get_chunks(size: int) -> Generator[ChunkBoundary]:
26
+ """
27
+ Yield chunk boundaries for Mega's MAC computation.
28
+
29
+ Chunk sizes double from 128 KiB (0x20000) up to 1 MiB (0x100000).
30
+ The last chunk may be smaller
31
+ """
32
+
33
+ offset = 0
34
+ current_size = init_size = 0x20000
35
+ while offset + current_size < size:
36
+ yield ChunkBoundary(offset, current_size)
37
+ offset += current_size
38
+ if current_size < 0x100000:
39
+ current_size += init_size
40
+ yield ChunkBoundary(offset, size - offset)
41
+
42
+
20
43
  @dataclasses.dataclass(slots=True)
21
44
  class MegaChunker:
22
- """Decrypts/encrypts a flow of chunks using Mega's CBC algorithm"""
45
+ """Decrypts/encrypts a flow of chunks using Mega's custom CBC-MAC algorithm"""
23
46
 
24
47
  iv: tuple[int, int]
25
48
  key: tuple[int, int, int, int]
@@ -155,7 +155,7 @@ class MegaNzClient(MegaCore):
155
155
  return await self.get_folder_link(fs[node.id])
156
156
 
157
157
  async def get_public_filesystem(self, public_handle: NodeID, public_key: str) -> FileSystem:
158
- logger.info(f"Getting filesystem for {public_handle}...")
158
+ logger.info(f"Fetching filesystem information for {public_handle = }...")
159
159
  folder: GetNodesResponse = await self._api.post(
160
160
  {
161
161
  "a": "f",
@@ -165,7 +165,8 @@ class MegaNzClient(MegaCore):
165
165
  },
166
166
  {"n": public_handle},
167
167
  )
168
-
168
+ nodes = folder["f"]
169
+ logger.info(f"Decrypting and building filesystem for {public_handle =} ({len(nodes)} nodes)...")
169
170
  nodes = await self._vault.deserialize_nodes(folder["f"], public_key)
170
171
  return await asyncio.to_thread(FileSystem.build, nodes)
171
172
 
@@ -22,7 +22,7 @@ from mega.crypto import (
22
22
  from mega.data_structures import Crypto, FileInfo, FileInfoSerialized, Node, NodeID
23
23
  from mega.errors import MegaNzError, RequestError, ValidationError
24
24
  from mega.filesystem import UserFileSystem
25
- from mega.utils import Site, random_u32int_array, transform_v1_url
25
+ from mega.utils import Site, get_file_size, random_u32int_array, transform_v1_url
26
26
  from mega.vault import MegaVault
27
27
 
28
28
  if TYPE_CHECKING:
@@ -169,6 +169,7 @@ class MegaCore(AbstractApiClient):
169
169
  return FileInfo.parse(resp)
170
170
 
171
171
  async def _prepare_filesystem(self) -> UserFileSystem:
172
+ logger.info("Fetching users's filesystem information...")
172
173
  nodes_resp: GetNodesResponse = await self._api.post(
173
174
  {
174
175
  "a": "f",
@@ -177,13 +178,15 @@ class MegaCore(AbstractApiClient):
177
178
  },
178
179
  )
179
180
 
181
+ nodes = nodes_resp["f"]
182
+ logger.info(f"Decrypting and building users's filesystem ({len(nodes)} nodes)...")
180
183
  self._vault.init_shared_keys(nodes_resp)
181
- nodes = await self._vault.deserialize_nodes(nodes_resp["f"])
184
+ nodes = await self._vault.deserialize_nodes(nodes)
182
185
  return await asyncio.to_thread(UserFileSystem.build, nodes)
183
186
 
184
187
  async def _upload(self, file_path: str | PathLike[str], dest_node_id: NodeID) -> GetNodesResponse:
185
188
  file_path = Path(file_path)
186
- file_size = file_path.stat().st_size
189
+ file_size = await asyncio.to_thread(get_file_size, file_path)
187
190
 
188
191
  with progress.new_task(file_path.name, file_size, "UP"):
189
192
  file_id, crypto = await upload.upload(self._api, file_path, file_size)
@@ -7,14 +7,14 @@ import logging
7
7
  import math
8
8
  import struct
9
9
  import time
10
- from typing import TYPE_CHECKING, Any, NamedTuple
10
+ from typing import TYPE_CHECKING, Any
11
11
 
12
12
  from Crypto.Cipher import AES
13
13
  from Crypto.Math.Numbers import Integer
14
14
  from Crypto.PublicKey import RSA
15
15
 
16
16
  if TYPE_CHECKING:
17
- from collections.abc import Generator, Mapping, Sequence
17
+ from collections.abc import Mapping, Sequence
18
18
 
19
19
  from mega.data_structures import AttributesSerialized
20
20
 
@@ -24,11 +24,6 @@ CHUNK_BLOCK_LEN = 16 # Hexadecimal
24
24
  EMPTY_IV = b"\0" * CHUNK_BLOCK_LEN
25
25
 
26
26
 
27
- class ChunkBoundary(NamedTuple):
28
- offset: int
29
- size: int
30
-
31
-
32
27
  def pad_bytes(data: bytes | memoryview[int], length: int = CHUNK_BLOCK_LEN) -> bytes:
33
28
  if len(data) % length:
34
29
  padding = b"\0" * (length - len(data) % length)
@@ -147,18 +142,6 @@ def a32_to_base64(array: Sequence[int]) -> str:
147
142
  return b64_url_encode(a32_to_bytes(array))
148
143
 
149
144
 
150
- def get_chunks(size: int) -> Generator[ChunkBoundary]:
151
- # generates a list of chunks (offset, chunk_size), where offset refers to the file initial position
152
- offset = 0
153
- current_size = init_size = 0x20000
154
- while offset + current_size < size:
155
- yield ChunkBoundary(offset, current_size)
156
- offset += current_size
157
- if current_size < 0x100000:
158
- current_size += init_size
159
- yield ChunkBoundary(offset, size - offset)
160
-
161
-
162
145
  def decrypt_rsa_key(private_key: bytes) -> RSA.RsaKey:
163
146
  # The private_key contains 4 MPI integers concatenated together.
164
147
  rsa_private_key = [0, 0, 0, 0]
@@ -13,8 +13,7 @@ from types import MappingProxyType
13
13
  from typing import IO, TYPE_CHECKING, Final, Generic, Self, TypeVar
14
14
 
15
15
  from mega import progress
16
- from mega.chunker import MegaChunker
17
- from mega.crypto import get_chunks
16
+ from mega.chunker import MegaChunker, get_chunks
18
17
  from mega.data_structures import NodeID
19
18
 
20
19
  if TYPE_CHECKING:
@@ -63,11 +62,11 @@ async def encrypted_stream(
63
62
 
64
63
  chunker = MegaChunker(iv, key, meta_mac)
65
64
  progress_hook = progress.current_hook.get()
66
- async with _new_temp_download(output_path) as output:
65
+ async with _new_temp_download(output_path) as file_io:
67
66
  for _, chunk_size in get_chunks(file_size):
68
67
  encrypted_chunk = await stream.readexactly(chunk_size)
69
68
  chunk = chunker.read(encrypted_chunk)
70
- output.write(chunk)
69
+ await asyncio.to_thread(file_io.write, chunk)
71
70
  progress_hook(len(chunk))
72
71
 
73
72
  chunker.check_integrity()
@@ -81,9 +80,9 @@ async def stream(stream: aiohttp.StreamReader, output_path: Path) -> Path:
81
80
  raise FileExistsError(errno.EEXIST, output_path)
82
81
 
83
82
  progress_hook = progress.current_hook.get()
84
- async with _new_temp_download(output_path) as output:
83
+ async with _new_temp_download(output_path) as file_io:
85
84
  async for chunk in stream.iter_chunked(_CHUNK_SIZE):
86
- output.write(chunk)
85
+ await asyncio.to_thread(file_io.write, chunk)
87
86
  progress_hook(len(chunk))
88
87
 
89
88
  return output_path
@@ -92,7 +91,7 @@ async def stream(stream: aiohttp.StreamReader, output_path: Path) -> Path:
92
91
  @contextlib.asynccontextmanager
93
92
  async def _new_temp_download(output_path: Path) -> AsyncGenerator[IO[bytes]]:
94
93
  # We need NamedTemporaryFile to not delete on file.close() but on context exit, which is not supported until python 3.12
95
- temp_file = tempfile.NamedTemporaryFile(prefix="mega_py_", delete=False)
94
+ temp_file = await asyncio.to_thread(tempfile.NamedTemporaryFile, prefix="mega_py_", delete=False)
96
95
  logger.debug(f'Created temp file "{temp_file.name!s}" for "{output_path!s}"')
97
96
  try:
98
97
  yield temp_file
@@ -47,6 +47,7 @@ class TransferItClient(AbstractApiClient):
47
47
  self._api = TransferItAPI(session)
48
48
 
49
49
  async def get_filesystem(self, transfer_id: TransferID) -> FileSystem:
50
+ logger.info(f"Fetching filesystem information for {transfer_id = }...")
50
51
  folder: GetNodesResponse = await self._api.post(
51
52
  {
52
53
  "a": "f",
@@ -56,7 +57,9 @@ class TransferItClient(AbstractApiClient):
56
57
  },
57
58
  {"x": transfer_id},
58
59
  )
59
- return await asyncio.to_thread(self._deserialize_nodes, folder["f"])
60
+ nodes = folder["f"]
61
+ logger.info(f"Decrypting and building filesystem for {transfer_id = } ({len(nodes)} nodes)...")
62
+ return await asyncio.to_thread(self._deserialize_nodes, nodes)
60
63
 
61
64
  @staticmethod
62
65
  def parse_url(url: str | yarl.URL) -> TransferID:
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import logging
4
- from typing import IO, TYPE_CHECKING
5
+ from typing import TYPE_CHECKING
5
6
 
6
7
  from mega import progress
7
- from mega.chunker import MegaChunker
8
- from mega.crypto import a32_to_base64, b64_url_encode, encrypt_attr, encrypt_key, get_chunks
9
- from mega.data_structures import Crypto
8
+ from mega.chunker import MegaChunker, get_chunks
9
+ from mega.crypto import a32_to_base64, b64_url_encode, encrypt_attr, encrypt_key
10
+ from mega.data_structures import ByteSize, Crypto
10
11
  from mega.utils import random_u32int_array
11
12
 
12
13
  if TYPE_CHECKING:
@@ -24,24 +25,23 @@ async def _request_upload_url(api: MegaAPI, file_size: int) -> str:
24
25
 
25
26
 
26
27
  async def upload(api: MegaAPI, file_path: Path, file_size: int) -> tuple[str, Crypto]:
27
- with file_path.open("rb") as input_file:
28
- random_array = random_u32int_array(6)
29
- key, iv = random_array[:4], random_array[4:]
28
+ random_array = random_u32int_array(6)
29
+ key, iv = random_array[:4], random_array[4:]
30
30
 
31
- if file_size == 0:
32
- upload_url = await _request_upload_url(api, file_size)
33
- file_handle = await api.upload_chunk(upload_url, 0, b"")
34
- meta_mac = 0, 0
35
- return file_handle, Crypto.compose(key, iv, meta_mac)
31
+ if file_size == 0:
32
+ upload_url = await _request_upload_url(api, file_size)
33
+ file_handle = await api.upload_chunk(upload_url, 0, b"")
34
+ meta_mac = 0, 0
35
+ return file_handle, Crypto.compose(key, iv, meta_mac)
36
36
 
37
- chunker = MegaChunker(iv, key) # pyright: ignore[reportArgumentType]
38
- return await _upload_chunks(api, chunker, input_file, file_size)
37
+ chunker = MegaChunker(iv, key) # pyright: ignore[reportArgumentType]
38
+ return await _upload_chunks(api, chunker, file_path, file_size)
39
39
 
40
40
 
41
41
  async def _upload_chunks(
42
42
  api: MegaAPI,
43
43
  chunker: MegaChunker,
44
- input_file: IO[bytes],
44
+ file_path: Path,
45
45
  file_size: int,
46
46
  ) -> tuple[str, Crypto]:
47
47
  upload_progress = 0
@@ -49,13 +49,18 @@ async def _upload_chunks(
49
49
  upload_url = await _request_upload_url(api, file_size)
50
50
  progress_hook = progress.current_hook.get()
51
51
 
52
- for offset, size in get_chunks(file_size):
53
- chunk = chunker.read(input_file.read(size))
54
- file_handle = await api.upload_chunk(upload_url, offset, chunk)
55
- logger.info(f"{upload_progress} of {file_size} uploaded ({upload_progress / file_size:0.1f}%)")
56
- real_size = len(chunk)
57
- upload_progress += real_size
58
- progress_hook(real_size)
52
+ file_size = ByteSize(file_size)
53
+ total = file_size.human_readable()
54
+ with await asyncio.to_thread(file_path.open, "rb") as input_file:
55
+ for offset, size in get_chunks(file_size):
56
+ chunk = chunker.read(await asyncio.to_thread(input_file.read, size))
57
+ file_handle = await api.upload_chunk(upload_url, offset, chunk)
58
+ human_progress = ByteSize(upload_progress).human_readable()
59
+ ratio = (upload_progress / file_size) * 100
60
+ logger.debug(f'{human_progress}/{total} uploaded ({ratio:0.1f}%) for "{file_path!s}"')
61
+ real_size = len(chunk)
62
+ upload_progress += real_size
63
+ progress_hook(real_size)
59
64
 
60
65
  assert file_handle
61
66
  return file_handle, Crypto.compose(chunker.key, chunker.iv, chunker.compute_meta_mac())
@@ -2,16 +2,19 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import datetime
5
+ import errno
5
6
  import logging
6
7
  import random
7
8
  import string
8
9
  from enum import Enum
10
+ from stat import S_ISREG
9
11
  from typing import TYPE_CHECKING, Literal, TypeVar, overload
10
12
 
11
13
  import yarl
12
14
 
13
15
  if TYPE_CHECKING:
14
16
  from collections.abc import Awaitable, Callable, Iterable, Sequence
17
+ from pathlib import Path
15
18
 
16
19
  _T1 = TypeVar("_T1")
17
20
  _T2 = TypeVar("_T2")
@@ -76,6 +79,18 @@ def transform_v1_url(url: yarl.URL) -> yarl.URL:
76
79
  return url
77
80
 
78
81
 
82
+ def get_file_size(file_path: Path) -> int:
83
+ try:
84
+ stat = file_path.stat()
85
+ except (OSError, ValueError):
86
+ raise FileNotFoundError(errno.ENOENT, str(file_path)) from None
87
+
88
+ if not S_ISREG(stat.st_mode):
89
+ raise IsADirectoryError(errno.EISDIR, str(file_path))
90
+
91
+ return stat.st_size
92
+
93
+
79
94
  @overload
80
95
  async def async_map(
81
96
  coro_factory: Callable[[_T1], Awaitable[_T2]],