diff --git a/app/jaunch/jy.toml b/app/jaunch/jy.toml index 51ed083..dd55e47 100644 --- a/app/jaunch/jy.toml +++ b/app/jaunch/jy.toml @@ -12,6 +12,8 @@ jaunch-version = 1 program-name = 'Jy' +directives = ['JVM'] + jvm.version-min = '8' #jvm.version-max = '21' diff --git a/app/jaunch/parsy.toml b/app/jaunch/parsy.toml index ee6a938..ada1aeb 100644 --- a/app/jaunch/parsy.toml +++ b/app/jaunch/parsy.toml @@ -12,6 +12,8 @@ jaunch-version = 1 program-name = 'Parsy' +directives = ['JVM'] + jvm.version-min = '8' #java-version-max = '21' diff --git a/app/jaunch/paunch.toml b/app/jaunch/paunch.toml new file mode 100644 index 0000000..a5994f8 --- /dev/null +++ b/app/jaunch/paunch.toml @@ -0,0 +1,22 @@ +# *** Welcome to the Paunch configuration file! *** +# +# Paunch is a Python launcher built on Jaunch (https://github.com/scijava/jaunch). +# +# One advantage of Paunch over the normal python launcher is that on macOS, +# Paunch starts Python on a separate pthread, and starts the AppKit event loop +# on the main thread, so that GUIs (especially Java-based AWT/Swing GUIs) can +# successfully operate without hanging. +# +# There is a general layer of launcher configuration in the jaunch.toml file. +# The contents below define Paunch's particular launcher behavior and features, +# on top of Paunch's "sensible default" functionality. +# +# See that jaunch.toml file for more details about Jaunch configuration. + +jaunch-version = 1 + +program-name = 'Paunch' + +directives = ['PYTHON'] + +python.version-min = '3.8' diff --git a/bin/app.sh b/bin/app.sh index a6af664..d4c6e4a 100755 --- a/bin/app.sh +++ b/bin/app.sh @@ -11,6 +11,7 @@ do ( srcDir=${l%/launcher*} suffix=${l#*/launcher} set -x + cp "$l" "$srcDir/paunch$suffix" cp "$l" "$srcDir/jy$suffix" mv "$l" "$srcDir/parsy$suffix" ) done diff --git a/configs/fiji.toml b/configs/fiji.toml index d0a568f..e8da183 100644 --- a/configs/fiji.toml +++ b/configs/fiji.toml @@ -82,9 +82,16 @@ modes = [ ] directives = [ - '--print-ij-dir|print-app-dir', # For backwards compatibility. + '--print-ij-dir|print-app-dir,STOP', # For backwards compatibility. + 'JVM', ] +# /============================================================================\ +# | PYTHON CONFIG | +# \============================================================================/ + +# TODO + # /============================================================================\ # | JAVA CONFIG | # \============================================================================/ diff --git a/configs/icy.toml b/configs/icy.toml index e1781d5..451d0fe 100644 --- a/configs/icy.toml +++ b/configs/icy.toml @@ -12,6 +12,8 @@ jaunch-version = 1 program-name = 'Icy' +directives = ['JVM'] + jvm.version-min = '8' jvm.classpath = [ diff --git a/configs/imagej.toml b/configs/imagej.toml index 573f12a..a47768f 100644 --- a/configs/imagej.toml +++ b/configs/imagej.toml @@ -12,6 +12,8 @@ jaunch-version = 1 program-name = 'ImageJ' +directives = ['JVM'] + jvm.root-paths = [ '${app-dir}', ] diff --git a/configs/qupath.toml b/configs/qupath.toml index 9817fec..89086ed 100644 --- a/configs/qupath.toml +++ b/configs/qupath.toml @@ -12,6 +12,8 @@ jaunch-version = 1 program-name = 'QuPath' +directives = ['JVM'] + jvm.version-min = '21' jvm.classpath = [ diff --git a/jaunch.toml b/jaunch.toml index 5d96d34..bfa1548 100644 --- a/jaunch.toml +++ b/jaunch.toml @@ -3,7 +3,7 @@ # ============================================================================== # # This jaunch.toml file contains useful general-purpose, non-application-specific -# configuration that defines some sensible defaults for typical Java-based launchers. +# configuration that defines some sensible defaults for typical launchers. # You can of course edit it to customize Jaunch's behavior however you like. # # Each application will typically have its own extensions to the general configuration @@ -12,11 +12,9 @@ # FizzBuzz application, you would also write a fizzbuzz.toml companion file that # overrides or augments this configuration with fizzbuzz-specific settings. # -# Minimally, such application-specific configuration will include the `program-name` -# and `main-class` fields, and typically a `classpath` -# for needed JAR files. But it may also add application-specific options via the -# `supported-options` field, as well as constraints such as `version-min` and -# `version-max` to restrict which Javas are compatible with the application. +# For simple examples of application-specific configuration, see: +# - app/config/paunch.toml for a Jaunch-based Python launcher +# - app/config/jy.toml for a Jaunch-based Jython launcher # # Alternately, if you would like to keep all configuration together in one file # for simplicity, you can write a single TOML file with everything, and name it @@ -30,12 +28,14 @@ jaunch-version = 1 # ============================================================================== -# The name of your program! This name will appear in the usage text. +# The name of your program! This name will appear in usage text and dialog boxes. #program-name = 'FizzBuzz' # ============================================================================== # The list of command line options supported by Jaunch out of the box. # +# TODO: Update this section to be unspecific to JVM programs. +# # These are arguments that Jaunch will interpret, transforming them in various ways # into arguments to the Java Virtual Machine (JVM) and/or main class that is launched. # @@ -91,6 +91,9 @@ supported-options = [ '--java-home=|specify JAVA_HOME explicitly', '--print-java-home|print path to the selected Java', '--print-java-info|print information about the selected Java', + '--python-home=|specify PYTHON_HOME explicitly', + '--print-python-home|print path to the selected Python', + '--print-python-info|print information about the selected Python', '--print-app-dir|print directory where the application is located', '--headless|run in text mode', "--heap,--mem,--memory=|set Java's heap size to (e.g. 512M)", @@ -122,6 +125,8 @@ arch-aliases = [ # ============================================================================== # List of additional hints to enable or disable based on other hints. # +# TODO: Rework this section to introduce hints, rather than referring to below. +# # See "jvm.root-paths" below for an overview of hints. # # With modes, you can set a single hint in response to several different other hints, @@ -151,6 +156,8 @@ modes = [] # ============================================================================== # Commands that control Jaunch's launching behavior. # +# TODO: Rewrite this section to be nicer and more up to date. +# # Each one runs at a particular (hardcoded) time during configuration. # Directives unsupported by the configurator program are ignored. # @@ -171,6 +178,12 @@ modes = [] # * print-app-dir - Print out the path to the application. Typically, this will be # the folder containing the launcher. +# * PYTHON - Launches the program using the Python interpreter. +# * print-python-home - Print out the path to the chosen Python installation. +# * print-python-info - Print out all the details of the chosen Python installation, +# including not only its path, but also the distro, version, +# operating system, CPU architecture, and other metadata fields. + # * JVM - Launches the program using the Java Virtual Machine. # * print-java-home - Print out the path to the chosen Java installation. # * print-java-info - Print out all the details of the chosen Java installation, @@ -190,11 +203,12 @@ directives = [ '-h|help,STOP', # global '--print-app-dir|print-app-dir', # global '--print-config-dir|print-config-dir', # global - '--dry-run|dry-run,STOP', # jvm + '--dry-run|dry-run,STOP', # python, jvm + '--print-python-home|print-python-home', # python + '--print-python-info|print-python-info', # python '--print-classpath|print-classpath', # jvm '--print-java-home|print-java-home', # jvm '--print-java-info|print-java-info', # jvm - 'JVM', ] # ============================================================================== @@ -232,8 +246,161 @@ directives = [ # supported options. In such cases, this option is here for you. allow-unrecognized-args = false +# ============================================================================== +# Here begins the fields for controlling the launch of Python-based programs. +# For JVM-specific fields, skip to the `jvm` section below. +# ============================================================================== + +python.recognized-args = [ + '-b', '-bb', + '-B', + '-c cmd', + '-d', + '-E', + '-h', '-?', # --help is reserved for Jaunch + '-i', + '-I', + '-m mod', + '-O', + '-OO', + '-q', + '-s', + '-S', + '-u', + '-v', + '-V', # --version is reserved for Jaunch + '-W arg', + '-x', + '-X opt', + '--check-hash-based-pycs always|default|never', +] + +# ============================================================================== +# Paths to check for Python installations. +# +# This is a list of directories where Jaunch might hope to find a Python installation. +# Directories are checked sequentially until one is found that matches all criteria. +# +# This is also the first field where we see Jaunch's hints/rules system in action. +# Each entry on the root-dirs list may be prefixed with string separated by pipes. +# Each segment is a *hint* for Jaunch regarding a flag that must be set for that +# particular line to be considered. Jaunch sets hint flags based on a few sources: +# +# * Active operating system: OS:LINUX, OS:MACOSX, OS:WINDOWS, +# OS:IOS, OS:ANDROID, OS:WASM, OS:TVOS, OS:WATCHOS, or OS:UNKNOWN. +# +# * Active CPU architecture: ARCH:ARM32, ARCH:ARM64, ARCH:X86, ARCH:X64, +# ARCH:MIPS32, ARCH:MIPSEL32, ARCH:WASM32, or ARCH:UNKNOWN. + +# * Option hints, set from arguments passed to Jaunch, each of which sets a matching +# hint. For example, passing the --headless option will set a hint '--headless'. +# +# * Mode hints, set from evaluation of the modes field (see below). +# +# * Python hints, based on the Python installation selected: +# - PYTHON:3.9 if the selected Python installation is version 3.9. +# - PYTHON:3.9+ if the selected Python installation is version 3.9 or later. +# - PYTHON:3.10 if the selected Python installation is version 3.10. +# - PYTHON:3.10+ if the selected Python installation is version 3.10 or later. +# - and so on. +# Of course, Python hints will only be set after a Python installation matches, +# so they won't work here in python.root-paths, nor in python.lib-suffixes. +# But they can be useful in the python.runtime-args section to ensure Jaunch +# passes runtime args only to those versions of Python that support them, +# such as the -P flag which was introduced in Python 3.11. +# +# Finally, a segment prefixed by a bang symbol (!) negates the hint, +# making that line match only when that particular hint is *not* set. +# +# For example, consider the following python.root-paths line: +# +# '!--system|OS:LINUX|ARCH:X64|python/linux64', +# +# The applicable hints are !--system, OS:LINUX, and ARCH:X64, so the root path +# of python/linux64 will only be considered on 64-bit Linux systems, and only +# when the --system option was *not* given as part of the launcher invocation. +# +# This is also the first field where we see Jaunch's variables in use: +# +# '--python-home|${python-home}' +# +# Thanks to the above line, when the user passes '--python-home=/best', the +# --python-home hint will be set, and the python-home variable will be set to /best. +# So not only will the line match, but the root path to check will become /best. +python.root-paths = [ + '--python-home|${python-home}', + '${PYTHON_HOME}', + '~/miniforge3/envs/*', + '~/mambaforge/envs/*', + '${app-dir}/python', + '${app-dir}/lib/runtime', +] + +# TODO: Verify this list for all platforms. +python.lib-suffixes = [ + 'OS:LINUX|lib/libpython3.so', + 'OS:MACOSX|lib/libpython3.dylib', + 'OS:WINDOWS|lib\libpython3.dll', +] + +# ============================================================================== +# Acceptable range of Python versions to match. +# +# These two fields let you constrain the minimum and maximum Python versions +# respectively that your application supports. This information will be used when +# searching the system for appropriate Python installations. If a Python installation +# is successfully discovered, but then found to be outside these constraints, it is +# discarded and the search continues. +# +# The most common use of these fields is to specify a major.minor version pair +# (e.g. `python.version-min = '3.9'`), but Jaunch does compare version strings digit by +# digit, so you could write `python.version-min = '3.8.5' if you need to be specific. +python.version-min = '3.8' +#python.version-max = '3.12' + +# ============================================================================== +# Packages that must be present in the Python installation. +# +# TODO +python.packages = [] + +# ============================================================================== +# Arguments to pass to Python. +# +# This is the magic sauce where Jaunch options and other criteria get translated +# into Python arguments. See 'python.root-paths' above for a thorough explanation. +python.runtime-args = [] + +# ============================================================================== +# A list of paths to candidate main scripts, one of which will get launched. +# +# Jaunch evaluates the rules attached to each candidate main script. The first +# line with matching rules becomes the main script, with subsequent lines ignored. +# +# This field is useful if you want to launch a different main script depending on +# criteria such as OS, CPU architecture, or which options are given on the CLI. +#python.main-script = [ +# '--fizzbuzz|fizzbuzz.py' +# '--main-script|${main-script}', +# 'main.py', # default behavior +#] + +# ============================================================================== +# Arguments to pass to the main class on the Python side. +# +# This is the other half of the magic sauce, along with python.runtime-args above: +# Options and other criteria get translated into main arguments here. +# See the 'python.root-paths' section above for a thorough explanation. +#python.main-args = [ +# '!--fizz|!--buzz|--mode=number', +# '--fizz|!--buzz|--mode=fizz', +# '--buzz|!--fizz|--mode=buzz', +# '--fizz|--buzz|--mode=fizzbuzz', +#] + # ============================================================================== # Now come the fields for controlling the launch of JVM-based programs. +# For Python-specific fields, jump back up to the `python` section above. # ============================================================================== # ============================================================================== diff --git a/src/commonMain/kotlin/config.kt b/src/commonMain/kotlin/config.kt index d9dbc15..02daf34 100644 --- a/src/commonMain/kotlin/config.kt +++ b/src/commonMain/kotlin/config.kt @@ -39,6 +39,39 @@ data class JaunchConfig ( /** Whether to allow unrecognized arguments to be passed to the runtime. */ val allowUnrecognizedArgs: Boolean? = null, + // -- Python-specific configuration fields -- + + /** + * The list of arguments that Jaunch will recognize as belonging to the Python interpreter, + * as opposed to the application's main program. + */ + val pythonRecognizedArgs: Array = emptyArray(), + + /** Paths to check for Python installations. */ + val pythonRootPaths: Array = emptyArray(), + + /** List of places within a Python installation to look for the Python library. */ + val pythonLibSuffixes: Array = emptyArray(), + + /** Minimum acceptable Python version to match. */ + val pythonVersionMin: String? = null, + + /** Maximum acceptable Python version to match. */ + val pythonVersionMax: String? = null, + + /** List of packages that must be present in a suitable Python installation. */ + val pythonPackages: Array = emptyArray(), + + /** Arguments to pass to the Python runtime. */ + val pythonRuntimeArgs: Array = emptyArray(), + + /** A list of candidate Python scripts, one of which will get launched. */ + val pythonMainScript: Array = emptyArray(), + + /** Arguments to pass to the Python program itself. */ + val pythonMainArgs: Array = emptyArray(), + + // -- JVM-specific configuration fields -- /** @@ -116,6 +149,16 @@ data class JaunchConfig ( directives = config.directives + directives, allowUnrecognizedArgs = config.allowUnrecognizedArgs ?: allowUnrecognizedArgs, + pythonRecognizedArgs = config.pythonRecognizedArgs + pythonRecognizedArgs, + pythonRootPaths = config.pythonRootPaths + pythonRootPaths, + pythonLibSuffixes = config.pythonLibSuffixes + pythonLibSuffixes, + pythonVersionMin = config.pythonVersionMin ?: pythonVersionMin, + pythonVersionMax = config.pythonVersionMax ?: pythonVersionMax, + pythonPackages = config.pythonPackages + pythonPackages, + pythonRuntimeArgs = config.pythonRuntimeArgs + pythonRuntimeArgs, + pythonMainScript = config.pythonMainScript + pythonMainScript, + pythonMainArgs = config.pythonMainArgs + pythonMainArgs, + jvmRecognizedArgs = config.jvmRecognizedArgs + jvmRecognizedArgs, jvmAllowWeirdRuntimes = config.jvmAllowWeirdRuntimes ?: jvmAllowWeirdRuntimes, jvmVersionMin = config.jvmVersionMin ?: jvmVersionMin, @@ -150,6 +193,15 @@ fun readConfig(tomlFile: File): JaunchConfig { var modes: List? = null var directives: List? = null var allowUnrecognizedArgs: Boolean? = null + var pythonRecognizedArgs: List? = null + var pythonRootPaths: List? = null + var pythonLibSuffixes: List? = null + var pythonVersionMin: String? = null + var pythonVersionMax: String? = null + var pythonPackages: List? = null + var pythonRuntimeArgs: List? = null + var pythonMainScript: List? = null + var pythonMainArgs: List? = null var jvmRecognizedArgs: List? = null var jvmAllowWeirdRuntimes: Boolean? = null var jvmVersionMin: String? = null @@ -193,6 +245,15 @@ fun readConfig(tomlFile: File): JaunchConfig { "modes" -> modes = asList(value) "directives" -> directives = asList(value) "allow-unrecognized-args" -> allowUnrecognizedArgs = asBoolean(value) + "python.recognized-args" -> pythonRecognizedArgs = asList(value) + "python.root-paths" -> pythonRootPaths = asList(value) + "python.lib-suffixes" -> pythonLibSuffixes = asList(value) + "python.version-min" -> pythonVersionMin = asString(value) + "python.version-max" -> pythonVersionMax = asString(value) + "python.packages" -> pythonPackages = asList(value) + "python.runtime-args" -> pythonRuntimeArgs = asList(value) + "python.script-path" -> pythonMainScript = asList(value) + "python.main-args" -> pythonMainArgs = asList(value) "jvm.recognized-args" -> jvmRecognizedArgs = asList(value) "jvm.allow-weird-runtimes" -> jvmAllowWeirdRuntimes = asBoolean(value) "jvm.version-min" -> jvmVersionMin = asString(value) @@ -222,6 +283,15 @@ fun readConfig(tomlFile: File): JaunchConfig { modes = asArray(modes), directives = asArray(directives), allowUnrecognizedArgs = allowUnrecognizedArgs, + pythonRecognizedArgs = asArray(pythonRecognizedArgs), + pythonRootPaths = asArray(pythonRootPaths), + pythonLibSuffixes = asArray(pythonLibSuffixes), + pythonVersionMin = pythonVersionMin, + pythonVersionMax = pythonVersionMax, + pythonPackages = asArray(pythonPackages), + pythonRuntimeArgs = asArray(pythonRuntimeArgs), + pythonMainScript = asArray(pythonMainScript), + pythonMainArgs = asArray(pythonMainArgs), jvmRecognizedArgs = asArray(jvmRecognizedArgs), jvmAllowWeirdRuntimes = jvmAllowWeirdRuntimes, jvmVersionMin = jvmVersionMin, diff --git a/src/commonMain/kotlin/jvm.kt b/src/commonMain/kotlin/jvm.kt index d6af166..c3e8446 100644 --- a/src/commonMain/kotlin/jvm.kt +++ b/src/commonMain/kotlin/jvm.kt @@ -129,8 +129,6 @@ class JvmRuntimeConfig(recognizedArgs: Array) : val mainClassNames = calculate(config.jvmMainClass, hints, vars) mainProgram = mainClassNames.firstOrNull() debug("mainProgram -> ", mainProgram ?: "") - if (mainProgram == null) - error("No matching main class name") // Calculate main args. mainArgs += calculate(config.jvmMainArgs, hints, vars) @@ -139,17 +137,15 @@ class JvmRuntimeConfig(recognizedArgs: Array) : this.java = java } - override fun launch(): String { + override fun launch(args: ProgramArgs): List { val libjvmPath = java?.libjvmPath ?: error("No matching Java installations found.") val mainClass = mainProgram ?: error("No Java main program specified.") - return buildString { - appendLine(directive) - appendLine(runtimeArgs.size + mainArgs.size + 3) - appendLine(libjvmPath) - appendLine(runtimeArgs.size) - for (arg in runtimeArgs) appendLine(arg) - appendLine(mainClass.replace(".", "/")) - for (mainArg in mainArgs) appendLine(mainArg) + return buildList { + add(libjvmPath) + add(args.runtime.size.toString()) + addAll(args.runtime) + add(mainClass.replace(".", "/")) + addAll(args.main) } } diff --git a/src/commonMain/kotlin/main.kt b/src/commonMain/kotlin/main.kt index d520900..e588e01 100644 --- a/src/commonMain/kotlin/main.kt +++ b/src/commonMain/kotlin/main.kt @@ -345,13 +345,14 @@ private fun configureRuntimes( configDir: File, hints: MutableSet, vars: MutableMap -): List { +): List { debug() debug("Configuring runtimes...") // Define the list of supported runtimes. val runtimes = listOf( JvmRuntimeConfig(config.jvmRecognizedArgs), + PythonRuntimeConfig(config.pythonRecognizedArgs), ) // Discover the runtime installations. @@ -442,6 +443,19 @@ private fun unknownArg( private fun interpolateArgs(argsInContext: Map, vars: Map) { // TODO: something ;-) + /* + for (args in argsInContext.values) { + val nooRuntime = mutableListOf() + for (arg in args.runtime) nooRuntime += interpolate(arg) + args.runtime.clear() + args.runtime += nooRuntime + + val nooMain = mutableListOf() + for (arg in args.main) nooMain += interpolate(arg) + args.main.clear() + args.main += nooMain + } + */ } private fun executeDirectives( @@ -477,9 +491,15 @@ private fun executeDirectives( // Emit launch-side directives. debug() debug("Emitting launch directives to stdout...") - for (directive in launchDirectives) { + // HACK: If STOP appears in the launch directives, don't also launch other things. + // Further thought and config wrangling needed, but it gets the job done for now. + val tweakedLaunchDirectives = if ("STOP" in launchDirectives) listOf("STOP") else launchDirectives + for (directive in tweakedLaunchDirectives) { val runtime = runtimes.firstOrNull { it.directive == directive } - println(runtime?.launch() ?: directive) + val lines = runtime?.launch(argsInContext[runtime.prefix]!!) ?: emptyList() + println(directive) + println(lines.size) + lines.forEach { println(it) } } } @@ -497,7 +517,7 @@ private fun help(exeFile: File?, programName: String, supportedOptions: JaunchOp printlnErr("Usage: $exeName [.. --] [
..]") printlnErr() printlnErr("$programName launcher (Jaunch v$JAUNCH_VERSION / $JAUNCH_BUILD / $BUILD_TARGET)") - printlnErr("Runtime options are passed to the Java Runtime,") + printlnErr("Runtime options are passed to the runtime platform (JVM or Python),") printlnErr("main arguments to the launched program ($programName).") printlnErr() printlnErr("In addition, the following options are supported:") diff --git a/src/commonMain/kotlin/python.kt b/src/commonMain/kotlin/python.kt new file mode 100644 index 0000000..edeaef1 --- /dev/null +++ b/src/commonMain/kotlin/python.kt @@ -0,0 +1,253 @@ +// Logic for discovery and inspection of Python installations. + +import kotlin.math.min + +data class PythonConstraints( + val configDir: File, + val libSuffixes: List, + val versionMin: String?, + val versionMax: String?, +) + +class PythonRuntimeConfig(recognizedArgs: Array) : + RuntimeConfig("python", "PYTHON", recognizedArgs) +{ + var python: PythonInstallation? = null + + override val supportedDirectives: DirectivesMap = mutableMapOf( + "dry-run" to { printlnErr(dryRun()) }, + "print-python-home" to { printlnErr(pythonHome()) }, + "print-python-info" to { printlnErr(pythonInfo()) }, + ) + + override fun configure( + configDir: File, + config: JaunchConfig, + hints: MutableSet, + vars: MutableMap + ) { + // Calculate all the places to search for Python. + val pythonRootPaths = calculate(config.pythonRootPaths, hints, vars) + .flatMap { glob(it) } + .filter { File(it).isDirectory } + .toSet() + + debug() + debug("Root paths to search for Python:") + pythonRootPaths.forEach { debug("* ", it) } + + // Calculate all the places to look for the Python library. + val libPythonSuffixes = calculate(config.pythonLibSuffixes, hints, vars) + + debug() + debug("Suffixes to check for libpython:") + libPythonSuffixes.forEach { debug("* ", it) } + + // Calculate Python distro and version constraints. + val constraints = PythonConstraints( + configDir, + libPythonSuffixes, + config.pythonVersionMin, config.pythonVersionMax + ) + + // Discover Python. + debug() + debug("Discovering Python installations...") + var python: PythonInstallation? = null + for (pythonPath in pythonRootPaths) { + debug("Analyzing candidate Python directory: '", pythonPath, "'") + val pythonCandidate = PythonInstallation(pythonPath, constraints) + if (pythonCandidate.conforms) { + // Installation looks good! Moving on. + python = pythonCandidate + break + } + } + if (python == null) error("No Python installation found.") + debug("Successfully discovered Python installation:") + debug("* rootPath -> ", python.rootPath) + debug("* libPythonPath -> ", python.libPythonPath ?: "") + debug("* binPython -> ", python.binPython ?: "") + + // Apply PYTHON: hints. + val majorMinor = python.majorMinorVersion + if (majorMinor != null) { + val (major, minor) = majorMinor + hints += "PYTHON:$major.$minor" + // If minor version is OVER 9000, something went wrong in the parsing. + // Let's not explode the hints set with too many bogus values. + for (v in 0..min(minor, 9000)) hints += "PYTHON:$major.$v+" + } + debug("* hints -> ", hints) + + // Calculate runtime arguments. + runtimeArgs += calculate(config.pythonRuntimeArgs, hints, vars) + debugList("Python arguments calculated:", runtimeArgs) + + // Calculate main script. + debug() + debug("Calculating main script path...") + val mainScriptPaths = calculate(config.pythonMainScript, hints, vars) + mainProgram = mainScriptPaths.firstOrNull() + debug("mainProgram -> ", mainProgram ?: "") + + // Calculate main args. + mainArgs += calculate(config.pythonMainArgs, hints, vars) + debugList("Main arguments calculated:", mainArgs) + + this.python = python + } + + override fun launch(args: ProgramArgs): List { + val libPythonPath = python?.libPythonPath ?: error("No matching Python installations found.") + return buildList { + add(libPythonPath) + addAll(args.runtime) + if (mainProgram != null) add(mainProgram!!) + addAll(args.main) + } + } + + // -- Directive handlers -- + + fun dryRun(): String { + return buildString { + append(python?.binPython ?: "python") + runtimeArgs.forEach { append(" $it") } + append(" $mainProgram") + mainArgs.forEach { append(" $it") } + } + } + + fun pythonHome(): String { + return python?.rootPath ?: error("No matching Python installations found.") + } + + fun pythonInfo(): String { + return python?.toString() ?: error("No matching Python installations found.") + } +} + +/** + * A Python installation, rooted at a particular directory. + * + * This class contains heuristics for discerning the Python installation's version + * and installed packages, by invoking the Python binary and reading the output. + */ +class PythonInstallation( + val rootPath: String, + val constraints: PythonConstraints, +) { + val libPythonPath: String? by lazy { findLibPython() } + val binPython: String? by lazy { findBinPython() } + val version: String? by lazy { guessPythonVersion() } + val packages: Map by lazy { guessInstalledPackages() } + val conforms: Boolean by lazy { checkConstraints() } + + /** Gets the major.minor version digits of the Python installation. */ + val majorMinorVersion: Pair? + get() { + val digits = versionDigits(version ?: return null) + return if (digits.size < 2) null else Pair(digits[0], digits[1]) + } + + override fun toString(): String { + return listOf( + "root: $rootPath", + "libPython: $libPythonPath", + "version: $version", + "packages:${bulletList(packages)}", + ).joinToString(NL) + } + + // -- Lazy evaluation functions -- + + private fun findLibPython(): String? { + return constraints.libSuffixes.map { File("$rootPath$SLASH$it") }.firstOrNull { it.exists }?.path + } + + private fun findBinPython(): String? { + val extension = if (OS_NAME == "WINDOWS") ".exe" else "" + for (candidate in arrayOf("python", "python3", "bin${SLASH}python", "bin${SLASH}python3")) { + val pythonFile = File("$rootPath$SLASH$candidate$extension") + if (pythonFile.exists) return pythonFile.path + } + return null + } + + private fun guessPythonVersion(): String { + return guess("Python version") { askPythonForVersion() ?: "" } + } + + private fun guessInstalledPackages(): Map { + return guess("installed packages") { askPipForPackages() } + } + + private fun guess(label: String, doGuess: () -> T): T { + debug("Guessing $label...") + val result = doGuess() + debug("-> $label: $result") + return result + } + + /** Calls `python --version` to be told the Python version from the boss. */ + private fun askPythonForVersion(): String? { + val pythonExe = binPython + if (pythonExe == null) { + debug("Python executable does not exist") + return null + } + debug("Invoking `\"", pythonExe, "\" --version`...") + val line = execute("\"$pythonExe\" --version")?.get(0) ?: return null + val versionPattern = Regex("(\\d+\\.\\d+\\.\\d+[^ ]*)") + return versionPattern.find(line)?.value + } + + private fun askPipForPackages(): Map { + val pythonExe = binPython + if (pythonExe == null) { + debug("Python executable does not exist") + return emptyMap() + } + debug("Invoking `\"", pythonExe, "\" -m pip list`...") + val lines = execute("\"$pythonExe\" -m pip list") ?: emptyList() + // Start at index 2 to skip the table headers. + return lines.subList(min(2, lines.size), lines.size) + .map { it.split(Regex("\\s+")) } + .associate { it[0] to if (it.isEmpty()) "" else it[1] } + } + + private fun checkConstraints(): Boolean { + // Ensure libpython is present. + if (libPythonPath == null) return fail("No Python library found.") + + // Check Python version constraints. + if (constraints.versionMin != null || constraints.versionMax != null) { + if (version == null) + return fail("Version constraints exist, but version is unknown.") + if (version != null) { + if (versionOutOfBounds(version!!, constraints.versionMin, constraints.versionMax)) + return fail("Version '$version' is outside specified bounds " + + "[${constraints.versionMin}, ${constraints.versionMax}].") + } + } + + // Check installed package constraints. + // TODO: Actually check packages. ;-) + + // All checks passed! + return true + } + + // -- Helper methods -- + + private fun bulletList(map: Map?, bullet: String = "* "): String { + return when { + map == null -> " " + map.isEmpty() -> " " + else -> "$NL$bullet" + map.entries.joinToString("$NL$bullet") + } + } + + private fun fail(vararg args: Any): Boolean { debug(*args); return false } +} diff --git a/src/commonMain/kotlin/runtime.kt b/src/commonMain/kotlin/runtime.kt index f3f0271..fc87b7a 100644 --- a/src/commonMain/kotlin/runtime.kt +++ b/src/commonMain/kotlin/runtime.kt @@ -38,7 +38,7 @@ abstract class RuntimeConfig( ) /** Gets the launch directive block for this runtime configuration. */ - abstract fun launch(): String + abstract fun launch(args: ProgramArgs): List /** * Attempt to execute the given directive.