Skip to content

Commit 765248b

Browse files
Zhang Beihaiclaude
authored andcommitted
storage: standard PUT create-or-replace semantics (sync with JS SDK v1.5.0)
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 <noreply@anthropic.com>
1 parent c823bfa commit 765248b

3 files changed

Lines changed: 139 additions & 22 deletions

File tree

insforge/storage/client.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from __future__ import annotations
22

3+
import random
4+
import re
5+
import string
6+
import time
37
from collections.abc import Mapping
48
from typing import Any
59
from typing import Iterable
@@ -25,6 +29,23 @@
2529
from .models import UploadStrategy
2630

2731

32+
def _generate_object_key(filename: str) -> str:
33+
"""Generate a unique object key: ``<sanitized-base>-<timestamp>-<random><ext>``.
34+
35+
Auto-key generation is a client-side convenience — the storage API has no
36+
server-side key minting — so ``upload_object_auto`` produces the key here
37+
and then uploads through the standard ``upload_object`` path.
38+
"""
39+
dot_index = filename.rfind(".")
40+
has_ext = dot_index > 0
41+
ext = filename[dot_index:] if has_ext else ""
42+
base = filename[:dot_index] if has_ext else filename
43+
sanitized_base = re.sub(r"[^a-zA-Z0-9-_]", "-", base)[:32] or "file"
44+
timestamp = int(time.time() * 1000)
45+
random_suffix = "".join(random.choices(string.digits + string.ascii_lowercase, k=6))
46+
return f"{sanitized_base}-{timestamp}-{random_suffix}{ext}"
47+
48+
2849
class StorageClient:
2950
def __init__(self, client: BaseClient) -> None:
3051
self._client = client
@@ -133,6 +154,11 @@ async def upload_object(
133154
access_token: str | None = None,
134155
extra_headers: Mapping[str, str] | None = None,
135156
) -> StorageObjectResponse:
157+
"""Upload a file to a specific key.
158+
159+
Standard PUT semantics: uploading to an existing key replaces the
160+
current object in place.
161+
"""
136162
path = self._object_url_path(bucket_name, object_key)
137163
url = self._client._build_url(path)
138164
logger.debug(">>> PUT %s file=%s size=%d", url, object_key, len(data))
@@ -227,24 +253,21 @@ async def upload_object_auto(
227253
content_type: str | None = None,
228254
access_token: str | None = None,
229255
) -> StorageObjectResponse:
230-
path = f"/api/storage/buckets/{quote_path_segment(bucket_name)}/objects"
231-
url = self._client._build_url(path)
232-
logger.debug(">>> POST %s file=%s size=%d", url, filename, len(data))
233-
234-
response = await self._client.http_client.request(
235-
"POST",
236-
url,
237-
files={"file": (filename, data, content_type or "application/octet-stream")},
238-
headers=self._client._build_headers(access_token=access_token),
256+
"""Upload a file under an automatically generated, collision-free key.
257+
258+
The key is derived client-side from the filename (sanitized base +
259+
timestamp + random suffix) and uploaded through the standard
260+
``upload_object`` path, so repeated uploads of the same file never
261+
overwrite each other.
262+
"""
263+
return await self.upload_object(
264+
bucket_name,
265+
_generate_object_key(filename),
266+
data,
267+
content_type=content_type,
268+
access_token=access_token,
239269
)
240270

241-
logger.debug("<<< POST %s status=%d", url, response.status_code)
242-
243-
if response.is_error:
244-
raise InsforgeHTTPError.from_response("POST", path, response)
245-
246-
return StorageObjectResponse.model_validate(response.json())
247-
248271
async def confirm_upload(
249272
self,
250273
bucket_name: str,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "insforge"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Python SDK for Insforge"
99
readme = "README.md"
1010
requires-python = ">=3.11"

tests/storage/test_storage_client.py

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import re
23
from pathlib import Path
34
import tomllib
45

@@ -332,16 +333,16 @@ async def scenario() -> tuple[list[dict[str, object]], object, object, object]:
332333

333334
async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response:
334335
calls.append({"method": method, "url": str(url), "kwargs": kwargs})
335-
if url.path.endswith("/objects"):
336+
if method == "PUT":
336337
return httpx.Response(
337338
201,
338339
json={
339340
"bucket": "avatars",
340-
"key": "auto.jpg",
341+
"key": url.path.rsplit("/objects/", 1)[-1],
341342
"size": 10,
342343
"mimeType": "image/jpeg",
343344
"uploadedAt": "2024-01-01T00:00:00Z",
344-
"url": "/api/storage/buckets/avatars/objects/auto.jpg",
345+
"url": str(url.path),
345346
},
346347
)
347348
if url.path.endswith("/confirm-upload"):
@@ -383,8 +384,11 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R
383384

384385
calls, auto, confirm, upload_strategy, download_strategy = asyncio.run(scenario())
385386

386-
assert calls[0]["method"] == "POST" and calls[0]["url"].endswith("/objects")
387-
assert auto.key == "auto.jpg"
387+
# upload_object_auto mints a unique key client-side and uploads via the
388+
# standard PUT route — the backend no longer generates keys.
389+
assert calls[0]["method"] == "PUT"
390+
assert re.search(r"/objects/auto-\d+-[a-z0-9]{6}\.jpg$", calls[0]["url"])
391+
assert re.fullmatch(r"auto-\d+-[a-z0-9]{6}\.jpg", auto.key)
388392

389393
assert calls[1]["method"] == "POST" and calls[1]["url"].endswith("/confirm-upload")
390394
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
403407
assert download_strategy.method == "direct"
404408

405409

410+
def test_upload_object_auto_mints_key_client_side_and_uses_put() -> None:
411+
async def scenario() -> tuple[object, dict[str, object]]:
412+
captured: dict[str, object] = {}
413+
414+
async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response:
415+
captured["method"] = method
416+
captured["url"] = str(url)
417+
captured["kwargs"] = kwargs
418+
key = url.path.rsplit("/objects/", 1)[-1]
419+
return httpx.Response(
420+
201,
421+
json={
422+
"bucket": "docs",
423+
"key": key,
424+
"size": 3,
425+
"mimeType": "application/pdf",
426+
"uploadedAt": "2026-01-01T00:00:00Z",
427+
"url": str(url.path),
428+
},
429+
)
430+
431+
async with InsforgeClient(
432+
base_url="https://example.com",
433+
api_key="ins_test",
434+
) as client:
435+
client.http_client.request = fake_request # type: ignore[method-assign]
436+
result = await client.storage.upload_object_auto(
437+
"docs",
438+
data=b"pdf",
439+
filename="report.pdf",
440+
content_type="application/pdf",
441+
)
442+
return result, captured
443+
444+
result, captured = asyncio.run(scenario())
445+
446+
# Client-generated key: sanitized base + timestamp + random, preserving ext.
447+
assert captured["method"] == "PUT"
448+
assert re.fullmatch(r"report-\d+-[a-z0-9]{6}\.pdf", result.key)
449+
assert captured["url"].endswith(f"/api/storage/buckets/docs/objects/{result.key}")
450+
assert captured["kwargs"]["files"]["file"] == (result.key, b"pdf", "application/pdf")
451+
452+
453+
def test_upload_object_auto_generates_distinct_keys_for_same_filename() -> None:
454+
async def scenario() -> list[str]:
455+
keys: list[str] = []
456+
457+
async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.Response:
458+
key = url.path.rsplit("/objects/", 1)[-1]
459+
keys.append(key)
460+
return httpx.Response(
461+
201,
462+
json={
463+
"bucket": "docs",
464+
"key": key,
465+
"size": 3,
466+
"mimeType": "application/octet-stream",
467+
"uploadedAt": "2026-01-01T00:00:00Z",
468+
"url": str(url.path),
469+
},
470+
)
471+
472+
async with InsforgeClient(base_url="https://example.com", api_key="ins_test") as client:
473+
client.http_client.request = fake_request # type: ignore[method-assign]
474+
await client.storage.upload_object_auto("docs", data=b"abc", filename="photo.png")
475+
await client.storage.upload_object_auto("docs", data=b"abc", filename="photo.png")
476+
return keys
477+
478+
keys = asyncio.run(scenario())
479+
480+
assert len(keys) == 2
481+
assert keys[0] != keys[1]
482+
483+
484+
def test_generate_object_key_sanitizes_base_and_falls_back_to_file() -> None:
485+
from insforge.storage.client import _generate_object_key
486+
487+
# Non-alphanumeric characters in the base are replaced, extension kept.
488+
assert re.fullmatch(r"my-r-sum--v2-\d+-[a-z0-9]{6}\.pdf", _generate_object_key("my résumé v2.pdf"))
489+
# Base longer than 32 chars is truncated.
490+
key = _generate_object_key("a" * 50 + ".txt")
491+
assert re.fullmatch(r"a{32}-\d+-[a-z0-9]{6}\.txt", key)
492+
# Disallowed characters are each replaced with a dash.
493+
assert re.fullmatch(r"----\d+-[a-z0-9]{6}", _generate_object_key("日本語"))
494+
# An empty base falls back to "file".
495+
assert re.fullmatch(r"file-\d+-[a-z0-9]{6}", _generate_object_key(""))
496+
# A leading dot is not treated as an extension separator.
497+
assert re.fullmatch(r"-gitignore-\d+-[a-z0-9]{6}", _generate_object_key(".gitignore"))
498+
499+
406500
def test_storage_encoding_for_new_admin_paths() -> None:
407501
async def scenario() -> list[dict[str, object]]:
408502
calls: list[dict[str, object]] = []

0 commit comments

Comments
 (0)