CoolFace
Apppublic

breakpointsoftware/document-parser

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
google_drive_service.py407 linesDownload Raw Back to root
1from __future__ import annotations2 3import json4import logging5import os6import re7import tempfile8from dataclasses import dataclass9from pathlib import Path10from typing import Any11 12from dotenv import load_dotenv13from google.oauth2.service_account import Credentials14 15from document_processing import SUPPORTED_EXTENSIONS16 17 18load_dotenv()19 20 21logger = logging.getLogger(__name__)22if not logger.handlers:23    handler = logging.StreamHandler()24    handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))25    logger.addHandler(handler)26logger.setLevel(logging.INFO)27logger.propagate = False28 29 30DRIVE_READONLY_SCOPE = "https://www.googleapis.com/auth/drive.readonly"31DRIVE_FULL_SCOPE = "https://www.googleapis.com/auth/drive"32FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"33 34# Google Workspace files require export, while binary files can be downloaded directly.35SUPPORTED_EXPORT_MIME_MAP = {36    "application/vnd.google-apps.document": (37        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",38        ".docx",39    ),40}41 42SUPPORTED_BINARY_MIME_TO_SUFFIX = {43    "application/pdf": ".pdf",44    "text/plain": ".txt",45    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",46    "image/jpeg": ".jpg",47    "image/png": ".png",48}49 50 51@dataclass52class DriveDocument:53    document_id: str54    source_file: str55    mime_type: str56    modificationDate: str | None57    local_path: str58 59 60@dataclass61class DriveDownloadResult:62    documents: list[DriveDocument]63    discovered_count: int64    temp_dir: str65 66 67class GoogleDriveConfigError(RuntimeError):68    """Raised when required Google Drive environment configuration is missing or invalid."""69 70 71def load_drive_folder_ids_from_env(env_var: str = "GOOGLE_DRIVE_FOLDER_IDS") -> list[str]:72    raw = (os.getenv(env_var) or "").strip()73    if not raw:74        raise GoogleDriveConfigError(f"Missing {env_var}. Set one or more folder IDs separated by commas.")75 76    folder_ids = [item.strip() for item in raw.split(",") if item.strip()]77    if not folder_ids:78        raise GoogleDriveConfigError(f"{env_var} is empty after parsing.")79 80    logger.info("Loaded %s folder id(s) from env var %s", len(folder_ids), env_var)81 82    return folder_ids83 84 85def build_drive_credentials(scope: str = DRIVE_READONLY_SCOPE, service_account_json: str | None = None) -> Credentials:86    """Build Google Drive credentials.87    88    Args:89        scope: OAuth scope for the credentials90        service_account_json: Optional JSON string with service account credentials.91                            If not provided, uses GOOGLE_SERVICE_ACCOUNT_JSON env var.92    """93    json_to_use = (service_account_json or "").strip() or (os.getenv("GOOGLE_SERVICE_ACCOUNT_JSON") or "").strip()94    service_account_file = (os.getenv("GOOGLE_SERVICE_ACCOUNT_FILE") or "").strip()95 96    if json_to_use:97        try:98            info: dict[str, Any] = json.loads(json_to_use)99        except json.JSONDecodeError as exc:100            raise GoogleDriveConfigError("GOOGLE_SERVICE_ACCOUNT_JSON is not valid JSON.") from exc101        logger.info("Using GOOGLE_SERVICE_ACCOUNT_JSON for Drive credentials")102        return Credentials.from_service_account_info(info, scopes=[scope])103 104    if service_account_file:105        logger.info("Using GOOGLE_SERVICE_ACCOUNT_FILE for Drive credentials")106        return Credentials.from_service_account_file(service_account_file, scopes=[scope])107 108    raise GoogleDriveConfigError(109        "Missing Google credentials. Set GOOGLE_SERVICE_ACCOUNT_JSON or GOOGLE_SERVICE_ACCOUNT_FILE."110    )111 112 113def build_drive_service(scope: str = DRIVE_READONLY_SCOPE, service_account_json: str | None = None):114    """Build Google Drive service.115    116    Args:117        scope: OAuth scope for the credentials118        service_account_json: Optional JSON string with service account credentials for tenant.119    """120    try:121        from googleapiclient.discovery import build122    except ImportError as exc:123        raise GoogleDriveConfigError(124            "google-api-python-client is required for Drive scanning. Add it to requirements and install dependencies."125        ) from exc126 127    credentials = build_drive_credentials(scope=scope, service_account_json=service_account_json)128    logger.info("Building Drive service with scope=%s", scope)129    return build("drive", "v3", credentials=credentials, cache_discovery=False)130 131 132def _escape_drive_query_value(value: str) -> str:133    return value.replace("'", "\\'")134 135 136def _sanitize_file_name(name: str) -> str:137    cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1F]', "_", name).strip().rstrip(".")138    return cleaned or "document"139 140 141def _ensure_unique_destination(path: Path) -> Path:142    if not path.exists():143        return path144 145    counter = 1146    while True:147        candidate = path.with_name(f"{path.stem}_{counter}{path.suffix}")148        if not candidate.exists():149            return candidate150        counter += 1151 152 153def _list_files_from_folder(service, folder_id: str, include_subfolders: bool) -> list[dict[str, Any]]:154    logger.info("Listing Drive folder id=%s include_subfolders=%s", folder_id, include_subfolders)155    folders_to_scan = [folder_id]156    discovered: list[dict[str, Any]] = []157 158    while folders_to_scan:159        current_folder = folders_to_scan.pop(0)160        page_token = None161 162        while True:163            response = (164                service.files()165                .list(166                    q=f"'{current_folder}' in parents and trashed = false",167                    fields="nextPageToken, files(id, name, mimeType, modifiedTime)",168                    supportsAllDrives=True,169                    includeItemsFromAllDrives=True,170                    pageSize=1000,171                    pageToken=page_token,172                )173                .execute()174            )175 176            for file_meta in response.get("files", []):177                mime_type = file_meta.get("mimeType")178                if mime_type == FOLDER_MIME_TYPE:179                    if include_subfolders:180                        child_folder_id = file_meta.get("id")181                        if child_folder_id:182                            folders_to_scan.append(child_folder_id)183                    continue184 185                discovered.append(file_meta)186 187            page_token = response.get("nextPageToken")188            if not page_token:189                break190 191    return discovered192 193 194def _is_supported_drive_file(file_meta: dict[str, Any]) -> bool:195    mime_type = (file_meta.get("mimeType") or "").strip().lower()196    if mime_type in SUPPORTED_BINARY_MIME_TO_SUFFIX:197        return True198    if mime_type in SUPPORTED_EXPORT_MIME_MAP:199        return True200 201    name = (file_meta.get("name") or "").strip()202    suffix = Path(name).suffix.lower()203    return suffix in SUPPORTED_EXTENSIONS204 205 206def _download_drive_file(service, file_meta: dict[str, Any], destination_dir: Path) -> str:207    try:208        from googleapiclient.http import MediaIoBaseDownload209    except ImportError as exc:210        raise GoogleDriveConfigError(211            "google-api-python-client is required for Drive scanning. Add it to requirements and install dependencies."212        ) from exc213 214    file_id = (file_meta.get("id") or "").strip()215    file_name = _sanitize_file_name((file_meta.get("name") or "document").strip())216    mime_type = (file_meta.get("mimeType") or "").strip().lower()217 218    suffix = Path(file_name).suffix.lower()219    request = None220 221    if mime_type in SUPPORTED_EXPORT_MIME_MAP:222        export_mime, export_suffix = SUPPORTED_EXPORT_MIME_MAP[mime_type]223        if suffix != export_suffix:224            file_name = f"{Path(file_name).stem}{export_suffix}"225            suffix = export_suffix226        request = service.files().export_media(fileId=file_id, mimeType=export_mime)227    else:228        if not suffix and mime_type in SUPPORTED_BINARY_MIME_TO_SUFFIX:229            file_name = f"{file_name}{SUPPORTED_BINARY_MIME_TO_SUFFIX[mime_type]}"230            suffix = Path(file_name).suffix.lower()231        request = service.files().get_media(fileId=file_id)232 233    if suffix not in SUPPORTED_EXTENSIONS:234        raise ValueError(f"Unsupported downloaded file type: {suffix or '[no extension]'}")235 236    destination = _ensure_unique_destination(destination_dir / file_name)237 238    with destination.open("wb") as stream:239        downloader = MediaIoBaseDownload(stream, request)240        done = False241        while not done:242            _, done = downloader.next_chunk()243 244    return str(destination)245 246 247def scan_drive_supported_documents(folder_ids: list[str], include_subfolders: bool = True) -> DriveDownloadResult:248    service = build_drive_service()249    logger.info("Starting Drive scan for %s folder(s)", len(folder_ids))250 251    discovered_files: list[dict[str, Any]] = []252    for folder_id in folder_ids:253        discovered_files.extend(_list_files_from_folder(service, folder_id, include_subfolders))254 255    supported_files = [file_meta for file_meta in discovered_files if _is_supported_drive_file(file_meta)]256    logger.info("Drive scan discovered=%s supported=%s", len(discovered_files), len(supported_files))257    temp_dir = tempfile.mkdtemp(prefix="drive_docs_")258    destination_dir = Path(temp_dir)259 260    documents: list[DriveDocument] = []261    for file_meta in supported_files:262        try:263            local_path = _download_drive_file(service, file_meta, destination_dir)264        except Exception:265            # Keep scan resilient if one file is malformed or inaccessible.266            logger.exception("Skipping file during Drive download due to error file_id=%s", file_meta.get("id"))267            continue268 269        documents.append(270            DriveDocument(271                document_id=str(file_meta.get("id") or "").strip(),272                source_file=str(file_meta.get("name") or "").strip(),273                mime_type=str(file_meta.get("mimeType") or "").strip(),274                modificationDate=str(file_meta.get("modifiedTime") or "").strip() or None,275                local_path=local_path,276            )277        )278 279    return DriveDownloadResult(280        documents=documents,281        discovered_count=len(supported_files),282        temp_dir=temp_dir,283    )284 285 286def _find_folder_in_parent(service, folder_name: str, parent_folder_id: str | None) -> str | None:287    query_parts = [288        "trashed = false",289        f"mimeType = '{FOLDER_MIME_TYPE}'",290        f"name = '{_escape_drive_query_value(folder_name)}'",291    ]292    if parent_folder_id:293        query_parts.append(f"'{parent_folder_id}' in parents")294 295    try:296        response = (297            service.files()298            .list(299                q=" and ".join(query_parts),300                fields="files(id,name)",301                supportsAllDrives=True,302                includeItemsFromAllDrives=True,303                pageSize=10,304            )305            .execute()306        )307    except Exception as exc:308        raise GoogleDriveConfigError(f"Failed to query folder '{folder_name}': {exc}") from exc309 310    files = response.get("files", [])311    if not files:312        logger.info("Drive folder not found name=%s parent=%s", folder_name, parent_folder_id or "root")313        return None314 315    logger.info("Found Drive folder name=%s parent=%s", folder_name, parent_folder_id or "root")316    return str(files[0].get("id") or "").strip() or None317 318 319def _create_folder_in_parent(service, folder_name: str, parent_folder_id: str | None) -> str:320    body: dict[str, Any] = {"name": folder_name, "mimeType": FOLDER_MIME_TYPE}321    if parent_folder_id:322        body["parents"] = [parent_folder_id]323 324    try:325        created = (326            service.files()327            .create(body=body, fields="id", supportsAllDrives=True)328            .execute()329        )330    except Exception as exc:331        raise GoogleDriveConfigError(332            f"Failed to create folder '{folder_name}' under parent '{parent_folder_id or 'root'}': {exc}"333        ) from exc334 335    folder_id = str(created.get("id") or "").strip()336    if not folder_id:337        raise GoogleDriveConfigError(f"Folder '{folder_name}' was created but no id was returned.")338 339    logger.info("Created Drive folder name=%s parent=%s id=%s", folder_name, parent_folder_id or "root", folder_id)340 341    return folder_id342 343 344def ensure_drive_folder_path(service, folder_path: str, root_folder_id: str | None = None) -> str:345    normalized = (folder_path or "").strip().strip("/")346    if not normalized:347        raise GoogleDriveConfigError("folder_path is required to resolve a Drive destination.")348 349    current_parent = (root_folder_id or "").strip() or None350    for part in [segment.strip() for segment in normalized.split("/") if segment.strip()]:351        folder_id = _find_folder_in_parent(service, part, current_parent)352        if folder_id is None:353            folder_id = _create_folder_in_parent(service, part, current_parent)354        current_parent = folder_id355 356    if not current_parent:357        raise GoogleDriveConfigError(f"Could not resolve Drive path '{folder_path}'.")358 359    logger.info("Resolved Drive path=%s to folder_id=%s", folder_path, current_parent)360 361    return current_parent362 363 364def move_file_to_path(service, file_id: str, destination_path: str, root_folder_id: str | None = None) -> str:365    normalized_file_id = (file_id or "").strip()366    if not normalized_file_id:367        raise GoogleDriveConfigError("file_id is required to move a Drive file.")368 369    destination_folder_id = ensure_drive_folder_path(service, destination_path, root_folder_id=root_folder_id)370    logger.info("Moving Drive file_id=%s to destination_path=%s", normalized_file_id, destination_path)371 372    try:373        metadata = (374            service.files()375            .get(fileId=normalized_file_id, fields="parents", supportsAllDrives=True)376            .execute()377        )378    except Exception as exc:379        raise GoogleDriveConfigError(f"Failed to read current parents for file '{normalized_file_id}': {exc}") from exc380 381    current_parents = [str(parent).strip() for parent in metadata.get("parents", []) if str(parent).strip()]382 383    if destination_folder_id in current_parents and len(current_parents) == 1:384        logger.info("Drive file already in destination file_id=%s folder_id=%s", normalized_file_id, destination_folder_id)385        return destination_folder_id386 387    request_kwargs: dict[str, Any] = {388        "fileId": normalized_file_id,389        "addParents": destination_folder_id,390        "supportsAllDrives": True,391        "fields": "id,parents",392    }393    remove_parents = ",".join(parent for parent in current_parents if parent != destination_folder_id)394    if remove_parents:395        request_kwargs["removeParents"] = remove_parents396 397    try:398        service.files().update(**request_kwargs).execute()399    except Exception as exc:400        raise GoogleDriveConfigError(401            f"Failed to move file '{normalized_file_id}' to Drive path '{destination_path}': {exc}"402        ) from exc403 404    logger.info("Moved Drive file_id=%s to folder_id=%s", normalized_file_id, destination_folder_id)405 406    return destination_folder_id407