diff --git a/README.md b/README.md
index a10393a..83da5bd 100644
--- a/README.md
+++ b/README.md
@@ -134,3 +134,44 @@ memory) it holds, is otherwise released when the application exits.
Text printed to stdout and stderr appears in the Script Editor's output
panes. Errors are reported with a Python traceback that refers to the
script's own line numbers.
+
+## Code completion
+
+The Script Editor completes code in these scripts with a Python language
+server, `scijava-code-lsp-jedi`'s:
+[jedi-language-server](https://github.com/pappasam/jedi-language-server)
+(built on [Jedi](https://jedi.readthedocs.io/)). This plugin's
+`ApposePythonDialect` shows it each script as plain Python, and says where
+the script runs (so any other client of `scijava-code-lsp`'s
+`LanguageServerService` gets the same). It completes names in scope,
+the script's `#@` parameters, `task`, builtins and keywords, and members of
+objects and modules; and it gives function signatures (with parameter names
+and types), documentation, hover and syntax errors.
+
+The server runs in a small environment of its own (`code-lsp-jedi`: Python,
+Jedi, jedi-language-server, NumPy and uv), which is built in the background
+the first time you complete code, and takes a while only then; until it is, there are no completions. The server
+talks to the editor via the
+[Language Server Protocol](https://microsoft.github.io/language-server-protocol/)
+(see `scijava-code-lsp`); it sees the script as plain Python, with its `#@`
+parameters declared. One server runs per script environment; servers
+unused for 10 minutes stop. Their error output goes to
+`code-lsp-jedi-server.log`, next to Appose's environments.
+
+Once a script's environment is built, by running the script (in this session
+or an earlier one), the server analyzes the script against it, so that the packages
+installed there are known too. Packages the script declares (in its
+`# /// script` block) but its environment lacks, e.g. a dependency you just
+added, are installed for completion only, into a separate directory, a moment
+after you stop editing the dependencies; so they are known before you run the
+script. (`scijava-code-lsp-jedi` does this, for any Python script.) This
+covers PyPI packages; conda packages (`[tool.pixi.dependencies]`)
+and environments declared in files are known once the script has run.
+Completion never builds a script's environment itself, and never disturbs
+its worker. Script parameters are known by the Python types they have at run
+time, e.g. images as NumPy arrays.
+
+Typing never waits for the server: if its answer for a word is not ready
+within a moment (the first answer for a package can take a few seconds), it
+is shown once ready. Questions that are no longer of interest (e.g. as you
+type on) are cancelled.
diff --git a/pom.xml b/pom.xml
index 9977f03..47cd289 100644
--- a/pom.xml
+++ b/pom.xml
@@ -92,8 +92,12 @@
0.12.0
0.9.0
-
+
2.101.0
+
+ 0.1.0-SNAPSHOT
+ 0.1.0-SNAPSHOT
@@ -106,6 +110,18 @@
org.scijava
script-editor
+
+ org.scijava
+ scijava-code-lsp
+ ${scijava-code-lsp.version}
+
+
+ org.scijava
+ scijava-code-lsp-jedi
+ ${scijava-code-lsp-jedi.version}
+
+ test
+
diff --git a/src/main/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngine.java b/src/main/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngine.java
index afc02f2..4905008 100644
--- a/src/main/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngine.java
+++ b/src/main/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngine.java
@@ -37,7 +37,6 @@
import java.io.UncheckedIOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
@@ -58,7 +57,7 @@
import org.apposed.appose.Appose;
import org.apposed.appose.BuildException;
-import org.apposed.appose.Builder;
+import org.apposed.appose.Environment;
import org.apposed.appose.NDArray;
import org.apposed.appose.Service;
import org.apposed.appose.Service.TaskStatus;
@@ -70,7 +69,6 @@
import org.scijava.log.Logger;
import org.scijava.module.ModuleItem;
import org.scijava.plugin.Parameter;
-import org.scijava.plugins.scripting.appose.python._internal.InlineMetadata;
import org.scijava.plugins.scripting.appose.python._internal.ResidentWorker;
import org.scijava.plugins.scripting.appose.python._internal.ResidentWorkerService;
import org.scijava.plugins.scripting.appose.python._internal.SciJavaTasks;
@@ -440,112 +438,32 @@ static boolean ownsNDArray(final Object value, final NDArray nd) {
}
/**
- * Gets the resident worker for the script's Appose environment, which the
- * script declares either inline, via a PEP 723 {@code # /// script}
- * metadata block, or in a file named by the {@code env} (and optionally
- * {@code scheme}) attributes of its {@code #@script} directive. The
- * environment itself is built lazily, by the worker's first task.
+ * Gets the resident worker for the script's Appose environment (see
+ * {@link ScriptEnvironment}). The environment itself is built lazily, by
+ * the worker's first task.
*/
private ResidentWorker worker(final ScriptInfo info, final String script)
throws ScriptException
{
if (info == null) throw noEnvironment();
- final String envRef = info.get("env");
- final String metadata;
+ final ScriptEnvironment env;
try {
- metadata = InlineMetadata.extract(script);
+ env = ScriptEnvironment.of(script, info.getPath(), info.get("env"), info
+ .get("scheme"));
}
- catch (final IllegalArgumentException e) {
+ catch (final IllegalArgumentException | IOException e) {
throw scriptException(e.getMessage(), e);
}
- if (metadata != null) {
- if (envRef != null) {
- throw new ScriptException("The script declares its environment " +
- "twice: inline, and via #@script(env=\"" + envRef + "\"). " +
- "Remove one of them.");
- }
- return inlineWorker(metadata, info.getPath());
- }
- if (envRef == null) throw noEnvironment();
-
- final File envFile = resolveEnvFile(envRef, info.getPath());
- if (!envFile.isFile()) {
- throw new ScriptException("Appose environment file not found: " +
- envFile.getAbsolutePath());
- }
- final String content;
- try {
- content = new String(Files.readAllBytes(envFile.toPath()),
- StandardCharsets.UTF_8);
- }
- catch (final IOException e) {
- throw scriptException("Cannot read Appose environment file: " +
- envFile.getAbsolutePath(), e);
- }
- return worker(envName(envFile), content, info.get("scheme"));
+ if (env == null) throw noEnvironment();
+ return env.worker(workerService);
}
- /** Gets the resident worker for an environment declared inline. */
- private ResidentWorker inlineWorker(final String metadata,
- final String scriptPath) throws ScriptException
- {
- // Note: Name the environment after the script, as for environment
- // files, so that editing the metadata updates the environment and
- // replaces its worker, rather than leaving the old one running.
- // Unsaved scripts, e.g. new from a template, have no path to go by.
- final String envName = scriptPath == null ? "script-" + String.format(
- "%08x", metadata.hashCode()) : envName(new File(scriptPath));
- final String content;
- try {
- content = InlineMetadata.toPyProject(metadata, envName);
- }
- catch (final IllegalArgumentException e) {
- throw scriptException(e.getMessage(), e);
- }
- return worker(envName, content, InlineMetadata.SCHEME);
- }
-
- /**
- * Gets the resident worker for the named environment, built from the given
- * configuration.
- */
- private ResidentWorker worker(final String envName, final String content,
- final String scheme)
- {
- return workerService.worker(envName, scheme + "\n" + content, () -> {
- final Builder> builder = Appose.content(content);
- return scheme == null ? builder : builder.scheme(scheme);
- },
- // Note: On Windows, importing numpy from a task hangs unless numpy
- // was imported during worker initialization.
- env -> env.python().init(
- "try:\n import numpy\nexcept ImportError:\n pass\n"));
- }
-
- /** Resolves an (optionally relative) env file path against the script path. */
- static File resolveEnvFile(final String envRef, final String scriptPath) {
- final File envFile = new File(envRef);
- if (envFile.isAbsolute()) return envFile;
- if (scriptPath != null) {
- final File parent = new File(scriptPath).getAbsoluteFile().getParentFile();
- if (parent != null) return new File(parent, envRef);
- }
- return envFile;
- }
-
- /**
- * Derives a valid Appose environment name from an environment file: its
- * base name, plus a hash of its absolute path to avoid collisions between
- * like-named files in different directories. E.g.:
- * {@code /path/to/cellcast.toml} becomes {@code cellcast-1a2b3c4d}.
- */
- static String envName(final File envFile) {
- final String fileName = envFile.getName();
- final int dot = fileName.lastIndexOf('.');
- final String baseName = dot > 0 ? fileName.substring(0, dot) : fileName;
- final String path = envFile.getAbsoluteFile().toPath().normalize().toString();
- return baseName.replaceAll("[^a-zA-Z0-9_-]", "_") + "-" +
- String.format("%08x", path.hashCode());
+ /** Creates the service of a worker in the given (built) environment. */
+ static Service launch(final Environment env) {
+ // Note: On Windows, importing numpy from a task hangs unless numpy
+ // was imported during worker initialization.
+ return env.python().init(
+ "try:\n import numpy\nexcept ImportError:\n pass\n");
}
private static ScriptException noEnvironment() {
diff --git a/src/main/java/org/scijava/plugins/scripting/appose/python/ScriptEnvironment.java b/src/main/java/org/scijava/plugins/scripting/appose/python/ScriptEnvironment.java
new file mode 100644
index 0000000..dbb5b1d
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/appose/python/ScriptEnvironment.java
@@ -0,0 +1,222 @@
+/*
+ * #%L
+ * Python scripting language plugin backed by Appose.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.plugins.scripting.appose.python;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apposed.appose.Appose;
+import org.apposed.appose.Builder;
+import org.scijava.plugins.scripting.appose.python._internal.InlineMetadata;
+import org.scijava.plugins.scripting.appose.python._internal.ResidentWorker;
+import org.scijava.plugins.scripting.appose.python._internal.ResidentWorkerService;
+
+/**
+ * The Appose environment a script declares: either inline, via a PEP 723
+ * {@code # /// script} metadata block, or in a file named by the {@code env}
+ * (and optionally {@code scheme}) attributes of its {@code #@script}
+ * directive.
+ *
+ * Both running a script ({@link ApposePythonScriptEngine}) and completing code
+ * in it ({@link
+ * org.scijava.plugins.scripting.appose.python.completion.ApposePythonDialect})
+ * resolve its environment here, so that they agree on the environment's name
+ * and configuration.
+ *
+ */
+public final class ScriptEnvironment {
+
+ /** The {@code #@script(...)} directive. */
+ private static final Pattern SCRIPT_DIRECTIVE = Pattern.compile(
+ "^#@\\s*script\\s*\\((.*)\\)\\s*$", Pattern.MULTILINE);
+
+ /** An attribute of a directive: key="value", key='value' or key=value. */
+ private static final Pattern ATTRIBUTE = Pattern.compile(
+ "(\\w+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^,\\s)]+))");
+
+ /** Name of the environment. */
+ public final String name;
+
+ /** The environment's configuration, e.g. the contents of its file. */
+ final String content;
+
+ /** The configuration's scheme (e.g. {@code pixi.toml}), or null if implied. */
+ final String scheme;
+
+ private ScriptEnvironment(final String name, final String content,
+ final String scheme)
+ {
+ this.name = name;
+ this.content = content;
+ this.scheme = scheme;
+ }
+
+ /**
+ * Resolves the environment declared by a script.
+ *
+ * @param script The script's source code.
+ * @param scriptPath The script's file path, or null if unsaved.
+ * @param envRef The {@code env} attribute of its {@code #@script} directive,
+ * or null.
+ * @param scheme The {@code scheme} attribute of its {@code #@script}
+ * directive, or null.
+ * @return The environment, or null if the script declares none.
+ * @throws IllegalArgumentException If the declaration is invalid, e.g.
+ * malformed, given twice, or naming a missing file.
+ * @throws IOException If the environment file cannot be read.
+ */
+ public static ScriptEnvironment of(final String script,
+ final String scriptPath, final String envRef, final String scheme)
+ throws IOException
+ {
+ final String metadata = InlineMetadata.extract(script);
+ if (metadata != null) {
+ if (envRef != null) {
+ throw new IllegalArgumentException("The script declares its " +
+ "environment twice: inline, and via #@script(env=\"" + envRef +
+ "\"). Remove one of them.");
+ }
+ final String name = inlineName(metadata, scriptPath);
+ return new ScriptEnvironment(name, InlineMetadata.toPyProject(metadata,
+ name), InlineMetadata.SCHEME);
+ }
+ if (envRef == null) return null;
+
+ final File envFile = resolveEnvFile(envRef, scriptPath);
+ if (!envFile.isFile()) {
+ throw new IllegalArgumentException("Appose environment file not " +
+ "found: " + envFile.getAbsolutePath());
+ }
+ final String content;
+ try {
+ content = new String(Files.readAllBytes(envFile.toPath()),
+ StandardCharsets.UTF_8);
+ }
+ catch (final IOException e) {
+ throw new IOException("Cannot read Appose environment file: " + envFile
+ .getAbsolutePath(), e);
+ }
+ return new ScriptEnvironment(envName(envFile), content, scheme);
+ }
+
+ /**
+ * Gets the name of the environment a script declares inline.
+ *
+ * @param metadata The script's metadata block (see
+ * {@link InlineMetadata#extract}).
+ * @param scriptPath The script's file path, or null if unsaved.
+ */
+ public static String inlineName(final String metadata,
+ final String scriptPath)
+ {
+ // Note: Name the environment after the script, as for environment
+ // files, so that editing the metadata updates the environment and
+ // replaces its worker, rather than leaving the old one running.
+ // Unsaved scripts, e.g. new from a template, have no path to go by.
+ return scriptPath == null ? "script-" + String.format("%08x", metadata
+ .hashCode()) : envName(new File(scriptPath));
+ }
+
+ /**
+ * Gets the attributes of a script's {@code #@script(...)} directive, e.g.
+ * {@code env} and {@code scheme}, without fully parsing its parameters.
+ */
+ public static Map directive(final String script) {
+ final Map attrs = new HashMap<>();
+ final Matcher m = SCRIPT_DIRECTIVE.matcher(script);
+ if (!m.find()) return attrs;
+ final Matcher a = ATTRIBUTE.matcher(m.group(1));
+ while (a.find()) {
+ final String value = a.group(2) != null ? a.group(2) : a.group(3) != null
+ ? a.group(3) : a.group(4);
+ attrs.put(a.group(1), value);
+ }
+ return attrs;
+ }
+
+ /**
+ * The key under which {@link
+ * org.scijava.plugins.scripting.appose.python._internal.ResidentWorkerService}
+ * keeps the environment: changes to it replace the environment's worker.
+ */
+ public String config() {
+ return scheme + "\n" + content;
+ }
+
+ /**
+ * Gets the resident worker of this environment (see
+ * {@link ResidentWorkerService#worker}), as scripts run it: replacing the
+ * environment's worker of an earlier configuration, if any. The
+ * environment is built by the worker's first task, or when asked (see
+ * {@link ResidentWorker#environment()}).
+ */
+ public ResidentWorker worker(final ResidentWorkerService workers) {
+ return workers.worker(name, config(), this::builder,
+ ApposePythonScriptEngine::launch);
+ }
+
+ /** Creates a builder for the environment. */
+ Builder> builder() {
+ final Builder> builder = Appose.content(content);
+ return scheme == null ? builder : builder.scheme(scheme);
+ }
+
+ /** Resolves an (optionally relative) env file path against the script path. */
+ static File resolveEnvFile(final String envRef, final String scriptPath) {
+ final File envFile = new File(envRef);
+ if (envFile.isAbsolute()) return envFile;
+ if (scriptPath != null) {
+ final File parent = new File(scriptPath).getAbsoluteFile().getParentFile();
+ if (parent != null) return new File(parent, envRef);
+ }
+ return envFile;
+ }
+
+ /**
+ * Derives a valid Appose environment name from an environment file: its
+ * base name, plus a hash of its absolute path to avoid collisions between
+ * like-named files in different directories. E.g.:
+ * {@code /path/to/cellcast.toml} becomes {@code cellcast-1a2b3c4d}.
+ */
+ static String envName(final File envFile) {
+ final String fileName = envFile.getName();
+ final int dot = fileName.lastIndexOf('.');
+ final String baseName = dot > 0 ? fileName.substring(0, dot) : fileName;
+ final String path = envFile.getAbsoluteFile().toPath().normalize().toString();
+ return baseName.replaceAll("[^a-zA-Z0-9_-]", "_") + "-" +
+ String.format("%08x", path.hashCode());
+ }
+}
diff --git a/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/InlineMetadata.java b/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/InlineMetadata.java
index 51dc690..38e9b0f 100644
--- a/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/InlineMetadata.java
+++ b/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/InlineMetadata.java
@@ -110,6 +110,34 @@ public static String extract(final String script) {
return content;
}
+ /**
+ * Gets the {@code dependencies} (PEP 508 requirement strings, e.g.
+ * {@code "numpy > 2"}) of a {@code script} metadata block.
+ *
+ * @param metadata TOML content of the metadata block.
+ * @return The dependencies, in declaration order; empty if none.
+ * @throws IllegalArgumentException If the metadata is not valid.
+ */
+ public static List dependencies(final String metadata) {
+ final JsonNode parsed;
+ try {
+ parsed = new TomlMapper().readTree(metadata);
+ }
+ catch (final IOException e) {
+ throw new IllegalArgumentException(
+ "Invalid TOML in script metadata: " + e.getMessage(), e);
+ }
+ final JsonNode deps = parsed == null ? null : parsed.get("dependencies");
+ if (deps == null) return Collections.emptyList();
+ if (!deps.isArray()) throw invalid("dependencies must be an array");
+ final List out = new ArrayList<>();
+ for (final JsonNode dep : deps) {
+ if (!dep.isTextual()) throw invalid("dependencies must be strings");
+ out.add(dep.asText());
+ }
+ return out;
+ }
+
/**
* Translates the TOML content of a {@code script} metadata block into an
* equivalent {@code pyproject.toml}:
diff --git a/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/LazyEnvironment.java b/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/LazyEnvironment.java
index c3f00a5..d7470cb 100644
--- a/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/LazyEnvironment.java
+++ b/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/LazyEnvironment.java
@@ -31,7 +31,11 @@
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
import java.util.concurrent.CopyOnWriteArrayList;
import org.apposed.appose.BuildException;
@@ -45,6 +49,8 @@
* Builds of environments with the same name are serialized, even across
* instances, because they share a directory on disk: e.g. when the
* environment's configuration changes while an older build is still running.
+ * For the same reason, a build may wait for a worker of the environment's
+ * earlier configuration to end, so as not to rebuild beneath it.
*
*/
public class LazyEnvironment {
@@ -53,8 +59,20 @@ public class LazyEnvironment {
private static final Map BUILD_LOCKS =
new ConcurrentHashMap<>();
+ /** The names of the environments being built now. */
+ private static final Set BUILDING = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Whether the named environment is being built now: if so, its files are
+ * not to be used yet (e.g. its Python may exist before its packages do).
+ */
+ public static boolean isBuilding(final String name) {
+ return BUILDING.contains(name);
+ }
+
private final String name;
private final Builder> builder;
+ private final Future> before;
private final List listeners = new CopyOnWriteArrayList<>();
private volatile Environment env;
@@ -65,7 +83,21 @@ public class LazyEnvironment {
* @param builder Builder for the environment; built on first use.
*/
public LazyEnvironment(final String name, final Builder> builder) {
+ this(name, builder, CompletableFuture.completedFuture(null));
+ }
+
+ /**
+ * @param name Name of the environment, as reported to listeners, and as
+ * given to the builder.
+ * @param builder Builder for the environment; built on first use.
+ * @param before What to wait for before building: e.g. the end of a worker
+ * still using the environment's earlier configuration.
+ */
+ public LazyEnvironment(final String name, final Builder> builder,
+ final Future> before)
+ {
this.name = name;
+ this.before = before;
// Note: Subscribe once, here, and dispatch to the current listeners,
// because builders accumulate subscribers with every call.
this.builder = builder.name(name) //
@@ -96,7 +128,19 @@ public Environment get() throws BuildException {
if (env != null) return env;
synchronized (BUILD_LOCKS.computeIfAbsent(name, k -> new Object())) {
if (env != null) return env;
+ try {
+ before.get();
+ }
+ catch (final InterruptedException exc) {
+ Thread.currentThread().interrupt();
+ throw new BuildException("Interrupted waiting to build " + name,
+ exc);
+ }
+ catch (final ExecutionException exc) {
+ // NB: Whatever it was could not be waited for: build anyway.
+ }
listeners.forEach(l -> l.buildStarted(name));
+ BUILDING.add(name);
try {
env = builder.build();
}
@@ -104,6 +148,9 @@ public Environment get() throws BuildException {
listeners.forEach(l -> l.buildFinished(name, exc));
throw exc;
}
+ finally {
+ BUILDING.remove(name);
+ }
listeners.forEach(l -> l.buildFinished(name, null));
return env;
}
diff --git a/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorker.java b/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorker.java
index b8ae9e3..7ef0839 100644
--- a/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorker.java
+++ b/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorker.java
@@ -32,6 +32,9 @@
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
@@ -61,6 +64,14 @@
*/
public class ResidentWorker implements AutoCloseable {
+ /** Where released workers are waited for, until their processes end. */
+ private static final ExecutorService EXITS = Executors.newCachedThreadPool(
+ r -> {
+ final Thread t = new Thread(r, "appose-worker-exit");
+ t.setDaemon(true);
+ return t;
+ });
+
private final LazyEnvironment environment;
private final Function launcher;
private final Lock exclusive = new ReentrantLock();
@@ -141,11 +152,23 @@ public synchronized boolean isAlive() {
/**
* Ends the worker process gently, letting pending tasks finish, and
* releasing everything it holds. The environment stays built.
+ *
+ * @return When the process has ended (at once, if none was running): e.g.
+ * so that its environment is not rebuilt while it still uses it.
*/
- public synchronized void release() {
- if (service == null) return;
- service.close();
+ public synchronized CompletableFuture release() {
+ if (service == null) return CompletableFuture.completedFuture(null);
+ final Service s = service;
+ s.close();
service = null;
+ return CompletableFuture.runAsync(() -> {
+ try {
+ s.waitFor();
+ }
+ catch (final InterruptedException exc) {
+ Thread.currentThread().interrupt();
+ }
+ }, EXITS);
}
/** Ends the worker process immediately, interrupting any pending tasks. */
diff --git a/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorkerService.java b/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorkerService.java
index fd85316..ddb11dc 100644
--- a/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorkerService.java
+++ b/src/main/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorkerService.java
@@ -33,6 +33,9 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -75,6 +78,13 @@ public class ResidentWorkerService extends AbstractService implements
/** Environments by name, each with its configuration and worker. */
private final Map entries = new HashMap<>();
+ /** Whether the context was disposed: no more workers are made. */
+ private boolean disposed;
+
+ /** Told about every environment's builds. */
+ private final List buildListeners =
+ new CopyOnWriteArrayList<>();
+
/**
* Gets the resident worker for the named environment, creating the
* environment and worker if needed.
@@ -95,20 +105,75 @@ public ResidentWorker worker(final String name, final String config,
{
final ResidentWorker stale;
final ResidentWorker worker;
+ // NB: The environment is rebuilt in place: only once the stale worker,
+ // still using it, has ended.
+ final CompletableFuture staleEnded = new CompletableFuture<>();
synchronized (entries) {
+ if (disposed) {
+ throw new IllegalStateException("The context was disposed");
+ }
final Entry entry = entries.get(name);
if (entry != null && entry.config.equals(config)) return entry.worker;
stale = entry == null ? null : entry.worker;
- final LazyEnvironment env = new LazyEnvironment(name, builder.get());
+ final LazyEnvironment env = new LazyEnvironment(name, builder.get(),
+ staleEnded);
env.addBuildListener(SciJavaTasks.buildListener(taskService,
statusService, log));
+ env.addBuildListener(new BuildListener() {
+
+ @Override
+ public void buildFinished(final String envName, final Throwable error) {
+ for (final BuildListener l : buildListeners) {
+ l.buildFinished(envName, error);
+ }
+ }
+ });
worker = new ResidentWorker(env, launcher);
entries.put(name, new Entry(config, worker));
}
- if (stale != null) stale.release();
+ if (stale == null) staleEnded.complete(null);
+ else stale.release().whenComplete((v, exc) -> staleEnded.complete(
+ null));
return worker;
}
+ /**
+ * Adds a listener told when any environment's build ends (e.g. so that
+ * code completion can use it).
+ */
+ public void addBuildListener(final BuildListener listener) {
+ buildListeners.add(listener);
+ }
+
+ /**
+ * Gets the resident worker for the named environment, if there is one with
+ * the given configuration. Unlike {@link #worker}, this never creates,
+ * replaces or releases anything, so it suits callers that must not disturb
+ * running workers, such as code completion while the user edits the
+ * environment's configuration.
+ *
+ * @return The worker, or null if there is none with that configuration.
+ */
+ public ResidentWorker find(final String name, final String config) {
+ synchronized (entries) {
+ final Entry entry = entries.get(name);
+ return entry != null && entry.config.equals(config) ? entry.worker
+ : null;
+ }
+ }
+
+ /**
+ * Gets the resident worker of the named environment, whatever its
+ * configuration: e.g. to wait, via its {@link ResidentWorker#exclusive()}
+ * lock, for a script it runs before replacing it.
+ */
+ public Optional current(final String name) {
+ synchronized (entries) {
+ final Entry entry = entries.get(name);
+ return entry == null ? Optional.empty() : Optional.of(entry.worker);
+ }
+ }
+
/** Gets the current resident workers. */
public List workers() {
final List list = new ArrayList<>();
@@ -133,6 +198,7 @@ public void dispose() {
releaseAll();
synchronized (entries) {
entries.clear();
+ disposed = true;
}
}
diff --git a/src/main/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonDialect.java b/src/main/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonDialect.java
new file mode 100644
index 0000000..eeab636
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonDialect.java
@@ -0,0 +1,155 @@
+/*
+ * #%L
+ * Python scripting language plugin backed by Appose.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.plugins.scripting.appose.python.completion;
+
+import java.io.File;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import net.imglib2.RandomAccessibleInterval;
+
+import org.apposed.appose.NDArray;
+import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.ScriptDialect;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+import org.scijava.plugins.scripting.appose.python.ApposePythonScriptLanguage;
+import org.scijava.plugins.scripting.appose.python._internal.ResidentWorkerService;
+import org.scijava.script.ScriptLanguage;
+
+/**
+ * Describes how to parse Appose python scripts into plain python code and
+ * report the environment where they run
+ *
+ * @author Gabriel Selzer
+ */
+@Plugin(type = ScriptDialect.class, name = "Appose-Python")
+public class ApposePythonDialect implements ScriptDialect {
+
+ /** The type of images, as Appose passes them. */
+ private static final String NDARRAY = "numpy.ndarray";
+
+ /** The type of the {@code task} variable scripts can use. */
+ private static final String TASK_TYPE = "appose.python_worker.Task";
+
+ @Parameter(required = false)
+ private ResidentWorkerService workers;
+
+ private ApposePythonEnvironments environments;
+
+ @Override
+ public boolean supports(final ScriptLanguage language) {
+ return language instanceof ApposePythonScriptLanguage;
+ }
+
+ @Override
+ public String languageId() {
+ return "python";
+ }
+
+ @Override
+ public Environment environment(final String text,
+ final Optional file)
+ {
+ // NB: As its #@script line, or its inline metadata, says.
+ return environments().environment(text, file.map(File::getPath).orElse(
+ null));
+ }
+
+ @Override
+ public Header header(final Map variables) {
+ final List code = new ArrayList<>();
+ final List hidden = new ArrayList<>();
+ // Each Appose-Python script has access to its task
+ code.add("import appose.python_worker");
+ hidden.add("appose");
+ // And numpy is imported for the server whenever an image parameter is
+ // provided (the script itself does not get it)
+ if (variables.values().stream().map(v -> parameterType(v.type()))
+ .anyMatch(NDARRAY::equals))
+ {
+ code.add("import numpy");
+ hidden.add("numpy");
+ }
+ // Declare the types of each parameter for type reasoning
+ variables.forEach((name, v) -> {
+ final String t = parameterType(v.type());
+ // NB: Values Appose cannot pass are not declared.
+ if (t != null) code.add(name + ": " + t);
+ });
+ // Declare the type of task for type reasoning
+ code.add("task: " + TASK_TYPE);
+ // Combine into a Header
+ return new Header(String.join("; ", code), hidden);
+ }
+
+ /**
+ * @param type the Java type (of a SciJava Script parameter) declared in the
+ * script
+ * @return the Python type of the object given to the script by Appose, or
+ * null if Appose cannot pass it
+ */
+ static String parameterType(final Class> type) {
+ if (RandomAccessibleInterval.class.isAssignableFrom(type) || NDArray.class
+ .isAssignableFrom(type)) return NDARRAY;
+ if (type == boolean.class || type == Boolean.class) return "bool";
+ if (type == int.class || type == long.class || type == short.class ||
+ type == byte.class || type == Integer.class || type == Long.class ||
+ type == Short.class || type == Byte.class || type == BigInteger.class)
+ {
+ return "int";
+ }
+ if (type == float.class || type == double.class || type == Float.class ||
+ type == Double.class || type == BigDecimal.class) return "float";
+ if (CharSequence.class.isAssignableFrom(type) || type == char.class ||
+ type == Character.class || type.isEnum() || type == File.class)
+ {
+ return "str";
+ }
+ if (type.isArray() || Collection.class.isAssignableFrom(type)) {
+ return "list";
+ }
+ if (Map.class.isAssignableFrom(type)) return "dict";
+ return null;
+ }
+
+ /** Gets where scripts run, creating it on first use. */
+ private synchronized ApposePythonEnvironments environments() {
+ if (environments == null) {
+ environments = new ApposePythonEnvironments(workers);
+ }
+ return environments;
+ }
+}
diff --git a/src/main/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonEnvironments.java b/src/main/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonEnvironments.java
new file mode 100644
index 0000000..7ac0a69
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonEnvironments.java
@@ -0,0 +1,363 @@
+/*
+ * #%L
+ * Python scripting language plugin backed by Appose.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.plugins.scripting.appose.python.completion;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.function.Consumer;
+
+import org.apposed.appose.Environment;
+import org.apposed.appose.util.Environments;
+import org.scijava.plugins.scripting.appose.python.ScriptEnvironment;
+import org.scijava.plugins.scripting.appose.python._internal.InlineMetadata;
+import org.scijava.plugins.scripting.appose.python._internal.LazyEnvironment;
+import org.scijava.plugins.scripting.appose.python._internal.ResidentWorker;
+import org.scijava.plugins.scripting.appose.python._internal.ResidentWorkerService;
+
+/**
+ * Where Python scripts run via Appose, as their language servers need to
+ * know: the Python of a script's environment, if built (in this session, or an
+ * earlier one).
+ *
+ * An environment not built yet (e.g. as the script's dependencies are
+ * edited) is built in the background, once its configuration has stayed the
+ * same for a while, so that completion soon knows what the script will run
+ * with. A script running in the environment is waited for first: its
+ * environment is not rebuilt beneath it. Building replaces the environment's
+ * resident worker (of its earlier configuration), as the script's next run
+ * would.
+ *
+ *
+ * @author Gabriel Selzer
+ */
+final class ApposePythonEnvironments {
+
+ /**
+ * How long an environment's configuration must stay the same before it is
+ * built, in milliseconds.
+ */
+ static final long SETTLE = 5000;
+
+ /** Where settling environments are waited for. */
+ private static final ScheduledExecutorService TIMER = Executors
+ .newSingleThreadScheduledExecutor(daemon("appose-python-settle"));
+
+ /** Where environments are built: one at a time. */
+ private static final ExecutorService BUILDS = Executors
+ .newSingleThreadExecutor(daemon("appose-python-prebuild"));
+
+ /** The resident workers, to find built environments; or null if none. */
+ private final ResidentWorkerService workers;
+
+ /** How long a configuration must stay the same before it is built. */
+ private final long settle;
+
+ /** Builds an environment; or null, to build none. */
+ private final Consumer build;
+
+ /** Scripts waiting to settle, by path ("" if unsaved): what, and when. */
+ private final Map settling = new HashMap<>();
+
+ /**
+ * The configuration last built (or failing to) for each script, by path
+ * ("" if unsaved): not built again, unless the script changes to another.
+ */
+ private final Map tried = new HashMap<>();
+
+ /** Python executables of script environments, by script and metadata. */
+ private final Map pythons = new LinkedHashMap(
+ 16, 0.75f, true)
+ {
+
+ @Override
+ protected boolean removeEldestEntry(
+ final Map.Entry eldest)
+ {
+ return size() > 16;
+ }
+ };
+
+ /** @param workers The resident workers, or null if none (none built). */
+ ApposePythonEnvironments(final ResidentWorkerService workers) {
+ this(workers, SETTLE, null);
+ }
+
+ /**
+ * @param workers The resident workers, or null if none.
+ * @param settle How long a configuration must stay the same before it is
+ * built, in milliseconds.
+ * @param build Builds an environment (e.g. a fake one, in tests); or null,
+ * to build them with the workers (if any).
+ */
+ ApposePythonEnvironments(final ResidentWorkerService workers,
+ final long settle, final Consumer build)
+ {
+ this.workers = workers;
+ this.settle = settle;
+ this.build = build != null ? build : workers != null ? this::build
+ : null;
+ }
+
+ /**
+ * Gets where an Appose script runs: the Python executable of its declared
+ * environment, if that has already been built; or none (the
+ * servers' own Python), otherwise.
+ *
+ * @param text The script's text.
+ * @param path The script's file path, or null if it has none.
+ */
+ org.scijava.code.lsp.Environment environment(final String text,
+ final String path)
+ {
+ final String python = scriptPython(text, path);
+ return python == null ? new org.scijava.code.lsp.Environment()
+ : new org.scijava.code.lsp.Environment().withInterpreter(python);
+ }
+
+ /**
+ * Gets the Python executable of the script's environment, if it has been
+ * built already: in this session (e.g. by running the script), or in an
+ * earlier one. Completion never builds it. Returns null if not built.
+ */
+ String scriptPython(final String text, final String path) {
+ // NB: Called before each request, so remember what was found (but not
+ // what was not: the environment may be built at any moment).
+ final String key;
+ try {
+ key = path + "\n" + ScriptEnvironment.directive(text) + "\n" +
+ InlineMetadata.extract(text);
+ }
+ catch (final RuntimeException exc) {
+ return null; // NB: E.g. half-typed metadata.
+ }
+ synchronized (pythons) {
+ final String known = pythons.get(key);
+ if (known != null && new File(known).isFile()) return known;
+ }
+ final String python = findScriptPython(text, path);
+ if (python != null) {
+ synchronized (pythons) {
+ pythons.put(key, python);
+ }
+ }
+ return python;
+ }
+
+ /** As {@link #scriptPython}, without remembering. */
+ private String findScriptPython(final String text, final String path) {
+ // FIXME: For an unsaved script, this path is null, so its environment is
+ // named after its metadata (see ScriptEnvironment#inlineName). But the
+ // script editor runs it with its tab's name as its path (e.g. New_.py),
+ // so a run names it after that: the environment built here is not the
+ // one the run uses, which builds another. Completion and runs should
+ // agree on unsaved scripts' paths: e.g. the engine taking a path that is
+ // no file as none, or the editor giving sessions its tab's name too.
+ final ScriptEnvironment env;
+ try {
+ final Map directive = ScriptEnvironment.directive(text);
+ env = ScriptEnvironment.of(text, path, directive.get("env"), directive
+ .get("scheme"));
+ }
+ catch (final IOException | RuntimeException exc) {
+ return null; // NB: E.g. half-typed metadata.
+ }
+ if (env == null) return null;
+ // NB: A read-only lookup: worker(...) would replace the worker whenever
+ // the configuration changed, i.e. as the user edits it.
+ final ResidentWorker worker = workers == null ? null : workers.find(
+ env.name, env.config());
+ if (worker != null && worker.environment().isBuilt()) {
+ try {
+ final String python = pythonExecutable(worker.environment().get());
+ if (python != null) return python;
+ }
+ catch (final Exception exc) {
+ // NB: Fall back to looking on disk.
+ }
+ }
+ else prepare(path, env);
+ // NB: Being built (e.g. above): not usable yet, though its Python may
+ // already exist.
+ if (LazyEnvironment.isBuilding(env.name)) return null;
+ // Built in an earlier session? Its files are where Appose keeps them.
+ return pythonOnDisk(new File(Environments.apposeEnvsDir(), env.name));
+ }
+
+ /**
+ * Builds an environment once its configuration has stayed the same for a
+ * while (see {@link #settle}), unless it was tried already.
+ */
+ private void prepare(final String path, final ScriptEnvironment env) {
+ // NB: By script, not by environment: an unsaved script's environment
+ // is named after its configuration, so changes to it with each edit.
+ final String script = path == null ? "" : path;
+ if (build == null) return;
+ final String key = env.name + "\n" + env.config();
+ synchronized (settling) {
+ if (key.equals(tried.get(script))) return;
+ final Settling last = settling.get(script);
+ if (last != null) {
+ if (last.key.equals(key)) return; // NB: Already waiting.
+ last.timer.cancel(false);
+ }
+ settling.put(script, new Settling(key, TIMER.schedule(() -> {
+ synchronized (settling) {
+ settling.remove(script);
+ if (key.equals(tried.put(script, key))) return;
+ }
+ CompletableFuture.runAsync(() -> build.accept(env), BUILDS);
+ }, settle, TimeUnit.MILLISECONDS)));
+ }
+ }
+
+ /**
+ * Builds an environment, with the resident workers: waiting for a script
+ * running in it first, then replacing its worker, as the script's next run
+ * would.
+ */
+ private void build(final ScriptEnvironment env) {
+ try {
+ final Optional running = workers.current(env.name).map(
+ ResidentWorker::exclusive);
+ final ResidentWorker worker;
+ if (running.isPresent()) running.get().lockInterruptibly();
+ try {
+ worker = env.worker(workers);
+ }
+ finally {
+ running.ifPresent(Lock::unlock);
+ }
+ // NB: Shown as a task, as all builds are (see ResidentWorkerService).
+ worker.environment().get();
+ }
+ catch (final InterruptedException exc) {
+ Thread.currentThread().interrupt();
+ }
+ catch (final Exception exc) {
+ // NB: E.g. the context was disposed, or the build failed (which its
+ // task shows): completion goes on without it.
+ }
+ }
+
+ /** Finds the Python executable of a built environment, or null. */
+ static String pythonExecutable(final Environment env) {
+ // NB: Look in the bin paths, and in their parents: on Windows, conda-style
+ // (e.g. pixi) environments keep python.exe in the environment's root,
+ // above the bin directory that Appose reports.
+ final List dirs = new ArrayList<>();
+ for (final String dir : env.binPaths()) {
+ dirs.add(new File(dir));
+ final File parent = new File(dir).getParentFile();
+ if (parent != null) dirs.add(parent);
+ }
+ if (env.base() != null) {
+ final File base = new File(env.base());
+ dirs.add(base);
+ dirs.add(new File(base, "Scripts"));
+ dirs.add(new File(base, "bin"));
+ }
+ return findPython(dirs);
+ }
+
+ /**
+ * Finds the Python executable of an environment in the given directory, as
+ * Appose builds them (pixi, uv or conda), without building or starting
+ * anything; or null if there is none.
+ */
+ static String pythonOnDisk(final File envDir) {
+ if (!envDir.isDirectory()) return null;
+ final List dirs = new ArrayList<>();
+ // pixi: .pixi/envs/, with "default" first.
+ final File pixiEnvs = new File(envDir, ".pixi" + File.separator + "envs");
+ final List pixi = new ArrayList<>();
+ pixi.add(new File(pixiEnvs, "default"));
+ final File[] others = pixiEnvs.listFiles(File::isDirectory);
+ if (others != null) pixi.addAll(Arrays.asList(others));
+ for (final File p : pixi) {
+ dirs.add(p);
+ dirs.add(new File(p, "bin"));
+ }
+ // uv (a virtual environment) and conda/mamba.
+ for (final File base : new File[] { envDir, new File(envDir, ".venv") }) {
+ dirs.add(new File(base, "Scripts"));
+ dirs.add(new File(base, "bin"));
+ dirs.add(base);
+ }
+ return findPython(dirs);
+ }
+
+ private static String findPython(final List dirs) {
+ for (final File dir : dirs) {
+ for (final String exe : new String[] { "python.exe", "python",
+ "python3" })
+ {
+ final File f = new File(dir, exe);
+ if (f.isFile()) return f.getAbsolutePath();
+ }
+ }
+ return null;
+ }
+
+ private static ThreadFactory daemon(final String name) {
+ return r -> {
+ final Thread t = new Thread(r, name);
+ t.setDaemon(true);
+ return t;
+ };
+ }
+
+ /** An environment's configuration, waiting to settle. */
+ private static final class Settling {
+
+ private final String key;
+ private final ScheduledFuture> timer;
+
+ private Settling(final String key, final ScheduledFuture> timer) {
+ this.key = key;
+ this.timer = timer;
+ }
+ }
+}
diff --git a/src/test/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngineTest.java b/src/test/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngineTest.java
index fc1799b..b1b443d 100644
--- a/src/test/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngineTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/appose/python/ApposePythonScriptEngineTest.java
@@ -146,39 +146,39 @@ public void testDoesNotOwnWrappedNDArray() {
@Test
public void testEnvName() {
final String name =
- ApposePythonScriptEngine.envName(new File("/a/b/cellcast.toml"));
+ ScriptEnvironment.envName(new File("/a/b/cellcast.toml"));
assertTrue(name, name.matches("cellcast-[0-9a-f]{8}"));
// Same file via a different path spelling.
assertEquals(name,
- ApposePythonScriptEngine.envName(new File("/a/c/../b/cellcast.toml")));
+ ScriptEnvironment.envName(new File("/a/c/../b/cellcast.toml")));
// Same file name in a different directory.
assertNotEquals(name,
- ApposePythonScriptEngine.envName(new File("/a/cellcast.toml")));
+ ScriptEnvironment.envName(new File("/a/cellcast.toml")));
- assertTrue(ApposePythonScriptEngine.envName(new File("/x/my env.v2.toml"))
+ assertTrue(ScriptEnvironment.envName(new File("/x/my env.v2.toml"))
.matches("my_env_v2-[0-9a-f]{8}"));
- assertTrue(ApposePythonScriptEngine.envName(new File("/x/requirements"))
+ assertTrue(ScriptEnvironment.envName(new File("/x/requirements"))
.matches("requirements-[0-9a-f]{8}"));
}
@Test
public void testResolveEnvFile() {
final File abs = new File("/some/where/env.toml").getAbsoluteFile();
- assertEquals(abs, ApposePythonScriptEngine.resolveEnvFile(abs.getPath(),
+ assertEquals(abs, ScriptEnvironment.resolveEnvFile(abs.getPath(),
"/other/script.py"));
final File scriptDir = new File("/scripts").getAbsoluteFile();
- assertEquals(new File(scriptDir, "env.toml"), ApposePythonScriptEngine
+ assertEquals(new File(scriptDir, "env.toml"), ScriptEnvironment
.resolveEnvFile("env.toml", new File(scriptDir, "s.py").getPath()));
// Relative script path resolves relative to the working directory.
assertEquals(new File("sub", "env.toml").getAbsoluteFile(),
- ApposePythonScriptEngine.resolveEnvFile("env.toml", "sub/s.py")
+ ScriptEnvironment.resolveEnvFile("env.toml", "sub/s.py")
.getAbsoluteFile());
- assertEquals(new File("env.toml"), ApposePythonScriptEngine
+ assertEquals(new File("env.toml"), ScriptEnvironment
.resolveEnvFile("env.toml", null));
}
diff --git a/src/test/java/org/scijava/plugins/scripting/appose/python/_internal/InlineMetadataTest.java b/src/test/java/org/scijava/plugins/scripting/appose/python/_internal/InlineMetadataTest.java
index 24eb893..dbc7884 100644
--- a/src/test/java/org/scijava/plugins/scripting/appose/python/_internal/InlineMetadataTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/appose/python/_internal/InlineMetadataTest.java
@@ -38,6 +38,7 @@
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import com.fasterxml.jackson.databind.JsonNode;
@@ -82,6 +83,15 @@ public void testExtract() {
"pytorch-gpu = \"*\""), metadata.trim());
}
+ @Test
+ public void testDependencies() {
+ assertEquals(Arrays.asList("appose>=0.12.0,<0.13", "numpy > 2"),
+ InlineMetadata.dependencies("requires-python = \">=3.12\"\n" +
+ "dependencies = [\"appose>=0.12.0,<0.13\", \"numpy > 2\"]\n"));
+ assertEquals(Collections.emptyList(), InlineMetadata.dependencies(
+ "requires-python = \">=3.12\"\n"));
+ }
+
@Test
public void testExtractCRLF() {
final String metadata =
diff --git a/src/test/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorkerTest.java b/src/test/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorkerTest.java
index aa44951..7459344 100644
--- a/src/test/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorkerTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/appose/python/_internal/ResidentWorkerTest.java
@@ -32,14 +32,18 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
import java.io.File;
+import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.apposed.appose.Appose;
+import org.apposed.appose.BuildException;
import org.apposed.appose.Builder;
import org.junit.Test;
@@ -93,11 +97,56 @@ public void buildFinished(final String envName, final Throwable error) {
}
}
+ @Test
+ public void testBuildWaitsForEarlierWorker() throws Exception {
+ // An environment whose earlier worker is still ending: not rebuilt
+ // beneath it, but once it has ended.
+ final CompletableFuture ended = new CompletableFuture<>();
+ final AtomicInteger builds = new AtomicInteger();
+ final LazyEnvironment env = new LazyEnvironment("test", fakeBuilder(
+ builds), ended);
+ final Thread building = new Thread(() -> {
+ try {
+ env.get();
+ }
+ catch (final BuildException exc) {
+ throw new RuntimeException(exc);
+ }
+ });
+ building.start();
+ Thread.sleep(200);
+ assertEquals(0, builds.get());
+ ended.complete(null);
+ building.join(10_000);
+ assertEquals(1, builds.get());
+ }
+
+ @Test
+ public void testReleaseEnds() {
+ // A worker never started: ended at once.
+ final ResidentWorker idle = new ResidentWorker(new LazyEnvironment(
+ "test", fakeBuilder(new AtomicInteger())), e -> e.python());
+ assertTrue(idle.release().isDone());
+ }
+
private static int pid(final ResidentWorker worker) throws Exception {
return ((Number) worker.task(PID, null).waitFor().outputs.get("pid"))
.intValue();
}
+ /** A builder counting its builds, building nothing. */
+ private static Builder> fakeBuilder(final AtomicInteger builds) {
+ return (Builder>) Proxy.newProxyInstance(Builder.class
+ .getClassLoader(), new Class>[] { Builder.class }, (proxy, method,
+ args) -> {
+ if (method.getName().equals("build")) {
+ builds.incrementAndGet();
+ return null;
+ }
+ return method.getReturnType().isInstance(proxy) ? proxy : null;
+ });
+ }
+
private static LazyEnvironment environment() throws Exception {
final File file = new File(ResidentWorkerTest.class.getResource(
"/appose-test/requirements.txt").toURI());
diff --git a/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonCompletionIntegrationTest.java b/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonCompletionIntegrationTest.java
new file mode 100644
index 0000000..fb49732
--- /dev/null
+++ b/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonCompletionIntegrationTest.java
@@ -0,0 +1,211 @@
+/*
+ * #%L
+ * Python scripting language plugin backed by Appose.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.plugins.scripting.appose.python.completion;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.File;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.Diagnostic;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.scijava.Context;
+import org.scijava.code.lsp.LanguageServerService;
+import org.scijava.code.lsp.ScriptSession;
+import org.scijava.module.ModuleService;
+import org.scijava.script.ScriptInfo;
+import org.scijava.script.ScriptLanguage;
+import org.scijava.script.ScriptModule;
+import org.scijava.script.ScriptService;
+
+/**
+ * End-to-end tests of completing Python scripts run via Appose (see
+ * {@link ApposePythonDialect}), with a real jedi-language-server (from
+ * scijava-code-lsp-jedi, whose own tests test it on plain Python), through the
+ * {@link LanguageServerService}, as editors use it: what is particular to
+ * these scripts.
+ *
+ * The first completion builds jedi-language-server's environment, a script
+ * run the small uv environment of {@code appose-test/requirements.txt}, and a
+ * script declaring a new dependency its own environment; all require network
+ * access.
+ *
+ *
+ * @author Gabriel Selzer
+ */
+public class ApposePythonCompletionIntegrationTest {
+
+ private static final String HEADER = "#!appose-python\n" +
+ "#@script(env=\"requirements.txt\", scheme=\"requirements.txt\")\n";
+
+ /** How long to wait for environments to build. */
+ private static final long BUILD_TIMEOUT = TimeUnit.MINUTES.toMillis(15);
+
+ private static Context context;
+ private static ScriptLanguage language;
+ private static LanguageServerService servers;
+ private static File scriptDir;
+
+ @BeforeClass
+ public static void setUp() throws URISyntaxException {
+ context = new Context();
+ language = context.service(ScriptService.class).getLanguageByName(
+ "appose-python");
+ servers = context.service(LanguageServerService.class);
+ scriptDir = new File(ApposePythonCompletionIntegrationTest.class
+ .getResource("/appose-test/requirements.txt").toURI()).getParentFile();
+ }
+
+ @AfterClass
+ public static void tearDown() {
+ sessions.values().forEach(ScriptSession::close);
+ if (context != null) context.dispose();
+ }
+
+ @Test
+ public void testScriptEnvironment() throws Exception {
+ // Run a script, building its environment (which includes numpy).
+ final File script = new File(scriptDir, "completion.py");
+ final String header = HEADER + "#@ net.imglib2.img.Img image\n" +
+ "import numpy as np\n";
+ Files.write(script.toPath(), (header + "x = 1\n").getBytes(
+ StandardCharsets.UTF_8));
+ final ModuleService moduleService = context.service(ModuleService.class);
+ final ScriptModule module = (ScriptModule) moduleService.createModule(
+ new ScriptInfo(context, script));
+ moduleService.run(module, false, Collections.singletonMap("image",
+ null)).get();
+
+ // Now the server analyzes the script against its environment: numpy is
+ // known, and so is the type of the image parameter.
+ completeUntil(header + "np.ze", script.getPath(), "zeros");
+ completeUntil(header + "image.sha", script.getPath(), "shape");
+ }
+
+ @Test
+ public void testDiagnostics() throws Exception {
+ // A syntax error, on the script's third line: after its parameter
+ // lines.
+ final String code = "#@ double sigma\nx = sigma\ny = (1 +\n";
+ final long deadline = System.currentTimeMillis() + BUILD_TIMEOUT;
+ List problems = Collections.emptyList();
+ while (problems.isEmpty() && System.currentTimeMillis() < deadline) {
+ problems = problems(code);
+ }
+ assertFalse(problems.isEmpty());
+ assertTrue(problems.toString(), problems.get(0).getRange().getStart()
+ .getLine() >= 2);
+ // Fixed: no more problems.
+ assertTrue(problems(code.replace("(1 +", "(1 + 2)")).isEmpty());
+ }
+
+ @Test
+ public void testParameterBeforeEnvironment() throws Exception {
+ // An image parameter is a NumPy array: known, even though the script's
+ // environment is not built (here, it is not even declared).
+ completeUntil("#@ net.imglib2.img.Img image\nx = 1\nimage.", null,
+ "shape");
+ }
+
+ @Test
+ public void testNewDependency() throws Exception {
+ // A script declaring a dependency that is installed nowhere: its
+ // environment is built, in the background, once its dependencies have
+ // settled; then its servers analyze the script there.
+ final String header = "# /// script\n# dependencies = [\"tifffile\"]\n" +
+ "# ///\nimport tifffile\n";
+ completeUntil(header + "tifffile.imr", null, "imread");
+ }
+
+ // -- Helper methods --
+
+ /** The scripts' sessions, by path ("" for an untitled one). */
+ private static final Map sessions = new HashMap<>();
+
+ /** The session about a script (null path: untitled). */
+ private static ScriptSession session(final String path) {
+ return sessions.computeIfAbsent(path == null ? "" : path,
+ p -> {
+ final ScriptSession session = new ScriptSession(servers, language);
+ session.setFile(() -> path == null ? null : new File(path));
+ return session;
+ });
+ }
+
+ /** The problems found in a script, in a session of its own. */
+ private static List problems(final String code)
+ throws Exception
+ {
+ final CompletableFuture> found =
+ new CompletableFuture<>();
+ try (ScriptSession session = new ScriptSession(servers, language)) {
+ session.setDiagnosticsListener(found::complete);
+ session.update(code);
+ return found.get(120, TimeUnit.SECONDS);
+ }
+ }
+
+ /** Completes until the expected completion appears (environments build). */
+ private static List completeUntil(final String code,
+ final String path, final String expected) throws Exception
+ {
+ final long deadline = System.currentTimeMillis() + BUILD_TIMEOUT;
+ while (System.currentTimeMillis() < deadline) {
+ final List completions = complete(code, path);
+ if (completions.stream().anyMatch(c -> c.getLabel().equals(expected))) {
+ return completions;
+ }
+ Thread.sleep(1000);
+ }
+ fail("No '" + expected + "' completion for: " + code);
+ return null;
+ }
+
+ /** Completes at the end of the code: all servers' completions. */
+ private static List complete(final String code,
+ final String path) throws Exception
+ {
+ return session(path).completion(code, code.length()).get(1,
+ TimeUnit.MINUTES).getItems();
+ }
+}
diff --git a/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonDialectTest.java b/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonDialectTest.java
new file mode 100644
index 0000000..343663c
--- /dev/null
+++ b/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonDialectTest.java
@@ -0,0 +1,287 @@
+/*
+ * #%L
+ * Python scripting language plugin backed by Appose.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.plugins.scripting.appose.python.completion;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import net.imglib2.img.Img;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.scijava.Context;
+import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.ScriptDialect;
+import org.scijava.code.lsp.ScriptDialect.Header;
+import org.scijava.code.lsp.ScriptDialect.Variable;
+import org.scijava.plugin.PluginService;
+import org.scijava.plugins.scripting.appose.python.ScriptEnvironment;
+import org.scijava.plugins.scripting.appose.python._internal.ResidentWorkerService;
+import org.scijava.script.ScriptLanguage;
+import org.scijava.script.ScriptService;
+
+/**
+ * Tests {@link ApposePythonDialect}.
+ *
+ * @author Gabriel Selzer
+ */
+public class ApposePythonDialectTest {
+
+ private Context context;
+ private ScriptLanguage language;
+ private ApposePythonDialect dialect;
+
+ @Before
+ public void setUp() {
+ context = new Context(ScriptService.class, ResidentWorkerService.class);
+ language = context.service(ScriptService.class).getLanguageByName(
+ "appose-python");
+ dialect = context.service(PluginService.class).createInstancesOfType(
+ ScriptDialect.class).stream().filter(
+ p -> p instanceof ApposePythonDialect).map(
+ p -> (ApposePythonDialect) p)
+ .findFirst().get();
+ }
+
+ @After
+ public void tearDown() {
+ context.dispose();
+ }
+
+ @Test
+ public void testScriptsLanguageAdherence() {
+ // Ensure the dialect supports the Appose Python script language
+ assertTrue(dialect.supports(language));
+ }
+
+ @Test
+ public void testParameterType() {
+ // Script parameters have the types Appose converts them to
+ assertEquals("numpy.ndarray", ApposePythonDialect.parameterType(
+ Img.class));
+ assertEquals("float", ApposePythonDialect.parameterType(double.class));
+ assertEquals("int", ApposePythonDialect.parameterType(Long.class));
+ assertEquals("str", ApposePythonDialect.parameterType(String.class));
+ assertEquals("bool", ApposePythonDialect.parameterType(boolean.class));
+ assertEquals("list", ApposePythonDialect.parameterType(int[].class));
+ assertEquals("dict", ApposePythonDialect.parameterType(Map.class));
+ // Types Appose cannot convert are not declared
+ assertNull(ApposePythonDialect.parameterType(Context.class));
+ }
+
+ @Test
+ public void testHeader() {
+ // SciJava Script parameters should be declared in Python code with the
+ // types Appose converts them to, e.g. "image: numpy.ndarray"; and
+ // scripts can use Appose's task variable
+ final Map parameters = new LinkedHashMap<>();
+ parameters.put("image", Variable.ofType(Img.class));
+ parameters.put("sigma", Variable.ofType(double.class));
+ parameters.put("context", Variable.ofType(Context.class));
+ Header header = dialect.header(parameters);
+ assertEquals("import appose.python_worker; import numpy; " +
+ "image: numpy.ndarray; sigma: float; " +
+ "task: appose.python_worker.Task", header.code());
+ // The imports' names are the header's alone
+ assertEquals(Arrays.asList("appose", "numpy"), Arrays.asList(header
+ .hiddenNames().toArray()));
+ // numpy is imported only when a parameter needs it
+ header = dialect.header(Collections.singletonMap("sigma", Variable
+ .ofType(double.class)));
+ assertEquals("import appose.python_worker; sigma: float; " +
+ "task: appose.python_worker.Task", header.code());
+ assertEquals(Collections.singleton("appose"), header.hiddenNames());
+ }
+
+ @Test
+ public void testEnvironmentWithoutOne() {
+ // No script environment has been built: the server's own Python.
+ final Environment env = dialect.environment("import os\nos.pa", Optional
+ .empty());
+ assertEquals(Optional.empty(), env.interpreter());
+ assertTrue(env.paths().isEmpty());
+ }
+
+ @Test
+ public void testScriptWorkersUntouched() {
+ // Finding where a script runs does not create (or replace) a worker for
+ // it at once, even as its metadata is edited: only once the metadata
+ // has settled (see ApposePythonEnvironmentsTest).
+ final String script = "# /// script\n# dependencies = [\"appose\"]\n" +
+ "# ///\nimport os\nos.pa";
+ dialect.environment(script, Optional.empty());
+ dialect.environment(script.replace("appose", "appose, numpy"), Optional
+ .empty());
+ assertTrue(context.service(ResidentWorkerService.class).workers()
+ .isEmpty());
+ }
+
+ @Test
+ public void testEnvironmentFromEarlierSession() throws Exception {
+ // A script whose environment was built in an earlier session: it is on
+ // disk, but no worker for it exists in this one.
+ final java.nio.file.Path tmp = java.nio.file.Files.createTempDirectory(
+ "appose-envs");
+ final String old = System.getProperty("appose.envs-dir");
+ System.setProperty("appose.envs-dir", tmp.toString());
+ try {
+ final File script = tmp.resolve("blur.py").toFile();
+ final String header = "# /// script\n# dependencies = [\"numpy\"]\n" +
+ "# ///\nimport numpy\n";
+ final String envName = ScriptEnvironment.of(header, script.getPath(),
+ null, null).name;
+ final java.nio.file.Path python = tmp.resolve(envName).resolve(
+ ".pixi/envs/default/python.exe");
+ java.nio.file.Files.createDirectories(python.getParent());
+ java.nio.file.Files.createFile(python);
+
+ final Environment env = dialect.environment(header + "numpy.ze",
+ Optional.of(script));
+ // The server analyzes against the environment's Python...
+ assertEquals(Optional.of(python.toFile().getAbsolutePath()), env
+ .interpreter());
+ // ...without anything built or started.
+ assertTrue(context.service(ResidentWorkerService.class).workers()
+ .isEmpty());
+ }
+ finally {
+ if (old == null) System.clearProperty("appose.envs-dir");
+ else System.setProperty("appose.envs-dir", old);
+ delete(tmp);
+ }
+ }
+
+ @Test
+ public void testPythonExecutable() throws Exception {
+ final java.nio.file.Path tmp = java.nio.file.Files.createTempDirectory(
+ "appose-envs");
+ try {
+ // pixi on Windows: python.exe is in the environment's root, above the
+ // bin directory Appose reports (which may not even exist).
+ final java.nio.file.Path pixiEnv = tmp.resolve(
+ "pixi/.pixi/envs/default");
+ java.nio.file.Files.createDirectories(pixiEnv);
+ final java.nio.file.Path pixiPython = java.nio.file.Files.createFile(
+ pixiEnv.resolve("python.exe"));
+ assertEquals(pixiPython.toFile().getAbsolutePath(),
+ ApposePythonEnvironments.pythonExecutable(env(tmp.resolve("pixi"),
+ pixiEnv.resolve("bin"))));
+ // uv on Windows: python.exe is in the reported Scripts directory.
+ final java.nio.file.Path scripts = tmp.resolve("uv/Scripts");
+ java.nio.file.Files.createDirectories(scripts);
+ final java.nio.file.Path uvPython = java.nio.file.Files.createFile(
+ scripts.resolve("python.exe"));
+ assertEquals(uvPython.toFile().getAbsolutePath(),
+ ApposePythonEnvironments.pythonExecutable(env(tmp.resolve("uv"),
+ scripts)));
+ // No Python at all.
+ assertNull(ApposePythonEnvironments.pythonExecutable(env(tmp.resolve(
+ "none"), tmp.resolve("none/bin"))));
+ }
+ finally {
+ delete(tmp);
+ }
+ }
+
+ @Test
+ public void testPythonOnDisk() throws Exception {
+ final java.nio.file.Path tmp = java.nio.file.Files.createTempDirectory(
+ "appose-envs");
+ try {
+ for (final String layout : new String[] {
+ ".pixi/envs/default/python.exe", // pixi, Windows
+ ".pixi/envs/default/bin/python", // pixi, Linux & macOS
+ "Scripts/python.exe", // uv, Windows
+ "bin/python" }) // uv or conda, Linux & macOS
+ {
+ final java.nio.file.Path env = tmp.resolve("env" + layout.hashCode());
+ final java.nio.file.Path python = env.resolve(layout);
+ java.nio.file.Files.createDirectories(python.getParent());
+ java.nio.file.Files.createFile(python);
+ assertEquals(layout, python.toFile().getAbsolutePath(),
+ ApposePythonEnvironments.pythonOnDisk(env.toFile()));
+ }
+ assertNull(ApposePythonEnvironments.pythonOnDisk(tmp.resolve("missing")
+ .toFile()));
+ }
+ finally {
+ delete(tmp);
+ }
+ }
+
+ // -- Helper methods --
+
+ private static void delete(final java.nio.file.Path dir) throws Exception {
+ try (java.util.stream.Stream paths =
+ java.nio.file.Files.walk(dir))
+ {
+ paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p
+ .toFile().delete());
+ }
+ }
+
+ /** A built environment with the given base and bin directory. */
+ private static org.apposed.appose.Environment env(
+ final java.nio.file.Path base, final java.nio.file.Path bin)
+ {
+ return new org.apposed.appose.Environment() {
+
+ @Override
+ public String base() {
+ return base.toString();
+ }
+
+ @Override
+ public List binPaths() {
+ return Collections.singletonList(bin.toString());
+ }
+
+ @Override
+ public List launchArgs() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public org.apposed.appose.Builder> builder() {
+ return null;
+ }
+ };
+ }
+}
diff --git a/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonEnvironmentsTest.java b/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonEnvironmentsTest.java
new file mode 100644
index 0000000..2679158
--- /dev/null
+++ b/src/test/java/org/scijava/plugins/scripting/appose/python/completion/ApposePythonEnvironmentsTest.java
@@ -0,0 +1,96 @@
+/*
+ * #%L
+ * Python scripting language plugin backed by Appose.
+ * %%
+ * Copyright (C) 2026 SciJava developers.
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+
+package org.scijava.plugins.scripting.appose.python.completion;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.Test;
+
+/**
+ * Tests that {@link ApposePythonEnvironments} builds the environments scripts
+ * declare once they settle (with a fake build: nothing is built).
+ *
+ * @author Gabriel Selzer
+ */
+public class ApposePythonEnvironmentsTest {
+
+ /** The configurations built, in order. */
+ private final List built = new CopyOnWriteArrayList<>();
+
+ private final ApposePythonEnvironments environments =
+ new ApposePythonEnvironments(null, 200, env -> built.add(env.config()));
+
+ @Test
+ public void testBuildsOnceSettled() throws Exception {
+ // Being typed: not built...
+ where(script("nu"));
+ Thread.sleep(100);
+ where(script("numpy"));
+ Thread.sleep(100);
+ assertTrue(built.isEmpty());
+ // ...until it has stayed the same for a while: then once.
+ Thread.sleep(400);
+ assertEquals(1, built.size());
+ assertTrue(built.get(0), built.get(0).contains("numpy"));
+ where(script("numpy"));
+ Thread.sleep(400);
+ assertEquals(1, built.size());
+ // Changed, then changed back: built again.
+ where(script("scipy"));
+ Thread.sleep(400);
+ where(script("numpy"));
+ Thread.sleep(400);
+ assertEquals(3, built.size());
+ }
+
+ @Test
+ public void testNothingDeclared() throws Exception {
+ where("import os\nos.pa");
+ Thread.sleep(400);
+ assertTrue(built.isEmpty());
+ }
+
+ // -- Helper methods --
+
+ /** Asks where an unsaved script runs. */
+ private void where(final String script) {
+ environments.environment(script, null);
+ }
+
+ /** An unsaved script declaring a dependency, in its inline metadata. */
+ private static String script(final String dependency) {
+ return "# /// script\n# dependencies = [\"" + dependency + "\"]\n" +
+ "# ///\nimport os\n";
+ }
+}