datamasque-cli 1.1.0__tar.gz → 1.2.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 (75) hide show
  1. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/CHANGELOG.md +36 -0
  2. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/PKG-INFO +22 -1
  3. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/README.md +21 -0
  4. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/pyproject.toml +1 -1
  5. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/client.py +63 -25
  6. datamasque_cli-1.2.0/src/datamasque_cli/commands/ifm.py +354 -0
  7. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/main.py +2 -0
  8. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/output.py +26 -6
  9. datamasque_cli-1.2.0/tests/commands/test_ifm.py +577 -0
  10. datamasque_cli-1.2.0/tests/test_client_ifm.py +65 -0
  11. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/uv.lock +1 -1
  12. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.claude-plugin/marketplace.json +0 -0
  13. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.github/workflows/ci.yml +0 -0
  14. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.github/workflows/release-testpypi.yml +0 -0
  15. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.github/workflows/release.yml +0 -0
  16. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/.gitignore +0 -0
  17. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/CONTRIBUTING.md +0 -0
  18. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/LICENSE +0 -0
  19. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/Makefile +0 -0
  20. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/NOTICE +0 -0
  21. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/assets/demo.gif +0 -0
  22. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/README.md +0 -0
  23. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/datamasque-cli/.claude-plugin/plugin.json +0 -0
  24. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/datamasque-cli/skills/datamasque-cli/SKILL.md +0 -0
  25. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/.claude-plugin/plugin.json +0 -0
  26. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/SKILL.md +0 -0
  27. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/hash-columns-guide.md +0 -0
  28. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/mask-definitions-guide.md +0 -0
  29. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/ruleset-libraries-guide.md +0 -0
  30. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-builder/skills/ruleset-builder/references/ruleset-yaml-reference.md +0 -0
  31. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-splitter/.claude-plugin/plugin.json +0 -0
  32. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/claude-skills/ruleset-splitter/skills/ruleset-splitter/SKILL.md +0 -0
  33. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/scripts/active_profile_env.py +0 -0
  34. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/scripts/bump_version.py +0 -0
  35. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/__init__.py +0 -0
  36. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/__init__.py +0 -0
  37. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/auth.py +0 -0
  38. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/connections.py +0 -0
  39. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/discovery.py +0 -0
  40. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/files.py +0 -0
  41. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/ruleset_libraries.py +0 -0
  42. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/rulesets.py +0 -0
  43. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/runs.py +0 -0
  44. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/seeds.py +0 -0
  45. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/system.py +0 -0
  46. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/commands/users.py +0 -0
  47. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/config.py +0 -0
  48. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/src/datamasque_cli/py.typed +0 -0
  49. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/__init__.py +0 -0
  50. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/__init__.py +0 -0
  51. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_auth.py +0 -0
  52. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_catalog.py +0 -0
  53. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_connections.py +0 -0
  54. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_discovery.py +0 -0
  55. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_files.py +0 -0
  56. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_ruleset_libraries.py +0 -0
  57. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_rulesets.py +0 -0
  58. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_runs.py +0 -0
  59. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_seeds.py +0 -0
  60. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_system.py +0 -0
  61. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/commands/test_users.py +0 -0
  62. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/conftest.py +0 -0
  63. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/README.md +0 -0
  64. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/__init__.py +0 -0
  65. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/conftest.py +0 -0
  66. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/test_connections.py +0 -0
  67. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/test_delete_safety.py +0 -0
  68. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/test_rulesets.py +0 -0
  69. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/integration/test_runs.py +0 -0
  70. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_client_auth.py +0 -0
  71. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_client_env.py +0 -0
  72. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_client_profile.py +0 -0
  73. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_config.py +0 -0
  74. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_output.py +0 -0
  75. {datamasque_cli-1.1.0 → datamasque_cli-1.2.0}/tests/test_version.py +0 -0
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.2.0
4
+
5
+ ### Added
6
+ - `dm ifm` command group
7
+ for managing in-flight masking ruleset plans
8
+ and running mask operations against the IFM service:
9
+ - `dm ifm list` —
10
+ list all IFM ruleset plans.
11
+ - `dm ifm get <name>` —
12
+ show plan metadata,
13
+ or the ruleset YAML with `--yaml`.
14
+ - `dm ifm create --name <name> --file <yaml>` —
15
+ create a plan from a YAML ruleset,
16
+ with optional `--enabled/--disabled` and `--log-level`.
17
+ - `dm ifm update <name>` —
18
+ update a plan;
19
+ pass any of `--file`, `--enabled/--disabled`, `--log-level`
20
+ and only those fields are sent.
21
+ - `dm ifm delete <name>` —
22
+ delete a plan
23
+ (interactive confirm,
24
+ or `--yes` to skip).
25
+ - `dm ifm mask <name> --data <file|->` —
26
+ mask a JSON list of records against a plan,
27
+ with `--disable-instance-secret`,
28
+ `--run-secret`,
29
+ `--log-level`,
30
+ `--request-id`,
31
+ and `--json/--no-json` (NDJSON) output.
32
+ - `dm ifm verify-token` —
33
+ verify the current IFM token and list its scopes.
34
+
35
+ Authentication reuses your existing `dm` profile credentials
36
+ via the SDK's `DataMasqueIfmClient`,
37
+ which transparently exchanges admin-server credentials for an IFM JWT.
38
+
3
39
  ## v1.1.0
4
40
 
5
41
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datamasque-cli
3
- Version: 1.1.0
3
+ Version: 1.2.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
@@ -39,6 +39,7 @@ so teams can use production-shaped data in non-production environments without e
39
39
  DataMasque CLI `dm` covers:
40
40
 
41
41
  - connections, rulesets, ruleset libraries, and masking runs
42
+ - in-flight masking (IFM) ruleset plans and on-demand mask requests
42
43
  - schema discovery and sensitive-data discovery
43
44
  - users, files, and DataMasque instance administration
44
45
 
@@ -196,6 +197,26 @@ dm libraries validate <name> # Re-validate against current
196
197
  dm libraries usage <name> # Show rulesets using it
197
198
  ```
198
199
 
200
+ ### In-flight masking
201
+
202
+ The IFM service runs alongside the admin server,
203
+ reached at `<DataMasque URL>/ifm` via the standard nginx topology.
204
+
205
+ ```console
206
+ dm ifm list # List ruleset plans
207
+ dm ifm get <name> # Show plan metadata
208
+ dm ifm get <name> --yaml # Print the ruleset YAML
209
+ dm ifm create --name myplan --file rules.yaml # Create (server suffixes a random string to the name)
210
+ dm ifm create --name myplan --file rules.yaml --disabled --log-level DEBUG
211
+ dm ifm update <name> --file rules.yaml # Replace the ruleset YAML
212
+ dm ifm update <name> --enabled # Toggle without re-sending the YAML
213
+ dm ifm update <name> --log-level INFO
214
+ dm ifm delete <name> --yes # Delete a plan
215
+ dm ifm mask <name> --data input.json # Mask a JSON list of records
216
+ dm ifm mask <name> --data - # Read records from stdin
217
+ dm ifm verify-token # Show scopes granted to the current IFM token
218
+ ```
219
+
199
220
  ### Masking runs
200
221
 
201
222
  ```console
@@ -9,6 +9,7 @@ so teams can use production-shaped data in non-production environments without e
9
9
  DataMasque CLI `dm` covers:
10
10
 
11
11
  - connections, rulesets, ruleset libraries, and masking runs
12
+ - in-flight masking (IFM) ruleset plans and on-demand mask requests
12
13
  - schema discovery and sensitive-data discovery
13
14
  - users, files, and DataMasque instance administration
14
15
 
@@ -166,6 +167,26 @@ dm libraries validate <name> # Re-validate against current
166
167
  dm libraries usage <name> # Show rulesets using it
167
168
  ```
168
169
 
170
+ ### In-flight masking
171
+
172
+ The IFM service runs alongside the admin server,
173
+ reached at `<DataMasque URL>/ifm` via the standard nginx topology.
174
+
175
+ ```console
176
+ dm ifm list # List ruleset plans
177
+ dm ifm get <name> # Show plan metadata
178
+ dm ifm get <name> --yaml # Print the ruleset YAML
179
+ dm ifm create --name myplan --file rules.yaml # Create (server suffixes a random string to the name)
180
+ dm ifm create --name myplan --file rules.yaml --disabled --log-level DEBUG
181
+ dm ifm update <name> --file rules.yaml # Replace the ruleset YAML
182
+ dm ifm update <name> --enabled # Toggle without re-sending the YAML
183
+ dm ifm update <name> --log-level INFO
184
+ dm ifm delete <name> --yes # Delete a plan
185
+ dm ifm mask <name> --data input.json # Mask a JSON list of records
186
+ dm ifm mask <name> --data - # Read records from stdin
187
+ dm ifm verify-token # Show scopes granted to the current IFM token
188
+ ```
189
+
169
190
  ### Masking runs
170
191
 
171
192
  ```console
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datamasque-cli"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "Official command-line interface for the DataMasque data-masking platform."
5
5
  authors = [
6
6
  { name = "DataMasque Ltd" },
@@ -8,9 +8,10 @@ from __future__ import annotations
8
8
 
9
9
  import os
10
10
 
11
- from datamasque.client import DataMasqueClient
12
- from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError
11
+ from datamasque.client import DataMasqueClient, DataMasqueIfmClient
12
+ from datamasque.client.exceptions import DataMasqueApiError, DataMasqueTransportError, IfmAuthError
13
13
  from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
14
+ from datamasque.client.models.ifm import DataMasqueIfmInstanceConfig
14
15
 
15
16
  from datamasque_cli.config import Config, Profile, load_config
16
17
  from datamasque_cli.output import ErrorCode, abort
@@ -54,14 +55,38 @@ def _resolve_profile(config: Config, profile_name: str | None) -> Profile:
54
55
  abort(
55
56
  f"Profile '{name}' is not configured.",
56
57
  code=ErrorCode.AUTH_REQUIRED,
57
- hint=(
58
- f"Run: dm auth login --profile {name} --url <URL> --username <USER> "
59
- f"or set {ENV_URL}, {ENV_USERNAME}, and {ENV_PASSWORD}."
60
- ),
58
+ hint=(f"Run: dm auth login --profile {name} or set {ENV_URL}, {ENV_USERNAME}, and {ENV_PASSWORD}."),
61
59
  )
62
60
  return profile
63
61
 
64
62
 
63
+ def _resolve_profile_with_verify(profile_name: str | None) -> tuple[Profile, bool]:
64
+ """Resolve the active `Profile` and apply env-var overrides for `verify_ssl`."""
65
+ env_profile = profile_from_env() if profile_name is None else None
66
+ if env_profile is not None:
67
+ profile = env_profile
68
+ else:
69
+ config = load_config()
70
+ profile = _resolve_profile(config, profile_name)
71
+ return profile, _verify_ssl_from_env(default=profile.verify_ssl)
72
+
73
+
74
+ def _authenticate_or_abort(
75
+ client: DataMasqueClient | DataMasqueIfmClient,
76
+ url: str,
77
+ *,
78
+ verify_ssl: bool,
79
+ failure_label: str = "Authentication",
80
+ extra_auth_excs: tuple[type[Exception], ...] = (),
81
+ ) -> None:
82
+ try:
83
+ client.authenticate()
84
+ except DataMasqueTransportError as e:
85
+ abort(_format_transport_error(url, e, verify_ssl=verify_ssl), code=ErrorCode.TRANSPORT_ERROR)
86
+ except (DataMasqueApiError, *extra_auth_excs) as e:
87
+ abort(f"{failure_label} failed: {e}", code=ErrorCode.AUTH_FAILED)
88
+
89
+
65
90
  def get_client(profile_name: str | None = None) -> DataMasqueClient:
66
91
  """Build and authenticate a `DataMasqueClient`.
67
92
 
@@ -69,18 +94,11 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient:
69
94
  1. Environment variables (DATAMASQUE_URL, DATAMASQUE_USERNAME, DATAMASQUE_PASSWORD)
70
95
  2. Named profile (--profile flag)
71
96
  3. Active profile from config file
72
- """
73
- # Env vars take precedence unless a specific profile was requested.
74
- env_profile = profile_from_env() if profile_name is None else None
75
- if env_profile is not None:
76
- profile = env_profile
77
- else:
78
- config = load_config()
79
- profile = _resolve_profile(config, profile_name)
80
97
 
81
- # `DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
82
- # flip TLS verification per-call without re-running `dm auth login`.
83
- verify_ssl = _verify_ssl_from_env(default=profile.verify_ssl)
98
+ `DATAMASQUE_VERIFY_SSL` always wins over the stored profile so you can
99
+ flip TLS verification per-call without re-running `dm auth login`.
100
+ """
101
+ profile, verify_ssl = _resolve_profile_with_verify(profile_name)
84
102
  instance_config = DataMasqueInstanceConfig(
85
103
  base_url=profile.url,
86
104
  username=profile.username,
@@ -89,14 +107,7 @@ def get_client(profile_name: str | None = None) -> DataMasqueClient:
89
107
  )
90
108
 
91
109
  client = DataMasqueClient(instance_config)
92
-
93
- try:
94
- client.authenticate()
95
- except DataMasqueTransportError as e:
96
- abort(_format_transport_error(profile.url, e, verify_ssl=verify_ssl), code=ErrorCode.TRANSPORT_ERROR)
97
- except DataMasqueApiError as e:
98
- abort(f"Authentication failed: {e}", code=ErrorCode.AUTH_FAILED)
99
-
110
+ _authenticate_or_abort(client, profile.url, verify_ssl=verify_ssl)
100
111
  return client
101
112
 
102
113
 
@@ -110,3 +121,30 @@ def _format_transport_error(url: str, error: Exception, *, verify_ssl: bool) ->
110
121
  if verify_ssl and any(term in str(error).lower() for term in _SSL_HINT_TERMS):
111
122
  message += "\nIf this is a self-signed local build, retry with --insecure or set DATAMASQUE_VERIFY_SSL=false."
112
123
  return message
124
+
125
+
126
+ def get_ifm_client(profile_name: str | None = None) -> DataMasqueIfmClient:
127
+ """Build and authenticate a `DataMasqueIfmClient`.
128
+
129
+ Credential resolution order matches `get_client`.
130
+ The IFM base URL is derived as `<admin_url>/ifm`,
131
+ matching the standard nginx topology that proxies `/ifm/` to the IFM container on the same hostname.
132
+ """
133
+ profile, verify_ssl = _resolve_profile_with_verify(profile_name)
134
+ instance_config = DataMasqueIfmInstanceConfig(
135
+ admin_server_base_url=profile.url,
136
+ ifm_base_url=f"{profile.url.rstrip('/')}/ifm",
137
+ username=profile.username,
138
+ password=profile.password,
139
+ verify_ssl=verify_ssl,
140
+ )
141
+
142
+ client = DataMasqueIfmClient(instance_config)
143
+ _authenticate_or_abort(
144
+ client,
145
+ profile.url,
146
+ verify_ssl=verify_ssl,
147
+ failure_label="IFM authentication",
148
+ extra_auth_excs=(IfmAuthError,),
149
+ )
150
+ return client
@@ -0,0 +1,354 @@
1
+ """In-flight masking (IFM) commands.
2
+
3
+ Wraps `DataMasqueIfmClient` for managing IFM ruleset plans and running mask operations.
4
+ The IFM service exposes a separate HTTP API;
5
+ the SDK handles JWT auth transparently using the same admin-server credentials as `dm rulesets`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from enum import StrEnum
13
+ from pathlib import Path
14
+ from typing import Any, NoReturn
15
+
16
+ import typer
17
+ from datamasque.client.exceptions import DataMasqueApiError
18
+ from datamasque.client.models.ifm import (
19
+ IfmMaskRequest,
20
+ RulesetPlanCreateRequest,
21
+ RulesetPlanOptions,
22
+ RulesetPlanPartialUpdateRequest,
23
+ )
24
+
25
+ from datamasque_cli.client import get_ifm_client
26
+ from datamasque_cli.output import ErrorCode, abort, print_error, print_json, print_success, render_output
27
+
28
+ app = typer.Typer(help="Manage in-flight-masking (IFM) ruleset plans and execute masks.", no_args_is_help=True)
29
+
30
+
31
+ # IFM service maps HTTP statuses to the CLI's stable `ErrorCode` taxonomy so
32
+ # agents and scripts get the right exit code (see "Exit codes" in `README.md`).
33
+ # Anything not listed falls through to `ErrorCode.ERROR` (exit 1).
34
+ _STATUS_TO_ERROR_CODE: dict[int, ErrorCode] = {
35
+ 400: ErrorCode.INVALID_INPUT,
36
+ 404: ErrorCode.NOT_FOUND,
37
+ 409: ErrorCode.CONFLICT,
38
+ 422: ErrorCode.INVALID_INPUT,
39
+ }
40
+
41
+
42
+ def _format_pydantic_errors(errors: list[Any]) -> str:
43
+ """Flatten FastAPI's `detail` list (Pydantic `e.errors()`) into a readable string.
44
+
45
+ Each entry looks like `{"loc": [...], "msg": "...", "type": "..."}`;
46
+ we render `field.path: message` per entry, joined with `; `.
47
+ Entries that don't match the shape fall back to `str(entry)`.
48
+ """
49
+ parts: list[str] = []
50
+ for entry in errors:
51
+ if isinstance(entry, dict) and "msg" in entry:
52
+ loc = entry.get("loc") or []
53
+ location = ".".join(str(part) for part in loc if part != "body") if isinstance(loc, (list, tuple)) else ""
54
+ parts.append(f"{location}: {entry['msg']}" if location else str(entry["msg"]))
55
+ else:
56
+ parts.append(str(entry))
57
+ return "; ".join(parts)
58
+
59
+
60
+ def _server_error_detail(exc: DataMasqueApiError) -> str | None:
61
+ """Pull a human-readable error string from the IFM response body, if present.
62
+
63
+ The IFM service returns `{"error": "..."}`;
64
+ FastAPI validation errors come back as `{"detail": ...}`,
65
+ where `detail` is either a string or a list of Pydantic error dicts (422s).
66
+ Falls through to `None` if the body is missing or not parseable.
67
+ """
68
+ try:
69
+ body = exc.response.json()
70
+ except (ValueError, AttributeError):
71
+ return None
72
+ if isinstance(body, dict):
73
+ error = body.get("error")
74
+ if isinstance(error, str):
75
+ return error
76
+ if "detail" in body:
77
+ detail = body["detail"]
78
+ if isinstance(detail, str):
79
+ return detail
80
+ if isinstance(detail, list):
81
+ return _format_pydantic_errors(detail)
82
+ return str(detail)
83
+ return None
84
+
85
+
86
+ def _abort_api_error(prefix: str, exc: DataMasqueApiError) -> NoReturn:
87
+ """Map an `DataMasqueApiError` to the right `ErrorCode` and surface the body.
88
+
89
+ The default `str(exc)` only includes the HTTP status,
90
+ so the actual server message is hidden without this.
91
+ """
92
+ status_code = getattr(exc.response, "status_code", None)
93
+ code = _STATUS_TO_ERROR_CODE.get(status_code, ErrorCode.ERROR) if isinstance(status_code, int) else ErrorCode.ERROR
94
+ detail = _server_error_detail(exc)
95
+ message = f"{prefix}: {detail}" if detail else f"{prefix}: {exc}"
96
+ abort(message, code=code)
97
+
98
+
99
+ class LogLevel(StrEnum):
100
+ DEBUG = "DEBUG"
101
+ INFO = "INFO"
102
+ WARNING = "WARNING"
103
+ ERROR = "ERROR"
104
+ CRITICAL = "CRITICAL"
105
+
106
+
107
+ def _options_from_flags(
108
+ enabled: bool | None,
109
+ log_level: LogLevel | None,
110
+ ) -> RulesetPlanOptions | None:
111
+ if enabled is None and log_level is None:
112
+ return None
113
+ return RulesetPlanOptions(enabled=enabled, default_log_level=log_level)
114
+
115
+
116
+ def _load_mask_input(data: str) -> list[Any]:
117
+ if data == "-":
118
+ raw = sys.stdin.read()
119
+ else:
120
+ try:
121
+ raw = Path(data).read_text()
122
+ except OSError as exc:
123
+ code = ErrorCode.NOT_FOUND if isinstance(exc, FileNotFoundError) else ErrorCode.INVALID_INPUT
124
+ abort(f"Could not read mask input file '{data}': {exc.strerror or exc}", code=code)
125
+
126
+ try:
127
+ parsed = json.loads(raw)
128
+ except json.JSONDecodeError as exc:
129
+ abort(f"Failed to parse mask input as JSON: {exc}", code=ErrorCode.INVALID_INPUT)
130
+
131
+ if not isinstance(parsed, list):
132
+ abort("Mask input must be a JSON list (array) of records.", code=ErrorCode.INVALID_INPUT)
133
+ return parsed
134
+
135
+
136
+ @app.command("list")
137
+ def list_plans(
138
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
139
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
140
+ ) -> None:
141
+ """List all IFM ruleset plans."""
142
+ client = get_ifm_client(profile)
143
+ try:
144
+ plans = client.list_ruleset_plans()
145
+ except DataMasqueApiError as exc:
146
+ _abort_api_error("Failed to list IFM ruleset plans", exc)
147
+
148
+ data = [
149
+ {
150
+ "name": plan.name,
151
+ "serial": plan.serial,
152
+ "created": plan.created_time.isoformat(),
153
+ "modified": plan.modified_time.isoformat(),
154
+ "enabled": plan.options.enabled,
155
+ }
156
+ for plan in plans
157
+ ]
158
+
159
+ render_output(
160
+ data,
161
+ is_json=is_json,
162
+ columns=["name", "serial", "created", "modified", "enabled"],
163
+ title="IFM ruleset plans",
164
+ )
165
+
166
+
167
+ @app.command("get")
168
+ def get_plan(
169
+ name: str = typer.Argument(help="Ruleset plan name"),
170
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
171
+ is_yaml: bool = typer.Option(False, "--yaml", help="Output the ruleset YAML only"),
172
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
173
+ ) -> None:
174
+ """Show an IFM ruleset plan's metadata or YAML."""
175
+ client = get_ifm_client(profile)
176
+ try:
177
+ plan = client.get_ruleset_plan(name)
178
+ except DataMasqueApiError as exc:
179
+ _abort_api_error(f"Failed to get IFM ruleset plan '{name}'", exc)
180
+
181
+ if is_yaml:
182
+ if plan.ruleset_yaml is None:
183
+ abort(f"IFM ruleset plan '{name}' has no ruleset YAML.")
184
+ typer.echo(plan.ruleset_yaml)
185
+ return
186
+
187
+ data: dict[str, object] = {
188
+ "name": plan.name,
189
+ "serial": plan.serial,
190
+ "created": plan.created_time.isoformat(),
191
+ "modified": plan.modified_time.isoformat(),
192
+ "enabled": plan.options.enabled,
193
+ "default_log_level": plan.options.default_log_level,
194
+ "ruleset_yaml": plan.ruleset_yaml,
195
+ }
196
+ render_output(data, is_json=is_json, title=f"IFM plan: {name}")
197
+
198
+
199
+ @app.command("create")
200
+ def create_plan(
201
+ name: str = typer.Option(..., "--name", help="Ruleset plan name (server may suffix a random string)"),
202
+ file: Path = typer.Option(..., "--file", "-f", help="Path to YAML ruleset file", exists=True, readable=True),
203
+ enabled: bool | None = typer.Option(
204
+ None,
205
+ "--enabled/--disabled",
206
+ help="Enable or disable the plan immediately. Defaults to the server default.",
207
+ ),
208
+ log_level: LogLevel | None = typer.Option(
209
+ None,
210
+ "--log-level",
211
+ case_sensitive=False,
212
+ help="Default log level.",
213
+ ),
214
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
215
+ ) -> None:
216
+ """Create a new IFM ruleset plan from a YAML file."""
217
+ client = get_ifm_client(profile)
218
+ request = RulesetPlanCreateRequest(
219
+ name=name,
220
+ ruleset_yaml=file.read_text(),
221
+ options=_options_from_flags(enabled, log_level),
222
+ )
223
+ try:
224
+ created = client.create_ruleset_plan(request)
225
+ except DataMasqueApiError as exc:
226
+ _abort_api_error("Failed to create IFM ruleset plan", exc)
227
+
228
+ print_success(f"IFM ruleset plan '{created.name}' created (serial {created.serial}).")
229
+ if created.url:
230
+ typer.echo(created.url)
231
+
232
+
233
+ @app.command("update")
234
+ def update_plan(
235
+ name: str = typer.Argument(help="Existing ruleset plan name"),
236
+ file: Path | None = typer.Option(
237
+ None, "--file", "-f", help="Path to YAML ruleset file (optional)", exists=True, readable=True
238
+ ),
239
+ enabled: bool | None = typer.Option(None, "--enabled/--disabled", help="Enable or disable the plan."),
240
+ log_level: LogLevel | None = typer.Option(None, "--log-level", case_sensitive=False, help="Default log level."),
241
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
242
+ ) -> None:
243
+ """Update an IFM ruleset plan: only fields you pass are sent."""
244
+ if file is None and enabled is None and log_level is None:
245
+ abort(
246
+ "Pass at least one of --file, --enabled/--disabled, or --log-level.",
247
+ code=ErrorCode.INVALID_INPUT,
248
+ )
249
+
250
+ client = get_ifm_client(profile)
251
+ request = RulesetPlanPartialUpdateRequest(
252
+ ruleset_yaml=file.read_text() if file is not None else None,
253
+ options=_options_from_flags(enabled, log_level),
254
+ )
255
+ try:
256
+ updated = client.patch_ruleset_plan(name, request)
257
+ except DataMasqueApiError as exc:
258
+ _abort_api_error(f"Failed to update IFM ruleset plan '{name}'", exc)
259
+
260
+ print_success(f"IFM ruleset plan '{name}' updated (serial {updated.serial}).")
261
+
262
+
263
+ @app.command("delete")
264
+ def delete_plan(
265
+ name: str = typer.Argument(help="Ruleset plan name to delete"),
266
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
267
+ is_confirmed: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
268
+ ) -> None:
269
+ """Delete an IFM ruleset plan."""
270
+ if not is_confirmed:
271
+ typer.confirm(f"Delete IFM ruleset plan '{name}'?", abort=True)
272
+
273
+ client = get_ifm_client(profile)
274
+ try:
275
+ client.delete_ruleset_plan(name)
276
+ except DataMasqueApiError as exc:
277
+ _abort_api_error(f"Failed to delete IFM ruleset plan '{name}'", exc)
278
+
279
+ print_success(f"IFM ruleset plan '{name}' deleted.")
280
+
281
+
282
+ @app.command("mask")
283
+ def mask(
284
+ name: str = typer.Argument(help="Ruleset plan name to mask against"),
285
+ data: str = typer.Option(
286
+ ...,
287
+ "--data",
288
+ "-d",
289
+ help="Path to a JSON file containing a list of records to mask, or '-' to read from stdin.",
290
+ ),
291
+ disable_instance_secret: bool = typer.Option(
292
+ False, "--disable-instance-secret", help="Disable the per-instance secret for this run."
293
+ ),
294
+ run_secret: str | None = typer.Option(None, "--run-secret", help="Override the run secret for this call."),
295
+ log_level: LogLevel | None = typer.Option(
296
+ None, "--log-level", case_sensitive=False, help="Override the plan's default log level."
297
+ ),
298
+ request_id: str | None = typer.Option(None, "--request-id", help="Custom request id (echoed in the response)."),
299
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
300
+ is_json: bool = typer.Option(
301
+ True,
302
+ "--json/--no-json",
303
+ help="Output the masked records as a JSON array (default). Use --no-json for NDJSON (one record per line).",
304
+ ),
305
+ ) -> None:
306
+ """Run an IFM mask against a list of records."""
307
+ records = _load_mask_input(data)
308
+ client = get_ifm_client(profile)
309
+ request = IfmMaskRequest(
310
+ data=records,
311
+ disable_instance_secret=disable_instance_secret or None,
312
+ run_secret=run_secret,
313
+ log_level=log_level,
314
+ request_id=request_id,
315
+ )
316
+
317
+ try:
318
+ result = client.mask(name, request)
319
+ except DataMasqueApiError as exc:
320
+ _abort_api_error("Mask request failed", exc)
321
+
322
+ if not result.success:
323
+ print_error("Mask failed.")
324
+ for log in result.logs or []:
325
+ print_error(f" [{log.log_level}] {log.timestamp} {log.message}")
326
+ raise SystemExit(1)
327
+
328
+ if is_json:
329
+ print_json(result.data or [])
330
+ else:
331
+ for record in result.data or []:
332
+ typer.echo(json.dumps(record, default=str))
333
+
334
+
335
+ @app.command("verify-token")
336
+ def verify_token(
337
+ profile: str | None = typer.Option(None, "--profile", "-p", help="Profile to use"),
338
+ is_json: bool = typer.Option(False, "--json", help="Output as JSON"),
339
+ ) -> None:
340
+ """Verify the current IFM token and list its scopes."""
341
+ client = get_ifm_client(profile)
342
+ try:
343
+ info = client.verify_token()
344
+ except DataMasqueApiError as exc:
345
+ _abort_api_error("Failed to verify IFM token", exc)
346
+ if is_json:
347
+ print_json({"scopes": info.scopes})
348
+ return
349
+ render_output(
350
+ [{"scope": scope} for scope in info.scopes],
351
+ is_json=False,
352
+ columns=["scope"],
353
+ title="IFM token scopes",
354
+ )
@@ -22,6 +22,7 @@ from datamasque_cli.commands import (
22
22
  connections,
23
23
  discovery,
24
24
  files,
25
+ ifm,
25
26
  ruleset_libraries,
26
27
  rulesets,
27
28
  runs,
@@ -47,6 +48,7 @@ app.add_typer(seeds.app, name="seeds")
47
48
  app.add_typer(files.app, name="files")
48
49
  app.add_typer(system.app, name="system")
49
50
  app.add_typer(ruleset_libraries.app, name="libraries")
51
+ app.add_typer(ifm.app, name="ifm")
50
52
 
51
53
 
52
54
  @app.command()
@@ -20,6 +20,7 @@ from typing import Any, NoReturn
20
20
  import typer
21
21
  from rich.console import Console
22
22
  from rich.table import Table
23
+ from rich.text import Text
23
24
  from rich.theme import Theme
24
25
 
25
26
  _DM_THEME = Theme(
@@ -124,6 +125,21 @@ def print_json(data: object) -> None:
124
125
  typer.echo(json.dumps(data, indent=2, default=str))
125
126
 
126
127
 
128
+ def _cell(value: object) -> Text:
129
+ """Coerce a cell value into a `Text` so Rich treats it literally.
130
+
131
+ Without this, square brackets in YAML inline lists (e.g. `path: [a, b]`)
132
+ are parsed by Rich as console markup tags and silently dropped from the
133
+ rendered cell. `Text` instances pass through unchanged so callers that
134
+ *want* styling (see `style_status`) still work.
135
+ """
136
+ if value is None:
137
+ return Text("")
138
+ if isinstance(value, Text):
139
+ return value
140
+ return Text(str(value))
141
+
142
+
127
143
  def print_table(
128
144
  columns: list[str],
129
145
  rows: list[list[Any]],
@@ -135,7 +151,7 @@ def print_table(
135
151
  # rather than silently ellipsizing them, so IDs stay copyable in narrow terminals.
136
152
  table.add_column(col, overflow="fold")
137
153
  for row in rows:
138
- table.add_row(*[str(v) if v is not None else "" for v in row])
154
+ table.add_row(*[_cell(v) for v in row])
139
155
  stdout_console.print(table)
140
156
 
141
157
 
@@ -145,7 +161,7 @@ def print_kv(data: dict[str, Any], title: str | None = None) -> None:
145
161
  table.add_column("Key", style="bold")
146
162
  table.add_column("Value", overflow="fold")
147
163
  for key, value in data.items():
148
- table.add_row(key, str(value) if value is not None else "")
164
+ table.add_row(key, _cell(value))
149
165
  stdout_console.print(table)
150
166
 
151
167
 
@@ -171,10 +187,14 @@ def print_info(message: str) -> None:
171
187
  console.print(f"[dim]{message}[/dim]")
172
188
 
173
189
 
174
- def style_status(status: str) -> str:
175
- """Wrap a run status string in the appropriate colour tag."""
176
- style_name = f"status.{status}"
177
- return f"[{style_name}]{status}[/{style_name}]"
190
+ def style_status(status: str) -> Text:
191
+ """Wrap a run status string in the appropriate colour tag.
192
+
193
+ Returns a `Text` (not a markup string) so it passes through `print_table`
194
+ and `print_kv` unchanged. Returning a raw markup string would be re-escaped
195
+ by `_cell` and lose its colour.
196
+ """
197
+ return Text(status, style=f"status.{status}")
178
198
 
179
199
 
180
200
  def render_output(