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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ qod = qod_cli.main:main