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.
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/CHANGELOG.md +7 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/CONTRIBUTING.md +44 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/PKG-INFO +1 -1
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/pyproject.toml +1 -1
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/client.py +68 -4
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/connections.py +14 -1
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/runs.py +2 -3
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/system.py +38 -7
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_connections.py +60 -0
- datamasque_cli-1.4.0/tests/commands/test_system.py +192 -0
- datamasque_cli-1.4.0/tests/integration/test_system.py +97 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_client_auth.py +22 -1
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/uv.lock +4 -4
- datamasque_cli-1.3.0/tests/commands/test_system.py +0 -82
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.claude-plugin/marketplace.json +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.github/workflows/ci.yml +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.github/workflows/release-testpypi.yml +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.github/workflows/release.yml +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/.gitignore +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/LICENSE +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/Makefile +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/NOTICE +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/README.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/assets/demo.gif +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/README.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/datamasque-cli/.claude-plugin/plugin.json +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/datamasque-cli/skills/datamasque-cli/SKILL.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/.claude-plugin/plugin.json +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/SKILL.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/fk-cascade.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/hash-columns-guide.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/mask-definitions-guide.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/ruleset-libraries-guide.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/ruleset-yaml-reference.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-splitter/.claude-plugin/plugin.json +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/claude-skills/ruleset-splitter/skills/ruleset-splitter/SKILL.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/scripts/active_profile_env.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/scripts/bump_version.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/__init__.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/__init__.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/auth.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/discovery.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/files.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/ifm.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/ruleset_libraries.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/rulesets.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/seeds.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/users.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/config.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/main.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/output.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/py.typed +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/__init__.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/__init__.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_auth.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_catalog.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_discovery.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_files.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_ifm.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_ruleset_libraries.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_rulesets.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_runs.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_seeds.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/commands/test_users.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/conftest.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/README.md +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/__init__.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/conftest.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/test_connections.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/test_delete_safety.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/test_rulesets.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/integration/test_runs.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_client_env.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_client_ifm.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_client_profile.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_config.py +0 -0
- {datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/tests/test_output.py +0 -0
- {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
|
+
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
|
|
@@ -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 =
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
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 ==
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
client
|
|
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.
|
|
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.
|
|
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/
|
|
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/
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datamasque_cli-1.3.0 → datamasque_cli-1.4.0}/src/datamasque_cli/commands/ruleset_libraries.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|