Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/HOST_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Hosts: **Napari** (dock widget), **Fiji** (SciJava dialog, Groovy script or jar)
| `Optional` / "set" box | yes | yes ("Set <name>") | yes |
| `Group` headings, `Advanced` section | yes (checkbox toggles it) | headings in the dialog | heading, folded section |
| `Collapsed` (accordion) | yes (click the heading) | plain heading (SciJava cannot fold) | yes (titled pane) |
| `Widget("slider")` / `Widget("radio")` | planned | planned | planned |
| `Widget("slider")` / `Widget("radio")` | `FloatSlider`, `Slider`, radio buttons | slider and radio styles | slider beside the box, radio buttons |
| `Copy as command` (host feature, no manifest change) | planned | planned | planned |
| `EnabledWhen` | greys out | ignored: every field stays editable | greys out |
| `PixelSizeOf` | prefilled from the layer | prefilled from the calibration | prefilled from the image |
| `ChoicesFrom` (dropdown) | yes, updates when its inputs change | yes, from the previous run's values; text field the first time | yes, updates when its inputs change |
Expand All @@ -27,3 +28,12 @@ Hosts: **Napari** (dock widget), **Fiji** (SciJava dialog, Groovy script or jar)
Not yet in any host (see the roadmap): shapes outputs, run on the selected region, tracks, slider and radio styles, time-lapse and 3D axes, figure output, Repeat, batch.

How this table is kept honest: each row has a test in the host's own suite that runs the example app (`labconstrictor_tools.examples.interactions`); a change that breaks a row fails that suite.

## Copy as command: the format every host produces

Reference implementation: `labconstrictor_tools.command` (`command_line`, `python_snippet`). Fiji and QuPath build the same text.

- **Terminal line:** `<app python> -m labconstrictor_tools run <App> <tool id> name=value ...`, one `name=value` per parameter that is set (unset optional parameters are left out), booleans as `true`/`false`, each argument quoted for the person's shell (POSIX quoting, or double quotes on Windows).
- **Images, labels, tables, files:** a layer or window cannot be named on a command line, so each required one gets a placeholder (`image=image.tif`) and the text starts with `# replace the file for: image`. Where a host knows the file behind the input, it uses that path.
- **Python snippet:** `client.run_once("<App>", "<tool id>", {...})` with the same values as Python literals, then a print of the status and results.
- Test: the copied line and the copied snippet, run for real, give the same values as the form (`tests/test_command.py`).
81 changes: 81 additions & 0 deletions labconstrictor_tools/command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Copy as command: what a host puts on the clipboard so a form can be repeated outside it.

A terminal line (`labconstrictor_tools run <App> <tool> name=value ...`) and a Python snippet (`client.run_once`).
Images, labels and tables cannot be named by a layer or a window, so each gets a placeholder path and the text says so.
Parameters that are unset (None) are left out, exactly as the host leaves them out of the request.
Fiji and QuPath build the same text in Groovy (docs/HOST_FEATURES.md lists the format); this module is the reference.
"""

from __future__ import annotations

import os
import shlex
import sys
from typing import Any

FILE_TYPES = ("image", "labels", "table", "file", "folder")
PLACEHOLDER = {"image": "image.tif", "labels": "labels.tif", "table": "table.csv", "file": "file", "folder": "folder"}


def _text(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)


def _quote(text: str, windows: bool) -> str:
if windows:
if text and all(c.isalnum() or c in "-_.:/\\=+," for c in text):
return text
return '"' + text.replace('"', '\\"') + '"'
return shlex.quote(text)


def _given(tool: dict, values: dict) -> list[tuple[dict, Any]]:
"""The inputs that are set, in the tool's order; file-like inputs without a path get their placeholder."""
out = []
for p in tool["inputs"]:
value = values.get(p["name"])
if value is None:
if p["type"] in FILE_TYPES and p.get("required"):
value = PLACEHOLDER[p["type"]]
else:
continue
out.append((p, value))
return out


def placeholders(tool: dict, values: dict) -> list[str]:
"""Names of the required file-like inputs that have no path in `values` (the person must fill these in)."""
return [p["name"] for p in tool["inputs"] if p["type"] in FILE_TYPES and p.get("required") and values.get(p["name"]) is None]


def command_line(app: str, tool: dict, values: dict, python: str = "python", windows: bool | None = None) -> str:
"""`python -m labconstrictor_tools run App tool name=value ...`, quoted for the shell."""
windows = (os.name == "nt") if windows is None else windows
parts = [python, "-m", "labconstrictor_tools", "run", app, tool["id"]]
parts = [_quote(x, windows) for x in parts]
for p, value in _given(tool, values):
parts.append(_quote("%s=%s" % (p["name"], _text(value)), windows))
note = placeholders(tool, values)
line = " ".join(parts)
if note:
line = "# replace the file for: %s\n%s" % (", ".join(note), line)
return line


def python_snippet(app: str, tool: dict, values: dict) -> str:
"""Python that runs the tool once in its own environment's worker and prints the results."""
given = _given(tool, values)
body = "{\n" + "".join(" %r: %r,\n" % (p["name"], v) for p, v in given) + "}" if given else "{}"
note = placeholders(tool, values)
lines = []
if note:
lines.append("# replace the file for: %s" % ", ".join(note))
lines += [
"from labconstrictor_tools import client",
"",
"task = client.run_once(%r, %r, %s)" % (app, tool["id"], body),
'print(task.status, task.outputs if task.status == "COMPLETE" else task.error)',
]
return "\n".join(lines)
87 changes: 87 additions & 0 deletions tests/test_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Copy as command: the copied terminal line and the copied Python snippet, run for real, give the same values as the form."""

import json
import os
import shlex
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

import _paths # noqa: F401 (must come first)
from _paths import GENERIC_PREFIX, SYNTHETIC_MODULE, V3

from labconstrictor_tools import command
from labconstrictor_tools.introspection import describe_tools

ENV_BASE = {**os.environ, "PYTHONPATH": str(V3), "LC_APPS_PATH": ""}


def tool_schema(tool_id):
__import__(SYNTHETIC_MODULE) # declaring the tools registers them
schema = describe_tools(SYNTHETIC_MODULE)
return next(t for t in schema["tools"] if t["id"] == tool_id)


class Text(unittest.TestCase):
def test_unset_values_are_left_out_and_files_get_a_placeholder(self):
tool = tool_schema("kitchen_sink")
line = command.command_line("App", tool, {"required_string": "hi", "optional_string": None, "count": 4}, windows=False)
self.assertIn("run App kitchen_sink", line)
self.assertIn("image=image.tif", line) # required image: a placeholder, and the line says so
self.assertIn("# replace the file for: image", line)
self.assertNotIn("optional_string", line)
self.assertNotIn("optional_image", line)
self.assertIn("count=4", line)

def test_booleans_are_lowercase_and_spaces_are_quoted_for_each_shell(self):
tool = tool_schema("kitchen_sink")
values = {"required_string": "two words", "image": "a b.tif", "flag": False}
posix = command.command_line("My App", tool, values, windows=False)
windows = command.command_line("My App", tool, values, windows=True)
self.assertIn("flag=false", posix)
self.assertIn("'required_string=two words'", posix)
self.assertIn('"required_string=two words"', windows)
self.assertIn('"My App"', windows)

def test_the_snippet_is_valid_python_with_real_booleans(self):
tool = tool_schema("kitchen_sink")
text = command.python_snippet("App", tool, {"required_string": 'q"uote', "image": "x.tif", "flag": True})
compile(text, "snippet", "exec")
self.assertIn("'flag': True", text)


class RunForReal(unittest.TestCase):
"""What the form held, what the copied text says, and what the tool received are the same."""

def setUp(self):
self.home = Path(tempfile.mkdtemp(prefix="lchome_cmd_"))
self.env = {**ENV_BASE, "LC_HOME": str(self.home)}
done = subprocess.run(
[sys.executable, "-m", "labconstrictor_tools", "register", "--name", "synthetic", "--prefix", str(GENERIC_PREFIX),
"--module", SYNTHETIC_MODULE, "--version", "1"],
capture_output=True, text=True, env=self.env,
) # fmt: skip
self.assertEqual(done.returncode, 0, done.stderr)

def test_terminal_line_gives_the_same_values(self):
tool = tool_schema("unicode_echo")
line = command.command_line("synthetic", tool, {"text": "µm → ok 'q'"}, python=sys.executable, windows=False)
done = subprocess.run(shlex.split(line), capture_output=True, text=True, env=self.env, encoding="utf-8")
self.assertEqual(done.returncode, 0, done.stderr)
report = json.loads(done.stdout[: done.stdout.index("\n(results")] if "\n(results" in done.stdout else done.stdout)
self.assertEqual(report["status"], "COMPLETE")
self.assertEqual(json.dumps(report["results"], ensure_ascii=False).count("µm → ok 'q'"), 1)

def test_python_snippet_gives_the_same_values(self):
tool = tool_schema("scalar_echo")
text = command.python_snippet("synthetic", tool, {"a": 1.5, "b": 2.25})
done = subprocess.run([sys.executable, "-c", text], capture_output=True, text=True, env=self.env)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertIn("COMPLETE", done.stdout)
self.assertIn("3.75", done.stdout)


if __name__ == "__main__":
unittest.main()
Loading