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,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