qod-cli 0.3.7__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.
- qod_cli/__init__.py +1 -0
- qod_cli/commands/__init__.py +0 -0
- qod_cli/commands/_run.py +35 -0
- qod_cli/commands/auth.py +67 -0
- qod_cli/commands/catalog.py +173 -0
- qod_cli/commands/config_cmd.py +17 -0
- qod_cli/commands/database.py +69 -0
- qod_cli/commands/federation.py +82 -0
- qod_cli/commands/group.py +28 -0
- qod_cli/commands/maintenance.py +81 -0
- qod_cli/commands/manifest.py +34 -0
- qod_cli/commands/membership.py +50 -0
- qod_cli/commands/node.py +58 -0
- qod_cli/commands/pool.py +137 -0
- qod_cli/commands/role.py +150 -0
- qod_cli/commands/sql_cmd.py +86 -0
- qod_cli/commands/tag.py +47 -0
- qod_cli/commands/telemetry.py +95 -0
- qod_cli/commands/tenant.py +61 -0
- qod_cli/commands/user.py +62 -0
- qod_cli/config.py +107 -0
- qod_cli/main.py +96 -0
- qod_cli/output.py +66 -0
- qod_cli/rest.py +73 -0
- qod_cli/sql.py +80 -0
- qod_cli-0.3.7.dist-info/METADATA +17 -0
- qod_cli-0.3.7.dist-info/RECORD +29 -0
- qod_cli-0.3.7.dist-info/WHEEL +4 -0
- qod_cli-0.3.7.dist-info/entry_points.txt +2 -0
qod_cli/commands/user.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from ._run import call
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(help="User principals.")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.command("list")
|
|
9
|
+
def list_(ctx: typer.Context, tenant: str = typer.Option(None, "--tenant", help="Omit = all users incl. superusers.")):
|
|
10
|
+
call(ctx, "GET", "/api/user/list", params={"tenant": tenant})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def create(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
username: str = typer.Option(..., "--username"),
|
|
17
|
+
tenant: str = typer.Option(None, "--tenant"),
|
|
18
|
+
superuser: bool = typer.Option(False, "--superuser", help="Create a superuser (no tenant)."),
|
|
19
|
+
password: str = typer.Option(None, "--password", help="Prompted when omitted."),
|
|
20
|
+
role: str = typer.Option("user", "--role"),
|
|
21
|
+
):
|
|
22
|
+
if superuser and tenant:
|
|
23
|
+
raise typer.BadParameter("--superuser and --tenant are mutually exclusive")
|
|
24
|
+
if not superuser and not tenant:
|
|
25
|
+
raise typer.BadParameter("pass --tenant, or --superuser for a tenant-less superuser")
|
|
26
|
+
if password is None:
|
|
27
|
+
password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
|
|
28
|
+
call(
|
|
29
|
+
ctx,
|
|
30
|
+
"POST",
|
|
31
|
+
"/api/user/create",
|
|
32
|
+
body={"tenant": None if superuser else tenant, "username": username, "password": password, "role": role},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def update(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
user_id: str = typer.Argument(..., metavar="ID"),
|
|
40
|
+
tenant: str = typer.Option(None, "--tenant"),
|
|
41
|
+
password: str = typer.Option(None, "--password", help="Omit = no rotation."),
|
|
42
|
+
role: str = typer.Option(None, "--role"),
|
|
43
|
+
):
|
|
44
|
+
body: dict = {"id": user_id}
|
|
45
|
+
if tenant is not None:
|
|
46
|
+
body["tenant"] = tenant
|
|
47
|
+
if password is not None:
|
|
48
|
+
body["password"] = password
|
|
49
|
+
if role is not None:
|
|
50
|
+
body["role"] = role
|
|
51
|
+
call(ctx, "POST", "/api/user/update", body=body)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def delete(ctx: typer.Context, user_id: str = typer.Argument(..., metavar="ID")):
|
|
56
|
+
call(ctx, "POST", "/api/user/delete", body={"id": user_id})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def effective(ctx: typer.Context, user_id: str = typer.Argument(..., metavar="ID")):
|
|
61
|
+
"""Closure of roles, groups, table permissions, and pool grants."""
|
|
62
|
+
call(ctx, "GET", f"/api/user/{user_id}/effective")
|
qod_cli/config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Profile file + env + flag precedence.
|
|
2
|
+
|
|
3
|
+
Resolution order per setting: explicit override (command flag) > QOD_* env
|
|
4
|
+
var > profile file > built-in default. The profile file is TOML at the
|
|
5
|
+
platform config dir (QOD_CONFIG_FILE overrides the full path) and is written
|
|
6
|
+
with mode 0600 because it can hold a session token and an opt-in SQL password.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, fields
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import platformdirs
|
|
17
|
+
import tomli_w
|
|
18
|
+
|
|
19
|
+
if sys.version_info >= (3, 11):
|
|
20
|
+
import tomllib
|
|
21
|
+
else:
|
|
22
|
+
import tomli as tomllib
|
|
23
|
+
|
|
24
|
+
ENV_VARS = {
|
|
25
|
+
"manager_url": "QOD_MANAGER_URL",
|
|
26
|
+
"api_key": "QOD_API_KEY",
|
|
27
|
+
"token": "QOD_TOKEN",
|
|
28
|
+
"edge_host": "QOD_HOST",
|
|
29
|
+
"edge_port": "QOD_PORT",
|
|
30
|
+
"edge_tls": "QOD_TLS",
|
|
31
|
+
"edge_tls_verify": "QOD_TLS_VERIFY",
|
|
32
|
+
"tenant": "QOD_TENANT",
|
|
33
|
+
"pool": "QOD_POOL",
|
|
34
|
+
"sql_user": "QOD_USER",
|
|
35
|
+
"sql_password": "QOD_PASSWORD",
|
|
36
|
+
"superuser": "QOD_SUPERUSER",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Settings:
|
|
42
|
+
manager_url: str = "http://localhost:20900"
|
|
43
|
+
api_key: str = ""
|
|
44
|
+
token: str = ""
|
|
45
|
+
edge_host: str = "localhost"
|
|
46
|
+
edge_port: int = 31338
|
|
47
|
+
edge_tls: bool = True
|
|
48
|
+
edge_tls_verify: bool = False
|
|
49
|
+
tenant: str = ""
|
|
50
|
+
pool: str = ""
|
|
51
|
+
sql_user: str = ""
|
|
52
|
+
sql_password: str = ""
|
|
53
|
+
superuser: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def config_path() -> Path:
|
|
57
|
+
env = os.environ.get("QOD_CONFIG_FILE")
|
|
58
|
+
if env:
|
|
59
|
+
return Path(env)
|
|
60
|
+
return Path(platformdirs.user_config_dir("qod", appauthor=False, roaming=True)) / "config.toml"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_file() -> dict:
|
|
64
|
+
path = config_path()
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return {}
|
|
67
|
+
with path.open("rb") as f:
|
|
68
|
+
return tomllib.load(f)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _coerce(name: str, kind: type, raw) -> object:
|
|
72
|
+
if kind is bool:
|
|
73
|
+
if isinstance(raw, bool):
|
|
74
|
+
return raw
|
|
75
|
+
return str(raw).strip().lower() in ("true", "1", "yes")
|
|
76
|
+
if kind is int:
|
|
77
|
+
return int(raw)
|
|
78
|
+
return str(raw)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_settings(profile: str = "default", overrides: dict | None = None) -> Settings:
|
|
82
|
+
file_values = _read_file().get("profiles", {}).get(profile, {})
|
|
83
|
+
overrides = overrides or {}
|
|
84
|
+
values: dict = {}
|
|
85
|
+
for f in fields(Settings):
|
|
86
|
+
if f.name in overrides and overrides[f.name] is not None:
|
|
87
|
+
raw = overrides[f.name]
|
|
88
|
+
elif os.environ.get(ENV_VARS[f.name]) is not None:
|
|
89
|
+
raw = os.environ[ENV_VARS[f.name]]
|
|
90
|
+
elif f.name in file_values:
|
|
91
|
+
raw = file_values[f.name]
|
|
92
|
+
else:
|
|
93
|
+
continue
|
|
94
|
+
values[f.name] = _coerce(f.name, f.type if isinstance(f.type, type) else type(f.default), raw)
|
|
95
|
+
return Settings(**values)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_profile(profile: str, values: dict) -> None:
|
|
99
|
+
data = _read_file()
|
|
100
|
+
profiles = data.setdefault("profiles", {})
|
|
101
|
+
current = profiles.setdefault(profile, {})
|
|
102
|
+
current.update({k: v for k, v in values.items() if v is not None})
|
|
103
|
+
path = config_path()
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
with path.open("wb") as f:
|
|
106
|
+
tomli_w.dump(data, f)
|
|
107
|
+
os.chmod(path, 0o600)
|
qod_cli/main.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .config import Settings, load_settings
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False, help="quack-on-demand CLI")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AppCtx:
|
|
13
|
+
settings: Settings
|
|
14
|
+
json_output: bool
|
|
15
|
+
profile: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _version_callback(value: bool) -> None:
|
|
19
|
+
if value:
|
|
20
|
+
typer.echo(f"qod {__version__}")
|
|
21
|
+
raise typer.Exit()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback()
|
|
25
|
+
def _root(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
profile: str = typer.Option("default", "--profile", envvar="QOD_PROFILE", help="Named profile."),
|
|
28
|
+
json_output: bool = typer.Option(False, "--json", help="Print raw JSON responses."),
|
|
29
|
+
version: bool = typer.Option(
|
|
30
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Print version and exit."
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
ctx.obj = AppCtx(settings=load_settings(profile), json_output=json_output, profile=profile)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def health(ctx: typer.Context):
|
|
38
|
+
"""Liveness plus pool/node counts (open endpoint)."""
|
|
39
|
+
from .commands._run import call
|
|
40
|
+
|
|
41
|
+
call(ctx, "GET", "/health")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
from .commands import config_cmd # noqa: E402
|
|
45
|
+
|
|
46
|
+
app.add_typer(config_cmd.app, name="config")
|
|
47
|
+
|
|
48
|
+
from .commands import auth # noqa: E402
|
|
49
|
+
|
|
50
|
+
app.command()(auth.login)
|
|
51
|
+
app.command()(auth.logout)
|
|
52
|
+
app.command()(auth.whoami)
|
|
53
|
+
app.add_typer(auth.app, name="auth")
|
|
54
|
+
|
|
55
|
+
from .commands import database, tenant # noqa: E402
|
|
56
|
+
|
|
57
|
+
app.add_typer(tenant.app, name="tenant")
|
|
58
|
+
app.add_typer(database.app, name="database")
|
|
59
|
+
|
|
60
|
+
from .commands import node, pool # noqa: E402
|
|
61
|
+
|
|
62
|
+
app.add_typer(pool.app, name="pool")
|
|
63
|
+
app.add_typer(node.app, name="node")
|
|
64
|
+
app.add_typer(node.statement_app, name="statement")
|
|
65
|
+
|
|
66
|
+
from .commands import group, membership, role, user # noqa: E402
|
|
67
|
+
|
|
68
|
+
app.add_typer(user.app, name="user")
|
|
69
|
+
app.add_typer(role.app, name="role")
|
|
70
|
+
app.add_typer(group.app, name="group")
|
|
71
|
+
app.add_typer(membership.app, name="membership")
|
|
72
|
+
|
|
73
|
+
from .commands import catalog, tag # noqa: E402
|
|
74
|
+
|
|
75
|
+
app.add_typer(catalog.app, name="catalog")
|
|
76
|
+
app.add_typer(tag.app, name="tag")
|
|
77
|
+
|
|
78
|
+
from .commands import federation, maintenance, manifest # noqa: E402
|
|
79
|
+
|
|
80
|
+
app.add_typer(maintenance.app, name="maintenance")
|
|
81
|
+
app.add_typer(manifest.app, name="manifest")
|
|
82
|
+
app.add_typer(federation.app, name="federation")
|
|
83
|
+
|
|
84
|
+
from .commands import telemetry # noqa: E402
|
|
85
|
+
|
|
86
|
+
app.add_typer(telemetry.audit_app, name="audit")
|
|
87
|
+
app.add_typer(telemetry.history_app, name="history")
|
|
88
|
+
app.command()(telemetry.usage)
|
|
89
|
+
|
|
90
|
+
from .commands import sql_cmd # noqa: E402
|
|
91
|
+
|
|
92
|
+
app.command("sql")(sql_cmd.sql)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> None:
|
|
96
|
+
app()
|
qod_cli/output.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Human-readable Rich rendering, or raw JSON with --json (the stable
|
|
2
|
+
scripting interface)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
_console = Console(soft_wrap=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _cell(value: Any) -> str:
|
|
16
|
+
if value is None:
|
|
17
|
+
return ""
|
|
18
|
+
if isinstance(value, (dict, list)):
|
|
19
|
+
return json.dumps(value, default=str)
|
|
20
|
+
return str(value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _render_rows(rows: list[dict]) -> None:
|
|
24
|
+
columns: list[str] = []
|
|
25
|
+
for row in rows:
|
|
26
|
+
for key in row:
|
|
27
|
+
if key not in columns:
|
|
28
|
+
columns.append(key)
|
|
29
|
+
table = Table(show_header=True, header_style="bold")
|
|
30
|
+
for col in columns:
|
|
31
|
+
table.add_column(col)
|
|
32
|
+
for row in rows:
|
|
33
|
+
table.add_row(*(_cell(row.get(col)) for col in columns))
|
|
34
|
+
_console.print(table)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render(data: Any, as_json: bool) -> None:
|
|
38
|
+
if as_json:
|
|
39
|
+
print(json.dumps(data, indent=2, default=str))
|
|
40
|
+
return
|
|
41
|
+
if data is None:
|
|
42
|
+
print("ok")
|
|
43
|
+
return
|
|
44
|
+
if isinstance(data, str):
|
|
45
|
+
print(data)
|
|
46
|
+
return
|
|
47
|
+
if isinstance(data, dict) and len(data) == 1:
|
|
48
|
+
(only_value,) = data.values()
|
|
49
|
+
if isinstance(only_value, list):
|
|
50
|
+
data = only_value
|
|
51
|
+
if isinstance(data, list):
|
|
52
|
+
if data and all(isinstance(item, dict) for item in data):
|
|
53
|
+
_render_rows(data)
|
|
54
|
+
else:
|
|
55
|
+
for item in data:
|
|
56
|
+
print(_cell(item))
|
|
57
|
+
return
|
|
58
|
+
if isinstance(data, dict):
|
|
59
|
+
table = Table(show_header=False)
|
|
60
|
+
table.add_column("key", style="bold")
|
|
61
|
+
table.add_column("value")
|
|
62
|
+
for key, value in data.items():
|
|
63
|
+
table.add_row(key, _cell(value))
|
|
64
|
+
_console.print(table)
|
|
65
|
+
return
|
|
66
|
+
print(_cell(data))
|
qod_cli/rest.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""One httpx client for the manager REST plane.
|
|
2
|
+
|
|
3
|
+
Auth: X-API-Key carries either the static api_key or the session JWT written
|
|
4
|
+
by `qod login` (api_key wins when both are set, matching apiKeyGuard which
|
|
5
|
+
accepts either).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .config import Settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiError(Exception):
|
|
18
|
+
def __init__(self, status: int, error: str, message: str):
|
|
19
|
+
self.status = status
|
|
20
|
+
self.error = error
|
|
21
|
+
self.message = message
|
|
22
|
+
super().__init__(f"{status} {error}: {message}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RestClient:
|
|
26
|
+
def __init__(self, settings: Settings):
|
|
27
|
+
self._settings = settings
|
|
28
|
+
|
|
29
|
+
def request(
|
|
30
|
+
self,
|
|
31
|
+
method: str,
|
|
32
|
+
path: str,
|
|
33
|
+
params: dict | None = None,
|
|
34
|
+
body: Any | None = None,
|
|
35
|
+
text: bool = False,
|
|
36
|
+
) -> Any:
|
|
37
|
+
settings = self._settings
|
|
38
|
+
headers = {}
|
|
39
|
+
key = settings.api_key or settings.token
|
|
40
|
+
if key:
|
|
41
|
+
headers["X-API-Key"] = key
|
|
42
|
+
query = {}
|
|
43
|
+
for name, value in (params or {}).items():
|
|
44
|
+
if value is None:
|
|
45
|
+
continue
|
|
46
|
+
query[name] = "true" if value is True else "false" if value is False else str(value)
|
|
47
|
+
kwargs: dict = {"params": query, "headers": headers, "timeout": 30.0}
|
|
48
|
+
if isinstance(body, str):
|
|
49
|
+
kwargs["content"] = body.encode()
|
|
50
|
+
headers["Content-Type"] = "text/plain"
|
|
51
|
+
elif body is not None:
|
|
52
|
+
kwargs["json"] = body
|
|
53
|
+
try:
|
|
54
|
+
response = httpx.request(method, settings.manager_url + path, **kwargs)
|
|
55
|
+
except httpx.HTTPError as exc:
|
|
56
|
+
raise ApiError(0, "connection_error", f"{settings.manager_url}: {exc}")
|
|
57
|
+
if response.is_success:
|
|
58
|
+
if text:
|
|
59
|
+
return response.text
|
|
60
|
+
if not response.content:
|
|
61
|
+
return None
|
|
62
|
+
return response.json()
|
|
63
|
+
try:
|
|
64
|
+
payload = response.json()
|
|
65
|
+
if isinstance(payload, dict):
|
|
66
|
+
error, message = payload.get("error", "error"), payload.get("message", response.text)
|
|
67
|
+
else:
|
|
68
|
+
error, message = "error", response.text
|
|
69
|
+
except ValueError:
|
|
70
|
+
error, message = "error", response.text or response.reason_phrase
|
|
71
|
+
if response.status_code == 401 and not settings.api_key:
|
|
72
|
+
message = "session expired or invalid, run qod login"
|
|
73
|
+
raise ApiError(response.status_code, error, message)
|
qod_cli/sql.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""FlightSQL plane over ADBC, adapted from examples/python/client.py.
|
|
2
|
+
|
|
3
|
+
pyarrow and the ADBC driver import lazily inside functions so `qod tenant
|
|
4
|
+
list` never pays their import cost.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .config import Settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SqlClient:
|
|
18
|
+
def __init__(self, conn):
|
|
19
|
+
self._conn = conn
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def connect(cls, settings: Settings) -> "SqlClient":
|
|
23
|
+
import adbc_driver_flightsql.dbapi as flight_sql
|
|
24
|
+
from adbc_driver_flightsql import DatabaseOptions
|
|
25
|
+
|
|
26
|
+
scheme = "grpc+tls" if settings.edge_tls else "grpc"
|
|
27
|
+
uri = f"{scheme}://{settings.edge_host}:{settings.edge_port}"
|
|
28
|
+
hdr = DatabaseOptions.RPC_CALL_HEADER_PREFIX.value
|
|
29
|
+
db_kwargs = {
|
|
30
|
+
"username": settings.sql_user,
|
|
31
|
+
"password": settings.sql_password,
|
|
32
|
+
hdr + "tenant": settings.tenant,
|
|
33
|
+
hdr + "pool": settings.pool,
|
|
34
|
+
}
|
|
35
|
+
if settings.superuser:
|
|
36
|
+
db_kwargs[hdr + "superuser"] = "true"
|
|
37
|
+
if settings.edge_tls and not settings.edge_tls_verify:
|
|
38
|
+
db_kwargs[DatabaseOptions.TLS_SKIP_VERIFY.value] = "true"
|
|
39
|
+
conn = flight_sql.connect(uri=uri, db_kwargs=db_kwargs, autocommit=True)
|
|
40
|
+
return cls(conn)
|
|
41
|
+
|
|
42
|
+
def query(self, sql: str):
|
|
43
|
+
with self._conn.cursor() as cur:
|
|
44
|
+
cur.execute(sql)
|
|
45
|
+
return cur.fetch_arrow_table()
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
self._conn.close()
|
|
49
|
+
|
|
50
|
+
def __enter__(self) -> "SqlClient":
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *_exc) -> None:
|
|
54
|
+
self.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _row(table, index: int) -> dict:
|
|
58
|
+
out: dict = {}
|
|
59
|
+
for name in table.column_names:
|
|
60
|
+
value = table.column(name)[index].as_py()
|
|
61
|
+
out[name] = value if isinstance(value, (int, float, str, bool, type(None))) else str(value)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_table(table, mode: str) -> None:
|
|
66
|
+
rows = [_row(table, i) for i in range(table.num_rows)]
|
|
67
|
+
if mode == "json":
|
|
68
|
+
print(json.dumps(rows, indent=2, default=str))
|
|
69
|
+
return
|
|
70
|
+
if mode == "csv":
|
|
71
|
+
buf = io.StringIO()
|
|
72
|
+
writer = csv.DictWriter(buf, fieldnames=table.column_names)
|
|
73
|
+
writer.writeheader()
|
|
74
|
+
writer.writerows(rows)
|
|
75
|
+
sys.stdout.write(buf.getvalue())
|
|
76
|
+
return
|
|
77
|
+
from .output import render
|
|
78
|
+
|
|
79
|
+
render(rows if rows else f"(empty result: {', '.join(table.column_names)})", as_json=False)
|
|
80
|
+
print(f"-- {table.num_rows} rows", file=sys.stderr)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qod-cli
|
|
3
|
+
Version: 0.3.7
|
|
4
|
+
Summary: Command-line client for quack-on-demand: admin REST plane and FlightSQL plane
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: adbc-driver-flightsql>=1.0
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: platformdirs>=4.0
|
|
9
|
+
Requires-Dist: pyarrow>=16.0
|
|
10
|
+
Requires-Dist: pyreadline3>=3.4; sys_platform == 'win32'
|
|
11
|
+
Requires-Dist: rich>=13.7
|
|
12
|
+
Requires-Dist: tomli-w>=1.0
|
|
13
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
14
|
+
Requires-Dist: typer>=0.12
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
qod_cli/__init__.py,sha256=J0I0c7-a50EOnWXMryTu_E6xhXSYFBPjVpeYP_a3vRI,22
|
|
2
|
+
qod_cli/config.py,sha256=oiOc8SW3OIyifbdnaA8eOVyZFkS2yrAV4YHzxD32Epk,3067
|
|
3
|
+
qod_cli/main.py,sha256=pC0DfjtZN9Y-JiVkQECgAUkmmYJ1K8nAvvv8na5XqC8,2535
|
|
4
|
+
qod_cli/output.py,sha256=xgOHZFS3AeMyhKaP0ELsMS3uJ2hWGaPQnukEBh4Pt4Y,1800
|
|
5
|
+
qod_cli/rest.py,sha256=YXQ_DTcnYWHE6Vr5eR1rOkq4ItT4Y8jTR2Uol370LAA,2474
|
|
6
|
+
qod_cli/sql.py,sha256=eKdaFUcQp-6B06VsR8CWtGWA8nvQRUv0XDW0s5l5tS4,2479
|
|
7
|
+
qod_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
qod_cli/commands/_run.py,sha256=B2yZoHsyebpn3oVoHB1IkAsQZOEPfCLFAiZ8TsAbspw,858
|
|
9
|
+
qod_cli/commands/auth.py,sha256=eVX1ff29qfQdn5zR6-asYQbJWA7pv2WPEd3gvxDOjpQ,2166
|
|
10
|
+
qod_cli/commands/catalog.py,sha256=aXrfbpduR28Ul0PjQ-fmDvnu2-HpDSpcIxX-qfQrS_c,5362
|
|
11
|
+
qod_cli/commands/config_cmd.py,sha256=ZZTmWSrTqodNuVYntoDNi-7iPAZG4USA6IxnZUKeFdg,394
|
|
12
|
+
qod_cli/commands/database.py,sha256=OYptjsKin6QxScYJtWSBQ8EJXvOc6NEpnr7XyiZIMGY,2699
|
|
13
|
+
qod_cli/commands/federation.py,sha256=0lVOtCTpUhieDJXeyiVKW_wCTRONtaOh2HBA_tydP14,2630
|
|
14
|
+
qod_cli/commands/group.py,sha256=iQq7RhDbt5zyXkg2c7JHwEKXIhnkyC1-3WBu32_2u9I,792
|
|
15
|
+
qod_cli/commands/maintenance.py,sha256=UU0KPaiyNIyznMi7FGG_b6zFzsOOsToUfN9rmB0Al5Y,3027
|
|
16
|
+
qod_cli/commands/manifest.py,sha256=Q2zHCjVFi-r8m3Hf2LgEBDCx6i4qfCZPeG_oIeXAqCw,1001
|
|
17
|
+
qod_cli/commands/membership.py,sha256=_JplkBXz3kjnjDjKffP9UyPXqHy8RdE9W_pv8ktWBCg,1983
|
|
18
|
+
qod_cli/commands/node.py,sha256=frmOwQAPMbIkSDxSXvcSCEqQ21uk_nHYrD8BLQN3Y48,1976
|
|
19
|
+
qod_cli/commands/pool.py,sha256=CP9iXu8JcmTstKPLeUu-9EdB5PCfj-bR6oe_PVH6cMA,5143
|
|
20
|
+
qod_cli/commands/role.py,sha256=QAiSg439JU6i1ERCHjV5DzUVyNEpO9Fevq-rQ0VTfmU,5184
|
|
21
|
+
qod_cli/commands/sql_cmd.py,sha256=kOi-F1DULo_Y9uTbur43CYyfe8gYb6vZnmWYiv3OyFc,2939
|
|
22
|
+
qod_cli/commands/tag.py,sha256=5BsXmq0bD-D3CYyu7_o96s7t5fae0dFf75BJf34ak5A,1216
|
|
23
|
+
qod_cli/commands/telemetry.py,sha256=yYq9XqC7h1mg_z3zfpuhNvWesG1I9OiD3IiBbIrfg8o,2945
|
|
24
|
+
qod_cli/commands/tenant.py,sha256=18WhxyDnG_HHJYJeScU7lrv8x6ctqla_EvJrUm7Q994,1752
|
|
25
|
+
qod_cli/commands/user.py,sha256=j3txpbfqoxUn2t1pJOkJ9AHBQI9PaVKpb-7YAfHePCY,2166
|
|
26
|
+
qod_cli-0.3.7.dist-info/METADATA,sha256=5Dnb5aBSj6hF2SwZ94hor5sFDMpDlvL8HHtYOWCkwP0,588
|
|
27
|
+
qod_cli-0.3.7.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
28
|
+
qod_cli-0.3.7.dist-info/entry_points.txt,sha256=OECysrOZGQ_zr-VET7_ceoquEH6yvjeEVFuxq5Udv2s,42
|
|
29
|
+
qod_cli-0.3.7.dist-info/RECORD,,
|