Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 39 additions & 16 deletions insforge/storage/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,6 +29,23 @@
from .models import UploadStrategy


def _generate_object_key(filename: str) -> str:
"""Generate a unique object key: ``<sanitized-base>-<timestamp>-<random><ext>``.

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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
104 changes: 99 additions & 5 deletions tests/storage/test_storage_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import re
from pathlib import Path
import tomllib

Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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"}
Expand All @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This assertion will raise a KeyError because upload_object sends data as content=data, not as multipart files=.... The upload_object_auto method now delegates to upload_object, which makes a PUT request with content=data (raw bytes) and a Content-Type header — not with the files= parameter that multipart uploads use. The captured["kwargs"] dict will contain "content" and "headers" keys, not "files".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/storage/test_storage_client.py, line 450:

<comment>This assertion will raise a `KeyError` because `upload_object` sends data as `content=data`, not as multipart `files=...`. The `upload_object_auto` method now delegates to `upload_object`, which makes a `PUT` request with `content=data` (raw bytes) and a `Content-Type` header — not with the `files=` parameter that multipart uploads use. The `captured["kwargs"]` dict will contain `"content"` and `"headers"` keys, not `"files"`.</comment>

<file context>
@@ -403,6 +407,96 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R
+    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")
+
+
</file context>
Suggested change
assert captured["kwargs"]["files"]["file"] == (result.key, b"pdf", "application/pdf")
assert captured["kwargs"]["content"] == b"pdf"
assert captured["kwargs"]["headers"]["Content-Type"] == "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("日本語"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: _generate_object_key("日本語") produces ---<timestamp>-<random6> (3-dash sanitized base), but the regex in the test uses ---- (4 dashes). This assertion will fail at runtime. Update the assertion to match the actual key format: ---\d+-[a-z0-9]{6}.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/storage/test_storage_client.py, line 493:

<comment>`_generate_object_key("日本語")` produces `---<timestamp>-<random6>` (3-dash sanitized base), but the regex in the test uses `----` (4 dashes). This assertion will fail at runtime. Update the assertion to match the actual key format: `---\d+-[a-z0-9]{6}`.</comment>

<file context>
@@ -403,6 +407,96 @@ async def fake_request(method: str, url: httpx.URL, **kwargs: object) -> httpx.R
+    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(""))
</file context>
Suggested change
assert re.fullmatch(r"----\d+-[a-z0-9]{6}", _generate_object_key("日本語"))
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]] = []
Expand Down