robin-cloud-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: robin-cloud-client
3
+ Version: 0.1.0
4
+ Summary: Python client for Robin-Cloud storage and logs (X-Api-Key auth)
5
+ Project-URL: Homepage, https://github.com/robintech-seoul/robin-cloud-client
6
+ Project-URL: Repository, https://github.com/robintech-seoul/robin-cloud-client
7
+ Author: RobinTech
8
+ License: MIT
9
+ Keywords: logs,robin-cloud,s3,sdk,storage
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: httpx>=0.27
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: respx>=0.21; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # robin-cloud-client (Python)
30
+
31
+ Robin-Cloud 스토리지·로그를 API 키로 이용하는 Python SDK.
32
+
33
+ ```bash
34
+ pip install robin-cloud-client
35
+ ```
36
+
37
+ ## 사용
38
+
39
+ ```python
40
+ from robincloud import RobinCloud
41
+
42
+ rc = RobinCloud(api_key="rbk_…", project="raxis")
43
+ # 또는 환경변수: ROBIN_CLOUD_API_KEY, ROBIN_CLOUD_BASE_URL
44
+
45
+ # 스토리지
46
+ rc.storage.create_bucket("assets") # storage:admin 스코프 필요
47
+ rc.storage.upload("assets", "img/logo.png", data) # presign→PUT→complete 자동
48
+ raw = rc.storage.download("assets", "img/logo.png") # -> bytes
49
+ listing = rc.storage.list_objects("assets", prefix="img/")
50
+ rc.storage.delete("assets", keys=["img/logo.png"])
51
+
52
+ # 로그
53
+ for src in rc.logs.sources():
54
+ print(src.pod, src.containers)
55
+ print(rc.logs.tail(source="web-1", lines=200))
56
+
57
+ rc.close() # 또는 with RobinCloud(...) as rc:
58
+ ```
59
+
60
+ ## 에러
61
+
62
+ 상태코드가 타입 예외로 매핑된다: `AuthenticationError`(401), `PermissionDeniedError`(403),
63
+ `NotFoundError`(404), `ConflictError`(409), `PayloadTooLargeError`(413), `ValidationError`(422),
64
+ `ServiceUnavailableError`(503), 그 외 `ApiError`. 모두 `RobinCloudError` 하위.
65
+
66
+ ```python
67
+ from robincloud import PermissionDeniedError
68
+ try:
69
+ rc.storage.create_bucket("x")
70
+ except PermissionDeniedError as e:
71
+ print(e.status_code, e.message) # 403 "key is missing required scope 'storage:admin'"
72
+ ```
73
+
74
+ ## 범위
75
+
76
+ - 스토리지(버킷·오브젝트·업로드/다운로드·이동·삭제·public URL) + 로그(소스·tail).
77
+ - 키 발급/폐기는 세션 인증이라 SDK 범위 밖(콘솔 UI).
78
+ - 업로드는 단일 presigned PUT(~5GB 상한). 멀티파트는 백로그.
79
+
80
+ ## 개발
81
+
82
+ ```bash
83
+ cd python
84
+ uv venv && uv pip install -e ".[dev]"
85
+ uv run pytest # respx 모킹, 실 네트워크 없음
86
+ uv run ruff check .
87
+ ```
@@ -0,0 +1,9 @@
1
+ robincloud/__init__.py,sha256=ctGNDY58Nv_m4UE-2bH9vLMsQmOJ3YquizALj4Krn4k,844
2
+ robincloud/client.py,sha256=J_8LBJNhIEikzngI5DXWMY0YaTYWf80PkqlN_eScXg8,3343
3
+ robincloud/errors.py,sha256=mdu0SRPqHRAUvwnIZaqay5DPz9dMDB63w4OyFPdK2ds,2610
4
+ robincloud/logs.py,sha256=e9kV773O1xrfMo-pO40zy5Qo84J4evKVUpgGMb059RY,1709
5
+ robincloud/models.py,sha256=jgENJ9o8dqkeW4xlHQNZKLoN8Rf70GuDN30MqQBBzTQ,2627
6
+ robincloud/storage.py,sha256=RmaNopAoqJfmY7U1DA0ccGTNfX-6vS4QNHCfwhtEfIA,8378
7
+ robin_cloud_client-0.1.0.dist-info/METADATA,sha256=FdXmBOg7ZkqhcHdb7pWvZwcbD9rWnJ2Aqc67XvbgQnk,2942
8
+ robin_cloud_client-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ robin_cloud_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
robincloud/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """robin-cloud-client — Robin-Cloud 스토리지·로그 Python SDK (X-Api-Key 인증)."""
2
+
3
+ from .client import RobinCloud
4
+ from .errors import (
5
+ ApiError,
6
+ AuthenticationError,
7
+ ConflictError,
8
+ NotFoundError,
9
+ PayloadTooLargeError,
10
+ PermissionDeniedError,
11
+ RobinCloudError,
12
+ ServiceUnavailableError,
13
+ ValidationError,
14
+ )
15
+ from .models import Bucket, Folder, LogSource, ObjectEntry, ObjectListing, UploadResult
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "RobinCloud",
21
+ # errors
22
+ "RobinCloudError",
23
+ "ApiError",
24
+ "AuthenticationError",
25
+ "PermissionDeniedError",
26
+ "NotFoundError",
27
+ "ConflictError",
28
+ "PayloadTooLargeError",
29
+ "ValidationError",
30
+ "ServiceUnavailableError",
31
+ # models
32
+ "Bucket",
33
+ "Folder",
34
+ "ObjectEntry",
35
+ "ObjectListing",
36
+ "UploadResult",
37
+ "LogSource",
38
+ ]
robincloud/client.py ADDED
@@ -0,0 +1,91 @@
1
+ """RobinCloud — 진입 클라이언트. api-key 인증 + 프로젝트 기본값 + HTTP 계층."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Optional
7
+
8
+ import httpx
9
+
10
+ from .errors import RobinCloudError, error_for_response
11
+ from .logs import LogsClient
12
+ from .storage import StorageClient
13
+
14
+ DEFAULT_BASE_URL = "https://console.robintech.cloud"
15
+ _API_PREFIX = "/api/v1"
16
+
17
+
18
+ class RobinCloud:
19
+ """Robin-Cloud 스토리지·로그 동기 클라이언트.
20
+
21
+ 사용:
22
+ rc = RobinCloud(api_key="rbk_…", project="raxis")
23
+ rc.storage.upload("assets", "img/logo.png", data)
24
+ rc.logs.tail(source="web-1")
25
+
26
+ Args:
27
+ api_key: `rbk_…` 형식 API 키. 생략 시 환경변수 `ROBIN_CLOUD_API_KEY`.
28
+ project: 스토리지·로그 호출의 기본 프로젝트명. 호출별로 override 가능.
29
+ base_url: 콘솔 호스트. 생략 시 `ROBIN_CLOUD_BASE_URL` 또는
30
+ `https://console.robintech.cloud`.
31
+ timeout: 초 단위 요청 타임아웃(기본 30). 대용량 업로드/다운로드는 늘려라.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: Optional[str] = None,
37
+ *,
38
+ project: Optional[str] = None,
39
+ base_url: Optional[str] = None,
40
+ timeout: float = 30.0,
41
+ ) -> None:
42
+ key = api_key or os.environ.get("ROBIN_CLOUD_API_KEY")
43
+ if not key:
44
+ raise RobinCloudError(
45
+ "api_key is required (pass api_key=… or set ROBIN_CLOUD_API_KEY)"
46
+ )
47
+ base = (base_url or os.environ.get("ROBIN_CLOUD_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
48
+
49
+ self._project = project
50
+ self._base_url = base
51
+ self._api_key = key
52
+ # BFF 호출용 인증 클라이언트. presigned S3 호출은 별도(api-key 미첨부).
53
+ self._http = httpx.Client(
54
+ base_url=base + _API_PREFIX,
55
+ headers={"X-Api-Key": key},
56
+ timeout=timeout,
57
+ )
58
+ self.storage = StorageClient(self)
59
+ self.logs = LogsClient(self)
60
+
61
+ # ── 내부 ───────────────────────────────────────────────────────────
62
+
63
+ def _resolve_project(self, project: Optional[str]) -> str:
64
+ p = project or self._project
65
+ if not p:
66
+ raise RobinCloudError(
67
+ "project is required — pass project=… to the call or set it on the client"
68
+ )
69
+ return p
70
+
71
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
72
+ """BFF 요청(api-key 자동 첨부). 4xx/5xx는 타입 예외로 변환."""
73
+ resp = self._http.request(method, path, **kwargs)
74
+ if resp.is_error:
75
+ try:
76
+ body: Any = resp.json()
77
+ except Exception:
78
+ body = resp.text
79
+ raise error_for_response(resp.status_code, body)
80
+ return resp
81
+
82
+ # ── 수명주기 ───────────────────────────────────────────────────────
83
+
84
+ def close(self) -> None:
85
+ self._http.close()
86
+
87
+ def __enter__(self) -> "RobinCloud":
88
+ return self
89
+
90
+ def __exit__(self, *exc: Any) -> None:
91
+ self.close()
robincloud/errors.py ADDED
@@ -0,0 +1,80 @@
1
+ """예외 계층 — HTTP 상태코드를 타입 있는 예외로 매핑한다 (docs/api-surface.md 에러 규약).
2
+
3
+ 상태코드가 계약이고 응답 바디 셰이프는 보장 대상이 아니므로(spec §7.3), 메시지는
4
+ `detail`(FastAPI 기본)과 `error.message`(도메인 핸들러) 양쪽에서 관대하게 뽑는다.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Optional
10
+
11
+
12
+ class RobinCloudError(Exception):
13
+ """모든 SDK 예외의 베이스."""
14
+
15
+
16
+ class ApiError(RobinCloudError):
17
+ """HTTP 에러 응답 — status_code와 서버 메시지를 담는다."""
18
+
19
+ def __init__(self, status_code: int, message: str, *, body: Any = None) -> None:
20
+ self.status_code = status_code
21
+ self.message = message
22
+ self.body = body
23
+ super().__init__(f"[{status_code}] {message}")
24
+
25
+
26
+ class AuthenticationError(ApiError):
27
+ """401 — 키가 없거나 무효·폐기·만료."""
28
+
29
+
30
+ class PermissionDeniedError(ApiError):
31
+ """403 — 스코프/프로젝트 바인딩/발급자 레벨 부족."""
32
+
33
+
34
+ class NotFoundError(ApiError):
35
+ """404 — 버킷·오브젝트 없음(존재 은닉 포함)."""
36
+
37
+
38
+ class ConflictError(ApiError):
39
+ """409 — 버킷 이름 중복, 비어있지 않은 버킷 삭제, 이동 대상 충돌 등."""
40
+
41
+
42
+ class PayloadTooLargeError(ApiError):
43
+ """413 — 오브젝트가 max_object_bytes 초과 또는 버킷 쿼터 초과."""
44
+
45
+
46
+ class ValidationError(ApiError):
47
+ """422 — 요청 검증 실패(잘못된 키·버킷명 등)."""
48
+
49
+
50
+ class ServiceUnavailableError(ApiError):
51
+ """503 — 스토리지 기능 휴면(서버에 ROBIN_STORAGE_BUCKET 미설정) 또는 백엔드 일시 불가."""
52
+
53
+
54
+ _STATUS_MAP = {
55
+ 401: AuthenticationError,
56
+ 403: PermissionDeniedError,
57
+ 404: NotFoundError,
58
+ 409: ConflictError,
59
+ 413: PayloadTooLargeError,
60
+ 422: ValidationError,
61
+ 503: ServiceUnavailableError,
62
+ }
63
+
64
+
65
+ def _extract_message(body: Any, fallback: str) -> str:
66
+ if isinstance(body, dict):
67
+ # FastAPI 기본: {"detail": "..."}. 도메인 핸들러: {"error": {"message": "..."}}.
68
+ detail = body.get("detail")
69
+ if isinstance(detail, str):
70
+ return detail
71
+ err = body.get("error")
72
+ if isinstance(err, dict) and isinstance(err.get("message"), str):
73
+ return err["message"]
74
+ return fallback
75
+
76
+
77
+ def error_for_response(status_code: int, body: Any, fallback: Optional[str] = None) -> ApiError:
78
+ message = _extract_message(body, fallback or f"HTTP {status_code}")
79
+ cls = _STATUS_MAP.get(status_code, ApiError)
80
+ return cls(status_code, message, body=body)
robincloud/logs.py ADDED
@@ -0,0 +1,50 @@
1
+ """로그 네임스페이스 — `rc.logs.*`. 컴포넌트 파드의 최근 로그 tail 조회.
2
+
3
+ 서버는 serve의 `{status, data}` 봉투를 전달한다. SDK는 `data` 안쪽을 꺼내 준다.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, List, Optional
9
+
10
+ from .models import LogSource
11
+
12
+ if TYPE_CHECKING:
13
+ from .client import RobinCloud
14
+
15
+ _MAX_TAIL = 5000
16
+
17
+
18
+ class LogsClient:
19
+ def __init__(self, rc: "RobinCloud") -> None:
20
+ self._rc = rc
21
+
22
+ def _base(self, project: Optional[str]) -> str:
23
+ return f"/projects/{self._rc._resolve_project(project)}"
24
+
25
+ def sources(self, *, project: Optional[str] = None) -> List[LogSource]:
26
+ """로그를 볼 수 있는 소스(파드+컨테이너) 목록."""
27
+ resp = self._rc._request("GET", f"{self._base(project)}/log-sources")
28
+ data = resp.json().get("data", {})
29
+ return [LogSource.from_api(s) for s in data.get("sources", [])]
30
+
31
+ def tail(
32
+ self,
33
+ source: str,
34
+ *,
35
+ container: Optional[str] = None,
36
+ lines: int = 1000,
37
+ project: Optional[str] = None,
38
+ ) -> str:
39
+ """선택한 파드/컨테이너의 최근 로그 tail 문자열.
40
+
41
+ Args:
42
+ source: 파드 이름(`sources()`의 `pod`).
43
+ container: 멀티컨테이너 파드에서 지정(생략 시 첫 컨테이너).
44
+ lines: 최근 N줄(서버 상한 5000).
45
+ """
46
+ params = {"source": source, "tail": min(lines, _MAX_TAIL)}
47
+ if container:
48
+ params["container"] = container
49
+ resp = self._rc._request("GET", f"{self._base(project)}/logs", params=params)
50
+ return resp.json().get("data", {}).get("content", "")
robincloud/models.py ADDED
@@ -0,0 +1,105 @@
1
+ """응답 데이터 모델 — 서버 JSON을 감싸는 얇은 dataclass.
2
+
3
+ 미지 필드는 무시(관대한 파싱)해 서버가 필드를 추가해도 SDK가 깨지지 않는다.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Dict, List, Optional
10
+
11
+
12
+ @dataclass
13
+ class Bucket:
14
+ name: str
15
+ public: bool
16
+ used_bytes: int
17
+ quota_bytes: Optional[int]
18
+ object_count: int
19
+ created_at: Optional[str]
20
+
21
+ @classmethod
22
+ def from_api(cls, d: Dict[str, Any]) -> "Bucket":
23
+ return cls(
24
+ name=d["name"],
25
+ public=bool(d.get("public", False)),
26
+ used_bytes=int(d.get("used_bytes", 0) or 0),
27
+ quota_bytes=d.get("quota_bytes"),
28
+ object_count=int(d.get("object_count", 0) or 0),
29
+ created_at=d.get("created_at"),
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class ObjectEntry:
35
+ name: str
36
+ key: Optional[str] = None
37
+ size_bytes: Optional[int] = None
38
+ content_type: Optional[str] = None
39
+ updated_at: Optional[str] = None
40
+
41
+ @classmethod
42
+ def from_api(cls, d: Dict[str, Any]) -> "ObjectEntry":
43
+ return cls(
44
+ name=d.get("name") or d.get("key", ""),
45
+ key=d.get("key"),
46
+ size_bytes=d.get("size_bytes"),
47
+ content_type=d.get("content_type"),
48
+ updated_at=d.get("updated_at"),
49
+ )
50
+
51
+
52
+ @dataclass
53
+ class Folder:
54
+ name: str
55
+
56
+ @classmethod
57
+ def from_api(cls, d: Dict[str, Any]) -> "Folder":
58
+ return cls(name=d["name"])
59
+
60
+
61
+ @dataclass
62
+ class ObjectListing:
63
+ objects: List[ObjectEntry]
64
+ folders: List[Folder]
65
+ next_cursor: Optional[str]
66
+
67
+ @classmethod
68
+ def from_api(cls, d: Dict[str, Any]) -> "ObjectListing":
69
+ return cls(
70
+ objects=[ObjectEntry.from_api(o) for o in d.get("objects", [])],
71
+ folders=[Folder.from_api(f) for f in d.get("folders", [])],
72
+ next_cursor=d.get("next_cursor"),
73
+ )
74
+
75
+
76
+ @dataclass
77
+ class UploadResult:
78
+ key: str
79
+ size_bytes: int
80
+ etag: Optional[str]
81
+ status: str
82
+
83
+ @classmethod
84
+ def from_api(cls, d: Dict[str, Any]) -> "UploadResult":
85
+ return cls(
86
+ key=d["key"],
87
+ size_bytes=int(d.get("size_bytes", 0) or 0),
88
+ etag=d.get("etag"),
89
+ status=d.get("status", "READY"),
90
+ )
91
+
92
+
93
+ @dataclass
94
+ class LogSource:
95
+ pod: str
96
+ status: Optional[str]
97
+ containers: List[str]
98
+
99
+ @classmethod
100
+ def from_api(cls, d: Dict[str, Any]) -> "LogSource":
101
+ return cls(
102
+ pod=d["pod"],
103
+ status=d.get("status"),
104
+ containers=list(d.get("containers", [])),
105
+ )
robincloud/storage.py ADDED
@@ -0,0 +1,221 @@
1
+ """스토리지 네임스페이스 — `rc.storage.*`.
2
+
3
+ 핵심은 업로드 3콜(presign → S3 PUT → complete)과 다운로드 2콜(presign → S3 GET)을
4
+ `upload()`/`download()` 한 줄로 감추는 것. 바이트는 BFF를 거치지 않고 presigned URL로
5
+ S3에 직행하며, 그 요청에는 api-key를 붙이지 않는다.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import mimetypes
11
+ from typing import TYPE_CHECKING, List, Optional
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+
16
+ from .models import Bucket, ObjectListing, UploadResult
17
+
18
+ if TYPE_CHECKING:
19
+ from .client import RobinCloud
20
+
21
+ _DEFAULT_CONTENT_TYPE = "application/octet-stream"
22
+
23
+
24
+ class StorageClient:
25
+ def __init__(self, rc: "RobinCloud") -> None:
26
+ self._rc = rc
27
+
28
+ def _base(self, project: Optional[str]) -> str:
29
+ return f"/projects/{self._rc._resolve_project(project)}/storage"
30
+
31
+ # ── 버킷 ───────────────────────────────────────────────────────────
32
+
33
+ def list_buckets(self, *, project: Optional[str] = None) -> List[Bucket]:
34
+ resp = self._rc._request("GET", f"{self._base(project)}/buckets")
35
+ return [Bucket.from_api(b) for b in resp.json()]
36
+
37
+ def create_bucket(
38
+ self,
39
+ name: str,
40
+ *,
41
+ public: bool = False,
42
+ quota_bytes: Optional[int] = None,
43
+ project: Optional[str] = None,
44
+ ) -> Bucket:
45
+ body = {"name": name, "public": public}
46
+ if quota_bytes is not None:
47
+ body["quota_bytes"] = quota_bytes
48
+ resp = self._rc._request("POST", f"{self._base(project)}/buckets", json=body)
49
+ return Bucket.from_api(resp.json())
50
+
51
+ def update_bucket(
52
+ self,
53
+ name: str,
54
+ *,
55
+ public: Optional[bool] = None,
56
+ quota_bytes: Optional[int] = None,
57
+ max_object_bytes: Optional[int] = None,
58
+ project: Optional[str] = None,
59
+ ) -> Bucket:
60
+ body = {
61
+ k: v
62
+ for k, v in {
63
+ "public": public,
64
+ "quota_bytes": quota_bytes,
65
+ "max_object_bytes": max_object_bytes,
66
+ }.items()
67
+ if v is not None
68
+ }
69
+ resp = self._rc._request("PATCH", f"{self._base(project)}/buckets/{name}", json=body)
70
+ return Bucket.from_api(resp.json())
71
+
72
+ def delete_bucket(
73
+ self, name: str, *, force: bool = False, project: Optional[str] = None
74
+ ) -> None:
75
+ # confirm은 서버가 요구하는 안전장치 — 버킷명 정확히 타이핑. SDK가 채워준다.
76
+ params = {"confirm": name, "force": str(force).lower()}
77
+ self._rc._request("DELETE", f"{self._base(project)}/buckets/{name}", params=params)
78
+
79
+ # ── 목록 ───────────────────────────────────────────────────────────
80
+
81
+ def list_objects(
82
+ self,
83
+ bucket: str,
84
+ *,
85
+ prefix: str = "",
86
+ limit: int = 100,
87
+ cursor: Optional[str] = None,
88
+ search: Optional[str] = None,
89
+ project: Optional[str] = None,
90
+ ) -> ObjectListing:
91
+ params = {"prefix": prefix, "limit": limit}
92
+ if cursor:
93
+ params["cursor"] = cursor
94
+ if search:
95
+ params["search"] = search
96
+ resp = self._rc._request(
97
+ "GET", f"{self._base(project)}/buckets/{bucket}/objects", params=params
98
+ )
99
+ return ObjectListing.from_api(resp.json())
100
+
101
+ # ── 업로드 / 다운로드 (멀티콜 래핑) ───────────────────────────────
102
+
103
+ def upload(
104
+ self,
105
+ bucket: str,
106
+ key: str,
107
+ data: bytes,
108
+ *,
109
+ content_type: Optional[str] = None,
110
+ project: Optional[str] = None,
111
+ ) -> UploadResult:
112
+ """오브젝트 업로드 — presign → S3 PUT → complete 3콜을 한 번에.
113
+
114
+ Args:
115
+ data: 업로드할 바이트.
116
+ content_type: 생략 시 key 확장자로 추정, 실패하면 octet-stream.
117
+ """
118
+ base = self._base(project)
119
+ ctype = content_type or mimetypes.guess_type(key)[0] or _DEFAULT_CONTENT_TYPE
120
+
121
+ # 1) presign
122
+ presign = self._rc._request(
123
+ "POST",
124
+ f"{base}/buckets/{bucket}/presign-upload",
125
+ json={"key": key, "content_type": ctype, "size_bytes": len(data)},
126
+ ).json()
127
+
128
+ # 2) S3 직행 PUT (api-key 미첨부 — presigned URL 자체가 자격증명)
129
+ put = httpx.request(
130
+ presign.get("method", "PUT"),
131
+ presign["url"],
132
+ content=data,
133
+ headers=presign.get("headers") or {},
134
+ timeout=self._rc._http.timeout,
135
+ )
136
+ put.raise_for_status()
137
+
138
+ # 3) complete (S3 HEAD 실측 검증 → READY)
139
+ done = self._rc._request(
140
+ "POST",
141
+ f"{base}/buckets/{bucket}/complete",
142
+ json={"object_id": presign["object_id"]},
143
+ ).json()
144
+ return UploadResult.from_api(done)
145
+
146
+ def download(self, bucket: str, key: str, *, project: Optional[str] = None) -> bytes:
147
+ """오브젝트 다운로드 — presign → S3 GET. 바이트 반환."""
148
+ presign = self._rc._request(
149
+ "POST",
150
+ f"{self._base(project)}/buckets/{bucket}/presign-download",
151
+ json={"key": key},
152
+ ).json()
153
+ got = httpx.get(presign["url"], timeout=self._rc._http.timeout, follow_redirects=True)
154
+ got.raise_for_status()
155
+ return got.content
156
+
157
+ # ── 폴더 / 이동 / 삭제 ─────────────────────────────────────────────
158
+
159
+ def create_folder(self, bucket: str, key: str, *, project: Optional[str] = None) -> None:
160
+ folder_key = key if key.endswith("/") else key + "/"
161
+ self._rc._request(
162
+ "POST", f"{self._base(project)}/buckets/{bucket}/folders", json={"key": folder_key}
163
+ )
164
+
165
+ def move(
166
+ self,
167
+ bucket: str,
168
+ *,
169
+ from_key: Optional[str] = None,
170
+ to_key: Optional[str] = None,
171
+ from_prefix: Optional[str] = None,
172
+ to_prefix: Optional[str] = None,
173
+ project: Optional[str] = None,
174
+ ) -> int:
175
+ """오브젝트/폴더 이동·이름변경. `from_key/to_key`(단건) 또는 `from_prefix/to_prefix`(폴더).
176
+
177
+ Returns: 이동된 오브젝트 수.
178
+ """
179
+ body = {
180
+ k: v
181
+ for k, v in {
182
+ "from_key": from_key,
183
+ "to_key": to_key,
184
+ "from_prefix": from_prefix,
185
+ "to_prefix": to_prefix,
186
+ }.items()
187
+ if v is not None
188
+ }
189
+ resp = self._rc._request("POST", f"{self._base(project)}/buckets/{bucket}/move", json=body)
190
+ return int(resp.json().get("moved_count", 0))
191
+
192
+ def delete(
193
+ self,
194
+ bucket: str,
195
+ *,
196
+ keys: Optional[List[str]] = None,
197
+ prefix: Optional[str] = None,
198
+ project: Optional[str] = None,
199
+ ) -> int:
200
+ """오브젝트 삭제 — 키 목록(`keys`) 또는 접두사(`prefix`). Returns: 삭제 수."""
201
+ body = {}
202
+ if keys is not None:
203
+ body["keys"] = keys
204
+ if prefix is not None:
205
+ body["prefix"] = prefix
206
+ resp = self._rc._request(
207
+ "DELETE", f"{self._base(project)}/buckets/{bucket}/objects", json=body
208
+ )
209
+ return int(resp.json().get("deleted_count", 0))
210
+
211
+ # ── public URL ─────────────────────────────────────────────────────
212
+
213
+ def public_url(self, bucket: str, key: str, *, project: Optional[str] = None) -> str:
214
+ """public 버킷 오브젝트의 무인증 공개 URL(302 presigned로 리다이렉트)을 만든다.
215
+
216
+ 네트워크 호출 없음 — URL 문자열만 조립한다. 실제 접근 가능 여부(버킷이 public
217
+ 인지)는 이 URL을 GET할 때 서버가 판정한다.
218
+ """
219
+ proj = self._rc._resolve_project(project)
220
+ enc_key = quote(key)
221
+ return f"{self._rc._base_url}/api/v1/storage/public/{proj}/{bucket}/{enc_key}"