diff --git a/pom.xml b/pom.xml
index 2b9c5a7..6235f03 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
scripting-python
- 0.3.1-SNAPSHOT
+ 0.4.0-SNAPSHOT
SciJava Scripting: Python
Python scripting language plugin to be used via scyjava.
@@ -87,10 +87,16 @@
sign,deploy-to-scijava
+
+ 0.3.0
+
+ org.scijava
+ app-launcher
+
org.scijava
scijava-common
@@ -105,6 +111,11 @@
com.fifesoft
rsyntaxtextarea
+
+ org.apposed
+ appose
+ ${appose.version}
+
diff --git a/src/main/java/org/scijava/plugins/scripting/python/CreateEnvironment.java b/src/main/java/org/scijava/plugins/scripting/python/CreateEnvironment.java
new file mode 100644
index 0000000..9cc8f97
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/python/CreateEnvironment.java
@@ -0,0 +1,89 @@
+/*-
+ * #%L
+ * Python scripting language plugin to be used via scyjava.
+ * %%
+ * Copyright (C) 2021 - 2025 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.python;
+
+import org.apposed.appose.Appose;
+import org.apposed.appose.Builder;
+import org.scijava.app.AppService;
+import org.scijava.command.Command;
+import org.scijava.launcher.Splash;
+import org.scijava.log.Logger;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+import org.scijava.util.FileUtils;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * SciJava command wrapper to build a Python environment.
+ *
+ * @author Curtis Rueden
+ */
+@Plugin(type = Command.class, label = "Create Python environment")
+public class CreateEnvironment implements Command {
+
+ @Parameter
+ private AppService appService;
+
+ @Parameter
+ private Logger log;
+
+ @Parameter(label = "environment definition file")
+ private File environmentYaml;
+
+ @Parameter(label = "Target directory")
+ private File targetDir;
+
+ // -- OptionsPython methods --
+
+ @Override
+ public void run() {
+ FileUtils.deleteRecursively(targetDir);
+ try {
+ Builder builder = Appose
+ .file(environmentYaml, "environment.yml")
+ .subscribeOutput(this::report)
+ .subscribeError(this::report)
+ .subscribeProgress((msg, cur, max) -> Splash.update(msg, (double) cur / max));
+ System.err.println("Creating Python environment"); // HACK: stderr stream triggers console window show.
+ Splash.show();
+ builder.build(targetDir);
+ }
+ catch (IOException exc) {
+ log.error("Failed to create Python environment", exc);
+ }
+ }
+
+ private void report(String s) {
+ if (s.isEmpty()) System.err.print(".");
+ else System.err.print(s);
+ }
+}
diff --git a/src/main/java/org/scijava/plugins/scripting/python/OptionsPython.java b/src/main/java/org/scijava/plugins/scripting/python/OptionsPython.java
new file mode 100644
index 0000000..bd8460e
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/python/OptionsPython.java
@@ -0,0 +1,202 @@
+/*-
+ * #%L
+ * Python scripting language plugin to be used via scyjava.
+ * %%
+ * Copyright (C) 2021 - 2025 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.python;
+
+import org.scijava.app.AppService;
+import org.scijava.command.CommandService;
+import org.scijava.launcher.Config;
+import org.scijava.log.LogService;
+import org.scijava.menu.MenuConstants;
+import org.scijava.options.OptionsPlugin;
+import org.scijava.plugin.Menu;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+import org.scijava.widget.Button;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Options for configuring the Python environment.
+ *
+ * @author Curtis Rueden
+ */
+@Plugin(type = OptionsPlugin.class, menu = {
+ @Menu(label = MenuConstants.EDIT_LABEL,
+ weight = MenuConstants.EDIT_WEIGHT,
+ mnemonic = MenuConstants.EDIT_MNEMONIC),
+ @Menu(label = "Options", mnemonic = 'o'),
+ @Menu(label = "Python...", weight = 10),
+})
+public class OptionsPython extends OptionsPlugin {
+
+ @Parameter
+ private AppService appService;
+
+ @Parameter
+ private CommandService commandService;
+
+ @Parameter
+ private LogService log;
+
+ @Parameter(label = "Python environment directory", persist = false)
+ private File pythonDir;
+
+ @Parameter(label = "Create Python environment", callback = "createEnv")
+ private Button createEnvironment;
+
+ @Parameter(label = "Launch in Python mode", callback = "updatePythonConfig", persist = false)
+ private boolean pythonMode;
+
+ // -- OptionsPython methods --
+
+ public File getPythonDir() {
+ return pythonDir;
+ }
+
+ public boolean isPythonMode() {
+ return pythonMode;
+ }
+
+ public void setPythonDir(final File pythonDir) {
+ this.pythonDir = pythonDir;
+ }
+
+ public void setPythonMode(final boolean pythonMode) {
+ this.pythonMode = pythonMode;
+ }
+
+ // -- Callback methods --
+
+ @Override
+ public void load() {
+ // Read python-dir and python-mode from app config file.
+ String configFileProp = System.getProperty("scijava.app.config-file");
+ File configFile = configFileProp == null ? null : new File(configFileProp);
+ if (configFile != null && configFile.canRead()) {
+ try {
+ final Map config = Config.load(configFile);
+
+ final String cfgPythonDir = config.get("python-dir");
+ if (cfgPythonDir != null) {
+ final Path appPath = appService.getApp().getBaseDirectory().toPath();
+ pythonDir = stringToFile(appPath, cfgPythonDir);
+ }
+
+ final String cfgPythonMode = config.get("python-mode");
+ if (cfgPythonMode != null) pythonMode = cfgPythonMode.equals("true");
+ }
+ catch (IOException e) {
+ // Proceed gracefully if config file is not accessible.
+ log.debug(e);
+ }
+ }
+
+ if (pythonDir == null) {
+ // For the default Python directory, try to match the platform string used for Java installations.
+ final String javaPlatform = System.getProperty("scijava.app.java-platform");
+ final String platform = javaPlatform != null ? javaPlatform :
+ System.getProperty("os.name") + "-" + System.getProperty("os.arch");
+ final Path pythonPath = appService.getApp().getBaseDirectory().toPath().resolve("python").resolve(platform);
+ pythonDir = pythonPath.toFile();
+ }
+ }
+
+ public void createEnv() {
+ // Use scijava.app.python-env-file system property if present.
+ final Path appPath = appService.getApp().getBaseDirectory().toPath();
+ File environmentYaml = appPath.resolve("config").resolve("environment.yml").toFile();
+ final String pythonEnvFileProp = System.getProperty("scijava.app.python-env-file");
+ if (pythonEnvFileProp != null) {
+ environmentYaml = OptionsPython.stringToFile(appPath, pythonEnvFileProp);
+ }
+
+ commandService.run(CreateEnvironment.class, true,
+ "environmentYaml", environmentYaml,
+ "targetDir", pythonDir
+ );
+ }
+
+ @Override
+ public void save() {
+ // Write python-dir and python-mode values to app config file.
+ final String configFileProp = System.getProperty("scijava.app.config-file");
+ if (configFileProp == null) return; // No config file to update.
+ final File configFile = new File(configFileProp);
+ Map config = null;
+ if (configFile.isFile()) {
+ try {
+ config = Config.load(configFile);
+ }
+ catch (IOException exc) {
+ // Proceed gracefully if config file is not accessible.
+ log.debug(exc);
+ }
+ }
+ if (config == null) config = new LinkedHashMap<>();
+ final Path appPath = appService.getApp().getBaseDirectory().toPath();
+ config.put("python-dir", fileToString(appPath, pythonDir));
+ config.put("python-mode", pythonMode ? "true" : "false");
+ try {
+ Config.save(configFile, config);
+ }
+ catch (IOException exc) {
+ // Proceed gracefully if config file cannot be written.
+ log.debug(exc);
+ }
+ }
+
+ // -- Utility methods --
+
+ /**
+ * Converts a path string to a file, treating relative path expressions as
+ * relative to the given base directory, not the current working directory.
+ */
+ static File stringToFile(Path baseDir, String value) {
+ final Path path = Paths.get(value);
+ final Path absPath = path.isAbsolute() ? path : baseDir.resolve(path);
+ return absPath.toFile();
+ }
+
+ /**
+ * Converts a file to a path string, which in the case of a file beneath the
+ * given base directory, will be a path expression relative to that base.
+ */
+ static String fileToString(Path baseDir, File file) {
+ Path filePath = file.toPath();
+ Path relPath = filePath.startsWith(baseDir) ?
+ baseDir.relativize(filePath) : filePath.toAbsolutePath();
+ return relPath.toString();
+ }
+}