Faithful port of GOLD's GoogleDriveSync against the live @api gateway. Completes the backup restoration (master FPLAN-0264). Drive pipeline (handlers/drive/): - DriveClient: folder hierarchy 'AIPass Backups/<project>/', thread-safe cache, retry-with-rebuild. Auth via aipass.api get_drive_service + api_call_with_retry (never console-OAuth). - upload.py: resumable MediaFileUpload, 3 threaded workers, single + batch. - tracker.py: mtime+size dedup (.backup_system/drive_tracker.json) — no re-upload of unchanged files. - test.py: connectivity check. All 4 drive_* modules un-stubbed. all = snapshot->versioned->drive-sync, drive step FAILS HONESTLY if creds absent (no silent skip, snapshot+versioned still run). restore command (modules/restore.py -> handlers/diff/restore.py): - 'restore <project> list <file>' (baseline + current + diffs) - 'restore <project> file <file> <out>' (reconstruct + write) pyright/cleanup (Patrick's call): removed backup's standalone pyrightconfig.json (pre-namespace leftover, archived) so it inherits the repo-root config like every citizen; dead PyQt5 ui/settings_window.py archived. Drive tests fully mocked — ZERO real Google calls in CI. Live Drive upload awaits Google OAuth creds (~/.secrets/aipass/google_client_secret.json + drone @api reauth google) — Patrick's setup step. Verified by artifact (devpulse): seedgo 100% all 36 standards / 37 files, 187 tests, ruff clean; restore list/file round-trip confirmed live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
245 lines
7.1 KiB
Python
245 lines
7.1 KiB
Python
# =================== AIPass ====================
|
|
# Name: drive_sync.py
|
|
# Description: Drive sync module — uploads versioned store to Google Drive
|
|
# Version: 1.0.0
|
|
# Created: 2026-04-17
|
|
# Modified: 2026-06-12
|
|
# =============================================
|
|
|
|
"""Drive Sync Module — orchestrates file upload to Google Drive.
|
|
|
|
Scans the versioned store, checks the tracker for changes, and uploads
|
|
new or modified files via the Drive upload engine.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from aipass.prax import logger
|
|
from aipass.cli.apps.modules import console
|
|
|
|
from aipass.backup.apps.handlers.json import json_handler
|
|
from aipass.backup.apps.handlers.path.builder import build_versioned_store
|
|
|
|
|
|
MODULE_NAME = "drive_sync"
|
|
PRIMARY_COMMAND = "drive-sync"
|
|
|
|
|
|
def print_introspection():
|
|
"""Display module info and connected handlers."""
|
|
console.print(f"[bold cyan]{MODULE_NAME} Module[/bold cyan]")
|
|
console.print(f" Primary command: [yellow]{PRIMARY_COMMAND}[/yellow]")
|
|
console.print(" Status: Phase 4 -- Drive sync via @api gateway")
|
|
console.print(" Handlers: drive/client, drive/upload, drive/tracker")
|
|
|
|
|
|
def print_help():
|
|
"""Display help for this module."""
|
|
print_introspection()
|
|
console.print()
|
|
console.print("Usage: drive-sync <project_root> [options]")
|
|
console.print(" --force Force re-upload of all files")
|
|
console.print(" --project Override project name")
|
|
console.print(" --note Add a note to uploaded files")
|
|
|
|
|
|
def run_drive_sync(
|
|
project_root: str,
|
|
project_name: str = "",
|
|
note: str = "",
|
|
force: bool = False,
|
|
show_panels: bool = True,
|
|
) -> dict:
|
|
"""Run Drive sync -- upload versioned store to Google Drive.
|
|
|
|
Flow:
|
|
1. Create DriveClient, authenticate
|
|
2. Build versioned store path from project_root
|
|
3. Scan versioned store for all files (skip dotfiles)
|
|
4. Load tracker, check each file with check_needs_upload()
|
|
5. Show Rich progress bar if show_panels
|
|
6. Call upload_batch() with files that need upload
|
|
7. Save tracker
|
|
8. Return result dict
|
|
|
|
Args:
|
|
project_root: Absolute path to the project.
|
|
project_name: Override project name (defaults to dir name).
|
|
note: Note attached to uploads.
|
|
force: Force re-upload of all files.
|
|
show_panels: Show rich CLI output.
|
|
|
|
Returns:
|
|
Dict with success, uploaded, failed, skipped, error keys.
|
|
"""
|
|
from aipass.backup.apps.handlers.drive.client import DriveClient
|
|
from aipass.backup.apps.handlers.drive.tracker import (
|
|
check_needs_upload,
|
|
load_tracker,
|
|
save_tracker,
|
|
)
|
|
from aipass.backup.apps.handlers.drive.upload import upload_batch
|
|
|
|
result: dict = {
|
|
"success": False,
|
|
"uploaded": 0,
|
|
"failed": 0,
|
|
"skipped": 0,
|
|
"error": None,
|
|
}
|
|
|
|
# 1. Authenticate
|
|
client = DriveClient()
|
|
if not client.authenticate():
|
|
result["error"] = client.last_error or "Drive authentication failed"
|
|
logger.warning(f"[backup] Drive sync auth failed: {result['error']}")
|
|
return result
|
|
|
|
# 2. Build versioned store path
|
|
store_path = build_versioned_store(project_root)
|
|
if not store_path.exists():
|
|
result["error"] = f"Versioned store not found: {store_path}"
|
|
logger.warning(f"[backup] {result['error']}")
|
|
return result
|
|
|
|
# 3. Scan for files (skip dotfiles)
|
|
all_files = [
|
|
f
|
|
for f in store_path.rglob("*")
|
|
if f.is_file() and not any(part.startswith(".") for part in f.relative_to(store_path).parts)
|
|
]
|
|
|
|
if not all_files:
|
|
result["success"] = True
|
|
result["skipped"] = 0
|
|
if show_panels:
|
|
console.print("[dim]No files found in versioned store.[/dim]")
|
|
return result
|
|
|
|
# 4. Load tracker + filter
|
|
tracker = load_tracker(project_root)
|
|
if force:
|
|
files_to_upload = all_files
|
|
else:
|
|
files_to_upload = [f for f in all_files if check_needs_upload(tracker, f, store_path)]
|
|
|
|
skipped = len(all_files) - len(files_to_upload)
|
|
result["skipped"] = skipped
|
|
|
|
if not files_to_upload:
|
|
result["success"] = True
|
|
if show_panels:
|
|
console.print(f"[green]All {len(all_files)} files up to date.[/green]")
|
|
return result
|
|
|
|
# 5. Resolve project name
|
|
if not project_name:
|
|
project_name = Path(project_root).name
|
|
|
|
# 6. Upload with progress
|
|
progress_fn = None
|
|
progress = None
|
|
task = None
|
|
|
|
if show_panels:
|
|
try:
|
|
from rich.progress import Progress
|
|
|
|
progress = Progress(console=console)
|
|
progress.start()
|
|
task = progress.add_task(
|
|
"Uploading to Drive...",
|
|
total=len(files_to_upload),
|
|
)
|
|
|
|
def _advance():
|
|
if progress is not None and task is not None:
|
|
progress.advance(task)
|
|
|
|
progress_fn = _advance
|
|
except ImportError:
|
|
logger.info("Rich progress not available for Drive upload display")
|
|
|
|
try:
|
|
batch_result = upload_batch(
|
|
client,
|
|
files_to_upload,
|
|
project_name,
|
|
store_path,
|
|
tracker,
|
|
note=note,
|
|
progress_fn=progress_fn,
|
|
)
|
|
finally:
|
|
if progress is not None:
|
|
progress.stop()
|
|
|
|
result["uploaded"] = batch_result.get("uploaded", 0)
|
|
result["failed"] = batch_result.get("failed", 0)
|
|
result["success"] = batch_result.get("success", False)
|
|
|
|
# 7. Save tracker
|
|
save_tracker(project_root, tracker)
|
|
|
|
json_handler.log_operation(
|
|
"drive_sync_complete",
|
|
{
|
|
"project_root": project_root,
|
|
"uploaded": result["uploaded"],
|
|
"failed": result["failed"],
|
|
"skipped": skipped,
|
|
},
|
|
)
|
|
logger.info(f"[backup] Drive sync: {result['uploaded']} uploaded, {result['failed']} failed, {skipped} skipped")
|
|
|
|
if show_panels:
|
|
if result["success"]:
|
|
console.print(f"[green]Drive sync complete: {result['uploaded']} uploaded, {skipped} skipped[/green]")
|
|
else:
|
|
console.print(f"Drive sync had failures: {result['uploaded']} ok, {result['failed']} failed")
|
|
|
|
return result
|
|
|
|
|
|
def handle_command(command: str, args: list) -> bool:
|
|
"""Handle the drive-sync command. Returns True if handled."""
|
|
if command != PRIMARY_COMMAND:
|
|
return False
|
|
|
|
if not args:
|
|
print_introspection()
|
|
return True
|
|
|
|
if args[0] in ("--help", "-h", "help"):
|
|
print_help()
|
|
return True
|
|
|
|
project_root = args[0]
|
|
force = "--force" in args
|
|
note = ""
|
|
project_name = ""
|
|
|
|
for i, arg in enumerate(args):
|
|
if arg == "--note" and i + 1 < len(args):
|
|
note = args[i + 1]
|
|
if arg == "--project" and i + 1 < len(args):
|
|
project_name = args[i + 1]
|
|
|
|
run_drive_sync(
|
|
project_root,
|
|
project_name=project_name,
|
|
note=note,
|
|
force=force,
|
|
)
|
|
return True
|
|
|
|
|
|
# =============================================
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print_introspection()
|
|
sys.exit(0)
|
|
handle_command(PRIMARY_COMMAND, sys.argv[1:])
|