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