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,160 @@
|
|
|
1
|
+
# Copyright 2026 3LC Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Shared utility for saving and copying model checkpoints to Run folders.
|
|
4
|
+
|
|
5
|
+
Handles both local filesystem and cloud storage (S3, GCS, Azure) via the
|
|
6
|
+
``tlc.Url`` abstraction. The compute service is assumed to have write access
|
|
7
|
+
to the Run folder location.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_local_url(url_str: str) -> bool:
|
|
22
|
+
"""Check if a URL points to the local filesystem (not cloud storage)."""
|
|
23
|
+
if "://" not in url_str:
|
|
24
|
+
return True
|
|
25
|
+
scheme = url_str.split("://", 1)[0].lower()
|
|
26
|
+
return scheme in ("file", "")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_to_local_path(url_str: str) -> Path | None:
|
|
30
|
+
"""Resolve a 3LC URL to a local filesystem path, or None if cloud."""
|
|
31
|
+
import tlc
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
abs_url = tlc.Url(url_str).to_absolute()
|
|
35
|
+
url_s = str(abs_url)
|
|
36
|
+
if _is_local_url(url_s):
|
|
37
|
+
# Strip file:// scheme if present
|
|
38
|
+
path = url_s.replace("file://", "")
|
|
39
|
+
return Path(path)
|
|
40
|
+
except Exception:
|
|
41
|
+
logger.debug("Could not resolve model URL via tlc.Url: %s", url_str)
|
|
42
|
+
# Try direct path interpretation
|
|
43
|
+
if _is_local_url(url_str):
|
|
44
|
+
return Path(url_str)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_model_to_run(
|
|
49
|
+
run_url: str,
|
|
50
|
+
model_data: Any,
|
|
51
|
+
filename: str = "best_model.pt",
|
|
52
|
+
source_file: str | Path | None = None,
|
|
53
|
+
on_status: Any = None,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Save a model checkpoint to a Run's ``model/`` subdirectory.
|
|
56
|
+
|
|
57
|
+
Supports both local and cloud (S3/GCS/Azure) run folders. For cloud storage,
|
|
58
|
+
the file is first written to a temp directory, then uploaded via ``tlc.Url``.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
run_url: The 3LC Run URL (local path or cloud URL).
|
|
62
|
+
model_data: PyTorch state_dict to save (ignored if ``source_file`` is set).
|
|
63
|
+
filename: Name for the model file in the run folder.
|
|
64
|
+
source_file: If set, copy this existing file instead of saving ``model_data``.
|
|
65
|
+
on_status: Optional callback for status messages.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The relative path to the saved model file (e.g. ``model/best.pt``).
|
|
69
|
+
This is relative to the run folder so it survives run renames.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
RuntimeError: If the model could not be saved.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
import tlc
|
|
76
|
+
|
|
77
|
+
if on_status is None:
|
|
78
|
+
on_status = lambda m: None # noqa: E731
|
|
79
|
+
|
|
80
|
+
relative_path = f"model/{filename}"
|
|
81
|
+
|
|
82
|
+
local_path = _resolve_to_local_path(run_url)
|
|
83
|
+
|
|
84
|
+
if local_path is not None:
|
|
85
|
+
# ── Local filesystem ──
|
|
86
|
+
model_dir = local_path / "model"
|
|
87
|
+
model_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
dest = model_dir / filename
|
|
89
|
+
|
|
90
|
+
if source_file is not None:
|
|
91
|
+
shutil.copy2(str(source_file), str(dest))
|
|
92
|
+
on_status(f"Copied model to run: {dest}")
|
|
93
|
+
else:
|
|
94
|
+
import torch
|
|
95
|
+
|
|
96
|
+
torch.save(model_data, str(dest))
|
|
97
|
+
on_status(f"Saved model to run: {dest}")
|
|
98
|
+
|
|
99
|
+
return relative_path
|
|
100
|
+
|
|
101
|
+
# ── Cloud storage (S3/GCS/Azure) ──
|
|
102
|
+
on_status("Run is on cloud storage, uploading model...")
|
|
103
|
+
run_abs = tlc.Url(run_url).to_absolute()
|
|
104
|
+
model_url = run_abs / "model" / filename
|
|
105
|
+
|
|
106
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
107
|
+
tmp_path = Path(tmp_dir) / filename
|
|
108
|
+
|
|
109
|
+
if source_file is not None:
|
|
110
|
+
shutil.copy2(str(source_file), str(tmp_path))
|
|
111
|
+
else:
|
|
112
|
+
import torch
|
|
113
|
+
|
|
114
|
+
torch.save(model_data, str(tmp_path))
|
|
115
|
+
|
|
116
|
+
# Upload using tlc.Url write facilities
|
|
117
|
+
try:
|
|
118
|
+
model_url.write_file(str(tmp_path))
|
|
119
|
+
on_status(f"Uploaded model to: {model_url}")
|
|
120
|
+
except AttributeError:
|
|
121
|
+
# Fallback: use the Url's native copy mechanism
|
|
122
|
+
src_url = tlc.Url(str(tmp_path))
|
|
123
|
+
src_url.copy_to(model_url)
|
|
124
|
+
on_status(f"Copied model to cloud: {model_url}")
|
|
125
|
+
|
|
126
|
+
return relative_path
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def store_model_info_in_run(
|
|
130
|
+
run: Any,
|
|
131
|
+
model_name: str,
|
|
132
|
+
model_path: str,
|
|
133
|
+
source_url: str = "",
|
|
134
|
+
on_status: Any = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Store model metadata in a Run's parameters.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
run: The ``tlc.Run`` object.
|
|
140
|
+
model_name: Model architecture name (e.g. ``yolov8n.pt``, ``resnet50``).
|
|
141
|
+
model_path: Path/URL to the saved model checkpoint.
|
|
142
|
+
source_url: Original pretrained model URL (if fine-tuning).
|
|
143
|
+
on_status: Optional callback for status messages.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
if on_status is None:
|
|
147
|
+
on_status = lambda m: None # noqa: E731
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
model_info: dict[str, str] = {
|
|
151
|
+
"model_name": model_name,
|
|
152
|
+
"model_path": model_path,
|
|
153
|
+
}
|
|
154
|
+
if source_url:
|
|
155
|
+
model_info["model_source"] = source_url
|
|
156
|
+
run.set_parameters(model_info)
|
|
157
|
+
on_status(f"Stored model info in Run: {model_name} → {model_path}")
|
|
158
|
+
except Exception as e:
|
|
159
|
+
on_status(f"Warning: could not store model info in Run: {e}")
|
|
160
|
+
logger.warning("Failed to store model info in Run: %s", e)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright 2026 3LC Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Random name generator for runs, jobs, and other entities.
|
|
4
|
+
|
|
5
|
+
Produces memorable names from data science and computer vision vocabulary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
|
|
12
|
+
_ADJECTIVES = [
|
|
13
|
+
"robust",
|
|
14
|
+
"sparse",
|
|
15
|
+
"dense",
|
|
16
|
+
"latent",
|
|
17
|
+
"frozen",
|
|
18
|
+
"residual",
|
|
19
|
+
"pooled",
|
|
20
|
+
"stacked",
|
|
21
|
+
"gated",
|
|
22
|
+
"masked",
|
|
23
|
+
"dilated",
|
|
24
|
+
"pretrained",
|
|
25
|
+
"finetuned",
|
|
26
|
+
"attentive",
|
|
27
|
+
"contrastive",
|
|
28
|
+
"recurrent",
|
|
29
|
+
"augmented",
|
|
30
|
+
"calibrated",
|
|
31
|
+
"distilled",
|
|
32
|
+
"quantized",
|
|
33
|
+
"pruned",
|
|
34
|
+
"anchored",
|
|
35
|
+
"focal",
|
|
36
|
+
"semantic",
|
|
37
|
+
"adaptive",
|
|
38
|
+
"causal",
|
|
39
|
+
"spectral",
|
|
40
|
+
"variational",
|
|
41
|
+
"embedded",
|
|
42
|
+
"strided",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
_NOUNS = [
|
|
46
|
+
"gradient",
|
|
47
|
+
"tensor",
|
|
48
|
+
"kernel",
|
|
49
|
+
"encoder",
|
|
50
|
+
"decoder",
|
|
51
|
+
"backbone",
|
|
52
|
+
"embedding",
|
|
53
|
+
"bottleneck",
|
|
54
|
+
"convolution",
|
|
55
|
+
"attention",
|
|
56
|
+
"dropout",
|
|
57
|
+
"batchnorm",
|
|
58
|
+
"activation",
|
|
59
|
+
"classifier",
|
|
60
|
+
"regressor",
|
|
61
|
+
"detector",
|
|
62
|
+
"segmenter",
|
|
63
|
+
"feature",
|
|
64
|
+
"logit",
|
|
65
|
+
"softmax",
|
|
66
|
+
"perceptron",
|
|
67
|
+
"transformer",
|
|
68
|
+
"diffusion",
|
|
69
|
+
"manifold",
|
|
70
|
+
"centroid",
|
|
71
|
+
"heatmap",
|
|
72
|
+
"mosaic",
|
|
73
|
+
"anchor",
|
|
74
|
+
"pipeline",
|
|
75
|
+
"checkpoint",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generate_name() -> str:
|
|
80
|
+
"""Generate a memorable name like ``robust-gradient-42``."""
|
|
81
|
+
adj = random.choice(_ADJECTIVES)
|
|
82
|
+
noun = random.choice(_NOUNS)
|
|
83
|
+
num = random.randint(10, 99)
|
|
84
|
+
return f"{adj}-{noun}-{num}"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright 2026 3LC Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Robustly inject shared client scripts into a plugin UI fragment.
|
|
4
|
+
|
|
5
|
+
Plugins prepend shared client JS — ``window.PluginJobs`` (job tracking), the alias
|
|
6
|
+
pickers, the config form — into their ``ui.html`` fragment so it runs before the
|
|
7
|
+
fragment's own handlers. The historical ``html.replace("<script>", …, 1)`` idiom
|
|
8
|
+
is a footgun: it matches the first ``<script>`` *substring* anywhere in the file,
|
|
9
|
+
including one mentioned in an HTML comment or a string, and silently injects the
|
|
10
|
+
client into that wrong spot (or nowhere useful) with **no error** — the symptom is
|
|
11
|
+
an ``undefined`` global at click time, far from the cause.
|
|
12
|
+
|
|
13
|
+
:func:`inject_scripts` targets the first real, inline ``<script>`` *tag* (ignoring
|
|
14
|
+
comments and ``src``-ed scripts) and raises if there is none, so a missing or
|
|
15
|
+
renamed tag fails loudly instead of dropping the client.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
# Scan for <script ...> opening tags, then exclude any that fall inside an HTML
|
|
23
|
+
# comment so a "<script>" written in the fragment's own docs can't be matched.
|
|
24
|
+
_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
25
|
+
_SCRIPT_OPEN_RE = re.compile(r"<script\b[^>]*>", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def inject_scripts(html: str, *scripts: str) -> str:
|
|
29
|
+
"""Prepend client ``scripts`` inside the fragment's first real ``<script>`` tag.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
html: The plugin UI fragment.
|
|
33
|
+
scripts: Raw JS bodies (no ``<script>`` tags). Inserted in order, immediately
|
|
34
|
+
after the opening tag of the first inline ``<script>`` element, so the
|
|
35
|
+
fragment's own code can rely on them having run.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
``html`` with the scripts injected (unchanged if ``scripts`` is empty).
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If ``html`` has no real inline ``<script>`` tag to inject into —
|
|
42
|
+
loud by design, so a missing/renamed tag can't silently drop the client.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
if not scripts:
|
|
46
|
+
return html
|
|
47
|
+
|
|
48
|
+
comment_spans = [(m.start(), m.end()) for m in _COMMENT_RE.finditer(html)]
|
|
49
|
+
|
|
50
|
+
def _in_comment(pos: int) -> bool:
|
|
51
|
+
return any(start <= pos < end for start, end in comment_spans)
|
|
52
|
+
|
|
53
|
+
for match in _SCRIPT_OPEN_RE.finditer(html):
|
|
54
|
+
# Skip a <script> mentioned in a comment, and external scripts (a ``src``
|
|
55
|
+
# script ignores inline content, so injecting into it is a silent no-op).
|
|
56
|
+
if _in_comment(match.start()) or "src=" in match.group().lower():
|
|
57
|
+
continue
|
|
58
|
+
insert_at = match.end()
|
|
59
|
+
payload = "\n" + "\n".join(scripts) + "\n"
|
|
60
|
+
return html[:insert_at] + payload + html[insert_at:]
|
|
61
|
+
|
|
62
|
+
msg = "inject_scripts: no inline <script> tag found in the fragment to inject into"
|
|
63
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Copyright 2026 3LC Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""URL normalization utilities for 3LC object URLs.
|
|
4
|
+
|
|
5
|
+
Ensures file-path URLs are absolute before passing to the tlc SDK,
|
|
6
|
+
preventing the CWD from being prepended to relative-looking paths.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_url(url: str) -> str:
|
|
19
|
+
"""Normalize a 3LC URL for use with the tlc SDK.
|
|
20
|
+
|
|
21
|
+
- If the URL is a protocol URL (e.g. api://, s3://, gs://), return as-is.
|
|
22
|
+
- If the URL looks like a file path, expand ``~`` and ensure it's absolute.
|
|
23
|
+
- Handles URL-decoded paths that may have lost their leading slash.
|
|
24
|
+
"""
|
|
25
|
+
if not url:
|
|
26
|
+
return url
|
|
27
|
+
|
|
28
|
+
# Protocol URLs — pass through
|
|
29
|
+
if "://" in url:
|
|
30
|
+
scheme = url.split("://", 1)[0].lower()
|
|
31
|
+
# File paths on macOS/Linux look like /Users/... not a protocol
|
|
32
|
+
if scheme in ("api", "s3", "gs", "http", "https", "3lc"):
|
|
33
|
+
return url
|
|
34
|
+
|
|
35
|
+
# File path — expand tilde, then ensure absolute
|
|
36
|
+
url = os.path.expanduser(url)
|
|
37
|
+
if not os.path.isabs(url):
|
|
38
|
+
# Common case: path like "Users/paul/..." that lost its leading /
|
|
39
|
+
if url.startswith(("Users/", "home/")):
|
|
40
|
+
return "/" + url
|
|
41
|
+
# Project-relative URL (e.g. "tinycoco/runs/demo1") — resolve
|
|
42
|
+
# against the 3LC project root directory.
|
|
43
|
+
try:
|
|
44
|
+
import tlc
|
|
45
|
+
|
|
46
|
+
project_root = str(tlc.config.project_root_url).rstrip("/")
|
|
47
|
+
candidate = os.path.join(project_root, url)
|
|
48
|
+
if os.path.exists(candidate):
|
|
49
|
+
return candidate
|
|
50
|
+
except Exception:
|
|
51
|
+
logger.debug("Could not resolve relative URL against project root: %s", url)
|
|
52
|
+
# Fallback: return as-is and let the SDK try
|
|
53
|
+
return url
|
|
54
|
+
|
|
55
|
+
return url
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_local_path(path: str) -> str:
|
|
59
|
+
"""Normalize a user-typed local filesystem path.
|
|
60
|
+
|
|
61
|
+
Strips whitespace and expands ``~``/``~user``. Plugins run with the plugin
|
|
62
|
+
venv as CWD, so a bare-relative path would silently resolve somewhere no
|
|
63
|
+
user ever looks — reject it instead.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: Raw path string as typed by the user.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The expanded, absolute path.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If the path is empty or not absolute after expansion.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
expanded = os.path.expanduser(path.strip())
|
|
76
|
+
if not expanded:
|
|
77
|
+
msg = "Path is empty."
|
|
78
|
+
raise ValueError(msg)
|
|
79
|
+
if not os.path.isabs(expanded):
|
|
80
|
+
msg = (
|
|
81
|
+
f"Path must be absolute (got {expanded!r}). "
|
|
82
|
+
"Relative paths would resolve against the plugin's working directory, not yours."
|
|
83
|
+
)
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
return expanded
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_url_column_names(table: Any) -> list[str]:
|
|
89
|
+
"""Return the names of a table's URL/path-valued columns.
|
|
90
|
+
|
|
91
|
+
Reads the table's ``_url_columns`` attribute, which is a private 3lc
|
|
92
|
+
attribute that is not part of the typed public API and may be absent
|
|
93
|
+
depending on the 3lc version, so reach it defensively. It can be
|
|
94
|
+
``[['image']]`` (nested) or ``['image']`` (flat).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
table: A ``tlc.Table``.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Flat list of column names; empty if none could be determined.
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
names: list[str] = []
|
|
104
|
+
try:
|
|
105
|
+
for entry in list(getattr(table, "_url_columns", [])):
|
|
106
|
+
if isinstance(entry, list):
|
|
107
|
+
names.extend(str(col) for col in entry)
|
|
108
|
+
else:
|
|
109
|
+
names.append(str(entry))
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.debug("Could not extract URL column names from table", exc_info=True)
|
|
112
|
+
return names
|