gdown2 0.2.0__py3-none-any.whl

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 (55) hide show
  1. gdown2/__init__.py +27 -0
  2. gdown2/__main__.py +9 -0
  3. gdown2/application/__init__.py +31 -0
  4. gdown2/application/crawler.py +128 -0
  5. gdown2/application/exporter.py +40 -0
  6. gdown2/application/planner.py +193 -0
  7. gdown2/application/ports.py +85 -0
  8. gdown2/application/reporter.py +285 -0
  9. gdown2/application/resolver.py +50 -0
  10. gdown2/application/scheduler.py +150 -0
  11. gdown2/application/verifier.py +81 -0
  12. gdown2/application/worker.py +307 -0
  13. gdown2/domain/__init__.py +89 -0
  14. gdown2/domain/errors.py +151 -0
  15. gdown2/domain/models.py +254 -0
  16. gdown2/domain/path_policy.py +138 -0
  17. gdown2/domain/policies.py +117 -0
  18. gdown2/domain/tree.py +113 -0
  19. gdown2/domain/url_parser.py +148 -0
  20. gdown2/infrastructure/__init__.py +12 -0
  21. gdown2/infrastructure/auth/__init__.py +16 -0
  22. gdown2/infrastructure/auth/anonymous.py +21 -0
  23. gdown2/infrastructure/auth/api_key.py +25 -0
  24. gdown2/infrastructure/auth/base.py +21 -0
  25. gdown2/infrastructure/auth/oauth.py +128 -0
  26. gdown2/infrastructure/auth/service_account.py +92 -0
  27. gdown2/infrastructure/cache.py +131 -0
  28. gdown2/infrastructure/config.py +182 -0
  29. gdown2/infrastructure/drive/__init__.py +20 -0
  30. gdown2/infrastructure/drive/metadata.py +105 -0
  31. gdown2/infrastructure/drive/source_api.py +204 -0
  32. gdown2/infrastructure/drive/source_composite.py +70 -0
  33. gdown2/infrastructure/drive/source_web.py +666 -0
  34. gdown2/infrastructure/http/__init__.py +14 -0
  35. gdown2/infrastructure/http/client.py +209 -0
  36. gdown2/infrastructure/http/ratelimit.py +46 -0
  37. gdown2/infrastructure/http/retry.py +114 -0
  38. gdown2/infrastructure/storage/__init__.py +7 -0
  39. gdown2/infrastructure/storage/fs_sink.py +87 -0
  40. gdown2/infrastructure/storage/resume_state.py +94 -0
  41. gdown2/interfaces/__init__.py +6 -0
  42. gdown2/interfaces/cli/__init__.py +6 -0
  43. gdown2/interfaces/cli/app.py +405 -0
  44. gdown2/interfaces/cli/render.py +247 -0
  45. gdown2/interfaces/json_events.py +27 -0
  46. gdown2/interfaces/tui/__init__.py +6 -0
  47. gdown2/interfaces/tui/app.py +205 -0
  48. gdown2/py.typed +0 -0
  49. gdown2/sdk.py +278 -0
  50. gdown2-0.2.0.dist-info/METADATA +456 -0
  51. gdown2-0.2.0.dist-info/RECORD +55 -0
  52. gdown2-0.2.0.dist-info/WHEEL +5 -0
  53. gdown2-0.2.0.dist-info/entry_points.txt +2 -0
  54. gdown2-0.2.0.dist-info/licenses/LICENSE +22 -0
  55. gdown2-0.2.0.dist-info/top_level.txt +1 -0
gdown2/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """gdown2 — Fast, resumable Google Drive file and folder downloader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from gdown2.domain.models import DownloadTask, DriveItem, FolderNode, TaskState
8
+ from gdown2.sdk import download, download_sync, get_info, get_tree, resume_directory
9
+
10
+ try:
11
+ __version__ = version("gdown2")
12
+ except PackageNotFoundError: # pragma: no cover - source checkout without installation
13
+ __version__ = "0.2.0"
14
+
15
+ __all__ = [
16
+ "DownloadTask",
17
+ "DriveItem",
18
+ "FolderNode",
19
+ "TaskState",
20
+ "__version__",
21
+ "download",
22
+ "download_sync",
23
+ "get_info",
24
+ "get_tree",
25
+ "resume_directory",
26
+ ]
27
+
gdown2/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Allow running ``python -m gdown2``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gdown2.interfaces.cli.app import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
9
+
@@ -0,0 +1,31 @@
1
+ """Application orchestration services for gdown2."""
2
+
3
+ from gdown2.application.crawler import FolderCrawler
4
+ from gdown2.application.exporter import ExportService
5
+ from gdown2.application.planner import DownloadPlan, Planner
6
+ from gdown2.application.ports import DriveSource, MediaStream, MetadataCache, ResumeRepository
7
+ from gdown2.application.reporter import EventType, Observer, Reporter, TransferEvent
8
+ from gdown2.application.resolver import Resolver
9
+ from gdown2.application.scheduler import Scheduler
10
+ from gdown2.application.verifier import Verifier
11
+ from gdown2.application.worker import DownloadWorker
12
+
13
+ __all__ = [
14
+ "DownloadPlan",
15
+ "DownloadWorker",
16
+ "DriveSource",
17
+ "EventType",
18
+ "ExportService",
19
+ "FolderCrawler",
20
+ "MediaStream",
21
+ "MetadataCache",
22
+ "Observer",
23
+ "Planner",
24
+ "Reporter",
25
+ "Resolver",
26
+ "ResumeRepository",
27
+ "Scheduler",
28
+ "TransferEvent",
29
+ "Verifier",
30
+ ]
31
+
@@ -0,0 +1,128 @@
1
+ """Recursive folder crawler with shortcut resolution and cycle prevention."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import PurePosixPath
7
+
8
+ from gdown2.application.ports import DriveSource, MetadataCache
9
+ from gdown2.domain.errors import PermissionDeniedError, ResourceNotFoundError
10
+ from gdown2.domain.models import DriveItem, FileEntry, FolderNode
11
+ from gdown2.domain.path_policy import PathPolicy
12
+ from gdown2.domain.url_parser import ParsedTarget, TargetKind
13
+
14
+
15
+ class FolderCrawler:
16
+ """Builds a complete :class:`FolderNode` tree from a root Drive folder."""
17
+
18
+ def __init__(
19
+ self,
20
+ source: DriveSource,
21
+ path_policy: PathPolicy | None = None,
22
+ cache: MetadataCache | None = None,
23
+ max_depth: int = 32,
24
+ follow_shortcuts: bool = True,
25
+ preferred_export_format: str | None = None,
26
+ concurrency: int = 6,
27
+ ) -> None:
28
+ self._source = source
29
+ self._path_policy = path_policy or PathPolicy()
30
+ self._cache = cache
31
+ self._max_depth = max_depth
32
+ self._follow_shortcuts = follow_shortcuts
33
+ self._preferred_export_format = preferred_export_format
34
+ self._concurrency = max(1, concurrency)
35
+
36
+ async def crawl(self, root_item: DriveItem) -> FolderNode:
37
+ """Traverse ``root_item`` in breadth-first order and return its :class:`FolderNode`."""
38
+ root_name = self._path_policy.sanitize_segment(root_item.name, fallback=root_item.id)
39
+ root_node = FolderNode(
40
+ item=root_item,
41
+ relative_path=PurePosixPath(root_name),
42
+ depth=0,
43
+ )
44
+
45
+ visited_folders: set[str] = {root_item.id}
46
+ current_level: list[FolderNode] = [root_node]
47
+ sem = asyncio.Semaphore(self._concurrency)
48
+
49
+ async def _fetch_children(node: FolderNode) -> tuple[FolderNode, list[DriveItem]]:
50
+ async with sem:
51
+ try:
52
+ children = await self._source.list_folder_children(
53
+ node.item.id,
54
+ resource_key=node.item.resource_key,
55
+ )
56
+ return node, children
57
+ except (ResourceNotFoundError, PermissionDeniedError):
58
+ if node.depth == 0:
59
+ raise
60
+ return node, []
61
+
62
+ while current_level:
63
+ results = await asyncio.gather(*(_fetch_children(node) for node in current_level))
64
+ next_level: list[FolderNode] = []
65
+
66
+ for current, children in results:
67
+ for child in children:
68
+ resolved = await self._resolve_if_shortcut(child)
69
+ if resolved is None:
70
+ continue
71
+
72
+ if self._cache is not None:
73
+ self._cache.put(resolved, persist=False)
74
+
75
+ if resolved.is_folder:
76
+ if resolved.id in visited_folders:
77
+ # Skip shortcut cycles pointing back to an ancestor or queued folder.
78
+ continue
79
+ visited_folders.add(resolved.id)
80
+ folder_rel = self._path_policy.allocate_child_path(
81
+ current.relative_path,
82
+ resolved.name,
83
+ )
84
+ sub_node = FolderNode(
85
+ item=resolved,
86
+ relative_path=folder_rel,
87
+ depth=current.depth + 1,
88
+ )
89
+ current.subfolders.append(sub_node)
90
+ if sub_node.depth < self._max_depth:
91
+ next_level.append(sub_node)
92
+ else:
93
+ forced_ext: str | None = None
94
+ if resolved.is_google_doc:
95
+ _, forced_ext = resolved.resolve_export(self._preferred_export_format)
96
+ file_rel = self._path_policy.allocate_child_path(
97
+ current.relative_path,
98
+ resolved.name,
99
+ forced_extension=forced_ext,
100
+ )
101
+ current.files.append(
102
+ FileEntry(
103
+ item=resolved,
104
+ relative_path=file_rel,
105
+ depth=current.depth + 1,
106
+ )
107
+ )
108
+
109
+ if self._cache is not None:
110
+ self._cache.flush()
111
+ current_level = next_level
112
+
113
+ return root_node
114
+
115
+ async def _resolve_if_shortcut(self, item: DriveItem) -> DriveItem | None:
116
+ if not item.is_shortcut:
117
+ return item
118
+ if not self._follow_shortcuts or not item.shortcut_target_id:
119
+ return None
120
+ try:
121
+ target = await self._source.fetch_metadata(
122
+ ParsedTarget(id=item.shortcut_target_id, kind=TargetKind.UNKNOWN)
123
+ )
124
+ except (ResourceNotFoundError, PermissionDeniedError):
125
+ return None
126
+ if item.name:
127
+ target.name = item.name
128
+ return target
@@ -0,0 +1,40 @@
1
+ """Export service for Google Workspace documents (Docs, Sheets, Slides, Drawings)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from gdown2.application.ports import DriveSource
8
+ from gdown2.domain.models import DownloadTask
9
+ from gdown2.infrastructure.storage.fs_sink import FileSystemSink
10
+
11
+
12
+ class ExportService:
13
+ """Streams Google Workspace documents in their target export MIME format."""
14
+
15
+ def __init__(self, source: DriveSource, sink: FileSystemSink) -> None:
16
+ self._source = source
17
+ self._sink = sink
18
+
19
+ async def export_task(
20
+ self,
21
+ task: DownloadTask,
22
+ *,
23
+ on_bytes: Callable[[int], None] | None = None,
24
+ ) -> int:
25
+ """Stream the exported document into ``task.part_path`` and return total bytes written."""
26
+ export_mime, _ = task.item.resolve_export(task.export_format)
27
+ written = 0
28
+
29
+ async with self._source.open_export_stream(task.item, export_mime) as media:
30
+ with self._sink.open_part(task.part_path, append=False) as handle:
31
+ async for chunk in media.chunks:
32
+ if not chunk:
33
+ continue
34
+ handle.write(chunk)
35
+ written += len(chunk)
36
+ if on_bytes is not None:
37
+ on_bytes(len(chunk))
38
+
39
+ return written
40
+
@@ -0,0 +1,193 @@
1
+ """Execution planner turning resolved Drive items or folder trees into DownloadTasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ from gdown2.domain.models import (
10
+ DownloadTask,
11
+ DriveItem,
12
+ FolderNode,
13
+ TaskState,
14
+ TransferState,
15
+ )
16
+ from gdown2.domain.path_policy import PathPolicy
17
+ from gdown2.domain.policies import ExistingFilePolicy
18
+ from gdown2.domain.tree import walk_files, walk_folders_bfs
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class DownloadPlan:
23
+ """Ordered directories to create and download tasks to execute."""
24
+
25
+ root_dir: Path
26
+ directories: list[Path] = field(default_factory=list)
27
+ tasks: list[DownloadTask] = field(default_factory=list)
28
+
29
+ @property
30
+ def total_bytes(self) -> int:
31
+ return sum(t.bytes_total or 0 for t in self.tasks)
32
+
33
+
34
+ class Planner:
35
+ """Produces a deterministic :class:`DownloadPlan` from domain objects."""
36
+
37
+ def __init__(
38
+ self,
39
+ path_policy: PathPolicy | None = None,
40
+ existing_policy: ExistingFilePolicy = ExistingFilePolicy.RESUME,
41
+ flat: bool = False,
42
+ export_format: str | None = None,
43
+ ) -> None:
44
+ self._path_policy = path_policy or PathPolicy()
45
+ self._existing_policy = existing_policy
46
+ self._flat = flat
47
+ self._export_format = export_format
48
+
49
+ def plan_single(
50
+ self,
51
+ item: DriveItem,
52
+ output: Path,
53
+ *,
54
+ saved_states: dict[str, TransferState] | None = None,
55
+ ) -> DownloadPlan:
56
+ """Create a plan for a single file or Google Workspace document."""
57
+ forced_ext: str | None = None
58
+ if item.is_google_doc:
59
+ _, forced_ext = item.resolve_export(self._export_format)
60
+
61
+ # If output is an existing directory (or ends with a separator), place the file inside it.
62
+ is_directory_output = output.is_dir() or str(output).endswith(("/", "\\"))
63
+ if is_directory_output:
64
+ root_dir = output.resolve()
65
+ rel = self._path_policy.allocate_child_path(
66
+ PurePosixPath("."),
67
+ item.name,
68
+ forced_extension=forced_ext,
69
+ )
70
+ local_path = self._path_policy.resolve_local_path(root_dir, rel)
71
+ else:
72
+ root_dir = output.parent.resolve()
73
+ rel = PurePosixPath(self._path_policy.sanitize_segment(output.name, fallback=item.name))
74
+ if forced_ext and not rel.name.lower().endswith(forced_ext.lower()):
75
+ rel = PurePosixPath(f"{rel.name}{forced_ext}")
76
+ local_path = self._path_policy.resolve_local_path(root_dir, rel)
77
+
78
+ task = self._build_task(item, local_path, rel, saved_states=saved_states)
79
+ return DownloadPlan(
80
+ root_dir=root_dir,
81
+ directories=[root_dir],
82
+ tasks=[task],
83
+ )
84
+
85
+ def plan_tree(
86
+ self,
87
+ tree: FolderNode,
88
+ output_dir: Path,
89
+ *,
90
+ saved_states: dict[str, TransferState] | None = None,
91
+ ) -> DownloadPlan:
92
+ """Create a plan for an entire :class:`FolderNode` hierarchy."""
93
+ root_dir = output_dir.resolve()
94
+ directories: list[Path] = [root_dir]
95
+ tasks: list[DownloadTask] = []
96
+
97
+ if not self._flat:
98
+ for folder in walk_folders_bfs(tree):
99
+ folder_path = self._path_policy.resolve_local_path(root_dir, folder.relative_path)
100
+ if folder_path not in directories:
101
+ directories.append(folder_path)
102
+
103
+ flat_policy = PathPolicy()
104
+ for entry in walk_files(tree):
105
+ if self._flat:
106
+ forced_ext: str | None = None
107
+ if entry.item.is_google_doc:
108
+ _, forced_ext = entry.item.resolve_export(self._export_format)
109
+ rel = flat_policy.allocate_child_path(
110
+ PurePosixPath("."),
111
+ entry.item.name,
112
+ forced_extension=forced_ext,
113
+ )
114
+ else:
115
+ rel = entry.relative_path
116
+
117
+ local_path = self._path_policy.resolve_local_path(root_dir, rel)
118
+ task = self._build_task(entry.item, local_path, rel, saved_states=saved_states)
119
+ tasks.append(task)
120
+
121
+ return DownloadPlan(
122
+ root_dir=root_dir,
123
+ directories=directories,
124
+ tasks=tasks,
125
+ )
126
+
127
+ def _build_task(
128
+ self,
129
+ item: DriveItem,
130
+ local_path: Path,
131
+ relative_path: PurePosixPath,
132
+ *,
133
+ saved_states: dict[str, TransferState] | None = None,
134
+ ) -> DownloadTask:
135
+ task = DownloadTask(
136
+ item=item,
137
+ local_path=local_path,
138
+ relative_path=relative_path,
139
+ bytes_total=item.size,
140
+ etag=item.etag,
141
+ export_format=self._export_format if item.is_google_doc else None,
142
+ )
143
+
144
+ # Check if the final file already exists on disk.
145
+ if local_path.exists() and local_path.is_file():
146
+ existing_size = local_path.stat().st_size
147
+ if self._existing_policy == ExistingFilePolicy.SKIP:
148
+ task.state = TaskState.SKIPPED
149
+ task.bytes_done = existing_size
150
+ return task
151
+ if self._existing_policy == ExistingFilePolicy.RESUME and self._is_complete(
152
+ item, local_path, existing_size
153
+ ):
154
+ task.state = TaskState.SKIPPED
155
+ task.bytes_done = existing_size
156
+ return task
157
+
158
+ # Overwriting must never resume stale bytes from a previous transfer.
159
+ if self._existing_policy == ExistingFilePolicy.OVERWRITE and task.part_path.exists():
160
+ task.stale_part = True
161
+ task.resume_offset = 0
162
+ task.bytes_done = 0
163
+ return task
164
+
165
+ # Inspect .part file and saved TransferState for mid-stream resume.
166
+ if self._existing_policy == ExistingFilePolicy.RESUME and task.part_path.exists():
167
+ part_size = task.part_path.stat().st_size
168
+ saved = (saved_states or {}).get(task.task_id)
169
+ if saved and saved.etag and item.etag and saved.etag != item.etag:
170
+ # Remote file changed since last session; discard stale partial data.
171
+ task.stale_part = True
172
+ task.resume_offset = 0
173
+ task.bytes_done = 0
174
+ else:
175
+ task.resume_offset = part_size
176
+ task.bytes_done = part_size
177
+ if saved and saved.etag and not task.etag:
178
+ task.etag = saved.etag
179
+
180
+ return task
181
+
182
+ @staticmethod
183
+ def _is_complete(item: DriveItem, local_path: Path, existing_size: int) -> bool:
184
+ if item.size is None or existing_size != item.size:
185
+ return False
186
+ if not item.md5:
187
+ return True
188
+ hasher = hashlib.md5()
189
+ with local_path.open("rb") as handle:
190
+ while chunk := handle.read(256 * 1024):
191
+ hasher.update(chunk)
192
+ return hasher.hexdigest().lower() == item.md5.lower()
193
+
@@ -0,0 +1,85 @@
1
+ """Ports (interfaces) used by the application layer to talk to infrastructure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import AbstractAsyncContextManager
7
+ from dataclasses import dataclass
8
+ from typing import Protocol
9
+
10
+ from gdown2.domain.models import DriveItem, TransferState
11
+ from gdown2.domain.url_parser import ParsedTarget
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class MediaStream:
16
+ """Opened byte stream along with HTTP transfer headers."""
17
+
18
+ chunks: AsyncIterator[bytes]
19
+ status_code: int = 200
20
+ content_length: int | None = None
21
+ etag: str | None = None
22
+ accept_ranges: bool = False
23
+ is_range_satisfied: bool = True
24
+
25
+
26
+ class DriveSource(Protocol):
27
+ """Port for querying Google Drive metadata and streaming file contents."""
28
+
29
+ async def fetch_metadata(self, target: ParsedTarget) -> DriveItem:
30
+ """Fetch metadata for a single file, folder, or native doc."""
31
+ ...
32
+
33
+ async def list_folder_children(
34
+ self,
35
+ folder_id: str,
36
+ *,
37
+ resource_key: str | None = None,
38
+ ) -> list[DriveItem]:
39
+ """List immediate children of a Drive folder (handling pagination internally)."""
40
+ ...
41
+
42
+ def open_download_stream(
43
+ self,
44
+ item: DriveItem,
45
+ *,
46
+ start_byte: int = 0,
47
+ end_byte: int | None = None,
48
+ ) -> AbstractAsyncContextManager[MediaStream]:
49
+ """Open a binary download stream, optionally starting at ``start_byte``."""
50
+ ...
51
+
52
+ def open_export_stream(
53
+ self,
54
+ item: DriveItem,
55
+ export_mime: str,
56
+ ) -> AbstractAsyncContextManager[MediaStream]:
57
+ """Open an export stream for a Google Workspace native document."""
58
+ ...
59
+
60
+
61
+ class MetadataCache(Protocol):
62
+ """Port for caching resolved DriveItem records."""
63
+
64
+ def get(self, item_id: str) -> DriveItem | None:
65
+ ...
66
+
67
+ def put(self, item: DriveItem, *, persist: bool = True) -> None:
68
+ ...
69
+
70
+ def flush(self) -> None:
71
+ ...
72
+
73
+
74
+ class ResumeRepository(Protocol):
75
+ """Port for persisting and loading transfer state across runs."""
76
+
77
+ def load_all(self) -> dict[str, TransferState]:
78
+ ...
79
+
80
+ def save_task(self, state: TransferState) -> None:
81
+ ...
82
+
83
+ def remove_task(self, task_key: str) -> None:
84
+ ...
85
+