mona-preview 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.
mona/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """Official Python SDK for MonaDB.
2
+
3
+ Sync and async clients for managing hosted databases and executing SQL.
4
+ Built on :mod:`httpx` and :mod:`pydantic`.
5
+
6
+ Examples:
7
+ Quick start with the synchronous client::
8
+
9
+ from mona import Client
10
+
11
+ with Client(api_key="mk-...", base_url="https://mona.example.workers.dev") as mo:
12
+ mo.databases.create(name="beatles")
13
+ rows = mo.database("beatles").query("select * from beatles;").rows
14
+
15
+ """
16
+
17
+ from ._client import AsyncClient, Client
18
+ from ._errors import (
19
+ APIError,
20
+ AuthenticationError,
21
+ BadRequestError,
22
+ ConflictError,
23
+ MonaError,
24
+ NotFoundError,
25
+ )
26
+ from ._models import (
27
+ AsyncDatabasePage,
28
+ DatabasePage,
29
+ ErrorResponse,
30
+ FieldError,
31
+ HealthStatus,
32
+ ProblemDetail,
33
+ ResolveDatabaseInstanceResponse,
34
+ Result,
35
+ Row,
36
+ Statement,
37
+ )
38
+ from ._models import (
39
+ Database as DatabaseRecord,
40
+ )
41
+ from ._version import __version__
42
+ from .database import AsyncDatabase, Database
43
+
44
+ __all__ = [
45
+ "APIError",
46
+ "AsyncClient",
47
+ "AsyncDatabase",
48
+ "AsyncDatabasePage",
49
+ "AuthenticationError",
50
+ "BadRequestError",
51
+ "Client",
52
+ "ConflictError",
53
+ "Database",
54
+ "DatabasePage",
55
+ "DatabaseRecord",
56
+ "ErrorResponse",
57
+ "FieldError",
58
+ "HealthStatus",
59
+ "MonaError",
60
+ "NotFoundError",
61
+ "ProblemDetail",
62
+ "ResolveDatabaseInstanceResponse",
63
+ "Result",
64
+ "Row",
65
+ "Statement",
66
+ "__version__",
67
+ ]
mona/_client.py ADDED
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import httpx
6
+ from typing_extensions import Self
7
+
8
+ from . import _ops
9
+ from ._transport import Config, default_headers, raise_for_status, resolve_config
10
+ from .database import AsyncDatabase, Database
11
+ from .resources import AsyncDatabasesResource, DatabasesResource
12
+
13
+ if TYPE_CHECKING:
14
+ from types import TracebackType
15
+
16
+ from ._models import HealthStatus
17
+ from ._ops import Op
18
+
19
+
20
+ class Client:
21
+ """Synchronous client for the Mona API.
22
+
23
+ Use as a context manager to ensure the underlying HTTP connection is closed,
24
+ or call :meth:`close` explicitly.
25
+
26
+ Attributes:
27
+ databases: Control-plane and query helpers for hosted databases.
28
+
29
+ Examples:
30
+ Create a database and query it::
31
+
32
+ from mona import Client
33
+
34
+ with Client(api_key="mk-...", base_url="https://mona.example.workers.dev") as client:
35
+ client.databases.create(name="my-app")
36
+ rows = client.database("my-app").fetchall("select * from t;")
37
+
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str | None = None,
43
+ base_url: str | None = None,
44
+ *,
45
+ query_base_url: str | None = None,
46
+ default_database: str | None = None,
47
+ timeout: float = 30.0,
48
+ max_retries: int = 2,
49
+ http_client: httpx.Client | None = None,
50
+ ) -> None:
51
+ """Initialize a synchronous client.
52
+
53
+ Args:
54
+ api_key: Bearer token for API authentication. Falls back to the
55
+ ``MONA_API_KEY`` environment variable when omitted.
56
+ base_url: Control-plane base URL. Falls back to ``MONA_BASE_URL``.
57
+ query_base_url: Optional override for the data-plane host. Defaults
58
+ to ``base_url``.
59
+ default_database: Default database for :meth:`database`. Falls back
60
+ to ``MONA_DEFAULT_DATABASE``.
61
+ timeout: Per-request timeout in seconds.
62
+ max_retries: Connection-level retries passed to httpx.
63
+ http_client: Optional pre-configured :class:`httpx.Client`. When
64
+ provided, ``timeout`` and ``max_retries`` are not applied.
65
+
66
+ Raises:
67
+ ValueError: If no API key is available from arguments or the
68
+ environment.
69
+
70
+ Examples:
71
+ Configure from environment variables::
72
+
73
+ import os
74
+
75
+ os.environ["MONA_API_KEY"] = "mk-..."
76
+ os.environ["MONA_BASE_URL"] = "https://mona.example.workers.dev"
77
+
78
+ with Client() as client:
79
+ client.health()
80
+
81
+ """
82
+ self._config: Config = resolve_config(
83
+ api_key,
84
+ base_url,
85
+ query_base_url,
86
+ timeout,
87
+ max_retries,
88
+ default_database,
89
+ )
90
+ self._http = http_client or httpx.Client(
91
+ headers=default_headers(self._config.api_key),
92
+ timeout=self._config.timeout,
93
+ transport=httpx.HTTPTransport(retries=self._config.max_retries),
94
+ )
95
+ self.databases = DatabasesResource(self)
96
+
97
+ def database(self, name: str | None = None) -> Database:
98
+ """Return a handle for running SQL against a database.
99
+
100
+ Args:
101
+ name: Database name. Falls back to :attr:`default_database` when
102
+ omitted.
103
+
104
+ Returns:
105
+ A :class:`~mona.Database` handle.
106
+
107
+ Raises:
108
+ ValueError: If no name is available from arguments or client config.
109
+
110
+ Examples:
111
+ Bind a database and query it::
112
+
113
+ db = client.database("my-app")
114
+ result = db.query("select {x: 1};")
115
+
116
+ Use the client default database::
117
+
118
+ client = Client(..., default_database="my-app")
119
+ rows = client.database().query("select * from my-app;").rows
120
+
121
+ """
122
+ resolved = name if name is not None else self._config.default_database
123
+ if not resolved:
124
+ msg = (
125
+ "database name is required: pass name=... or set default_database=... "
126
+ "or the MONA_DEFAULT_DATABASE environment variable"
127
+ )
128
+ raise ValueError(msg)
129
+ return Database(self, resolved)
130
+
131
+ @property
132
+ def default_database(self) -> str | None:
133
+ """Default database name for :meth:`database`."""
134
+ return self._config.default_database
135
+
136
+ def _url(self, op: Op) -> str:
137
+ base = self._config.query_base_url if op.plane == "query" else self._config.base_url
138
+ return f"{base}{op.path}"
139
+
140
+ def _send(self, op: Op) -> httpx.Response:
141
+ kwargs: dict[str, object] = {}
142
+ if op.timeout is not None:
143
+ kwargs["timeout"] = op.timeout
144
+ response = self._http.request(op.method, self._url(op), json=op.json, **kwargs)
145
+ return raise_for_status(response)
146
+
147
+ def health(self) -> HealthStatus:
148
+ """Check API availability.
149
+
150
+ Returns:
151
+ Parsed health payload from ``GET /health``.
152
+
153
+ Examples:
154
+ Verify the service is up::
155
+
156
+ status = client.health()
157
+ assert status.status == "ok"
158
+
159
+ """
160
+ ep = _ops.health()
161
+ return ep.parse(self._send(ep.op))
162
+
163
+ def close(self) -> None:
164
+ """Close the underlying HTTP client and release connections.
165
+
166
+ Examples:
167
+ Explicit cleanup without a context manager::
168
+
169
+ client = Client(api_key="mk-...", base_url="https://api.example")
170
+ try:
171
+ client.databases.list()
172
+ finally:
173
+ client.close()
174
+
175
+ """
176
+ self._http.close()
177
+
178
+ def __enter__(self) -> Self:
179
+ """Enter a context manager and return this client.
180
+
181
+ Returns:
182
+ This :class:`Client` instance.
183
+
184
+ """
185
+ return self
186
+
187
+ def __exit__(
188
+ self,
189
+ exc_type: type[BaseException] | None,
190
+ exc: BaseException | None,
191
+ tb: TracebackType | None,
192
+ ) -> None:
193
+ """Exit the context manager and close the HTTP client.
194
+
195
+ Args:
196
+ exc_type: Exception type, if an error was raised in the block.
197
+ exc: Exception instance, if raised.
198
+ tb: Traceback for the exception, if raised.
199
+
200
+ """
201
+ self.close()
202
+
203
+
204
+ class AsyncClient:
205
+ """Asynchronous client for the Mona API.
206
+
207
+ Use as an async context manager to ensure the underlying HTTP connection is
208
+ closed, or call :meth:`aclose` explicitly.
209
+
210
+ Attributes:
211
+ databases: Async control-plane and query helpers for hosted databases.
212
+
213
+ Examples:
214
+ Create a database and query it::
215
+
216
+ from mona import AsyncClient
217
+
218
+ async with AsyncClient(
219
+ api_key="mk-...",
220
+ base_url="https://mona.example.workers.dev",
221
+ ) as client:
222
+ await client.databases.create(name="my-app")
223
+ rows = await client.database("my-app").fetchall("select * from t;")
224
+
225
+ """
226
+
227
+ def __init__(
228
+ self,
229
+ api_key: str | None = None,
230
+ base_url: str | None = None,
231
+ *,
232
+ query_base_url: str | None = None,
233
+ default_database: str | None = None,
234
+ timeout: float = 30.0,
235
+ max_retries: int = 2,
236
+ http_client: httpx.AsyncClient | None = None,
237
+ ) -> None:
238
+ """Initialize an asynchronous client.
239
+
240
+ Args:
241
+ api_key: Bearer token for API authentication. Falls back to the
242
+ ``MONA_API_KEY`` environment variable when omitted.
243
+ base_url: Control-plane base URL. Falls back to ``MONA_BASE_URL``.
244
+ query_base_url: Optional override for the data-plane host. Defaults
245
+ to ``base_url``.
246
+ default_database: Default database for :meth:`database`. Falls back
247
+ to ``MONA_DEFAULT_DATABASE``.
248
+ timeout: Per-request timeout in seconds.
249
+ max_retries: Connection-level retries passed to httpx.
250
+ http_client: Optional pre-configured :class:`httpx.AsyncClient`.
251
+ When provided, ``timeout`` and ``max_retries`` are not applied.
252
+
253
+ Raises:
254
+ ValueError: If no API key is available from arguments or the
255
+ environment.
256
+
257
+ Examples:
258
+ Configure from environment variables::
259
+
260
+ import os
261
+
262
+ os.environ["MONA_API_KEY"] = "mk-..."
263
+ os.environ["MONA_BASE_URL"] = "https://mona.example.workers.dev"
264
+
265
+ async with AsyncClient() as client:
266
+ await client.health()
267
+
268
+ """
269
+ self._config: Config = resolve_config(
270
+ api_key,
271
+ base_url,
272
+ query_base_url,
273
+ timeout,
274
+ max_retries,
275
+ default_database,
276
+ )
277
+ self._http = http_client or httpx.AsyncClient(
278
+ headers=default_headers(self._config.api_key),
279
+ timeout=self._config.timeout,
280
+ transport=httpx.AsyncHTTPTransport(retries=self._config.max_retries),
281
+ )
282
+ self.databases = AsyncDatabasesResource(self)
283
+
284
+ def database(self, name: str | None = None) -> AsyncDatabase:
285
+ """Return an async handle for running SQL against a database."""
286
+ resolved = name if name is not None else self._config.default_database
287
+ if not resolved:
288
+ msg = (
289
+ "database name is required: pass name=... or set default_database=... "
290
+ "or the MONA_DEFAULT_DATABASE environment variable"
291
+ )
292
+ raise ValueError(msg)
293
+ return AsyncDatabase(self, resolved)
294
+
295
+ @property
296
+ def default_database(self) -> str | None:
297
+ """Default database name for :meth:`database`."""
298
+ return self._config.default_database
299
+
300
+ def _url(self, op: Op) -> str:
301
+ base = self._config.query_base_url if op.plane == "query" else self._config.base_url
302
+ return f"{base}{op.path}"
303
+
304
+ async def _send(self, op: Op) -> httpx.Response:
305
+ kwargs: dict[str, object] = {}
306
+ if op.timeout is not None:
307
+ kwargs["timeout"] = op.timeout
308
+ response = await self._http.request(op.method, self._url(op), json=op.json, **kwargs)
309
+ return raise_for_status(response)
310
+
311
+ async def health(self) -> HealthStatus:
312
+ """Check API availability.
313
+
314
+ Returns:
315
+ Parsed health payload from ``GET /health``.
316
+
317
+ Examples:
318
+ Verify the service is up::
319
+
320
+ status = await client.health()
321
+ assert status.status == "ok"
322
+
323
+ """
324
+ ep = _ops.health()
325
+ return ep.parse(await self._send(ep.op))
326
+
327
+ async def aclose(self) -> None:
328
+ """Close the underlying HTTP client and release connections.
329
+
330
+ Examples:
331
+ Explicit cleanup without a context manager::
332
+
333
+ client = AsyncClient(api_key="mk-...", base_url="https://api.example")
334
+ try:
335
+ await client.databases.list()
336
+ finally:
337
+ await client.aclose()
338
+
339
+ """
340
+ await self._http.aclose()
341
+
342
+ async def __aenter__(self) -> Self:
343
+ """Enter an async context manager and return this client.
344
+
345
+ Returns:
346
+ This :class:`AsyncClient` instance.
347
+
348
+ """
349
+ return self
350
+
351
+ async def __aexit__(
352
+ self,
353
+ exc_type: type[BaseException] | None,
354
+ exc: BaseException | None,
355
+ tb: TracebackType | None,
356
+ ) -> None:
357
+ """Exit the async context manager and close the HTTP client.
358
+
359
+ Args:
360
+ exc_type: Exception type, if an error was raised in the block.
361
+ exc: Exception instance, if raised.
362
+ tb: Traceback for the exception, if raised.
363
+
364
+ """
365
+ await self.aclose()
mona/_errors.py ADDED
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ import httpx
7
+
8
+
9
+ class MonaError(Exception):
10
+ """Base class for all errors raised by the Mona SDK.
11
+
12
+ Examples:
13
+ Catch any SDK error::
14
+
15
+ from mona import Client, MonaError
16
+
17
+ try:
18
+ client.databases.get("missing")
19
+ except MonaError:
20
+ ...
21
+
22
+ """
23
+
24
+
25
+ class APIError(MonaError):
26
+ """An error response returned by the Mona API.
27
+
28
+ ``code`` is the machine-readable error code from the control plane
29
+ (for example ``"not_found"``). For data-plane errors, which return a
30
+ plain-text body, it falls back to a code derived from the HTTP status.
31
+
32
+ Attributes:
33
+ message: Human-readable error description.
34
+ status_code: HTTP status code from the response.
35
+ code: Machine-readable error code.
36
+ response: Original :class:`httpx.Response` that triggered the error.
37
+
38
+ Examples:
39
+ Inspect error details::
40
+
41
+ from mona import Client, NotFoundError
42
+
43
+ try:
44
+ client.databases.get("missing")
45
+ except NotFoundError as exc:
46
+ print(exc.status_code, exc.code, exc.message)
47
+
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ *,
54
+ status_code: int,
55
+ code: str,
56
+ response: httpx.Response,
57
+ ) -> None:
58
+ """Initialize an API error.
59
+
60
+ Args:
61
+ message: Human-readable error description.
62
+ status_code: HTTP status code from the response.
63
+ code: Machine-readable error code.
64
+ response: Original HTTP response.
65
+
66
+ """
67
+ super().__init__(message)
68
+ self.message = message
69
+ self.status_code = status_code
70
+ self.code = code
71
+ self.response = response
72
+
73
+
74
+ class BadRequestError(APIError):
75
+ """HTTP 400 — the request was malformed or invalid.
76
+
77
+ Examples:
78
+ Handle validation failures::
79
+
80
+ from mona import BadRequestError
81
+
82
+ try:
83
+ client.databases.create(name="")
84
+ except BadRequestError as exc:
85
+ print(exc.message)
86
+
87
+ """
88
+
89
+
90
+ class AuthenticationError(APIError):
91
+ """HTTP 401 — the API key is missing or invalid.
92
+
93
+ Examples:
94
+ Detect auth problems::
95
+
96
+ from mona import AuthenticationError
97
+
98
+ try:
99
+ client.databases.list()
100
+ except AuthenticationError:
101
+ print("Check MONA_API_KEY")
102
+
103
+ """
104
+
105
+
106
+ class NotFoundError(APIError):
107
+ """HTTP 404 — the requested resource does not exist.
108
+
109
+ Examples:
110
+ Handle a missing database::
111
+
112
+ from mona import NotFoundError
113
+
114
+ try:
115
+ client.databases.get("unknown")
116
+ except NotFoundError as exc:
117
+ assert exc.code == "not_found"
118
+
119
+ """
120
+
121
+
122
+ class ConflictError(APIError):
123
+ """HTTP 409 — the request conflicts with the current state.
124
+
125
+ Examples:
126
+ Handle duplicate database creation::
127
+
128
+ from mona import ConflictError
129
+
130
+ try:
131
+ client.databases.create(name="existing")
132
+ except ConflictError as exc:
133
+ print(exc.message)
134
+
135
+ """
136
+
137
+
138
+ _STATUS_TO_CLASS: dict[int, type[APIError]] = {
139
+ 400: BadRequestError,
140
+ 401: AuthenticationError,
141
+ 404: NotFoundError,
142
+ 409: ConflictError,
143
+ }
144
+
145
+
146
+ def error_from_response(response: httpx.Response) -> APIError:
147
+ """Build the appropriate :class:`APIError` from an HTTP error response.
148
+
149
+ Handles both the control plane's JSON ``{"code", "message"}`` body and the
150
+ data plane's plain-text error body.
151
+
152
+ Args:
153
+ response: HTTP response with a non-success status code.
154
+
155
+ Returns:
156
+ A typed :class:`APIError` subclass when the status code is recognized,
157
+ otherwise a base :class:`APIError`.
158
+
159
+ """
160
+ status = response.status_code
161
+ code = f"http_{status}"
162
+ message = response.text
163
+
164
+ content_type = response.headers.get("content-type", "")
165
+ if "application/json" in content_type:
166
+ try:
167
+ body = response.json()
168
+ except ValueError:
169
+ body = None
170
+ if isinstance(body, dict):
171
+ if "code" in body and "message" in body:
172
+ code = str(body["code"])
173
+ message = str(body["message"])
174
+ elif "title" in body and "status" in body:
175
+ code = str(body.get("type", code))
176
+ message = str(body.get("detail") or body["title"])
177
+
178
+ cls = _STATUS_TO_CLASS.get(status, APIError)
179
+ return cls(message, status_code=status, code=code, response=response)