3lc-compute-plugin-sdk 0.2.2__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,365 @@
1
+ # Copyright 2026 3LC Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ r"""Shared UI component for data-source inputs (files and folders on the compute node).
4
+
5
+ Generates the HTML + JS block that plugins embed in their UI fragments.
6
+ This ensures a consistent file/folder selection experience across every plugin
7
+ that needs to reference data on the compute node — local paths, S3 URLs, or
8
+ browser-uploaded files all resolve to a compute-visible location.
9
+
10
+ Usage in a plugin's ``get_ui_fragment()``::
11
+
12
+ from tlc_plugin_sdk.shared.data_source_ui import data_source_ui_script
13
+ from tlc_plugin_sdk.shared.ui_inject import inject_scripts
14
+
15
+ raw = Path("ui.html").read_text()
16
+ html = inject_scripts(raw, data_source_ui_script())
17
+
18
+ Then in the plugin's ``ui.html`` JavaScript:
19
+
20
+ .. code-block:: javascript
21
+
22
+ // Render a data-source input
23
+ var html = _tlcDataSourceHtml('my-field', {
24
+ placeholder: 'Path to dataset YAML...',
25
+ accept: '*.yaml,*.yml', // glob filter for the browser
26
+ allowUpload: false, // show upload toggle?
27
+ mode: 'file', // 'file' or 'folder'
28
+ });
29
+ container.innerHTML = html;
30
+
31
+ // Bind the browse/upload handlers (call AFTER inserting into DOM)
32
+ _tlcBindDataSource('my-field', computeUrl, pluginId);
33
+
34
+ // Read the value at submit time
35
+ var path = _tlcGetDataSourceValue('my-field');
36
+
37
+ The widget requires the plugin to serve the ``/browse`` and (optionally)
38
+ ``/upload-temp`` routes from :mod:`~tlc_plugin_sdk.shared.data_source_routes`.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ # fmt: off
44
+ DATA_SOURCE_UI_JS = (
45
+ "// -- Shared Data Source UI ------\n"
46
+ "(function(){\n"
47
+ "var st=document.createElement('style');st.textContent=\n"
48
+ "'.tlc-ds-wrapper{position:relative}'"
49
+ "+'.tlc-ds-browse-panel{position:absolute;left:0;right:0;z-index:100;'"
50
+ "+' border:1px solid var(--border);border-radius:6px;'"
51
+ "+' margin-top:4px;max-height:320px;overflow-y:auto;background:var(--bg-card);'"
52
+ "+' font-size:12px;display:none;'"
53
+ "+' box-shadow:0 4px 16px rgba(0,0,0,.12)}'"
54
+ "+'.tlc-ds-browse-panel.open{display:block}'"
55
+ "+'.tlc-ds-breadcrumb{display:flex;align-items:center;gap:2px;padding:8px 10px;'"
56
+ "+' border-bottom:1px solid var(--border);font-size:11px;flex-wrap:wrap;'"
57
+ "+' background:var(--bg-secondary,rgba(0,0,0,.02))}'"
58
+ "+'.tlc-ds-breadcrumb span{cursor:pointer;color:var(--accent);font-weight:500}'"
59
+ "+'.tlc-ds-breadcrumb span:hover{text-decoration:underline}'"
60
+ "+'.tlc-ds-breadcrumb .sep{color:var(--text-muted);cursor:default;margin:0 1px}'"
61
+ "+'.tlc-ds-breadcrumb .sep:hover{text-decoration:none}'"
62
+ "+'.tlc-ds-entry{display:flex;align-items:center;gap:8px;padding:5px 10px;'"
63
+ "+' cursor:pointer;border-bottom:1px solid var(--border-light,rgba(0,0,0,.04))}'"
64
+ "+'.tlc-ds-entry:hover{background:var(--golden-light,rgba(217,119,6,.06))}'"
65
+ "+'.tlc-ds-entry:last-child{border-bottom:none}'"
66
+ "+'.tlc-ds-entry .ds-icon{flex-shrink:0;width:16px;text-align:center;font-size:13px}'"
67
+ "+'.tlc-ds-entry .ds-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'"
68
+ "+'.tlc-ds-entry .ds-size{font-size:10px;color:var(--text-muted);flex-shrink:0}'"
69
+ # Scoped to the panel (not .tlc-ds-entry): the folder-mode select button renders in
70
+ # the BREADCRUMB — entry-scoped rules left it a naked native <button>.
71
+ "+'.tlc-ds-browse-panel .ds-select-btn{font-size:10px;padding:2px 8px;border:1px solid var(--border);'"
72
+ "+' border-radius:3px;background:var(--bg-card);cursor:pointer;color:var(--accent);font-weight:600;flex-shrink:0}'"
73
+ "+'.tlc-ds-browse-panel .ds-select-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}'"
74
+ "+'.tlc-ds-empty{padding:16px;text-align:center;color:var(--text-muted);font-size:11px}'"
75
+ "+'.tlc-ds-error{padding:10px;color:var(--error,#ef4444);font-size:11px}'"
76
+ "+'.tlc-ds-upload-zone{position:absolute;left:0;right:0;z-index:100;'"
77
+ "+' border:2px dashed var(--border);border-radius:6px;padding:20px;'"
78
+ "+' text-align:center;cursor:pointer;transition:border-color .15s,background .15s;'"
79
+ "+' margin-top:4px;display:none;font-size:12px;background:var(--bg-card);'"
80
+ "+' box-shadow:0 4px 16px rgba(0,0,0,.12)}'"
81
+ "+'.tlc-ds-upload-zone.open{display:block}'"
82
+ "+'.tlc-ds-upload-zone:hover{border-color:var(--accent);background:var(--accent-light,rgba(42,74,97,.04))}'"
83
+ "+'.tlc-ds-upload-zone.dragging{border-color:var(--accent);background:var(--accent-light,rgba(42,74,97,.08))}'"
84
+ "+'.tlc-ds-upload-info{font-size:11px;color:var(--text-muted);margin-top:4px;display:none}'"
85
+ "+'.tlc-ds-actions{display:flex;gap:6px;align-items:center}';\n"
86
+ "document.head.appendChild(st)})();\n"
87
+ "\n"
88
+ "function _tlcDataSourceHtml(idPrefix, config) {\n"
89
+ " config = config || {};\n"
90
+ " var ph = config.placeholder || 'Path on compute node or URL…';\n"
91
+ " var allowUpload = config.allowUpload || false;\n"
92
+ " var accept = config.accept || '';\n"
93
+ " var h = '';\n"
94
+ " h += '<div class=\"tlc-ds-wrapper\">';\n"
95
+ " h += '<div class=\"tlc-ds-actions\">';\n"
96
+ " h += '<input type=\"text\" id=\"' + idPrefix + '\" class=\"form-control\"';\n"
97
+ " h += ' placeholder=\"' + ph + '\" style=\"flex:1\">';\n"
98
+ " h += '<button type=\"button\" class=\"btn btn-secondary btn-sm\" id=\"' + idPrefix + '-ds-browse-btn\"';\n"
99
+ " h += ' style=\"white-space:nowrap\">Browse…</button>';\n"
100
+ " if (allowUpload) {\n"
101
+ " h += '<button type=\"button\" class=\"btn btn-secondary btn-sm\" id=\"' + idPrefix + '-ds-upload-btn\"';\n"
102
+ " h += ' style=\"white-space:nowrap\">Upload</button>';\n"
103
+ " }\n"
104
+ " h += '</div>';\n"
105
+ " // Browse panel (inline, hidden by default)\n"
106
+ " h += '<div class=\"tlc-ds-browse-panel\" id=\"' + idPrefix + '-ds-panel\"></div>';\n"
107
+ " // Upload zone (hidden by default)\n"
108
+ " if (allowUpload) {\n"
109
+ " var acceptAttr = accept ? accept.split(',').map(function(g){"
110
+ " return g.trim().replace('*',''); }).join(',') : '';\n"
111
+ " h += '<div class=\"tlc-ds-upload-zone\" id=\"' + idPrefix + '-ds-upload-zone\">';\n"
112
+ " h += '<div style=\"font-weight:600;margin-bottom:4px\">Drop file here</div>';\n"
113
+ " h += '<div style=\"color:var(--text-muted)\">or click to browse';\n"
114
+ " if (accept) h += ' — ' + accept;\n"
115
+ " h += '</div>';\n"
116
+ " h += '<input type=\"file\" id=\"' + idPrefix + '-ds-file-input\"';\n"
117
+ " if (acceptAttr) h += ' accept=\"' + acceptAttr + '\"';\n"
118
+ " h += ' style=\"display:none\">';\n"
119
+ " h += '</div>';\n"
120
+ " h += '<div class=\"tlc-ds-upload-info\" id=\"' + idPrefix + '-ds-upload-info\"></div>';\n"
121
+ " }\n"
122
+ " h += '</div>'; // close .tlc-ds-wrapper\n"
123
+ " return h;\n"
124
+ "}\n"
125
+ "\n"
126
+ "function _tlcBindDataSource(idPrefix, computeUrl, pluginId, config) {\n"
127
+ " config = config || {};\n"
128
+ " var glob = config.accept || '';\n"
129
+ " var mode = config.mode || 'file';\n"
130
+ " var input = document.getElementById(idPrefix);\n"
131
+ " var browseBtn = document.getElementById(idPrefix + '-ds-browse-btn');\n"
132
+ " var panel = document.getElementById(idPrefix + '-ds-panel');\n"
133
+ " var uploadBtn = document.getElementById(idPrefix + '-ds-upload-btn');\n"
134
+ " var uploadZone = document.getElementById(idPrefix + '-ds-upload-zone');\n"
135
+ " var API = window.PLUGIN_API;\n"
136
+ "\n"
137
+ " // Format file sizes\n"
138
+ " function _fmtSize(bytes) {\n"
139
+ " if (bytes == null) return '';\n"
140
+ " if (bytes < 1024) return bytes + ' B';\n"
141
+ " if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';\n"
142
+ " return (bytes / 1048576).toFixed(1) + ' MB';\n"
143
+ " }\n"
144
+ "\n"
145
+ " function _esc(s) {\n"
146
+ " if (!s) return '';\n"
147
+ " var d = document.createElement('div');\n"
148
+ " d.textContent = s;\n"
149
+ " return d.innerHTML;\n"
150
+ " }\n"
151
+ "\n"
152
+ " // Browse panel\n"
153
+ " var _panelOpen = false;\n"
154
+ " browseBtn.addEventListener('click', function() {\n"
155
+ " _panelOpen = !_panelOpen;\n"
156
+ " panel.classList.toggle('open', _panelOpen);\n"
157
+ " if (uploadZone) { uploadZone.classList.remove('open'); }\n"
158
+ " if (_panelOpen) {\n"
159
+ " var startPath = input.value.trim() || '~';\n"
160
+ " // If the current value is a file path, browse its parent directory\n"
161
+ " if (startPath !== '~' && startPath.indexOf('://') === -1) {\n"
162
+ " var lastSlash = startPath.lastIndexOf('/');\n"
163
+ " if (lastSlash > 0) startPath = startPath.substring(0, lastSlash);\n"
164
+ " }\n"
165
+ " _browseTo(startPath);\n"
166
+ " }\n"
167
+ " });\n"
168
+ "\n"
169
+ " function _browseTo(dirPath) {\n"
170
+ " panel.innerHTML = '<div class=\"tlc-ds-empty\">Loading…</div>';\n"
171
+ " var url = computeUrl + '/api/plugins/' + pluginId + '/browse?path=' + encodeURIComponent(dirPath);\n"
172
+ " if (glob) url += '&glob=' + encodeURIComponent(glob);\n"
173
+ " API.authFetch(url)\n"
174
+ " .then(function(r) { return r.json(); })\n"
175
+ " .then(function(data) {\n"
176
+ " if (data.error) {\n"
177
+ " panel.innerHTML = '<div class=\"tlc-ds-error\">' + _esc(data.error) + '</div>';\n"
178
+ " return;\n"
179
+ " }\n"
180
+ " _renderBrowse(data);\n"
181
+ " })\n"
182
+ " .catch(function(err) {\n"
183
+ " panel.innerHTML = '<div class=\"tlc-ds-error\">Failed to browse: ' + _esc(String(err)) + '</div>';\n"
184
+ " });\n"
185
+ " }\n"
186
+ "\n"
187
+ " function _renderBrowse(data) {\n"
188
+ " var h = '';\n"
189
+ " // Breadcrumb — anchored at the confinement root: segments above it are not\n"
190
+ " // places this picker can go, so they aren't rendered at all. The root shows\n"
191
+ " // as its basename; only the path below it gets '/'-separated segments.\n"
192
+ " var rootPath = data.root || '/';\n"
193
+ " h += '<div class=\"tlc-ds-breadcrumb\">';\n"
194
+ " var rel, base;\n"
195
+ " if (rootPath === '/') {\n"
196
+ " h += '<span data-ds-nav=\"/\">/</span>';\n"
197
+ " rel = data.path.split('/').filter(Boolean);\n"
198
+ " base = '';\n"
199
+ " } else {\n"
200
+ " var rootLabel = rootPath.split('/').filter(Boolean).pop() || '/';\n"
201
+ " h += '<span data-ds-nav=\"' + _esc(rootPath) + '\">' + _esc(rootLabel) + '</span>';\n"
202
+ " rel = data.path.slice(rootPath.length).split('/').filter(Boolean);\n"
203
+ " base = rootPath;\n"
204
+ " }\n"
205
+ " for (var i = 0; i < rel.length; i++) {\n"
206
+ " h += '<span class=\"sep\">/</span>';\n"
207
+ " base = base + '/' + rel[i];\n"
208
+ " h += '<span data-ds-nav=\"' + _esc(base) + '\">' + _esc(rel[i]) + '</span>';\n"
209
+ " }\n"
210
+ " // Select-folder button (for mode='folder')\n"
211
+ " if (mode === 'folder') {\n"
212
+ " h += '<button type=\"button\" class=\"ds-select-btn\" data-ds-select-folder=\"' + _esc(data.path) + '\"'"
213
+ " + ' style=\"margin-left:auto\">Select This Folder</button>';\n"
214
+ " }\n"
215
+ " h += '</div>';\n"
216
+ " // Entries\n"
217
+ " if (data.parent != null) {\n"
218
+ " h += '<div class=\"tlc-ds-entry\" data-ds-nav=\"' + _esc(data.parent) + '\">';\n"
219
+ " h += '<span class=\"ds-icon\">↑</span>';\n"
220
+ " h += '<span class=\"ds-name\" style=\"color:var(--text-muted)\">..</span>';\n"
221
+ " h += '</div>';\n"
222
+ " }\n"
223
+ " if (!data.entries || data.entries.length === 0) {\n"
224
+ " h += '<div class=\"tlc-ds-empty\">Empty directory</div>';\n"
225
+ " } else {\n"
226
+ " data.entries.forEach(function(e) {\n"
227
+ " h += '<div class=\"tlc-ds-entry\"';\n"
228
+ " if (e.type === 'dir') {\n"
229
+ " h += ' data-ds-nav=\"' + _esc(data.path.replace(/\\/$/, '') + '/' + e.name) + '\"';\n"
230
+ " }\n"
231
+ " h += '>';\n"
232
+ " h += '<span class=\"ds-icon\">' + (e.type === 'dir' ? '📁' : '📄') + '</span>';\n"
233
+ " h += '<span class=\"ds-name\">' + _esc(e.name) + (e.type === 'dir' ? '/' : '') + '</span>';\n"
234
+ " if (e.size != null) h += '<span class=\"ds-size\">' + _fmtSize(e.size) + '</span>';\n"
235
+ " if (e.type === 'file') {\n"
236
+ " h += '<button type=\"button\" class=\"ds-select-btn\" data-ds-select=\"'"
237
+ " + _esc(data.path.replace(/\\/$/, '') + '/' + e.name) + '\">Select</button>';\n"
238
+ " }\n"
239
+ " h += '</div>';\n"
240
+ " });\n"
241
+ " }\n"
242
+ " panel.innerHTML = h;\n"
243
+ "\n"
244
+ " // Bind navigation clicks\n"
245
+ " panel.querySelectorAll('[data-ds-nav]').forEach(function(el) {\n"
246
+ " el.addEventListener('click', function(ev) {\n"
247
+ " ev.stopPropagation();\n"
248
+ " _browseTo(el.getAttribute('data-ds-nav'));\n"
249
+ " });\n"
250
+ " });\n"
251
+ " // Bind select buttons\n"
252
+ " panel.querySelectorAll('[data-ds-select]').forEach(function(el) {\n"
253
+ " el.addEventListener('click', function(ev) {\n"
254
+ " ev.stopPropagation();\n"
255
+ " input.value = el.getAttribute('data-ds-select');\n"
256
+ " _panelOpen = false;\n"
257
+ " panel.classList.remove('open');\n"
258
+ " input.dispatchEvent(new Event('input', {bubbles: true}));\n"
259
+ " input.dispatchEvent(new Event('change', {bubbles: true}));\n"
260
+ " });\n"
261
+ " });\n"
262
+ " // Bind select-folder button\n"
263
+ " panel.querySelectorAll('[data-ds-select-folder]').forEach(function(el) {\n"
264
+ " el.addEventListener('click', function(ev) {\n"
265
+ " ev.stopPropagation();\n"
266
+ " input.value = el.getAttribute('data-ds-select-folder');\n"
267
+ " _panelOpen = false;\n"
268
+ " panel.classList.remove('open');\n"
269
+ " input.dispatchEvent(new Event('input', {bubbles: true}));\n"
270
+ " input.dispatchEvent(new Event('change', {bubbles: true}));\n"
271
+ " });\n"
272
+ " });\n"
273
+ " }\n"
274
+ "\n"
275
+ " // Upload zone\n"
276
+ " if (uploadBtn && uploadZone) {\n"
277
+ " var fileInput = document.getElementById(idPrefix + '-ds-file-input');\n"
278
+ " var uploadInfo = document.getElementById(idPrefix + '-ds-upload-info');\n"
279
+ "\n"
280
+ " uploadBtn.addEventListener('click', function() {\n"
281
+ " var isOpen = uploadZone.classList.toggle('open');\n"
282
+ " if (isOpen) { panel.classList.remove('open'); _panelOpen = false; }\n"
283
+ " });\n"
284
+ "\n"
285
+ " uploadZone.addEventListener('click', function() { fileInput.click(); });\n"
286
+ " uploadZone.addEventListener('dragover', function(ev) {\n"
287
+ " ev.preventDefault(); uploadZone.classList.add('dragging');\n"
288
+ " });\n"
289
+ " uploadZone.addEventListener('dragleave', function() {\n"
290
+ " uploadZone.classList.remove('dragging');\n"
291
+ " });\n"
292
+ " uploadZone.addEventListener('drop', function(ev) {\n"
293
+ " ev.preventDefault(); uploadZone.classList.remove('dragging');\n"
294
+ " if (ev.dataTransfer.files.length > 0) _uploadFile(ev.dataTransfer.files[0]);\n"
295
+ " });\n"
296
+ " fileInput.addEventListener('change', function() {\n"
297
+ " if (fileInput.files.length > 0) _uploadFile(fileInput.files[0]);\n"
298
+ " });\n"
299
+ "\n"
300
+ " function _uploadFile(file) {\n"
301
+ " uploadInfo.style.display = '';\n"
302
+ " uploadInfo.textContent = 'Uploading ' + file.name + '…';\n"
303
+ " uploadZone.style.opacity = '0.6';\n"
304
+ " uploadZone.style.pointerEvents = 'none';\n"
305
+ "\n"
306
+ " var formData = new FormData();\n"
307
+ " formData.append('data', file);\n"
308
+ "\n"
309
+ " API.authFetch(computeUrl + '/api/plugins/' + pluginId + '/upload-temp', {\n"
310
+ " method: 'POST',\n"
311
+ " body: formData,\n"
312
+ " })\n"
313
+ " .then(function(r) { return r.json(); })\n"
314
+ " .then(function(result) {\n"
315
+ " input.value = result.path;\n"
316
+ " uploadInfo.innerHTML = '<span style=\"color:var(--success)\">✓</span> '"
317
+ " + _esc(file.name) + ' <span style=\"color:var(--text-muted)\">→ '"
318
+ " + _esc(result.path) + '</span>';\n"
319
+ " uploadZone.classList.remove('open');\n"
320
+ " input.dispatchEvent(new Event('input', {bubbles: true}));\n"
321
+ " input.dispatchEvent(new Event('change', {bubbles: true}));\n"
322
+ " })\n"
323
+ " .catch(function(err) {\n"
324
+ " uploadInfo.innerHTML = '<span style=\"color:var(--error)\">Upload failed:</span> '"
325
+ " + _esc(String(err));\n"
326
+ " })\n"
327
+ " .finally(function() {\n"
328
+ " uploadZone.style.opacity = '';\n"
329
+ " uploadZone.style.pointerEvents = '';\n"
330
+ " });\n"
331
+ " }\n"
332
+ " }\n"
333
+ "\n"
334
+ " // Close panel on outside click\n"
335
+ " document.addEventListener('click', function(ev) {\n"
336
+ " if (!_panelOpen) return;\n"
337
+ " var wrapper = input.closest('.tlc-ds-wrapper');\n"
338
+ " if (wrapper && !wrapper.contains(ev.target)) {\n"
339
+ " _panelOpen = false;\n"
340
+ " panel.classList.remove('open');\n"
341
+ " if (uploadZone) uploadZone.classList.remove('open');\n"
342
+ " }\n"
343
+ " });\n"
344
+ "}\n"
345
+ "\n"
346
+ "function _tlcGetDataSourceValue(idPrefix) {\n"
347
+ " var el = document.getElementById(idPrefix);\n"
348
+ " return el ? el.value.trim() : '';\n"
349
+ "}\n"
350
+ "\n"
351
+ "function _tlcSetDataSourceValue(idPrefix, value) {\n"
352
+ " var el = document.getElementById(idPrefix);\n"
353
+ " if (el) {\n"
354
+ " el.value = value || '';\n"
355
+ " el.dispatchEvent(new Event('input', {bubbles: true}));\n"
356
+ " el.dispatchEvent(new Event('change', {bubbles: true}));\n"
357
+ " }\n"
358
+ "}\n"
359
+ )
360
+ # fmt: on
361
+
362
+
363
+ def data_source_ui_script() -> str:
364
+ """Return the shared data-source UI JavaScript block."""
365
+ return DATA_SOURCE_UI_JS
@@ -0,0 +1,58 @@
1
+ # Copyright 2026 3LC Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared helper for translating training progress into the generic progress schema.
4
+
5
+ Used by training plugins inside ``run_job``: they compute an
6
+ epoch/batch progress dict and call :func:`epoch_progress` to render the generic
7
+ ``{percent, label, timing}`` shape the frontend understands, which they then push
8
+ through their own ``ctx.emit`` channel. (The host owns job *listing* and *cancel*;
9
+ this module only shapes the progress payload.)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+
17
+ def epoch_progress(
18
+ progress: dict[str, Any],
19
+ *,
20
+ phase_key: str = "phase",
21
+ epoch_key: str = "epoch",
22
+ total_key: str = "total_epochs",
23
+ batch_frac_key: str = "batch_frac",
24
+ step_label: str = "epoch",
25
+ ) -> dict[str, Any] | None:
26
+ """Build progress dict from epoch/batch-based training progress.
27
+
28
+ Common to epoch-based training plugins.
29
+ """
30
+ if not progress:
31
+ return None
32
+
33
+ epoch = progress.get(epoch_key, 0)
34
+ total = progress.get(total_key, 0)
35
+ if total <= 0:
36
+ return None
37
+
38
+ batch_frac = progress.get(batch_frac_key, 0)
39
+ percent = int(((epoch - 1 + batch_frac) / total) * 100) if total > 0 else 0
40
+ percent = max(0, min(100, percent))
41
+
42
+ phase = progress.get(phase_key, "")
43
+ label = f"Epoch {epoch}/{total}" if total > 0 else ""
44
+ if phase:
45
+ label = f"{label} · {phase}" if label else phase
46
+
47
+ result: dict[str, Any] = {"percent": percent, "label": label}
48
+
49
+ timing = progress.get("timing")
50
+ if timing:
51
+ result["timing"] = {
52
+ "elapsed_s": timing.get("elapsed_s"),
53
+ "eta_s": timing.get("eta_s"),
54
+ "avg_step_s": timing.get("avg_epoch_s", timing.get("avg_step_s")),
55
+ "step_label": step_label,
56
+ }
57
+
58
+ return result