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