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"; + } +}