Skip to content

Commit f8a0805

Browse files
TheTrueAICopilot
andauthored
feat: Add CLI (#208)
* fix: normalize PEM line endings in certificate reading * feat: add CLI for querying InfluxDB 3 Add a new query CLI to support quick read/debug workflows from terminal and AI agents. - add influx3 query with json, jsonl, csv, and pretty output - add module execution path via python -m influxdb_client_3 - wire console entry point in setup - add CLI tests * feat: enhance CLI output formatting and add error handling for query timeout * fix: handle nanosecond timestamps in CLI ns timestamps are now truncated to µs. This enables querying DBs with ns precision. If ns precision is needed as output, the direct Python client is the better option. Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com>
1 parent a1e03da commit f8a0805

8 files changed

Lines changed: 567 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## 0.20.0 [unreleased]
44

5+
### Features
6+
7+
1. [#208](https://github.com/InfluxCommunity/influxdb3-python/pull/208): Add `influx3 query` CLI support for executing SQL/InfluxQL queries with JSON/JSONL/CSV/pretty output, including module execution via `python -m influxdb_client_3`.
8+
9+
### Bug Fixes
10+
11+
1. [#208](https://github.com/InfluxCommunity/influxdb3-python/pull/208): Normalize PEM certificate line endings when loading Flight query root certificates to ensure consistent SSL option behavior on Windows.
12+
513
## 0.19.0 [2026-04-23]
614

715
### Features

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,44 @@ Note: This does not include Pandas support. If you would like to use key feature
5151

5252
*Note: Please make sure you are using 3.9 or above. For the best performance use 3.11+*
5353

54+
## CLI (Agent-Friendly Query Tool)
55+
56+
This package includes an `influx3` CLI for read/query workflows.
57+
58+
### Run a query
59+
60+
```bash
61+
influx3 query -d my_database "SELECT * FROM cpu LIMIT 5"
62+
```
63+
64+
By default, output is JSON to stdout.
65+
66+
### Supported formats
67+
68+
- `json` (default)
69+
- `jsonl`
70+
- `csv`
71+
- `pretty`
72+
73+
```bash
74+
influx3 query -d my_database --format csv "SELECT * FROM cpu LIMIT 5"
75+
```
76+
77+
### Config precedence
78+
79+
Configuration values are resolved in this order:
80+
81+
1. CLI flags
82+
2. `INFLUXDB3_*` environment variables
83+
3. legacy `INFLUX_*` environment variables
84+
4. built-in defaults (host defaults to `http://127.0.0.1:8181`)
85+
86+
Relevant environment variables:
87+
88+
- `INFLUXDB3_HOST_URL` (legacy fallback: `INFLUX_HOST`)
89+
- `INFLUXDB3_DATABASE_NAME` (legacy fallback: `INFLUX_DATABASE`)
90+
- `INFLUXDB3_AUTH_TOKEN` (legacy fallback: `INFLUX_TOKEN`)
91+
5492
# Usage
5593
One of the easiest ways to get started is to check out the ["Influxdb3 Python Basic Usage"](https://github.com/InfluxCommunity/influxdb3-python/blob/main/examples/jupyter/basic-write-query.ipynb) notebook. This scenario takes you through the core write and read APIs of the client library.
5694

influxdb_client_3/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from influxdb_client_3.cli import main
2+
3+
4+
if __name__ == "__main__":
5+
raise SystemExit(main())

influxdb_client_3/cli.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import argparse
2+
import csv
3+
import io
4+
import json
5+
import os
6+
import sys
7+
from typing import Mapping, Optional
8+
9+
import pyarrow as pa
10+
11+
from influxdb_client_3 import (
12+
INFLUX_DATABASE,
13+
INFLUX_HOST,
14+
INFLUX_TOKEN,
15+
InfluxDBClient3,
16+
)
17+
from influxdb_client_3.exceptions import InfluxDB3ClientQueryError, InfluxDBError
18+
19+
20+
def _resolve_option(
21+
cli_value: Optional[str],
22+
env: Mapping[str, str],
23+
primary_env: str,
24+
secondary_env: Optional[str] = None,
25+
default: Optional[str] = None,
26+
) -> Optional[str]:
27+
if cli_value is not None:
28+
return cli_value
29+
30+
for var in (primary_env, secondary_env):
31+
if not var:
32+
continue
33+
value = env.get(var)
34+
if value not in (None, ""):
35+
return value
36+
37+
return default
38+
39+
40+
def _rows_to_csv(rows, fieldnames):
41+
buff = io.StringIO()
42+
writer = csv.DictWriter(buff, fieldnames=fieldnames)
43+
writer.writeheader()
44+
for row in rows:
45+
writer.writerow(row)
46+
return buff.getvalue()
47+
48+
49+
def _rows_to_pretty(rows, fieldnames):
50+
if not rows:
51+
return "(0 rows)"
52+
53+
widths = {name: len(name) for name in fieldnames}
54+
for row in rows:
55+
for name in fieldnames:
56+
widths[name] = max(widths[name], len(str(row.get(name, ""))))
57+
58+
header = " | ".join(name.ljust(widths[name]) for name in fieldnames)
59+
sep = "-+-".join("-" * widths[name] for name in fieldnames)
60+
lines = [header, sep]
61+
for row in rows:
62+
lines.append(" | ".join(str(row.get(name, "")).ljust(widths[name]) for name in fieldnames))
63+
return "\n".join(lines)
64+
65+
66+
def _rows_to_json(rows, fieldnames):
67+
return json.dumps(rows, default=str)
68+
69+
70+
def _rows_to_jsonl(rows, fieldnames):
71+
if not rows:
72+
return ""
73+
return "\n".join(json.dumps(row, default=str) for row in rows)
74+
75+
76+
_FORMATTERS = {
77+
"json": _rows_to_json,
78+
"jsonl": _rows_to_jsonl,
79+
"csv": _rows_to_csv,
80+
"pretty": _rows_to_pretty,
81+
}
82+
83+
84+
def _is_ns_timestamp(field_type) -> bool:
85+
return pa.types.is_timestamp(field_type) and field_type.unit == "ns"
86+
87+
88+
def _coerce_timestamps(table: pa.Table) -> tuple[pa.Table, bool]:
89+
# Python datetime only supports microsecond precision, so to_pylist truncates ns
90+
# timestamps anyway; cast explicitly with safe=False to acknowledge the precision loss.
91+
if not any(_is_ns_timestamp(field.type) for field in table.schema):
92+
return table, False
93+
new_fields = [
94+
pa.field(field.name, pa.timestamp("us", tz=field.type.tz))
95+
if _is_ns_timestamp(field.type)
96+
else field
97+
for field in table.schema
98+
]
99+
return table.cast(pa.schema(new_fields), safe=False), True
100+
101+
102+
def _format_table(table: pa.Table, output_format: str, stderr=None) -> str:
103+
table, coerced = _coerce_timestamps(table)
104+
if coerced and stderr is not None:
105+
stderr.write(
106+
"Warning: nanosecond precision timestamps truncated to microseconds for display.\n"
107+
)
108+
rows = table.to_pylist()
109+
fieldnames = table.schema.names
110+
return _FORMATTERS[output_format](rows, fieldnames)
111+
112+
113+
def _ensure_trailing_nl(text: str) -> str:
114+
if not text:
115+
return ""
116+
return text if text.endswith("\n") else text + "\n"
117+
118+
119+
def _write_error(stderr, message: str):
120+
stderr.write(json.dumps({"error": str(message)}) + "\n")
121+
122+
123+
def build_parser() -> argparse.ArgumentParser:
124+
parser = argparse.ArgumentParser(prog="influx3", description="InfluxDB 3 query CLI")
125+
subparsers = parser.add_subparsers(dest="command", required=True)
126+
127+
query_parser = subparsers.add_parser("query", aliases=["q"], help="Run a SQL or InfluxQL query")
128+
query_parser.add_argument("query", nargs="?", help="The query string to execute")
129+
query_parser.add_argument("-f", "--file", dest="file_path", help="File containing the query")
130+
query_parser.add_argument("-H", "--host", dest="host", help="InfluxDB host URL")
131+
query_parser.add_argument("-d", "--database", dest="database", help="Database name")
132+
query_parser.add_argument("--token", dest="token", help="Authentication token")
133+
query_parser.add_argument(
134+
"-l",
135+
"--language",
136+
dest="language",
137+
choices=["sql", "influxql"],
138+
default="sql",
139+
help="Query language",
140+
)
141+
query_parser.add_argument(
142+
"--format",
143+
dest="output_format",
144+
choices=list(_FORMATTERS),
145+
default="json",
146+
help="Output format",
147+
)
148+
query_parser.add_argument("-o", "--output", dest="output_file_path", help="Write output to file")
149+
query_parser.add_argument("--query-timeout", dest="query_timeout", type=int, help="Query timeout in ms")
150+
query_parser.set_defaults(func=_run_query)
151+
return parser
152+
153+
154+
def _run_query(args, stdout, stderr, env: Optional[Mapping[str, str]] = None) -> int:
155+
if env is None:
156+
env = os.environ
157+
158+
host = _resolve_option(args.host, env, "INFLUXDB3_HOST_URL", INFLUX_HOST, "http://127.0.0.1:8181")
159+
database = _resolve_option(args.database, env, "INFLUXDB3_DATABASE_NAME", INFLUX_DATABASE)
160+
token = _resolve_option(args.token, env, "INFLUXDB3_AUTH_TOKEN", INFLUX_TOKEN)
161+
162+
if (args.query is None) == (args.file_path is None):
163+
_write_error(stderr, "Provide exactly one of query or --file.")
164+
return 1
165+
166+
if not database:
167+
_write_error(stderr, "Database is required. Set --database or INFLUXDB3_DATABASE_NAME.")
168+
return 1
169+
170+
if args.query_timeout is not None and args.query_timeout < 0:
171+
_write_error(stderr, "--query-timeout must be non-negative.")
172+
return 1
173+
174+
try:
175+
query = args.query
176+
if args.file_path:
177+
with open(args.file_path, "r", encoding="utf-8") as file_handle:
178+
query = file_handle.read()
179+
180+
query_kwargs = {}
181+
if args.query_timeout is not None:
182+
query_kwargs["query_timeout"] = args.query_timeout
183+
184+
with InfluxDBClient3(host=host, database=database, token=token, **query_kwargs) as client:
185+
table = client.query(
186+
query=query,
187+
language=args.language,
188+
mode="all",
189+
database=database,
190+
)
191+
192+
payload = _ensure_trailing_nl(_format_table(table, args.output_format, stderr=stderr))
193+
if args.output_file_path:
194+
with open(args.output_file_path, "w", encoding="utf-8", newline="") as file_handle:
195+
file_handle.write(payload)
196+
else:
197+
stdout.write(payload)
198+
return 0
199+
except (InfluxDB3ClientQueryError, InfluxDBError, OSError, pa.ArrowException) as error:
200+
_write_error(stderr, str(error))
201+
return 1
202+
203+
204+
def main(argv=None) -> int:
205+
parser = build_parser()
206+
args = parser.parse_args(argv)
207+
return args.func(args, sys.stdout, sys.stderr)
208+
209+
210+
if __name__ == "__main__":
211+
raise SystemExit(main())

influxdb_client_3/query/query_api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ def __init__(self, root_certs_path: str,
6161

6262
def _read_certs(self, path: str) -> bytes:
6363
with open(path, "rb") as certs_file:
64-
return certs_file.read()
64+
certs = certs_file.read()
65+
# Normalize PEM line endings so behavior is stable across platforms.
66+
return certs.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
6567

6668

6769
class QueryApiOptionsBuilder(object):

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ def get_version():
6060
]
6161
},
6262
install_requires=requires,
63+
entry_points={
64+
'console_scripts': ['influx3 = influxdb_client_3.cli:main'],
65+
},
6366
python_requires='>=3.9',
6467
classifiers=[
6568
'Development Status :: 4 - Beta',

0 commit comments

Comments
 (0)