#!/usr/bin/env python3 """Upload one or more files through the public v1 API and create a single project. All given files go into one upload session; then one project is created from it. A single file is just the N==1 case. Interrupted uploads resume automatically (per file), and an interrupted run can be continued by re-invoking with the same API_UPLOAD_ID. API_KEY=sk_... python upload_ifc_via_api.py [file1 file2 ...] Env: API_KEY (required) Bearer key from /profile#api-keys API_BASE default https://360-for-you.com/api/v1 API_UPLOAD_ID reuse an existing session id to resume a previous run CHUNK_BYTES chunk size, default 8 MiB PROJECT_TITLE project title (auto-generated if unset) MAX_RETRIES network/5xx retries per request, default 6 """ import os import sys import time import requests BASE = os.environ.get('API_BASE', 'https://360-for-you.com/api/v1').rstrip('/') KEY = os.environ.get('API_KEY') CHUNK = int(os.environ.get('CHUNK_BYTES', 8 * 1024 * 1024)) MAX_RETRIES = int(os.environ.get('MAX_RETRIES', 6)) HOST = BASE.rsplit('/api/', 1)[0] OCTET = 'application/octet-stream' FILES = sys.argv[1:] or [os.environ.get('UPLOAD_FILE', 'WU-09a-Treppe.ifc')] if not KEY: sys.exit('error: set the API_KEY environment variable') missing = [f for f in FILES if not os.path.isfile(f)] if missing: sys.exit(f'error: file(s) not found: {missing}') # One session keys files by name, so two inputs with the same basename would clobber each other. _names = [os.path.basename(f) for f in FILES] _dups = sorted({n for n in _names if _names.count(n) > 1}) if _dups: sys.exit(f'error: duplicate filenames would collide in one session: {_dups}') S = requests.Session() S.headers['Authorization'] = f'Bearer {KEY}' def req(method, url, *, ok=(200,), retry_status=(), **kw): """HTTP with exponential backoff on network errors and 5xx (uploads are idempotent, so retrying a chunk is safe). Returns the Response when its status is in `ok` or `retry_status` (the caller handles the latter); any other status aborts with a message.""" kw.setdefault('timeout', 300) delay = 1.0 r = None for attempt in range(MAX_RETRIES + 1): try: r = S.request(method, url, **kw) except requests.exceptions.RequestException as e: if attempt == MAX_RETRIES: sys.exit(f'error: {method} {url} failed after {MAX_RETRIES} retries: {e}') print(f' ! network error ({e.__class__.__name__}); retry in {delay:.0f}s') time.sleep(delay); delay = min(delay * 2, 30); continue if r.status_code >= 500 and attempt < MAX_RETRIES: print(f' ! server {r.status_code}; retry in {delay:.0f}s') time.sleep(delay); delay = min(delay * 2, 30); continue if r.status_code in ok or r.status_code in retry_status: return r print(f'! {method} {url} -> {r.status_code} {r.text[:300]}') sys.exit(f'error: {method} {url} returned {r.status_code}') return r def file_received(upload_id, name): """Bytes the server already holds for this file (0 if none) — drives resume.""" r = req('GET', f'{BASE}/uploads/{upload_id}/{name}', ok=(200, 404)) return r.json().get('received', 0) if r.status_code == 200 else 0 def upload_one(upload_id, path): size = os.path.getsize(path) name = os.path.basename(path) received = file_received(upload_id, name) # resume where a previous run/attempt stopped if received: print(f' {name}: resuming from {received:,}/{size:,}') if size == 0: # empty file: one request, whole (empty) body, no Content-Range req('PUT', f'{BASE}/uploads/{upload_id}/{name}', headers={'Content-Type': OCTET}, data=b'') print(f' {name}: 0 bytes -> complete') return with open(path, 'rb') as f: while received < size: f.seek(received) chunk = f.read(CHUNK) end = received + len(chunk) - 1 r = req('PUT', f'{BASE}/uploads/{upload_id}/{name}', retry_status=(409,), headers={'Content-Range': f'bytes {received}-{end}/{size}', 'Content-Type': OCTET}, data=chunk) if r.status_code == 409: # offset desync -> resync to the server's view and retry received = r.json()['received'] print(f' {name}: resync to {received:,}') continue body = r.json() received = body['received'] # advance to the server high-water mark print(f' {name}: {received:,}/{size:,} complete={body["complete"]}') if body['complete']: break def default_title(files): stems = [os.path.splitext(os.path.basename(f))[0] for f in files] return f'{stems[0]} (API test)' if len(stems) == 1 else f'API test ({len(stems)} files)' def main(): total = sum(os.path.getsize(f) for f in FILES) print(f'> {len(FILES)} file(s), {total:,} bytes total base: {BASE} chunk: {CHUNK:,}') # 1. create (or resume) one upload session for all files upload_id = os.environ.get('API_UPLOAD_ID') if upload_id: print(f'> resuming session {upload_id}') else: upload_id = req('POST', f'{BASE}/uploads', ok=(201,)).json()['upload_id'] print(f'> session {upload_id} (re-run with API_UPLOAD_ID={upload_id} to resume)') # 2. upload each file (sequentially; each resumes from what the server already has) for path in FILES: upload_one(upload_id, path) # 3. verify every file finished assembling listing = req('GET', f'{BASE}/uploads/{upload_id}').json()['files'] print('> session contents:') for fi in listing: print(f' {fi["name"]}: {fi["received"]:,}/{fi["total"]:,} complete={fi["complete"]}') if len(listing) != len(FILES) or not all(fi['complete'] for fi in listing): sys.exit(f'error: expected {len(FILES)} complete files, session has {[fi["name"] for fi in listing]}') # 4. create one project from the session title = os.environ.get('PROJECT_TITLE') or default_title(FILES) slug = req('POST', f'{BASE}/projects', ok=(201,), json={'title': title, 'upload_id': upload_id}).json()['name'] print(f'> project {slug} title={title!r}') # 5. poll until processing finishes deadline = time.time() + 600 while time.time() < deadline: st = req('GET', f'{BASE}/projects/{slug}').json().get('status') print(f'> status={st}') if st == 100: print(f'\nREADY: {HOST}/projects/{slug}') return time.sleep(5) print(f'\nstill processing — check later: {HOST}/projects/{slug}') if __name__ == '__main__': main()