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,442 @@
1
+ """`mason sessions` — manage session stores, sessions, and session items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Optional
7
+
8
+ import click
9
+
10
+ from databricks_mason import render, timefmt
11
+ from databricks_mason.errors import AgentCliError
12
+ from databricks_mason.render import field
13
+
14
+ _BREADCRUMB = "Agent Session"
15
+
16
+
17
+ def _truncate(value: Any, length: int = 60) -> str:
18
+ text = "" if value is None else (value if isinstance(value, str) else json.dumps(value))
19
+ return text if len(text) <= length else text[: length - 1] + "…"
20
+
21
+
22
+ # --- group ------------------------------------------------------------------
23
+
24
+
25
+ @click.group()
26
+ def sessions() -> None:
27
+ """Manage agent session stores, sessions, and items (/api/agents/v1/session-stores)."""
28
+
29
+
30
+ @sessions.group()
31
+ def stores() -> None:
32
+ """Workspace-scoped session stores."""
33
+
34
+
35
+ @sessions.group()
36
+ def items() -> None:
37
+ """Transcript items within a session."""
38
+
39
+
40
+ # --- session stores ---------------------------------------------------------
41
+
42
+
43
+ def _render_store_detail(store: dict) -> None:
44
+ render.detail(
45
+ f"{_BREADCRUMB} Store",
46
+ field(store, "session_store_name") or "—",
47
+ {
48
+ "Name": field(store, "session_store_name"),
49
+ "Store ID": field(store, "session_store_id"),
50
+ "Creator": field(store, "creator_user_id"),
51
+ "Description": field(store, "description"),
52
+ "Created": timefmt.absolute(field(store, "create_time")),
53
+ "Updated": timefmt.absolute(field(store, "update_time")),
54
+ },
55
+ status="ACTIVE",
56
+ )
57
+
58
+
59
+ @stores.command("create")
60
+ @click.option("--name", "name", required=True, help="Workspace-unique store name (3-63 chars).")
61
+ @click.option("--description", default=None)
62
+ @click.option("--metadata", default=None, help="JSON object of string labels.")
63
+ @click.pass_obj
64
+ def stores_create(obj, name, description, metadata) -> None:
65
+ """Create a session store."""
66
+ data = obj.client().create_session_store(name, description, _parse_metadata(metadata))
67
+ if obj.output == "json":
68
+ render.emit_json(data)
69
+ return
70
+ render.success(
71
+ f"Created session store '{name}'",
72
+ fields={"Store ID": field(data, "session_store_id")},
73
+ next_steps=[
74
+ f"mason sessions create --store {name} --actor-id <id>",
75
+ f"mason sessions stores get {name}",
76
+ ],
77
+ )
78
+
79
+
80
+ @stores.command("list")
81
+ @click.option("--page-size", type=int, default=None)
82
+ @click.option("--page-token", default=None)
83
+ @click.pass_obj
84
+ def stores_list(obj, page_size, page_token) -> None:
85
+ """List session stores in the workspace."""
86
+ data = obj.client().list_session_stores(page_size, page_token)
87
+ if obj.output == "json":
88
+ render.emit_json(data)
89
+ return
90
+ items_ = field(data, "session_stores") or []
91
+ rows = [
92
+ [
93
+ field(s, "session_store_name"),
94
+ field(s, "creator_user_id"),
95
+ timefmt.relative(field(s, "create_time")),
96
+ timefmt.relative(field(s, "update_time")),
97
+ _truncate(field(s, "description"), 40),
98
+ ]
99
+ for s in items_
100
+ ]
101
+ render.resource_table(
102
+ "Session Stores",
103
+ [
104
+ ("Name", "left"),
105
+ ("Creator", "left"),
106
+ ("Created", "left"),
107
+ ("Updated", "left"),
108
+ ("Description", "left"),
109
+ ],
110
+ rows,
111
+ subtitle=_page_note(data),
112
+ )
113
+
114
+
115
+ @stores.command("get")
116
+ @click.argument("name")
117
+ @click.pass_obj
118
+ def stores_get(obj, name) -> None:
119
+ """Get a session store by name."""
120
+ data = obj.client().get_session_store(name)
121
+ if obj.output == "json":
122
+ render.emit_json(data)
123
+ return
124
+ _render_store_detail(data)
125
+
126
+
127
+ @stores.command("update")
128
+ @click.argument("name")
129
+ @click.option("--description", default=None)
130
+ @click.option("--metadata", default=None, help="JSON object of string labels.")
131
+ @click.pass_obj
132
+ def stores_update(obj, name, description, metadata) -> None:
133
+ """Update a store's description and/or metadata."""
134
+ data = obj.client().update_session_store(name, description, _parse_metadata(metadata))
135
+ if obj.output == "json":
136
+ render.emit_json(data)
137
+ return
138
+ _render_store_detail(data)
139
+
140
+
141
+ @stores.command("delete")
142
+ @click.argument("name")
143
+ @click.pass_obj
144
+ def stores_delete(obj, name) -> None:
145
+ """Delete a session store."""
146
+ obj.client().delete_session_store(name)
147
+ if obj.output == "json":
148
+ render.emit_json({"deleted": name})
149
+ return
150
+ render.success(f"Deleted session store '{name}'")
151
+
152
+
153
+ # --- sessions ---------------------------------------------------------------
154
+
155
+
156
+ def _session_starter_code(obj, store: str, session_id: str) -> list[tuple[str, str, str]]:
157
+ return [
158
+ (
159
+ "curl",
160
+ "bash",
161
+ f"""
162
+ curl -X POST "{obj.client().host}/api/agents/v1/session-stores/{store}/sessions/{session_id}/items:append" \\
163
+ -H "Authorization: Bearer $DATABRICKS_TOKEN" -H "Content-Type: application/json" \\
164
+ -d '{{"items": [{{"data": {{"role": "user", "content": "Hello"}}}}]}}'
165
+ """,
166
+ ),
167
+ (
168
+ "mason",
169
+ "bash",
170
+ f"""
171
+ mason sessions items append --store {store} --session-id {session_id} \\
172
+ --data '{{"role": "user", "content": "Hello"}}'
173
+ mason sessions items list --store {store} --session-id {session_id}
174
+ """,
175
+ ),
176
+ ]
177
+
178
+
179
+ def _render_session_detail(obj, session: dict, store: Optional[str]) -> None:
180
+ store = store or field(session, "session_store_name")
181
+ session_id = field(session, "session_id")
182
+ render.detail(
183
+ _BREADCRUMB,
184
+ session_id or "—",
185
+ {
186
+ "Session ID": session_id,
187
+ "Store": field(session, "session_store_name") or store,
188
+ "Actor": field(session, "actor_id"),
189
+ "Parent": field(session, "parent_session_id"),
190
+ "Root": field(session, "root_session_id"),
191
+ "Created": timefmt.absolute(field(session, "create_time")),
192
+ "Last activity": timefmt.absolute(field(session, "last_activity_time")),
193
+ },
194
+ status="ACTIVE",
195
+ snippets=_session_starter_code(obj, store, session_id) if store and session_id else None,
196
+ )
197
+
198
+
199
+ @sessions.command("create")
200
+ @click.option("--store", required=True)
201
+ @click.option("--actor-id", required=True, help="Application actor id (child must match parent).")
202
+ @click.option("--session-id", default=None, help="Optional caller-chosen id.")
203
+ @click.option("--parent-session-id", default=None)
204
+ @click.option("--metadata", default=None, help="JSON object of string labels.")
205
+ @click.pass_obj
206
+ def sessions_create(obj, store, actor_id, session_id, parent_session_id, metadata) -> None:
207
+ """Create a session in a store."""
208
+ data = obj.client().create_session(
209
+ store, actor_id, session_id, parent_session_id, _parse_metadata(metadata)
210
+ )
211
+ if obj.output == "json":
212
+ render.emit_json(data)
213
+ return
214
+ render.success(
215
+ f"Created session '{field(data, 'session_id')}'",
216
+ fields={"Actor": actor_id, "Store": store},
217
+ next_steps=[
218
+ f"mason sessions items append --store {store} "
219
+ f"--session-id {field(data, 'session_id')} --data '{{...}}'",
220
+ ],
221
+ )
222
+
223
+
224
+ @sessions.command("list")
225
+ @click.option("--store", required=True)
226
+ @click.option("--filter", "filter_", default=None, help='e.g. actor_id = "support-123".')
227
+ @click.option("--order-by", default=None, help="e.g. 'last_activity_time desc'.")
228
+ @click.option("--page-size", type=int, default=None)
229
+ @click.option("--page-token", default=None)
230
+ @click.pass_obj
231
+ def sessions_list(obj, store, filter_, order_by, page_size, page_token) -> None:
232
+ """List sessions in a store."""
233
+ data = obj.client().list_sessions(store, filter_, order_by, page_size, page_token)
234
+ if obj.output == "json":
235
+ render.emit_json(data)
236
+ return
237
+ items_ = field(data, "sessions") or []
238
+ rows = [
239
+ [
240
+ field(s, "session_id"),
241
+ field(s, "actor_id"),
242
+ field(s, "root_session_id"),
243
+ timefmt.relative(field(s, "create_time")),
244
+ timefmt.relative(field(s, "last_activity_time")),
245
+ ]
246
+ for s in items_
247
+ ]
248
+ render.resource_table(
249
+ f"Sessions · {store}",
250
+ [
251
+ ("Session ID", "left"),
252
+ ("Actor", "left"),
253
+ ("Root", "left"),
254
+ ("Created", "left"),
255
+ ("Last Activity", "left"),
256
+ ],
257
+ rows,
258
+ subtitle=_page_note(data),
259
+ )
260
+
261
+
262
+ @sessions.command("get")
263
+ @click.argument("session_id")
264
+ @click.option("--store", default=None, help="Store name; omit to resolve by session id.")
265
+ @click.pass_obj
266
+ def sessions_get(obj, session_id, store) -> None:
267
+ """Get a session by id."""
268
+ data = obj.client().get_session(session_id, store)
269
+ if obj.output == "json":
270
+ render.emit_json(data)
271
+ return
272
+ _render_session_detail(obj, data, store)
273
+
274
+
275
+ @sessions.command("update")
276
+ @click.argument("session_id")
277
+ @click.option("--store", required=True)
278
+ @click.option(
279
+ "--metadata", required=True, help="JSON object of string labels (only mutable field)."
280
+ )
281
+ @click.pass_obj
282
+ def sessions_update(obj, session_id, store, metadata) -> None:
283
+ """Update a session's metadata."""
284
+ data = obj.client().update_session(store, session_id, _parse_metadata(metadata) or {})
285
+ if obj.output == "json":
286
+ render.emit_json(data)
287
+ return
288
+ _render_session_detail(obj, data, store)
289
+
290
+
291
+ @sessions.command("delete")
292
+ @click.argument("session_id")
293
+ @click.option("--store", required=True)
294
+ @click.option("--force", is_flag=True, help="Cascade-delete descendant sessions.")
295
+ @click.pass_obj
296
+ def sessions_delete(obj, session_id, store, force) -> None:
297
+ """Delete a session."""
298
+ obj.client().delete_session(store, session_id, force)
299
+ if obj.output == "json":
300
+ render.emit_json({"deleted": session_id})
301
+ return
302
+ render.success(f"Deleted session '{session_id}'")
303
+
304
+
305
+ @sessions.command("fork")
306
+ @click.option("--store", required=True)
307
+ @click.option("--source-session-id", required=True)
308
+ @click.option("--actor-id", required=True)
309
+ @click.option("--up-to-item-id", default=None, help="Copy through this item id inclusively.")
310
+ @click.option("--session-id", default=None, help="Optional id for the fork.")
311
+ @click.option("--metadata", default=None)
312
+ @click.pass_obj
313
+ def sessions_fork(
314
+ obj, store, source_session_id, actor_id, up_to_item_id, session_id, metadata
315
+ ) -> None:
316
+ """Fork a session into a new independent top-level session."""
317
+ data = obj.client().fork_session(
318
+ store, source_session_id, actor_id, up_to_item_id, session_id, _parse_metadata(metadata)
319
+ )
320
+ if obj.output == "json":
321
+ render.emit_json(data)
322
+ return
323
+ _render_session_detail(obj, field(data, "session") or {}, store)
324
+
325
+
326
+ # --- session items ----------------------------------------------------------
327
+
328
+
329
+ @items.command("list")
330
+ @click.option("--store", required=True)
331
+ @click.option("--session-id", required=True)
332
+ @click.option("--order-by", default=None, help="'create_time asc' or 'create_time desc'.")
333
+ @click.option("--page-size", type=int, default=None)
334
+ @click.option("--page-token", default=None)
335
+ @click.pass_obj
336
+ def items_list(obj, store, session_id, order_by, page_size, page_token) -> None:
337
+ """List transcript items in a session."""
338
+ data = obj.client().list_session_items(store, session_id, order_by, page_size, page_token)
339
+ if obj.output == "json":
340
+ render.emit_json(data)
341
+ return
342
+ items_ = field(data, "session_items") or []
343
+ rows = [
344
+ [
345
+ field(it, "item_id"),
346
+ timefmt.relative(field(it, "create_time")),
347
+ _truncate(field(it, "data"), 70),
348
+ ]
349
+ for it in items_
350
+ ]
351
+ render.resource_table(
352
+ f"Session Items · {session_id}",
353
+ [("Item ID", "left"), ("Created", "left"), ("Data", "left")],
354
+ rows,
355
+ subtitle=_page_note(data),
356
+ )
357
+
358
+
359
+ @items.command("append")
360
+ @click.option("--store", required=True)
361
+ @click.option("--session-id", required=True)
362
+ @click.option("--data", "data_", multiple=True, help="One item's JSON data (repeatable).")
363
+ @click.option(
364
+ "--file", "file_", type=click.File("r"), default=None, help="JSON array of item data values."
365
+ )
366
+ @click.pass_obj
367
+ def items_append(obj, store, session_id, data_, file_) -> None:
368
+ """Append one or more items to a session (atomic, in order)."""
369
+ payload = _load_items(data_, file_)
370
+ result = obj.client().append_session_items(store, session_id, payload)
371
+ if obj.output == "json":
372
+ render.emit_json(result)
373
+ return
374
+ appended = field(result, "session_items") or []
375
+ render.success(f"Appended {len(appended)} item(s) to session '{session_id}'")
376
+
377
+
378
+ @items.command("pop")
379
+ @click.option("--store", required=True)
380
+ @click.option("--session-id", required=True)
381
+ @click.pass_obj
382
+ def items_pop(obj, store, session_id) -> None:
383
+ """Remove and return the most recent item."""
384
+ data = obj.client().pop_session_item(store, session_id)
385
+ if obj.output == "json":
386
+ render.emit_json(data)
387
+ return
388
+ item = field(data, "item")
389
+ render.success("Popped last item" if item else "Session was already empty")
390
+
391
+
392
+ @items.command("clear")
393
+ @click.option("--store", required=True)
394
+ @click.option("--session-id", required=True)
395
+ @click.pass_obj
396
+ def items_clear(obj, store, session_id) -> None:
397
+ """Remove all items from a session."""
398
+ obj.client().clear_session_items(store, session_id)
399
+ if obj.output == "json":
400
+ render.emit_json({"cleared": session_id})
401
+ return
402
+ render.success(f"Cleared items from session '{session_id}'")
403
+
404
+
405
+ # --- shared helpers ---------------------------------------------------------
406
+
407
+
408
+ def _parse_metadata(value: Optional[str]) -> Optional[dict]:
409
+ if value is None:
410
+ return None
411
+ try:
412
+ parsed = json.loads(value)
413
+ except json.JSONDecodeError as exc:
414
+ raise AgentCliError(f"--metadata must be valid JSON: {exc}") from exc
415
+ if not isinstance(parsed, dict):
416
+ raise AgentCliError("--metadata must be a JSON object of string labels.")
417
+ return parsed
418
+
419
+
420
+ def _load_items(data_: tuple[str, ...], file_) -> list[Any]:
421
+ payload: list[Any] = []
422
+ if file_ is not None:
423
+ try:
424
+ loaded = json.load(file_)
425
+ except json.JSONDecodeError as exc:
426
+ raise AgentCliError(f"--file must contain a JSON array: {exc}") from exc
427
+ if not isinstance(loaded, list):
428
+ raise AgentCliError("--file must contain a JSON array of item data values.")
429
+ payload.extend(loaded)
430
+ for raw in data_:
431
+ try:
432
+ payload.append(json.loads(raw))
433
+ except json.JSONDecodeError as exc:
434
+ raise AgentCliError(f"--data must be valid JSON: {exc}") from exc
435
+ if not payload:
436
+ raise AgentCliError("Provide at least one --data item or a --file.")
437
+ return payload
438
+
439
+
440
+ def _page_note(data: dict) -> str | None:
441
+ token = field(data, "next_page_token")
442
+ return f"More results available — pass --page-token {token}" if token else None
@@ -0,0 +1,85 @@
1
+ """Timestamp parsing and humanization for the Mason CLI.
2
+
3
+ The agents/v1 APIs return timestamps two ways: memory *stores* use epoch-millis
4
+ int64 (`created_at`/`updated_at`), while entries, sessions, and session items use
5
+ `google.protobuf.Timestamp`, which serializes to an RFC 3339 string
6
+ (`2026-08-15T01:29:00Z`). `parse_timestamp` accepts either.
7
+
8
+ `relative` returns concise phrases such as "13 days ago" and "An hour ago".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime, timezone
14
+ from typing import Optional, Union
15
+
16
+ TimestampValue = Union[int, float, str, datetime, None]
17
+
18
+
19
+ def parse_timestamp(value: TimestampValue) -> Optional[datetime]:
20
+ """Parse a datetime, an epoch-millis number, a numeric string, or an RFC 3339 string.
21
+
22
+ Returns a timezone-aware UTC datetime, or None if the value is empty/unparseable.
23
+ """
24
+ if value is None or value == "":
25
+ return None
26
+ if isinstance(value, datetime):
27
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
28
+ if isinstance(value, (int, float)):
29
+ return datetime.fromtimestamp(value / 1000.0, tz=timezone.utc)
30
+ if isinstance(value, str):
31
+ if value.isdigit():
32
+ return datetime.fromtimestamp(int(value) / 1000.0, tz=timezone.utc)
33
+ iso = value.replace("Z", "+00:00")
34
+ try:
35
+ dt = datetime.fromisoformat(iso)
36
+ except ValueError:
37
+ return None
38
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
39
+ return None
40
+
41
+
42
+ def relative(value: TimestampValue, *, now: Optional[datetime] = None) -> str:
43
+ """Humanize a timestamp as an "N units ago" string."""
44
+ dt = parse_timestamp(value)
45
+ if dt is None:
46
+ return "—"
47
+ now = now or datetime.now(tz=timezone.utc)
48
+ seconds = (now - dt).total_seconds()
49
+ if seconds < 0:
50
+ return "just now"
51
+
52
+ minutes = seconds / 60
53
+ hours = minutes / 60
54
+ days = hours / 24
55
+
56
+ if seconds < 45:
57
+ return "just now"
58
+ if minutes < 2:
59
+ return "A minute ago"
60
+ if minutes < 60:
61
+ return f"{round(minutes)} minutes ago"
62
+ if hours < 2:
63
+ return "An hour ago"
64
+ if hours < 24:
65
+ return f"{round(hours)} hours ago"
66
+ if days < 2:
67
+ return "A day ago"
68
+ if days < 30:
69
+ return f"{round(days)} days ago"
70
+ if days < 60:
71
+ return "A month ago"
72
+ if days < 365:
73
+ return f"{round(days / 30)} months ago"
74
+ if days < 730:
75
+ return "A year ago"
76
+ return f"{round(days / 365)} years ago"
77
+
78
+
79
+ def absolute(value: TimestampValue) -> str:
80
+ """Format a timestamp like the mock's detail rail: "May 19, 2026, 05:00 PM"."""
81
+ dt = parse_timestamp(value)
82
+ if dt is None:
83
+ return "—"
84
+ local = dt.astimezone()
85
+ return f"{local.strftime('%b')} {local.day}, {local.strftime('%Y, %I:%M %p')}"