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.
@@ -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"
@@ -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
+ """
@@ -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)
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any