feat: stream uploads to the Gemini Files API with polling and cleanup

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bradautomates 2026-09-17 13:46:37 +10:00
parent 65d8dd1194
commit 2bbb265d5b
2 changed files with 124 additions and 0 deletions

View File

@ -8,7 +8,10 @@ from __future__ import annotations
import json
import math
import mimetypes
import re
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
@ -25,6 +28,7 @@ _HINTS = {
'service': 'Gemini had a server error; retry shortly',
'network': 'could not reach generativelanguage.googleapis.com; check network, proxy, or egress rules',
'response': 'Gemini returned something this version cannot read',
'upload': 'the video could not be uploaded to or processed by the Gemini Files API',
}
DEFAULT_QUESTION = ('Give a thorough summary of this video: what is shown, any on-screen text or code, '
'and what is said, in chronological order.')
@ -108,3 +112,49 @@ def ask(video: dict, question: str | None, *, model: str, key: str, clip=None, t
raise _fail('response', f'no answer text (status {data.get("status")})', key)
return {'text': '\n'.join(parts).strip(), 'model': model, 'processing': label,
'total_tokens': tokens if isinstance(tokens, int) else None}
def upload_file(path: Path, key: str, *, poll_seconds: float = 2.0, max_wait: float = 900.0, sleep=time.sleep) -> dict:
path = Path(path)
size = path.stat().st_size
if size == 0:
raise _fail('upload', f'{path.name} is empty', key)
guessed = mimetypes.guess_type(path.name)[0] or ''
mime = guessed if guessed.startswith('video/') else 'video/mp4'
_, headers, _ = _call('POST', f'{API}/upload/v1beta/files', key,
data=json.dumps({'file': {'display_name': 'watch-upload'}}).encode('utf-8'),
headers={'X-Goog-Upload-Protocol': 'resumable', 'X-Goog-Upload-Command': 'start',
'X-Goog-Upload-Header-Content-Length': str(size),
'X-Goog-Upload-Header-Content-Type': mime, 'Content-Type': 'application/json'})
session = {name.lower(): value for name, value in headers.items()}.get('x-goog-upload-url')
if not session:
raise _fail('upload', 'the Files API did not return an upload session URL', key)
with path.open('rb') as stream: # Streamed in blocks; never held in memory.
_, _, body = _call('POST', session, key, data=stream,
headers={'Content-Length': str(size), 'X-Goog-Upload-Offset': '0',
'X-Goog-Upload-Command': 'upload, finalize'}, timeout=3600.0)
try:
info = json.loads(body)['file']
waited = 0.0
while info.get('state') == 'PROCESSING':
if waited >= max_wait:
delete_file(info['name'], key)
raise _fail('upload', f'still processing after {int(max_wait)}s', key)
sleep(poll_seconds)
waited += poll_seconds
info = json.loads(_call('GET', f"{API}/v1beta/{info['name']}", key)[2])
if info.get('state') != 'ACTIVE':
delete_file(info['name'], key)
raise _fail('upload', f"file state {info.get('state')}", key)
return {'name': info['name'], 'uri': info['uri'], 'mime_type': info.get('mimeType') or mime}
except (ValueError, KeyError, TypeError):
raise _fail('upload', 'unreadable Files API response', key) from None
def delete_file(name: str, key: str) -> str | None:
try:
_call('DELETE', f'{API}/v1beta/{name}', key)
return None
except (SystemExit, Exception) as exc: # Cleanup must never mask the real result or error.
return (f'Could not delete uploaded {name} from Google ({str(exc).split(". Detail:")[0]}); '
'it expires on its own within 48 hours.')

View File

@ -136,3 +136,77 @@ def test_key_echoed_by_the_server_is_redacted(calls):
with pytest.raises(SystemExit) as caught:
gemini.ask({'uri': 'https://youtu.be/abc'}, None, model='m', key=KEY)
assert KEY not in str(caught.value) and '[redacted]' in str(caught.value)
def file_state(state, name='files/abc'):
return FakeResponse(json.dumps({'name': name, 'state': state, 'mimeType': 'video/mp4',
'uri': f'https://generativelanguage.googleapis.com/v1beta/{name}'}).encode())
def test_upload_streams_file_then_polls_until_active(calls, tmp_path):
clip = tmp_path / 'clip.mp4'
clip.write_bytes(b'x' * 1000)
calls.replies = [
FakeResponse(headers={'X-Goog-Upload-URL': 'https://upload.invalid/session?upload_id=1'}),
FakeResponse(json.dumps({'file': {'name': 'files/abc', 'state': 'PROCESSING', 'mimeType': 'video/mp4',
'uri': 'https://generativelanguage.googleapis.com/v1beta/files/abc'}}).encode()),
file_state('PROCESSING'), file_state('ACTIVE'),
]
naps = []
result = gemini.upload_file(clip, KEY, sleep=naps.append)
start, body, poll = calls[0], calls[1], calls[2]
assert start.full_url == 'https://generativelanguage.googleapis.com/upload/v1beta/files'
assert start.get_header('X-goog-upload-command') == 'start'
assert start.get_header('X-goog-upload-header-content-length') == '1000'
assert start.get_header('X-goog-upload-header-content-type') == 'video/mp4'
assert body.full_url == 'https://upload.invalid/session?upload_id=1'
assert body.get_header('X-goog-upload-command') == 'upload, finalize'
assert body.get_header('Content-length') == '1000'
assert hasattr(body.data, 'read'), 'the body must be streamed, not loaded into memory'
assert poll.full_url.endswith('/v1beta/files/abc') and poll.get_method() == 'GET'
assert naps == [2.0, 2.0]
assert result == {'name': 'files/abc', 'mime_type': 'video/mp4',
'uri': 'https://generativelanguage.googleapis.com/v1beta/files/abc'}
def test_upload_failed_state_and_timeout(calls, tmp_path):
clip = tmp_path / 'clip.mkv'
clip.write_bytes(b'x')
first = [FakeResponse(headers={'x-goog-upload-url': 'https://upload.invalid/s'}),
FakeResponse(json.dumps({'file': {'name': 'files/abc', 'state': 'PROCESSING', 'uri': 'u'}}).encode())]
calls.replies = [*first, file_state('FAILED')]
with pytest.raises(SystemExit, match='Gemini upload:.*FAILED'):
gemini.upload_file(clip, KEY, sleep=lambda s: None)
calls.replies = [*first, *[file_state('PROCESSING') for _ in range(3)]]
with pytest.raises(SystemExit, match='Gemini upload:.*still processing'):
gemini.upload_file(clip, KEY, poll_seconds=2.0, max_wait=6.0, sleep=lambda s: None)
def test_upload_without_session_url_or_with_empty_file(calls, tmp_path):
empty = tmp_path / 'empty.mp4'
empty.write_bytes(b'')
with pytest.raises(SystemExit, match='Gemini upload:.*empty'):
gemini.upload_file(empty, KEY)
clip = tmp_path / 'clip.mp4'
clip.write_bytes(b'x')
calls.replies = [FakeResponse(headers={})]
with pytest.raises(SystemExit, match='Gemini upload:.*session'):
gemini.upload_file(clip, KEY)
def test_unknown_extension_falls_back_to_mp4(calls, tmp_path):
clip = tmp_path / 'clip.unknownext'
clip.write_bytes(b'x')
calls.replies = [FakeResponse(headers={'x-goog-upload-url': 'https://upload.invalid/s'}),
FakeResponse(json.dumps({'file': {'name': 'files/a', 'state': 'ACTIVE', 'uri': 'u'}}).encode())]
assert gemini.upload_file(clip, KEY)['mime_type'] == 'video/mp4'
assert calls[0].get_header('X-goog-upload-header-content-type') == 'video/mp4'
def test_delete_is_best_effort(calls):
calls.replies = [FakeResponse(b'{}')]
assert gemini.delete_file('files/abc', KEY) is None
assert calls[0].get_method() == 'DELETE' and calls[0].full_url.endswith('/v1beta/files/abc')
calls.replies = [http_error(500, b'boom')]
warning = gemini.delete_file('files/abc', KEY)
assert 'files/abc' in warning and '48 hours' in warning and KEY not in warning