datamasque-cli 1.0.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.
@@ -0,0 +1,118 @@
1
+ """System-level commands: health, licence, logs, admin-install."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from datamasque_cli.client import get_client
10
+ from datamasque_cli.commands.rulesets import export_bundle, import_bundle
11
+ from datamasque_cli.output import print_json, print_success, print_warning, render_output
12
+
13
+ app = typer.Typer(help="System administration commands.")
14
+
15
+
16
+ @app.command()
17
+ def health(
18
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
19
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
20
+ ) -> None:
21
+ """Check DataMasque instance health."""
22
+ client = get_client(profile)
23
+ client.healthcheck()
24
+
25
+ if is_json:
26
+ print_json({"status": "healthy"})
27
+ else:
28
+ print_success("Instance is healthy.")
29
+
30
+
31
+ @app.command("licence")
32
+ def licence(
33
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
34
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
35
+ ) -> None:
36
+ """Show licence information."""
37
+ client = get_client(profile)
38
+ info = client.get_current_license_info()
39
+ # Project to the fields a user actually checks. The full pydantic dump
40
+ # also includes nested SwitchableLicenseMetadata which is noisy.
41
+ expiry = info.expiry_date.isoformat() if info.expiry_date else None
42
+ data = {
43
+ "uuid": info.uuid,
44
+ "name": info.name,
45
+ "type": info.type,
46
+ "is_expired": info.is_expired,
47
+ "expiry_date": expiry,
48
+ "days_until_expiry": info.days_until_expiry,
49
+ "platform_name": info.platform_name,
50
+ }
51
+ render_output(data, is_json=is_json, title="Licence")
52
+
53
+
54
+ @app.command()
55
+ def logs(
56
+ output_path: Path = typer.Option("datamasque-logs.tar.gz", "--output", "-o", help="Where to save the log file"),
57
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
58
+ ) -> None:
59
+ """Download application logs."""
60
+ client = get_client(profile)
61
+ client.retrieve_application_logs(output_path)
62
+ print_success(f"Logs saved to {output_path}")
63
+
64
+
65
+ @app.command("export", hidden=True, deprecated=True)
66
+ def export_config(
67
+ output_path: Path = typer.Option("datamasque-export.zip", "--output", "-o", help="Where to save the export"),
68
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
69
+ ) -> None:
70
+ """Deprecated alias for `dm rulesets export-bundle`."""
71
+ print_warning("`dm system export` is deprecated; use `dm rulesets export-bundle` instead.")
72
+ export_bundle(output_path=output_path, profile=profile)
73
+
74
+
75
+ @app.command("import", hidden=True, deprecated=True)
76
+ def import_config(
77
+ file: Path = typer.Option(..., "--file", "-f", help="Path to import file", exists=True, readable=True),
78
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
79
+ is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
80
+ ) -> None:
81
+ """Deprecated alias for `dm rulesets import-bundle`."""
82
+ print_warning("`dm system import` is deprecated; use `dm rulesets import-bundle` instead.")
83
+ import_bundle(file=file, profile=profile, is_confirmed=is_confirmed)
84
+
85
+
86
+ @app.command("upload-licence")
87
+ def upload_licence(
88
+ file: Path = typer.Argument(help="Path to .lic licence file", exists=True, readable=True),
89
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
90
+ ) -> None:
91
+ """Upload a DataMasque licence file."""
92
+ client = get_client(profile)
93
+ client.upload_license_file(str(file))
94
+ print_success(f"Licence file '{file.name}' uploaded.")
95
+
96
+
97
+ @app.command("admin-install")
98
+ def admin_install(
99
+ email: str = typer.Option(..., help="Admin email address"),
100
+ username: str = typer.Option("admin", help="Admin username"),
101
+ password: str = typer.Option(..., prompt=True, hide_input=True, help="Admin password"),
102
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
103
+ ) -> None:
104
+ """Initial admin setup for a fresh DataMasque instance."""
105
+ client = get_client(profile)
106
+ client.admin_install(email=email, username=username, password=password)
107
+ print_success(f"Admin user '{username}' created.")
108
+
109
+
110
+ @app.command("set-locality")
111
+ def set_locality(
112
+ locality: str = typer.Argument(help="Locality string to set"),
113
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
114
+ ) -> None:
115
+ """Set the system locality."""
116
+ client = get_client(profile)
117
+ client.set_locality(locality)
118
+ print_success(f"Locality set to '{locality}'.")
@@ -0,0 +1,86 @@
1
+ """User management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from datamasque.client.models.user import User, UserRole
7
+
8
+ from datamasque_cli.client import get_client
9
+ from datamasque_cli.output import abort, print_success, render_output
10
+
11
+ app = typer.Typer(help="Manage users.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_users(
16
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
17
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
18
+ ) -> None:
19
+ """List all users."""
20
+ client = get_client(profile)
21
+ users = client.list_users()
22
+
23
+ data = [
24
+ {
25
+ "id": u.id,
26
+ "username": u.username,
27
+ "email": u.email,
28
+ "roles": ", ".join(r.value for r in u.roles),
29
+ }
30
+ for u in users
31
+ ]
32
+
33
+ render_output(data, is_json=is_json, columns=["id", "username", "email", "roles"], title="Users")
34
+
35
+
36
+ @app.command("create")
37
+ def create_user(
38
+ username: str = typer.Option(..., help="Username"),
39
+ email: str = typer.Option(..., help="Email address"),
40
+ role: list[str] = typer.Option(..., help="Role(s): superuser, mask_builder, mask_runner"),
41
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
42
+ ) -> None:
43
+ """Create a new user."""
44
+ roles = [UserRole(r) for r in role]
45
+ user = User(username=username, email=email, password="", roles=roles)
46
+
47
+ client = get_client(profile)
48
+ created = client.create_or_update_user(user)
49
+ print_success(f"User '{username}' created. Temporary password: {created.password}")
50
+
51
+
52
+ @app.command("delete")
53
+ def delete_user(
54
+ username: str = typer.Argument(help="Username to delete"),
55
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
56
+ is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
57
+ ) -> None:
58
+ """Delete a user by username."""
59
+ client = get_client(profile)
60
+ users = client.list_users()
61
+
62
+ if not any(u.username == username for u in users):
63
+ abort(f"User '{username}' not found.")
64
+
65
+ if not is_confirmed:
66
+ typer.confirm(f"Delete user '{username}'?", abort=True)
67
+
68
+ client.delete_user_by_username_if_exists(username)
69
+ print_success(f"User '{username}' deleted.")
70
+
71
+
72
+ @app.command("reset-password")
73
+ def reset_password(
74
+ username: str = typer.Argument(help="Username to reset password for"),
75
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
76
+ ) -> None:
77
+ """Reset a user's password."""
78
+ client = get_client(profile)
79
+ users = client.list_users()
80
+
81
+ match = next((u for u in users if u.username == username), None)
82
+ if match is None:
83
+ abort(f"User '{username}' not found.")
84
+
85
+ new_password = client.reset_password_for_user(match)
86
+ print_success(f"Password reset for '{username}'. New temporary password: {new_password}")
@@ -0,0 +1,82 @@
1
+ """Profile-based configuration management.
2
+
3
+ Stores credentials and connection details in ~/.config/datamasque-cli/config.toml.
4
+ Supports named profiles (default, staging, prod, etc.).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tomllib
10
+ from pathlib import Path
11
+
12
+ import tomli_w
13
+ from pydantic import BaseModel, Field
14
+
15
+ CONFIG_DIR = Path.home() / ".config" / "datamasque-cli"
16
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
17
+ DEFAULT_PROFILE = "default"
18
+
19
+
20
+ class Profile(BaseModel):
21
+ url: str = ""
22
+ username: str = ""
23
+ password: str = ""
24
+ # Disable TLS verification for instances with self-signed or expired certs
25
+ # (typically local dev). Persisted to config so you don't re-pass it per call.
26
+ verify_ssl: bool = True
27
+
28
+ @property
29
+ def is_configured(self) -> bool:
30
+ return bool(self.url and self.username and self.password)
31
+
32
+
33
+ class Config(BaseModel):
34
+ profiles: dict[str, Profile] = Field(default_factory=dict)
35
+ active_profile: str = DEFAULT_PROFILE
36
+
37
+ def get_profile(self, name: str | None = None) -> Profile:
38
+ name = name or self.active_profile
39
+ # Return a fresh default for unknown names so callers can check
40
+ # `is_configured` without mutating the stored profile dict.
41
+ return self.profiles.get(name, Profile())
42
+
43
+ def set_profile(self, name: str, profile: Profile) -> None:
44
+ self.profiles[name] = profile
45
+
46
+ def delete_profile(self, name: str) -> bool:
47
+ if name not in self.profiles:
48
+ return False
49
+ del self.profiles[name]
50
+ return True
51
+
52
+ def list_profile_names(self) -> list[str]:
53
+ return list(self.profiles.keys())
54
+
55
+
56
+ def load_config() -> Config:
57
+ if not CONFIG_FILE.exists():
58
+ return Config()
59
+
60
+ with open(CONFIG_FILE, "rb") as f:
61
+ data = tomllib.load(f)
62
+
63
+ # Ignore unknown top-level or per-profile keys (e.g. the dead `verify_ssl`
64
+ # that older configs may still carry) so on-disk files written by previous
65
+ # releases keep loading cleanly.
66
+ profiles_raw = data.get("profiles", {}) or {}
67
+ profiles = {
68
+ name: Profile.model_validate({k: v for k, v in profile.items() if k in Profile.model_fields})
69
+ for name, profile in profiles_raw.items()
70
+ }
71
+ return Config(profiles=profiles, active_profile=data.get("active_profile", DEFAULT_PROFILE))
72
+
73
+
74
+ def save_config(config: Config) -> None:
75
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
76
+
77
+ data = config.model_dump()
78
+
79
+ with open(CONFIG_FILE, "wb") as f:
80
+ tomli_w.dump(data, f)
81
+
82
+ CONFIG_FILE.chmod(0o600)
datamasque_cli/main.py ADDED
@@ -0,0 +1,58 @@
1
+ """DataMasque CLI entry point.
2
+
3
+ Usage:
4
+ dm auth login
5
+ dm run start --connection mydb --ruleset myrules
6
+ dm run list --status running
7
+ dm rulesets list --json
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import version as pkg_version
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from datamasque_cli.commands import (
18
+ auth,
19
+ connections,
20
+ discovery,
21
+ files,
22
+ ruleset_libraries,
23
+ rulesets,
24
+ runs,
25
+ seeds,
26
+ system,
27
+ users,
28
+ )
29
+
30
+ app = typer.Typer(
31
+ name="dm",
32
+ help="DataMasque CLI — manage data masking from the command line.",
33
+ no_args_is_help=True,
34
+ )
35
+
36
+ app.add_typer(auth.app, name="auth")
37
+ app.add_typer(connections.app, name="connections")
38
+ app.add_typer(rulesets.app, name="rulesets")
39
+ app.add_typer(runs.app, name="run")
40
+ app.add_typer(users.app, name="users")
41
+ app.add_typer(discovery.app, name="discover")
42
+ app.add_typer(seeds.app, name="seeds")
43
+ app.add_typer(files.app, name="files")
44
+ app.add_typer(system.app, name="system")
45
+ app.add_typer(ruleset_libraries.app, name="libraries")
46
+
47
+
48
+ @app.command()
49
+ def version() -> None:
50
+ """Show the CLI version."""
51
+ console = Console(stderr=True)
52
+ console.print(" [#7B36F5]▷◁[/#7B36F5] ", end="")
53
+ console.print("[bold #7B36F5]DataMasque[/bold #7B36F5] CLI", end=" ")
54
+ typer.echo(f"v{pkg_version('datamasque-cli')}")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ app()
@@ -0,0 +1,133 @@
1
+ """Output formatting for the CLI.
2
+
3
+ Supports JSON output (--json) for machine consumption,
4
+ and rich tables for human-readable display.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, NoReturn
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ from rich.theme import Theme
16
+
17
+ _DM_THEME = Theme(
18
+ {
19
+ "status.finished": "bold green",
20
+ "status.finished_with_warnings": "bold yellow",
21
+ "status.running": "bold cyan",
22
+ "status.queued": "dim",
23
+ "status.failed": "bold red",
24
+ "status.cancelled": "dim strike",
25
+ }
26
+ )
27
+
28
+ # Diagnostic messages go to stderr so piped JSON output stays clean.
29
+ console = Console(stderr=True, theme=_DM_THEME)
30
+ stdout_console = Console(theme=_DM_THEME)
31
+
32
+
33
+ # Any top-level field whose lowercased name contains one of these substrings
34
+ # is replaced by `<redacted>` when a value dict passes through
35
+ # `redact_sensitive_fields`. Matches datamasque-python's SENSITIVE_REQUEST_DATA_KEYS.
36
+ _SENSITIVE_FIELD_SUBSTRINGS = ("password", "secret", "token", "key", "credential")
37
+ _REDACTED = "<redacted>"
38
+
39
+
40
+ def redact_sensitive_fields(data: dict[str, Any]) -> dict[str, Any]:
41
+ """Return a copy of `data` with values of sensitive-named keys replaced by `<redacted>`.
42
+
43
+ Matches any key whose lowercased name contains `password`, `secret`, `token`,
44
+ `key`, or `credential`. Does not recurse into nested dicts/lists.
45
+ """
46
+ return {
47
+ key: _REDACTED if any(word in key.lower() for word in _SENSITIVE_FIELD_SUBSTRINGS) else value
48
+ for key, value in data.items()
49
+ }
50
+
51
+
52
+ def print_json(data: object) -> None:
53
+ typer.echo(json.dumps(data, indent=2, default=str))
54
+
55
+
56
+ def print_table(
57
+ columns: list[str],
58
+ rows: list[list[Any]],
59
+ title: str | None = None,
60
+ ) -> None:
61
+ table = Table(title=title, show_header=True, header_style="bold cyan")
62
+ for col in columns:
63
+ table.add_column(col)
64
+ for row in rows:
65
+ table.add_row(*[str(v) if v is not None else "" for v in row])
66
+ stdout_console.print(table)
67
+
68
+
69
+ def print_kv(data: dict[str, Any], title: str | None = None) -> None:
70
+ """Print key-value pairs as a two-column table."""
71
+ table = Table(title=title, show_header=False, show_edge=False, padding=(0, 2))
72
+ table.add_column("Key", style="bold")
73
+ table.add_column("Value")
74
+ for key, value in data.items():
75
+ table.add_row(key, str(value) if value is not None else "")
76
+ stdout_console.print(table)
77
+
78
+
79
+ def print_success(message: str) -> None:
80
+ console.print(f"[green]{message}[/green]")
81
+
82
+
83
+ def print_error(message: str) -> None:
84
+ console.print(f"[red]Error:[/red] {message}")
85
+
86
+
87
+ def print_warning(message: str) -> None:
88
+ console.print(f"[yellow]Warning:[/yellow] {message}")
89
+
90
+
91
+ def print_info(message: str) -> None:
92
+ console.print(f"[dim]{message}[/dim]")
93
+
94
+
95
+ def style_status(status: str) -> str:
96
+ """Wrap a run status string in the appropriate colour tag."""
97
+ style_name = f"status.{status}"
98
+ return f"[{style_name}]{status}[/{style_name}]"
99
+
100
+
101
+ def render_output(
102
+ data: object,
103
+ *,
104
+ is_json: bool,
105
+ columns: list[str] | None = None,
106
+ title: str | None = None,
107
+ ) -> None:
108
+ """Unified output dispatcher.
109
+
110
+ When `is_json` is True, dumps `data` as JSON to stdout.
111
+ Otherwise renders a rich table from a list-of-dicts or a key-value dict.
112
+ """
113
+ if is_json:
114
+ print_json(data)
115
+ return
116
+
117
+ if not data:
118
+ print_info("No results.")
119
+ return
120
+
121
+ if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict):
122
+ cols = columns or list(data[0].keys())
123
+ rows = [[item.get(c) for c in cols] for item in data]
124
+ print_table(cols, rows, title=title)
125
+ elif isinstance(data, dict):
126
+ print_kv(data, title=title)
127
+ else:
128
+ typer.echo(data)
129
+
130
+
131
+ def abort(message: str) -> NoReturn:
132
+ print_error(message)
133
+ raise SystemExit(1)
File without changes