robin-cloud-client 0.1.0__tar.gz

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,26 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+
16
+ # TypeScript / Node
17
+ node_modules/
18
+ typescript/dist/
19
+ *.tsbuildinfo
20
+ .turbo/
21
+ coverage/
22
+
23
+ # Editors / OS
24
+ .DS_Store
25
+ .idea/
26
+ .vscode/
@@ -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,59 @@
1
+ # robin-cloud-client (Python)
2
+
3
+ Robin-Cloud 스토리지·로그를 API 키로 이용하는 Python SDK.
4
+
5
+ ```bash
6
+ pip install robin-cloud-client
7
+ ```
8
+
9
+ ## 사용
10
+
11
+ ```python
12
+ from robincloud import RobinCloud
13
+
14
+ rc = RobinCloud(api_key="rbk_…", project="raxis")
15
+ # 또는 환경변수: ROBIN_CLOUD_API_KEY, ROBIN_CLOUD_BASE_URL
16
+
17
+ # 스토리지
18
+ rc.storage.create_bucket("assets") # storage:admin 스코프 필요
19
+ rc.storage.upload("assets", "img/logo.png", data) # presign→PUT→complete 자동
20
+ raw = rc.storage.download("assets", "img/logo.png") # -> bytes
21
+ listing = rc.storage.list_objects("assets", prefix="img/")
22
+ rc.storage.delete("assets", keys=["img/logo.png"])
23
+
24
+ # 로그
25
+ for src in rc.logs.sources():
26
+ print(src.pod, src.containers)
27
+ print(rc.logs.tail(source="web-1", lines=200))
28
+
29
+ rc.close() # 또는 with RobinCloud(...) as rc:
30
+ ```
31
+
32
+ ## 에러
33
+
34
+ 상태코드가 타입 예외로 매핑된다: `AuthenticationError`(401), `PermissionDeniedError`(403),
35
+ `NotFoundError`(404), `ConflictError`(409), `PayloadTooLargeError`(413), `ValidationError`(422),
36
+ `ServiceUnavailableError`(503), 그 외 `ApiError`. 모두 `RobinCloudError` 하위.
37
+
38
+ ```python
39
+ from robincloud import PermissionDeniedError
40
+ try:
41
+ rc.storage.create_bucket("x")
42
+ except PermissionDeniedError as e:
43
+ print(e.status_code, e.message) # 403 "key is missing required scope 'storage:admin'"
44
+ ```
45
+
46
+ ## 범위
47
+
48
+ - 스토리지(버킷·오브젝트·업로드/다운로드·이동·삭제·public URL) + 로그(소스·tail).
49
+ - 키 발급/폐기는 세션 인증이라 SDK 범위 밖(콘솔 UI).
50
+ - 업로드는 단일 presigned PUT(~5GB 상한). 멀티파트는 백로그.
51
+
52
+ ## 개발
53
+
54
+ ```bash
55
+ cd python
56
+ uv venv && uv pip install -e ".[dev]"
57
+ uv run pytest # respx 모킹, 실 네트워크 없음
58
+ uv run ruff check .
59
+ ```
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "robin-cloud-client"
3
+ version = "0.1.0"
4
+ description = "Python client for Robin-Cloud storage and logs (X-Api-Key auth)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "RobinTech" }]
9
+ keywords = ["robin-cloud", "storage", "logs", "s3", "sdk"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.27",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0",
29
+ "pytest-cov>=5.0",
30
+ "respx>=0.21",
31
+ "ruff>=0.6",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/robintech-seoul/robin-cloud-client"
36
+ Repository = "https://github.com/robintech-seoul/robin-cloud-client"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/robincloud"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py39"
@@ -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
+ ]
@@ -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()
@@ -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)
@@ -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", "")
@@ -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
+ )