datamasque-cli 1.3.0__tar.gz → 1.4.0__tar.gz

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 (78) hide show
  1. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/CHANGELOG.md +7 -0
  2. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/CONTRIBUTING.md +44 -0
  3. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/PKG-INFO +1 -1
  4. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/pyproject.toml +1 -1
  5. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/client.py +68 -4
  6. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/connections.py +14 -1
  7. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/runs.py +2 -3
  8. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/system.py +38 -7
  9. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_connections.py +60 -0
  10. datamasque_cli-1.4.0/tests/commands/test_system.py +192 -0
  11. datamasque_cli-1.4.0/tests/integration/test_system.py +97 -0
  12. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_client_auth.py +22 -1
  13. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/uv.lock +4 -4
  14. datamasque_cli-1.3.0/tests/commands/test_system.py +0 -82
  15. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.claude-plugin/marketplace.json +0 -0
  16. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.github/workflows/ci.yml +0 -0
  17. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.github/workflows/release-testpypi.yml +0 -0
  18. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.github/workflows/release.yml +0 -0
  19. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.gitignore +0 -0
  20. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/LICENSE +0 -0
  21. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/Makefile +0 -0
  22. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/NOTICE +0 -0
  23. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/README.md +0 -0
  24. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/assets/demo.gif +0 -0
  25. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/README.md +0 -0
  26. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/datamasque-cli/.claude-plugin/plugin.json +0 -0
  27. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/datamasque-cli/skills/datamasque-cli/SKILL.md +0 -0
  28. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/.claude-plugin/plugin.json +0 -0
  29. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/SKILL.md +0 -0
  30. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/fk-cascade.md +0 -0
  31. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/hash-columns-guide.md +0 -0
  32. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/mask-definitions-guide.md +0 -0
  33. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/ruleset-libraries-guide.md +0 -0
  34. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/ruleset-yaml-reference.md +0 -0
  35. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-splitter/.claude-plugin/plugin.json +0 -0
  36. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-splitter/skills/ruleset-splitter/SKILL.md +0 -0
  37. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/scripts/active_profile_env.py +0 -0
  38. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/scripts/bump_version.py +0 -0
  39. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/__init__.py +0 -0
  40. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/__init__.py +0 -0
  41. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/auth.py +0 -0
  42. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/discovery.py +0 -0
  43. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/files.py +0 -0
  44. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/ifm.py +0 -0
  45. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/ruleset_libraries.py +0 -0
  46. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/rulesets.py +0 -0
  47. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/seeds.py +0 -0
  48. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/users.py +0 -0
  49. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/config.py +0 -0
  50. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/main.py +0 -0
  51. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/output.py +0 -0
  52. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/py.typed +0 -0
  53. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/__init__.py +0 -0
  54. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/__init__.py +0 -0
  55. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_auth.py +0 -0
  56. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_catalog.py +0 -0
  57. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_discovery.py +0 -0
  58. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_files.py +0 -0
  59. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_ifm.py +0 -0
  60. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_ruleset_libraries.py +0 -0
  61. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_rulesets.py +0 -0
  62. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_runs.py +0 -0
  63. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_seeds.py +0 -0
  64. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_users.py +0 -0
  65. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/conftest.py +0 -0
  66. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/README.md +0 -0
  67. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/__init__.py +0 -0
  68. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/conftest.py +0 -0
  69. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/test_connections.py +0 -0
  70. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/test_delete_safety.py +0 -0
  71. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/test_rulesets.py +0 -0
  72. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/test_runs.py +0 -0
  73. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_client_env.py +0 -0
  74. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_client_ifm.py +0 -0
  75. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_client_profile.py +0 -0
  76. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_config.py +0 -0
  77. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_output.py +0 -0
  78. {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_version.py +0 -0
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.4.0
4
+
5
+ ### Added
6
+ - `dm connections create --file` now supports Databricks SQL Warehouse
7
+ (`"type": "databricks"`) and MongoDB (`"type": "mongodb"`) connections.
8
+ Both list, get, create, and delete like the existing connection types.
9
+
3
10
  ## v1.3.0
4
11
 
5
12
  ### Added
@@ -41,6 +41,50 @@ uv sync
41
41
  Then either activate the venv (`source .venv/bin/activate`)
42
42
  or prefix commands with `uv run`.
43
43
 
44
+ ## Running `dm` locally
45
+
46
+ `uv sync` installs the CLI in editable mode,
47
+ so the `dm` entry point on the venv reflects your working tree —
48
+ no reinstall after each edit.
49
+
50
+ ```console
51
+ uv run dm --version # one-shot, no venv activation needed
52
+ source .venv/bin/activate && dm --version # or activate once per shell
53
+ ```
54
+
55
+ Point it at a DataMasque instance.
56
+ For ad-hoc development, env vars are the lowest-friction path
57
+ (no `~/.config/datamasque-cli/config.toml` to clean up afterwards):
58
+
59
+ ```console
60
+ export DATAMASQUE_URL=http://127.0.0.1:8000
61
+ export DATAMASQUE_USERNAME=admin
62
+ export DATAMASQUE_PASSWORD='P@ssword12'
63
+ export DATAMASQUE_VERIFY_SSL=false # for self-signed local builds
64
+ dm system health
65
+ dm connections list
66
+ ```
67
+
68
+ For longer-lived work, save a profile with `dm auth login`
69
+ (stored at `~/.config/datamasque-cli/config.toml`, mode 600).
70
+
71
+ ### Pairing with a local `datamasque-python` checkout
72
+
73
+ `datamasque-cli` depends on the `datamasque-python` package
74
+ for its actual API client.
75
+ If you're changing both repos at once
76
+ (for example, adding a new endpoint that needs a CLI surface),
77
+ install the sibling checkout in editable mode against the CLI's venv:
78
+
79
+ ```console
80
+ uv pip install -e ../datamasque-python
81
+ ```
82
+
83
+ The dependency is satisfied by the local checkout
84
+ and edits to either repo are picked up immediately by `dm`.
85
+ A subsequent `uv sync` will re-pin to the registered version —
86
+ re-run the `uv pip install -e` if you want the local override back.
87
+
44
88
  ## Running the tests
45
89
 
46
90
  ```console
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datamasque-cli
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Official command-line interface for the DataMasque data-masking platform.
5
5
  Project-URL: Homepage, https://datamasque.com/
6
6
  Project-URL: Repository, https://github.com/datamasque/datamasque-cli
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datamasque-cli"
3
- version = "1.3.0"
3
+ version = "1.4.0"
4
4
  description = "Official command-line interface for the DataMasque data-masking platform."
5
5
  authors = [
6
6
  { name = "DataMasque Ltd" },
@@ -48,6 +48,25 @@ def profile_from_env() -> Profile | None:
48
48
  return None
49
49
 
50
50
 
51
+ def _profile_from_env_url_only() -> Profile | None:
52
+ """Build a URL-only profile from `DATAMASQUE_URL`, with empty username/password.
53
+
54
+ Used by the unauthenticated client factory so callers can hit anonymous
55
+ endpoints (admin-install, health) without setting `DATAMASQUE_USERNAME`
56
+ and `DATAMASQUE_PASSWORD` -- those fields aren't read by anonymous calls
57
+ and demanding them is friction for the first-run setup workflow.
58
+ """
59
+ url = os.environ.get(ENV_URL)
60
+ if not url:
61
+ return None
62
+ return Profile(
63
+ url=url.rstrip("/"),
64
+ username="",
65
+ password="",
66
+ verify_ssl=_verify_ssl_from_env(default=True),
67
+ )
68
+
69
+
51
70
  def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
52
71
  profile = config.get_profile(profile_name)
53
72
  if not profile.is_configured:
@@ -60,6 +79,25 @@ def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
60
79
  return profile
61
80
 
62
81
 
82
+ def _resolve_profile_for_unauthenticated(profile_name: str | None) -> Profile:
83
+ """Resolve a profile for an unauthenticated call -- only the URL is required.
84
+
85
+ Order: explicit `--profile`, env vars (URL-only is sufficient here),
86
+ saved active profile. If none yield a URL, abort with a clear hint.
87
+ """
88
+ if profile_name is not None:
89
+ profile = load_config().get_profile(profile_name)
90
+ else:
91
+ profile = _profile_from_env_url_only() or load_config().get_profile()
92
+ if not profile.url:
93
+ abort(
94
+ "No DataMasque URL configured.",
95
+ code=ErrorCode.AUTH_REQUIRED,
96
+ hint=f"Set {ENV_URL} or run: dm auth login",
97
+ )
98
+ return profile
99
+
100
+
63
101
  def _resolve_profile_with_verify(profile_name: str | None) -> tuple[Profile, bool]:
64
102
  """Resolve the active `Profile` and apply env-var overrides for `verify_ssl`."""
65
103
  env_profile = profile_from_env() if profile_name is None else None
@@ -98,17 +136,43 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient:
98
136
  `DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
99
137
  flip TLS verification per-call without re-running `dm auth login`.
100
138
  """
101
- profile, verify_ssl = _resolve_profile_with_verify(profile_name)
139
+ client, profile, verify_ssl = _build_client(profile_name)
140
+ _authenticate_or_abort(client, profile.url, verify_ssl=verify_ssl)
141
+ return client
142
+
143
+
144
+ def get_unauthenticated_client(profile_name: str | None = None) -> DataMasqueClient:
145
+ """Build a `DataMasqueClient` without performing the up-front login handshake.
146
+
147
+ Used by commands that hit endpoints which don't require — or can't yet
148
+ use — a token. `admin-install` is the canonical example: on a fresh
149
+ server there's no user to authenticate as, so `client.authenticate()`
150
+ would always fail before the command ran.
151
+
152
+ Only `DATAMASQUE_URL` (or a profile with a URL) is required — username
153
+ and password aren't read by anonymous endpoints, so demanding them
154
+ would be unnecessary friction for first-run setup.
155
+ """
156
+ profile = _resolve_profile_for_unauthenticated(profile_name)
157
+ verify_ssl = _verify_ssl_from_env(default=profile.verify_ssl)
102
158
  instance_config = DataMasqueInstanceConfig(
103
159
  base_url=profile.url,
104
160
  username=profile.username,
105
161
  password=profile.password,
106
162
  verify_ssl=verify_ssl,
107
163
  )
164
+ return DataMasqueClient(instance_config)
108
165
 
109
- client = DataMasqueClient(instance_config)
110
- _authenticate_or_abort(client, profile.url, verify_ssl=verify_ssl)
111
- return client
166
+
167
+ def _build_client(profile_name: str | None) -> tuple[DataMasqueClient, Profile, bool]:
168
+ profile, verify_ssl = _resolve_profile_with_verify(profile_name)
169
+ instance_config = DataMasqueInstanceConfig(
170
+ base_url=profile.url,
171
+ username=profile.username,
172
+ password=profile.password,
173
+ verify_ssl=verify_ssl,
174
+ )
175
+ return DataMasqueClient(instance_config), profile, verify_ssl
112
176
 
113
177
 
114
178
  # Substrings that suggest the underlying error was a TLS failure rather than
@@ -13,7 +13,9 @@ from datamasque.client.models.connection import (
13
13
  ConnectionConfig,
14
14
  DatabaseConnectionConfig,
15
15
  DatabaseType,
16
+ DatabricksConnectionConfig,
16
17
  DynamoConnectionConfig,
18
+ MongoConnectionConfig,
17
19
  MountedShareConnectionConfig,
18
20
  S3ConnectionConfig,
19
21
  SnowflakeConnectionConfig,
@@ -37,6 +39,8 @@ class ConnectionType(StrEnum):
37
39
  MOUNTED_SHARE = "mounted_share"
38
40
  SNOWFLAKE = "snowflake"
39
41
  DYNAMODB = "dynamodb"
42
+ DATABRICKS = "databricks"
43
+ MONGODB = "mongodb"
40
44
 
41
45
 
42
46
  _FILE_CONNECTION_TYPES = (MountedShareConnectionConfig, S3ConnectionConfig, AzureConnectionConfig)
@@ -69,6 +73,8 @@ _CONNECTION_CLASSES: dict[ConnectionType, type[ConnectionConfig]] = {
69
73
  ConnectionType.MOUNTED_SHARE: MountedShareConnectionConfig,
70
74
  ConnectionType.SNOWFLAKE: SnowflakeConnectionConfig,
71
75
  ConnectionType.DYNAMODB: DynamoConnectionConfig,
76
+ ConnectionType.DATABRICKS: DatabricksConnectionConfig,
77
+ ConnectionType.MONGODB: MongoConnectionConfig,
72
78
  }
73
79
 
74
80
 
@@ -131,7 +137,9 @@ def get_connection(
131
137
  def create_connection(
132
138
  file: Path | None = typer.Option(None, "--file", "-f", help="JSON file defining the connection"),
133
139
  name: str | None = typer.Option(None, help="Connection name"),
134
- conn_type: str | None = typer.Option(None, "--type", "-t", help="database, s3, azure, mounted_share"),
140
+ conn_type: str | None = typer.Option(
141
+ None, "--type", "-t", help="database, s3, azure, mounted_share, snowflake, dynamodb, databricks, mongodb"
142
+ ),
135
143
  host: str | None = typer.Option(None, help="Database host"),
136
144
  port: str | None = typer.Option(None, help="Database port"),
137
145
  database: str | None = typer.Option(None, help="Database name"),
@@ -160,6 +168,11 @@ def create_connection(
160
168
 
161
169
  # Quick mounted share
162
170
  dm connections create --name input --type mounted_share --base-dir my-data --source
171
+
172
+ # Databricks, MongoDB, Snowflake and DynamoDB have many fields, so use --file:
173
+ # {"type": "databricks", "name": "dbx", "server_hostname": "...", "http_path": "...",
174
+ # "access_token": "...", "catalog": "main", "schema": "default"}
175
+ dm connections create --file databricks.json
163
176
  """
164
177
  client = get_client(profile)
165
178
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import time
7
7
  from datetime import UTC, datetime
8
+ from http import HTTPStatus
8
9
  from pathlib import Path
9
10
 
10
11
  import typer
@@ -31,8 +32,6 @@ app = typer.Typer(help="Manage masking runs.", no_args_is_help=True)
31
32
 
32
33
  _POLL_INTERVAL_SECONDS = 5
33
34
 
34
- _HTTP_NOT_FOUND = 404
35
-
36
35
 
37
36
  def _format_run_info(run: RunInfo, *, is_styled: bool = False) -> dict[str, object]:
38
37
  """Extract the fields most useful for display from a `RunInfo`."""
@@ -350,7 +349,7 @@ def run_report(
350
349
  # `GET .../run-report/` 404s for runs that didn't produce one
351
350
  # (still in flight, failed early, or a run type that doesn't emit a
352
351
  # report). The default error string is opaque, so name the cause.
353
- if exc.response is not None and exc.response.status_code == _HTTP_NOT_FOUND:
352
+ if exc.response is not None and exc.response.status_code == HTTPStatus.NOT_FOUND:
354
353
  abort(
355
354
  f"No report available for run {run_id}.",
356
355
  code=ErrorCode.NOT_FOUND,
@@ -2,13 +2,23 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from http import HTTPStatus
5
6
  from pathlib import Path
6
7
 
7
8
  import typer
9
+ from datamasque.client.exceptions import DataMasqueApiError
8
10
 
9
- from datamasque_cli.client import get_client
11
+ from datamasque_cli.client import get_client, get_unauthenticated_client
10
12
  from datamasque_cli.commands.rulesets import export_bundle, import_bundle
11
- from datamasque_cli.output import print_json, print_success, print_warning, render_output, should_emit_json
13
+ from datamasque_cli.output import (
14
+ ErrorCode,
15
+ abort,
16
+ print_json,
17
+ print_success,
18
+ print_warning,
19
+ render_output,
20
+ should_emit_json,
21
+ )
12
22
 
13
23
  app = typer.Typer(help="System administration commands.", no_args_is_help=True)
14
24
 
@@ -18,8 +28,14 @@ def health(
18
28
  profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
19
29
  is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
20
30
  ) -> None:
21
- """Check DataMasque instance health."""
22
- client = get_client(profile)
31
+ """Check DataMasque instance health.
32
+
33
+ Uses an unauthenticated client because `/api/healthcheck/` does not require a
34
+ token and should answer even when no admin user has been created yet --
35
+ the whole point of a health probe is to be the lowest-friction signal of
36
+ "is the server up?"
37
+ """
38
+ client = get_unauthenticated_client(profile)
23
39
  client.healthcheck()
24
40
 
25
41
  if should_emit_json(is_json):
@@ -101,9 +117,24 @@ def admin_install(
101
117
  password: str = typer.Option(..., prompt=True, hide_input=True, help="Admin password"),
102
118
  profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
103
119
  ) -> 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)
120
+ """Initial admin setup for a fresh DataMasque instance.
121
+
122
+ Uses an unauthenticated client because the admin-install endpoint is itself
123
+ anonymous and on a fresh server there's no user account to authenticate as.
124
+ """
125
+ client = get_unauthenticated_client(profile)
126
+ try:
127
+ client.admin_install(email=email, username=username, password=password)
128
+ except DataMasqueApiError as e:
129
+ # The server returns 401 on /api/users/admin-install/ once any user exists
130
+ # DataMasque treats it as a normal auth-required endpoint after that
131
+ if e.response.status_code == HTTPStatus.UNAUTHORIZED:
132
+ abort(
133
+ "Admin install is already complete on this DataMasque instance.",
134
+ code=ErrorCode.CONFLICT,
135
+ hint="Use `dm auth login` to sign in as an existing user.",
136
+ )
137
+ raise
107
138
  print_success(f"Admin user '{username}' created.")
108
139
 
109
140
 
@@ -7,6 +7,8 @@ import pytest
7
7
  from datamasque.client.models.connection import (
8
8
  DatabaseConnectionConfig,
9
9
  DatabaseType,
10
+ DatabricksConnectionConfig,
11
+ MongoConnectionConfig,
10
12
  MountedShareConnectionConfig,
11
13
  )
12
14
  from typer.testing import CliRunner
@@ -164,6 +166,64 @@ def test_create_connection_from_json_file(mock_get_client: MagicMock, runner: Cl
164
166
  client.create_or_update_connection.assert_called_once()
165
167
 
166
168
 
169
+ @patch(f"{MODULE}.get_client")
170
+ def test_create_databricks_connection_from_json_file(
171
+ mock_get_client: MagicMock, runner: CliRunner, tmp_path: MagicMock
172
+ ) -> None:
173
+ client = MagicMock()
174
+ mock_get_client.return_value = client
175
+
176
+ conn_file = tmp_path / "dbx.json"
177
+ conn_file.write_text(
178
+ json.dumps(
179
+ {
180
+ "type": "databricks",
181
+ "name": "dbx",
182
+ "server_hostname": "dbc-1514b142-1c6c.cloud.databricks.com",
183
+ "http_path": "/sql/1.0/warehouses/ea7d918dd5f236f9",
184
+ "access_token": "dapiTOKEN",
185
+ "catalog": "main",
186
+ "schema": "default",
187
+ }
188
+ )
189
+ )
190
+
191
+ result = runner.invoke(app, ["connections", "create", "--file", str(conn_file)])
192
+ assert result.exit_code == 0
193
+ config = client.create_or_update_connection.call_args.args[0]
194
+ assert isinstance(config, DatabricksConnectionConfig)
195
+ assert config.db_type == "databricks"
196
+
197
+
198
+ @patch(f"{MODULE}.get_client")
199
+ def test_create_mongodb_connection_from_json_file(
200
+ mock_get_client: MagicMock, runner: CliRunner, tmp_path: MagicMock
201
+ ) -> None:
202
+ client = MagicMock()
203
+ mock_get_client.return_value = client
204
+
205
+ conn_file = tmp_path / "mongo.json"
206
+ conn_file.write_text(
207
+ json.dumps(
208
+ {
209
+ "type": "mongodb",
210
+ "name": "mongo",
211
+ "host": "localhost",
212
+ "port": "27017",
213
+ "database": "mydb",
214
+ "user": "admin",
215
+ "password": "secret",
216
+ }
217
+ )
218
+ )
219
+
220
+ result = runner.invoke(app, ["connections", "create", "--file", str(conn_file)])
221
+ assert result.exit_code == 0
222
+ config = client.create_or_update_connection.call_args.args[0]
223
+ assert isinstance(config, MongoConnectionConfig)
224
+ assert config.db_type == "mongodb"
225
+
226
+
167
227
  # -- delete (tests confirmation logic) ------------------------------------
168
228
 
169
229
 
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+ from datamasque.client.exceptions import DataMasqueApiError
8
+ from datamasque.client.models.license import LicenseInfo, SwitchableLicenseMetadata
9
+ from typer.testing import CliRunner
10
+
11
+ from datamasque_cli.main import app
12
+
13
+ MODULE = "datamasque_cli.commands.system"
14
+
15
+
16
+ @patch(f"{MODULE}.get_client")
17
+ def test_licence_projects_to_user_facing_fields(mock_get_client: MagicMock, runner: CliRunner) -> None:
18
+ client = MagicMock()
19
+ mock_get_client.return_value = client
20
+ client.get_current_license_info.return_value = LicenseInfo(
21
+ uuid="lic-123",
22
+ name="Test Licence",
23
+ type="standard",
24
+ is_expired=False,
25
+ uploadable=True,
26
+ expiry_date=datetime(2027, 6, 1, tzinfo=UTC),
27
+ days_until_expiry=400,
28
+ platform_name="DataMasque",
29
+ # Noisy nested field that should NOT appear in the projected output.
30
+ switchable_license_metadata=SwitchableLicenseMetadata(license_source="aws"),
31
+ )
32
+
33
+ result = runner.invoke(app, ["system", "licence", "--json"])
34
+
35
+ assert result.exit_code == 0
36
+ assert '"uuid": "lic-123"' in result.stdout
37
+ assert '"days_until_expiry": 400' in result.stdout
38
+ assert '"platform_name": "DataMasque"' in result.stdout
39
+ assert "switchable_license_metadata" not in result.stdout
40
+ assert "license_source" not in result.stdout
41
+
42
+
43
+ @pytest.mark.parametrize(
44
+ ("extra_args", "settings_url", "expected_output"),
45
+ [
46
+ (["--json"], "http://engine.example.com:9021", '"dm_ai_engine_url": "http://engine.example.com:9021"'),
47
+ ([], "http://engine.example.com:9021", "http://engine.example.com:9021"),
48
+ ([], None, "<not configured>"),
49
+ ([], "", "<not configured>"),
50
+ ],
51
+ )
52
+ @patch(f"{MODULE}.get_client")
53
+ def test_ai_engine_show(
54
+ mock_get_client: MagicMock,
55
+ runner: CliRunner,
56
+ extra_args: list[str],
57
+ settings_url: str | None,
58
+ expected_output: str,
59
+ ) -> None:
60
+ client = MagicMock()
61
+ mock_get_client.return_value = client
62
+ response = MagicMock()
63
+ response.json.return_value = {"dm_ai_engine_url": settings_url}
64
+ client.make_request.return_value = response
65
+
66
+ result = runner.invoke(app, ["system", "ai-engine", "show", *extra_args])
67
+
68
+ assert result.exit_code == 0
69
+ client.make_request.assert_called_once_with("GET", "/api/settings/")
70
+ assert expected_output in result.stdout
71
+
72
+
73
+ @patch(f"{MODULE}.get_client")
74
+ def test_ai_engine_set_patches_settings_with_url(mock_get_client: MagicMock, runner: CliRunner) -> None:
75
+ client = MagicMock()
76
+ mock_get_client.return_value = client
77
+
78
+ result = runner.invoke(app, ["system", "ai-engine", "set", "http://engine.example.com:9021"])
79
+
80
+ assert result.exit_code == 0
81
+ client.make_request.assert_called_once_with(
82
+ "PATCH", "/api/settings/", data={"dm_ai_engine_url": "http://engine.example.com:9021"}
83
+ )
84
+
85
+
86
+ # Commands that hit anonymous endpoints must use `get_unauthenticated_client`, not `get_client`.
87
+ # Using `get_client` would call `authenticate()` first, which always fails on a fresh server
88
+ # (no admin user yet → 401 → SystemExit) and never reaches the actual endpoint.
89
+
90
+
91
+ @patch(f"{MODULE}.get_unauthenticated_client")
92
+ @patch(f"{MODULE}.get_client")
93
+ def test_admin_install_uses_unauthenticated_client(
94
+ mock_get_client: MagicMock, mock_get_unauth: MagicMock, runner: CliRunner
95
+ ) -> None:
96
+ client = MagicMock()
97
+ mock_get_unauth.return_value = client
98
+
99
+ result = runner.invoke(
100
+ app,
101
+ [
102
+ "system",
103
+ "admin-install",
104
+ "--email",
105
+ "admin@example.com",
106
+ "--username",
107
+ "admin",
108
+ "--password",
109
+ "P@ssword12",
110
+ ],
111
+ )
112
+
113
+ assert result.exit_code == 0, result.output
114
+ mock_get_unauth.assert_called_once()
115
+ mock_get_client.assert_not_called()
116
+ client.admin_install.assert_called_once_with(email="admin@example.com", username="admin", password="P@ssword12")
117
+
118
+
119
+ @patch(f"{MODULE}.get_unauthenticated_client")
120
+ def test_admin_install_translates_401_into_conflict(mock_get_unauth: MagicMock, runner: CliRunner) -> None:
121
+ """A 401 from /api/users/admin-install/ means the instance is already set up.
122
+
123
+ Translate the raw API error into a user-facing conflict message instead of
124
+ letting the misleading "Unable to login" traceback bubble up.
125
+ """
126
+ client = MagicMock()
127
+ mock_get_unauth.return_value = client
128
+ response = MagicMock()
129
+ response.status_code = 401
130
+ client.admin_install.side_effect = DataMasqueApiError("401 Unauthorized", response=response)
131
+
132
+ result = runner.invoke(
133
+ app,
134
+ [
135
+ "system",
136
+ "admin-install",
137
+ "--email",
138
+ "admin@example.com",
139
+ "--username",
140
+ "admin",
141
+ "--password",
142
+ "P@ssword12",
143
+ ],
144
+ )
145
+
146
+ assert result.exit_code == 8 # ErrorCode.CONFLICT
147
+ assert "already complete" in result.stderr
148
+ assert "dm auth login" in result.stderr
149
+
150
+
151
+ @patch(f"{MODULE}.get_unauthenticated_client")
152
+ def test_admin_install_does_not_swallow_non_401_errors(mock_get_unauth: MagicMock, runner: CliRunner) -> None:
153
+ """Only 401 is the "already installed" signal -- other errors must surface."""
154
+ client = MagicMock()
155
+ mock_get_unauth.return_value = client
156
+ response = MagicMock()
157
+ response.status_code = 400
158
+ client.admin_install.side_effect = DataMasqueApiError("400 Bad Request", response=response)
159
+
160
+ result = runner.invoke(
161
+ app,
162
+ [
163
+ "system",
164
+ "admin-install",
165
+ "--email",
166
+ "admin@example.com",
167
+ "--username",
168
+ "admin",
169
+ "--password",
170
+ "weak",
171
+ ],
172
+ )
173
+
174
+ assert result.exit_code != 0
175
+ assert "already complete" not in result.stderr
176
+
177
+
178
+ @patch(f"{MODULE}.get_unauthenticated_client")
179
+ @patch(f"{MODULE}.get_client")
180
+ def test_health_uses_unauthenticated_client(
181
+ mock_get_client: MagicMock, mock_get_unauth: MagicMock, runner: CliRunner
182
+ ) -> None:
183
+ client = MagicMock()
184
+ mock_get_unauth.return_value = client
185
+
186
+ result = runner.invoke(app, ["system", "health", "--json"])
187
+
188
+ assert result.exit_code == 0, result.output
189
+ mock_get_unauth.assert_called_once()
190
+ mock_get_client.assert_not_called()
191
+ client.healthcheck.assert_called_once_with()
192
+ assert '"status": "healthy"' in result.stdout
@@ -0,0 +1,97 @@
1
+ """Integration tests for `dm system` commands that hit anonymous endpoints.
2
+
3
+ These end-to-end exercise the `get_unauthenticated_client` path against a real
4
+ DataMasque instance, the regression they guard against (silently re-introducing
5
+ `get_client` on either command) can only be caught against a real server: a unit
6
+ test can mock the factory, but only a live instance reveals that the auth call
7
+ fails on a fresh server.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import uuid
15
+
16
+ import pytest
17
+ from typer.testing import CliRunner
18
+
19
+ from datamasque_cli.client import get_unauthenticated_client
20
+ from datamasque_cli.main import app
21
+
22
+ pytestmark = pytest.mark.integration
23
+
24
+
25
+ def _is_installed(runner: CliRunner) -> bool:
26
+ """Check installation state via the dm CLI's health-style probe.
27
+
28
+ `/api/app/check/` is what the admin frontend uses to decide whether to show
29
+ the install wizard, so it's the canonical signal for "this instance is
30
+ already set up".
31
+
32
+ Implemented via the python client directly because there's no `dm` command
33
+ for `/api/app/check/` yet; that's the only thing here that bypasses the CLI.
34
+ """
35
+ # Build a URL-only client so this works on a fresh instance too.
36
+ client = get_unauthenticated_client()
37
+ response = client.make_request("GET", "/api/app/check/", requires_authorization=False)
38
+ return bool(response.json().get("installed"))
39
+
40
+
41
+ def test_health_works_without_authentication(runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None:
42
+ """`dm system health` must succeed even when no credentials are configured.
43
+
44
+ The whole point of a health probe is to be the lowest-friction "is the
45
+ server up?" signal -- it shouldn't depend on a valid login.
46
+ """
47
+ monkeypatch.delenv("DATAMASQUE_USERNAME", raising=False)
48
+ monkeypatch.delenv("DATAMASQUE_PASSWORD", raising=False)
49
+
50
+ result = runner.invoke(app, ["system", "health", "--json"])
51
+
52
+ assert result.exit_code == 0, result.output
53
+ assert json.loads(result.stdout)["status"] == "healthy"
54
+
55
+
56
+ def test_admin_install_creates_admin_user_on_fresh_instance(runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None:
57
+ """End-to-end admin-install against a real instance.
58
+
59
+ Skips when the instance is already configured -- the endpoint is gated on
60
+ "no user has been created yet" and reusing a server between runs is the
61
+ common case. To exercise this path, point the integration suite at a
62
+ freshly-restarted DataMasque (e.g. `docker compose down -v && up -d`).
63
+ """
64
+ if _is_installed(runner):
65
+ pytest.skip(
66
+ "Instance is already installed. Reset it (e.g. `docker compose down -v && up -d`) "
67
+ "to exercise the admin-install path."
68
+ )
69
+
70
+ # `dm system admin-install` needs no credentials -- the password is passed
71
+ # via --password and the endpoint itself is anonymous. Clear the env vars
72
+ # the parent fixture set so this test proves the no-creds path works.
73
+ monkeypatch.delenv("DATAMASQUE_USERNAME", raising=False)
74
+ monkeypatch.delenv("DATAMASQUE_PASSWORD", raising=False)
75
+
76
+ # Use the same credentials the parent fixture configures, so any
77
+ # subsequent authenticated tests in this session can log in.
78
+ username = os.environ["DM_TEST_USERNAME"]
79
+ password = os.environ["DM_TEST_PASSWORD"]
80
+ email = f"{uuid.uuid4().hex[:8]}@dm-integration.test"
81
+
82
+ result = runner.invoke(
83
+ app,
84
+ [
85
+ "system",
86
+ "admin-install",
87
+ "--email",
88
+ email,
89
+ "--username",
90
+ username,
91
+ "--password",
92
+ password,
93
+ ],
94
+ )
95
+
96
+ assert result.exit_code == 0, result.output
97
+ assert _is_installed(runner), "Instance should be marked installed after admin-install"
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
5
5
  import pytest
6
6
  from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError
7
7
 
8
- from datamasque_cli.client import _format_transport_error, get_client
8
+ from datamasque_cli.client import _format_transport_error, get_client, get_unauthenticated_client
9
9
  from datamasque_cli.config import Config, Profile
10
10
 
11
11
 
@@ -46,6 +46,27 @@ def test_get_client_aborts_on_auth_failure(
46
46
  assert exc_info.value.code == 7 # auth_failed
47
47
 
48
48
 
49
+ @patch("datamasque_cli.client.DataMasqueClient")
50
+ @patch("datamasque_cli.client.load_config")
51
+ def test_get_unauthenticated_client_does_not_call_authenticate(
52
+ mock_load: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
53
+ ) -> None:
54
+ """Commands that hit anonymous endpoints (admin-install, health) must not try
55
+ to log in first -- on a fresh server there's no user to authenticate as."""
56
+ monkeypatch.delenv("DATAMASQUE_URL", raising=False)
57
+ monkeypatch.delenv("DATAMASQUE_USERNAME", raising=False)
58
+ monkeypatch.delenv("DATAMASQUE_PASSWORD", raising=False)
59
+
60
+ config = Config()
61
+ config.set_profile("default", Profile(url="https://dm", username="admin", password="secret"))
62
+ mock_load.return_value = config
63
+
64
+ client = get_unauthenticated_client()
65
+
66
+ assert client is mock_client_cls.return_value
67
+ mock_client_cls.return_value.authenticate.assert_not_called()
68
+
69
+
49
70
  @pytest.mark.parametrize(
50
71
  ("error_message", "verify_ssl", "expect_hint"),
51
72
  [
@@ -141,7 +141,7 @@ wheels = [
141
141
 
142
142
  [[package]]
143
143
  name = "datamasque-cli"
144
- version = "1.3.0"
144
+ version = "1.4.0"
145
145
  source = { editable = "." }
146
146
  dependencies = [
147
147
  { name = "datamasque-python" },
@@ -174,15 +174,15 @@ dev = [
174
174
 
175
175
  [[package]]
176
176
  name = "datamasque-python"
177
- version = "1.0.0"
177
+ version = "1.0.4"
178
178
  source = { registry = "https://pypi.org/simple" }
179
179
  dependencies = [
180
180
  { name = "pydantic" },
181
181
  { name = "requests" },
182
182
  ]
183
- sdist = { url = "https://files.pythonhosted.org/packages/0b/0b/507ed18ee2f73e8cd01ae56955d7c84b1d13b196c190ec137d08f2fb00a4/datamasque_python-1.0.0.tar.gz", hash = "sha256:2acc548c7be659c174c2af45f5a0494868c1571eb8f2c282adeb58a0a73b372c", size = 161412, upload-time = "2026-04-21T22:50:47.836Z" }
183
+ sdist = { url = "https://files.pythonhosted.org/packages/e0/52/1acd8c73b15e07c417a7a90060facc15b8823d5cf3207c6a51a8f9510be6/datamasque_python-1.0.4.tar.gz", hash = "sha256:45d1020364e16cd8200b972960f2bf72a683a2633cacde0c0d3eb8b00a80191a", size = 164063, upload-time = "2026-06-09T06:35:57.719Z" }
184
184
  wheels = [
185
- { url = "https://files.pythonhosted.org/packages/5b/d8/7185251764bbd3455d940383afb8a192910436c0ff917cf6cdace5e33bcd/datamasque_python-1.0.0-py3-none-any.whl", hash = "sha256:1586d14e5006392d4b37ec140c0bf047da03c8e1e0c18dd30a670debf49c8b64", size = 49642, upload-time = "2026-04-21T22:50:46.332Z" },
185
+ { url = "https://files.pythonhosted.org/packages/50/8e/7323a24cd06116cd2b1fcb3a664df86660fc11dbc49c470afc91d2744819/datamasque_python-1.0.4-py3-none-any.whl", hash = "sha256:893eb5e63814d2862d3f2d5d0b26850cb7367f54cd315bb7e2716ed4cdd69cd3", size = 50839, upload-time = "2026-06-09T06:35:56.328Z" },
186
186
  ]
187
187
 
188
188
  [[package]]
@@ -1,82 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from datetime import UTC, datetime
4
- from unittest.mock import MagicMock, patch
5
-
6
- import pytest
7
- from datamasque.client.models.license import LicenseInfo, SwitchableLicenseMetadata
8
- from typer.testing import CliRunner
9
-
10
- from datamasque_cli.main import app
11
-
12
- MODULE = "datamasque_cli.commands.system"
13
-
14
-
15
- @patch(f"{MODULE}.get_client")
16
- def test_licence_projects_to_user_facing_fields(mock_get_client: MagicMock, runner: CliRunner) -> None:
17
- client = MagicMock()
18
- mock_get_client.return_value = client
19
- client.get_current_license_info.return_value = LicenseInfo(
20
- uuid="lic-123",
21
- name="Test Licence",
22
- type="standard",
23
- is_expired=False,
24
- uploadable=True,
25
- expiry_date=datetime(2027, 6, 1, tzinfo=UTC),
26
- days_until_expiry=400,
27
- platform_name="DataMasque",
28
- # Noisy nested field that should NOT appear in the projected output.
29
- switchable_license_metadata=SwitchableLicenseMetadata(license_source="aws"),
30
- )
31
-
32
- result = runner.invoke(app, ["system", "licence", "--json"])
33
-
34
- assert result.exit_code == 0
35
- assert '"uuid": "lic-123"' in result.stdout
36
- assert '"days_until_expiry": 400' in result.stdout
37
- assert '"platform_name": "DataMasque"' in result.stdout
38
- assert "switchable_license_metadata" not in result.stdout
39
- assert "license_source" not in result.stdout
40
-
41
-
42
- @pytest.mark.parametrize(
43
- ("extra_args", "settings_url", "expected_output"),
44
- [
45
- (["--json"], "http://engine.example.com:9021", '"dm_ai_engine_url": "http://engine.example.com:9021"'),
46
- ([], "http://engine.example.com:9021", "http://engine.example.com:9021"),
47
- ([], None, "<not configured>"),
48
- ([], "", "<not configured>"),
49
- ],
50
- )
51
- @patch(f"{MODULE}.get_client")
52
- def test_ai_engine_show(
53
- mock_get_client: MagicMock,
54
- runner: CliRunner,
55
- extra_args: list[str],
56
- settings_url: str | None,
57
- expected_output: str,
58
- ) -> None:
59
- client = MagicMock()
60
- mock_get_client.return_value = client
61
- response = MagicMock()
62
- response.json.return_value = {"dm_ai_engine_url": settings_url}
63
- client.make_request.return_value = response
64
-
65
- result = runner.invoke(app, ["system", "ai-engine", "show", *extra_args])
66
-
67
- assert result.exit_code == 0
68
- client.make_request.assert_called_once_with("GET", "/api/settings/")
69
- assert expected_output in result.stdout
70
-
71
-
72
- @patch(f"{MODULE}.get_client")
73
- def test_ai_engine_set_patches_settings_with_url(mock_get_client: MagicMock, runner: CliRunner) -> None:
74
- client = MagicMock()
75
- mock_get_client.return_value = client
76
-
77
- result = runner.invoke(app, ["system", "ai-engine", "set", "http://engine.example.com:9021"])
78
-
79
- assert result.exit_code == 0
80
- client.make_request.assert_called_once_with(
81
- "PATCH", "/api/settings/", data={"dm_ai_engine_url": "http://engine.example.com:9021"}
82
- )
File without changes
File without changes
File without changes
File without changes