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,140 @@
1
+ # Copyright 2026 3LC Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Generic on-disk store for a plugin's saved job configs.
4
+
5
+ A "config" here is a **reusable job parameterization** a user names and re-runs
6
+ from the plugin UI (the "New config" / config-bar feature driven by
7
+ :func:`tlc_plugin_sdk.shared.config_ui.config_ui_script`) — NOT the
8
+ service/host settings (those live in ``persistent_settings`` / ``settings.json``).
9
+
10
+ Each plugin keeps its own ``@dataclass`` config schema and hands the *type* to
11
+ :class:`PluginConfigStore`, which owns the JSON-on-disk CRUD. The config
12
+ dataclass must carry the common envelope fields the store manages:
13
+
14
+ - ``id: str`` — assigned on first save
15
+ - ``created: str`` — ISO timestamp, assigned on first save; list order key
16
+ - ``last_run: str | None`` — bumped by :meth:`update_last_run`
17
+
18
+ (``name: str`` is conventional for the UI but not required by the store.)
19
+
20
+ Configs live under ``~/.3lc-plugin-configs/<plugin-id>/``. Pass ``legacy_dir``
21
+ to lazily migrate a pre-standardization location on first construction.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import shutil
29
+ import uuid
30
+ from dataclasses import asdict, is_dataclass
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Generic, TypeVar
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Standardized root for all plugins' saved job configs.
38
+ CONFIG_ROOT = Path.home() / ".3lc-plugin-configs"
39
+
40
+ T = TypeVar("T")
41
+
42
+
43
+ class PluginConfigStore(Generic[T]):
44
+ """Persist a plugin's saved job configs as JSON files, one per config.
45
+
46
+ Args:
47
+ config_cls: The plugin's config ``@dataclass`` (must have ``id`` /
48
+ ``created`` / ``last_run`` fields). Instances are (de)serialized via
49
+ :func:`dataclasses.asdict` and ``config_cls(**known_fields)``.
50
+ plugin_id: The plugin's manifest id; configs live under
51
+ ``~/.3lc-plugin-configs/<plugin_id>/``.
52
+ legacy_dir: Optional back-compat directory. If the standardized
53
+ directory has no configs yet and ``legacy_dir`` holds some, they are
54
+ moved on construction (one-time, idempotent).
55
+
56
+ """
57
+
58
+ def __init__(self, config_cls: type[T], plugin_id: str, *, legacy_dir: Path | str | None = None) -> None:
59
+ if not is_dataclass(config_cls):
60
+ msg = f"PluginConfigStore requires a dataclass config type, got {config_cls!r}"
61
+ raise TypeError(msg)
62
+ self._cls = config_cls
63
+ self._dir = CONFIG_ROOT / plugin_id
64
+ self._dir.mkdir(parents=True, exist_ok=True)
65
+ if legacy_dir is not None:
66
+ self._migrate_legacy(Path(legacy_dir))
67
+
68
+ # ── CRUD ─────────────────────────────────────────────────────────────
69
+ def list_configs(self) -> list[T]:
70
+ """Return all saved configs, newest first (by ``created``)."""
71
+ configs: list[T] = []
72
+ for f in sorted(self._dir.glob("*.json")):
73
+ cfg = self._read(f)
74
+ if cfg is not None:
75
+ configs.append(cfg)
76
+ configs.sort(key=lambda c: getattr(c, "created", "") or "", reverse=True)
77
+ return configs
78
+
79
+ def get_config(self, config_id: str) -> T | None:
80
+ """Load a config by id, or None if missing/unreadable."""
81
+ return self._read(self._path(config_id))
82
+
83
+ def save_config(self, config: T) -> T:
84
+ """Save a config, assigning ``id`` and ``created`` on first save."""
85
+ if not getattr(config, "id", ""):
86
+ config.id = str(uuid.uuid4()) # type: ignore[attr-defined]
87
+ if not getattr(config, "created", ""):
88
+ config.created = datetime.now(timezone.utc).isoformat() # type: ignore[attr-defined]
89
+ with open(self._path(config.id), "w") as f: # type: ignore[attr-defined]
90
+ json.dump(asdict(config), f, indent=2) # type: ignore[call-overload]
91
+ return config
92
+
93
+ def delete_config(self, config_id: str) -> bool:
94
+ """Delete a config. Returns True if it existed."""
95
+ path = self._path(config_id)
96
+ if path.exists():
97
+ path.unlink()
98
+ return True
99
+ return False
100
+
101
+ def update_last_run(self, config_id: str) -> None:
102
+ """Stamp ``last_run`` with the current time, if the config exists."""
103
+ config = self.get_config(config_id)
104
+ if config is not None:
105
+ config.last_run = datetime.now(timezone.utc).isoformat() # type: ignore[attr-defined]
106
+ self.save_config(config)
107
+
108
+ # ── internals ────────────────────────────────────────────────────────
109
+ def _path(self, config_id: str) -> Path:
110
+ return self._dir / f"{config_id}.json"
111
+
112
+ def _read(self, path: Path) -> T | None:
113
+ """Load + deserialize one config file, tolerating drift/corruption.
114
+
115
+ Unknown keys (older/newer schema) are dropped and missing fields fall
116
+ back to dataclass defaults, so a config schema can evolve without
117
+ invalidating saved files.
118
+ """
119
+ if not path.exists():
120
+ return None
121
+ try:
122
+ with open(path) as f:
123
+ data = json.load(f)
124
+ known = {k: v for k, v in data.items() if k in self._cls.__dataclass_fields__} # type: ignore[attr-defined]
125
+ return self._cls(**known)
126
+ except (json.JSONDecodeError, OSError, TypeError):
127
+ return None
128
+
129
+ def _migrate_legacy(self, legacy_dir: Path) -> None:
130
+ """Move configs from a pre-standardization dir into the new location once."""
131
+ if not legacy_dir.is_dir() or any(self._dir.glob("*.json")):
132
+ return
133
+ moved = 0
134
+ for f in legacy_dir.glob("*.json"):
135
+ dest = self._dir / f.name
136
+ if not dest.exists():
137
+ shutil.move(str(f), str(dest))
138
+ moved += 1
139
+ if moved:
140
+ logger.info("Migrated %d config(s) from %s → %s", moved, legacy_dir, self._dir)
@@ -0,0 +1,256 @@
1
+ # Copyright 2026 3LC Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Shared config bar UI for plugins — JS module injected into plugin fragments.
4
+
5
+ Follows the same injection pattern as ``alias_ui.py``. Plugins call
6
+ ``config_ui_script()`` to get the JS string, then inject it with
7
+ ``inject_scripts(raw, config_ui_script())`` (see ``ui_inject.py``).
8
+
9
+ The module exposes a single factory:
10
+
11
+ var cfg = _tlcPluginConfig({ prefix, apiPath, storageKey,
12
+ collectFn, populateFn, labelFn,
13
+ onNew, onLoad });
14
+ cfg.init();
15
+
16
+ See doc-block inside CONFIG_UI_JS for full API.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ CONFIG_UI_JS = r"""
22
+ /* ── Shared Plugin Config Manager ────────────────────────────────
23
+ *
24
+ * Factory that returns a config-bar controller. Handles:
25
+ * - list / load / save / saveAsNew / delete via REST
26
+ * - localStorage for last-selected config ID
27
+ * - dirty-tracking (snapshot-based) with "Save New" visibility
28
+ * - dropdown population with custom label
29
+ *
30
+ * Required HTML IDs (by convention, {prefix}-config-select, etc.):
31
+ * {prefix}-config-select — <select> dropdown
32
+ * {prefix}-save-new-btn — "Save New" button (hidden by default)
33
+ * {prefix}-delete-btn — "Delete" button (hidden by default)
34
+ *
35
+ * Options:
36
+ * prefix {string} HTML ID prefix (e.g. "im", "dc")
37
+ * apiPath {string} REST endpoint base (e.g. "/api/plugins/image-metrics/configs")
38
+ * storageKey {string} localStorage key for last config ID
39
+ * collectFn {function} () => formDataObject (must include .name)
40
+ * populateFn {function} (config) => void — fill form from config
41
+ * labelFn {function} (config) => string — dropdown label
42
+ * onNew {function} () => void — extra reset logic
43
+ * onLoad {function} (config) => void — extra post-load logic
44
+ * ──────────────────────────────────────────────────────────────── */
45
+ function _tlcPluginConfig(opts) {
46
+ var prefix = opts.prefix;
47
+ var apiPath = opts.apiPath;
48
+ var storageKey = opts.storageKey;
49
+ var collectFn = opts.collectFn;
50
+ var populateFn = opts.populateFn;
51
+ var labelFn = opts.labelFn || function(c) { return c.name || "Untitled"; };
52
+ var onNew = opts.onNew || function() {};
53
+ var onLoad = opts.onLoad || function() {};
54
+
55
+ var _currentId = null;
56
+ var _snapshot = null;
57
+ var _timer = null;
58
+
59
+ var _selId = prefix + "-config-select";
60
+ var _saveNewId = prefix + "-save-new-btn";
61
+ var _deleteId = prefix + "-delete-btn";
62
+
63
+ /* ── Auth-aware fetch ── */
64
+ function _fetch(url, reqOpts) {
65
+ if (window.PLUGIN_API && window.PLUGIN_API.authFetch) {
66
+ return window.PLUGIN_API.authFetch(url, reqOpts || {});
67
+ }
68
+ return fetch(url, reqOpts || {});
69
+ }
70
+
71
+ function _baseUrl() {
72
+ var base = "";
73
+ if (window.PLUGIN_API && window.PLUGIN_API.getConfig) {
74
+ base = window.PLUGIN_API.getConfig("compute_service_url") || "";
75
+ }
76
+ return base + apiPath;
77
+ }
78
+
79
+ /* ── Snapshot / dirty ── */
80
+ function takeSnapshot() {
81
+ try { _snapshot = JSON.stringify(collectFn()); } catch(e) { _snapshot = null; }
82
+ }
83
+
84
+ function checkDirty() {
85
+ var btn = document.getElementById(_saveNewId);
86
+ if (!btn) return;
87
+ try {
88
+ var data = collectFn();
89
+ var hasName = data.name && data.name.trim();
90
+ if (!_currentId) {
91
+ // New config: show "Save New" as soon as a name is entered
92
+ btn.style.display = hasName ? "" : "none";
93
+ } else {
94
+ // Existing config: show "Save New" when form has changed
95
+ var dirty = JSON.stringify(data) !== _snapshot;
96
+ btn.style.display = dirty ? "" : "none";
97
+ }
98
+ } catch(e) {}
99
+ }
100
+
101
+ function scheduleAutoSave() {
102
+ clearTimeout(_timer);
103
+ _timer = setTimeout(checkDirty, 300);
104
+ }
105
+
106
+ /* ── CRUD ── */
107
+ function loadAll() {
108
+ _fetch(_baseUrl())
109
+ .then(function(r) { return r.json(); })
110
+ .then(function(configs) {
111
+ var sel = document.getElementById(_selId);
112
+ if (!sel) return;
113
+ var prev = sel.value || (_currentId || "");
114
+ sel.innerHTML = '<option value="">New config</option>';
115
+ (Array.isArray(configs) ? configs : []).forEach(function(c) {
116
+ var opt = document.createElement("option");
117
+ opt.value = c.id;
118
+ opt.textContent = labelFn(c);
119
+ sel.appendChild(opt);
120
+ });
121
+ if (prev) sel.value = prev;
122
+ })
123
+ .catch(function() {});
124
+ }
125
+
126
+ function loadConfig(configId) {
127
+ _fetch(_baseUrl() + "/" + configId)
128
+ .then(function(r) { return r.json(); })
129
+ .then(function(c) {
130
+ if (c.error) return;
131
+ _currentId = c.id;
132
+ try { localStorage.setItem(storageKey, c.id); } catch(e) {}
133
+ populateFn(c);
134
+ var sel = document.getElementById(_selId);
135
+ if (sel) sel.value = c.id;
136
+ var saveBtn = document.getElementById(_saveNewId);
137
+ if (saveBtn) saveBtn.style.display = "";
138
+ var delBtn = document.getElementById(_deleteId);
139
+ if (delBtn) delBtn.style.display = "";
140
+ onLoad(c);
141
+ setTimeout(function() { takeSnapshot(); checkDirty(); }, 100);
142
+ loadAll();
143
+ })
144
+ .catch(function(e) { console.warn("Config load failed:", e); });
145
+ }
146
+
147
+ function onSelect(val) {
148
+ if (val) { loadConfig(val); } else { newConfig(); }
149
+ }
150
+
151
+ function newConfig() {
152
+ _currentId = null;
153
+ try { localStorage.removeItem(storageKey); } catch(e) {}
154
+ var sel = document.getElementById(_selId);
155
+ if (sel) sel.value = "";
156
+ var saveBtn = document.getElementById(_saveNewId);
157
+ if (saveBtn) saveBtn.style.display = "none";
158
+ var delBtn = document.getElementById(_deleteId);
159
+ if (delBtn) delBtn.style.display = "none";
160
+ onNew();
161
+ takeSnapshot();
162
+ loadAll();
163
+ }
164
+
165
+ function saveAsNew() {
166
+ var data = collectFn();
167
+ data.id = "";
168
+ if (!data.name || !data.name.trim()) {
169
+ alert("Please enter a config name.");
170
+ return;
171
+ }
172
+ _fetch(_baseUrl(), {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify(data),
176
+ })
177
+ .then(function(r) { return r.json(); })
178
+ .then(function(resp) {
179
+ if (resp.error) { alert("Error: " + resp.error); return; }
180
+ _currentId = resp.id;
181
+ try { localStorage.setItem(storageKey, _currentId); } catch(e) {}
182
+ var delBtn = document.getElementById(_deleteId);
183
+ if (delBtn) delBtn.style.display = "";
184
+ takeSnapshot();
185
+ checkDirty();
186
+ loadAll();
187
+ })
188
+ .catch(function(e) { alert("Save failed: " + e); });
189
+ }
190
+
191
+ function save(silent) {
192
+ var data = collectFn();
193
+ data.id = _currentId || "";
194
+ if (!data.name || !data.name.trim()) {
195
+ if (!silent) alert("Please enter a config name.");
196
+ return;
197
+ }
198
+ _fetch(_baseUrl(), {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify(data),
202
+ })
203
+ .then(function(r) { return r.json(); })
204
+ .then(function(resp) {
205
+ if (resp.error) { if (!silent) alert("Error: " + resp.error); return; }
206
+ _currentId = resp.id;
207
+ try { localStorage.setItem(storageKey, _currentId); } catch(e) {}
208
+ var delBtn = document.getElementById(_deleteId);
209
+ if (delBtn) delBtn.style.display = "";
210
+ takeSnapshot();
211
+ checkDirty();
212
+ loadAll();
213
+ })
214
+ .catch(function(e) { if (!silent) alert("Save failed: " + e); });
215
+ }
216
+
217
+ function deleteConfig() {
218
+ if (!_currentId) return;
219
+ if (!confirm("Delete this config?")) return;
220
+ _fetch(_baseUrl() + "/" + _currentId + "/delete", { method: "POST" })
221
+ .then(function() { newConfig(); })
222
+ .catch(function() {});
223
+ }
224
+
225
+ function init() {
226
+ var savedId = null;
227
+ try { savedId = localStorage.getItem(storageKey); } catch(e) {}
228
+ if (savedId) {
229
+ loadConfig(savedId);
230
+ } else {
231
+ loadAll();
232
+ }
233
+ }
234
+
235
+ return {
236
+ init: init,
237
+ loadAll: loadAll,
238
+ loadConfig: loadConfig,
239
+ onSelect: onSelect,
240
+ newConfig: newConfig,
241
+ save: save,
242
+ saveAsNew: saveAsNew,
243
+ deleteConfig: deleteConfig,
244
+ scheduleAutoSave: scheduleAutoSave,
245
+ takeSnapshot: takeSnapshot,
246
+ checkDirty: checkDirty,
247
+ getCurrentId: function() { return _currentId; },
248
+ setCurrentId: function(id) { _currentId = id; },
249
+ };
250
+ }
251
+ """
252
+
253
+
254
+ def config_ui_script() -> str:
255
+ """Return the shared config bar JavaScript for injection into plugin UI fragments."""
256
+ return CONFIG_UI_JS
@@ -0,0 +1,221 @@
1
+ # Copyright 2026 3LC Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Reusable Litestar route handlers for data-source input widgets.
4
+
5
+ Plugins that use the shared ``data_source`` UI component (see
6
+ :mod:`~tlc_plugin_sdk.shared.data_source_ui`) should include these handlers in
7
+ their ``get_route_handlers()`` list so the frontend widget can browse the compute
8
+ node's filesystem and receive file uploads.
9
+
10
+ Usage in a plugin's ``routes.py``::
11
+
12
+ from tlc_plugin_sdk.shared.data_source_routes import data_source_route_handlers
13
+
14
+
15
+ def get_route_handlers():
16
+ handlers = [my_handler_1, my_handler_2, ...]
17
+ handlers.extend(data_source_route_handlers())
18
+ return handlers
19
+
20
+ The routes are:
21
+
22
+ ``GET /browse?path=<dir>&glob=<pattern>&show_hidden=false``
23
+ List files and directories visible on the compute node, **confined to the
24
+ allowed roots**. An empty or ``~`` path opens the first root.
25
+
26
+ ``POST /upload-temp`` (multipart ``data`` field)
27
+ Upload a file from the browser to a fresh private temp directory on the
28
+ compute node and return the server-side path.
29
+
30
+ Confinement — these routes expose the compute node's filesystem to every
31
+ authenticated hub user, so what they can reach is an operator decision, not the
32
+ caller's:
33
+
34
+ - ``/browse`` only lists paths inside the roots named by ``TLC_DATA_SOURCE_ROOTS``
35
+ (``os.pathsep``-separated directories; default: the service user's home). Every
36
+ requested path is ``realpath``-resolved before the containment check, so ``..``
37
+ segments and symlinks pointing outside a root are denied rather than followed.
38
+ - ``/upload-temp`` ignores directory components of the client-supplied filename
39
+ (a ``../``-laden name cannot escape the upload dir) and rejects bodies larger
40
+ than ``TLC_DATA_SOURCE_MAX_UPLOAD_MB`` (default ``512``).
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import logging
46
+ import os
47
+ from typing import TYPE_CHECKING, Annotated, Any
48
+
49
+ from litestar import get, post
50
+ from litestar.datastructures import UploadFile
51
+ from litestar.enums import RequestEncodingType
52
+ from litestar.params import Body
53
+
54
+ if TYPE_CHECKING:
55
+ from litestar import Request
56
+ from litestar.handlers import BaseRouteHandler
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ #: Directories ``/browse`` may list, ``os.pathsep``-separated. Default: the user's home.
61
+ ROOTS_ENV = "TLC_DATA_SOURCE_ROOTS"
62
+
63
+ #: Upload size ceiling in MB for ``/upload-temp``. Default: :data:`DEFAULT_MAX_UPLOAD_MB`.
64
+ MAX_UPLOAD_MB_ENV = "TLC_DATA_SOURCE_MAX_UPLOAD_MB"
65
+
66
+ DEFAULT_MAX_UPLOAD_MB = 512
67
+
68
+
69
+ def allowed_browse_roots() -> list[str]:
70
+ """Return the directories ``/browse`` may list, resolved and existing.
71
+
72
+ Read from :data:`ROOTS_ENV` (``os.pathsep``-separated). Entries are
73
+ tilde-expanded and ``realpath``-resolved; ones that are not existing
74
+ directories are dropped. Falls back to the user's home directory when the
75
+ variable is unset or nothing survives — a picker with zero roots is just a
76
+ broken picker, and home is the least surprising default.
77
+
78
+ Returns:
79
+ Resolved root directories, first one being the default browse target.
80
+
81
+ """
82
+ raw = os.environ.get(ROOTS_ENV, "")
83
+ candidates = [c for c in (part.strip() for part in raw.split(os.pathsep)) if c] or ["~"]
84
+ roots: list[str] = []
85
+ for candidate in candidates:
86
+ resolved = os.path.realpath(os.path.expanduser(candidate))
87
+ if os.path.isdir(resolved) and resolved not in roots:
88
+ roots.append(resolved)
89
+ return roots or [os.path.realpath(os.path.expanduser("~"))]
90
+
91
+
92
+ def _confine_to_roots(path: str, roots: list[str]) -> str | None:
93
+ """Resolve ``path`` and return it when it lies inside one of ``roots``, else ``None``.
94
+
95
+ ``realpath`` first, contain second: a ``..`` segment or a symlink that points
96
+ outside a root resolves to its real target and is then denied, rather than the
97
+ literal path passing a prefix test it does not deserve.
98
+ """
99
+ resolved = os.path.realpath(os.path.expanduser(path))
100
+ for root in roots:
101
+ if resolved == root or resolved.startswith(root + os.sep):
102
+ return resolved
103
+ return None
104
+
105
+
106
+ def _max_upload_mb() -> int:
107
+ """The upload ceiling in MB — :data:`MAX_UPLOAD_MB_ENV`, else the default."""
108
+ try:
109
+ value = int(os.environ.get(MAX_UPLOAD_MB_ENV, ""))
110
+ except ValueError:
111
+ value = 0
112
+ return value if value > 0 else DEFAULT_MAX_UPLOAD_MB
113
+
114
+
115
+ def _safe_upload_name(raw: str | None) -> str:
116
+ """Reduce a client-supplied filename to a bare, safe basename.
117
+
118
+ Directory components are the client reaching for a *location*, which is not
119
+ theirs to choose — strip them (both separator flavors, whichever OS serves)
120
+ and refuse the dot names that survive ``basename``.
121
+ """
122
+ name = os.path.basename((raw or "").replace("\\", "/").strip())
123
+ if name in ("", ".", ".."):
124
+ return "upload"
125
+ return name
126
+
127
+
128
+ def data_source_route_handlers() -> list[BaseRouteHandler]:
129
+ """Return browse + upload-temp route handlers for the data-source widget."""
130
+
131
+ @get("/browse", sync_to_thread=True)
132
+ def browse_filesystem(request: Request[Any, Any, Any]) -> dict[str, Any]:
133
+ """List files and directories at a compute-node path inside the allowed roots.
134
+
135
+ Query params:
136
+ path: Directory to list. Empty or ``~`` opens the first allowed root.
137
+ glob: Optional glob pattern to filter files (e.g. ``*.yaml``).
138
+ show_hidden: Whether to include dotfiles (default ``false``).
139
+ """
140
+ import fnmatch
141
+
142
+ roots = allowed_browse_roots()
143
+ raw_path = request.query_params.get("path", "").strip()
144
+ glob_pattern = request.query_params.get("glob", "")
145
+ show_hidden = request.query_params.get("show_hidden", "false").lower() == "true"
146
+
147
+ if not raw_path or raw_path == "~":
148
+ expanded = roots[0]
149
+ else:
150
+ candidate = os.path.expanduser(raw_path)
151
+ if not os.path.isabs(candidate):
152
+ return {"error": f"Path must be absolute (got {candidate!r})."}
153
+ confined = _confine_to_roots(candidate, roots)
154
+ if confined is None:
155
+ return {"error": f"Path is outside the allowed data-source roots: {candidate}"}
156
+ expanded = confined
157
+
158
+ if not os.path.exists(expanded):
159
+ return {"error": f"Path does not exist: {expanded}"}
160
+ if not os.path.isdir(expanded):
161
+ return {"error": f"Not a directory: {expanded}"}
162
+
163
+ active_root = next(r for r in roots if expanded == r or expanded.startswith(r + os.sep))
164
+
165
+ entries: list[dict[str, Any]] = []
166
+ try:
167
+ for entry in sorted(os.scandir(expanded), key=lambda e: (not e.is_dir(), e.name.lower())):
168
+ if not show_hidden and entry.name.startswith("."):
169
+ continue
170
+ if glob_pattern and not entry.is_dir() and not fnmatch.fnmatch(entry.name, glob_pattern):
171
+ continue
172
+ try:
173
+ stat = entry.stat()
174
+ entries.append({
175
+ "name": entry.name,
176
+ "type": "dir" if entry.is_dir() else "file",
177
+ "size": stat.st_size if entry.is_file() else None,
178
+ })
179
+ except OSError:
180
+ continue
181
+ except PermissionError:
182
+ return {"error": f"Permission denied: {expanded}"}
183
+
184
+ return {
185
+ "path": expanded,
186
+ # Clamped at the containing root: "up" from a root is not a place this
187
+ # widget can go, so the UI gets no parent to offer.
188
+ "parent": os.path.dirname(expanded) if expanded != active_root else None,
189
+ "root": active_root,
190
+ "roots": roots,
191
+ "entries": entries,
192
+ }
193
+
194
+ @post("/upload-temp", status_code=200)
195
+ async def upload_temp(
196
+ data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)],
197
+ ) -> dict[str, Any]:
198
+ """Upload a file to a fresh temp directory and return the server-side path.
199
+
200
+ Used by data-source widgets that need to transfer a file from the
201
+ browser to the compute node's filesystem. Each upload lands in its own
202
+ ``mkdtemp`` directory (mode 0700), so concurrent uploads of the same
203
+ filename never clobber each other.
204
+ """
205
+ import tempfile
206
+ from pathlib import Path
207
+
208
+ max_mb = _max_upload_mb()
209
+ file_bytes = await data.read()
210
+ if len(file_bytes) > max_mb * 1024 * 1024:
211
+ return {"error": f"Upload is larger than the {max_mb} MB limit; set {MAX_UPLOAD_MB_ENV} to raise it."}
212
+ filename = _safe_upload_name(data.filename)
213
+
214
+ tmp_base = Path(tempfile.gettempdir()) / "tlc-uploads"
215
+ tmp_base.mkdir(exist_ok=True)
216
+ dest = Path(tempfile.mkdtemp(dir=tmp_base)) / filename
217
+ dest.write_bytes(file_bytes)
218
+
219
+ return {"path": str(dest), "filename": filename}
220
+
221
+ return [browse_filesystem, upload_temp]