$ cat node-template.py
D
Drive File Reader
// Reads one Drive file exactly as it was uploaded (a PDF, a PowerPoint or Word file, an image, an archive or any other type) and outputs it as a File, for nodes that take the file itself rather than its text: e.g. Snapshot, to turn a PDF into page images. Also outputs its MIME type and size. For the text of a document or a transcript, use Drive Content Reader instead.
Input
Storage
#drive#storage#file#binary#pdf#original
template.py
1"""drive-file-reader — read one Drive file as it was uploaded, output it as a File.23The generic sibling of drive-image-reader / drive-3d-model-loader: no type4check, so any file (a PDF, .pptx, .docx, image, archive, ...) reaches a File5port byte for byte. Drive Content Reader is the one for a file's processed6text. Privileged Drive IO is delegated to the gais.drive SDK (USER_TOKEN +7Drive API live there); this node imports no HTTP client and uses no os/shutil8(both are lint-blocked). The downloaded file is stream-copied into9/data/output with plain open() so the executor uploads it as the File output.10"""1112from __future__ import annotations1314import json15import re16import sys17import traceback18from typing import Any1920from gais import Gais2122OUTPUT_DIR = "/data/output"23DEFAULT_NAME = "drive-file"24# The next node stages an input as "<input>_<i>_<j>_<name>" and a file name25# stops at 255 bytes, so the output name stays well under that.26MAX_NAME_BYTES = 20027MAX_EXTENSION_BYTES = 162829_UUID_RE = re.compile(30 r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",31 re.IGNORECASE,32)33# The Drive mimeType is whatever the uploader's client sent, and it reaches the34# agent's context through getNodeOutput: only a plain type/subtype is passed on.35_MIME_TYPE_RE = re.compile(r"[A-Za-z0-9.+_-]{1,64}/[A-Za-z0-9.+_-]{1,64}")36# What the workspace refuses in an output file name: a control character, or a37# %XX escape (the upload check decodes it first, so "%2F" is a slash). A38# leading "." is refused at the next edge. Either way the node would report39# success and the downstream node would get no file.40_UNSAFE_NAME_CHARS = re.compile(r"[\x00-\x1f\x7f]|%(?=[0-9A-Fa-f]{2})")414243def _normalize_drive_item_id(value: Any) -> str:44 """Accept a bare UUID or a mention://drive_{file,folder}/<uuid> URI."""45 if not isinstance(value, str):46 raise ValueError(f"Drive item id must be a string, got {type(value).__name__}")47 s = value.strip()48 for prefix in ("mention://drive_file/", "mention://drive_folder/"):49 if s.startswith(prefix):50 s = s[len(prefix):]51 break52 if not _UUID_RE.match(s):53 raise ValueError(f"Drive item id is not a valid UUID: {value!r}")54 return s555657def _cap_name_bytes(name: str) -> str:58 """Cut the stem so the UTF-8 name fits MAX_NAME_BYTES, keeping a short59 extension and never splitting a character."""60 if len(name.encode("utf-8")) <= MAX_NAME_BYTES:61 return name62 stem, dot, ext = name.rpartition(".")63 if not dot or not stem or len(ext.encode("utf-8")) > MAX_EXTENSION_BYTES:64 stem, ext = name, ""65 suffix = f".{ext}" if ext else ""66 budget = MAX_NAME_BYTES - len(suffix.encode("utf-8"))67 return stem.encode("utf-8")[:budget].decode("utf-8", "ignore") + suffix686970def _safe_output_name(name: Any, default: str) -> str:71 """Reduce a Drive-supplied name to a bare filename the workspace accepts:72 no directory part (it cannot escape OUTPUT_DIR), no control character or73 %XX escape (refused at upload), no leading dot (refused at the next edge),74 at most MAX_NAME_BYTES (the next edge prefixes it when staging)."""75 base = str(name or "").replace("\\", "/").rsplit("/", 1)[-1].strip()76 return _cap_name_bytes(_UNSAFE_NAME_CHARS.sub("_", base).lstrip(". ")) or default777879def _copy_to_output(src_path: str, name: str) -> int:80 """Stream-copy the downloaded file into /data/output in 1 MiB chunks; return bytes copied."""81 total = 082 with open(src_path, "rb") as src, open(f"{OUTPUT_DIR}/{name}", "wb") as dst:83 while True:84 chunk = src.read(1024 * 1024)85 if not chunk:86 break87 dst.write(chunk)88 total += len(chunk)89 return total909192def process(inputs: dict[str, Any]) -> dict[str, Any]:93 """Validate the input, download via gais.drive, stage the File output."""94 raw = inputs.get("driveItemId")95 if not raw:96 raise ValueError("Required input 'driveItemId' not provided")9798 if isinstance(raw, list):99 if len(raw) == 1:100 raw = raw[0]101 else:102 raise ValueError(103 f"Drive File Reader accepts a single file, got {len(raw)} items. "104 "Use one Drive File Reader per file."105 )106 elif not isinstance(raw, str):107 raise ValueError(f"driveItemId must be a string, got {type(raw).__name__}")108109 item_id = _normalize_drive_item_id(raw)110111 result = Gais.drive.download(item_id=item_id)112 file_name = _safe_output_name(result.metadata.get("name"), DEFAULT_NAME)113 raw_mime = result.metadata.get("mimeType", "") or ""114 mime_type = raw_mime if isinstance(raw_mime, str) and _MIME_TYPE_RE.fullmatch(raw_mime) else ""115116 size_bytes = _copy_to_output(str(result.file("file")), file_name)117 return {"file": file_name, "mime_type": mime_type, "size_bytes": size_bytes}118119120def main() -> None:121 try:122 envelope = json.loads(sys.stdin.read() or "{}")123 if not isinstance(envelope, dict):124 envelope = {}125 inputs = envelope.get("inputs", {}) or {}126 json.dump(process(inputs), sys.stdout)127 except Exception as e:128 print(129 json.dumps({130 "error": str(e),131 "errorType": type(e).__name__,132 "traceback": traceback.format_exc(),133 }),134 file=sys.stderr,135 )136 sys.exit(1)137138139if __name__ == "__main__":140 main()141$ git log --oneline
v1.0.0
HEAD
2026-11-04First release: reads one Drive file as uploaded (PDF, Office, image, archive) and passes it on as a file.