diff --git a/docs/HOST_FEATURES.md b/docs/HOST_FEATURES.md index 49e85e6..c8b7927 100644 --- a/docs/HOST_FEATURES.md +++ b/docs/HOST_FEATURES.md @@ -8,7 +8,8 @@ Hosts: **Napari** (dock widget), **Fiji** (SciJava dialog, Groovy script or jar) | `Optional` / "set" box | yes | yes ("Set ") | 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 | @@ -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:** ` -m labconstrictor_tools run 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("", "", {...})` 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`). diff --git a/labconstrictor_tools/command.py b/labconstrictor_tools/command.py new file mode 100644 index 0000000..1c4a1f1 --- /dev/null +++ b/labconstrictor_tools/command.py @@ -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 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) diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 0000000..a51b6e3 --- /dev/null +++ b/tests/test_command.py @@ -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()