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 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
+ ]
@@ -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()
@@ -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)