dataplat 0.1.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.
- dataplat/__init__.py +3 -0
- dataplat/cli/__init__.py +1 -0
- dataplat/cli/_missing.py +108 -0
- dataplat/cli/bi/__init__.py +1 -0
- dataplat/cli/bi/app.py +14 -0
- dataplat/cli/bi/superset.py +558 -0
- dataplat/cli/ci/__init__.py +1 -0
- dataplat/cli/ci/app.py +14 -0
- dataplat/cli/ci/github/__init__.py +1 -0
- dataplat/cli/ci/github/app.py +10 -0
- dataplat/cli/ci/github/runner.py +399 -0
- dataplat/cli/cloud/__init__.py +1 -0
- dataplat/cli/cloud/app.py +14 -0
- dataplat/cli/cloud/aws/__init__.py +1 -0
- dataplat/cli/cloud/aws/_common.py +77 -0
- dataplat/cli/cloud/aws/app.py +12 -0
- dataplat/cli/cloud/aws/rds.py +686 -0
- dataplat/cli/cloud/aws/redshift.py +312 -0
- dataplat/cli/cloud/aws/secrets.py +875 -0
- dataplat/cli/config.py +492 -0
- dataplat/cli/db/__init__.py +342 -0
- dataplat/cli/db/_common.py +130 -0
- dataplat/cli/db/_report.py +180 -0
- dataplat/cli/db/dbt_orphans.py +842 -0
- dataplat/cli/db/describe.py +939 -0
- dataplat/cli/db/long_queries.py +299 -0
- dataplat/cli/db/role.py +434 -0
- dataplat/cli/db/role_create.py +452 -0
- dataplat/cli/db/role_drop.py +290 -0
- dataplat/cli/db/role_list.py +147 -0
- dataplat/cli/db/top_tables.py +337 -0
- dataplat/cli/ingest/__init__.py +1 -0
- dataplat/cli/ingest/airbyte/__init__.py +5 -0
- dataplat/cli/ingest/airbyte/_common.py +36 -0
- dataplat/cli/ingest/airbyte/_cursor.py +268 -0
- dataplat/cli/ingest/airbyte/_resource.py +192 -0
- dataplat/cli/ingest/airbyte/app.py +31 -0
- dataplat/cli/ingest/airbyte/connections.py +1234 -0
- dataplat/cli/ingest/airbyte/definitions.py +122 -0
- dataplat/cli/ingest/airbyte/destinations.py +26 -0
- dataplat/cli/ingest/airbyte/enums.py +45 -0
- dataplat/cli/ingest/airbyte/jobs.py +112 -0
- dataplat/cli/ingest/airbyte/sources.py +26 -0
- dataplat/cli/ingest/airbyte/tags.py +57 -0
- dataplat/cli/ingest/airbyte/templates.py +144 -0
- dataplat/cli/ingest/airbyte/tui.py +123 -0
- dataplat/cli/ingest/airbyte/workspaces.py +80 -0
- dataplat/cli/ingest/app.py +14 -0
- dataplat/cli/open.py +117 -0
- dataplat/cli/status.py +308 -0
- dataplat/core/__init__.py +1 -0
- dataplat/core/deps.py +138 -0
- dataplat/core/envrc.py +125 -0
- dataplat/core/errors.py +23 -0
- dataplat/main.py +87 -0
- dataplat/services/__init__.py +1 -0
- dataplat/services/airbyte/__init__.py +1 -0
- dataplat/services/airbyte/_resource.py +113 -0
- dataplat/services/airbyte/client.py +280 -0
- dataplat/services/airbyte/connections.py +306 -0
- dataplat/services/airbyte/definitions.py +64 -0
- dataplat/services/airbyte/destinations.py +68 -0
- dataplat/services/airbyte/jobs.py +72 -0
- dataplat/services/airbyte/sources.py +58 -0
- dataplat/services/airbyte/tags.py +120 -0
- dataplat/services/airbyte/workspaces.py +47 -0
- dataplat/services/aws/__init__.py +1 -0
- dataplat/services/aws/auth.py +80 -0
- dataplat/services/db/__init__.py +1 -0
- dataplat/services/db/connection.py +169 -0
- dataplat/services/db/describe.py +1034 -0
- dataplat/services/db/long_queries.py +378 -0
- dataplat/services/db/orphans.py +429 -0
- dataplat/services/db/role.py +812 -0
- dataplat/services/db/role_admin.py +458 -0
- dataplat/services/db/role_dialects.py +521 -0
- dataplat/services/db/targets.py +128 -0
- dataplat/services/db/top_tables.py +193 -0
- dataplat/services/superset/__init__.py +1 -0
- dataplat/services/superset/client.py +319 -0
- dataplat-0.1.0.dist-info/METADATA +326 -0
- dataplat-0.1.0.dist-info/RECORD +85 -0
- dataplat-0.1.0.dist-info/WHEEL +4 -0
- dataplat-0.1.0.dist-info/entry_points.txt +2 -0
- dataplat-0.1.0.dist-info/licenses/LICENSE +21 -0
dataplat/__init__.py
ADDED
dataplat/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command adapters."""
|
dataplat/cli/_missing.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Stub apps and installer flow for areas whose extras are not installed.
|
|
2
|
+
|
|
3
|
+
When an area's optional dependencies are absent, ``main`` mounts a stub
|
|
4
|
+
group in its place so ``dp <area> ...`` explains what is missing, offers
|
|
5
|
+
to install the extra into dp's own environment, and re-runs the original
|
|
6
|
+
command on success.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, NoReturn
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.markup import escape
|
|
19
|
+
from typer.core import TyperGroup
|
|
20
|
+
|
|
21
|
+
from dataplat.core.deps import (
|
|
22
|
+
AREAS,
|
|
23
|
+
install_command,
|
|
24
|
+
install_spec,
|
|
25
|
+
manual_hint,
|
|
26
|
+
missing_modules,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_install(extras: list[str], *, yes: bool) -> bool:
|
|
33
|
+
"""Install ``extras`` into dp's environment; True on success.
|
|
34
|
+
|
|
35
|
+
Always shows the exact command first. Prompts unless ``yes``; in
|
|
36
|
+
non-interactive sessions it prints the command and declines instead
|
|
37
|
+
of installing silently.
|
|
38
|
+
"""
|
|
39
|
+
cmd = install_command(extras)
|
|
40
|
+
if cmd is None:
|
|
41
|
+
console.print(f"[yellow]{escape(manual_hint(extras))}[/yellow]")
|
|
42
|
+
return False
|
|
43
|
+
console.print(f"[bold]Will run:[/bold] {escape(' '.join(cmd))}")
|
|
44
|
+
if not yes:
|
|
45
|
+
if not sys.stdin.isatty():
|
|
46
|
+
console.print(
|
|
47
|
+
"[yellow]Non-interactive session; run the command above "
|
|
48
|
+
"yourself, or pass --yes.[/yellow]"
|
|
49
|
+
)
|
|
50
|
+
return False
|
|
51
|
+
if not typer.confirm("Proceed?", default=True):
|
|
52
|
+
return False
|
|
53
|
+
proc = subprocess.run(cmd)
|
|
54
|
+
if proc.returncode != 0:
|
|
55
|
+
console.print(f"[red]Install failed (exit {proc.returncode}).[/red]")
|
|
56
|
+
return False
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def reexec() -> NoReturn:
|
|
61
|
+
"""Re-run the original dp invocation against the updated environment."""
|
|
62
|
+
os.execvp(sys.argv[0], sys.argv)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_missing_deps_app(area: str, help_text: str) -> typer.Typer:
|
|
66
|
+
"""A stand-in Typer group for ``area`` while its extra is missing."""
|
|
67
|
+
spec = AREAS[area]
|
|
68
|
+
|
|
69
|
+
def handle() -> None:
|
|
70
|
+
missing = missing_modules(area)
|
|
71
|
+
console.print(
|
|
72
|
+
f"[yellow]`dp {area}` needs {', '.join(missing) or spec.extra} — "
|
|
73
|
+
f"the '{spec.extra}' extra "
|
|
74
|
+
f"([bold]{escape(install_spec([spec.extra]))}[/bold]).[/yellow]"
|
|
75
|
+
)
|
|
76
|
+
if run_install([spec.extra], yes=False):
|
|
77
|
+
console.print("[green]Installed. Re-running your command…[/green]")
|
|
78
|
+
reexec()
|
|
79
|
+
raise typer.Exit(code=1)
|
|
80
|
+
|
|
81
|
+
class MissingDepsGroup(TyperGroup):
|
|
82
|
+
"""Subcommands resolve before the group callback runs, so a plain
|
|
83
|
+
empty group would die with "No such command" instead of explaining
|
|
84
|
+
the missing extra. Intercept resolution and run the handler for any
|
|
85
|
+
subcommand. (No direct click import: typer >= 0.27 vendors click,
|
|
86
|
+
so we only rely on the TyperGroup surface.)"""
|
|
87
|
+
|
|
88
|
+
def list_commands(self, ctx: Any) -> list[str]:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
def resolve_command(self, ctx: Any, args: Any) -> Any:
|
|
92
|
+
handle()
|
|
93
|
+
raise AssertionError("unreachable: handle() exits or re-execs")
|
|
94
|
+
|
|
95
|
+
stub = typer.Typer(
|
|
96
|
+
name=area,
|
|
97
|
+
cls=MissingDepsGroup,
|
|
98
|
+
help=f"{help_text} (needs extra: {spec.extra})",
|
|
99
|
+
invoke_without_command=True,
|
|
100
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@stub.callback()
|
|
104
|
+
def _missing_deps(ctx: typer.Context) -> None:
|
|
105
|
+
if ctx.invoked_subcommand is None:
|
|
106
|
+
handle()
|
|
107
|
+
|
|
108
|
+
return stub
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""BI area — dashboards and analytics front-ends."""
|
dataplat/cli/bi/app.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""BI command area."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from dataplat.cli.bi import superset
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="bi",
|
|
11
|
+
help="Business-intelligence tools (Superset)",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
app.add_typer(superset.app, name="superset")
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""Superset management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
from rich import box
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from dataplat.core.errors import AuthError, ConfigError, ServiceError
|
|
15
|
+
from dataplat.services.superset.client import (
|
|
16
|
+
create_user as _create_user,
|
|
17
|
+
)
|
|
18
|
+
from dataplat.services.superset.client import (
|
|
19
|
+
delete_user as _delete_user,
|
|
20
|
+
)
|
|
21
|
+
from dataplat.services.superset.client import (
|
|
22
|
+
get_auth_config_from_env,
|
|
23
|
+
)
|
|
24
|
+
from dataplat.services.superset.client import (
|
|
25
|
+
iter_groups as _iter_groups,
|
|
26
|
+
)
|
|
27
|
+
from dataplat.services.superset.client import (
|
|
28
|
+
iter_roles as _iter_roles,
|
|
29
|
+
)
|
|
30
|
+
from dataplat.services.superset.client import (
|
|
31
|
+
iter_users as _iter_users,
|
|
32
|
+
)
|
|
33
|
+
from dataplat.services.superset.client import (
|
|
34
|
+
login as _login,
|
|
35
|
+
)
|
|
36
|
+
from dataplat.services.superset.client import (
|
|
37
|
+
resolve_group_ids as _resolve_group_ids,
|
|
38
|
+
)
|
|
39
|
+
from dataplat.services.superset.client import (
|
|
40
|
+
resolve_role_ids as _resolve_role_ids,
|
|
41
|
+
)
|
|
42
|
+
from dataplat.services.superset.client import (
|
|
43
|
+
update_user as _update_user,
|
|
44
|
+
)
|
|
45
|
+
from dataplat.services.superset.client import (
|
|
46
|
+
user_group_ids as _user_group_ids,
|
|
47
|
+
)
|
|
48
|
+
from dataplat.services.superset.client import (
|
|
49
|
+
user_role_ids as _user_role_ids,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
app = typer.Typer(
|
|
53
|
+
name="superset",
|
|
54
|
+
help="Manage Superset resources",
|
|
55
|
+
no_args_is_help=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
users_app = typer.Typer(
|
|
59
|
+
name="users",
|
|
60
|
+
help="Manage Superset users",
|
|
61
|
+
no_args_is_help=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
roles_app = typer.Typer(
|
|
65
|
+
name="roles",
|
|
66
|
+
help="Manage Superset roles",
|
|
67
|
+
no_args_is_help=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
groups_app = typer.Typer(
|
|
71
|
+
name="groups",
|
|
72
|
+
help="Inspect Superset groups",
|
|
73
|
+
no_args_is_help=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
app.add_typer(users_app, name="users")
|
|
77
|
+
app.add_typer(roles_app, name="roles")
|
|
78
|
+
app.add_typer(groups_app, name="groups")
|
|
79
|
+
|
|
80
|
+
console = Console()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RoleOrder(str, Enum):
|
|
84
|
+
"""Role list ordering options."""
|
|
85
|
+
|
|
86
|
+
by_name = "name"
|
|
87
|
+
by_id = "id"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RoleOrderDir(str, Enum):
|
|
91
|
+
"""Role list ordering direction options."""
|
|
92
|
+
|
|
93
|
+
asc = "asc"
|
|
94
|
+
desc = "desc"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class UserRoleMatch(str, Enum):
|
|
98
|
+
"""How to match users by role set."""
|
|
99
|
+
|
|
100
|
+
exact = "exact"
|
|
101
|
+
subset = "subset"
|
|
102
|
+
any = "any"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_auth_context() -> tuple[str, str, str]:
|
|
106
|
+
try:
|
|
107
|
+
cfg = get_auth_config_from_env()
|
|
108
|
+
except ConfigError as exc:
|
|
109
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
110
|
+
raise typer.Exit(code=1)
|
|
111
|
+
return cfg.base_url, cfg.username, cfg.password
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@roles_app.command("list")
|
|
115
|
+
def list_roles(
|
|
116
|
+
order_by: RoleOrder = typer.Option(
|
|
117
|
+
RoleOrder.by_name,
|
|
118
|
+
"--order",
|
|
119
|
+
help="Order roles by name or id",
|
|
120
|
+
),
|
|
121
|
+
order_dir: RoleOrderDir = typer.Option(
|
|
122
|
+
RoleOrderDir.asc,
|
|
123
|
+
"--order-dir",
|
|
124
|
+
help="Sort direction (asc or desc)",
|
|
125
|
+
),
|
|
126
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
|
|
127
|
+
):
|
|
128
|
+
"""List available Superset roles."""
|
|
129
|
+
base_url, admin_username, admin_password = _load_auth_context()
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
with httpx.Client() as client:
|
|
133
|
+
access_token = _login(client, base_url, admin_username, admin_password)
|
|
134
|
+
roles = list(_iter_roles(client, base_url, access_token))
|
|
135
|
+
except (AuthError, ServiceError, ConfigError) as exc:
|
|
136
|
+
console.print(f"[red]{exc}[/red]")
|
|
137
|
+
raise typer.Exit(code=1)
|
|
138
|
+
|
|
139
|
+
if as_json:
|
|
140
|
+
typer.echo(json.dumps(roles, indent=2, ensure_ascii=False))
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
if not roles:
|
|
144
|
+
console.print("[yellow]No roles found[/yellow]")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
table = Table(
|
|
148
|
+
show_header=True,
|
|
149
|
+
header_style="bold cyan",
|
|
150
|
+
box=box.SIMPLE_HEAVY,
|
|
151
|
+
show_lines=False,
|
|
152
|
+
expand=True,
|
|
153
|
+
)
|
|
154
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
155
|
+
table.add_column("Name", style="cyan")
|
|
156
|
+
|
|
157
|
+
def _sort_by_id(role: dict) -> int:
|
|
158
|
+
return int(role.get("id") or 0)
|
|
159
|
+
|
|
160
|
+
def _sort_by_name(role: dict) -> str:
|
|
161
|
+
return str(role.get("name", ""))
|
|
162
|
+
|
|
163
|
+
sort_key = _sort_by_id if order_by == RoleOrder.by_id else _sort_by_name
|
|
164
|
+
reverse = order_dir == RoleOrderDir.desc
|
|
165
|
+
|
|
166
|
+
for role in sorted(roles, key=sort_key, reverse=reverse):
|
|
167
|
+
role_id = role.get("id")
|
|
168
|
+
name = role.get("name", "")
|
|
169
|
+
table.add_row(str(role_id) if role_id is not None else "", str(name))
|
|
170
|
+
|
|
171
|
+
console.print(table)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@users_app.command("create")
|
|
175
|
+
def create_user(
|
|
176
|
+
username: str = typer.Argument(..., help="Superset username to create"),
|
|
177
|
+
password: str = typer.Option(
|
|
178
|
+
...,
|
|
179
|
+
"--password",
|
|
180
|
+
help="Password for the new user (omit to be prompted without echo).",
|
|
181
|
+
prompt=True,
|
|
182
|
+
hide_input=True,
|
|
183
|
+
confirmation_prompt=True,
|
|
184
|
+
),
|
|
185
|
+
email: str = typer.Option(
|
|
186
|
+
..., "--email", "-e", help="Email for the new user"
|
|
187
|
+
),
|
|
188
|
+
first_name: str | None = typer.Option(
|
|
189
|
+
None, "--first-name", help="First name for the new user"
|
|
190
|
+
),
|
|
191
|
+
last_name: str | None = typer.Option(
|
|
192
|
+
None, "--last-name", help="Last name for the new user"
|
|
193
|
+
),
|
|
194
|
+
role_name: list[str] | None = typer.Option(
|
|
195
|
+
None,
|
|
196
|
+
"--role",
|
|
197
|
+
"-r",
|
|
198
|
+
help="Role name to assign (can be repeated). Defaults to Gamma",
|
|
199
|
+
),
|
|
200
|
+
group_name: list[str] | None = typer.Option(
|
|
201
|
+
None,
|
|
202
|
+
"--group",
|
|
203
|
+
"-g",
|
|
204
|
+
help="Group name to assign (can be repeated)",
|
|
205
|
+
),
|
|
206
|
+
active: bool = typer.Option(True, "--active/--inactive", help="User is active"),
|
|
207
|
+
):
|
|
208
|
+
"""Create a Superset user."""
|
|
209
|
+
base_url, admin_username, admin_password = _load_auth_context()
|
|
210
|
+
|
|
211
|
+
resolved_email = email
|
|
212
|
+
resolved_first_name = first_name or username
|
|
213
|
+
resolved_last_name = last_name or "User"
|
|
214
|
+
role_names = role_name if role_name else ["Gamma"]
|
|
215
|
+
group_names = group_name if group_name else []
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
with httpx.Client() as client:
|
|
219
|
+
access_token = _login(client, base_url, admin_username, admin_password)
|
|
220
|
+
role_ids = _resolve_role_ids(client, base_url, access_token, role_names)
|
|
221
|
+
payload = {
|
|
222
|
+
"username": username,
|
|
223
|
+
"first_name": resolved_first_name,
|
|
224
|
+
"last_name": resolved_last_name,
|
|
225
|
+
"email": resolved_email,
|
|
226
|
+
"password": password,
|
|
227
|
+
"active": active,
|
|
228
|
+
"roles": role_ids,
|
|
229
|
+
}
|
|
230
|
+
if group_names:
|
|
231
|
+
group_ids = _resolve_group_ids(
|
|
232
|
+
client, base_url, access_token, group_names
|
|
233
|
+
)
|
|
234
|
+
payload["groups"] = group_ids
|
|
235
|
+
|
|
236
|
+
response = _create_user(client, base_url, access_token, payload)
|
|
237
|
+
except (AuthError, ServiceError, ConfigError) as exc:
|
|
238
|
+
console.print(f"[red]{exc}[/red]")
|
|
239
|
+
raise typer.Exit(code=1)
|
|
240
|
+
|
|
241
|
+
user_id = response.get("id") or response.get("result", {}).get("id")
|
|
242
|
+
console.print(
|
|
243
|
+
"[green]✓ Superset user created[/green] "
|
|
244
|
+
f"[dim](username={username}, id={user_id})[/dim]"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@users_app.command("update")
|
|
249
|
+
def update_users(
|
|
250
|
+
user_ids: list[int] | None = typer.Option(
|
|
251
|
+
None,
|
|
252
|
+
"--user-id",
|
|
253
|
+
help="Only update specific user id(s) (repeatable)",
|
|
254
|
+
),
|
|
255
|
+
email: list[str] | None = typer.Option(
|
|
256
|
+
None,
|
|
257
|
+
"--email",
|
|
258
|
+
help="Only update specific user email(s) (repeatable)",
|
|
259
|
+
),
|
|
260
|
+
filter_role: list[str] | None = typer.Option(
|
|
261
|
+
None,
|
|
262
|
+
"--filter-role",
|
|
263
|
+
help="Only update users with these roles (repeatable)",
|
|
264
|
+
),
|
|
265
|
+
match: UserRoleMatch = typer.Option(
|
|
266
|
+
UserRoleMatch.exact,
|
|
267
|
+
"--match",
|
|
268
|
+
help="Role match mode for filtering (exact, subset, any)",
|
|
269
|
+
),
|
|
270
|
+
add_group: list[str] | None = typer.Option(
|
|
271
|
+
None,
|
|
272
|
+
"--add-group",
|
|
273
|
+
help="Group to add (repeatable)",
|
|
274
|
+
),
|
|
275
|
+
remove_group: list[str] | None = typer.Option(
|
|
276
|
+
None,
|
|
277
|
+
"--remove-group",
|
|
278
|
+
help="Group to remove (repeatable)",
|
|
279
|
+
),
|
|
280
|
+
set_group: list[str] | None = typer.Option(
|
|
281
|
+
None,
|
|
282
|
+
"--set-group",
|
|
283
|
+
help="Replace groups with this list (repeatable)",
|
|
284
|
+
),
|
|
285
|
+
dry_run: bool = typer.Option(
|
|
286
|
+
False,
|
|
287
|
+
"--dry-run",
|
|
288
|
+
help="Preview changes without updating users",
|
|
289
|
+
),
|
|
290
|
+
):
|
|
291
|
+
"""Update users in bulk based on role filters and group changes."""
|
|
292
|
+
base_url, admin_username, admin_password = _load_auth_context()
|
|
293
|
+
|
|
294
|
+
if not any([add_group, remove_group, set_group]):
|
|
295
|
+
console.print("[red]Error: specify at least one group update flag[/red]")
|
|
296
|
+
raise typer.Exit(code=1)
|
|
297
|
+
|
|
298
|
+
if set_group and (add_group or remove_group):
|
|
299
|
+
console.print(
|
|
300
|
+
"[red]Error: --set-group cannot be combined with --add-group or --remove-group[/red]"
|
|
301
|
+
)
|
|
302
|
+
raise typer.Exit(code=1)
|
|
303
|
+
|
|
304
|
+
user_id_set = set(user_ids or [])
|
|
305
|
+
email_set = {e.lower() for e in (email or [])}
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
with httpx.Client() as client:
|
|
309
|
+
access_token = _login(client, base_url, admin_username, admin_password)
|
|
310
|
+
|
|
311
|
+
filter_role_ids = (
|
|
312
|
+
_resolve_role_ids(client, base_url, access_token, filter_role)
|
|
313
|
+
if filter_role
|
|
314
|
+
else []
|
|
315
|
+
)
|
|
316
|
+
add_group_ids = (
|
|
317
|
+
_resolve_group_ids(client, base_url, access_token, add_group)
|
|
318
|
+
if add_group
|
|
319
|
+
else []
|
|
320
|
+
)
|
|
321
|
+
remove_group_ids = (
|
|
322
|
+
_resolve_group_ids(client, base_url, access_token, remove_group)
|
|
323
|
+
if remove_group
|
|
324
|
+
else []
|
|
325
|
+
)
|
|
326
|
+
set_group_ids = (
|
|
327
|
+
_resolve_group_ids(client, base_url, access_token, set_group)
|
|
328
|
+
if set_group
|
|
329
|
+
else []
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
matched = 0
|
|
333
|
+
updated = 0
|
|
334
|
+
|
|
335
|
+
for user in _iter_users(client, base_url, access_token):
|
|
336
|
+
user_id = user.get("id")
|
|
337
|
+
if not isinstance(user_id, int):
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
if user_id_set and user_id not in user_id_set:
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if email_set:
|
|
344
|
+
user_email = user.get("email")
|
|
345
|
+
if not isinstance(user_email, str):
|
|
346
|
+
continue
|
|
347
|
+
if user_email.lower() not in email_set:
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
user_roles = _user_role_ids(user)
|
|
351
|
+
if filter_role_ids:
|
|
352
|
+
if not user_roles:
|
|
353
|
+
continue
|
|
354
|
+
user_roles_set = set(user_roles)
|
|
355
|
+
filter_roles_set = set(filter_role_ids)
|
|
356
|
+
if match == UserRoleMatch.exact:
|
|
357
|
+
if user_roles_set != filter_roles_set:
|
|
358
|
+
continue
|
|
359
|
+
elif match == UserRoleMatch.subset:
|
|
360
|
+
if not user_roles_set.issubset(filter_roles_set):
|
|
361
|
+
continue
|
|
362
|
+
else:
|
|
363
|
+
if not user_roles_set.intersection(filter_roles_set):
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
user_groups = _user_group_ids(user)
|
|
367
|
+
updated_groups = user_groups
|
|
368
|
+
|
|
369
|
+
if set_group is not None:
|
|
370
|
+
updated_groups = list(set_group_ids)
|
|
371
|
+
else:
|
|
372
|
+
updated_groups = list(set(user_groups).union(add_group_ids))
|
|
373
|
+
if remove_group_ids:
|
|
374
|
+
updated_groups = [
|
|
375
|
+
gid for gid in updated_groups if gid not in remove_group_ids
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
if set(updated_groups) == set(user_groups):
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
matched += 1
|
|
382
|
+
if dry_run:
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
payload = {
|
|
386
|
+
"roles": user_roles,
|
|
387
|
+
"groups": updated_groups,
|
|
388
|
+
}
|
|
389
|
+
_update_user(client, base_url, access_token, user_id, payload)
|
|
390
|
+
updated += 1
|
|
391
|
+
except (AuthError, ServiceError, ConfigError) as exc:
|
|
392
|
+
console.print(f"[red]{exc}[/red]")
|
|
393
|
+
raise typer.Exit(code=1)
|
|
394
|
+
|
|
395
|
+
if matched == 0:
|
|
396
|
+
console.print("[yellow]No users matched the criteria[/yellow]")
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
if dry_run:
|
|
400
|
+
console.print(
|
|
401
|
+
f"[green]Matched {matched} user(s)[/green] (dry run; no updates applied)"
|
|
402
|
+
)
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
console.print(f"[green]Updated {updated} user(s)[/green]")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@users_app.command("list")
|
|
409
|
+
def list_users(
|
|
410
|
+
filter_role: list[str] | None = typer.Option(
|
|
411
|
+
None,
|
|
412
|
+
"--filter-role",
|
|
413
|
+
help="Only show users holding this role (repeatable).",
|
|
414
|
+
),
|
|
415
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
|
|
416
|
+
):
|
|
417
|
+
"""List Superset users."""
|
|
418
|
+
base_url, admin_username, admin_password = _load_auth_context()
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
with httpx.Client() as client:
|
|
422
|
+
access_token = _login(client, base_url, admin_username, admin_password)
|
|
423
|
+
users = list(_iter_users(client, base_url, access_token))
|
|
424
|
+
role_filter_ids: set[int] = set()
|
|
425
|
+
if filter_role:
|
|
426
|
+
role_filter_ids = set(
|
|
427
|
+
_resolve_role_ids(client, base_url, access_token, filter_role)
|
|
428
|
+
)
|
|
429
|
+
except (AuthError, ServiceError, ConfigError) as exc:
|
|
430
|
+
console.print(f"[red]{exc}[/red]")
|
|
431
|
+
raise typer.Exit(code=1)
|
|
432
|
+
|
|
433
|
+
if role_filter_ids:
|
|
434
|
+
users = [
|
|
435
|
+
u for u in users if role_filter_ids & set(_user_role_ids(u))
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
if as_json:
|
|
439
|
+
typer.echo(json.dumps(users, indent=2, ensure_ascii=False))
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
if not users:
|
|
443
|
+
console.print("[yellow]No users found[/yellow]")
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
table = Table(
|
|
447
|
+
show_header=True,
|
|
448
|
+
header_style="bold cyan",
|
|
449
|
+
box=box.SIMPLE_HEAVY,
|
|
450
|
+
expand=True,
|
|
451
|
+
)
|
|
452
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
453
|
+
table.add_column("Username", style="cyan")
|
|
454
|
+
table.add_column("Name")
|
|
455
|
+
table.add_column("Email")
|
|
456
|
+
table.add_column("Active", justify="center")
|
|
457
|
+
table.add_column("Roles", style="dim")
|
|
458
|
+
|
|
459
|
+
for user in sorted(users, key=lambda u: str(u.get("username", ""))):
|
|
460
|
+
roles = user.get("roles") or []
|
|
461
|
+
role_names = ", ".join(
|
|
462
|
+
str(r.get("name", r)) if isinstance(r, dict) else str(r) for r in roles
|
|
463
|
+
)
|
|
464
|
+
full_name = " ".join(
|
|
465
|
+
part
|
|
466
|
+
for part in (user.get("first_name"), user.get("last_name"))
|
|
467
|
+
if part
|
|
468
|
+
)
|
|
469
|
+
table.add_row(
|
|
470
|
+
str(user.get("id", "")),
|
|
471
|
+
str(user.get("username", "")),
|
|
472
|
+
full_name,
|
|
473
|
+
str(user.get("email", "")),
|
|
474
|
+
"yes" if user.get("active") else "no",
|
|
475
|
+
role_names,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
console.print(table)
|
|
479
|
+
console.print(f"\n[dim]Total: {len(users)} user(s)[/dim]")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@users_app.command("delete")
|
|
483
|
+
def delete_users(
|
|
484
|
+
user_ids: list[int] = typer.Argument(..., help="Superset user ID(s) to delete"),
|
|
485
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
486
|
+
):
|
|
487
|
+
"""Delete Superset user(s) by ID."""
|
|
488
|
+
if not yes:
|
|
489
|
+
typer.confirm(
|
|
490
|
+
f"Delete Superset user(s) {', '.join(str(u) for u in user_ids)}?",
|
|
491
|
+
abort=True,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
base_url, admin_username, admin_password = _load_auth_context()
|
|
495
|
+
|
|
496
|
+
failures = 0
|
|
497
|
+
try:
|
|
498
|
+
with httpx.Client() as client:
|
|
499
|
+
access_token = _login(client, base_url, admin_username, admin_password)
|
|
500
|
+
for user_id in user_ids:
|
|
501
|
+
try:
|
|
502
|
+
_delete_user(client, base_url, access_token, user_id)
|
|
503
|
+
console.print(f"[green]✓ Deleted user {user_id}[/green]")
|
|
504
|
+
except ServiceError as exc:
|
|
505
|
+
console.print(f"[red]✗ user {user_id}: {exc}[/red]")
|
|
506
|
+
failures += 1
|
|
507
|
+
except (AuthError, ConfigError) as exc:
|
|
508
|
+
console.print(f"[red]{exc}[/red]")
|
|
509
|
+
raise typer.Exit(code=1)
|
|
510
|
+
|
|
511
|
+
if failures:
|
|
512
|
+
raise typer.Exit(code=1)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@groups_app.command("list")
|
|
516
|
+
def list_groups(
|
|
517
|
+
as_json: bool = typer.Option(False, "--json", help="Emit JSON instead of a table"),
|
|
518
|
+
):
|
|
519
|
+
"""List Superset groups."""
|
|
520
|
+
base_url, admin_username, admin_password = _load_auth_context()
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
with httpx.Client() as client:
|
|
524
|
+
access_token = _login(client, base_url, admin_username, admin_password)
|
|
525
|
+
groups = list(_iter_groups(client, base_url, access_token))
|
|
526
|
+
except (AuthError, ServiceError, ConfigError) as exc:
|
|
527
|
+
console.print(f"[red]{exc}[/red]")
|
|
528
|
+
raise typer.Exit(code=1)
|
|
529
|
+
|
|
530
|
+
if as_json:
|
|
531
|
+
typer.echo(json.dumps(groups, indent=2, ensure_ascii=False))
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
if not groups:
|
|
535
|
+
console.print("[yellow]No groups found[/yellow]")
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
table = Table(
|
|
539
|
+
show_header=True,
|
|
540
|
+
header_style="bold cyan",
|
|
541
|
+
box=box.SIMPLE_HEAVY,
|
|
542
|
+
expand=True,
|
|
543
|
+
)
|
|
544
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
545
|
+
table.add_column("Name", style="cyan")
|
|
546
|
+
table.add_column("Label")
|
|
547
|
+
table.add_column("Description")
|
|
548
|
+
|
|
549
|
+
for group in sorted(groups, key=lambda g: str(g.get("name", ""))):
|
|
550
|
+
table.add_row(
|
|
551
|
+
str(group.get("id", "")),
|
|
552
|
+
str(group.get("name", "")),
|
|
553
|
+
str(group.get("label", "") or ""),
|
|
554
|
+
str(group.get("description", "") or ""),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
console.print(table)
|
|
558
|
+
console.print(f"\n[dim]Total: {len(groups)} group(s)[/dim]")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CI area — build/runner infrastructure."""
|