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.
- robin_cloud_client-0.1.0/.gitignore +26 -0
- robin_cloud_client-0.1.0/PKG-INFO +87 -0
- robin_cloud_client-0.1.0/README.md +59 -0
- robin_cloud_client-0.1.0/pyproject.toml +50 -0
- robin_cloud_client-0.1.0/src/robincloud/__init__.py +38 -0
- robin_cloud_client-0.1.0/src/robincloud/client.py +91 -0
- robin_cloud_client-0.1.0/src/robincloud/errors.py +80 -0
- robin_cloud_client-0.1.0/src/robincloud/logs.py +50 -0
- robin_cloud_client-0.1.0/src/robincloud/models.py +105 -0
- robin_cloud_client-0.1.0/src/robincloud/storage.py +221 -0
- robin_cloud_client-0.1.0/tests/test_client_and_logs.py +76 -0
- robin_cloud_client-0.1.0/tests/test_storage.py +142 -0
- robin_cloud_client-0.1.0/uv.lock +579 -0
|
@@ -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
|
+
)
|