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.
- 3lc_compute_plugin_sdk-0.2.2.dist-info/METADATA +81 -0
- 3lc_compute_plugin_sdk-0.2.2.dist-info/RECORD +28 -0
- 3lc_compute_plugin_sdk-0.2.2.dist-info/WHEEL +4 -0
- 3lc_compute_plugin_sdk-0.2.2.dist-info/licenses/LICENSE +202 -0
- tlc_plugin_sdk/__init__.py +58 -0
- tlc_plugin_sdk/asgi_app.py +103 -0
- tlc_plugin_sdk/contract/plugin-api.d.ts +429 -0
- tlc_plugin_sdk/contract.py +108 -0
- tlc_plugin_sdk/job_context.py +120 -0
- tlc_plugin_sdk/py.typed +0 -0
- tlc_plugin_sdk/shared/__init__.py +11 -0
- tlc_plugin_sdk/shared/alias_override_ui.py +175 -0
- tlc_plugin_sdk/shared/alias_ui.py +198 -0
- tlc_plugin_sdk/shared/aliases.py +227 -0
- tlc_plugin_sdk/shared/config_store.py +140 -0
- tlc_plugin_sdk/shared/config_ui.py +256 -0
- tlc_plugin_sdk/shared/data_source_routes.py +221 -0
- tlc_plugin_sdk/shared/data_source_ui.py +365 -0
- tlc_plugin_sdk/shared/generic_job.py +58 -0
- tlc_plugin_sdk/shared/images.py +350 -0
- tlc_plugin_sdk/shared/job_tracker.py +174 -0
- tlc_plugin_sdk/shared/labels.py +247 -0
- tlc_plugin_sdk/shared/modality.py +551 -0
- tlc_plugin_sdk/shared/model_storage.py +160 -0
- tlc_plugin_sdk/shared/naming.py +84 -0
- tlc_plugin_sdk/shared/ui_inject.py +63 -0
- tlc_plugin_sdk/shared/url_utils.py +112 -0
- tlc_plugin_sdk/worker.py +295 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# Copyright 2026 3LC Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Shared helpers for discovering and reading image data from 3LC tables.
|
|
4
|
+
|
|
5
|
+
Centralizes the image-column discovery and image-reading pattern used across
|
|
6
|
+
plugins. Raw paths are read from ``table.table_rows`` (the row view — paths as
|
|
7
|
+
stored, no sample-view decoding) and resolved with ``tlc.Url.to_absolute``
|
|
8
|
+
against the table URL, mirroring what ``Table.__getitem__`` does internally.
|
|
9
|
+
This makes relative, aliased (``<TOKEN>/...``), and cloud (S3/GCS/Azure) paths
|
|
10
|
+
all work — rather than being passed straight to ``PIL.Image.open``, which only
|
|
11
|
+
handles local filesystem paths.
|
|
12
|
+
|
|
13
|
+
Porting to tlc core
|
|
14
|
+
-------------------
|
|
15
|
+
This functionality belongs in the tlc SDK; this module is shaped so the port
|
|
16
|
+
is mechanical. Plugins import only from here, so the port touches exactly this
|
|
17
|
+
file. Intended core mapping:
|
|
18
|
+
|
|
19
|
+
- ``get_image_column(table, ...)`` → ``Table.resolve_image_column(name)``
|
|
20
|
+
plus a ``Table.image_columns`` property (schema walk over
|
|
21
|
+
``STRING_ROLE_IMAGE_URL``).
|
|
22
|
+
- ``resolve_image_url(path, table_url)`` → already in core:
|
|
23
|
+
``Url(path).to_absolute(owner)`` (= ``Table.absolute_url_from_relative``).
|
|
24
|
+
- ``load_image(path, table_url)`` → ``ImageHelper.open_image(url)``
|
|
25
|
+
(local file → PIL directly, else ``BytesIO(url.read_bytes())``; faithful
|
|
26
|
+
mode by default — the RGB conversion is this app's policy and stays here).
|
|
27
|
+
- ``read_image_from_table(table, idx, col)`` → ``Table.read_image(idx, column)``.
|
|
28
|
+
- ``get_image_paths(table, col)`` → ``Table.get_image_urls(column)``.
|
|
29
|
+
- ``list_image_urls(folder)`` → needs a **public recursive listing API** in
|
|
30
|
+
core first: ``UrlAdapterRegistry.list_dir`` is not exported from
|
|
31
|
+
``tlc.url``, and ``Url._list_dir`` is private, single-level, and drops the
|
|
32
|
+
``is_dir`` flag needed to recurse. Suggested core addition:
|
|
33
|
+
``Url.list_dir()`` / ``Url.walk()`` returning ``UrlAdapterDirEntry``.
|
|
34
|
+
|
|
35
|
+
Once those land, each function body here becomes a one-line delegation and
|
|
36
|
+
plugin code is untouched.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import logging
|
|
42
|
+
from typing import TYPE_CHECKING, Any
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
import tlc
|
|
46
|
+
from PIL.Image import Image
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
# Common image-column names, tried as a fallback when schema-role detection
|
|
51
|
+
# does not identify an image column.
|
|
52
|
+
_IMAGE_COLUMN_CANDIDATES = ("image", "image_path", "file_name", "filename")
|
|
53
|
+
|
|
54
|
+
# File extensions treated as images when listing folders.
|
|
55
|
+
_IMAGE_FILE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_image_column(table: Any, override: str | None = None) -> str:
|
|
59
|
+
"""Discover the name of the image column in a table.
|
|
60
|
+
|
|
61
|
+
Detection order:
|
|
62
|
+
1. ``override`` if given (validated against the table's columns).
|
|
63
|
+
2. Schema ``string_role == STRING_ROLE_IMAGE_URL`` (the canonical SDK
|
|
64
|
+
signal).
|
|
65
|
+
3. Common column-name candidates (``image``, ``image_path``, ...).
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
table: A loaded ``tlc.Table``.
|
|
69
|
+
override: An explicit column name to use, if provided.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The image column name.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValueError: If no image column can be found, listing the available
|
|
76
|
+
columns so the caller can diagnose the table.
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
from tlc.constants import STRING_ROLE_IMAGE_URL
|
|
80
|
+
|
|
81
|
+
columns = _table_columns(table)
|
|
82
|
+
|
|
83
|
+
if override:
|
|
84
|
+
if override in columns:
|
|
85
|
+
return override
|
|
86
|
+
msg = f"Image column '{override}' not found in table. Available columns: {columns}"
|
|
87
|
+
raise ValueError(msg)
|
|
88
|
+
|
|
89
|
+
# Schema-role based detection.
|
|
90
|
+
try:
|
|
91
|
+
for name, col_schema in table.rows_schema.values.items():
|
|
92
|
+
value_obj = getattr(col_schema, "value", None)
|
|
93
|
+
if getattr(value_obj, "string_role", None) == STRING_ROLE_IMAGE_URL:
|
|
94
|
+
return str(name)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# Name-candidate fallback (case-insensitive).
|
|
99
|
+
lower_to_actual = {c.lower(): c for c in columns}
|
|
100
|
+
for candidate in _IMAGE_COLUMN_CANDIDATES:
|
|
101
|
+
if candidate in lower_to_actual:
|
|
102
|
+
return lower_to_actual[candidate]
|
|
103
|
+
|
|
104
|
+
msg = (
|
|
105
|
+
"Could not find an image column in the table. "
|
|
106
|
+
f"Available columns: {columns}. "
|
|
107
|
+
f"Expected a column with an image URL role, or one named one of {list(_IMAGE_COLUMN_CANDIDATES)}."
|
|
108
|
+
)
|
|
109
|
+
raise ValueError(msg)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_image_url(img_path: str, table_url: Any = None) -> tlc.Url:
|
|
113
|
+
"""Resolve an image path (possibly relative or aliased) against a table URL.
|
|
114
|
+
|
|
115
|
+
Uses ``tlc.Url.to_absolute`` — the same resolution ``Table.__getitem__``
|
|
116
|
+
applies to URL columns — so absolute paths pass through unchanged, alias
|
|
117
|
+
paths (``<TOKEN>/...``) are expanded, and relative paths are resolved
|
|
118
|
+
against the table URL.
|
|
119
|
+
|
|
120
|
+
One deliberate deviation from core: aliases are expanded **strictly**.
|
|
121
|
+
Core's lenient ``to_absolute`` keeps an unregistered alias in the path and
|
|
122
|
+
joins it onto the owner (``.../tables/t/<TOKEN>/img.jpg``), which turns
|
|
123
|
+
"alias not registered" into a confusing FileNotFoundError — or silently-NaN
|
|
124
|
+
metrics — far from the cause. Raising here fails jobs fast with the actual
|
|
125
|
+
problem. (Whether core should do the same is tracked as a core ask in the
|
|
126
|
+
deployment doc roadmap.)
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
img_path: The image path stored in the table.
|
|
130
|
+
table_url: URL of the table the image belongs to (``tlc.Url`` or
|
|
131
|
+
string; used as the owner for relative paths). May be omitted for
|
|
132
|
+
absolute/alias paths.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
An absolute ``tlc.Url`` readable through 3LC's URL adapters.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ValueError: If ``img_path`` contains an alias that is not registered.
|
|
139
|
+
|
|
140
|
+
"""
|
|
141
|
+
import tlc
|
|
142
|
+
|
|
143
|
+
owner = tlc.Url(str(table_url)) if table_url else None
|
|
144
|
+
return tlc.Url(img_path).expand_aliases(allow_unexpanded=False).to_absolute(owner)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_image(img_path: str, table_url: Any = None) -> Image:
|
|
148
|
+
"""Load an image from a stored path as an RGB PIL image.
|
|
149
|
+
|
|
150
|
+
Resolves the path via :func:`resolve_image_url`, then opens local files
|
|
151
|
+
directly with PIL and everything else through the URL adapters
|
|
152
|
+
(``BytesIO(url.read_bytes())``).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
img_path: The image path stored in the table.
|
|
156
|
+
table_url: URL of the table the image belongs to (base for relative
|
|
157
|
+
paths). May be omitted for absolute/alias paths.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The decoded image, converted to RGB.
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
import io
|
|
164
|
+
|
|
165
|
+
from PIL import Image as PILImage
|
|
166
|
+
from tlc.url import Scheme
|
|
167
|
+
|
|
168
|
+
url = resolve_image_url(str(img_path), table_url)
|
|
169
|
+
if url.scheme == Scheme.FILE:
|
|
170
|
+
image = PILImage.open(url.to_str())
|
|
171
|
+
else:
|
|
172
|
+
image = PILImage.open(io.BytesIO(url.read_bytes()))
|
|
173
|
+
return image.convert("RGB")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def read_image_size(img_path: str, table_url: Any = None) -> tuple[int, int]:
|
|
177
|
+
"""Return a stored image's ``(width, height)`` without decoding its pixels.
|
|
178
|
+
|
|
179
|
+
Resolves the path the same way as :func:`load_image` (through the URL
|
|
180
|
+
adapters, so any storage backend works), but reads only the image header
|
|
181
|
+
rather than the full image — much cheaper when only the dimensions are
|
|
182
|
+
needed (e.g. populating ``image_width``/``image_height`` for empty
|
|
183
|
+
annotations on a table built from images alone).
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
img_path: The image path stored in the table.
|
|
187
|
+
table_url: URL of the table the image belongs to (base for relative
|
|
188
|
+
paths). May be omitted for absolute/alias paths.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The image ``(width, height)`` in pixels.
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
import io
|
|
195
|
+
|
|
196
|
+
from PIL import Image as PILImage
|
|
197
|
+
from tlc.url import Scheme
|
|
198
|
+
|
|
199
|
+
url = resolve_image_url(str(img_path), table_url)
|
|
200
|
+
if url.scheme == Scheme.FILE:
|
|
201
|
+
with PILImage.open(url.to_str()) as image:
|
|
202
|
+
return image.width, image.height
|
|
203
|
+
with PILImage.open(io.BytesIO(url.read_bytes())) as image:
|
|
204
|
+
return image.width, image.height
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def read_image_from_table(table: Any, idx: int, image_column: str | None = None) -> Image:
|
|
208
|
+
"""Read a single image from a table row as an RGB PIL image.
|
|
209
|
+
|
|
210
|
+
Reads the raw path from ``table.table_rows`` (``table[idx]`` would return
|
|
211
|
+
a decoded image rather than the path) and opens it via :func:`load_image`.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
table: A loaded ``tlc.Table``.
|
|
215
|
+
idx: Row index.
|
|
216
|
+
image_column: Image column name; discovered via :func:`get_image_column`
|
|
217
|
+
if not provided.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
The decoded image, converted to RGB.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
IndexError: If ``idx`` is out of range.
|
|
224
|
+
ValueError: If the row has no image path.
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
column = image_column or get_image_column(table)
|
|
228
|
+
if idx < 0 or idx >= len(table):
|
|
229
|
+
msg = f"Row index {idx} out of range for table with {len(table)} rows"
|
|
230
|
+
raise IndexError(msg)
|
|
231
|
+
img_path = table.table_rows[idx][column]
|
|
232
|
+
if not img_path:
|
|
233
|
+
msg = f"Row {idx} has no image path in column '{column}'"
|
|
234
|
+
raise ValueError(msg)
|
|
235
|
+
return load_image(str(img_path), table.url)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_image_paths(table: Any, image_column: str | None = None) -> list[str]:
|
|
239
|
+
"""Read all image paths from a table, resolved to absolute URLs.
|
|
240
|
+
|
|
241
|
+
Iterates ``table.table_rows`` (raw row view) and absolutizes each path
|
|
242
|
+
against the table URL, so the result is safe to read from anywhere or to
|
|
243
|
+
write into a new table at a different location. Rows without a path yield
|
|
244
|
+
an empty string. This is the single bulk-read entry point — if it ever
|
|
245
|
+
becomes a bottleneck, optimize here rather than at call sites.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
table: A loaded ``tlc.Table``.
|
|
249
|
+
image_column: Image column name; discovered via :func:`get_image_column`
|
|
250
|
+
if not provided (an explicit name is validated the same way).
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
One absolute URL string per row, in row order.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
ValueError: If the image column cannot be found.
|
|
257
|
+
|
|
258
|
+
"""
|
|
259
|
+
column = get_image_column(table, override=image_column)
|
|
260
|
+
table_url = table.url
|
|
261
|
+
paths: list[str] = []
|
|
262
|
+
for row in table.table_rows:
|
|
263
|
+
img_path = row[column]
|
|
264
|
+
paths.append(resolve_image_url(str(img_path), table_url).to_str() if img_path else "")
|
|
265
|
+
return paths
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def list_image_urls(folder: Any, max_count: int = 10000) -> list[str]:
|
|
269
|
+
"""List image files under a folder, recursively, on any storage backend.
|
|
270
|
+
|
|
271
|
+
Resolves the folder through ``tlc.Url`` (so aliased ``<TOKEN>/...`` and
|
|
272
|
+
relative paths work) and walks it via the URL adapter registry, so local,
|
|
273
|
+
S3/GCS/Azure, and any custom-adapter folders all list correctly — unlike
|
|
274
|
+
``pathlib``, which silently returns nothing for non-local paths.
|
|
275
|
+
|
|
276
|
+
The full tree is walked before sorting and capping, so the result is
|
|
277
|
+
deterministic (the lexicographically first ``max_count`` paths).
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
folder: Folder path or URL (``str`` or ``tlc.Url``).
|
|
281
|
+
max_count: Maximum number of paths to return.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Sorted list of image URLs/paths. Empty if the folder does not exist or
|
|
285
|
+
exists but contains no images.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
ValueError: If ``folder`` contains an alias that is not registered.
|
|
289
|
+
OSError: If the folder exists but cannot be listed — e.g. a cloud auth,
|
|
290
|
+
region, or permission misconfiguration. (A merely non-existent
|
|
291
|
+
folder returns an empty list, not an error.)
|
|
292
|
+
|
|
293
|
+
"""
|
|
294
|
+
import tlc
|
|
295
|
+
|
|
296
|
+
# No public listing API in tlc.url yet (Url._list_dir is private and
|
|
297
|
+
# single-level) — see the porting notes in the module docstring.
|
|
298
|
+
from tlcurl.url_adapters._registry import UrlAdapterRegistry
|
|
299
|
+
|
|
300
|
+
# Expand aliases strictly before absolutizing: to_absolute() on an
|
|
301
|
+
# unregistered alias would silently join the still-aliased path onto cwd
|
|
302
|
+
# ("/cwd/<TOKEN>/images") — never what you want. Failing loudly gives the
|
|
303
|
+
# caller a diagnosable "could not expand alias" error instead of an empty
|
|
304
|
+
# listing.
|
|
305
|
+
root = tlc.Url(str(folder)).expand_aliases(allow_unexpanded=False).to_absolute()
|
|
306
|
+
found: list[str] = []
|
|
307
|
+
stack = [root]
|
|
308
|
+
is_root = True
|
|
309
|
+
while stack:
|
|
310
|
+
current = stack.pop()
|
|
311
|
+
try:
|
|
312
|
+
entries = list(UrlAdapterRegistry.list_dir(current))
|
|
313
|
+
except FileNotFoundError:
|
|
314
|
+
# The folder simply does not exist — a benign empty result. (Deeper
|
|
315
|
+
# in the walk this is a race: a subfolder vanished between listing
|
|
316
|
+
# and visiting it.)
|
|
317
|
+
logger.debug("Folder does not exist: %s", current)
|
|
318
|
+
is_root = False
|
|
319
|
+
continue
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
if is_root:
|
|
322
|
+
# The top-level folder exists but could not be listed: a real
|
|
323
|
+
# misconfiguration (cloud auth, bad region, denied access). Raise
|
|
324
|
+
# it — returning [] here would read as a benign "no images found"
|
|
325
|
+
# and send the caller hunting for the wrong problem.
|
|
326
|
+
msg = f"Could not list folder {current}: {exc}"
|
|
327
|
+
raise OSError(msg) from exc
|
|
328
|
+
# Tolerate an unlistable subfolder mid-walk so a partial listing is
|
|
329
|
+
# still useful, but log why it was skipped.
|
|
330
|
+
logger.warning("Skipping unlistable subfolder %s: %s", current, exc)
|
|
331
|
+
continue
|
|
332
|
+
is_root = False
|
|
333
|
+
for entry in entries:
|
|
334
|
+
if entry.is_dir():
|
|
335
|
+
stack.append(tlc.Url(entry.path))
|
|
336
|
+
elif entry.name.lower().endswith(_IMAGE_FILE_EXTENSIONS):
|
|
337
|
+
found.append(str(entry.path))
|
|
338
|
+
found.sort()
|
|
339
|
+
return found[:max_count]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _table_columns(table: Any) -> list[str]:
|
|
343
|
+
"""Best-effort list of a table's column names, for detection and errors."""
|
|
344
|
+
try:
|
|
345
|
+
return list(table.columns)
|
|
346
|
+
except Exception:
|
|
347
|
+
try:
|
|
348
|
+
return list(table.rows_schema.values.keys())
|
|
349
|
+
except Exception:
|
|
350
|
+
return []
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Copyright 2026 3LC Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Shared client helper: subscribe a plugin's own UI to the generic job channel.
|
|
4
|
+
|
|
5
|
+
Every host/venv job already broadcasts a generic ``job_update`` SocketIO event on
|
|
6
|
+
its namespace, carrying the schema the frontend renders without plugin knowledge
|
|
7
|
+
(``status``, ``progress.{percent,label,timing}``, ``run_url``, ``metrics[]``). A
|
|
8
|
+
plugin's *own* ``ui.html`` can be a **second consumer** of that same channel to
|
|
9
|
+
drive a richer, plugin-tailored view — no bespoke events needed for the generic
|
|
10
|
+
lifecycle (a plugin reaches for ``ctx.emit`` only for telemetry the generic
|
|
11
|
+
schema can't express, e.g. a loss curve).
|
|
12
|
+
|
|
13
|
+
This module returns that subscription client as an injectable ``<script>`` block,
|
|
14
|
+
mirroring :func:`tlc_plugin_sdk.shared.alias_ui.alias_ui_script`. After injecting it
|
|
15
|
+
a plugin UI gets a ``window.PluginJobs`` object::
|
|
16
|
+
|
|
17
|
+
PluginJobs.run(pluginId, params, {onUpdate, onDone, onError}) // start + track
|
|
18
|
+
PluginJobs.start(pluginId, params) // -> Promise<{job_id,status,namespace}>
|
|
19
|
+
PluginJobs.track(namespace, jobId, {onUpdate, onDone, onError}) // -> unsubscribe()
|
|
20
|
+
PluginJobs.cancel(jobId) // -> Promise<{cancelled}>
|
|
21
|
+
PluginJobs.on(namespace, event, handler) // -> unsubscribe(); custom ctx.emit() events
|
|
22
|
+
|
|
23
|
+
``run`` pre-subscribes and buffers so a job that finishes between the ``/run``
|
|
24
|
+
response and the client subscribing still delivers its terminal event.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
# fmt: off
|
|
30
|
+
JOB_TRACKER_JS = r"""
|
|
31
|
+
// ── Shared plugin job tracker (generic job_update channel) ──────────────
|
|
32
|
+
(function(){
|
|
33
|
+
if (window.PluginJobs) { return; }
|
|
34
|
+
var API = window.PLUGIN_API;
|
|
35
|
+
function _computeUrl(){ return (API && API.getConfig && API.getConfig('compute_service_url')) || ''; }
|
|
36
|
+
|
|
37
|
+
// One socket per namespace, reused across jobs.
|
|
38
|
+
var _sockets = {};
|
|
39
|
+
function _socket(namespace){
|
|
40
|
+
if (_sockets[namespace]) { return _sockets[namespace]; }
|
|
41
|
+
var io = API && API.libs && API.libs.io;
|
|
42
|
+
if (!io) { return null; }
|
|
43
|
+
var s = io(_computeUrl() + namespace, {
|
|
44
|
+
path: '/socket.io/', transports: ['websocket', 'polling'], reconnection: true
|
|
45
|
+
});
|
|
46
|
+
_sockets[namespace] = s;
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function start(pluginId, params){
|
|
51
|
+
params = params || {};
|
|
52
|
+
// Attribute the job to the launch project so it shows in that project's generic
|
|
53
|
+
// Queue & Progress pane — the panel polls /api/plugins/jobs?project=<name> and the
|
|
54
|
+
// host filters by the record's project_name. Default it from the plugin's launch
|
|
55
|
+
// context (empty when launched bare from the sidebar — such jobs only appear on the
|
|
56
|
+
// global Queue page). An explicit params.project_name always wins.
|
|
57
|
+
var ctxProject = (API && API.context && API.context.projectName) || '';
|
|
58
|
+
if (ctxProject && !params.project_name){
|
|
59
|
+
var merged = {}; for (var k in params){ if (params.hasOwnProperty(k)) { merged[k] = params[k]; } }
|
|
60
|
+
merged.project_name = ctxProject;
|
|
61
|
+
params = merged;
|
|
62
|
+
}
|
|
63
|
+
return API.authFetch(_computeUrl() + '/api/plugins/' + pluginId + '/run', {
|
|
64
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
body: JSON.stringify(params)
|
|
66
|
+
}).then(function(r){ return r.json(); });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cancel(jobId){
|
|
70
|
+
return API.authFetch(_computeUrl() + '/api/plugins/jobs/' + jobId + '/cancel', {
|
|
71
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}'
|
|
72
|
+
}).then(function(r){ return r.json(); });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Subscribe to a plugin's own custom ctx.emit() event on its namespace.
|
|
76
|
+
// Returns an unsubscribe function. Use for rich per-job detail the flat generic
|
|
77
|
+
// job_update schema can't carry (a result payload, a loss curve, per-item rows).
|
|
78
|
+
// Subscribe BEFORE calling run() so the socket is connected when the event fires.
|
|
79
|
+
function on(namespace, event, handler){
|
|
80
|
+
var s = _socket(namespace);
|
|
81
|
+
if (!s) { return function(){}; }
|
|
82
|
+
s.on(event, handler);
|
|
83
|
+
return function(){ s.off(event, handler); };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Subscribe to job_update for one job on a known namespace. Returns unsubscribe().
|
|
87
|
+
function track(namespace, jobId, handlers){
|
|
88
|
+
handlers = handlers || {};
|
|
89
|
+
var s = _socket(namespace);
|
|
90
|
+
if (!s) { return function(){}; }
|
|
91
|
+
var done = false;
|
|
92
|
+
function off(){ s.off('job_update', onUpd); }
|
|
93
|
+
function onUpd(job){
|
|
94
|
+
if (!job || job.id !== jobId) { return; }
|
|
95
|
+
if (handlers.onUpdate) { handlers.onUpdate(job); }
|
|
96
|
+
if (done) { return; }
|
|
97
|
+
if (job.status === 'completed' || job.status === 'cancelled'){
|
|
98
|
+
done = true; off(); if (handlers.onDone) { handlers.onDone(job); }
|
|
99
|
+
} else if (job.status === 'failed'){
|
|
100
|
+
done = true; off(); if (handlers.onError) { handlers.onError(job); }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
s.on('job_update', onUpd);
|
|
104
|
+
return off;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Start a job and track it. Pre-subscribes on the default namespace and buffers
|
|
108
|
+
// events so a job that completes before we learn its id still delivers onDone.
|
|
109
|
+
function run(pluginId, params, handlers){
|
|
110
|
+
handlers = handlers || {};
|
|
111
|
+
var ns = '/' + pluginId; // default channel; corrected from resp.namespace
|
|
112
|
+
var s = _socket(ns);
|
|
113
|
+
var jobId = null;
|
|
114
|
+
var done = false;
|
|
115
|
+
var buffer = [];
|
|
116
|
+
|
|
117
|
+
function cleanup(){ if (s) { s.off('job_update', onUpd); } }
|
|
118
|
+
function dispatch(job){
|
|
119
|
+
if (handlers.onUpdate) { handlers.onUpdate(job); }
|
|
120
|
+
if (done) { return; }
|
|
121
|
+
if (job.status === 'completed' || job.status === 'cancelled'){
|
|
122
|
+
done = true; cleanup(); if (handlers.onDone) { handlers.onDone(job); }
|
|
123
|
+
} else if (job.status === 'failed'){
|
|
124
|
+
done = true; cleanup(); if (handlers.onError) { handlers.onError(job); }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function onUpd(job){
|
|
128
|
+
if (!job) { return; }
|
|
129
|
+
if (jobId === null) { buffer.push(job); return; } // id unknown yet -> buffer
|
|
130
|
+
if (job.id !== jobId) { return; }
|
|
131
|
+
dispatch(job);
|
|
132
|
+
}
|
|
133
|
+
if (s) { s.on('job_update', onUpd); }
|
|
134
|
+
|
|
135
|
+
return start(pluginId, params).then(function(resp){
|
|
136
|
+
if (!resp || resp.error || !resp.job_id){
|
|
137
|
+
cleanup();
|
|
138
|
+
var emsg = (resp && resp.error) || 'Failed to start job';
|
|
139
|
+
if (handlers.onError) { handlers.onError({ status: 'failed', subtitle: emsg }); }
|
|
140
|
+
return resp;
|
|
141
|
+
}
|
|
142
|
+
jobId = resp.job_id;
|
|
143
|
+
// If the job actually runs on a custom namespace, move the listener there.
|
|
144
|
+
if (resp.namespace && resp.namespace !== ns){
|
|
145
|
+
cleanup();
|
|
146
|
+
s = _socket(resp.namespace);
|
|
147
|
+
if (s) { s.on('job_update', onUpd); }
|
|
148
|
+
}
|
|
149
|
+
// Replay anything buffered for this job (may include the terminal event).
|
|
150
|
+
var pending = buffer.filter(function(j){ return j.id === jobId; });
|
|
151
|
+
buffer = [];
|
|
152
|
+
pending.forEach(dispatch);
|
|
153
|
+
return resp;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
window.PluginJobs = { start: start, track: track, run: run, cancel: cancel, on: on };
|
|
158
|
+
})();
|
|
159
|
+
"""
|
|
160
|
+
# fmt: on
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def job_tracker_script() -> str:
|
|
164
|
+
"""Return the shared plugin-job tracker JavaScript block.
|
|
165
|
+
|
|
166
|
+
Inject once into a plugin's ``ui.html`` (inside the opening ``<script>``),
|
|
167
|
+
then drive the generic lifecycle via ``window.PluginJobs`` — see the module
|
|
168
|
+
docstring for the API.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The ``PluginJobs`` client as a self-installing JS string.
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
return JOB_TRACKER_JS
|