chronix-client 0.2.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.
- chronix_client/__init__.py +41 -0
- chronix_client/client.py +530 -0
- chronix_client/exceptions.py +89 -0
- chronix_client/models.py +194 -0
- chronix_client/py.typed +0 -0
- chronix_client-0.2.0.dist-info/METADATA +200 -0
- chronix_client-0.2.0.dist-info/RECORD +8 -0
- chronix_client-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Chronix Python client — async-first SDK for Chronix time-series database."""
|
|
2
|
+
|
|
3
|
+
from chronix_client.client import ChronixClient
|
|
4
|
+
from chronix_client.exceptions import (
|
|
5
|
+
BackpressureError,
|
|
6
|
+
ChronixError,
|
|
7
|
+
ConnectionError,
|
|
8
|
+
DeadlineExceeded,
|
|
9
|
+
QueryError,
|
|
10
|
+
WriteError,
|
|
11
|
+
)
|
|
12
|
+
from chronix_client.models import (
|
|
13
|
+
ColumnSchema,
|
|
14
|
+
FieldValue,
|
|
15
|
+
MeasurementInfo,
|
|
16
|
+
Point,
|
|
17
|
+
QueryResult,
|
|
18
|
+
DeleteResult,
|
|
19
|
+
ServerInfo,
|
|
20
|
+
TimeRange,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"BackpressureError",
|
|
25
|
+
"ChronixClient",
|
|
26
|
+
"ChronixError",
|
|
27
|
+
"ColumnSchema",
|
|
28
|
+
"ConnectionError",
|
|
29
|
+
"DeadlineExceeded",
|
|
30
|
+
"DeleteResult",
|
|
31
|
+
"FieldValue",
|
|
32
|
+
"MeasurementInfo",
|
|
33
|
+
"Point",
|
|
34
|
+
"QueryError",
|
|
35
|
+
"QueryResult",
|
|
36
|
+
"ServerInfo",
|
|
37
|
+
"TimeRange",
|
|
38
|
+
"WriteError",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.2.0"
|
chronix_client/client.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Async HTTP client for the Chronix REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from chronix_client.exceptions import (
|
|
10
|
+
BackpressureError,
|
|
11
|
+
ChronixError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
DeadlineExceeded,
|
|
14
|
+
QueryError,
|
|
15
|
+
WriteError,
|
|
16
|
+
)
|
|
17
|
+
from chronix_client.models import (
|
|
18
|
+
ColumnSchema,
|
|
19
|
+
DeleteResult,
|
|
20
|
+
MeasurementInfo,
|
|
21
|
+
Point,
|
|
22
|
+
QueryResult,
|
|
23
|
+
ServerInfo,
|
|
24
|
+
TimeRange,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChronixClient:
|
|
31
|
+
"""Async client for the Chronix time-series database REST API.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
base_url : str
|
|
36
|
+
Chronix server URL, e.g. ``"http://localhost:5555"``.
|
|
37
|
+
api_key : str | None
|
|
38
|
+
Optional API key for authentication (sent as ``Authorization: Bearer``).
|
|
39
|
+
timeout : float
|
|
40
|
+
Default request timeout in seconds.
|
|
41
|
+
namespace : str | None
|
|
42
|
+
Optional namespace header for multi-tenant deployments.
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> async with ChronixClient("http://localhost:5555") as client:
|
|
47
|
+
... await client.write([Point("cpu", {"usage": 42.5}, tags={"host": "a"})])
|
|
48
|
+
... result = await client.query("cpu", TimeRange(start=0, end=2**63 - 1))
|
|
49
|
+
... print(len(result))
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
base_url: str,
|
|
55
|
+
*,
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
58
|
+
namespace: str | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
61
|
+
if api_key is not None:
|
|
62
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
63
|
+
if namespace is not None:
|
|
64
|
+
headers["X-Chronix-Namespace"] = namespace
|
|
65
|
+
|
|
66
|
+
self._client = httpx.AsyncClient(
|
|
67
|
+
base_url=base_url,
|
|
68
|
+
headers=headers,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def __aenter__(self) -> ChronixClient:
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
76
|
+
await self.close()
|
|
77
|
+
|
|
78
|
+
async def close(self) -> None:
|
|
79
|
+
"""Close the underlying HTTP connection pool."""
|
|
80
|
+
await self._client.aclose()
|
|
81
|
+
|
|
82
|
+
# ── Health ────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async def health(self) -> dict[str, Any]:
|
|
85
|
+
"""Check server health.
|
|
86
|
+
|
|
87
|
+
Returns a dict with ``status`` key.
|
|
88
|
+
"""
|
|
89
|
+
return await self._get("/health")
|
|
90
|
+
|
|
91
|
+
async def ready(self) -> dict[str, Any]:
|
|
92
|
+
"""Check server readiness."""
|
|
93
|
+
return await self._get("/ready")
|
|
94
|
+
|
|
95
|
+
async def server_info(self) -> ServerInfo:
|
|
96
|
+
"""Get server metadata (version, uptime, measurement count).
|
|
97
|
+
|
|
98
|
+
Assembled from ``/health`` and the measurement listing's ``total``;
|
|
99
|
+
there is no ``/api/v1/server_info`` route.
|
|
100
|
+
"""
|
|
101
|
+
health = await self._get("/health")
|
|
102
|
+
listing = await self._get("/api/v1/measurements", params={"limit": 1})
|
|
103
|
+
return ServerInfo(
|
|
104
|
+
version=str(health.get("version", "unknown")),
|
|
105
|
+
uptime_seconds=float(health.get("uptime_secs", 0) or 0),
|
|
106
|
+
measurement_count=int(listing.get("total", 0) or 0),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# ── Write ─────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
async def write(
|
|
112
|
+
self,
|
|
113
|
+
points: list[Point],
|
|
114
|
+
*,
|
|
115
|
+
idempotency_key: str | None = None,
|
|
116
|
+
backfill: bool = False,
|
|
117
|
+
) -> int:
|
|
118
|
+
"""Write points via JSON endpoint.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
points : list[Point]
|
|
123
|
+
Data points to write.
|
|
124
|
+
idempotency_key : str | None
|
|
125
|
+
Optional idempotency key (HTTP 409 on duplicate).
|
|
126
|
+
backfill : bool
|
|
127
|
+
Write **outside** the server's out-of-order window — importing
|
|
128
|
+
history rather than ingesting live. Live writes are held to
|
|
129
|
+
``±ooo_shard_tolerance`` shards of the newest write; anything
|
|
130
|
+
older is refused, and this is the way to store it. Off by
|
|
131
|
+
default, because the window is what keeps the number of open
|
|
132
|
+
memtables bounded.
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
int
|
|
137
|
+
Number of points written.
|
|
138
|
+
"""
|
|
139
|
+
headers: dict[str, str] = {}
|
|
140
|
+
if idempotency_key is not None:
|
|
141
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
142
|
+
|
|
143
|
+
body = [p.to_dict() for p in points]
|
|
144
|
+
# `POST /api/v1/write` answers **204 No Content** — no body to parse,
|
|
145
|
+
# so the count returned here is the caller's own.
|
|
146
|
+
#
|
|
147
|
+
# `params`, never a query string concatenated into the path: the live
|
|
148
|
+
# smoke test checks every path this file names against the server's
|
|
149
|
+
# OpenAPI document, and a path carrying its own query matches nothing.
|
|
150
|
+
params = {"backfill": "true"} if backfill else None
|
|
151
|
+
await self._post_no_content(
|
|
152
|
+
"/api/v1/write", json=body, params=params, extra_headers=headers
|
|
153
|
+
)
|
|
154
|
+
return len(points)
|
|
155
|
+
|
|
156
|
+
async def write_line_protocol(
|
|
157
|
+
self,
|
|
158
|
+
lines: str | list[str],
|
|
159
|
+
*,
|
|
160
|
+
idempotency_key: str | None = None,
|
|
161
|
+
) -> int:
|
|
162
|
+
"""Write using InfluxDB line protocol.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
lines : str | list[str]
|
|
167
|
+
Line protocol string(s).
|
|
168
|
+
idempotency_key : str | None
|
|
169
|
+
Optional idempotency key.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
int
|
|
174
|
+
Number of points written.
|
|
175
|
+
"""
|
|
176
|
+
if isinstance(lines, list):
|
|
177
|
+
body = "\n".join(lines)
|
|
178
|
+
else:
|
|
179
|
+
body = lines
|
|
180
|
+
|
|
181
|
+
headers: dict[str, str] = {"Content-Type": "text/plain"}
|
|
182
|
+
if idempotency_key is not None:
|
|
183
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
184
|
+
|
|
185
|
+
resp = await self._client.post(
|
|
186
|
+
"/api/v1/write/influx", content=body, headers=headers
|
|
187
|
+
)
|
|
188
|
+
self._check_response(resp)
|
|
189
|
+
data = resp.json()
|
|
190
|
+
return data.get("written", 0)
|
|
191
|
+
|
|
192
|
+
# ── Query ─────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async def query(
|
|
195
|
+
self,
|
|
196
|
+
measurement: str,
|
|
197
|
+
time_range: TimeRange,
|
|
198
|
+
*,
|
|
199
|
+
tags: dict[str, str] | None = None,
|
|
200
|
+
fields: list[str] | None = None,
|
|
201
|
+
limit: int | None = None,
|
|
202
|
+
offset: int | None = None,
|
|
203
|
+
) -> QueryResult:
|
|
204
|
+
"""Execute a structured query.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
measurement : str
|
|
209
|
+
Measurement name.
|
|
210
|
+
time_range : TimeRange
|
|
211
|
+
Time range to query.
|
|
212
|
+
tags : dict[str, str] | None
|
|
213
|
+
Optional tag filter predicates (exact match).
|
|
214
|
+
fields : list[str] | None
|
|
215
|
+
Specific fields to return (default: all).
|
|
216
|
+
limit : int | None
|
|
217
|
+
Maximum rows to return.
|
|
218
|
+
offset : int | None
|
|
219
|
+
Rows to skip.
|
|
220
|
+
|
|
221
|
+
Returns
|
|
222
|
+
-------
|
|
223
|
+
QueryResult
|
|
224
|
+
Query results with row dicts.
|
|
225
|
+
"""
|
|
226
|
+
body: dict[str, Any] = {
|
|
227
|
+
"measurement": measurement,
|
|
228
|
+
"range": time_range.to_dict(),
|
|
229
|
+
}
|
|
230
|
+
if tags:
|
|
231
|
+
body["tags"] = dict(tags)
|
|
232
|
+
if fields:
|
|
233
|
+
body["fields"] = list(fields)
|
|
234
|
+
if limit is not None:
|
|
235
|
+
body["limit"] = limit
|
|
236
|
+
if offset is not None:
|
|
237
|
+
body["offset"] = offset
|
|
238
|
+
|
|
239
|
+
# The response is a bare JSON **array** of rows, not an object with a
|
|
240
|
+
# `rows` key. Every field name here was wrong in the same direction:
|
|
241
|
+
# the body used `tag_filters`/`field_columns`, which the server's
|
|
242
|
+
# `QueryRequest` does not define — and it ignores unknown fields, so
|
|
243
|
+
# the query silently ran unfiltered and unprojected before failing on
|
|
244
|
+
# the response shape.
|
|
245
|
+
rows = await self._post("/api/v1/chronix/query", json=body)
|
|
246
|
+
return QueryResult(rows=rows if isinstance(rows, list) else [])
|
|
247
|
+
|
|
248
|
+
async def sql(self, query: str) -> QueryResult:
|
|
249
|
+
"""Execute a SQL query.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
query : str
|
|
254
|
+
SQL query string.
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
QueryResult
|
|
259
|
+
Query results.
|
|
260
|
+
"""
|
|
261
|
+
# The SQL endpoint answers column-oriented metadata plus **positional**
|
|
262
|
+
# rows — `{"columns": [{"name", "data_type"}], "rows": [[...]],
|
|
263
|
+
# "row_count": n}`. `QueryResult` holds row dicts, so the two are
|
|
264
|
+
# zipped here; handing the raw arrays through would give callers rows
|
|
265
|
+
# with no column names and a `to_dataframe()` with integer headers.
|
|
266
|
+
data = await self._post("/api/v1/chronix/sql", json={"query": query})
|
|
267
|
+
names = [c["name"] for c in data.get("columns", [])]
|
|
268
|
+
rows = [dict(zip(names, row)) for row in data.get("rows", [])]
|
|
269
|
+
return QueryResult(rows=rows, truncated=bool(data.get("truncated", False)))
|
|
270
|
+
|
|
271
|
+
async def explain(self, measurement: str, time_range: TimeRange) -> dict[str, Any]:
|
|
272
|
+
"""Get the query execution plan (EXPLAIN)."""
|
|
273
|
+
body = {"measurement": measurement, "range": time_range.to_dict()}
|
|
274
|
+
return await self._post("/api/v1/chronix/query/explain", json=body)
|
|
275
|
+
|
|
276
|
+
# ── Schema ────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
async def list_measurements(
|
|
279
|
+
self,
|
|
280
|
+
*,
|
|
281
|
+
offset: int | None = None,
|
|
282
|
+
limit: int | None = None,
|
|
283
|
+
) -> list[MeasurementInfo]:
|
|
284
|
+
"""List measurements.
|
|
285
|
+
|
|
286
|
+
The endpoint is **paginated** and answers
|
|
287
|
+
``{"items": [...], "total": n, "offset": n, "limit": n}``; this method
|
|
288
|
+
read a ``measurements`` key that no response carries, so it always
|
|
289
|
+
returned an empty list. Pass ``limit`` to page explicitly — the server
|
|
290
|
+
applies its own default otherwise.
|
|
291
|
+
"""
|
|
292
|
+
params: dict[str, Any] = {}
|
|
293
|
+
if offset is not None:
|
|
294
|
+
params["offset"] = offset
|
|
295
|
+
if limit is not None:
|
|
296
|
+
params["limit"] = limit
|
|
297
|
+
data = await self._get("/api/v1/measurements", params=params or None)
|
|
298
|
+
return [MeasurementInfo(name=m["name"]) for m in data.get("items", [])]
|
|
299
|
+
|
|
300
|
+
async def get_schema(self, measurement: str) -> list[ColumnSchema]:
|
|
301
|
+
"""Get schema for a measurement."""
|
|
302
|
+
data = await self._get(f"/api/v1/measurements/{measurement}/schema")
|
|
303
|
+
# `data_type` is omitted for timestamp and tag columns — the server
|
|
304
|
+
# skips serialising `None` — so indexing it raised `KeyError` on every
|
|
305
|
+
# measurement that has a tag.
|
|
306
|
+
return [
|
|
307
|
+
ColumnSchema(
|
|
308
|
+
name=c["name"], role=c["role"], data_type=c.get("data_type")
|
|
309
|
+
)
|
|
310
|
+
for c in data.get("columns", [])
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
async def drop_measurement(self, measurement: str) -> None:
|
|
314
|
+
"""Drop a measurement and all its data."""
|
|
315
|
+
resp = await self._client.delete(f"/api/v1/measurements/{measurement}")
|
|
316
|
+
self._check_response(resp)
|
|
317
|
+
|
|
318
|
+
# ── Delete ────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
async def delete(
|
|
321
|
+
self,
|
|
322
|
+
measurement: str,
|
|
323
|
+
time_range: TimeRange | None = None,
|
|
324
|
+
*,
|
|
325
|
+
tags: dict[str, str] | None = None,
|
|
326
|
+
) -> DeleteResult:
|
|
327
|
+
"""Delete data by measurement, optional time range, and optional tags.
|
|
328
|
+
|
|
329
|
+
``time_range`` is inclusive at both ends. Omitting it deletes
|
|
330
|
+
everything currently stored for the matching series — but not data
|
|
331
|
+
written afterwards, so writing to a deleted series re-creates it.
|
|
332
|
+
|
|
333
|
+
Returns a :class:`DeleteResult`. Check
|
|
334
|
+
:attr:`~DeleteResult.complete` before treating the delete as done: the
|
|
335
|
+
server skips segments it cannot read rather than failing the request.
|
|
336
|
+
"""
|
|
337
|
+
body: dict[str, Any] = {"measurement": measurement}
|
|
338
|
+
if time_range is not None:
|
|
339
|
+
body["range"] = time_range.to_dict()
|
|
340
|
+
if tags:
|
|
341
|
+
body["tags"] = dict(tags)
|
|
342
|
+
data = await self._post("/api/v1/delete", json=body)
|
|
343
|
+
return DeleteResult.from_dict(data)
|
|
344
|
+
|
|
345
|
+
# ── Admin ─────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
async def list_rollups(self) -> list[dict[str, Any]]:
|
|
348
|
+
"""List configured rollup rules.
|
|
349
|
+
|
|
350
|
+
Paginated, like every listing endpoint: the payload key is ``items``.
|
|
351
|
+
"""
|
|
352
|
+
data = await self._get("/api/v1/rollups")
|
|
353
|
+
return data.get("items", [])
|
|
354
|
+
|
|
355
|
+
async def list_connectors(self) -> list[dict[str, Any]]:
|
|
356
|
+
"""List active connectors (payload key is ``items``)."""
|
|
357
|
+
data = await self._get("/api/v1/connectors")
|
|
358
|
+
return data.get("items", [])
|
|
359
|
+
|
|
360
|
+
async def openapi_spec(self) -> dict[str, Any]:
|
|
361
|
+
"""Fetch the OpenAPI 3.1 specification."""
|
|
362
|
+
return await self._get("/api/v1/openapi.json")
|
|
363
|
+
|
|
364
|
+
# ── PromQL ────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
async def prom_query(self, query: str, *, time: str | None = None) -> dict[str, Any]:
|
|
367
|
+
"""Execute an instant PromQL query.
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
query : str
|
|
372
|
+
PromQL expression.
|
|
373
|
+
time : str | None
|
|
374
|
+
Evaluation timestamp (RFC 3339 or Unix seconds).
|
|
375
|
+
"""
|
|
376
|
+
params: dict[str, str] = {"query": query}
|
|
377
|
+
if time is not None:
|
|
378
|
+
params["time"] = time
|
|
379
|
+
return await self._get("/api/v1/prom/query", params=params)
|
|
380
|
+
|
|
381
|
+
async def prom_query_range(
|
|
382
|
+
self,
|
|
383
|
+
query: str,
|
|
384
|
+
start: str,
|
|
385
|
+
end: str,
|
|
386
|
+
step: str,
|
|
387
|
+
) -> dict[str, Any]:
|
|
388
|
+
"""Execute a range PromQL query.
|
|
389
|
+
|
|
390
|
+
Parameters
|
|
391
|
+
----------
|
|
392
|
+
query : str
|
|
393
|
+
PromQL expression.
|
|
394
|
+
start, end : str
|
|
395
|
+
Range boundaries (RFC 3339 or Unix seconds).
|
|
396
|
+
step : str
|
|
397
|
+
Query resolution step (e.g. ``"15s"``).
|
|
398
|
+
"""
|
|
399
|
+
params = {"query": query, "start": start, "end": end, "step": step}
|
|
400
|
+
return await self._get("/api/v1/prom/query_range", params=params)
|
|
401
|
+
|
|
402
|
+
async def prom_labels(self) -> list[str]:
|
|
403
|
+
"""List all Prometheus label names."""
|
|
404
|
+
data = await self._get("/api/v1/prom/labels")
|
|
405
|
+
return data.get("data", [])
|
|
406
|
+
|
|
407
|
+
async def prom_label_values(self, label: str) -> list[str]:
|
|
408
|
+
"""Get values for a Prometheus label."""
|
|
409
|
+
data = await self._get(f"/api/v1/prom/label/{label}/values")
|
|
410
|
+
return data.get("data", [])
|
|
411
|
+
|
|
412
|
+
async def prom_series(self, match: list[str]) -> list[dict[str, str]]:
|
|
413
|
+
"""Find series matching label selectors."""
|
|
414
|
+
params = {"match[]": match}
|
|
415
|
+
data = await self._get("/api/v1/prom/series", params=params)
|
|
416
|
+
return data.get("data", [])
|
|
417
|
+
|
|
418
|
+
# ── Arrow Flight SQL ──────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def flight_sql_uri(host: str = "localhost", port: int = 5557) -> str:
|
|
422
|
+
"""Build an ADBC Flight SQL connection URI.
|
|
423
|
+
|
|
424
|
+
Use with ``adbc_driver_flightsql`` for Arrow-native queries::
|
|
425
|
+
|
|
426
|
+
import adbc_driver_flightsql.dbapi
|
|
427
|
+
uri = ChronixClient.flight_sql_uri()
|
|
428
|
+
conn = adbc_driver_flightsql.dbapi.connect(uri)
|
|
429
|
+
cursor = conn.cursor()
|
|
430
|
+
cursor.execute("SELECT * FROM cpu LIMIT 10")
|
|
431
|
+
df = cursor.fetch_arrow_table().to_pandas()
|
|
432
|
+
"""
|
|
433
|
+
return f"grpc://{host}:{port}"
|
|
434
|
+
|
|
435
|
+
# ── Internal ──────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
async def _get(
|
|
438
|
+
self,
|
|
439
|
+
path: str,
|
|
440
|
+
*,
|
|
441
|
+
params: dict[str, Any] | None = None,
|
|
442
|
+
) -> dict[str, Any]:
|
|
443
|
+
try:
|
|
444
|
+
resp = await self._client.get(path, params=params)
|
|
445
|
+
except httpx.ConnectError as e:
|
|
446
|
+
raise ConnectionError(str(e)) from e
|
|
447
|
+
self._check_response(resp)
|
|
448
|
+
return resp.json()
|
|
449
|
+
|
|
450
|
+
async def _post(
|
|
451
|
+
self,
|
|
452
|
+
path: str,
|
|
453
|
+
*,
|
|
454
|
+
json: Any = None,
|
|
455
|
+
extra_headers: dict[str, str] | None = None,
|
|
456
|
+
) -> Any:
|
|
457
|
+
"""POST and decode the JSON body.
|
|
458
|
+
|
|
459
|
+
The return is deliberately `Any` rather than `dict`: `/api/v1/query`
|
|
460
|
+
answers a bare JSON **array**. Typing this as a dict is what let
|
|
461
|
+
`data.get("rows", [])` past review on a response that has no keys.
|
|
462
|
+
"""
|
|
463
|
+
try:
|
|
464
|
+
resp = await self._client.post(path, json=json, headers=extra_headers)
|
|
465
|
+
except httpx.ConnectError as e:
|
|
466
|
+
raise ConnectionError(str(e)) from e
|
|
467
|
+
self._check_response(resp)
|
|
468
|
+
return resp.json()
|
|
469
|
+
|
|
470
|
+
async def _post_no_content(
|
|
471
|
+
self,
|
|
472
|
+
path: str,
|
|
473
|
+
*,
|
|
474
|
+
json: Any = None,
|
|
475
|
+
params: dict[str, str] | None = None,
|
|
476
|
+
extra_headers: dict[str, str] | None = None,
|
|
477
|
+
) -> None:
|
|
478
|
+
"""POST to an endpoint that answers `204 No Content`."""
|
|
479
|
+
try:
|
|
480
|
+
resp = await self._client.post(
|
|
481
|
+
path, json=json, params=params, headers=extra_headers
|
|
482
|
+
)
|
|
483
|
+
except httpx.ConnectError as e:
|
|
484
|
+
raise ConnectionError(str(e)) from e
|
|
485
|
+
self._check_response(resp)
|
|
486
|
+
|
|
487
|
+
@staticmethod
|
|
488
|
+
def _check_response(resp: httpx.Response) -> None:
|
|
489
|
+
"""Turn an error response into the exception that says what to do.
|
|
490
|
+
|
|
491
|
+
The server's `code` is the machine-readable half of every error and
|
|
492
|
+
the API reference tells clients to branch on it; this used to discard
|
|
493
|
+
it, so `503 BACKPRESSURE` — back off a second, the flush clearing it
|
|
494
|
+
is already running — was indistinguishable from a bug in the server.
|
|
495
|
+
"""
|
|
496
|
+
if resp.is_success:
|
|
497
|
+
return
|
|
498
|
+
status = resp.status_code
|
|
499
|
+
code: str | None = None
|
|
500
|
+
detail = resp.text
|
|
501
|
+
try:
|
|
502
|
+
body = resp.json()
|
|
503
|
+
detail = body.get("error", resp.text)
|
|
504
|
+
code = body.get("code")
|
|
505
|
+
except Exception: # noqa: BLE001
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
retry_after: float | None = None
|
|
509
|
+
header = resp.headers.get("retry-after")
|
|
510
|
+
if header is not None:
|
|
511
|
+
try:
|
|
512
|
+
retry_after = float(header)
|
|
513
|
+
except ValueError:
|
|
514
|
+
# An HTTP-date is legal here and chronix never sends one;
|
|
515
|
+
# ignoring it is better than failing the error path.
|
|
516
|
+
retry_after = None
|
|
517
|
+
|
|
518
|
+
kwargs = {"status_code": status, "code": code, "retry_after": retry_after}
|
|
519
|
+
|
|
520
|
+
if status == 409:
|
|
521
|
+
raise WriteError(
|
|
522
|
+
f"duplicate write (idempotency conflict): {detail}", **kwargs
|
|
523
|
+
)
|
|
524
|
+
if status in (503, 507):
|
|
525
|
+
raise BackpressureError(f"server unavailable {status}: {detail}", **kwargs)
|
|
526
|
+
if status == 504:
|
|
527
|
+
raise DeadlineExceeded(f"deadline exceeded {status}: {detail}", **kwargs)
|
|
528
|
+
if 400 <= status < 500:
|
|
529
|
+
raise QueryError(f"client error {status}: {detail}", **kwargs)
|
|
530
|
+
raise ChronixError(f"server error {status}: {detail}", **kwargs)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Exception hierarchy for the Chronix Python client.
|
|
2
|
+
|
|
3
|
+
Every error response carries a machine-readable ``code`` beside its message,
|
|
4
|
+
and the API reference tells clients to branch on that rather than on the
|
|
5
|
+
sentence. This module used to throw it away: a full memtable, a query that
|
|
6
|
+
ran out of time and a genuine server bug all arrived as a bare
|
|
7
|
+
:class:`ChronixError` whose only distinguishing feature was a status number
|
|
8
|
+
embedded in a string. So the one thing an ingestion loop needs to decide —
|
|
9
|
+
*should I retry, and when?* — was not available to it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChronixError(Exception):
|
|
16
|
+
"""Base exception for all Chronix client errors.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
status_code: The HTTP status, when the failure came from the server.
|
|
20
|
+
code: The server's machine-readable error code (``BACKPRESSURE``,
|
|
21
|
+
``CARDINALITY_EXCEEDED``, …), or ``None`` for a transport failure.
|
|
22
|
+
retry_after: Seconds the server asked the client to wait, from the
|
|
23
|
+
``Retry-After`` header, or ``None`` when it did not say.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
status_code: int | None = None,
|
|
31
|
+
code: str | None = None,
|
|
32
|
+
retry_after: float | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
self.code = code
|
|
37
|
+
self.retry_after = retry_after
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def retryable(self) -> bool:
|
|
41
|
+
"""Whether re-sending the same request could succeed.
|
|
42
|
+
|
|
43
|
+
``True`` for the conditions the server marks as the deployment's and
|
|
44
|
+
transient — back-pressure, a full disk, the database shutting down —
|
|
45
|
+
and for a deadline, which a smaller query or a quieter moment may
|
|
46
|
+
clear. ``False`` for anything the caller has to change first.
|
|
47
|
+
"""
|
|
48
|
+
return isinstance(self, (BackpressureError, DeadlineExceeded))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConnectionError(ChronixError): # noqa: A001 — intentional shadow
|
|
52
|
+
"""Raised when the client cannot reach the Chronix server."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def retryable(self) -> bool:
|
|
56
|
+
"""A server that is not there yet may be there in a moment."""
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WriteError(ChronixError):
|
|
61
|
+
"""Raised when a write operation fails."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class QueryError(ChronixError):
|
|
65
|
+
"""Raised when a query operation fails."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BackpressureError(ChronixError):
|
|
69
|
+
"""The server is temporarily unable to accept the request.
|
|
70
|
+
|
|
71
|
+
``503 BACKPRESSURE`` (the memtable is at capacity and the flush that
|
|
72
|
+
clears it is already running), ``503 OVERLOADED`` (a condition that needs
|
|
73
|
+
an operator), ``503 DATABASE_CLOSED`` (the server is shutting down) and
|
|
74
|
+
``507 STORAGE_FULL``. :attr:`~ChronixError.retry_after` carries the wait
|
|
75
|
+
the server asked for, in seconds.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DeadlineExceeded(ChronixError):
|
|
80
|
+
"""The server stopped waiting.
|
|
81
|
+
|
|
82
|
+
``504 QUERY_TIMEOUT`` — the read outran its deadline; narrow the range or
|
|
83
|
+
raise the setting.
|
|
84
|
+
|
|
85
|
+
``504 WRITE_TIMEOUT`` — the server stopped waiting for a write it cannot
|
|
86
|
+
cancel, so **the outcome is unknown and the write may still land**.
|
|
87
|
+
Retrying is safe: a point is identified by its series and its timestamp,
|
|
88
|
+
so writing it twice stores it once.
|
|
89
|
+
"""
|
chronix_client/models.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Data models for the Chronix Python client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FieldValue(Enum):
|
|
12
|
+
"""Discriminator for Chronix field types."""
|
|
13
|
+
|
|
14
|
+
FLOAT64 = "float64"
|
|
15
|
+
INT64 = "int64"
|
|
16
|
+
UINT64 = "uint64"
|
|
17
|
+
BOOL = "boolean"
|
|
18
|
+
STRING = "string"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class Point:
|
|
23
|
+
"""A single data point to write to Chronix.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
measurement : str
|
|
28
|
+
Measurement name.
|
|
29
|
+
tags : dict[str, str]
|
|
30
|
+
Tag key-value pairs (indexed, low cardinality).
|
|
31
|
+
fields : dict[str, int | float | bool | str]
|
|
32
|
+
Field key-value pairs (the actual data).
|
|
33
|
+
timestamp : int | None
|
|
34
|
+
Unix timestamp in nanoseconds. Defaults to current time.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
measurement: str
|
|
38
|
+
fields: dict[str, int | float | bool | str]
|
|
39
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
timestamp: int | None = None
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
"""Serialize to the JSON write request format."""
|
|
44
|
+
return {
|
|
45
|
+
"measurement": self.measurement,
|
|
46
|
+
"tags": self.tags,
|
|
47
|
+
"fields": self.fields,
|
|
48
|
+
"timestamp": self.timestamp or time.time_ns(),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def to_line_protocol(self) -> str:
|
|
52
|
+
"""Serialize to InfluxDB line protocol format."""
|
|
53
|
+
parts = [self.measurement]
|
|
54
|
+
|
|
55
|
+
# Tags (sorted by key for canonical ordering).
|
|
56
|
+
if self.tags:
|
|
57
|
+
tag_str = ",".join(
|
|
58
|
+
f"{_escape_tag(k)}={_escape_tag(v)}"
|
|
59
|
+
for k, v in sorted(self.tags.items())
|
|
60
|
+
)
|
|
61
|
+
parts[0] = f"{self.measurement},{tag_str}"
|
|
62
|
+
|
|
63
|
+
# Fields (sorted by key).
|
|
64
|
+
field_parts: list[str] = []
|
|
65
|
+
for k, v in sorted(self.fields.items()):
|
|
66
|
+
field_parts.append(f"{_escape_tag(k)}={_encode_field(v)}")
|
|
67
|
+
parts.append(",".join(field_parts))
|
|
68
|
+
|
|
69
|
+
# Timestamp.
|
|
70
|
+
ts = self.timestamp or time.time_ns()
|
|
71
|
+
parts.append(str(ts))
|
|
72
|
+
|
|
73
|
+
return " ".join(parts)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True, frozen=True)
|
|
77
|
+
class TimeRange:
|
|
78
|
+
"""Closed time range ``[start, end]`` in nanoseconds.
|
|
79
|
+
|
|
80
|
+
Both ends are **inclusive**, matching the server's ``TimeRangeRequest``.
|
|
81
|
+
This docstring said half-open for as long as it existed, which is a
|
|
82
|
+
one-nanosecond error on a query and a whole-boundary-sample error on a
|
|
83
|
+
delete.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
start: int
|
|
87
|
+
end: int
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, int]:
|
|
90
|
+
return {"start": self.start, "end": self.end}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(slots=True, frozen=True)
|
|
94
|
+
class DeleteResult:
|
|
95
|
+
"""Outcome of a predicate delete.
|
|
96
|
+
|
|
97
|
+
A delete can be **partial**: a segment the server could not open or read is
|
|
98
|
+
skipped rather than aborting the whole operation, so ``series_tombstoned``
|
|
99
|
+
on its own cannot distinguish "nothing matched" from "some data was never
|
|
100
|
+
scanned". Callers acting on an erasure obligation must check
|
|
101
|
+
:attr:`complete` and retry.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
series_tombstoned: int
|
|
105
|
+
segments_skipped: int
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def complete(self) -> bool:
|
|
109
|
+
"""Whether every matching segment was scanned."""
|
|
110
|
+
return self.segments_skipped == 0
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_dict(cls, data: dict[str, object]) -> "DeleteResult":
|
|
114
|
+
return cls(
|
|
115
|
+
series_tombstoned=int(data.get("deleted", 0) or 0),
|
|
116
|
+
segments_skipped=int(data.get("segments_skipped", 0) or 0),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(slots=True, frozen=True)
|
|
121
|
+
class ColumnSchema:
|
|
122
|
+
"""Schema for a single column in a measurement.
|
|
123
|
+
|
|
124
|
+
``data_type`` is ``None`` for timestamp and tag columns: the server omits
|
|
125
|
+
the key rather than sending null.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
name: str
|
|
129
|
+
role: str # "tag", "field", or "timestamp"
|
|
130
|
+
data_type: str | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(slots=True, frozen=True)
|
|
134
|
+
class MeasurementInfo:
|
|
135
|
+
"""Summary of a measurement."""
|
|
136
|
+
|
|
137
|
+
name: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(slots=True, frozen=True)
|
|
141
|
+
class ServerInfo:
|
|
142
|
+
"""Server metadata."""
|
|
143
|
+
|
|
144
|
+
version: str
|
|
145
|
+
uptime_seconds: float
|
|
146
|
+
measurement_count: int
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(slots=True)
|
|
150
|
+
class QueryResult:
|
|
151
|
+
"""Result of a query — a list of row dicts.
|
|
152
|
+
|
|
153
|
+
``truncated`` is ``True`` when the server's ``sql_max_rows`` cut the
|
|
154
|
+
answer short. It is not decoration: an aggregate computed over a
|
|
155
|
+
truncated scan is a **wrong** number, not a partial one, and before the
|
|
156
|
+
server reported this there was no way for a caller to tell the two apart.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
rows: list[dict[str, Any]]
|
|
160
|
+
truncated: bool = False
|
|
161
|
+
|
|
162
|
+
def __len__(self) -> int:
|
|
163
|
+
return len(self.rows)
|
|
164
|
+
|
|
165
|
+
def __iter__(self):
|
|
166
|
+
return iter(self.rows)
|
|
167
|
+
|
|
168
|
+
def to_dataframe(self):
|
|
169
|
+
"""Convert to a pandas DataFrame (requires ``pandas`` extra)."""
|
|
170
|
+
import pandas as pd # noqa: PLC0415 — lazy import
|
|
171
|
+
|
|
172
|
+
return pd.DataFrame(self.rows)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── helpers ──────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def _escape_tag(s: str) -> str:
|
|
178
|
+
"""Escape special characters for line protocol tags/keys."""
|
|
179
|
+
return s.replace("\\", "\\\\").replace(" ", "\\ ").replace(",", "\\,").replace("=", "\\=")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _encode_field(v: int | float | bool | str) -> str:
|
|
183
|
+
"""Encode a field value for line protocol."""
|
|
184
|
+
if isinstance(v, bool):
|
|
185
|
+
return "true" if v else "false"
|
|
186
|
+
if isinstance(v, int):
|
|
187
|
+
return f"{v}i"
|
|
188
|
+
if isinstance(v, float):
|
|
189
|
+
return repr(v)
|
|
190
|
+
if isinstance(v, str):
|
|
191
|
+
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
|
192
|
+
return f'"{escaped}"'
|
|
193
|
+
msg = f"unsupported field type: {type(v)}"
|
|
194
|
+
raise TypeError(msg)
|
chronix_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: chronix-client
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for Chronix time-series database
|
|
5
|
+
Author: Chronix Authors
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: chronix,client,database,timeseries
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: adbc-driver-flightsql>=1.0
|
|
21
|
+
Requires-Dist: httpx<1,>=0.27
|
|
22
|
+
Requires-Dist: pyarrow>=17
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
27
|
+
Provides-Extra: pandas
|
|
28
|
+
Requires-Dist: pandas>=2; extra == 'pandas'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# chronix-client (Python)
|
|
32
|
+
|
|
33
|
+
Async Python client for the [Chronix](https://github.com/hupe1980/chronix) time-series database.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Async-first** — built on `httpx` for non-blocking I/O
|
|
38
|
+
- **JSON + Line Protocol writes** — both formats supported
|
|
39
|
+
- **Structured queries + SQL** — first-class support for both query APIs
|
|
40
|
+
- **PromQL** — instant & range queries via Prometheus-compatible endpoints
|
|
41
|
+
- **Arrow Flight SQL** — zero-copy queries via ADBC driver
|
|
42
|
+
- **Schema introspection** — list measurements, get column schemas
|
|
43
|
+
- **Multi-tenant** — namespace header support
|
|
44
|
+
- **Typed** — full type annotations with `py.typed` marker
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install chronix-client
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
With pandas support:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install "chronix-client[pandas]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import asyncio
|
|
62
|
+
from chronix_client import ChronixClient, Point, TimeRange
|
|
63
|
+
|
|
64
|
+
async def main():
|
|
65
|
+
async with ChronixClient("http://localhost:5555") as client:
|
|
66
|
+
# Write
|
|
67
|
+
await client.write([
|
|
68
|
+
Point("cpu", {"usage": 42.5}, tags={"host": "a"})
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
# Query
|
|
72
|
+
result = await client.query(
|
|
73
|
+
"cpu",
|
|
74
|
+
TimeRange(start=0, end=2**63 - 1),
|
|
75
|
+
tag_filters={"host": "a"},
|
|
76
|
+
)
|
|
77
|
+
for row in result:
|
|
78
|
+
print(row)
|
|
79
|
+
|
|
80
|
+
# SQL
|
|
81
|
+
result = await client.sql("SELECT * FROM cpu ORDER BY _time DESC LIMIT 10")
|
|
82
|
+
df = result.to_dataframe() # requires pandas extra
|
|
83
|
+
|
|
84
|
+
asyncio.run(main())
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Line Protocol Writes
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
await client.write_line_protocol([
|
|
91
|
+
"cpu,host=a usage=42.5 1700000000000000000",
|
|
92
|
+
"mem,host=a total=16384i,used=8192i 1700000000000000000",
|
|
93
|
+
])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## PromQL Queries
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# Instant query
|
|
100
|
+
result = await client.prom_query('cpu_usage{host="server-1"}')
|
|
101
|
+
|
|
102
|
+
# Range query
|
|
103
|
+
result = await client.prom_query_range(
|
|
104
|
+
'rate(cpu_usage[5m])',
|
|
105
|
+
start="2024-01-01T00:00:00Z",
|
|
106
|
+
end="2024-01-02T00:00:00Z",
|
|
107
|
+
step="60s",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Label discovery
|
|
111
|
+
labels = await client.prom_labels()
|
|
112
|
+
values = await client.prom_label_values("host")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Arrow Flight SQL (Zero-Copy)
|
|
116
|
+
|
|
117
|
+
For high-throughput analytical queries, use Arrow Flight SQL via ADBC:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import adbc_driver_flightsql.dbapi
|
|
121
|
+
|
|
122
|
+
uri = ChronixClient.flight_sql_uri("localhost", 5557)
|
|
123
|
+
conn = adbc_driver_flightsql.dbapi.connect(uri)
|
|
124
|
+
cursor = conn.cursor()
|
|
125
|
+
cursor.execute("SELECT * FROM cpu WHERE _time > now() - INTERVAL '1 hour'")
|
|
126
|
+
table = cursor.fetch_arrow_table()
|
|
127
|
+
df = table.to_pandas()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Authentication & Multi-Tenancy
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
client = ChronixClient(
|
|
134
|
+
"http://localhost:5555",
|
|
135
|
+
api_key="your-api-key",
|
|
136
|
+
namespace="production",
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Idempotent Writes
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
await client.write(
|
|
144
|
+
[Point("cpu", {"usage": 42.5})],
|
|
145
|
+
idempotency_key="batch-2024-01-15-001",
|
|
146
|
+
)
|
|
147
|
+
# Second call with same key → WriteError (HTTP 409)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## API Reference
|
|
151
|
+
|
|
152
|
+
### `ChronixClient`
|
|
153
|
+
|
|
154
|
+
| Method | Description |
|
|
155
|
+
|--------|-------------|
|
|
156
|
+
| `health()` | Health check |
|
|
157
|
+
| `ready()` | Readiness check |
|
|
158
|
+
| `server_info()` | Server metadata |
|
|
159
|
+
| `write(points, *, idempotency_key)` | Write points (JSON) |
|
|
160
|
+
| `write_line_protocol(lines, *, idempotency_key)` | Write (line protocol) |
|
|
161
|
+
| `query(measurement, time_range, *, tag_filters, field_columns, limit)` | Structured query |
|
|
162
|
+
| `sql(query)` | SQL query |
|
|
163
|
+
| `explain(measurement, time_range)` | Explain query plan |
|
|
164
|
+
| `list_measurements()` | List measurements |
|
|
165
|
+
| `get_schema(measurement)` | Get column schema |
|
|
166
|
+
| `drop_measurement(measurement)` | Drop measurement |
|
|
167
|
+
| `delete(measurement, time_range=None, *, tags)` | Delete data; returns a `DeleteResult` — check `.complete` |
|
|
168
|
+
| `prom_query(query, *, time)` | PromQL instant query |
|
|
169
|
+
| `prom_query_range(query, start, end, step)` | PromQL range query |
|
|
170
|
+
| `prom_labels()` | List Prometheus labels |
|
|
171
|
+
| `prom_label_values(label)` | Label values |
|
|
172
|
+
| `prom_series(match)` | Find series |
|
|
173
|
+
| `list_rollups()` | List rollup rules |
|
|
174
|
+
| `list_connectors()` | List connectors |
|
|
175
|
+
| `openapi_spec()` | Fetch OpenAPI spec |
|
|
176
|
+
| `flight_sql_uri(host, port)` | Build Flight SQL URI |
|
|
177
|
+
|
|
178
|
+
### Models
|
|
179
|
+
|
|
180
|
+
| Type | Description |
|
|
181
|
+
|------|-------------|
|
|
182
|
+
| `Point` | Data point with measurement, tags, fields, timestamp |
|
|
183
|
+
| `TimeRange` | Half-open `[start, end)` in nanoseconds |
|
|
184
|
+
| `QueryResult` | Query result with `.rows`, `.to_dataframe()` |
|
|
185
|
+
| `ColumnSchema` | Column metadata (name, role, data_type) |
|
|
186
|
+
| `MeasurementInfo` | Measurement summary (name) |
|
|
187
|
+
| `ServerInfo` | Server metadata (version, uptime, measurement_count) |
|
|
188
|
+
|
|
189
|
+
### Exceptions
|
|
190
|
+
|
|
191
|
+
| Exception | When |
|
|
192
|
+
|-----------|------|
|
|
193
|
+
| `ChronixError` | Base exception (server errors) |
|
|
194
|
+
| `ConnectionError` | Cannot reach server |
|
|
195
|
+
| `WriteError` | Write failure (including idempotency conflict) |
|
|
196
|
+
| `QueryError` | Query/client error (4xx) |
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
Apache-2.0 — same as Chronix.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
chronix_client/__init__.py,sha256=lp7eqUy8erKHWXcWzzZSh2AhXvTN3x93uPrBkfPwnKY,794
|
|
2
|
+
chronix_client/client.py,sha256=jNx4WfD3GBz7qCVMA-b5M0x5FdGQwa8KijFE3zg1JpA,19426
|
|
3
|
+
chronix_client/exceptions.py,sha256=DD3y7mJEwyPfd7f6pQdidbOzidrTJU5Bn47IXWjGRlg,3267
|
|
4
|
+
chronix_client/models.py,sha256=iA2ptCU1WGw2WyXRYO2qiudYGPTTm9qxwW7QNZ_mZbk,5646
|
|
5
|
+
chronix_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
chronix_client-0.2.0.dist-info/METADATA,sha256=GJleEy4oBnjOc0l75Cf5AZWYXrBZ449kwmHFaOeyo8w,5887
|
|
7
|
+
chronix_client-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
chronix_client-0.2.0.dist-info/RECORD,,
|