lumilake-sdk 0.1.0.dev1__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.
- lumilake/__init__.py +57 -0
- lumilake/_base_client.py +202 -0
- lumilake/async_client.py +87 -0
- lumilake/client.py +89 -0
- lumilake/config.py +40 -0
- lumilake/envs.py +275 -0
- lumilake/errors.py +43 -0
- lumilake/log.py +123 -0
- lumilake/py.typed +0 -0
- lumilake/resources/__init__.py +8 -0
- lumilake/resources/_base.py +27 -0
- lumilake/resources/deploy.py +272 -0
- lumilake/resources/info.py +22 -0
- lumilake/resources/jobs.py +122 -0
- lumilake/resources/traces.py +54 -0
- lumilake/resources/workers.py +54 -0
- lumilake_sdk-0.1.0.dev1.dist-info/METADATA +92 -0
- lumilake_sdk-0.1.0.dev1.dist-info/RECORD +21 -0
- lumilake_sdk-0.1.0.dev1.dist-info/WHEEL +5 -0
- lumilake_sdk-0.1.0.dev1.dist-info/licenses/LICENSE +201 -0
- lumilake_sdk-0.1.0.dev1.dist-info/top_level.txt +1 -0
lumilake/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Lumilake Python SDK.
|
|
2
|
+
|
|
3
|
+
Programmatic equivalent of the ``lumilake`` CLI. Every CLI subcommand has a
|
|
4
|
+
matching method on ``LumilakeClient`` / ``AsyncLumilakeClient``.
|
|
5
|
+
|
|
6
|
+
from lumilake import LumilakeClient, AsyncLumilakeClient
|
|
7
|
+
with LumilakeClient.from_config() as client:
|
|
8
|
+
client.jobs.list()
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from lumilake._base_client import BaseAsyncClient, BaseClient, unwrap
|
|
14
|
+
from lumilake.async_client import AsyncLumilakeClient
|
|
15
|
+
from lumilake.client import LumilakeClient
|
|
16
|
+
from lumilake.config import DEFAULT_CONFIG_PATH, LumilakeConfig
|
|
17
|
+
from lumilake.errors import (
|
|
18
|
+
DeployError,
|
|
19
|
+
HttpError,
|
|
20
|
+
LumilakeError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from lumilake.resources.deploy import SERVICE_NAMES, AsyncDeploy, Deploy
|
|
24
|
+
from lumilake.resources.info import AsyncInfo, Info
|
|
25
|
+
from lumilake.resources.jobs import AsyncJobs, Jobs
|
|
26
|
+
from lumilake.resources.traces import AsyncTraces, Traces
|
|
27
|
+
from lumilake.resources.workers import AsyncWorkers, Workers
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0.dev1"
|
|
30
|
+
|
|
31
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
32
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DEFAULT_CONFIG_PATH",
|
|
36
|
+
"SERVICE_NAMES",
|
|
37
|
+
"AsyncDeploy",
|
|
38
|
+
"AsyncInfo",
|
|
39
|
+
"AsyncJobs",
|
|
40
|
+
"AsyncLumilakeClient",
|
|
41
|
+
"AsyncTraces",
|
|
42
|
+
"AsyncWorkers",
|
|
43
|
+
"BaseAsyncClient",
|
|
44
|
+
"BaseClient",
|
|
45
|
+
"Deploy",
|
|
46
|
+
"DeployError",
|
|
47
|
+
"HttpError",
|
|
48
|
+
"Info",
|
|
49
|
+
"Jobs",
|
|
50
|
+
"LumilakeClient",
|
|
51
|
+
"LumilakeConfig",
|
|
52
|
+
"LumilakeError",
|
|
53
|
+
"NotFoundError",
|
|
54
|
+
"Traces",
|
|
55
|
+
"Workers",
|
|
56
|
+
"unwrap",
|
|
57
|
+
]
|
lumilake/_base_client.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Shared HTTP transport for the SDK.
|
|
2
|
+
|
|
3
|
+
Two parallel classes — ``BaseClient`` for sync, ``BaseAsyncClient`` for async —
|
|
4
|
+
own the connection lifecycle, version-prefix handling (``/api/v1``), default
|
|
5
|
+
headers, and error → exception mapping (404 → ``NotFoundError``, 5xx →
|
|
6
|
+
``HttpError``). Resource classes consume them via
|
|
7
|
+
``self._client.get/post/...`` and call ``unwrap()`` on the response.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any, Self
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from lumilake import envs
|
|
16
|
+
from lumilake.config import LumilakeConfig
|
|
17
|
+
from lumilake.errors import HttpError, NotFoundError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
API_VERSION_PREFIX = "/api/v1"
|
|
22
|
+
DEFAULT_TIMEOUT = 300.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resolve_timeout(timeout: float | None) -> float:
|
|
26
|
+
if timeout is not None and timeout > 0:
|
|
27
|
+
return timeout
|
|
28
|
+
return envs.get_lumilake_timeout(default=DEFAULT_TIMEOUT)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_config(
|
|
32
|
+
base_url: str | None,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Resolve the base URL in priority order: explicit arg >
|
|
35
|
+
``LUMILAKE_BASE_URL`` env > saved ``~/.lumilake/config.toml``.
|
|
36
|
+
|
|
37
|
+
Raises ``RuntimeError`` if no base_url can be determined and no saved
|
|
38
|
+
config exists — the caller should run ``lumilake login`` first.
|
|
39
|
+
"""
|
|
40
|
+
url = base_url or envs.get_lumilake_base_url()
|
|
41
|
+
if not url:
|
|
42
|
+
try:
|
|
43
|
+
cfg = LumilakeConfig.load()
|
|
44
|
+
except FileNotFoundError as exc:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
"no base_url provided and no saved config. Pass base_url= "
|
|
47
|
+
"explicitly, set LUMILAKE_BASE_URL, or run `lumilake login`."
|
|
48
|
+
) from exc
|
|
49
|
+
url = cfg.base_url
|
|
50
|
+
return url
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _headers(extra: Mapping[str, str] | None) -> dict[str, str]:
|
|
54
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
55
|
+
if extra:
|
|
56
|
+
headers.update(extra)
|
|
57
|
+
return headers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _url(base_url: str, path: str, *, version_prefix: bool) -> str:
|
|
61
|
+
p = path if path.startswith("/") else f"/{path}"
|
|
62
|
+
if version_prefix and not p.startswith(API_VERSION_PREFIX):
|
|
63
|
+
p = f"{API_VERSION_PREFIX}{p}"
|
|
64
|
+
return f"{base_url.rstrip('/')}{p}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _raise_for_status(response: httpx.Response, url: str) -> None:
|
|
68
|
+
if response.status_code == 404:
|
|
69
|
+
raise NotFoundError(response.status_code, response.text, url=url)
|
|
70
|
+
if response.status_code >= 400:
|
|
71
|
+
raise HttpError(response.status_code, response.text, url=url)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def unwrap(response: httpx.Response) -> Any:
|
|
75
|
+
"""Unwrap the ``{"ok": ..., "data": ...}`` envelope used by the lumilake API."""
|
|
76
|
+
payload = response.json()
|
|
77
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
78
|
+
return payload["data"]
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BaseClient:
|
|
83
|
+
"""Sync HTTP transport. Wraps ``httpx.Client`` with shared headers,
|
|
84
|
+
``/api/v1`` prefixing, and error-to-exception mapping. Owns the
|
|
85
|
+
underlying client when constructed without an injected one;
|
|
86
|
+
``close()`` / ``__exit__`` close it."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
base_url: str,
|
|
91
|
+
*,
|
|
92
|
+
timeout: float | None = None,
|
|
93
|
+
verify: bool | str = True,
|
|
94
|
+
http_client: httpx.Client | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self.base_url = base_url
|
|
97
|
+
self._http = http_client or httpx.Client(
|
|
98
|
+
timeout=_resolve_timeout(timeout),
|
|
99
|
+
verify=verify,
|
|
100
|
+
)
|
|
101
|
+
self._owns_client = http_client is None
|
|
102
|
+
|
|
103
|
+
def request(
|
|
104
|
+
self,
|
|
105
|
+
method: str,
|
|
106
|
+
path: str,
|
|
107
|
+
*,
|
|
108
|
+
version_prefix: bool = True,
|
|
109
|
+
params: Mapping[str, Any] | None = None,
|
|
110
|
+
json_body: Any = None,
|
|
111
|
+
headers: Mapping[str, str] | None = None,
|
|
112
|
+
) -> httpx.Response:
|
|
113
|
+
url = _url(self.base_url, path, version_prefix=version_prefix)
|
|
114
|
+
try:
|
|
115
|
+
response = self._http.request(
|
|
116
|
+
method,
|
|
117
|
+
url,
|
|
118
|
+
params=dict(params) if params else None,
|
|
119
|
+
json=json_body,
|
|
120
|
+
headers=_headers(headers),
|
|
121
|
+
)
|
|
122
|
+
except httpx.HTTPError as exc:
|
|
123
|
+
raise HttpError(0, f"network: {exc}", url=url) from exc
|
|
124
|
+
_raise_for_status(response, url)
|
|
125
|
+
return response
|
|
126
|
+
|
|
127
|
+
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
128
|
+
return self.request("GET", path, **kwargs)
|
|
129
|
+
|
|
130
|
+
def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
131
|
+
return self.request("POST", path, **kwargs)
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
if self._owns_client:
|
|
135
|
+
self._http.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> Self:
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *args: Any) -> None:
|
|
141
|
+
self.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class BaseAsyncClient:
|
|
145
|
+
"""Async HTTP transport. Wraps ``httpx.AsyncClient`` with the same path,
|
|
146
|
+
headers, and error-mapping rules as ``BaseClient``; resources ``await``
|
|
147
|
+
``self._client.request(...)`` and call ``unwrap()`` on the response."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
base_url: str,
|
|
152
|
+
*,
|
|
153
|
+
timeout: float | None = None,
|
|
154
|
+
verify: bool | str = True,
|
|
155
|
+
http_client: httpx.AsyncClient | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
self.base_url = base_url
|
|
158
|
+
self._http = http_client or httpx.AsyncClient(
|
|
159
|
+
timeout=_resolve_timeout(timeout),
|
|
160
|
+
verify=verify,
|
|
161
|
+
)
|
|
162
|
+
self._owns_client = http_client is None
|
|
163
|
+
|
|
164
|
+
async def request(
|
|
165
|
+
self,
|
|
166
|
+
method: str,
|
|
167
|
+
path: str,
|
|
168
|
+
*,
|
|
169
|
+
version_prefix: bool = True,
|
|
170
|
+
params: Mapping[str, Any] | None = None,
|
|
171
|
+
json_body: Any = None,
|
|
172
|
+
headers: Mapping[str, str] | None = None,
|
|
173
|
+
) -> httpx.Response:
|
|
174
|
+
url = _url(self.base_url, path, version_prefix=version_prefix)
|
|
175
|
+
try:
|
|
176
|
+
response = await self._http.request(
|
|
177
|
+
method,
|
|
178
|
+
url,
|
|
179
|
+
params=dict(params) if params else None,
|
|
180
|
+
json=json_body,
|
|
181
|
+
headers=_headers(headers),
|
|
182
|
+
)
|
|
183
|
+
except httpx.HTTPError as exc:
|
|
184
|
+
raise HttpError(0, f"network: {exc}", url=url) from exc
|
|
185
|
+
_raise_for_status(response, url)
|
|
186
|
+
return response
|
|
187
|
+
|
|
188
|
+
async def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
189
|
+
return await self.request("GET", path, **kwargs)
|
|
190
|
+
|
|
191
|
+
async def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
192
|
+
return await self.request("POST", path, **kwargs)
|
|
193
|
+
|
|
194
|
+
async def close(self) -> None:
|
|
195
|
+
if self._owns_client:
|
|
196
|
+
await self._http.aclose()
|
|
197
|
+
|
|
198
|
+
async def __aenter__(self) -> Self:
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
202
|
+
await self.close()
|
lumilake/async_client.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""AsyncLumilakeClient — asynchronous SDK entry point.
|
|
2
|
+
|
|
3
|
+
Composes every async resource — HTTP-backed ones over ``httpx.AsyncClient``,
|
|
4
|
+
``deploy`` over ``asyncio.to_thread`` calls into ``lumilake_deploy``. Same
|
|
5
|
+
construction options as ``LumilakeClient`` (explicit args, env, or
|
|
6
|
+
``.from_config()``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Self
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from lumilake._base_client import BaseAsyncClient, resolve_config
|
|
15
|
+
from lumilake.config import LumilakeConfig
|
|
16
|
+
from lumilake.resources.deploy import AsyncDeploy
|
|
17
|
+
from lumilake.resources.info import AsyncInfo
|
|
18
|
+
from lumilake.resources.jobs import AsyncJobs
|
|
19
|
+
from lumilake.resources.traces import AsyncTraces
|
|
20
|
+
from lumilake.resources.workers import AsyncWorkers
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncLumilakeClient(BaseAsyncClient):
|
|
26
|
+
"""Asynchronous Lumilake API client.
|
|
27
|
+
|
|
28
|
+
Usage::
|
|
29
|
+
|
|
30
|
+
from lumilake import AsyncLumilakeClient
|
|
31
|
+
|
|
32
|
+
async with AsyncLumilakeClient(
|
|
33
|
+
base_url="http://localhost:19000",
|
|
34
|
+
) as client:
|
|
35
|
+
await client.jobs.list()
|
|
36
|
+
await client.deploy.up()
|
|
37
|
+
|
|
38
|
+
Or::
|
|
39
|
+
|
|
40
|
+
client = AsyncLumilakeClient.from_config()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
base_url: str | None = None,
|
|
46
|
+
*,
|
|
47
|
+
timeout: float | None = None,
|
|
48
|
+
verify: bool | str = True,
|
|
49
|
+
http_client: httpx.AsyncClient | None = None,
|
|
50
|
+
repo_root: Path | str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
url = resolve_config(base_url)
|
|
53
|
+
super().__init__(
|
|
54
|
+
base_url=url,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
verify=verify,
|
|
57
|
+
http_client=http_client,
|
|
58
|
+
)
|
|
59
|
+
self._repo_root = Path(repo_root) if repo_root else Path.cwd()
|
|
60
|
+
|
|
61
|
+
self.info = AsyncInfo(self)
|
|
62
|
+
self.deploy = AsyncDeploy(self._repo_root)
|
|
63
|
+
self.jobs = AsyncJobs(self)
|
|
64
|
+
self.workers = AsyncWorkers(self)
|
|
65
|
+
self.traces = AsyncTraces(self)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_config(
|
|
69
|
+
cls,
|
|
70
|
+
path: Path | str | None = None,
|
|
71
|
+
*,
|
|
72
|
+
timeout: float | None = None,
|
|
73
|
+
verify: bool | str = True,
|
|
74
|
+
http_client: httpx.AsyncClient | None = None,
|
|
75
|
+
repo_root: Path | str | None = None,
|
|
76
|
+
) -> Self:
|
|
77
|
+
config = LumilakeConfig.load(path)
|
|
78
|
+
return cls(
|
|
79
|
+
base_url=config.base_url,
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
verify=verify,
|
|
82
|
+
http_client=http_client,
|
|
83
|
+
repo_root=repo_root,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def health(self) -> dict[str, Any]:
|
|
87
|
+
return await self.info.health()
|
lumilake/client.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""LumilakeClient — synchronous SDK entry point.
|
|
2
|
+
|
|
3
|
+
Composes every server-API resource (HTTP) plus the ``deploy`` resource
|
|
4
|
+
(subprocess) and exposes them as attributes (``client.jobs``,
|
|
5
|
+
``client.workers``, ``client.deploy``, …). Construct with an explicit
|
|
6
|
+
``base_url``, ``LUMILAKE_BASE_URL`` env var, or
|
|
7
|
+
``LumilakeClient.from_config()`` to read the file ``lumilake login``
|
|
8
|
+
writes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Self
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from lumilake._base_client import BaseClient, resolve_config
|
|
17
|
+
from lumilake.config import LumilakeConfig
|
|
18
|
+
from lumilake.resources.deploy import Deploy
|
|
19
|
+
from lumilake.resources.info import Info
|
|
20
|
+
from lumilake.resources.jobs import Jobs
|
|
21
|
+
from lumilake.resources.traces import Traces
|
|
22
|
+
from lumilake.resources.workers import Workers
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LumilakeClient(BaseClient):
|
|
28
|
+
"""Synchronous Lumilake API client.
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
from lumilake import LumilakeClient
|
|
33
|
+
|
|
34
|
+
with LumilakeClient(base_url="http://localhost:19000") as client:
|
|
35
|
+
client.jobs.list()
|
|
36
|
+
client.deploy.up()
|
|
37
|
+
|
|
38
|
+
Or pull the URL from ``~/.lumilake/config.toml`` (written by
|
|
39
|
+
``lumilake login``)::
|
|
40
|
+
|
|
41
|
+
client = LumilakeClient.from_config()
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
base_url: str | None = None,
|
|
47
|
+
*,
|
|
48
|
+
timeout: float | None = None,
|
|
49
|
+
verify: bool | str = True,
|
|
50
|
+
http_client: httpx.Client | None = None,
|
|
51
|
+
repo_root: Path | str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
url = resolve_config(base_url)
|
|
54
|
+
super().__init__(
|
|
55
|
+
base_url=url,
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
verify=verify,
|
|
58
|
+
http_client=http_client,
|
|
59
|
+
)
|
|
60
|
+
self._repo_root = Path(repo_root) if repo_root else Path.cwd()
|
|
61
|
+
|
|
62
|
+
self.info = Info(self)
|
|
63
|
+
self.deploy = Deploy(self._repo_root)
|
|
64
|
+
self.jobs = Jobs(self)
|
|
65
|
+
self.workers = Workers(self)
|
|
66
|
+
self.traces = Traces(self)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_config(
|
|
70
|
+
cls,
|
|
71
|
+
path: Path | str | None = None,
|
|
72
|
+
*,
|
|
73
|
+
timeout: float | None = None,
|
|
74
|
+
verify: bool | str = True,
|
|
75
|
+
http_client: httpx.Client | None = None,
|
|
76
|
+
repo_root: Path | str | None = None,
|
|
77
|
+
) -> Self:
|
|
78
|
+
config = LumilakeConfig.load(path)
|
|
79
|
+
return cls(
|
|
80
|
+
base_url=config.base_url,
|
|
81
|
+
timeout=timeout,
|
|
82
|
+
verify=verify,
|
|
83
|
+
http_client=http_client,
|
|
84
|
+
repo_root=repo_root,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def health(self) -> dict[str, Any]:
|
|
88
|
+
"""Shortcut for ``client.info.health()``."""
|
|
89
|
+
return self.info.health()
|
lumilake/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Saved connection state at ``~/.lumilake/config.toml``.
|
|
2
|
+
|
|
3
|
+
Reads the same TOML schema ``lumilake login`` writes (``base_url``).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".lumilake" / "config.toml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class LumilakeConfig:
|
|
18
|
+
"""Saved server URL. Matches the CLI's TOML schema."""
|
|
19
|
+
|
|
20
|
+
base_url: str
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def load(cls, path: Path | str | None = None) -> "LumilakeConfig":
|
|
24
|
+
"""Read ``path`` (default ``~/.lumilake/config.toml``)."""
|
|
25
|
+
target = Path(path) if path else DEFAULT_CONFIG_PATH
|
|
26
|
+
if not target.exists():
|
|
27
|
+
raise FileNotFoundError(
|
|
28
|
+
f"lumilake config not found at {target}. Run "
|
|
29
|
+
f"`lumilake login <url>` to create it."
|
|
30
|
+
)
|
|
31
|
+
with open(target, "rb") as f:
|
|
32
|
+
data = tomllib.load(f)
|
|
33
|
+
return cls(base_url=data["base_url"])
|
|
34
|
+
|
|
35
|
+
def save(self, path: Path | str | None = None) -> None:
|
|
36
|
+
target = Path(path) if path else DEFAULT_CONFIG_PATH
|
|
37
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
body = f'base_url = "{self.base_url}"\n'
|
|
39
|
+
target.write_text(body, encoding="utf-8")
|
|
40
|
+
logger.info("saved lumilake config to %s", target)
|