diff --git a/modules/nextflow/src/main/groovy/nextflow/Session.groovy b/modules/nextflow/src/main/groovy/nextflow/Session.groovy index d45f26c528..292376183e 100644 --- a/modules/nextflow/src/main/groovy/nextflow/Session.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/Session.groovy @@ -61,6 +61,7 @@ import nextflow.script.ScriptFile import nextflow.script.ScriptMeta import nextflow.script.ScriptRunner import nextflow.script.WorkflowMetadata +import nextflow.pixi.PixiConfig import nextflow.spack.SpackConfig import nextflow.trace.AnsiLogObserver import nextflow.trace.TraceObserver @@ -1194,6 +1195,12 @@ class Session implements ISession { return new SpackConfig(cfg, getSystemEnv()) } + @Memoized + PixiConfig getPixiConfig() { + final cfg = config.pixi as Map ?: Collections.emptyMap() + return new PixiConfig(cfg, getSystemEnv()) + } + /** * Get the container engine configuration for the specified engine. If no engine is specified * if returns the one enabled in the configuration file. If no configuration is found diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy index d096e678e6..3d446e5ac3 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy @@ -250,6 +250,12 @@ class CmdRun extends CmdBase implements HubOptions { @Parameter(names=['-without-spack'], description = 'Disable the use of Spack environments') Boolean withoutSpack + @Parameter(names=['-with-pixi'], description = 'Use the specified Pixi environment package or file (must end with .toml suffix)') + String withPixi + + @Parameter(names=['-without-pixi'], description = 'Disable the use of Pixi environments') + Boolean withoutPixi + @Parameter(names=['-offline'], description = 'Do not check for remote project updates') boolean offline = System.getenv('NXF_OFFLINE')=='true' diff --git a/modules/nextflow/src/main/groovy/nextflow/config/ConfigBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/config/ConfigBuilder.groovy index 0341f73523..1b8ea75d0f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/config/ConfigBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/config/ConfigBuilder.groovy @@ -193,7 +193,7 @@ class ConfigBuilder { def result = [] if ( files ) { - for( String fileName : files ) { + for( String fileName : files ) { def thisFile = currentDir.resolve(fileName) if(!thisFile.exists()) { throw new AbortOperationException("The specified configuration file does not exist: $thisFile -- check the name or choose another file") @@ -597,6 +597,19 @@ class ConfigBuilder { config.spack.enabled = true } + if( cmdRun.withoutPixi && config.pixi instanceof Map ) { + // disable pixi execution + log.debug "Disabling execution with Pixi as requested by command-line option `-without-pixi`" + config.pixi.enabled = false + } + + // -- apply the pixi environment + if( cmdRun.withPixi ) { + if( cmdRun.withPixi != '-' ) + config.process.pixi = cmdRun.withPixi + config.pixi.enabled = true + } + // -- sets the resume option if( cmdRun.resume ) config.resume = cmdRun.resume @@ -872,7 +885,7 @@ class ConfigBuilder { final value = entry.value final previous = getConfigVal0(config, key) keys << entry.key - + if( previous==null ) { config[key] = value } diff --git a/modules/nextflow/src/main/groovy/nextflow/executor/BashWrapperBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/executor/BashWrapperBuilder.groovy index 793560a61d..483e4f810d 100644 --- a/modules/nextflow/src/main/groovy/nextflow/executor/BashWrapperBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/executor/BashWrapperBuilder.groovy @@ -335,6 +335,7 @@ class BashWrapperBuilder { binding.before_script = getBeforeScriptSnippet() binding.conda_activate = getCondaActivateSnippet() binding.spack_activate = getSpackActivateSnippet() + binding.pixi_activate = getPixiActivateSnippet() /* * add the task environment @@ -388,7 +389,7 @@ class BashWrapperBuilder { binding.fix_ownership = fixOwnership() ? "[ \${NXF_OWNER:=''} ] && (shopt -s extglob; GLOBIGNORE='..'; chown -fR --from root \$NXF_OWNER ${workDir}/{*,.*}) || true" : null binding.trace_script = isTraceRequired() ? getTraceScript(binding) : null - + return binding } @@ -554,6 +555,29 @@ class BashWrapperBuilder { return result } + private String getPixiActivateSnippet() { + if( !pixiEnv ) + return null + def result = "# pixi environment\n" + + // Check if there's a .pixi file that points to the project directory + final pixiFile = pixiEnv.resolve('.pixi') + if( pixiFile.exists() ) { + // Read the project directory path + final projectDir = pixiFile.text.trim() + result += "cd ${Escape.path(projectDir as String)} && " + result += "eval \"\$(pixi shell-hook --shell bash)\" && " + result += "cd \"\$OLDPWD\"\n" + } + else { + // Direct activation from environment directory + result += "cd ${Escape.path(pixiEnv)} && " + result += "eval \"\$(pixi shell-hook --shell bash)\" && " + result += "cd \"\$OLDPWD\"\n" + } + return result + } + protected String getTraceCommand(String interpreter) { String result = "${interpreter} ${fileStr(scriptFile)}" if( input != null ) @@ -622,7 +646,7 @@ class BashWrapperBuilder { private String copyFileToWorkDir(String fileName) { copyFile(fileName, workDir.resolve(fileName)) } - + String getCleanupCmd(String scratch) { String result = '' diff --git a/modules/nextflow/src/main/groovy/nextflow/pixi/PixiCache.groovy b/modules/nextflow/src/main/groovy/nextflow/pixi/PixiCache.groovy new file mode 100644 index 0000000000..e9aa316e1d --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/pixi/PixiCache.groovy @@ -0,0 +1,367 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.pixi + +import java.nio.file.FileSystems +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.ConcurrentHashMap + +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j +import groovyx.gpars.dataflow.DataflowVariable +import groovyx.gpars.dataflow.LazyDataflowVariable +import nextflow.Global +import nextflow.file.FileMutex +import nextflow.util.CacheHelper +import nextflow.util.Duration +import nextflow.util.Escape +import nextflow.util.TestOnly + +/** + * Handle Pixi environment creation and caching + * + * @author Edmund Miller + */ +@Slf4j +@CompileStatic +class PixiCache { + + /** + * Cache the prefix path for each Pixi environment + */ + static final private Map> pixiPrefixPaths = new ConcurrentHashMap<>() + + /** + * The Pixi settings defined in the nextflow config file + */ + private PixiConfig config + + /** + * Timeout after which the environment creation is aborted + */ + private Duration createTimeout = Duration.of('20min') + + private String createOptions + + private Path configCacheDir0 + + @PackageScope String getCreateOptions() { createOptions } + + @PackageScope Duration getCreateTimeout() { createTimeout } + + @PackageScope Map getEnv() { System.getenv() } + + @PackageScope Path getConfigCacheDir0() { configCacheDir0 } + + @TestOnly + protected PixiCache() {} + + /** + * Create a Pixi env cache object + * + * @param config A {@link PixiConfig} object + */ + PixiCache(PixiConfig config) { + this.config = config + + if( config.createTimeout() ) + createTimeout = config.createTimeout() + + if( config.createOptions() ) + createOptions = config.createOptions() + + if( config.cacheDir() ) + configCacheDir0 = config.cacheDir().toAbsolutePath() + } + + /** + * Retrieve the directory where store the pixi environment. + * + * If tries these setting in the following order: + * 1) {@code pixi.cacheDir} setting in the nextflow config file; + * 2) the {@code $workDir/pixi} path + * + * @return + * the {@code Path} where store the pixi envs + */ + @PackageScope + Path getCacheDir() { + + def cacheDir = configCacheDir0 + + if( !cacheDir && getEnv().NXF_PIXI_CACHEDIR ) + cacheDir = getEnv().NXF_PIXI_CACHEDIR as Path + + if( !cacheDir ) + cacheDir = getSessionWorkDir().resolve('pixi') + + if( cacheDir.fileSystem != FileSystems.default ) { + throw new IOException("Cannot store Pixi environments to a remote work directory -- Use a POSIX compatible work directory or specify an alternative path with the `pixi.cacheDir` config setting") + } + + if( !cacheDir.exists() && !cacheDir.mkdirs() ) { + throw new IOException("Failed to create Pixi cache directory: $cacheDir -- Make sure a file with the same name does not exist and you have write permission") + } + + return cacheDir + } + + @PackageScope Path getSessionWorkDir() { + Global.session.workDir + } + + @PackageScope + boolean isTomlFilePath(String str) { + str.endsWith('.toml') && !str.contains('\n') + } + + @PackageScope + boolean isLockFilePath(String str) { + str.endsWith('.lock') && !str.contains('\n') + } + + /** + * Get the path on the file system where store a Pixi environment + * + * @param pixiEnv The pixi environment + * @return the pixi unique prefix {@link Path} where the env is created + */ + @PackageScope + Path pixiPrefixPath(String pixiEnv) { + assert pixiEnv + + String content + String name = 'env' + + // check if it's a TOML file (pixi.toml or pyproject.toml) + if( isTomlFilePath(pixiEnv) ) { + try { + final path = pixiEnv as Path + content = path.text + name = path.baseName + } + catch( NoSuchFileException e ) { + throw new IllegalArgumentException("Pixi environment file does not exist: $pixiEnv") + } + catch( Exception e ) { + throw new IllegalArgumentException("Error parsing Pixi environment TOML file: $pixiEnv -- Check the log file for details", e) + } + } + // check if it's a lock file (pixi.lock) + else if( isLockFilePath(pixiEnv) ) { + try { + final path = pixiEnv as Path + content = path.text + name = path.baseName + } + catch( NoSuchFileException e ) { + throw new IllegalArgumentException("Pixi lock file does not exist: $pixiEnv") + } + catch( Exception e ) { + throw new IllegalArgumentException("Error parsing Pixi lock file: $pixiEnv -- Check the log file for details", e) + } + } + // it's interpreted as user provided prefix directory + else if( pixiEnv.contains('/') ) { + final prefix = pixiEnv as Path + if( !prefix.isDirectory() ) + throw new IllegalArgumentException("Pixi prefix path does not exist or is not a directory: $prefix") + if( prefix.fileSystem != FileSystems.default ) + throw new IllegalArgumentException("Pixi prefix path must be a POSIX file path: $prefix") + + return prefix + } + else if( pixiEnv.contains('\n') ) { + throw new IllegalArgumentException("Invalid Pixi environment definition: $pixiEnv") + } + else { + // it's interpreted as a package specification + content = pixiEnv + } + + final hash = CacheHelper.hasher(content).hash().toString() + getCacheDir().resolve("$name-$hash") + } + + /** + * Run the pixi tool to create an environment in the file system. + * + * @param pixiEnv The pixi environment definition + * @return the pixi environment prefix {@link Path} + */ + @PackageScope + Path createLocalPixiEnv(String pixiEnv, Path prefixPath) { + + if( prefixPath.isDirectory() ) { + log.debug "pixi found local env for environment=$pixiEnv; path=$prefixPath" + return prefixPath + } + + final file = new File("${prefixPath.parent}/.${prefixPath.name}.lock") + final wait = "Another Nextflow instance is creating the pixi environment $pixiEnv -- please wait till it completes" + final err = "Unable to acquire exclusive lock after $createTimeout on file: $file" + + final mutex = new FileMutex(target: file, timeout: createTimeout, waitMessage: wait, errorMessage: err) + try { + mutex .lock { createLocalPixiEnv0(pixiEnv, prefixPath) } + } + finally { + file.delete() + } + + return prefixPath + } + + @PackageScope + Path makeAbsolute( String envFile ) { + Paths.get(envFile).toAbsolutePath() + } + + @PackageScope + Path createLocalPixiEnv0(String pixiEnv, Path prefixPath) { + log.info "Creating env using pixi: $pixiEnv [cache $prefixPath]" + + String opts = createOptions ? "$createOptions " : '' + + def cmd + if( isTomlFilePath(pixiEnv) || isLockFilePath(pixiEnv) ) { + final target = Escape.path(makeAbsolute(pixiEnv)) + final projectDir = makeAbsolute(pixiEnv).parent + + // Create environment from project file + cmd = "cd ${Escape.path(projectDir)} && pixi install ${opts}" + + // Set up the environment directory + prefixPath.mkdirs() + final envLink = prefixPath.resolve('.pixi') + if( !envLink.exists() ) { + envLink.toFile().createNewFile() + envLink.write(projectDir.toString()) + } + } + else { + // Create environment from package specification + prefixPath.mkdirs() + final manifestFile = prefixPath.resolve('pixi.toml') + + // Create a simple pixi.toml with the requested packages + manifestFile.text = """\ +[project] +name = "nextflow-env" +version = "0.1.0" +description = "Nextflow generated Pixi environment" +channels = ["conda-forge"] + +[dependencies] +${pixiEnv} +""".stripIndent() + + cmd = "cd ${Escape.path(prefixPath)} && pixi install ${opts}" + } + + try { + runCommand( cmd ) + log.debug "'pixi' create complete env=$pixiEnv path=$prefixPath" + } + catch( Exception e ){ + // clean-up to avoid to keep eventually corrupted image file + prefixPath.delete() + throw e + } + return prefixPath + } + + @PackageScope + int runCommand( String cmd ) { + log.trace """pixi create + command: $cmd + timeout: $createTimeout""".stripIndent(true) + + final max = createTimeout.toMillis() + final builder = new ProcessBuilder(['bash','-c',cmd]) + final proc = builder.redirectErrorStream(true).start() + final err = new StringBuilder() + final consumer = proc.consumeProcessOutputStream(err) + proc.waitForOrKill(max) + def status = proc.exitValue() + if( status != 0 ) { + consumer.join() + def msg = "Failed to create Pixi environment\n command: $cmd\n status : $status\n message:\n" + msg += err.toString().trim().indent(' ') + throw new IllegalStateException(msg) + } + return status + } + + /** + * Given a remote image URL returns a {@link DataflowVariable} which holds + * the local image path. + * + * This method synchronise multiple concurrent requests so that only one + * image download is actually executed. + * + * @param pixiEnv + * Pixi environment string + * @return + * The {@link DataflowVariable} which hold (and pull) the local image file + */ + @PackageScope + DataflowVariable getLazyImagePath(String pixiEnv) { + final prefixPath = pixiPrefixPath(pixiEnv) + final pixiEnvPath = prefixPath.toString() + if( pixiEnvPath in pixiPrefixPaths ) { + log.trace "pixi found local environment `$pixiEnv`" + return pixiPrefixPaths[pixiEnvPath] + } + + synchronized (pixiPrefixPaths) { + def result = pixiPrefixPaths[pixiEnvPath] + if( result == null ) { + result = new LazyDataflowVariable({ createLocalPixiEnv(pixiEnv, prefixPath) }) + pixiPrefixPaths[pixiEnvPath] = result + } + else { + log.trace "pixi found local cache for environment `$pixiEnv` (2)" + } + return result + } + } + + /** + * Create a pixi environment caching it in the file system. + * + * This method synchronise multiple concurrent requests so that only one + * environment is actually created. + * + * @param pixiEnv The pixi environment string + * @return the local environment path prefix {@link Path} + */ + Path getCachePathFor(String pixiEnv) { + def promise = getLazyImagePath(pixiEnv) + def result = promise.getVal() + if( promise.isError() ) + throw new IllegalStateException(promise.getError()) + if( !result ) + throw new IllegalStateException("Cannot create Pixi environment `$pixiEnv`") + log.trace "Pixi cache for env `$pixiEnv` path=$result" + return result + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/pixi/PixiConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/pixi/PixiConfig.groovy new file mode 100644 index 0000000000..968d638556 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/pixi/PixiConfig.groovy @@ -0,0 +1,60 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.pixi + +import java.nio.file.Path + +import groovy.transform.CompileStatic +import nextflow.util.Duration + +/** + * Model Pixi configuration + * + * @author Edmund Miller + */ +@CompileStatic +class PixiConfig extends LinkedHashMap { + + private Map env + + /* required by Kryo deserialization -- do not remove */ + private PixiConfig() { } + + PixiConfig(Map config, Map env) { + super(config) + this.env = env + } + + boolean isEnabled() { + def enabled = get('enabled') + if( enabled == null ) + enabled = env.get('NXF_PIXI_ENABLED') + return enabled?.toString() == 'true' + } + + Duration createTimeout() { + get('createTimeout') as Duration + } + + String createOptions() { + get('createOptions') as String + } + + Path cacheDir() { + get('cacheDir') as Path + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskBean.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskBean.groovy index 6c63e6bae5..00a3e9ea90 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskBean.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskBean.groovy @@ -51,6 +51,8 @@ class TaskBean implements Serializable, Cloneable { Path spackEnv + Path pixiEnv + List moduleNames Path workDir @@ -138,6 +140,7 @@ class TaskBean implements Serializable, Cloneable { this.condaEnv = task.getCondaEnv() this.useMicromamba = task.getCondaConfig()?.useMicromamba() this.spackEnv = task.getSpackEnv() + this.pixiEnv = task.getPixiEnv() this.moduleNames = task.config.getModule() this.shell = task.config.getShell() ?: BashWrapperBuilder.BASH this.script = task.getScript() diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy index 20ab76ec36..7ba319f000 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy @@ -51,6 +51,8 @@ import nextflow.script.params.InParam import nextflow.script.params.OutParam import nextflow.script.params.StdInParam import nextflow.script.params.ValueOutParam +import nextflow.pixi.PixiCache +import nextflow.pixi.PixiConfig import nextflow.spack.SpackCache /** * Models a task instance @@ -647,6 +649,25 @@ class TaskRun implements Cloneable { cache.getCachePathFor(config.conda as String) } + Path getPixiEnv() { + // note: use an explicit function instead of a closure or lambda syntax, otherwise + // when calling this method from a subclass it will result into a MissingMethodExeception + // see https://issues.apache.org/jira/browse/GROOVY-2433 + cache0.computeIfAbsent('pixiEnv', new Function() { + @Override + Path apply(String it) { + return getPixiEnv0() + }}) + } + + private Path getPixiEnv0() { + if( !config.pixi || !processor.session.getPixiConfig().isEnabled() ) + return null + + final cache = new PixiCache(processor.session.getPixiConfig()) + cache.getCachePathFor(config.pixi as String) + } + Path getSpackEnv() { // note: use an explicit function instead of a closure or lambda syntax, otherwise // when calling this method from a subclass it will result into a MissingMethodExeception @@ -726,7 +747,7 @@ class TaskRun implements Cloneable { ? containerResolver().getContainerMeta(containerKey) : null } - + String getContainerPlatform() { final result = config.getArchitecture() return result ? result.dockerArch : containerResolver().defaultContainerPlatform() @@ -991,6 +1012,10 @@ class TaskRun implements Cloneable { return processor.session.getCondaConfig() } + PixiConfig getPixiConfig() { + return processor.session.getPixiConfig() + } + String getStubSource() { return config?.getStubBlock()?.source } diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy index 84c52c3a91..a05a28f840 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy @@ -90,6 +90,7 @@ class ProcessConfig implements Map, Cloneable { 'memory', 'module', 'penv', + 'pixi', 'pod', 'publishDir', 'queue', diff --git a/modules/nextflow/src/main/resources/nextflow/executor/command-run.txt b/modules/nextflow/src/main/resources/nextflow/executor/command-run.txt index 26cbf6a829..03db5d8f0e 100644 --- a/modules/nextflow/src/main/resources/nextflow/executor/command-run.txt +++ b/modules/nextflow/src/main/resources/nextflow/executor/command-run.txt @@ -162,6 +162,7 @@ nxf_main() { {{module_load}} {{conda_activate}} {{spack_activate}} + {{pixi_activate}} set -u {{task_env}} {{secrets_env}} diff --git a/modules/nextflow/src/test/groovy/nextflow/pixi/PixiCacheIntegrationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/pixi/PixiCacheIntegrationTest.groovy new file mode 100644 index 0000000000..6430685868 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/pixi/PixiCacheIntegrationTest.groovy @@ -0,0 +1,305 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.pixi + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +import nextflow.util.Duration +import spock.lang.IgnoreIf +import spock.lang.Specification + +/** + * Integration tests for PixiCache that require actual Pixi installation + * + * @author Edmund Miller + */ +class PixiCacheIntegrationTest extends Specification { + + /** + * Check if Pixi is installed and available on the system + */ + private static boolean hasPixiInstalled() { + try { + def process = new ProcessBuilder('pixi', '--version').start() + process.waitFor() + return process.exitValue() == 0 + } catch (Exception e) { + return false + } + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should create pixi environment from package specification'() { + given: + def tempDir = Files.createTempDirectory('pixi-cache-test') + def config = new PixiConfig([cacheDir: tempDir.toString()], [:]) + def cache = new PixiCache(config) + def ENV = 'python>=3.9' + + when: + def prefix = cache.pixiPrefixPath(ENV) + + then: + prefix != null + prefix.parent == tempDir + prefix.fileName.toString().startsWith('env-') + + cleanup: + tempDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should create pixi environment from TOML file'() { + given: + def tempDir = Files.createTempDirectory('pixi-cache-toml-test') + def config = new PixiConfig([cacheDir: tempDir.toString()], [:]) + def cache = new PixiCache(config) + + // Create a test TOML file + def tomlFile = tempDir.resolve('test-env.toml') + tomlFile.text = ''' +[project] +name = "test-integration" +version = "0.1.0" +description = "Integration test environment" +channels = ["conda-forge"] +platforms = ["linux-64", "osx-64", "osx-arm64"] + +[dependencies] +python = ">=3.9" +'''.stripIndent() + + when: + def prefix = cache.pixiPrefixPath(tomlFile.toString()) + + then: + prefix != null + prefix.parent == tempDir + prefix.fileName.toString().startsWith('test-env-') + + cleanup: + tempDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should handle pixi environment creation with custom cache directory'() { + given: + def customCacheDir = Files.createTempDirectory('custom-pixi-cache') + def config = new PixiConfig([ + cacheDir: customCacheDir.toString(), + createTimeout: '10min', + createOptions: '--no-lockfile-update' + ], [:]) + def cache = new PixiCache(config) + + when: + def cacheDir = cache.getCacheDir() + + then: + cacheDir == customCacheDir + cacheDir.exists() + cache.createTimeout == Duration.of('10min') + cache.createOptions == '--no-lockfile-update' + + cleanup: + customCacheDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should validate TOML file detection'() { + given: + def cache = new PixiCache(new PixiConfig([:], [:])) + + expect: + cache.isTomlFilePath('pixi.toml') + cache.isTomlFilePath('pyproject.toml') + cache.isTomlFilePath('/path/to/environment.toml') + !cache.isTomlFilePath('python>=3.9') + !cache.isTomlFilePath('environment.yaml') + !cache.isTomlFilePath('multiline\nstring') + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should validate lock file detection'() { + given: + def cache = new PixiCache(new PixiConfig([:], [:])) + + expect: + cache.isLockFilePath('pixi.lock') + cache.isLockFilePath('/path/to/environment.lock') + !cache.isLockFilePath('python>=3.9') + !cache.isLockFilePath('environment.toml') + !cache.isLockFilePath('multiline\nstring') + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should handle existing prefix directory'() { + given: + def tempDir = Files.createTempDirectory('pixi-prefix-test') + def config = new PixiConfig([:], [:]) + def cache = new PixiCache(config) + + when: + def prefix = cache.pixiPrefixPath(tempDir.toString()) + + then: + prefix == tempDir + prefix.isDirectory() + + cleanup: + tempDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should handle environment variable cache directory'() { + given: + def envCacheDir = Files.createTempDirectory('env-pixi-cache') + def config = new PixiConfig([:], [NXF_PIXI_CACHEDIR: envCacheDir.toString()]) + def cache = Spy(PixiCache, constructorArgs: [config]) { + getEnv() >> [NXF_PIXI_CACHEDIR: envCacheDir.toString()] + } + + when: + def cacheDir = cache.getCacheDir() + + then: + cacheDir == envCacheDir + cacheDir.exists() + + cleanup: + envCacheDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should create cache from lock file'() { + given: + def tempDir = Files.createTempDirectory('pixi-lock-cache-test') + def config = new PixiConfig([cacheDir: tempDir.toString()], [:]) + def cache = new PixiCache(config) + + // Create a test lock file (simplified format) + def lockFile = tempDir.resolve('test.lock') + lockFile.text = ''' +version: 4 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.6-hab00c5b_0_cpython.conda +'''.stripIndent() + + when: + def prefix = cache.pixiPrefixPath(lockFile.toString()) + + then: + prefix != null + prefix.parent == tempDir + prefix.fileName.toString().startsWith('test-') + + cleanup: + tempDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should reject invalid environment specifications'() { + given: + def config = new PixiConfig([:], [:]) + def cache = new PixiCache(config) + + when: + cache.pixiPrefixPath('invalid\nmultiline\nspec') + + then: + thrown(IllegalArgumentException) + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should reject non-existent TOML file'() { + given: + def config = new PixiConfig([:], [:]) + def cache = new PixiCache(config) + + when: + cache.pixiPrefixPath('/non/existent/file.toml') + + then: + thrown(IllegalArgumentException) + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should reject non-existent lock file'() { + given: + def config = new PixiConfig([:], [:]) + def cache = new PixiCache(config) + + when: + cache.pixiPrefixPath('/non/existent/file.lock') + + then: + thrown(IllegalArgumentException) + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should handle complex TOML file with multiple dependencies'() { + given: + def tempDir = Files.createTempDirectory('pixi-complex-toml-test') + def config = new PixiConfig([cacheDir: tempDir.toString()], [:]) + def cache = new PixiCache(config) + + // Create a complex TOML file + def tomlFile = tempDir.resolve('complex-env.toml') + tomlFile.text = ''' +[project] +name = "complex-integration-test" +version = "1.0.0" +description = "Complex integration test environment with multiple dependencies" +channels = ["conda-forge", "bioconda", "pytorch"] +platforms = ["linux-64", "osx-64", "osx-arm64"] + +[dependencies] +python = ">=3.9,<3.12" +numpy = ">=1.20" +pandas = ">=1.3" +matplotlib = ">=3.5" +scipy = ">=1.7" + +[pypi-dependencies] +requests = ">=2.25" + +[tasks] +test = "python -c 'import numpy, pandas, matplotlib, scipy; print(\"All packages imported successfully\")'" + +[feature.cuda.dependencies] +pytorch = { version = ">=1.12", channel = "pytorch" } +'''.stripIndent() + + when: + def prefix = cache.pixiPrefixPath(tomlFile.toString()) + + then: + prefix != null + prefix.parent == tempDir + prefix.fileName.toString().startsWith('complex-env-') + + cleanup: + tempDir?.deleteDir() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/pixi/PixiCacheTest.groovy b/modules/nextflow/src/test/groovy/nextflow/pixi/PixiCacheTest.groovy new file mode 100644 index 0000000000..ab454b0269 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/pixi/PixiCacheTest.groovy @@ -0,0 +1,423 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.pixi + +import java.nio.file.Files +import java.nio.file.Paths + +import nextflow.util.Duration +import spock.lang.Specification + +/** + * + * @author Edmund Miller + */ +class PixiCacheTest extends Specification { + + def 'should detect TOML file path' () { + given: + def cache = new PixiCache() + + expect: + !cache.isTomlFilePath('python=3.8') + !cache.isTomlFilePath('env.yaml') + cache.isTomlFilePath('pixi.toml') + cache.isTomlFilePath('pyproject.toml') + cache.isTomlFilePath('env.toml') + cache.isTomlFilePath('/path/to/pixi.toml') + !cache.isTomlFilePath('pixi.toml\nsome other content') + } + + def 'should detect lock file path' () { + given: + def cache = new PixiCache() + + expect: + !cache.isLockFilePath('python=3.8') + !cache.isLockFilePath('env.yaml') + !cache.isLockFilePath('pixi.toml') + cache.isLockFilePath('pixi.lock') + cache.isLockFilePath('/path/to/pixi.lock') + !cache.isLockFilePath('pixi.lock\nsome other content') + } + + def 'should create pixi env prefix path for a package specification' () { + given: + def ENV = 'python=3.8' + def cache = Spy(PixiCache) + def BASE = Paths.get('/pixi/envs') + + when: + def prefix = cache.pixiPrefixPath(ENV) + then: + 1 * cache.getCacheDir() >> BASE + prefix.toString().startsWith('/pixi/envs/env-') + prefix.toString().length() > '/pixi/envs/env-'.length() + } + + def 'should create pixi env prefix path for a toml file' () { + given: + def folder = Files.createTempDirectory('test') + def cache = Spy(PixiCache) + def BASE = Paths.get('/pixi/envs') + def ENV = folder.resolve('pixi.toml') + ENV.text = ''' + [project] + name = "my-project" + version = "0.1.0" + channels = ["conda-forge"] + + [dependencies] + python = "3.8" + numpy = ">=1.20" + ''' + .stripIndent(true) + + when: + def prefix = cache.pixiPrefixPath(ENV.toString()) + then: + 1 * cache.isTomlFilePath(ENV.toString()) + 1 * cache.getCacheDir() >> BASE + prefix.toString().startsWith('/pixi/envs/pixi-') + + cleanup: + folder?.deleteDir() + } + + def 'should create pixi env prefix path for a lock file' () { + given: + def folder = Files.createTempDirectory('test') + def cache = Spy(PixiCache) + def BASE = Paths.get('/pixi/envs') + def ENV = folder.resolve('pixi.lock') + ENV.text = ''' + version: 3 + environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.8.18-h955ad1f_0.conda + ''' + .stripIndent(true) + + when: + def prefix = cache.pixiPrefixPath(ENV.toString()) + then: + 1 * cache.isTomlFilePath(ENV.toString()) + 1 * cache.isLockFilePath(ENV.toString()) + 1 * cache.getCacheDir() >> BASE + prefix.toString().startsWith('/pixi/envs/pixi-') + + cleanup: + folder?.deleteDir() + } + + def 'should handle non-existent TOML file' () { + given: + def cache = new PixiCache() + def nonExistentFile = '/non/existent/pixi.toml' + + when: + cache.pixiPrefixPath(nonExistentFile) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('Pixi environment file does not exist') + } + + def 'should handle non-existent lock file' () { + given: + def cache = new PixiCache() + def nonExistentFile = '/non/existent/pixi.lock' + + when: + cache.pixiPrefixPath(nonExistentFile) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('Pixi lock file does not exist') + } + + def 'should return a pixi prefix directory' () { + given: + def cache = Spy(PixiCache) + def folder = Files.createTempDirectory('test') + def ENV = folder.toString() + + when: + def prefix = cache.pixiPrefixPath(ENV) + then: + 1 * cache.isTomlFilePath(ENV) + 1 * cache.isLockFilePath(ENV) + 0 * cache.getCacheDir() + prefix.toString() == folder.toString() + + cleanup: + folder?.deleteDir() + } + + def 'should reject prefix path with newlines' () { + given: + def cache = new PixiCache() + def ENV = 'invalid\npath' + + when: + cache.pixiPrefixPath(ENV) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('Invalid Pixi environment definition') + } + + def 'should reject non-directory prefix path' () { + given: + def cache = new PixiCache() + def tempFile = Files.createTempFile('test', '.txt') + def ENV = tempFile.toString() + + when: + cache.pixiPrefixPath(ENV) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('Pixi prefix path does not exist or is not a directory') + + cleanup: + Files.deleteIfExists(tempFile) + } + + def 'should create a pixi environment for existing prefix' () { + given: + def ENV = 'python=3.8' + def PREFIX = Files.createTempDirectory('foo') + def cache = Spy(PixiCache) + + when: + def result = cache.createLocalPixiEnv(ENV, PREFIX) + then: + result == PREFIX + 0 * cache.createLocalPixiEnv0(_, _) + + cleanup: + PREFIX?.deleteDir() + } + + def 'should create a pixi environment from package specification' () { + given: + def ENV = 'python=3.8' + def PREFIX = Files.createTempDirectory('test-env') + def cache = Spy(PixiCache) + + when: + def result = cache.createLocalPixiEnv0(ENV, PREFIX) + + then: + 1 * cache.runCommand(_) >> { String cmd -> + assert cmd.contains("cd ${PREFIX} && pixi install") + return 0 + } + result == PREFIX + + cleanup: + PREFIX?.deleteDir() + } + + def 'should create a pixi environment from TOML file' () { + given: + def ENV = 'pixi.toml' + def PREFIX = Files.createTempDirectory('test-env') + def cache = Spy(PixiCache) + + when: + def result = cache.createLocalPixiEnv0(ENV, PREFIX) + + then: + _ * cache.makeAbsolute(ENV) >> Paths.get('/usr/project/pixi.toml') + 1 * cache.runCommand(_) >> { String cmd -> + assert cmd.contains("pixi install") + return 0 + } + result == PREFIX + + cleanup: + PREFIX?.deleteDir() + } + + def 'should create pixi env with options' () { + given: + def ENV = 'python=3.8' + def PREFIX = Files.createTempDirectory('test-env') + def config = new PixiConfig([createOptions: '--verbose'], [:]) + def cache = Spy(new PixiCache(config)) + + when: + def result = cache.createLocalPixiEnv0(ENV, PREFIX) + + then: + 1 * cache.runCommand(_) >> { String cmd -> + assert cmd.contains("cd ${PREFIX} && pixi install --verbose") + return 0 + } + result == PREFIX + + cleanup: + PREFIX?.deleteDir() + } + + def 'should get options from the config' () { + when: + def cache = new PixiCache(new PixiConfig([:], [:])) + then: + cache.createTimeout.minutes == 20 + cache.configCacheDir0 == null + cache.createOptions == null + + when: + cache = new PixiCache(new PixiConfig([createTimeout: '5 min', cacheDir: '/pixi/cache', createOptions: '--verbose'], [:])) + then: + cache.createTimeout.minutes == 5 + cache.configCacheDir0.toString() == '/pixi/cache' + cache.createOptions == '--verbose' + } + + def 'should get cache directory from config' () { + given: + def customCacheDir = Files.createTempDirectory('custom-cache') + def config = new PixiConfig([cacheDir: customCacheDir], [:]) + def cache = Spy(new PixiCache(config)) + + when: + def result = cache.getCacheDir() + + then: + result.toString() == customCacheDir.toString() + + cleanup: + customCacheDir?.deleteDir() + } + + def 'should get cache directory from environment variable' () { + given: + def cache = Spy(new PixiCache(new PixiConfig([:], [:]))) + def envCacheDir = Files.createTempDirectory('env-cache') + + when: + def result = cache.getCacheDir() + + then: + _ * cache.getEnv() >> [NXF_PIXI_CACHEDIR: envCacheDir.toString()] + result.toString() == envCacheDir.toString() + + cleanup: + envCacheDir?.deleteDir() + } + + def 'should get cache directory from work directory when no config' () { + given: + def cache = Spy(new PixiCache(new PixiConfig([:], [:]))) + def workDir = Files.createTempDirectory('work') + + when: + def result = cache.getCacheDir() + + then: + 1 * cache.getEnv() >> [:] + 1 * cache.getSessionWorkDir() >> workDir + result.toString() == workDir.resolve('pixi').toString() + + cleanup: + workDir?.deleteDir() + } + + def 'should make absolute path' () { + given: + def cache = new PixiCache() + + when: + def result = cache.makeAbsolute('pixi.toml') + + then: + result.isAbsolute() + result.toString().endsWith('pixi.toml') + } + + def 'should handle command execution success' () { + given: + def cache = Spy(PixiCache) + def cmd = 'echo "test"' + + when: + def result = cache.runCommand(cmd) + + then: + result == 0 + } + + def 'should handle command execution failure' () { + given: + def cache = new PixiCache() + def cmd = 'false' // command that always fails + + when: + cache.runCommand(cmd) + + then: + def e = thrown(IllegalStateException) + e.message.contains('Failed to create Pixi environment') + } + + def 'should handle command execution with timeout' () { + given: + def config = new PixiConfig([createTimeout: '1 min'], [:]) + def cache = new PixiCache(config) + + expect: + cache.createTimeout.minutes == 1 + } + + def 'should get cache path for environment' () { + given: + def ENV = 'python=3.8' + def cache = Spy(PixiCache) + def BASE = Files.createTempDirectory('pixi-cache') + + when: + def result = cache.pixiPrefixPath(ENV) + + then: + 1 * cache.getCacheDir() >> BASE + result.toString().startsWith(BASE.toString()) + result.toString().contains('env-') + + cleanup: + BASE?.deleteDir() + } + + def 'should test cache configuration inheritance' () { + given: + def CONFIG = [createTimeout: '5 min', cacheDir: '/custom/cache', createOptions: '--verbose'] + def cache = new PixiCache(new PixiConfig(CONFIG, [:])) + + expect: + cache.createTimeout.minutes == 5 + cache.createOptions == '--verbose' + cache.configCacheDir0.toString() == '/custom/cache' + } +} + diff --git a/modules/nextflow/src/test/groovy/nextflow/pixi/PixiConfigTest.groovy b/modules/nextflow/src/test/groovy/nextflow/pixi/PixiConfigTest.groovy new file mode 100644 index 0000000000..3d0a008231 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/pixi/PixiConfigTest.groovy @@ -0,0 +1,384 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.pixi + +import java.nio.file.Files +import java.nio.file.Paths + +import nextflow.util.Duration +import spock.lang.IgnoreIf +import spock.lang.PendingFeature +import spock.lang.Specification +import spock.lang.Unroll + +/** + * + * @author Edmund Miller + */ +class PixiConfigTest extends Specification { + + @Unroll + def 'should check enabled flag'() { + given: + def pixi = new PixiConfig(CONFIG, ENV) + expect: + pixi.isEnabled() == EXPECTED + + where: + EXPECTED | CONFIG | ENV + false | [:] | [:] + false | [enabled: false] | [:] + true | [enabled: true] | [:] + and: + false | [:] | [NXF_PIXI_ENABLED: 'false'] + true | [:] | [NXF_PIXI_ENABLED: 'true'] + false | [enabled: false] | [NXF_PIXI_ENABLED: 'true'] // <-- config has priority + true | [enabled: true] | [NXF_PIXI_ENABLED: 'true'] + } + + def 'should return create timeout'() { + given: + def CONFIG = [createTimeout: '30min'] + def pixi = new PixiConfig(CONFIG, [:]) + + expect: + pixi.createTimeout() == Duration.of('30min') + } + + def 'should return null create timeout when not specified'() { + given: + def pixi = new PixiConfig([:], [:]) + + expect: + pixi.createTimeout() == null + } + + def 'should return create options'() { + given: + def CONFIG = [createOptions: '--verbose --no-lock-update'] + def pixi = new PixiConfig(CONFIG, [:]) + + expect: + pixi.createOptions() == '--verbose --no-lock-update' + } + + def 'should return null create options when not specified'() { + given: + def pixi = new PixiConfig([:], [:]) + + expect: + pixi.createOptions() == null + } + + def 'should return cache directory'() { + given: + def CONFIG = [cacheDir: '/my/cache/dir'] + def pixi = new PixiConfig(CONFIG, [:]) + + expect: + pixi.cacheDir() == Paths.get('/my/cache/dir') + } + + def 'should return null cache directory when not specified'() { + given: + def pixi = new PixiConfig([:], [:]) + + expect: + pixi.cacheDir() == null + } + + def 'should handle boolean values for enabled flag'() { + given: + def pixi = new PixiConfig([enabled: true], [:]) + + expect: + pixi.isEnabled() == true + + when: + pixi = new PixiConfig([enabled: false], [:]) + + then: + pixi.isEnabled() == false + } + + def 'should handle string values for enabled flag'() { + given: + def pixi = new PixiConfig([enabled: 'true'], [:]) + + expect: + pixi.isEnabled() == true + + when: + pixi = new PixiConfig([enabled: 'false'], [:]) + + then: + pixi.isEnabled() == false + } + + def 'should inherit from LinkedHashMap'() { + given: + def CONFIG = [enabled: true, createTimeout: '10min', customOption: 'value'] + def pixi = new PixiConfig(CONFIG, [:]) + + expect: + pixi instanceof LinkedHashMap + pixi.enabled == true + pixi.createTimeout == '10min' + pixi.customOption == 'value' + pixi.size() == 3 + } + + // ==== Integration Tests (require actual Pixi installation) ==== + + /** + * Check if Pixi is installed and available on the system + */ + private static boolean hasPixiInstalled() { + try { + def process = new ProcessBuilder('pixi', '--version').start() + process.waitFor() + return process.exitValue() == 0 + } catch (Exception e) { + return false + } + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should verify pixi version command works'() { + when: + def process = new ProcessBuilder('pixi', '--version').start() + def exitCode = process.waitFor() + def output = process.inputStream.text.trim() + + then: + exitCode == 0 + output.contains('pixi') + output.matches(/pixi \d+\.\d+\.\d+.*/) + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should verify pixi info command works'() { + when: + def process = new ProcessBuilder('pixi', 'info').start() + def exitCode = process.waitFor() + def output = process.inputStream.text.trim() + + then: + exitCode == 0 + output.contains('pixi') + // Should contain platform information + output.toLowerCase().contains('platform') + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should create and remove a temporary pixi environment'() { + given: + def tempDir = Files.createTempDirectory('pixi-test') + def pixiToml = tempDir.resolve('pixi.toml') + + // Create a minimal pixi.toml + pixiToml.text = ''' +[project] +name = "test-env" +version = "0.1.0" +description = "Test environment" +channels = ["conda-forge"] +platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"] + +[dependencies] +python = ">=3.8" +'''.stripIndent() + + when: + // Create the environment + def createProcess = new ProcessBuilder('pixi', 'install') + .directory(tempDir.toFile()) + .start() + def createExitCode = createProcess.waitFor() + + then: + createExitCode == 0 + tempDir.resolve('.pixi').exists() + + cleanup: + tempDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should validate pixi environment creation with specific dependencies'() { + given: + def tempDir = Files.createTempDirectory('pixi-test-deps') + def pixiToml = tempDir.resolve('pixi.toml') + + // Create a pixi.toml with specific dependencies commonly used in bioinformatics + pixiToml.text = ''' +[project] +name = "bio-test-env" +version = "0.1.0" +description = "Bioinformatics test environment" +channels = ["conda-forge", "bioconda"] +platforms = ["linux-64", "osx-64", "osx-arm64"] + +[dependencies] +python = ">=3.9" +numpy = "*" +'''.stripIndent() + + when: + // Install the environment + def installProcess = new ProcessBuilder('pixi', 'install') + .directory(tempDir.toFile()) + .start() + def installExitCode = installProcess.waitFor() + + and: + // List the installed packages + def listProcess = new ProcessBuilder('pixi', 'list') + .directory(tempDir.toFile()) + .start() + def listExitCode = listProcess.waitFor() + def listOutput = listProcess.inputStream.text + + then: + installExitCode == 0 + listExitCode == 0 + tempDir.resolve('.pixi').exists() + listOutput.contains('python') + listOutput.contains('numpy') + + cleanup: + tempDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should handle pixi environment with lock file'() { + given: + def tempDir = Files.createTempDirectory('pixi-test-lock') + def pixiToml = tempDir.resolve('pixi.toml') + + pixiToml.text = ''' +[project] +name = "lock-test-env" +version = "0.1.0" +description = "Lock file test environment" +channels = ["conda-forge"] +platforms = ["linux-64", "osx-64", "osx-arm64"] + +[dependencies] +python = "3.11.*" +'''.stripIndent() + + when: + // Create lock file + def lockProcess = new ProcessBuilder('pixi', 'install') + .directory(tempDir.toFile()) + .start() + def lockExitCode = lockProcess.waitFor() + + then: + lockExitCode == 0 + tempDir.resolve('pixi.lock').exists() + tempDir.resolve('.pixi').exists() + + when: + // Clean and reinstall from lock file + tempDir.resolve('.pixi').deleteDir() + def reinstallProcess = new ProcessBuilder('pixi', 'install') + .directory(tempDir.toFile()) + .start() + def reinstallExitCode = reinstallProcess.waitFor() + + then: + reinstallExitCode == 0 + tempDir.resolve('.pixi').exists() + + cleanup: + tempDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should run commands in pixi environment'() { + given: + def tempDir = Files.createTempDirectory('pixi-test-run') + def pixiToml = tempDir.resolve('pixi.toml') + + pixiToml.text = ''' +[project] +name = "run-test-env" +version = "0.1.0" +description = "Run command test environment" +channels = ["conda-forge"] +platforms = ["linux-64", "osx-64", "osx-arm64"] + +[dependencies] +python = ">=3.9" + +[tasks] +hello = "python -c 'print(\\\"Hello from Pixi!\\\")'" +'''.stripIndent() + + when: + // Install the environment + def installProcess = new ProcessBuilder('pixi', 'install') + .directory(tempDir.toFile()) + .redirectErrorStream(true) + .start() + def installOutput = installProcess.inputStream.text + def installExitCode = installProcess.waitFor() + + and: + // Run the hello task + def runProcess = new ProcessBuilder('pixi', 'run', 'hello') + .directory(tempDir.toFile()) + .redirectErrorStream(true) + .start() + def runOutput = runProcess.inputStream.text + def runExitCode = runProcess.waitFor() + + then: + if (installExitCode != 0) { + println "Pixi install failed with output:\n${installOutput}" + } + installExitCode == 0 + runExitCode == 0 + assert runOutput.contains('Hello from Pixi!') : "Pixi output was:\n${runOutput}" + + cleanup: + tempDir?.deleteDir() + } + + @IgnoreIf({ !hasPixiInstalled() }) + def 'should validate pixi global commands'() { + when: + // Test pixi global list (should work even if no global packages are installed) + def globalListProcess = new ProcessBuilder('pixi', 'global', 'list').start() + def globalListExitCode = globalListProcess.waitFor() + + then: + globalListExitCode == 0 + + when: + // Test pixi search for a common package + def searchProcess = new ProcessBuilder('pixi', 'search', 'python').start() + def searchExitCode = searchProcess.waitFor() + def searchOutput = searchProcess.inputStream.text + + then: + searchExitCode == 0 + searchOutput.contains('python') + } +} diff --git a/tests/checks/pixi-env.nf b/tests/checks/pixi-env.nf new file mode 100644 index 0000000000..229fc09e9e --- /dev/null +++ b/tests/checks/pixi-env.nf @@ -0,0 +1,21 @@ +#!/usr/bin/env nextflow +workflow { + sayHello() | view +} + + +/* + * Test for Pixi environment support + */ + +process sayHello { + pixi 'cowpy' + + output: + stdout + + script: + """ + cowpy "hello pixi" + """ +}