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.
File without changes
@@ -0,0 +1,109 @@
1
+ """Authenticated DataMasque client factory.
2
+
3
+ Resolves credentials from environment variables or the active profile,
4
+ builds a `DataMasqueClient`, and authenticates it before returning.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from datamasque.client import DataMasqueClient
12
+ from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError
13
+ from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
14
+
15
+ from datamasque_cli.config import Config, Profile, load_config
16
+ from datamasque_cli.output import abort
17
+
18
+ ENV_URL = "DATAMASQUE_URL"
19
+ ENV_USERNAME = "DATAMASQUE_USERNAME"
20
+ ENV_PASSWORD = "DATAMASQUE_PASSWORD"
21
+ ENV_VERIFY_SSL = "DATAMASQUE_VERIFY_SSL"
22
+
23
+ # `false`-y env values that disable TLS verification.
24
+ _FALSE_VALUES = frozenset({"false", "0", "no", "off"})
25
+
26
+
27
+ def _verify_ssl_from_env(default: bool) -> bool:
28
+ """Resolve `verify_ssl` from `DATAMASQUE_VERIFY_SSL`, falling back to `default`."""
29
+ raw = os.environ.get(ENV_VERIFY_SSL)
30
+ if raw is None:
31
+ return default
32
+ return raw.strip().lower() not in _FALSE_VALUES
33
+
34
+
35
+ def profile_from_env() -> Profile | None:
36
+ """Build a profile from environment variables, or return None if not set."""
37
+ url = os.environ.get(ENV_URL)
38
+ username = os.environ.get(ENV_USERNAME)
39
+ password = os.environ.get(ENV_PASSWORD)
40
+ if url and username and password:
41
+ return Profile(
42
+ url=url.rstrip("/"),
43
+ username=username,
44
+ password=password,
45
+ verify_ssl=_verify_ssl_from_env(default=True),
46
+ )
47
+ return None
48
+
49
+
50
+ def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
51
+ profile = config.get_profile(profile_name)
52
+ if not profile.is_configured:
53
+ name = profile_name or config.active_profile
54
+ abort(
55
+ f"Profile '{name}' is not configured. "
56
+ f"Run: dm auth login --profile {name} --url <URL> --username <USER>\n"
57
+ f"Or set {ENV_URL}, {ENV_USERNAME}, and {ENV_PASSWORD} environment variables."
58
+ )
59
+ return profile
60
+
61
+
62
+ def get_client(profile_name: str | None = None) -> DataMasqueClient:
63
+ """Build and authenticate a `DataMasqueClient`.
64
+
65
+ Credential resolution order:
66
+ 1. Environment variables (DATAMASQUE_URL, DATAMASQUE_USERNAME, DATAMASQUE_PASSWORD)
67
+ 2. Named profile (--profile flag)
68
+ 3. Active profile from config file
69
+ """
70
+ # Env vars take precedence unless a specific profile was requested.
71
+ env_profile = profile_from_env() if profile_name is None else None
72
+ if env_profile is not None:
73
+ profile = env_profile
74
+ else:
75
+ config = load_config()
76
+ profile = _resolve_profile(config, profile_name)
77
+
78
+ # `DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
79
+ # flip TLS verification per-call without re-running `dm auth login`.
80
+ verify_ssl = _verify_ssl_from_env(default=profile.verify_ssl)
81
+ instance_config = DataMasqueInstanceConfig(
82
+ base_url=profile.url,
83
+ username=profile.username,
84
+ password=profile.password,
85
+ verify_ssl=verify_ssl,
86
+ )
87
+
88
+ client = DataMasqueClient(instance_config)
89
+
90
+ try:
91
+ client.authenticate()
92
+ except DataMasqueTransportError as e:
93
+ abort(_format_transport_error(profile.url, e, verify_ssl=verify_ssl))
94
+ except DataMasqueApiError as e:
95
+ abort(f"Authentication failed: {e}")
96
+
97
+ return client
98
+
99
+
100
+ # Substrings that suggest the underlying error was a TLS failure rather than
101
+ # a plain network outage, so we can point local-build users at `--insecure`.
102
+ _SSL_HINT_TERMS = ("ssl", "certificate", "verify")
103
+
104
+
105
+ def _format_transport_error(url: str, error: Exception, *, verify_ssl: bool) -> str:
106
+ message = f"Could not connect to {url}: {error}"
107
+ if verify_ssl and any(term in str(error).lower() for term in _SSL_HINT_TERMS):
108
+ message += "\nIf this is a self-signed local build, retry with --insecure or set DATAMASQUE_VERIFY_SSL=false."
109
+ return message
File without changes
@@ -0,0 +1,154 @@
1
+ """Authentication and profile management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from datamasque_cli.client import get_client, profile_from_env
8
+ from datamasque_cli.config import DEFAULT_PROFILE, Profile, load_config, save_config
9
+ from datamasque_cli.output import abort, print_info, print_success, print_table
10
+
11
+ # `login` and `status` handle connection errors locally
12
+ # because they need softer behaviour than `get_client`'s hard abort:
13
+ # login saves credentials even when the server is unreachable,
14
+ # and status prints profile info before attempting connection.
15
+
16
+ app = typer.Typer(help="Authentication and profile management.")
17
+
18
+
19
+ @app.command()
20
+ def login(
21
+ profile: str = typer.Option("default", help="Profile name to store credentials under"),
22
+ is_insecure: bool = typer.Option(
23
+ False,
24
+ "--insecure",
25
+ help="Skip TLS verification when talking to this instance (self-signed / expired cert).",
26
+ ),
27
+ ) -> None:
28
+ """Save credentials for a DataMasque instance.
29
+
30
+ Fully interactive: prompts for URL, username, and password. Credentials
31
+ are saved to `~/.config/datamasque-cli/config.toml` (mode 0600). For
32
+ non-interactive / CI use, set `DATAMASQUE_URL`, `DATAMASQUE_USERNAME`,
33
+ and `DATAMASQUE_PASSWORD` env vars instead — `dm` reads them directly
34
+ without a saved profile. Pair with `DATAMASQUE_VERIFY_SSL=false` to
35
+ skip TLS verification on a per-call basis.
36
+ """
37
+ url = typer.prompt("DataMasque URL").rstrip("/")
38
+ if not url.startswith(("http://", "https://")):
39
+ abort(f"URL must start with http:// or https:// (got '{url}').")
40
+
41
+ username = typer.prompt("Username")
42
+ password = typer.prompt("Password", hide_input=True)
43
+ config = load_config()
44
+
45
+ config.set_profile(
46
+ profile,
47
+ Profile(url=url, username=username, password=password, verify_ssl=not is_insecure),
48
+ )
49
+ config.active_profile = profile
50
+ save_config(config)
51
+ print_success(f"Credentials saved to profile '{profile}'.")
52
+
53
+ print_info("Verifying connection...")
54
+ try:
55
+ get_client(profile)
56
+ except SystemExit:
57
+ # Credentials are already saved; connection verification is best-effort.
58
+ return
59
+ print_success("Authentication successful.")
60
+
61
+
62
+ @app.command()
63
+ def logout(
64
+ profile: str | None = typer.Option(None, help="Profile to remove. Removes active profile if omitted."),
65
+ ) -> None:
66
+ """Remove stored credentials for a profile."""
67
+ config = load_config()
68
+ name = profile or config.active_profile
69
+
70
+ if not config.delete_profile(name):
71
+ abort(f"Profile '{name}' does not exist.")
72
+
73
+ # If we just deleted the active profile, fall back to another one.
74
+ if name == config.active_profile:
75
+ remaining = config.list_profile_names()
76
+ config.active_profile = remaining[0] if remaining else DEFAULT_PROFILE
77
+
78
+ save_config(config)
79
+ print_success(f"Profile '{name}' removed.")
80
+
81
+
82
+ @app.command("use")
83
+ def use_profile(
84
+ profile: str = typer.Argument(help="Profile name to set as active"),
85
+ ) -> None:
86
+ """Set the active profile."""
87
+ config = load_config()
88
+
89
+ if profile not in config.profiles:
90
+ abort(f"Profile '{profile}' does not exist. Run: dm auth login --profile {profile}")
91
+
92
+ config.active_profile = profile
93
+ save_config(config)
94
+ print_success(f"Active profile set to '{profile}'.")
95
+
96
+
97
+ @app.command("list")
98
+ def list_profiles() -> None:
99
+ """List all configured profiles."""
100
+ config = load_config()
101
+ names = config.list_profile_names()
102
+
103
+ if not names:
104
+ print_info("No profiles configured. Run: dm auth login")
105
+ return
106
+
107
+ rows = []
108
+ for name in names:
109
+ p = config.profiles[name]
110
+ is_active = name == config.active_profile
111
+ rows.append(
112
+ [
113
+ "*" if is_active else "",
114
+ name,
115
+ p.url,
116
+ p.username,
117
+ ]
118
+ )
119
+
120
+ print_table(["", "Profile", "URL", "Username"], rows)
121
+
122
+
123
+ @app.command()
124
+ def status() -> None:
125
+ """Show current authentication status and instance info."""
126
+ # Env vars take precedence over any saved profile in `get_client`,
127
+ # so report them here as the actual source rather than the stale profile.
128
+ env_profile = profile_from_env()
129
+ if env_profile is not None:
130
+ profile_label = "(env)"
131
+ profile = env_profile
132
+ else:
133
+ config = load_config()
134
+ profile = config.get_profile()
135
+ if not profile.is_configured:
136
+ abort(f"Profile '{config.active_profile}' is not configured. Run: dm auth login")
137
+ profile_label = config.active_profile
138
+
139
+ print_info(f"Profile: {profile_label}")
140
+ print_info(f"URL: {profile.url}")
141
+ print_info(f"Username: {profile.username}")
142
+
143
+ try:
144
+ client = get_client()
145
+ except SystemExit:
146
+ # `get_client` aborts on connection/auth failure,
147
+ # but here we want a softer warning since profile info was already printed.
148
+ return
149
+
150
+ license_info = client.get_current_license_info()
151
+ print_success("Authenticated.")
152
+ print_info(f"Licence: {license_info.uuid}")
153
+ expiry = license_info.expiry_date.isoformat() if license_info.expiry_date else "unknown"
154
+ print_info(f"Expiry: {expiry}")
@@ -0,0 +1,325 @@
1
+ """Connection management 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
10
+ from datamasque.client.models.connection import (
11
+ AzureConnectionConfig,
12
+ ConnectionConfig,
13
+ DatabaseConnectionConfig,
14
+ DatabaseType,
15
+ DynamoConnectionConfig,
16
+ MountedShareConnectionConfig,
17
+ S3ConnectionConfig,
18
+ SnowflakeConnectionConfig,
19
+ )
20
+
21
+ from datamasque_cli.client import get_client
22
+ from datamasque_cli.output import abort, print_success, redact_sensitive_fields, render_output
23
+
24
+ _FILE_CONNECTION_TYPES = (MountedShareConnectionConfig, S3ConnectionConfig, AzureConnectionConfig)
25
+
26
+
27
+ def _format_role(conn: ConnectionConfig) -> str:
28
+ """Return `source`, `destination`, `source+destination`, or `—` for a connection.
29
+
30
+ Database-type connections always act as the source and mask in place,
31
+ so there's no source/destination split to display.
32
+ """
33
+ if not isinstance(conn, _FILE_CONNECTION_TYPES):
34
+ return "source"
35
+ if conn.is_file_mask_source and conn.is_file_mask_destination:
36
+ return "source+destination"
37
+ if conn.is_file_mask_source:
38
+ return "source"
39
+ if conn.is_file_mask_destination:
40
+ return "destination"
41
+ return "—"
42
+
43
+
44
+ app = typer.Typer(help="Manage database and file connections.")
45
+
46
+ # Maps the `type` field in JSON to the right config class.
47
+ _CONNECTION_CLASSES = {
48
+ "database": DatabaseConnectionConfig,
49
+ "s3": S3ConnectionConfig,
50
+ "azure": AzureConnectionConfig,
51
+ "mounted_share": MountedShareConnectionConfig,
52
+ "snowflake": SnowflakeConnectionConfig,
53
+ "dynamodb": DynamoConnectionConfig,
54
+ }
55
+
56
+
57
+ @app.command("list")
58
+ def list_connections(
59
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
60
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
61
+ ) -> None:
62
+ """List all configured connections."""
63
+ client = get_client(profile)
64
+ connections = client.list_connections()
65
+
66
+ data = []
67
+ for conn in connections:
68
+ class_name = type(conn).__name__
69
+ entry: dict[str, object] = {
70
+ "id": conn.id,
71
+ "name": conn.name,
72
+ "type": class_name.replace("ConnectionConfig", "").replace("Config", ""),
73
+ "role": _format_role(conn),
74
+ }
75
+ data.append(entry)
76
+
77
+ render_output(
78
+ data,
79
+ is_json=is_json,
80
+ columns=["id", "name", "type", "role"],
81
+ title="Connections",
82
+ )
83
+
84
+
85
+ @app.command("get")
86
+ def get_connection(
87
+ name: str = typer.Argument(help="Connection name"),
88
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
89
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
90
+ ) -> None:
91
+ """Show details for a specific connection."""
92
+ client = get_client(profile)
93
+ connections = client.list_connections()
94
+
95
+ match = next((c for c in connections if c.name == name), None)
96
+ if match is None:
97
+ abort(f"Connection '{name}' not found.")
98
+
99
+ data = redact_sensitive_fields(match.model_dump())
100
+ render_output(data, is_json=is_json, title=f"Connection: {name}")
101
+
102
+
103
+ @app.command("create")
104
+ def create_connection(
105
+ file: Path | None = typer.Option(None, "--file", "-f", help="JSON file defining the connection"),
106
+ name: str | None = typer.Option(None, help="Connection name"),
107
+ conn_type: str | None = typer.Option(None, "--type", "-t", help="database, s3, azure, mounted_share"),
108
+ host: str | None = typer.Option(None, help="Database host"),
109
+ port: str | None = typer.Option(None, help="Database port"),
110
+ database: str | None = typer.Option(None, help="Database name"),
111
+ user: str | None = typer.Option(None, help="Database user"),
112
+ password: str | None = typer.Option(None, help="Database password"),
113
+ db_type: str | None = typer.Option(None, "--db-type", help="postgres, mysql, oracle, mssql, mariadb, etc."),
114
+ schema: str | None = typer.Option(None, help="Schema name"),
115
+ base_directory: str | None = typer.Option(None, "--base-dir", help="Base directory (file connections)"),
116
+ is_source: bool = typer.Option(False, "--source", help="Is a file mask source"),
117
+ is_destination: bool = typer.Option(False, "--destination", help="Is a file mask destination"),
118
+ bucket: str | None = typer.Option(None, help="S3 bucket name"),
119
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
120
+ ) -> None:
121
+ """Create or update a connection.
122
+
123
+ Use --file for full control (JSON), or flags for quick database/file connections.
124
+
125
+ Examples:
126
+
127
+ # From JSON file (any connection type)
128
+ dm connections create --file connection.json
129
+
130
+ # Quick database connection
131
+ dm connections create --name mydb --type database --db-type postgres \\
132
+ --host db.example.com --port 5432 --database mydb --user admin --password secret
133
+
134
+ # Quick mounted share
135
+ dm connections create --name input --type mounted_share --base-dir my-data --source
136
+ """
137
+ client = get_client(profile)
138
+
139
+ if file is not None:
140
+ _create_from_file(client, file)
141
+ return
142
+
143
+ if name is None or conn_type is None:
144
+ abort("Provide either --file or both --name and --type.")
145
+
146
+ config = _build_connection_config(
147
+ name=name,
148
+ conn_type=conn_type,
149
+ host=host,
150
+ port=port,
151
+ database=database,
152
+ user=user,
153
+ password=password,
154
+ db_type=db_type,
155
+ schema=schema,
156
+ base_directory=base_directory,
157
+ is_source=is_source,
158
+ is_destination=is_destination,
159
+ bucket=bucket,
160
+ )
161
+
162
+ client.create_or_update_connection(config)
163
+ print_success(f"Connection '{name}' created/updated.")
164
+
165
+
166
+ def _create_from_file(client: DataMasqueClient, file: Path) -> None:
167
+ """Create a connection from a JSON file."""
168
+ data = json.loads(file.read_text())
169
+ conn_type = data.pop("type", "database")
170
+
171
+ if conn_type not in _CONNECTION_CLASSES:
172
+ valid = ", ".join(_CONNECTION_CLASSES)
173
+ abort(f"Unknown connection type '{conn_type}'. Valid: {valid}")
174
+
175
+ # Convert db_type string to enum for database connections
176
+ if conn_type == "database" and "database_type" in data:
177
+ data["database_type"] = DatabaseType(data["database_type"])
178
+
179
+ klass = _CONNECTION_CLASSES[conn_type]
180
+ config = klass(**data)
181
+ client.create_or_update_connection(config)
182
+ print_success(f"Connection '{config.name}' created/updated.")
183
+
184
+
185
+ def _build_connection_config(
186
+ *,
187
+ name: str,
188
+ conn_type: str,
189
+ host: str | None,
190
+ port: str | None,
191
+ database: str | None,
192
+ user: str | None,
193
+ password: str | None,
194
+ db_type: str | None,
195
+ schema: str | None,
196
+ base_directory: str | None,
197
+ is_source: bool,
198
+ is_destination: bool,
199
+ bucket: str | None,
200
+ ) -> DatabaseConnectionConfig | S3ConnectionConfig | MountedShareConnectionConfig:
201
+ """Build a connection config from CLI flags."""
202
+ if conn_type == "database":
203
+ if not all([host, port, database, user, password, db_type]):
204
+ abort("Database connections require: --host, --port, --database, --user, --password, --db-type")
205
+ return DatabaseConnectionConfig(
206
+ name=name,
207
+ host=host,
208
+ port=port,
209
+ database=database,
210
+ user=user,
211
+ password=password,
212
+ database_type=DatabaseType(db_type),
213
+ schema=schema,
214
+ )
215
+
216
+ if conn_type == "mounted_share":
217
+ if base_directory is None:
218
+ abort("Mounted share connections require: --base-dir")
219
+ return MountedShareConnectionConfig(
220
+ name=name,
221
+ base_directory=base_directory,
222
+ is_file_mask_source=is_source,
223
+ is_file_mask_destination=is_destination,
224
+ )
225
+
226
+ if conn_type == "s3":
227
+ if base_directory is None or bucket is None:
228
+ abort("S3 connections require: --base-dir, --bucket")
229
+ return S3ConnectionConfig(
230
+ name=name,
231
+ base_directory=base_directory,
232
+ bucket=bucket,
233
+ is_file_mask_source=is_source,
234
+ is_file_mask_destination=is_destination,
235
+ )
236
+
237
+ abort(f"Use --file for '{conn_type}' connections (too many fields for CLI flags).")
238
+
239
+
240
+ @app.command("test")
241
+ def test_connection(
242
+ name: str = typer.Argument(help="Connection name or ID to test"),
243
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
244
+ ) -> None:
245
+ """Verify a connection can reach its target.
246
+
247
+ Posts the stored connection to the server's test endpoint, which attempts
248
+ an actual database handshake or filesystem/bucket open and reports
249
+ success, a warning, or a hard failure.
250
+ """
251
+ client = get_client(profile)
252
+
253
+ match = next((c for c in client.list_connections() if c.name == name or str(c.id) == name), None)
254
+ if match is None:
255
+ abort(f"Connection '{name}' not found.")
256
+
257
+ response = client.make_request("POST", f"/api/connections/{match.id}/test/", data={})
258
+ body = response.json() if response.content else {}
259
+ warning = body.get("message") if isinstance(body, dict) else None
260
+
261
+ if warning:
262
+ print_success(f"Connection '{match.name}' reachable (warning: {warning}).")
263
+ else:
264
+ print_success(f"Connection '{match.name}' reachable.")
265
+
266
+
267
+ @app.command("update")
268
+ def update_connection(
269
+ name: str = typer.Argument(help="Connection name or ID to update"),
270
+ host: str | None = typer.Option(None, help="New database host"),
271
+ port: str | None = typer.Option(None, help="New database port"),
272
+ database: str | None = typer.Option(None, help="New database name"),
273
+ user: str | None = typer.Option(None, help="New database user"),
274
+ password: str | None = typer.Option(None, help="New database password"),
275
+ schema: str | None = typer.Option(None, help="New schema name"),
276
+ base_directory: str | None = typer.Option(None, "--base-dir", help="New base directory (file connections)"),
277
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
278
+ ) -> None:
279
+ """Update selected fields on an existing connection without recreating it.
280
+
281
+ Preserves the connection's UUID, so any ruleset or run history that
282
+ references it stays intact. Pass only the fields that should change.
283
+ """
284
+ client = get_client(profile)
285
+
286
+ match = next((c for c in client.list_connections() if c.name == name or str(c.id) == name), None)
287
+ if match is None:
288
+ abort(f"Connection '{name}' not found.")
289
+
290
+ updates: dict[str, object] = {
291
+ key: value
292
+ for key, value in {
293
+ "host": host,
294
+ "port": port,
295
+ "database": database,
296
+ "user": user,
297
+ "password": password,
298
+ "schema": schema,
299
+ "base_directory": base_directory,
300
+ }.items()
301
+ if value is not None
302
+ }
303
+ if not updates:
304
+ abort("Pass at least one field to update (e.g. --password, --host).")
305
+
306
+ client.make_request("PATCH", f"/api/connections/{match.id}/", data=updates)
307
+ print_success(f"Connection '{match.name}' updated: {', '.join(updates)}.")
308
+
309
+
310
+ @app.command("delete")
311
+ def delete_connection(
312
+ name: str = typer.Argument(help="Connection name to delete"),
313
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
314
+ is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
315
+ ) -> None:
316
+ """Delete a connection by name."""
317
+ client = get_client(profile)
318
+ if not any(c.name == name for c in client.list_connections()):
319
+ abort(f"Connection '{name}' not found.")
320
+
321
+ if not is_confirmed:
322
+ typer.confirm(f"Delete connection '{name}'?", abort=True)
323
+
324
+ client.delete_connection_by_name_if_exists(name)
325
+ print_success(f"Connection '{name}' deleted.")