From 765248b0ac369812cd2b146e3b59b93a25a857d2 Mon Sep 17 00:00:00 2001 From: Zhang Beihai Date: Mon, 20 Jul 2026 21:25:06 -0700 Subject: [PATCH] storage: standard PUT create-or-replace semantics (sync with JS SDK v1.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port InsForge/InsForge-sdk-js v1.5.0 storage changes: - upload_object: uploading to an existing key now replaces the object in place (standard PUT semantics), matching the backend change in InsForge/InsForge#1760. Signature unchanged; documented on the method. - upload_object_auto: the backend no longer mints/auto-renames keys, so the key is now generated client-side (sanitized base + timestamp + random suffix, extension preserved) and uploaded through the standard upload_object PUT path — repeated uploads of the same file never overwrite each other. The old POST /objects auto-name route is gone. - Bump version to 0.2.0 (runtime behavior change; requires a backend that includes the standard-PUT storage change). Co-Authored-By: Claude Opus 4.8 --- insforge/storage/client.py | 55 +++++++++----- pyproject.toml | 2 +- tests/storage/test_storage_client.py | 104 +++++++++++++++++++++++++-- 3 files changed, 139 insertions(+), 22 deletions(-) diff --git a/insforge/storage/client.py b/insforge/storage/client.py index a41402f..ec423fc 100644 --- a/insforge/storage/client.py +++ b/insforge/storage/client.py @@ -1,5 +1,9 @@ from __future__ import annotations +import random +import re +import string +import time from collections.abc import Mapping from typing import Any from typing import Iterable @@ -25,6 +29,23 @@ from .models import UploadStrategy +def _generate_object_key(filename: str) -> str: + """Generate a unique object key: ``--``. + + Auto-key generation is a client-side convenience — the storage API has no + server-side key minting — so ``upload_object_auto`` produces the key here + and then uploads through the standard ``upload_object`` path. + """ + dot_index = filename.rfind(".") + has_ext = dot_index > 0 + ext = filename[dot_index:] if has_ext else "" + base = filename[:dot_index] if has_ext else filename + sanitized_base = re.sub(r"[^a-zA-Z0-9-_]", "-", base)[:32] or "file" + timestamp = int(time.time() * 1000) + random_suffix = "".join(random.choices(string.digits + string.ascii_lowercase, k=6)) + return f"{sanitized_base}-{timestamp}-{random_suffix}{ext}" + + class StorageClient: def __init__(self, client: BaseClient) -> None: self._client = client @@ -133,6 +154,11 @@ async def upload_object( access_token: str | None = None, extra_headers: Mapping[str, str] | None = None, ) -> StorageObjectResponse: + """Upload a file to a specific key. + + Standard PUT semantics: uploading to an existing key replaces the + current object in place. + """ path = self._object_url_path(bucket_name, object_key) url = self._client._build_url(path) logger.debug(">>> PUT %s file=%s size=%d", url, object_key, len(data)) @@ -227,24 +253,21 @@ async def upload_object_auto( content_type: str | None = None, access_token: str | None = None, ) -> StorageObjectResponse: - path = f"/api/storage/buckets/{quote_path_segment(bucket_name)}/objects" - url = self._client._build_url(path) - logger.debug(">>> POST %s file=%s size=%d", url, filename, len(data)) - - response = await self._client.http_client.request( - "POST", - url, - files={"file": (filename, data, content_type or "application/octet-stream")}, - headers=self._client._build_headers(access_token=access_token), + """Upload a file under an automatically generated, collision-free key. + + The key is derived client-side from the filename (sanitized base + + timestamp + random suffix) and uploaded through the standard + ``upload_object`` path, so repeated uploads of the same file never + overwrite each other. + """ + return await self.upload_object( + bucket_name, + _generate_object_key(filename), + data, + content_type=content_type, + access_token=access_token, ) - logger.debug("<<< POST %s status=%d", url, response.status_code) - - if response.is_error: - raise InsforgeHTTPError.from_response("POST", path, response) - - return StorageObjectResponse.model_validate(response.json()) - async def confirm_upload( self, bucket_name: str, diff --git a/pyproject.toml b/pyproject.toml index 330788f..920cc6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "insforge" -version = "0.1.0" +version = "0.2.0" description = "Python SDK for Insforge" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/storage/test_storage_client.py b/tests/storage/test_storage_client.py index 96f27d5..4bf849d 100644 --- a/tests/storage/test_storage_client.py +++ b/tests/storage/test_storage_client.py @@ -1,4 +1,5 @@ import asyncio +import re from pathlib import Path import tomllib @@ -332,16 +333,16 @@ async def scenario() -> tuple[list[dict[str, object]], object, object, object]: async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: calls.append({"method": method, "url": str(url), "kwargs": kwargs}) - if url.path.endswith("/objects"): + if method == "PUT": return httpx.Response( 201, json={ "bucket": "avatars", - "key": "auto.jpg", + "key": url.path.rsplit("/objects/", 1)[-1], "size": 10, "mimeType": "image/jpeg", "uploadedAt": "2024-01-01T00:00:00Z", - "url": "/api/storage/buckets/avatars/objects/auto.jpg", + "url": str(url.path), }, ) if url.path.endswith("/confirm-upload"): @@ -383,8 +384,11 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R calls, auto, confirm, upload_strategy, download_strategy = asyncio.run(scenario()) - assert calls[0]["method"] == "POST" and calls[0]["url"].endswith("/objects") - assert auto.key == "auto.jpg" + # upload_object_auto mints a unique key client-side and uploads via the + # standard PUT route — the backend no longer generates keys. + assert calls[0]["method"] == "PUT" + assert re.search(r"/objects/auto-\d+-[a-z0-9]{6}\.jpg$", calls[0]["url"]) + assert re.fullmatch(r"auto-\d+-[a-z0-9]{6}\.jpg", auto.key) assert calls[1]["method"] == "POST" and calls[1]["url"].endswith("/confirm-upload") assert calls[1]["kwargs"]["json"] == {"size": 10, "etag": "etag123"} @@ -403,6 +407,96 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R assert download_strategy.method == "direct" +def test_upload_object_auto_mints_key_client_side_and_uses_put() -> None: + async def scenario() -> tuple[object, dict[str, object]]: + captured: dict[str, object] = {} + + async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: + captured["method"] = method + captured["url"] = str(url) + captured["kwargs"] = kwargs + key = url.path.rsplit("/objects/", 1)[-1] + return httpx.Response( + 201, + json={ + "bucket": "docs", + "key": key, + "size": 3, + "mimeType": "application/pdf", + "uploadedAt": "2026-01-01T00:00:00Z", + "url": str(url.path), + }, + ) + + async with InsforgeClient( + base_url="https://example.com", + api_key="ins_test", + ) as client: + client.http_client.request = fake_request # type: ignore[method-assign] + result = await client.storage.upload_object_auto( + "docs", + data=b"pdf", + filename="report.pdf", + content_type="application/pdf", + ) + return result, captured + + result, captured = asyncio.run(scenario()) + + # Client-generated key: sanitized base + timestamp + random, preserving ext. + assert captured["method"] == "PUT" + assert re.fullmatch(r"report-\d+-[a-z0-9]{6}\.pdf", result.key) + assert captured["url"].endswith(f"/api/storage/buckets/docs/objects/{result.key}") + assert captured["kwargs"]["files"]["file"] == (result.key, b"pdf", "application/pdf") + + +def test_upload_object_auto_generates_distinct_keys_for_same_filename() -> None: + async def scenario() -> list[str]: + keys: list[str] = [] + + async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response: + key = url.path.rsplit("/objects/", 1)[-1] + keys.append(key) + return httpx.Response( + 201, + json={ + "bucket": "docs", + "key": key, + "size": 3, + "mimeType": "application/octet-stream", + "uploadedAt": "2026-01-01T00:00:00Z", + "url": str(url.path), + }, + ) + + async with InsforgeClient(base_url="https://example.com", api_key="ins_test") as client: + client.http_client.request = fake_request # type: ignore[method-assign] + await client.storage.upload_object_auto("docs", data=b"abc", filename="photo.png") + await client.storage.upload_object_auto("docs", data=b"abc", filename="photo.png") + return keys + + keys = asyncio.run(scenario()) + + assert len(keys) == 2 + assert keys[0] != keys[1] + + +def test_generate_object_key_sanitizes_base_and_falls_back_to_file() -> None: + from insforge.storage.client import _generate_object_key + + # Non-alphanumeric characters in the base are replaced, extension kept. + assert re.fullmatch(r"my-r-sum--v2-\d+-[a-z0-9]{6}\.pdf", _generate_object_key("my résumé v2.pdf")) + # Base longer than 32 chars is truncated. + key = _generate_object_key("a" * 50 + ".txt") + assert re.fullmatch(r"a{32}-\d+-[a-z0-9]{6}\.txt", key) + # Disallowed characters are each replaced with a dash. + assert re.fullmatch(r"----\d+-[a-z0-9]{6}", _generate_object_key("日本語")) + # An empty base falls back to "file". + assert re.fullmatch(r"file-\d+-[a-z0-9]{6}", _generate_object_key("")) + # A leading dot is not treated as an extension separator. + assert re.fullmatch(r"-gitignore-\d+-[a-z0-9]{6}", _generate_object_key(".gitignore")) + + def test_storage_encoding_for_new_admin_paths() -> None: async def scenario() -> list[dict[str, object]]: calls: list[dict[str, object]] = []