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 +0 -0
- gdrives/auth.py +161 -0
- gdrives/cli.py +176 -0
- gdrives/download.py +330 -0
- gdrives/drives.py +81 -0
- gdrives/export.py +55 -0
- gdrives/files.py +219 -0
- gdrives/listing.py +188 -0
- gdrives/py.typed +0 -0
- gdrives/resolve.py +135 -0
- gdrives-0.5.8.dist-info/METADATA +217 -0
- gdrives-0.5.8.dist-info/RECORD +15 -0
- gdrives-0.5.8.dist-info/WHEEL +4 -0
- gdrives-0.5.8.dist-info/entry_points.txt +2 -0
- gdrives-0.5.8.dist-info/licenses/LICENSE +22 -0
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)
|