Skip to content

Commit d05d0d0

Browse files
Command fixes
1 parent 6ba91e9 commit d05d0d0

File tree

5 files changed

+290
-69
lines changed

5 files changed

+290
-69
lines changed

cli.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,21 @@ def db_push(
183183
local_db: Annotated[
184184
Optional[str],
185185
typer.Option(
186-
"--local-db", help="Path to local-db binary (falls back to config.local_db_path)"
186+
"--local-db",
187+
help="Optional helper script path (e.g. tools/dev-env/local-db). This script may stash/restore; the actual data file comes from config.local_db_path."
187188
),
188189
] = None,
190+
dry_run: Annotated[
191+
bool,
192+
typer.Option(
193+
"--dry-run", help="Do not upload to Drive, just show what would be uploaded"
194+
),
195+
] = False,
189196
config: ConfigOpt = None,
190197
log_level: LogLevelOpt = None,
191198
):
192199
cfg = _load_cfg(config, None, None, log_level)
193-
push_to_drive(cfg, version, overwrite, local_db)
200+
push_to_drive(cfg, version, overwrite, local_db, dry_run=dry_run)
194201

195202

196203
@db_app.command("pull")
@@ -201,14 +208,36 @@ def db_pull(
201208
local_db: Annotated[
202209
Optional[str],
203210
typer.Option(
204-
"--local-db", help="Path to local-db binary (falls back to config.local_db_path)"
211+
"--local-db",
212+
help="Optional helper script path (e.g. tools/dev-env/local-db). This script may stash/restore; the actual data file comes from config.local_db_path.",
205213
),
206214
] = None,
215+
overwrite: Annotated[
216+
bool,
217+
typer.Option(
218+
"--overwrite/--no-overwrite",
219+
help="Whether to overwrite the local data file when restoring from Drive (default: overwrite)",
220+
show_default=True,
221+
),
222+
] = True,
207223
config: ConfigOpt = None,
208224
log_level: LogLevelOpt = None,
209225
):
210226
cfg = _load_cfg(config, None, None, log_level)
211-
pull_from_drive(cfg, version, local_db)
227+
pull_from_drive(cfg, version, local_db, overwrite=overwrite)
228+
229+
230+
@db_app.command("list")
231+
def db_list(
232+
config: ConfigOpt = None,
233+
log_level: LogLevelOpt = None,
234+
):
235+
cfg = _load_cfg(config, None, None, log_level)
236+
from commands.drive_sync import list_backups
237+
238+
backups = list_backups(cfg)
239+
for b in backups:
240+
typer.echo(b)
212241

213242

214243
app.add_typer(db_app, name="db")

commands/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ class AppConfig:
3939

4040
# Drive sync settings
4141
local_db_path: Optional[str] = None
42+
# Google Drive folder name where backups are stored
43+
gdrive_directory: str = "datastore"
4244

4345

4446
def _as_list(value: Optional[Iterable[str]]) -> List[str]:
@@ -94,6 +96,7 @@ def load_config(path: Optional[str] = None, overrides: Optional[Dict] = None) ->
9496
config.log_level = str(merged.get("log_level", config.log_level)).upper()
9597

9698
config.local_db_path = merged.get("local_db_path", config.local_db_path)
99+
config.gdrive_directory = merged.get("gdrive_directory", config.gdrive_directory)
97100

98101
_configure_logging(config.log_level)
99102
return config

commands/drive_sync.py

Lines changed: 164 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,28 @@
44
import os
55
import subprocess
66
from datetime import datetime
7-
from typing import Optional
7+
from typing import Optional, List
8+
import shutil
89

910
from pydrive2.auth import GoogleAuth
1011
from pydrive2.drive import GoogleDrive
1112

13+
try:
14+
# Optional imports for ADC/google-api fallback
15+
import google.auth
16+
from googleapiclient.discovery import build
17+
from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
18+
except Exception:
19+
google = None
20+
1221
from .config import AppConfig
1322

1423
logger = logging.getLogger(__name__)
1524

1625

1726
def _authenticate_drive() -> GoogleDrive:
1827
gauth = GoogleAuth()
28+
# Interactive flow: open local webserver and prompt user to authenticate in browser
1929
gauth.LocalWebserverAuth()
2030
return GoogleDrive(gauth)
2131

@@ -29,99 +39,211 @@ def _get_local_db_path(config: AppConfig, local_db_override: Optional[str]) -> s
2939
return local_db
3040

3141

32-
def _get_or_create_datastore_folder(drive: GoogleDrive) -> str:
42+
def _get_or_create_gdrive_folder(drive: GoogleDrive, folder_name: str) -> str:
3343
file_list = drive.ListFile(
3444
{
35-
"q": "title='datastore' and mimeType='application/vnd.google-apps.folder' and trashed=false"
45+
"q": f"title='{folder_name}' and mimeType='application/vnd.google-apps.folder' and trashed=false"
3646
}
3747
).GetList()
3848
if file_list:
3949
return file_list[0]["id"]
40-
folder = drive.CreateFile(
41-
{"title": "datastore", "mimeType": "application/vnd.google-apps.folder"}
42-
)
50+
folder = drive.CreateFile({"title": folder_name, "mimeType": "application/vnd.google-apps.folder"})
4351
folder.Upload()
44-
logger.info("Created /datastore folder in Google Drive")
52+
logger.info(f"Created /{folder_name} folder in Google Drive")
4553
return folder["id"]
4654

4755

48-
def _run_local_db_command(local_db_path: str, args: list[str]) -> None:
49-
cmd = [local_db_path] + args
56+
def _get_adc_drive_service():
57+
"""Return a googleapiclient Drive service when ADC is available and has Drive scope, otherwise None.
58+
59+
We avoid using ADC unless the obtained credentials explicitly include the Drive scope. This
60+
prevents accidentally selecting ADC in test environments or runtime environments where the
61+
default credentials don't have Drive permissions (which would cause 403 errors later).
62+
"""
63+
try:
64+
if google is None:
65+
return None
66+
DRIVE_SCOPE = "https://www.googleapis.com/auth/drive"
67+
# Try to get ADC without forcing scopes first; some environments may provide scoped creds
68+
creds, _ = google.auth.default()
69+
70+
scopes = getattr(creds, "scopes", None)
71+
# If scopes are not present or don't include the Drive scope, try requesting the Drive scope
72+
if not scopes or DRIVE_SCOPE not in scopes:
73+
try:
74+
creds, _ = google.auth.default(scopes=[DRIVE_SCOPE])
75+
scopes = getattr(creds, "scopes", None)
76+
except Exception:
77+
return None
78+
79+
if not scopes or DRIVE_SCOPE not in scopes:
80+
return None
81+
82+
service = build("drive", "v3", credentials=creds, cache_discovery=False)
83+
return service
84+
except Exception:
85+
return None
86+
87+
88+
def list_backups(config: AppConfig) -> List[str]:
89+
drive = _authenticate_drive()
90+
folder_id = _get_or_create_gdrive_folder(drive, config.gdrive_directory)
91+
files = drive.ListFile({"q": f"'{folder_id}' in parents and trashed=false"}).GetList()
92+
return [f["title"] for f in files]
93+
94+
95+
def _run_local_db_command(local_db_script: str, args: list[str]) -> str:
96+
# Run helper script (if provided) and return stdout. The script is optional; callers may ignore the output.
97+
if not os.path.exists(local_db_script):
98+
raise FileNotFoundError(f"local-db script not found at: {local_db_script}")
99+
if not os.access(local_db_script, os.X_OK):
100+
raise PermissionError(f"local-db script is not executable: {local_db_script}")
101+
102+
cmd = [local_db_script] + args
50103
logger.info(f"Running: {' '.join(cmd)}")
51104
result = subprocess.run(cmd, capture_output=True, text=True)
52105
if result.returncode != 0:
53106
raise RuntimeError(f"Command failed: {result.stderr}")
54107
if result.stdout:
55108
logger.info(result.stdout)
109+
return result.stdout
56110

57111

58112
def push_to_drive(
59-
config: AppConfig, version: Optional[str], overwrite: bool, local_db: Optional[str]
113+
config: AppConfig,
114+
version: Optional[str],
115+
overwrite: bool,
116+
local_db_script: Optional[str],
117+
dry_run: bool = False,
60118
) -> None:
61-
local_db_path = _get_local_db_path(config, local_db)
119+
# local_db_script: optional helper script that produces stashes; local_db_path points to the data binary
120+
data_path = config.local_db_path
121+
if local_db_script:
122+
# If script provided via CLI, use that to produce a stash; capture any output but do not rely on it
123+
_run_local_db_command(local_db_script, ["stash", version or datetime.now().strftime("%Y-%m-%d")])
124+
125+
if not data_path or not os.path.exists(data_path):
126+
raise FileNotFoundError("local_db_path must point to the datastore data binary to upload")
62127

63128
if not version:
64129
version = datetime.now().strftime("%Y-%m-%d")
65130

66131
backup_file = f"local-db-{version}.bin"
67132

68-
_run_local_db_command(local_db_path, ["stash", version])
69-
70-
if not os.path.exists(backup_file):
71-
raise FileNotFoundError(f"Stash did not produce expected file: {backup_file}")
133+
if dry_run:
134+
logger.info(f"DRY RUN: would upload {data_path} to Google Drive as {backup_file} in /{config.gdrive_directory}")
135+
return
136+
# Prefer Application Default Credentials (gcloud auth) when available
137+
adc_service = _get_adc_drive_service()
138+
if adc_service:
139+
# Ensure folder exists or create it
140+
# Search for folder
141+
q = f"mimeType='application/vnd.google-apps.folder' and name='{config.gdrive_directory}' and trashed=false"
142+
res = adc_service.files().list(q=q, fields="files(id,name)").execute()
143+
files = res.get("files", [])
144+
if files:
145+
folder_id = files[0]["id"]
146+
else:
147+
file_metadata = {"name": config.gdrive_directory, "mimeType": "application/vnd.google-apps.folder"}
148+
created = adc_service.files().create(body=file_metadata, fields="id").execute()
149+
folder_id = created["id"]
150+
151+
# Check if a backup with the same name exists
152+
q2 = f"name='{backup_file}' and '{folder_id}' in parents and trashed=false"
153+
res2 = adc_service.files().list(q=q2, fields="files(id,name)").execute()
154+
existing_files = res2.get("files", [])
155+
if existing_files and not overwrite:
156+
raise FileExistsError(f"File {backup_file} already exists in /{config.gdrive_directory}. Use -o to overwrite.")
157+
158+
media = MediaFileUpload(data_path, resumable=True)
159+
if existing_files:
160+
file_id = existing_files[0]["id"]
161+
adc_service.files().update(fileId=file_id, media_body=media).execute()
162+
else:
163+
file_metadata = {"name": backup_file, "parents": [folder_id]}
164+
adc_service.files().create(body=file_metadata, media_body=media, fields="id").execute()
165+
logger.info(f"Successfully uploaded {backup_file} to Google Drive /{config.gdrive_directory} (ADC)")
166+
return
72167

168+
# Fallback to pydrive2 interactive flow
73169
drive = _authenticate_drive()
74-
folder_id = _get_or_create_datastore_folder(drive)
170+
folder_id = _get_or_create_gdrive_folder(drive, config.gdrive_directory)
75171

76-
existing = drive.ListFile(
77-
{"q": f"title='{backup_file}' and '{folder_id}' in parents and trashed=false"}
78-
).GetList()
172+
existing = drive.ListFile({"q": f"title='{backup_file}' and '{folder_id}' in parents and trashed=false"}).GetList()
79173
if existing:
80174
if overwrite:
81175
logger.info(f"Overwriting existing file: {backup_file}")
82176
file_to_upload = existing[0]
83177
else:
84-
raise FileExistsError(
85-
f"File {backup_file} already exists in /datastore. Use -o to overwrite."
86-
)
178+
raise FileExistsError(f"File {backup_file} already exists in /{config.gdrive_directory}. Use -o to overwrite.")
87179
else:
88180
file_to_upload = drive.CreateFile({"title": backup_file, "parents": [{"id": folder_id}]})
89181

90-
file_to_upload.SetContentFile(backup_file)
182+
file_to_upload.SetContentFile(data_path)
91183
file_to_upload.Upload()
92-
logger.info(f"Successfully uploaded {backup_file} to Google Drive /datastore")
184+
logger.info(f"Successfully uploaded {backup_file} to Google Drive /{config.gdrive_directory}")
93185

94186

95-
def pull_from_drive(config: AppConfig, version: Optional[str], local_db: Optional[str]) -> None:
96-
local_db_path = _get_local_db_path(config, local_db)
187+
def pull_from_drive(config: AppConfig, version: Optional[str], local_db_script: Optional[str], overwrite: bool = True) -> None:
188+
data_path = config.local_db_path
189+
if not data_path:
190+
raise ValueError("local_db_path must be configured in order to restore the database file")
97191

98192
drive = _authenticate_drive()
99-
folder_id = _get_or_create_datastore_folder(drive)
193+
folder_id = _get_or_create_gdrive_folder(drive, config.gdrive_directory)
100194

101195
if version:
102196
backup_file = f"local-db-{version}.bin"
103-
files = drive.ListFile(
104-
{"q": f"title='{backup_file}' and '{folder_id}' in parents and trashed=false"}
105-
).GetList()
197+
files = drive.ListFile({"q": f"title='{backup_file}' and '{folder_id}' in parents and trashed=false"}).GetList()
106198
if not files:
107199
raise FileNotFoundError(f"No backup found with version: {version}")
108200
file_to_download = files[0]
109201
else:
110-
files = drive.ListFile(
111-
{
112-
"q": f"'{folder_id}' in parents and trashed=false and title contains 'local-db-' and title contains '.bin'",
113-
"orderBy": "modifiedDate desc",
114-
"maxResults": 1,
115-
}
116-
).GetList()
202+
files = drive.ListFile({
203+
"q": f"'{folder_id}' in parents and trashed=false and title contains 'local-db-' and title contains '.bin'",
204+
"orderBy": "modifiedDate desc",
205+
"maxResults": 1,
206+
}).GetList()
117207
if not files:
118-
raise FileNotFoundError("No backups found in /datastore folder")
208+
raise FileNotFoundError(f"No backups found in /{config.gdrive_directory} folder")
119209
file_to_download = files[0]
120210
backup_file = file_to_download["title"]
121211

122212
logger.info(f"Downloading {backup_file} from Google Drive")
123-
file_to_download.GetContentFile(backup_file)
213+
# Prefer ADC if available
214+
adc_service = _get_adc_drive_service()
215+
tmp_download = f".download_{backup_file}"
216+
if adc_service:
217+
# find file id
218+
q = f"name='{backup_file}' and '{folder_id}' in parents and trashed=false"
219+
res = adc_service.files().list(q=q, fields="files(id,name)").execute()
220+
files = res.get("files", [])
221+
if not files:
222+
raise FileNotFoundError(f"No backup found with name: {backup_file}")
223+
file_id = files[0]["id"]
224+
request = adc_service.files().get_media(fileId=file_id)
225+
fh = open(tmp_download, "wb")
226+
downloader = MediaIoBaseDownload(fh, request)
227+
done = False
228+
while not done:
229+
status, done = downloader.next_chunk()
230+
fh.close()
231+
else:
232+
# Download into a temporary location then move into place
233+
file_to_download.GetContentFile(tmp_download)
234+
235+
if os.path.exists(data_path) and not overwrite:
236+
raise FileExistsError(f"Local DB file exists at {data_path}; use overwrite option to replace")
237+
238+
# ensure parent dir exists
239+
parent = os.path.dirname(data_path)
240+
if parent:
241+
os.makedirs(parent, exist_ok=True)
242+
243+
shutil.copy2(tmp_download, data_path)
244+
os.remove(tmp_download)
245+
logger.info(f"Restored backup to {data_path}")
124246

125-
version_to_restore = backup_file.replace("local-db-", "").replace(".bin", "")
126-
_run_local_db_command(local_db_path, ["restore", version_to_restore])
127-
logger.info(f"Successfully restored backup: {version_to_restore}")
247+
# Optionally run helper restore script if provided
248+
if local_db_script:
249+
_run_local_db_command(local_db_script, ["restore", version or "latest"])

0 commit comments

Comments
 (0)