Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,50 @@ uv sync
Then either activate the venv (`source .venv/bin/activate`)
or prefix commands with `uv run`.

## Running `dm` locally

`uv sync` installs the CLI in editable mode,
so the `dm` entry point on the venv reflects your working tree —
no reinstall after each edit.

```console
uv run dm --version # one-shot, no venv activation needed
source .venv/bin/activate && dm --version # or activate once per shell
```

Point it at a DataMasque instance.
For ad-hoc development, env vars are the lowest-friction path
(no `~/.config/datamasque-cli/config.toml` to clean up afterwards):

```console
export DATAMASQUE_URL=http://127.0.0.1:8000
export DATAMASQUE_USERNAME=admin
export DATAMASQUE_PASSWORD='P@ssword12'
export DATAMASQUE_VERIFY_SSL=false # for self-signed local builds
dm system health
dm connections list
```

For longer-lived work, save a profile with `dm auth login`
(stored at `~/.config/datamasque-cli/config.toml`, mode 600).

### Pairing with a local `datamasque-python` checkout

`datamasque-cli` depends on the `datamasque-python` package
for its actual API client.
If you're changing both repos at once
(for example, adding a new endpoint that needs a CLI surface),
install the sibling checkout in editable mode against the CLI's venv:

```console
uv pip install -e ../datamasque-python
```

The dependency is satisfied by the local checkout
and edits to either repo are picked up immediately by `dm`.
A subsequent `uv sync` will re-pin to the registered version —
re-run the `uv pip install -e` if you want the local override back.

## Running the tests

```console
Expand Down
72 changes: 68 additions & 4 deletions src/datamasque_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ def profile_from_env() -> Profile | None:
return None


def _profile_from_env_url_only() -> Profile | None:
"""Build a URL-only profile from `DATAMASQUE_URL`, with empty username/password.

Used by the unauthenticated client factory so callers can hit anonymous
endpoints (admin-install, health) without setting `DATAMASQUE_USERNAME`
and `DATAMASQUE_PASSWORD` -- those fields aren't read by anonymous calls
and demanding them is friction for the first-run setup workflow.
"""
url = os.environ.get(ENV_URL)
if not url:
return None
return Profile(
url=url.rstrip("/"),
username="",
password="",
verify_ssl=_verify_ssl_from_env(default=True),
)


def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
profile = config.get_profile(profile_name)
if not profile.is_configured:
Expand All @@ -60,6 +79,25 @@ def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
return profile


def _resolve_profile_for_unauthenticated(profile_name: str | None) -> Profile:
"""Resolve a profile for an unauthenticated call -- only the URL is required.

Order: explicit `--profile`, env vars (URL-only is sufficient here),
saved active profile. If none yield a URL, abort with a clear hint.
"""
if profile_name is not None:
profile = load_config().get_profile(profile_name)
else:
profile = _profile_from_env_url_only() or load_config().get_profile()
if not profile.url:
abort(
"No DataMasque URL configured.",
code=ErrorCode.AUTH_REQUIRED,
hint=f"Set {ENV_URL} or run: dm auth login",
)
return profile


def _resolve_profile_with_verify(profile_name: str | None) -> tuple[Profile, bool]:
"""Resolve the active `Profile` and apply env-var overrides for `verify_ssl`."""
env_profile = profile_from_env() if profile_name is None else None
Expand Down Expand Up @@ -98,17 +136,43 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient:
`DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
flip TLS verification per-call without re-running `dm auth login`.
"""
profile, verify_ssl = _resolve_profile_with_verify(profile_name)
client, profile, verify_ssl = _build_client(profile_name)
_authenticate_or_abort(client, profile.url, verify_ssl=verify_ssl)
return client


def get_unauthenticated_client(profile_name: str | None = None) -> DataMasqueClient:
"""Build a `DataMasqueClient` without performing the up-front login handshake.

Used by commands that hit endpoints which don't require — or can't yet
use — a token. `admin-install` is the canonical example: on a fresh
server there's no user to authenticate as, so `client.authenticate()`
would always fail before the command ran.

Only `DATAMASQUE_URL` (or a profile with a URL) is required — username
and password aren't read by anonymous endpoints, so demanding them
would be unnecessary friction for first-run setup.
"""
profile = _resolve_profile_for_unauthenticated(profile_name)
verify_ssl = _verify_ssl_from_env(default=profile.verify_ssl)
instance_config = DataMasqueInstanceConfig(
base_url=profile.url,
username=profile.username,
password=profile.password,
verify_ssl=verify_ssl,
)
return DataMasqueClient(instance_config)

client = DataMasqueClient(instance_config)
_authenticate_or_abort(client, profile.url, verify_ssl=verify_ssl)
return client

def _build_client(profile_name: str | None) -> tuple[DataMasqueClient, Profile, bool]:
profile, verify_ssl = _resolve_profile_with_verify(profile_name)
instance_config = DataMasqueInstanceConfig(
base_url=profile.url,
username=profile.username,
password=profile.password,
verify_ssl=verify_ssl,
)
return DataMasqueClient(instance_config), profile, verify_ssl


# Substrings that suggest the underlying error was a TLS failure rather than
Expand Down
38 changes: 31 additions & 7 deletions src/datamasque_cli/commands/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from pathlib import Path

import typer
from datamasque.client.exceptions import DataMasqueApiError

from datamasque_cli.client import get_client
from datamasque_cli.client import get_client, get_unauthenticated_client
from datamasque_cli.commands.rulesets import export_bundle, import_bundle
from datamasque_cli.output import print_json, print_success, print_warning, render_output, should_emit_json
from datamasque_cli.output import ErrorCode, abort, print_json, print_success, print_warning, render_output, should_emit_json

app = typer.Typer(help="System administration commands.", no_args_is_help=True)

Expand All @@ -18,8 +19,14 @@ def health(
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
) -> None:
"""Check DataMasque instance health."""
client = get_client(profile)
"""Check DataMasque instance health.

Uses an unauthenticated client because `/api/healthcheck/` does not require a
token and should answer even when no admin user has been created yet --
the whole point of a health probe is to be the lowest-friction signal of
"is the server up?"
"""
client = get_unauthenticated_client(profile)
client.healthcheck()

if should_emit_json(is_json):
Expand Down Expand Up @@ -101,9 +108,26 @@ def admin_install(
password: str = typer.Option(..., prompt=True, hide_input=True, help="Admin password"),
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
) -> None:
"""Initial admin setup for a fresh DataMasque instance."""
client = get_client(profile)
client.admin_install(email=email, username=username, password=password)
"""Initial admin setup for a fresh DataMasque instance.

Uses an unauthenticated client because the admin-install endpoint is itself
anonymous and on a fresh server there's no user account to authenticate as.
"""
client = get_unauthenticated_client(profile)
try:
client.admin_install(email=email, username=username, password=password)
except DataMasqueApiError as e:
# The server returns 401 on /api/users/admin-install/ once any user exists --
# the endpoint is gated on "no user has been created yet" and DRF treats it
# as a normal auth-required endpoint thereafter. Translate that into a clear
# message rather than letting the underlying "401 Unauthorized" bubble up.
if e.response is not None and e.response.status_code == 401:
abort(
"Admin install is already complete on this DataMasque instance.",
code=ErrorCode.CONFLICT,
hint="Use `dm auth login` to sign in as an existing user.",
)
raise
print_success(f"Admin user '{username}' created.")


Expand Down
104 changes: 104 additions & 0 deletions tests/commands/test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from unittest.mock import MagicMock, patch

import pytest
from datamasque.client.exceptions import DataMasqueApiError
from datamasque.client.models.license import LicenseInfo, SwitchableLicenseMetadata
from typer.testing import CliRunner

Expand Down Expand Up @@ -80,3 +81,106 @@ def test_ai_engine_set_patches_settings_with_url(mock_get_client: MagicMock, run
client.make_request.assert_called_once_with(
"PATCH", "/api/settings/", data={"dm_ai_engine_url": "http://engine.example.com:9021"}
)


# Commands that hit anonymous endpoints must use `get_unauthenticated_client`, not `get_client`.
# Using `get_client` would call `authenticate()` first, which always fails on a fresh server
# (no admin user yet → 401 → SystemExit) and never reaches the actual endpoint.


@patch(f"{MODULE}.get_unauthenticated_client")
@patch(f"{MODULE}.get_client")
def test_admin_install_uses_unauthenticated_client(
mock_get_client: MagicMock, mock_get_unauth: MagicMock, runner: CliRunner
) -> None:
client = MagicMock()
mock_get_unauth.return_value = client

result = runner.invoke(
app,
[
"system", "admin-install",
"--email", "admin@example.com",
"--username", "admin",
"--password", "P@ssword12",
],
)

assert result.exit_code == 0, result.output
mock_get_unauth.assert_called_once()
mock_get_client.assert_not_called()
client.admin_install.assert_called_once_with(
email="admin@example.com", username="admin", password="P@ssword12"
)


@patch(f"{MODULE}.get_unauthenticated_client")
def test_admin_install_translates_401_into_conflict(
mock_get_unauth: MagicMock, runner: CliRunner
) -> None:
"""A 401 from /api/users/admin-install/ means the instance is already set up.

Translate the raw API error into a user-facing conflict message instead of
letting the misleading "Unable to login" traceback bubble up.
"""
client = MagicMock()
mock_get_unauth.return_value = client
response = MagicMock()
response.status_code = 401
client.admin_install.side_effect = DataMasqueApiError("401 Unauthorized", response=response)

result = runner.invoke(
app,
[
"system", "admin-install",
"--email", "admin@example.com",
"--username", "admin",
"--password", "P@ssword12",
],
)

assert result.exit_code == 8 # ErrorCode.CONFLICT
assert "already complete" in result.stderr
assert "dm auth login" in result.stderr


@patch(f"{MODULE}.get_unauthenticated_client")
def test_admin_install_does_not_swallow_non_401_errors(
mock_get_unauth: MagicMock, runner: CliRunner
) -> None:
"""Only 401 is the "already installed" signal -- other errors must surface."""
client = MagicMock()
mock_get_unauth.return_value = client
response = MagicMock()
response.status_code = 400
client.admin_install.side_effect = DataMasqueApiError("400 Bad Request", response=response)

result = runner.invoke(
app,
[
"system", "admin-install",
"--email", "admin@example.com",
"--username", "admin",
"--password", "weak",
],
)

assert result.exit_code != 0
assert "already complete" not in result.stderr


@patch(f"{MODULE}.get_unauthenticated_client")
@patch(f"{MODULE}.get_client")
def test_health_uses_unauthenticated_client(
mock_get_client: MagicMock, mock_get_unauth: MagicMock, runner: CliRunner
) -> None:
client = MagicMock()
mock_get_unauth.return_value = client

result = runner.invoke(app, ["system", "health", "--json"])

assert result.exit_code == 0, result.output
mock_get_unauth.assert_called_once()
mock_get_client.assert_not_called()
client.healthcheck.assert_called_once_with()
assert '"status": "healthy"' in result.stdout
Loading
Loading