databricks-mason 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ """`mason deploy` and the `mason deployments` group — manage agent deployments.
2
+
3
+ `mason deploy` is the integrated entry point: it can provision a memory store and a
4
+ session store for the agent, inject their identifiers into the deployment's `app.yaml`
5
+ env, then roll out the deployment. `mason deployments` covers the lifecycle verbs
6
+ (`list`/`get`/`logs`/`start`/`stop`/`delete`).
7
+
8
+ Deployments run on the Databricks Apps runtime, which this module drives via the
9
+ `databricks apps` CLI — an implementation detail that is not part of Mason's surface.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import pathlib
16
+ import subprocess
17
+ from typing import Any, Optional
18
+
19
+ import click
20
+ import yaml
21
+
22
+ from databricks_mason import render, timefmt
23
+ from databricks_mason.errors import AgentCliError
24
+ from databricks_mason.render import field
25
+ from databricks_mason.tracing import TRACES_DEST_ENV, TRACES_EXPERIMENT_ENV
26
+
27
+ _MEMORY_ENV = "AGENT_MEMORY_STORE"
28
+ _SESSION_ENV = "AGENT_SESSION_STORE"
29
+
30
+
31
+ # --- databricks CLI plumbing (the deployment runtime) -----------------------
32
+
33
+
34
+ def _databricks(
35
+ args: list[str], profile: Optional[str], *, capture: bool = False, check: bool = True
36
+ ) -> subprocess.CompletedProcess:
37
+ cmd = ["databricks", *args]
38
+ if profile:
39
+ cmd += ["--profile", profile]
40
+ result = subprocess.run(cmd, text=True, capture_output=capture)
41
+ if check and result.returncode != 0:
42
+ detail = (result.stderr or result.stdout or "").strip() if capture else None
43
+ raise AgentCliError(f"`{' '.join(cmd)}` failed (exit {result.returncode})", hint=detail)
44
+ return result
45
+
46
+
47
+ def _deployment_exists(name: str, profile: Optional[str]) -> bool:
48
+ return _databricks(["apps", "get", name], profile, capture=True, check=False).returncode == 0
49
+
50
+
51
+ # --- app.yaml manifest handling ---------------------------------------------
52
+
53
+
54
+ def _upsert_manifest_env(source: pathlib.Path, updates: dict[str, str]) -> bool:
55
+ """Inject/overwrite env entries in <source>/app.yaml. Returns True if it scaffolded a new file."""
56
+ app_yaml = source / "app.yaml"
57
+ if app_yaml.exists():
58
+ loaded = yaml.safe_load(app_yaml.read_text())
59
+ doc: dict[str, Any] = loaded if isinstance(loaded, dict) else {}
60
+ scaffolded = False
61
+ else:
62
+ doc = {"command": ["# TODO: set your run command, e.g. ['uvicorn', 'app:app']"], "env": []}
63
+ scaffolded = True
64
+
65
+ raw_env = doc.get("env")
66
+ env: list[dict[str, Any]] = (
67
+ [entry for entry in raw_env if isinstance(entry, dict)] if isinstance(raw_env, list) else []
68
+ )
69
+ by_name = {e.get("name"): e for e in env if isinstance(e, dict)}
70
+ for name, value in updates.items():
71
+ if name in by_name:
72
+ by_name[name]["value"] = value
73
+ by_name[name].pop("valueFrom", None)
74
+ else:
75
+ env.append({"name": name, "value": value})
76
+ doc["env"] = env
77
+ app_yaml.write_text(yaml.safe_dump(doc, sort_keys=False))
78
+ return scaffolded
79
+
80
+
81
+ # --- store provisioning -----------------------------------------------------
82
+
83
+
84
+ def _ensure_memory_store(client, display_name: str) -> dict:
85
+ try:
86
+ return client.create_memory_store(display_name)
87
+ except AgentCliError as exc:
88
+ if exc.error_code != "ALREADY_EXISTS":
89
+ raise
90
+ listing = client.list_memory_stores(page_size=1000)
91
+ for store in field(listing, "managed_memory_stores") or []:
92
+ if field(store, "display_name") == display_name:
93
+ return store
94
+ raise AgentCliError(f"Memory store '{display_name}' exists but could not be resolved.")
95
+
96
+
97
+ def _ensure_session_store(client, name: str) -> dict:
98
+ try:
99
+ return client.create_session_store(name)
100
+ except AgentCliError as exc:
101
+ if exc.error_code != "ALREADY_EXISTS":
102
+ raise
103
+ return client.get_session_store(name)
104
+
105
+
106
+ # --- mason deploy -----------------------------------------------------------
107
+
108
+
109
+ @click.command()
110
+ @click.argument("name")
111
+ @click.option(
112
+ "--source",
113
+ required=True,
114
+ type=click.Path(exists=True, file_okay=False),
115
+ help="Local source directory for the deployment (containing app.yaml).",
116
+ )
117
+ @click.option(
118
+ "--with-memory-store",
119
+ "memory_store",
120
+ default=None,
121
+ help="Memory store display name to wire in via AGENT_MEMORY_STORE.",
122
+ )
123
+ @click.option(
124
+ "--with-session-store",
125
+ "session_store",
126
+ default=None,
127
+ help="Session store name to wire in via AGENT_SESSION_STORE.",
128
+ )
129
+ @click.option(
130
+ "--with-traces",
131
+ "traces_destination",
132
+ default=None,
133
+ help="UC trace destination 'catalog.schema' to wire in via MLFLOW_TRACING_DESTINATION "
134
+ "(link it first with `mason tracing setup`).",
135
+ )
136
+ @click.option(
137
+ "--traces-experiment",
138
+ default=None,
139
+ help="MLflow experiment path to wire in via MLFLOW_EXPERIMENT_NAME.",
140
+ )
141
+ @click.option(
142
+ "--create-stores",
143
+ is_flag=True,
144
+ help="Create the referenced stores if they don't exist (idempotent).",
145
+ )
146
+ @click.option(
147
+ "--workspace-path",
148
+ default=None,
149
+ help="Workspace destination for the synced source (defaults to a per-user path).",
150
+ )
151
+ @click.pass_obj
152
+ def deploy(
153
+ obj,
154
+ name,
155
+ source,
156
+ memory_store,
157
+ session_store,
158
+ traces_destination,
159
+ traces_experiment,
160
+ create_stores,
161
+ workspace_path,
162
+ ) -> None:
163
+ """Deploy an agent: provision its stores, wire them in, and roll out the deployment."""
164
+ source_dir = pathlib.Path(source)
165
+ client = obj.client()
166
+
167
+ # 1. Provision / resolve stores and build the env to inject.
168
+ env_updates: dict[str, str] = {}
169
+ provisioned: dict[str, Any] = {}
170
+ if memory_store:
171
+ store = (
172
+ _ensure_memory_store(client, memory_store)
173
+ if create_stores
174
+ else client.get_memory_store(memory_store)
175
+ )
176
+ env_updates[_MEMORY_ENV] = field(store, "name") or memory_store
177
+ provisioned["Memory store"] = env_updates[_MEMORY_ENV]
178
+ if session_store:
179
+ if create_stores:
180
+ _ensure_session_store(client, session_store)
181
+ env_updates[_SESSION_ENV] = session_store
182
+ provisioned["Session store"] = session_store
183
+ if traces_destination:
184
+ env_updates[TRACES_DEST_ENV] = traces_destination
185
+ provisioned["Traces"] = traces_destination
186
+ if traces_experiment:
187
+ env_updates[TRACES_EXPERIMENT_ENV] = traces_experiment
188
+
189
+ # 2. Patch the app.yaml manifest with the store identifiers.
190
+ scaffolded = False
191
+ if env_updates:
192
+ scaffolded = _upsert_manifest_env(source_dir, env_updates)
193
+
194
+ # 3. Roll out the deployment (Databricks Apps runtime).
195
+ if not _deployment_exists(name, obj.profile):
196
+ _databricks(["apps", "create", name], obj.profile)
197
+ ws_path = workspace_path or f"/Workspace/Users/{client.current_user}/mason_deployments/{name}"
198
+ _databricks(["sync", str(source_dir), ws_path], obj.profile)
199
+ _databricks(["apps", "deploy", name, "--source-code-path", ws_path], obj.profile)
200
+
201
+ if obj.output == "json":
202
+ render.emit_json({"deployment": name, "workspace_path": ws_path, "env": env_updates})
203
+ return
204
+
205
+ steps = [f"mason deployments logs {name}", f"mason deployments get {name}"]
206
+ if scaffolded:
207
+ steps.insert(
208
+ 0, f"Set a real `command:` in {source_dir / 'app.yaml'} (a placeholder was written)"
209
+ )
210
+ render.success(
211
+ f"Deployed agent '{name}'",
212
+ fields={"Workspace path": ws_path, **provisioned},
213
+ next_steps=steps,
214
+ )
215
+
216
+
217
+ # --- mason deployments <lifecycle> ------------------------------------------
218
+
219
+
220
+ @click.group()
221
+ def deployments() -> None:
222
+ """Manage agent deployments."""
223
+
224
+
225
+ def _deployment_status(a: dict) -> Optional[str]:
226
+ for key in ("app_status", "compute_status"):
227
+ section = a.get(key)
228
+ if isinstance(section, dict) and field(section, "state"):
229
+ return field(section, "state")
230
+ return field(a, "state")
231
+
232
+
233
+ @deployments.command("list")
234
+ @click.pass_obj
235
+ def deployments_list(obj) -> None:
236
+ """List agent deployments in the workspace."""
237
+ result = _databricks(["apps", "list", "-o", "json"], obj.profile, capture=True)
238
+ data = json.loads(result.stdout or "[]")
239
+ items = data.get("apps", data) if isinstance(data, dict) else data
240
+ if obj.output == "json":
241
+ render.emit_json(items)
242
+ return
243
+ rows = [
244
+ [
245
+ field(a, "name"),
246
+ render.status_pill(_deployment_status(a)),
247
+ field(a, "url"),
248
+ timefmt.relative(field(a, "update_time")),
249
+ ]
250
+ for a in items
251
+ ]
252
+ render.resource_table(
253
+ "Agent Deployments",
254
+ [("Name", "left"), ("Status", "left"), ("URL", "left"), ("Updated", "left")],
255
+ rows,
256
+ )
257
+
258
+
259
+ @deployments.command("get")
260
+ @click.argument("name")
261
+ @click.pass_obj
262
+ def deployments_get(obj, name) -> None:
263
+ """Get an agent deployment's details."""
264
+ result = _databricks(["apps", "get", name, "-o", "json"], obj.profile, capture=True)
265
+ data = json.loads(result.stdout or "{}")
266
+ if obj.output == "json":
267
+ render.emit_json(data)
268
+ return
269
+ url = field(data, "url")
270
+ render.detail(
271
+ "Agent Deployment",
272
+ field(data, "name") or name,
273
+ {
274
+ "URL": url,
275
+ "Description": field(data, "description"),
276
+ "Created": timefmt.absolute(field(data, "create_time")),
277
+ "Updated": timefmt.absolute(field(data, "update_time")),
278
+ },
279
+ status=_deployment_status(data),
280
+ snippets=[("open", "bash", f"open {url}")] if url else None,
281
+ )
282
+
283
+
284
+ @deployments.command("logs")
285
+ @click.argument("name")
286
+ @click.pass_obj
287
+ def deployments_logs(obj, name) -> None:
288
+ """Stream a deployment's logs."""
289
+ _databricks(["apps", "logs", name], obj.profile)
290
+
291
+
292
+ @deployments.command("start")
293
+ @click.argument("name")
294
+ @click.pass_obj
295
+ def deployments_start(obj, name) -> None:
296
+ """Start a deployment."""
297
+ _databricks(["apps", "start", name], obj.profile)
298
+ if obj.output == "json":
299
+ render.emit_json({"started": name})
300
+ return
301
+ render.success(f"Started deployment '{name}'")
302
+
303
+
304
+ @deployments.command("stop")
305
+ @click.argument("name")
306
+ @click.pass_obj
307
+ def deployments_stop(obj, name) -> None:
308
+ """Stop a deployment."""
309
+ _databricks(["apps", "stop", name], obj.profile)
310
+ if obj.output == "json":
311
+ render.emit_json({"stopped": name})
312
+ return
313
+ render.success(f"Stopped deployment '{name}'")
314
+
315
+
316
+ @deployments.command("delete")
317
+ @click.argument("name")
318
+ @click.pass_obj
319
+ def deployments_delete(obj, name) -> None:
320
+ """Delete a deployment."""
321
+ _databricks(["apps", "delete", name], obj.profile)
322
+ if obj.output == "json":
323
+ render.emit_json({"deleted": name})
324
+ return
325
+ render.success(f"Deleted deployment '{name}'")
@@ -0,0 +1,53 @@
1
+ """Error types for the Mason CLI and mapping from Databricks REST errors.
2
+
3
+ `AgentCliError` extends `click.ClickException` so a raised error prints as a clean
4
+ one-liner (plus an optional hint) and exits non-zero, instead of dumping a traceback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.text import Text
14
+
15
+ # Error codes indicating that a preview API is unavailable in the workspace.
16
+ _PREVIEW_ERROR_CODES = frozenset({"NOT_IMPLEMENTED", "UNIMPLEMENTED", "FEATURE_DISABLED"})
17
+
18
+ _PREVIEW_HINT = (
19
+ "These agents/v1 APIs are in preview and gated per workspace. This handler is "
20
+ "not enabled on the target workspace yet — try a different --profile or contact "
21
+ "your workspace administrator."
22
+ )
23
+
24
+
25
+ class AgentCliError(click.ClickException):
26
+ """A user-facing CLI error rendered without a Python traceback."""
27
+
28
+ def __init__(
29
+ self, message: str, *, error_code: Optional[str] = None, hint: Optional[str] = None
30
+ ):
31
+ super().__init__(message)
32
+ self.error_code = error_code
33
+ self.hint = hint
34
+
35
+ def show(self, file=None) -> None:
36
+ console = Console(stderr=True)
37
+ label = f"Error [{self.error_code}]" if self.error_code else "Error"
38
+ console.print(Text(f"{label}: ", style="bold red") + Text(self.message))
39
+ if self.hint:
40
+ console.print(Text(self.hint, style="grey62"))
41
+
42
+
43
+ def wrap_api_error(exc: Exception) -> AgentCliError:
44
+ """Convert a databricks-sdk error (or any exception) into an `AgentCliError`.
45
+
46
+ The SDK raises `databricks.sdk.errors.DatabricksError` subclasses carrying an
47
+ `error_code` attribute; we stay duck-typed so we don't couple to the SDK's error
48
+ hierarchy or version.
49
+ """
50
+ error_code = getattr(exc, "error_code", None)
51
+ message = str(exc).strip() or exc.__class__.__name__
52
+ hint = _PREVIEW_HINT if error_code in _PREVIEW_ERROR_CODES else None
53
+ return AgentCliError(message, error_code=error_code, hint=hint)