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.
- databricks_mason/__init__.py +1 -0
- databricks_mason/auth.py +83 -0
- databricks_mason/cli.py +72 -0
- databricks_mason/client.py +298 -0
- databricks_mason/deploy.py +325 -0
- databricks_mason/errors.py +53 -0
- databricks_mason/memory.py +347 -0
- databricks_mason/render.py +185 -0
- databricks_mason/sessions.py +442 -0
- databricks_mason/timefmt.py +85 -0
- databricks_mason/tracing.py +263 -0
- databricks_mason-0.1.0.dev0.dist-info/METADATA +80 -0
- databricks_mason-0.1.0.dev0.dist-info/RECORD +15 -0
- databricks_mason-0.1.0.dev0.dist-info/WHEEL +4 -0
- databricks_mason-0.1.0.dev0.dist-info/entry_points.txt +2 -0
|
@@ -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')}"
|