44import os
55import subprocess
66from datetime import datetime
7- from typing import Optional
7+ from typing import Optional , List
8+ import shutil
89
910from pydrive2 .auth import GoogleAuth
1011from 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+
1221from .config import AppConfig
1322
1423logger = logging .getLogger (__name__ )
1524
1625
1726def _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
58112def 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