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,136 @@
1
+ """Schema discovery and sensitive data discovery commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from datamasque.client import DataMasqueClient, RunId
10
+ from datamasque.client.models.connection import ConnectionId
11
+ from datamasque.client.models.discovery import SchemaDiscoveryRequest
12
+
13
+ from datamasque_cli.client import get_client
14
+ from datamasque_cli.output import abort, print_json, print_success, render_output
15
+
16
+ app = typer.Typer(help="Data discovery operations.")
17
+
18
+
19
+ def _write_or_echo(content: str, output: Path | None, success_label: str) -> None:
20
+ """Write `content` to `output` when given, otherwise echo to stdout."""
21
+ if output is None:
22
+ typer.echo(content)
23
+ return
24
+ output.write_text(content)
25
+ print_success(f"{success_label} written to {output}")
26
+
27
+
28
+ def _resolve_connection_id(client: DataMasqueClient, name_or_id: str) -> str:
29
+ """Resolve a connection name or ID to its UUID string."""
30
+ match = next((c for c in client.list_connections() if c.name == name_or_id or str(c.id) == name_or_id), None)
31
+ if match is None:
32
+ abort(f"Connection '{name_or_id}' not found.")
33
+ return str(match.id)
34
+
35
+
36
+ @app.command("schema")
37
+ def schema_discovery(
38
+ connection: str = typer.Argument(help="Connection name or ID"),
39
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
40
+ ) -> None:
41
+ """Start a schema-discovery run on a connection.
42
+
43
+ Results are scoped to a run ID, not a connection, so use
44
+ `dm discover schema-results <run-id>` once this run reaches a terminal state
45
+ (poll with `dm run status <run-id>`).
46
+ """
47
+ client = get_client(profile)
48
+ conn_id = _resolve_connection_id(client, connection)
49
+
50
+ request = SchemaDiscoveryRequest(connection=ConnectionId(conn_id))
51
+ run_id = client.start_schema_discovery_run(request)
52
+ print_success(
53
+ f"Schema discovery run {run_id} started for connection '{connection}'. "
54
+ f"Once finished, list results with: dm discover schema-results {run_id}"
55
+ )
56
+
57
+
58
+ @app.command("schema-results")
59
+ def schema_results(
60
+ run_id: int = typer.Argument(help="Schema discovery run ID"),
61
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
62
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
63
+ ) -> None:
64
+ """List schema-discovery results for a completed run (paginates server-side).
65
+
66
+ Surfaces the detected `data_type`, the comma-joined classifier `matches`,
67
+ and the column `constraint` (PK/UK/empty) from each result so the table
68
+ output reflects what discovery actually found.
69
+ """
70
+ client = get_client(profile)
71
+ results = client.list_schema_discovery_results(RunId(run_id))
72
+
73
+ data = [
74
+ {
75
+ "id": r.id,
76
+ "schema": r.schema_name or "",
77
+ "table": r.table,
78
+ "column": r.column,
79
+ "data_type": r.data.data_type or "",
80
+ "matches": ", ".join(m.label for m in r.data.discovery_matches) or "-",
81
+ "constraint": r.data.constraint or "",
82
+ }
83
+ for r in results
84
+ ]
85
+ render_output(
86
+ data,
87
+ is_json=is_json,
88
+ columns=["id", "schema", "table", "column", "data_type", "matches", "constraint"],
89
+ title=f"Schema Discovery: Run {run_id}",
90
+ )
91
+
92
+
93
+ @app.command("sdd-report")
94
+ def sdd_report(
95
+ run_id: int = typer.Argument(help="Run ID"),
96
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write CSV to this path"),
97
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
98
+ ) -> None:
99
+ """Download sensitive data discovery report for a run."""
100
+ client = get_client(profile)
101
+ report = client.get_sdd_report(RunId(run_id))
102
+ _write_or_echo(report, output, "SDD report")
103
+
104
+
105
+ @app.command("db-report")
106
+ def db_discovery_report(
107
+ run_id: int = typer.Argument(help="Run ID"),
108
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write CSV to this path"),
109
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
110
+ ) -> None:
111
+ """Download database discovery report (CSV) for a run."""
112
+ client = get_client(profile)
113
+ report = client.get_db_discovery_result_report(RunId(run_id))
114
+ _write_or_echo(report, output, "Database discovery report")
115
+
116
+
117
+ @app.command("file-report")
118
+ def file_discovery_report(
119
+ run_id: int = typer.Argument(help="Run ID"),
120
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write JSON to this path"),
121
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
122
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
123
+ ) -> None:
124
+ """Download file discovery report for a run."""
125
+ client = get_client(profile)
126
+ report = client.get_file_data_discovery_report(RunId(run_id))
127
+
128
+ if output is not None:
129
+ output.write_text(json.dumps(report, indent=2, default=str))
130
+ print_success(f"File discovery report written to {output}")
131
+ return
132
+
133
+ if is_json:
134
+ print_json(report)
135
+ else:
136
+ render_output(report, is_json=False, title=f"File Discovery: Run {run_id}")
@@ -0,0 +1,77 @@
1
+ """File management commands (Oracle wallets, Snowflake keys, etc.)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from datamasque.client.models.files import DataMasqueFile, SnowflakeKeyFile
9
+
10
+ from datamasque_cli.client import get_client
11
+ from datamasque_cli.output import abort, print_success, render_output
12
+
13
+ app = typer.Typer(help="Manage uploaded files (Oracle wallets, Snowflake keys).")
14
+
15
+ # Map user-friendly type names to their classes.
16
+ _FILE_TYPES: dict[str, type[DataMasqueFile]] = {
17
+ "snowflake-key": SnowflakeKeyFile,
18
+ }
19
+
20
+
21
+ def _resolve_file_type(type_name: str) -> type[DataMasqueFile]:
22
+ if type_name not in _FILE_TYPES:
23
+ valid = ", ".join(_FILE_TYPES)
24
+ msg = f"Unknown file type '{type_name}'. Valid types: {valid}"
25
+ raise typer.BadParameter(msg)
26
+ return _FILE_TYPES[type_name]
27
+
28
+
29
+ @app.command("list")
30
+ def list_files(
31
+ file_type: str = typer.Argument(help="File type: snowflake-key"),
32
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
33
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
34
+ ) -> None:
35
+ """List uploaded files of a given type."""
36
+ klass = _resolve_file_type(file_type)
37
+ client = get_client(profile)
38
+ files = client.list_files_of_type(klass)
39
+
40
+ data = [{"id": f.id, "name": f.name} for f in files]
41
+ render_output(data, is_json=is_json, columns=["id", "name"], title=f"Files ({file_type})")
42
+
43
+
44
+ @app.command("delete")
45
+ def delete_file(
46
+ file_type: str = typer.Argument(help="File type: snowflake-key"),
47
+ name: str = typer.Argument(help="File name"),
48
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
49
+ is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
50
+ ) -> None:
51
+ """Delete a previously uploaded file by name."""
52
+ klass = _resolve_file_type(file_type)
53
+ client = get_client(profile)
54
+
55
+ match = client.get_file_of_type_by_name(klass, name)
56
+ if match is None:
57
+ abort(f"File '{name}' ({file_type}) not found.")
58
+
59
+ if not is_confirmed:
60
+ typer.confirm(f"Delete file '{name}' ({file_type})?", abort=True)
61
+
62
+ client.delete_file_if_exists(match)
63
+ print_success(f"File '{name}' deleted.")
64
+
65
+
66
+ @app.command("upload")
67
+ def upload_file(
68
+ file_type: str = typer.Argument(help="File type: snowflake-key"),
69
+ file: Path = typer.Option(..., "--file", "-f", help="Path to file", exists=True, readable=True),
70
+ name: str = typer.Option(..., help="Name for the uploaded file"),
71
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
72
+ ) -> None:
73
+ """Upload a file."""
74
+ klass = _resolve_file_type(file_type)
75
+ client = get_client(profile)
76
+ client.upload_file(klass, name, file)
77
+ print_success(f"File '{name}' uploaded as {file_type}.")
@@ -0,0 +1,156 @@
1
+ """Ruleset library management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from datamasque.client.models.ruleset_library import RulesetLibrary
9
+
10
+ from datamasque_cli.client import get_client
11
+ from datamasque_cli.output import abort, print_success, render_output
12
+
13
+ app = typer.Typer(help="Manage ruleset libraries.")
14
+
15
+
16
+ @app.command("list")
17
+ def list_libraries(
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
+ """List all ruleset libraries."""
22
+ client = get_client(profile)
23
+ libraries = client.list_ruleset_libraries()
24
+
25
+ data = [
26
+ {
27
+ "id": lib.id,
28
+ "namespace": lib.namespace or "",
29
+ "name": lib.name,
30
+ "valid": lib.is_valid.value if lib.is_valid else "unknown",
31
+ }
32
+ for lib in libraries
33
+ ]
34
+
35
+ render_output(data, is_json=is_json, columns=["id", "namespace", "name", "valid"], title="Ruleset Libraries")
36
+
37
+
38
+ @app.command("get")
39
+ def get_library(
40
+ name: str = typer.Argument(help="Library name"),
41
+ namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
42
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
43
+ is_yaml: bool = typer.Option(False, "--yaml", help="Output raw YAML content only"),
44
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
45
+ ) -> None:
46
+ """Show a library's details or YAML content."""
47
+ client = get_client(profile)
48
+ lib = client.get_ruleset_library_by_name(name, namespace)
49
+
50
+ if lib is None:
51
+ label = f"{namespace}/{name}" if namespace else name
52
+ abort(f"Library '{label}' not found.")
53
+
54
+ if is_yaml:
55
+ typer.echo(lib.yaml)
56
+ return
57
+
58
+ data: dict[str, object] = {
59
+ "id": lib.id,
60
+ "namespace": lib.namespace,
61
+ "name": lib.name,
62
+ "valid": lib.is_valid.value if lib.is_valid else "unknown",
63
+ "created": lib.created,
64
+ "modified": lib.modified,
65
+ }
66
+ render_output(data, is_json=is_json, title=f"Library: {lib.name}")
67
+
68
+
69
+ @app.command("create")
70
+ def create_library(
71
+ name: str = typer.Option(..., help="Library name"),
72
+ file: Path = typer.Option(..., "--file", "-f", help="Path to YAML library file", exists=True, readable=True),
73
+ namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
74
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
75
+ ) -> None:
76
+ """Create or update a ruleset library from a YAML file."""
77
+ yaml_content = file.read_text()
78
+ client = get_client(profile)
79
+
80
+ library = RulesetLibrary(name=name, namespace=namespace, yaml=yaml_content)
81
+ client.create_or_update_ruleset_library(library)
82
+ print_success(f"Library '{name}' created/updated.")
83
+
84
+
85
+ @app.command("delete")
86
+ def delete_library(
87
+ name: str = typer.Argument(help="Library name to delete"),
88
+ namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
89
+ force: bool = typer.Option(False, "--force", help="Force delete even if imported by rulesets"),
90
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
91
+ is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
92
+ ) -> None:
93
+ """Delete a ruleset library by name."""
94
+ label = f"{namespace}/{name}" if namespace else name
95
+
96
+ client = get_client(profile)
97
+ if client.get_ruleset_library_by_name(name, namespace) is None:
98
+ abort(f"Library '{label}' not found.")
99
+
100
+ if not is_confirmed:
101
+ typer.confirm(f"Delete library '{label}'?", abort=True)
102
+
103
+ client.delete_ruleset_library_by_name_if_exists(name, namespace, force=force)
104
+ print_success(f"Library '{label}' deleted.")
105
+
106
+
107
+ @app.command("validate")
108
+ def validate_library(
109
+ name: str = typer.Argument(help="Library name"),
110
+ namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
111
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
112
+ ) -> None:
113
+ """Re-validate a ruleset library against the current server schema.
114
+
115
+ Triggers a server-side validation pass on an existing library and reports the result.
116
+ """
117
+ client = get_client(profile)
118
+ lib = client.get_ruleset_library_by_name(name, namespace)
119
+
120
+ if lib is None:
121
+ label = f"{namespace}/{name}" if namespace else name
122
+ abort(f"Library '{label}' not found.")
123
+
124
+ validated = client.validate_ruleset_library(lib.id)
125
+ status = validated.is_valid.value if validated.is_valid else "unknown"
126
+ label = f"{namespace}/{name}" if namespace else name
127
+ print_success(f"Library '{label}' validation status: {status}")
128
+
129
+
130
+ @app.command("usage")
131
+ def library_usage(
132
+ name: str = typer.Argument(help="Library name"),
133
+ namespace: str = typer.Option("", "--namespace", "-n", help="Library namespace"),
134
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
135
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
136
+ ) -> None:
137
+ """Show which rulesets import a given library."""
138
+ client = get_client(profile)
139
+ lib = client.get_ruleset_library_by_name(name, namespace)
140
+
141
+ if lib is None:
142
+ label = f"{namespace}/{name}" if namespace else name
143
+ abort(f"Library '{label}' not found.")
144
+
145
+ rulesets = client.list_rulesets_using_library(lib.id)
146
+
147
+ data = [
148
+ {
149
+ "id": rs.id,
150
+ "name": rs.name,
151
+ "type": rs.ruleset_type.value,
152
+ }
153
+ for rs in rulesets
154
+ ]
155
+
156
+ render_output(data, is_json=is_json, columns=["id", "name", "type"], title=f"Rulesets using '{name}'")
@@ -0,0 +1,303 @@
1
+ """Ruleset management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from datamasque.client import DataMasqueClient
11
+ from datamasque.client.base import UploadFile
12
+ from datamasque.client.exceptions import DataMasqueApiError
13
+ from datamasque.client.models.ruleset import Ruleset, RulesetType
14
+
15
+ from datamasque_cli.client import get_client
16
+ from datamasque_cli.output import abort, print_error, print_info, print_success, print_warning, render_output
17
+
18
+ app = typer.Typer(help="Manage masking rulesets.")
19
+
20
+
21
+ def _find_by_name(
22
+ client: DataMasqueClient,
23
+ name: str,
24
+ mask_type: RulesetType | None = None,
25
+ ) -> list[Ruleset]:
26
+ """Return all rulesets matching `name`, optionally narrowed by `mask_type`."""
27
+ matches = [rs for rs in client.list_rulesets() if rs.name == name]
28
+ if mask_type is not None:
29
+ matches = [rs for rs in matches if rs.ruleset_type == mask_type]
30
+ return matches
31
+
32
+
33
+ def _pick_single(matches: list[Ruleset], name: str) -> Ruleset:
34
+ """Return the sole match or abort with a disambiguation message."""
35
+ if not matches:
36
+ abort(f"Ruleset '{name}' not found.")
37
+ if len(matches) > 1:
38
+ options = "\n ".join(f"id={rs.id} type={rs.ruleset_type.value}" for rs in matches)
39
+ abort(f"Multiple rulesets named '{name}':\n {options}\nPass --type file|database to disambiguate.")
40
+ return matches[0]
41
+
42
+
43
+ @app.command("list")
44
+ def list_rulesets(
45
+ ruleset_type: str | None = typer.Option(None, "--type", "-t", help="Filter by type: database or file"),
46
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
47
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
48
+ ) -> None:
49
+ """List all rulesets."""
50
+ client = get_client(profile)
51
+ rulesets = client.list_rulesets()
52
+
53
+ if ruleset_type is not None:
54
+ wanted = RulesetType(ruleset_type)
55
+ rulesets = [rs for rs in rulesets if rs.ruleset_type == wanted]
56
+
57
+ data = [
58
+ {
59
+ "id": rs.id,
60
+ "name": rs.name,
61
+ "type": rs.ruleset_type.value,
62
+ }
63
+ for rs in rulesets
64
+ ]
65
+
66
+ render_output(data, is_json=is_json, columns=["id", "name", "type"], title="Rulesets")
67
+
68
+
69
+ @app.command("get")
70
+ def get_ruleset(
71
+ name: str = typer.Argument(help="Ruleset name"),
72
+ ruleset_type: str | None = typer.Option(None, "--type", "-t", help="Required when two rulesets share a name"),
73
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
74
+ is_yaml: bool = typer.Option(False, "--yaml", help="Output raw YAML content only"),
75
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
76
+ ) -> None:
77
+ """Show a ruleset's details or YAML content."""
78
+ client = get_client(profile)
79
+ wanted = RulesetType(ruleset_type) if ruleset_type is not None else None
80
+ match = _pick_single(_find_by_name(client, name, wanted), name)
81
+
82
+ # `list_rulesets` omits the YAML body for performance; fetch the single ruleset
83
+ # to populate `yaml` via the Ruleset pydantic model's `config_yaml` alias.
84
+ response = client.make_request("GET", f"/api/rulesets/{match.id}/")
85
+ full = Ruleset.model_validate(response.json())
86
+
87
+ if is_yaml:
88
+ typer.echo(full.yaml)
89
+ return
90
+
91
+ data: dict[str, object] = {
92
+ "id": full.id,
93
+ "name": full.name,
94
+ "type": full.ruleset_type.value,
95
+ "yaml": full.yaml,
96
+ }
97
+ render_output(data, is_json=is_json, title=f"Ruleset: {name}")
98
+
99
+
100
+ @app.command("create")
101
+ def create_ruleset(
102
+ name: str = typer.Option(..., help="Ruleset name"),
103
+ file: Path = typer.Option(..., "--file", "-f", help="Path to YAML ruleset file", exists=True, readable=True),
104
+ ruleset_type: str | None = typer.Option(
105
+ None,
106
+ "--type",
107
+ "-t",
108
+ help=(
109
+ "Ruleset type: database or file. "
110
+ "Required when the ruleset does not yet exist; defaults to the existing type on updates."
111
+ ),
112
+ ),
113
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
114
+ ) -> None:
115
+ """Create or update a ruleset from a YAML file.
116
+
117
+ Read the server first to decide which namespace the upload belongs in. A
118
+ brand-new ruleset needs --type because there is no stored row to copy the
119
+ type from; an update defaults to whatever the existing row is stored as.
120
+ """
121
+ client = get_client(profile)
122
+ existing = _find_by_name(client, name)
123
+ explicit = RulesetType(ruleset_type) if ruleset_type is not None else None
124
+
125
+ if explicit is not None:
126
+ rs_type = explicit
127
+ elif len(existing) == 1:
128
+ rs_type = existing[0].ruleset_type
129
+ print_info(f"Updating existing {rs_type.value}-type ruleset '{name}'.")
130
+ elif not existing:
131
+ abort(f"No ruleset named '{name}' exists. Pass --type file|database to create a new one.")
132
+ else:
133
+ options = ", ".join(r.ruleset_type.value for r in existing)
134
+ abort(f"Multiple rulesets named '{name}' ({options}). Pass --type file|database to pick which one to update.")
135
+
136
+ yaml_content = file.read_text()
137
+ ruleset = Ruleset(name=name, yaml=yaml_content, ruleset_type=rs_type)
138
+ client.create_or_update_ruleset(ruleset)
139
+ print_success(f"Ruleset '{name}' ({rs_type.value}) created/updated.")
140
+
141
+
142
+ @app.command("delete")
143
+ def delete_ruleset(
144
+ name: str = typer.Argument(help="Ruleset name to delete"),
145
+ ruleset_type: str | None = typer.Option(None, "--type", "-t", help="Required when two rulesets share a name"),
146
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
147
+ is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
148
+ ) -> None:
149
+ """Delete a ruleset by name."""
150
+ client = get_client(profile)
151
+ wanted = RulesetType(ruleset_type) if ruleset_type is not None else None
152
+ match = _pick_single(_find_by_name(client, name, wanted), name)
153
+
154
+ if not is_confirmed:
155
+ typer.confirm(f"Delete ruleset '{name}' ({match.ruleset_type.value})?", abort=True)
156
+
157
+ assert match.id is not None # Populated by list_rulesets
158
+ client.delete_ruleset_by_id_if_exists(match.id)
159
+ print_success(f"Ruleset '{name}' ({match.ruleset_type.value}) deleted.")
160
+
161
+
162
+ @app.command("validate")
163
+ def validate_ruleset(
164
+ file: Path = typer.Option(..., "--file", "-f", help="Path to YAML ruleset file", exists=True, readable=True),
165
+ ruleset_type: str = typer.Option(
166
+ ...,
167
+ "--type",
168
+ "-t",
169
+ help="Ruleset type: database or file",
170
+ ),
171
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
172
+ ) -> None:
173
+ """Validate a ruleset YAML file against the DataMasque server.
174
+
175
+ Creates a temporary ruleset to trigger server-side validation,
176
+ then deletes it. Reports any validation errors.
177
+ """
178
+ yaml_content = file.read_text()
179
+ rs_type = RulesetType(ruleset_type)
180
+ # `uuid` guards against collisions between concurrent `validate` runs.
181
+ temp_name = f"__dm_cli_validate_{uuid.uuid4().hex}"
182
+
183
+ client = get_client(profile)
184
+ ruleset = Ruleset(name=temp_name, yaml=yaml_content, ruleset_type=rs_type)
185
+
186
+ try:
187
+ created = client.create_or_update_ruleset(ruleset)
188
+ except DataMasqueApiError as exc:
189
+ print_error(f"Validation failed: {exc}")
190
+ raise SystemExit(1) from None
191
+
192
+ # `try/finally` so a Ctrl-C or unexpected exception between create and
193
+ # delete still cleans up the temp ruleset on the server.
194
+ try:
195
+ print_success(f"Ruleset '{file.name}' ({rs_type.value}) is valid.")
196
+ finally:
197
+ if created.id is not None:
198
+ try:
199
+ client.delete_ruleset_by_id_if_exists(created.id)
200
+ except DataMasqueApiError as exc:
201
+ print_warning(f"Validation ruleset '{temp_name}' left on server; delete manually. Reason: {exc}")
202
+
203
+
204
+ @app.command("export-bundle")
205
+ def export_bundle(
206
+ output_path: Path = typer.Option("datamasque-bundle.zip", "--output", "-o", help="Where to save the ZIP bundle"),
207
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
208
+ ) -> None:
209
+ """Export rulesets, ruleset libraries, and any referenced seed files as a ZIP bundle.
210
+
211
+ Bundles only rulesets + libraries + optional seed files — not connections,
212
+ users, licences, or instance settings.
213
+ """
214
+ client = get_client(profile)
215
+ # `export_configuration` is not yet wrapped in datamasque-python; hit the endpoint directly.
216
+ response = client.make_request("GET", "/api/export/v1/")
217
+ output_path.write_bytes(response.content)
218
+ print_success(f"Bundle exported to {output_path}")
219
+
220
+
221
+ @app.command("import-bundle")
222
+ def import_bundle(
223
+ file: Path = typer.Option(..., "--file", "-f", help="Path to bundle ZIP", exists=True, readable=True),
224
+ overwrite_rulesets: bool = typer.Option(
225
+ False, "--overwrite-rulesets", help="Overwrite rulesets that already exist with the same name"
226
+ ),
227
+ overwrite_libraries: bool = typer.Option(
228
+ False, "--overwrite-libraries", help="Overwrite ruleset libraries that already exist with the same name"
229
+ ),
230
+ overwrite_seeds: bool = typer.Option(
231
+ False, "--overwrite-seeds", help="Overwrite seed files that already exist with the same name"
232
+ ),
233
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
234
+ is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
235
+ ) -> None:
236
+ """Import rulesets, ruleset libraries, and seed files from a ZIP bundle.
237
+
238
+ Bundle must have been produced by `dm rulesets export-bundle` or the
239
+ server's `/api/export/v1/` endpoint. By default, duplicates are skipped;
240
+ pass `--overwrite-*` flags to replace existing entries.
241
+ """
242
+ if not is_confirmed:
243
+ typer.confirm("This will modify rulesets, libraries, and seed files. Continue?", abort=True)
244
+
245
+ client = get_client(profile)
246
+ # `/api/import/v1/` expects a multipart `zip_archive` upload plus three
247
+ # optional boolean overwrite flags; `import_configuration` is not yet
248
+ # wrapped in datamasque-python, so we call `make_request` directly.
249
+ with file.open("rb") as zip_content:
250
+ response = client.make_request(
251
+ "POST",
252
+ "/api/import/v1/",
253
+ data={
254
+ "overwrite_rulesets": str(overwrite_rulesets).lower(),
255
+ "overwrite_libraries": str(overwrite_libraries).lower(),
256
+ "overwrite_seed_files": str(overwrite_seeds).lower(),
257
+ },
258
+ files=[
259
+ UploadFile(
260
+ field_name="zip_archive",
261
+ filename=file.name,
262
+ content=zip_content,
263
+ content_type="application/zip",
264
+ ),
265
+ ],
266
+ )
267
+
268
+ print_success("Bundle imported successfully.")
269
+ if response.content:
270
+ try:
271
+ summary = response.json()
272
+ except ValueError:
273
+ return
274
+ if isinstance(summary, dict):
275
+ render_output(summary, is_json=False, title="Import summary")
276
+
277
+
278
+ @app.command("generate")
279
+ def generate_ruleset(
280
+ request_file: Path = typer.Option(
281
+ ..., "--file", "-f", help="Path to JSON generation request", exists=True, readable=True
282
+ ),
283
+ is_file_ruleset: bool = typer.Option(False, "--file-ruleset", help="Generate a file masking ruleset"),
284
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write generated YAML to file"),
285
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
286
+ ) -> None:
287
+ """Generate a ruleset from a generation request (JSON).
288
+
289
+ The request JSON format matches the DataMasque API's /api/generate-ruleset/v2/ endpoint.
290
+ """
291
+ client = get_client(profile)
292
+ generation_request = json.loads(request_file.read_text())
293
+
294
+ if is_file_ruleset:
295
+ yaml_content = client.generate_file_ruleset(generation_request)
296
+ else:
297
+ yaml_content = client.generate_ruleset(generation_request)
298
+
299
+ if output is not None:
300
+ output.write_text(yaml_content)
301
+ print_success(f"Generated ruleset written to {output}")
302
+ else:
303
+ typer.echo(yaml_content)