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.
Files changed (85) hide show
  1. dataplat/__init__.py +3 -0
  2. dataplat/cli/__init__.py +1 -0
  3. dataplat/cli/_missing.py +108 -0
  4. dataplat/cli/bi/__init__.py +1 -0
  5. dataplat/cli/bi/app.py +14 -0
  6. dataplat/cli/bi/superset.py +558 -0
  7. dataplat/cli/ci/__init__.py +1 -0
  8. dataplat/cli/ci/app.py +14 -0
  9. dataplat/cli/ci/github/__init__.py +1 -0
  10. dataplat/cli/ci/github/app.py +10 -0
  11. dataplat/cli/ci/github/runner.py +399 -0
  12. dataplat/cli/cloud/__init__.py +1 -0
  13. dataplat/cli/cloud/app.py +14 -0
  14. dataplat/cli/cloud/aws/__init__.py +1 -0
  15. dataplat/cli/cloud/aws/_common.py +77 -0
  16. dataplat/cli/cloud/aws/app.py +12 -0
  17. dataplat/cli/cloud/aws/rds.py +686 -0
  18. dataplat/cli/cloud/aws/redshift.py +312 -0
  19. dataplat/cli/cloud/aws/secrets.py +875 -0
  20. dataplat/cli/config.py +492 -0
  21. dataplat/cli/db/__init__.py +342 -0
  22. dataplat/cli/db/_common.py +130 -0
  23. dataplat/cli/db/_report.py +180 -0
  24. dataplat/cli/db/dbt_orphans.py +842 -0
  25. dataplat/cli/db/describe.py +939 -0
  26. dataplat/cli/db/long_queries.py +299 -0
  27. dataplat/cli/db/role.py +434 -0
  28. dataplat/cli/db/role_create.py +452 -0
  29. dataplat/cli/db/role_drop.py +290 -0
  30. dataplat/cli/db/role_list.py +147 -0
  31. dataplat/cli/db/top_tables.py +337 -0
  32. dataplat/cli/ingest/__init__.py +1 -0
  33. dataplat/cli/ingest/airbyte/__init__.py +5 -0
  34. dataplat/cli/ingest/airbyte/_common.py +36 -0
  35. dataplat/cli/ingest/airbyte/_cursor.py +268 -0
  36. dataplat/cli/ingest/airbyte/_resource.py +192 -0
  37. dataplat/cli/ingest/airbyte/app.py +31 -0
  38. dataplat/cli/ingest/airbyte/connections.py +1234 -0
  39. dataplat/cli/ingest/airbyte/definitions.py +122 -0
  40. dataplat/cli/ingest/airbyte/destinations.py +26 -0
  41. dataplat/cli/ingest/airbyte/enums.py +45 -0
  42. dataplat/cli/ingest/airbyte/jobs.py +112 -0
  43. dataplat/cli/ingest/airbyte/sources.py +26 -0
  44. dataplat/cli/ingest/airbyte/tags.py +57 -0
  45. dataplat/cli/ingest/airbyte/templates.py +144 -0
  46. dataplat/cli/ingest/airbyte/tui.py +123 -0
  47. dataplat/cli/ingest/airbyte/workspaces.py +80 -0
  48. dataplat/cli/ingest/app.py +14 -0
  49. dataplat/cli/open.py +117 -0
  50. dataplat/cli/status.py +308 -0
  51. dataplat/core/__init__.py +1 -0
  52. dataplat/core/deps.py +138 -0
  53. dataplat/core/envrc.py +125 -0
  54. dataplat/core/errors.py +23 -0
  55. dataplat/main.py +87 -0
  56. dataplat/services/__init__.py +1 -0
  57. dataplat/services/airbyte/__init__.py +1 -0
  58. dataplat/services/airbyte/_resource.py +113 -0
  59. dataplat/services/airbyte/client.py +280 -0
  60. dataplat/services/airbyte/connections.py +306 -0
  61. dataplat/services/airbyte/definitions.py +64 -0
  62. dataplat/services/airbyte/destinations.py +68 -0
  63. dataplat/services/airbyte/jobs.py +72 -0
  64. dataplat/services/airbyte/sources.py +58 -0
  65. dataplat/services/airbyte/tags.py +120 -0
  66. dataplat/services/airbyte/workspaces.py +47 -0
  67. dataplat/services/aws/__init__.py +1 -0
  68. dataplat/services/aws/auth.py +80 -0
  69. dataplat/services/db/__init__.py +1 -0
  70. dataplat/services/db/connection.py +169 -0
  71. dataplat/services/db/describe.py +1034 -0
  72. dataplat/services/db/long_queries.py +378 -0
  73. dataplat/services/db/orphans.py +429 -0
  74. dataplat/services/db/role.py +812 -0
  75. dataplat/services/db/role_admin.py +458 -0
  76. dataplat/services/db/role_dialects.py +521 -0
  77. dataplat/services/db/targets.py +128 -0
  78. dataplat/services/db/top_tables.py +193 -0
  79. dataplat/services/superset/__init__.py +1 -0
  80. dataplat/services/superset/client.py +319 -0
  81. dataplat-0.1.0.dist-info/METADATA +326 -0
  82. dataplat-0.1.0.dist-info/RECORD +85 -0
  83. dataplat-0.1.0.dist-info/WHEEL +4 -0
  84. dataplat-0.1.0.dist-info/entry_points.txt +2 -0
  85. dataplat-0.1.0.dist-info/licenses/LICENSE +21 -0
dataplat/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DNA HQ CLI - Command line tools for DNA HQ operations."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """CLI command adapters."""
@@ -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."""