gdrives 0.5.8__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.
gdrives/__init__.py ADDED
File without changes
gdrives/auth.py ADDED
@@ -0,0 +1,161 @@
1
+ """Shared Google Drive authentication and service builder."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import google.auth.exceptions
10
+ from dotenv import load_dotenv
11
+
12
+ load_dotenv()
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ SCOPES = ["https://www.googleapis.com/auth/drive.readonly"]
17
+
18
+ NO_CREDENTIALS_MESSAGE = (
19
+ "Error: no Google Drive credentials found. Set up one of:\n"
20
+ " - OAuth: put gdrives_credentials.json in $GOOGLE_CONFIG_DIR "
21
+ "(docs/setup-oauth.md)\n"
22
+ " - Service account: set GOOGLE_SERVICE_ACCOUNT_PATH, or put "
23
+ "service_account.json in $GOOGLE_CONFIG_DIR "
24
+ "(docs/setup-service-account.md)\n"
25
+ " - gcloud/ADC: run 'gcloud auth application-default login "
26
+ "--scopes=https://www.googleapis.com/auth/drive.readonly' "
27
+ "(docs/setup-adc.md)"
28
+ )
29
+
30
+
31
+ def _config_dir() -> Path | None:
32
+ """Return the credentials directory, or None if GOOGLE_CONFIG_DIR is unset."""
33
+ value = os.environ.get("GOOGLE_CONFIG_DIR")
34
+ return Path(value) if value else None
35
+
36
+
37
+ def _token_path() -> Path | None:
38
+ config_dir = _config_dir()
39
+ return config_dir / "gdrives_token.json" if config_dir else None
40
+
41
+
42
+ def _credentials_path() -> Path | None:
43
+ config_dir = _config_dir()
44
+ return config_dir / "gdrives_credentials.json" if config_dir else None
45
+
46
+
47
+ def _service_account_path() -> Path | None:
48
+ value = os.environ.get("GOOGLE_SERVICE_ACCOUNT_PATH")
49
+ if value:
50
+ return Path(value)
51
+ config_dir = _config_dir()
52
+ return config_dir / "service_account.json" if config_dir else None
53
+
54
+
55
+ def _is_interactive() -> bool:
56
+ """True when stdin is a TTY, i.e. an interactive OAuth browser flow can run."""
57
+ return sys.stdin.isatty()
58
+
59
+
60
+ def _write_token(token_path: Path, creds: Any) -> None:
61
+ """Persist OAuth credentials atomically with owner-only (0600) permissions.
62
+
63
+ The token file holds a long-lived refresh token, so it must not be group- or
64
+ world-readable. Writing through a 0600 temp file and an atomic rename avoids
65
+ both a loose-permission window and a partially written token on failure. A
66
+ failed write is logged, not raised — the caller still holds valid in-memory
67
+ credentials for this run.
68
+ """
69
+ try:
70
+ tmp = token_path.with_name(token_path.name + ".tmp")
71
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
72
+ with os.fdopen(fd, "w") as f:
73
+ f.write(creds.to_json())
74
+ tmp.replace(token_path)
75
+ except OSError:
76
+ logger.warning("could not persist OAuth token to %s", token_path)
77
+
78
+
79
+ def authenticate_oauth():
80
+ """Authenticate with Google Drive via OAuth client secrets flow.
81
+
82
+ Returns None when OAuth is not configured (GOOGLE_CONFIG_DIR unset, no client
83
+ secrets file, or no interactive terminal to complete the browser flow), so
84
+ authenticate() can fall through to other methods.
85
+ """
86
+ token_path = _token_path()
87
+ credentials_path = _credentials_path()
88
+ if token_path is None or credentials_path is None:
89
+ return None
90
+
91
+ from google.auth.transport.requests import Request
92
+ from google.oauth2.credentials import Credentials
93
+ from google_auth_oauthlib.flow import InstalledAppFlow
94
+
95
+ creds = None
96
+ if token_path.exists():
97
+ creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
98
+ if creds and creds.expired and creds.refresh_token:
99
+ try:
100
+ creds.refresh(Request())
101
+ except google.auth.exceptions.RefreshError:
102
+ creds = None
103
+ else:
104
+ _write_token(token_path, creds)
105
+ if not creds or not creds.valid:
106
+ # Fall through to other auth methods rather than blocking on a browser
107
+ # flow when there's no client secrets file or no interactive terminal.
108
+ if not credentials_path.exists() or not _is_interactive():
109
+ return None
110
+ flow = InstalledAppFlow.from_client_secrets_file(str(credentials_path), SCOPES)
111
+ creds = flow.run_local_server(port=0, open_browser=False)
112
+ _write_token(token_path, creds)
113
+ return creds
114
+
115
+
116
+ def authenticate_service_account():
117
+ """Authenticate with Google Drive via a service account key file.
118
+
119
+ Returns None when no key file is configured or present.
120
+ """
121
+ from google.oauth2.service_account import Credentials
122
+
123
+ sa_path = _service_account_path()
124
+ if sa_path is None or not sa_path.exists():
125
+ return None
126
+ return Credentials.from_service_account_file(str(sa_path), scopes=SCOPES)
127
+
128
+
129
+ def authenticate_adc():
130
+ """Authenticate with Google Drive via Application Default Credentials."""
131
+ import google.auth
132
+
133
+ creds, _ = google.auth.default(scopes=SCOPES)
134
+ return creds
135
+
136
+
137
+ def authenticate():
138
+ """Authenticate with Google Drive.
139
+
140
+ Tries OAuth, then a service account key, then Application Default
141
+ Credentials (e.g. `gcloud auth application-default login`). Raises a
142
+ helpful SystemExit if none are configured.
143
+ """
144
+ creds = authenticate_oauth()
145
+ if creds:
146
+ return creds
147
+ creds = authenticate_service_account()
148
+ if creds:
149
+ return creds
150
+ try:
151
+ return authenticate_adc()
152
+ except google.auth.exceptions.GoogleAuthError:
153
+ raise SystemExit(NO_CREDENTIALS_MESSAGE)
154
+
155
+
156
+ def build_drive_service():
157
+ """Authenticate and return a Drive v3 service."""
158
+ from googleapiclient.discovery import build
159
+
160
+ creds = authenticate()
161
+ return build("drive", "v3", credentials=creds)
gdrives/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ """CLI for Google Drive operations."""
2
+
3
+ import sys
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ app = typer.Typer(help="Google Drive file management tools.")
11
+
12
+
13
+ @contextmanager
14
+ def _cli_errors() -> Iterator[None]:
15
+ """Translate domain, filesystem, and Drive API errors into clean CLI output.
16
+
17
+ One seam so every command surfaces 'Error: ...' + exit 1 instead of a raw
18
+ traceback, and a new command can't forget to handle HttpError or OSError.
19
+ """
20
+ from googleapiclient.errors import HttpError
21
+
22
+ from gdrives.resolve import DrivePathError
23
+
24
+ try:
25
+ yield
26
+ except (DrivePathError, ValueError, OSError) as e:
27
+ print(f"Error: {e}", file=sys.stderr)
28
+ raise SystemExit(1)
29
+ except HttpError as e:
30
+ print(f"Error: Drive API request failed: {e}", file=sys.stderr)
31
+ raise SystemExit(1)
32
+
33
+
34
+ @app.command()
35
+ def export(
36
+ source: Annotated[str, typer.Argument(help="Google Drive URL or file ID")],
37
+ output: Annotated[
38
+ str,
39
+ typer.Option(
40
+ "-o",
41
+ "--output",
42
+ help="Output: .docx (Docs), .xlsx/.csv (Sheets), .pptx (Slides)",
43
+ ),
44
+ ],
45
+ ):
46
+ """Export a Google Doc to .docx, a Sheet to .xlsx/.csv, or Slides to .pptx."""
47
+ from gdrives.export import run
48
+
49
+ with _cli_errors():
50
+ run(source, output)
51
+
52
+
53
+ @app.command()
54
+ def download(
55
+ source: Annotated[
56
+ str,
57
+ typer.Argument(help="Drive file or folder URL or path (e.g. 'My Drive/refs')"),
58
+ ],
59
+ output_dir: Annotated[
60
+ str,
61
+ typer.Option(
62
+ "-o", "--output-dir", help="Local destination directory (default: cwd)"
63
+ ),
64
+ ] = ".",
65
+ depth: Annotated[
66
+ int | None,
67
+ typer.Option(
68
+ "--depth",
69
+ help="Max recursion depth (1=flat, 2=one level, ...; default: unlimited)",
70
+ ),
71
+ ] = None,
72
+ yes: Annotated[
73
+ bool,
74
+ typer.Option("-y", "--yes", help="Skip the confirmation prompt"),
75
+ ] = False,
76
+ ):
77
+ """Download a Drive file or folder to a local directory.
78
+
79
+ A single file downloads straight into the directory under its Drive name.
80
+ A folder is scanned first, showing a summary, then prompts before
81
+ downloading (recurses by default; --depth only affects folders).
82
+
83
+ Google Docs/Sheets/Slides auto-export to .docx/.xlsx/.pptx; other
84
+ Google-native types are skipped. Filename collisions get a ' (N)' suffix
85
+ matching Drive's UI convention.
86
+ """
87
+ from gdrives.download import run
88
+
89
+ with _cli_errors():
90
+ run(source, output_dir, depth=depth, yes=yes)
91
+
92
+
93
+ @app.command()
94
+ def ls(
95
+ path: Annotated[
96
+ str | None,
97
+ typer.Argument(help="Drive path (e.g. 'My Drive/projects')"),
98
+ ] = None,
99
+ drive_id: Annotated[
100
+ str | None,
101
+ typer.Option("--drive-id", help="Folder ID (skip path resolution)"),
102
+ ] = None,
103
+ depth: Annotated[
104
+ int, typer.Option("--depth", help="Max directory depth to list")
105
+ ] = 1,
106
+ save_as: Annotated[
107
+ list[str] | None,
108
+ typer.Option(
109
+ "--save-as",
110
+ help="Save to file (.md/.csv); repeat to write both in one traversal",
111
+ ),
112
+ ] = None,
113
+ shared_with_me: Annotated[
114
+ bool,
115
+ typer.Option(
116
+ "--shared-with-me",
117
+ help="Resolve path from 'Shared with me' items",
118
+ ),
119
+ ] = False,
120
+ ):
121
+ """List contents of a Drive folder by path or ID."""
122
+ if shared_with_me and drive_id:
123
+ print(
124
+ "Error: --shared-with-me and --drive-id are mutually exclusive",
125
+ file=sys.stderr,
126
+ )
127
+ raise SystemExit(1)
128
+
129
+ bad_save_as = [p for p in (save_as or []) if not p.endswith((".md", ".csv"))]
130
+ if bad_save_as:
131
+ print(
132
+ f"Error: --save-as must end in .md or .csv: {', '.join(bad_save_as)}",
133
+ file=sys.stderr,
134
+ )
135
+ raise SystemExit(1)
136
+
137
+ from gdrives.listing import ls as remote_ls
138
+ from gdrives.resolve import resolve_path, resolve_shared_path
139
+
140
+ with _cli_errors():
141
+ if shared_with_me:
142
+ if path is None:
143
+ if depth != 1:
144
+ print(
145
+ "Error: --depth is not supported when listing all shared items",
146
+ file=sys.stderr,
147
+ )
148
+ raise SystemExit(1)
149
+ remote_ls(shared_with_me=True, save_as=save_as)
150
+ else:
151
+ folder_id = resolve_shared_path(path)
152
+ remote_ls(folder_id, depth=depth, save_as=save_as)
153
+ else:
154
+ folder_id = drive_id or resolve_path(path or "My Drive")
155
+ remote_ls(folder_id, depth=depth, save_as=save_as)
156
+
157
+
158
+ @app.command(name="show-drives")
159
+ def show_drives():
160
+ """Fetch and cache available drives."""
161
+ from gdrives.auth import build_drive_service
162
+ from gdrives.drives import CACHE_PATH, fetch, save
163
+
164
+ with _cli_errors():
165
+ service = build_drive_service()
166
+ drives = fetch(service)
167
+ save(drives)
168
+
169
+ max_url = max(len(d["url"]) for d in drives)
170
+ max_kind = max(len(d["type"]) for d in drives)
171
+ for d in drives:
172
+ print(
173
+ f"{d['url']:<{max_url}} {d['type']:<{max_kind}} "
174
+ f"{d['name']} ({d['id']})"
175
+ )
176
+ print(f"\nSaved to {CACHE_PATH}", file=sys.stderr)
gdrives/download.py ADDED
@@ -0,0 +1,330 @@
1
+ """Download a Drive file or folder to a local directory.
2
+
3
+ A source that resolves to a single file downloads straight to output_dir
4
+ (no scan or prompt). A folder is scanned, summarized, and confirmed first.
5
+ Both honor the same per-entry rules below.
6
+
7
+ Recurses by default to unlimited depth; pass depth=N (CLI: --depth N) to cap.
8
+ Depth semantics match `gdrives ls`: depth=1 means flat (current folder only),
9
+ depth=2 includes one level of subfolders, and so on. Binary files download
10
+ directly. Google-native files (Docs, Sheets, Slides) auto-export to
11
+ .docx/.xlsx/.pptx; other native types (Forms, Drawings, etc.) are skipped
12
+ with a warning.
13
+
14
+ Filename collisions
15
+ -------------------
16
+ Drive allows two files with identical names in the same folder (e.g. Form
17
+ response uploads from different submitters). The Drive Web UI dedups these
18
+ for display by appending ' (1)', ' (2)', etc., but the API returns the raw
19
+ stored name — so we see two identical strings when listing. On a real local
20
+ collision, `unique_path` adds a ' (N)' suffix to mirror Drive's display
21
+ convention. A '(N)' you see in an *API-returned* name was baked into the
22
+ filename by the uploader (or by Google Forms), not added by Drive's UI or
23
+ by us.
24
+ """
25
+
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ import typer
31
+ from googleapiclient.http import MediaIoBaseDownload
32
+
33
+ from gdrives.export import NATIVE_EXPORTS, export_file
34
+ from gdrives.files import (
35
+ DriveFile,
36
+ Service,
37
+ WalkItem,
38
+ extract_drive_id,
39
+ file_type,
40
+ is_folder,
41
+ is_native,
42
+ walk_tree,
43
+ )
44
+
45
+ # Map Google-native type label -> local extension, derived from the canonical
46
+ # export table (export.NATIVE_EXPORTS) so download and `gdrives export` never drift.
47
+ NATIVE_EXPORT_EXT = {label: ext for label, (ext, _mime) in NATIVE_EXPORTS.items()}
48
+
49
+
50
+ def safe_filename(name: str) -> str:
51
+ """Sanitize a Drive file name for use on the local filesystem.
52
+
53
+ Replaces both path separators (``/`` and ``\\``) and NUL, then neutralizes
54
+ the ``.``/``..`` dot segments so a Drive entry named ``..`` can't escape the
55
+ target directory (``out / ".."`` would otherwise resolve to its parent).
56
+ Backslash is replaced too so a name like ``..\\..\\evil`` can't traverse on
57
+ Windows, where ``\\`` is a separator. Legitimate dotfiles like ``.env`` are
58
+ preserved.
59
+ """
60
+ cleaned = re.sub(r"[/\\\x00]", "_", name).strip()
61
+ if cleaned in {".", ".."}:
62
+ cleaned = cleaned.replace(".", "_") # "." -> "_", ".." -> "__"
63
+ return cleaned or "file"
64
+
65
+
66
+ def _ensure_dir(out: Path) -> None:
67
+ """Create directory ``out``, with a clear error if a file occupies its path."""
68
+ if out.exists() and not out.is_dir():
69
+ raise NotADirectoryError(
70
+ f"cannot create folder '{out}': a file with that name exists"
71
+ )
72
+ out.mkdir(parents=True, exist_ok=True)
73
+
74
+
75
+ def format_bytes(n: int) -> str:
76
+ """Convert byte count to a human-readable string."""
77
+ size = float(n)
78
+ for unit in ("B", "KB", "MB", "GB", "TB"):
79
+ if size < 1024:
80
+ return f"{size:.1f} {unit}"
81
+ size /= 1024
82
+ return f"{size:.1f} PB"
83
+
84
+
85
+ def classify_entry(f: DriveFile) -> str:
86
+ """Return the download disposition of a non-folder Drive entry.
87
+
88
+ ``"binary"`` downloads via get_media; ``"export"`` is a Google-native file
89
+ with an export format (Doc/Sheet/Slides); ``"skip"`` is a Google-native file
90
+ with no export format (Forms, Drawings, etc.). Shared by the summary counter
91
+ and ``download_entry`` so the two never disagree on what an entry is.
92
+ """
93
+ if is_native(f):
94
+ return "export" if file_type(f) in NATIVE_EXPORT_EXT else "skip"
95
+ return "binary"
96
+
97
+
98
+ def summarize(items: list[WalkItem]) -> dict[str, int]:
99
+ """Tally a materialized ``walk_tree`` into the counts ``print_summary`` shows.
100
+
101
+ Counts each entry once from the same list the download pass consumes, so the
102
+ summary can't drift from what is actually fetched.
103
+ """
104
+ summary = {
105
+ "binary_files": 0,
106
+ "binary_bytes": 0,
107
+ "auto_export": 0,
108
+ "skipped_natives": 0,
109
+ "subfolders": 0,
110
+ "skipped_subfolders": 0,
111
+ }
112
+ for item in items:
113
+ f = item.file
114
+ if is_folder(f):
115
+ if item.descended:
116
+ summary["subfolders"] += 1
117
+ else:
118
+ summary["skipped_subfolders"] += 1
119
+ continue
120
+
121
+ disposition = classify_entry(f)
122
+ if disposition == "export":
123
+ summary["auto_export"] += 1
124
+ elif disposition == "skip":
125
+ summary["skipped_natives"] += 1
126
+ else:
127
+ summary["binary_files"] += 1
128
+ try:
129
+ summary["binary_bytes"] += int(f.get("size") or 0)
130
+ except (ValueError, TypeError):
131
+ pass
132
+ return summary
133
+
134
+
135
+ def print_summary(summary: dict[str, int], output_dir: str) -> None:
136
+ """Print a download plan summary to stderr."""
137
+ print(f"\nDownload plan for {output_dir}/:", file=sys.stderr)
138
+ print(
139
+ f" {summary['binary_files']:>4} binary file(s) "
140
+ f"({format_bytes(summary['binary_bytes'])})",
141
+ file=sys.stderr,
142
+ )
143
+ if summary["auto_export"]:
144
+ print(
145
+ f" {summary['auto_export']:>4} Google Doc/Sheet/Slides to auto-export",
146
+ file=sys.stderr,
147
+ )
148
+ if summary["skipped_natives"]:
149
+ print(
150
+ f" {summary['skipped_natives']:>4} native file(s) skipped "
151
+ "(Forms, Drawings, etc.)",
152
+ file=sys.stderr,
153
+ )
154
+ if summary["subfolders"]:
155
+ print(
156
+ f" {summary['subfolders']:>4} subfolder(s) to create",
157
+ file=sys.stderr,
158
+ )
159
+ if summary["skipped_subfolders"]:
160
+ print(
161
+ f" {summary['skipped_subfolders']:>4} subfolder(s) skipped (depth limit)",
162
+ file=sys.stderr,
163
+ )
164
+
165
+
166
+ def unique_path(target: Path) -> Path:
167
+ """If target exists, append ' (1)', ' (2)', ... before the extension until unique.
168
+
169
+ Mirrors Google Drive Web UI's display-time dedup convention so that locally
170
+ disambiguated names look the way Drive would have rendered them.
171
+ """
172
+ if not target.exists():
173
+ return target
174
+ stem, suffix, parent = target.stem, target.suffix, target.parent
175
+ n = 1
176
+ while True:
177
+ candidate = parent / f"{stem} ({n}){suffix}"
178
+ if not candidate.exists():
179
+ return candidate
180
+ n += 1
181
+
182
+
183
+ def get_file_metadata(service: Service, file_id: str) -> DriveFile:
184
+ """Fetch id, name, and mimeType for a Drive file or folder.
185
+
186
+ Includes supportsAllDrives so IDs in shared drives resolve.
187
+ """
188
+ return (
189
+ service.files()
190
+ .get(fileId=file_id, fields="id, name, mimeType", supportsAllDrives=True)
191
+ .execute()
192
+ )
193
+
194
+
195
+ def download_file(service: Service, file_id: str, output_path: str) -> int:
196
+ """Download a binary Drive file to output_path. Returns bytes written.
197
+
198
+ Streams chunks straight to a temporary ``.part`` file and renames it into
199
+ place, so memory stays bounded regardless of file size. A failed download
200
+ removes the partial ``.part`` file and leaves nothing at the final path.
201
+ """
202
+ request = service.files().get_media(fileId=file_id, supportsAllDrives=True)
203
+ target = Path(output_path)
204
+ tmp = target.with_name(target.name + ".part")
205
+ try:
206
+ with tmp.open("wb") as fh:
207
+ downloader = MediaIoBaseDownload(fh, request)
208
+ done = False
209
+ while not done:
210
+ _, done = downloader.next_chunk()
211
+ tmp.replace(target)
212
+ finally:
213
+ # On success tmp was renamed away (no-op); on failure drop the partial.
214
+ tmp.unlink(missing_ok=True)
215
+ return target.stat().st_size
216
+
217
+
218
+ def download_entry(service: Service, f: DriveFile, out: Path) -> None:
219
+ """Download one non-folder Drive entry into the existing directory `out`.
220
+
221
+ Google-native Docs/Sheets/Slides auto-export to .docx/.xlsx/.pptx; other
222
+ native types (Forms, Drawings, etc.) are skipped with a warning. Binary
223
+ files download via get_media. Names that collide locally get a ' (N)' suffix.
224
+ """
225
+ name = safe_filename(f["name"])
226
+ disposition = classify_entry(f)
227
+
228
+ if disposition == "skip":
229
+ print(f" skip {file_type(f)} (no export format): {name}", file=sys.stderr)
230
+ return
231
+
232
+ if disposition == "export":
233
+ ext = NATIVE_EXPORT_EXT[file_type(f)]
234
+ target = unique_path(out / f"{name}{ext}")
235
+ export_file(service, f["id"], str(target))
236
+ return
237
+
238
+ target = unique_path(out / name)
239
+ size = download_file(service, f["id"], str(target))
240
+ print(f" {target} ({size} bytes)")
241
+
242
+
243
+ def download_walk(service: Service, items: list[WalkItem], output_dir: str) -> None:
244
+ """Download a materialized ``walk_tree`` into output_dir, depth-first.
245
+
246
+ Consumes the same list ``summarize`` counted, so the download can't diverge
247
+ from the summary. Each item's local parent directory is ``output_dir`` joined
248
+ with its sanitized ancestor names; a within-depth folder creates its
249
+ subdirectory (even when empty) and prints ``-> subdir/``, while a
250
+ depth-limited folder prints a skip notice. Messages fire here, at download
251
+ time, in the walk's depth-first order.
252
+ """
253
+ out = Path(output_dir)
254
+ _ensure_dir(out)
255
+
256
+ for item in items:
257
+ parent = out.joinpath(*(safe_filename(a) for a in item.ancestors))
258
+ f = item.file
259
+ if is_folder(f):
260
+ name = safe_filename(f["name"])
261
+ if item.descended:
262
+ subdir = parent / name
263
+ print(f" -> {subdir}/", file=sys.stderr)
264
+ _ensure_dir(subdir)
265
+ else:
266
+ print(f" skip subfolder (depth limit): {name}/", file=sys.stderr)
267
+ continue
268
+
269
+ download_entry(service, f, parent)
270
+
271
+
272
+ def download_single(service: Service, meta: DriveFile, output_dir: str) -> None:
273
+ """Download a single resolved Drive file into output_dir.
274
+
275
+ Creates output_dir if needed, then writes the file using its Drive name.
276
+ Google-native Docs/Sheets auto-export; other native types are skipped.
277
+ """
278
+ print(f"File ID: {meta['id']}", file=sys.stderr)
279
+ out = Path(output_dir)
280
+ _ensure_dir(out)
281
+ download_entry(service, meta, out)
282
+
283
+
284
+ def run(
285
+ source: str,
286
+ output_dir: str = ".",
287
+ depth: int | None = None,
288
+ yes: bool = False,
289
+ ) -> None:
290
+ """Download a Drive file or folder to output_dir.
291
+
292
+ A single file downloads immediately. A folder is scanned first, with a
293
+ summary and a confirmation prompt before its contents download. `depth`
294
+ only affects folders.
295
+ """
296
+ from gdrives.auth import build_drive_service
297
+ from gdrives.resolve import resolve_path
298
+
299
+ service = build_drive_service()
300
+
301
+ if source.startswith(("http://", "https://")):
302
+ entry_id = extract_drive_id(source)
303
+ else:
304
+ entry_id = resolve_path(source, service, allow_files=True)
305
+
306
+ meta = get_file_metadata(service, entry_id)
307
+
308
+ if not is_folder(meta):
309
+ download_single(service, meta, output_dir)
310
+ return
311
+
312
+ folder_id = meta["id"]
313
+ print(f"Folder ID: {folder_id}", file=sys.stderr)
314
+ print("Scanning folder...", file=sys.stderr)
315
+ # Walk the tree exactly once: the summary and the download both come from
316
+ # this single list, so they can't disagree and the proceed path makes one
317
+ # set of list_children calls instead of two.
318
+ items = list(walk_tree(service, folder_id, depth=depth))
319
+ summary = summarize(items)
320
+ print_summary(summary, output_dir)
321
+
322
+ if summary["binary_files"] == 0 and summary["auto_export"] == 0:
323
+ print("\nNothing to download.", file=sys.stderr)
324
+ return
325
+
326
+ if not yes and not typer.confirm("\nProceed?", default=False):
327
+ print("Aborted.", file=sys.stderr)
328
+ return
329
+
330
+ download_walk(service, items, output_dir)