diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy index ec00c62d56..b24ece5c7d 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy @@ -105,6 +105,7 @@ import nextflow.util.CacheHelper import nextflow.util.Escape import nextflow.util.LockManager import nextflow.util.LoggerHelper +import nextflow.util.NullPath import nextflow.util.TestOnly import org.codehaus.groovy.control.CompilerConfiguration import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer @@ -1595,6 +1596,10 @@ class TaskProcessor { def path = param.glob ? splitter.strip(filePattern) : filePattern def file = workDir.resolve(path) def exists = checkFileExists(file, param.followLinks) + if( !exists && param.isNullable() ) { + file = new NullPath(path) + exists = true + } if( exists ) result = [file] else @@ -1612,7 +1617,7 @@ class TaskProcessor { } } - if( !param.isValidArity(allFiles.size()) ) + if( !param.isValidArity(allFiles) ) throw new IllegalArityException("Incorrect number of output files for process `${safeTaskName(task)}` -- expected ${param.arity}, found ${allFiles.size()}") task.setOutput( param, allFiles.size()==1 && param.isSingle() ? allFiles[0] : allFiles ) @@ -1816,7 +1821,10 @@ class TaskProcessor { return new FileHolder(source, result) } - protected Path normalizeToPath( obj ) { + protected Path normalizeToPath( obj, boolean nullable=false ) { + if( obj instanceof NullPath && !nullable ) + throw new ProcessUnrecoverableException("Path value cannot be null") + if( obj instanceof Path ) return obj @@ -1839,7 +1847,7 @@ class TaskProcessor { throw new ProcessUnrecoverableException("Not a valid path value: '$str'") } - protected List normalizeInputToFiles( Object obj, int count, boolean coerceToPath, FilePorter.Batch batch ) { + protected List normalizeInputToFiles( Object obj, int count, boolean coerceToPath, boolean nullable, FilePorter.Batch batch ) { Collection allItems = obj instanceof Collection ? obj : [obj] def len = allItems.size() @@ -1849,7 +1857,7 @@ class TaskProcessor { for( def item : allItems ) { if( item instanceof Path || coerceToPath ) { - def path = normalizeToPath(item) + def path = normalizeToPath(item, nullable) def target = executor.isForeignFile(path) ? batch.addToForeign(path) : path def holder = new FileHolder(target) files << holder @@ -2063,11 +2071,15 @@ class TaskProcessor { final param = entry.getKey() final val = entry.getValue() final fileParam = param as FileInParam - final normalized = normalizeInputToFiles(val, count, fileParam.isPathQualifier(), batch) + final normalized = normalizeInputToFiles(val, count, fileParam.isPathQualifier(), param.isNullable(), batch) final resolved = expandWildcards( fileParam.getFilePattern(ctx), normalized ) - if( !param.isValidArity(resolved.size()) ) - throw new IllegalArityException("Incorrect number of input files for process `${safeTaskName(task)}` -- expected ${param.arity}, found ${resolved.size()}") + if( !param.isValidArity(resolved) ) { + final msg = param.isNullable() + ? "expected a nullable file (0..1) but a list was provided" + : "expected ${param.arity}, found ${resolved.size()}" + throw new IllegalArityException("Incorrect number of input files for process `${safeTaskName(task)}` -- ${msg}") + } ctx.put( param.name, singleItemOrList(resolved, param.isSingle(), task.type) ) count += resolved.size() diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/ArityParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/ArityParam.groovy index 3c1a425288..f817ce2d8d 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/params/ArityParam.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/params/ArityParam.groovy @@ -55,6 +55,13 @@ trait ArityParam { throw new IllegalArityException("Path arity should be a number (e.g. '1') or a range (e.g. '1..*')") } + /** + * Determine whether a null file is allowed. + */ + boolean isNullable() { + return arity && arity.min == 0 && arity.max == 1 + } + /** * Determine whether a single output file should be unwrapped. */ @@ -62,8 +69,21 @@ trait ArityParam { return !arity || arity.max == 1 } - boolean isValidArity(int size) { - return !arity || arity.contains(size) + /** + * Determine whether a collection of files has valid arity. + * + * If the param is nullable, there should be exactly one file (either + * a real file or a null file) + * + * @param files + */ + boolean isValidArity(Collection files) { + if( !arity ) + return true + + return isNullable() + ? files.size() == 1 + : arity.contains(files.size()) } @EqualsAndHashCode @@ -72,12 +92,10 @@ trait ArityParam { int max Range(int min, int max) { - if( min<0 ) - throw new IllegalArityException("Path arity min value must be greater or equals to 0") - if( max<1 ) - throw new IllegalArityException("Path arity max value must be greater or equals to 1") - if( min==0 && max==1 ) - throw new IllegalArityException("Path arity 0..1 is not allowed") + if( min < 0 ) + throw new IllegalArityException("Path arity min value must be at least 0") + if( max < 1 ) + throw new IllegalArityException("Path arity max value must be at least 1") this.min = min this.max = max } diff --git a/modules/nextflow/src/main/groovy/nextflow/util/NullPath.groovy b/modules/nextflow/src/main/groovy/nextflow/util/NullPath.groovy new file mode 100644 index 0000000000..2b0933c9fa --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/util/NullPath.groovy @@ -0,0 +1,41 @@ +/* + * Copyright 2013-2023, 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.util + +import java.nio.file.Path +import java.nio.file.Paths + +import groovy.transform.EqualsAndHashCode +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j + +/** + * + * @author Ben Sherman + */ +@EqualsAndHashCode +@Slf4j +class NullPath implements Path { + + @PackageScope + @Delegate + Path delegate + + NullPath(String path) { + delegate = Paths.get(path) + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy index 131cc6d0a9..26ebb05f61 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy @@ -839,7 +839,7 @@ class TaskProcessorTest extends Specification { def proc = new TaskProcessor(); proc.executor = executor when: - def result = proc.normalizeInputToFiles(PATH.toString(), 0, true, batch) + def result = proc.normalizeInputToFiles(PATH.toString(), 0, true, false, batch) then: 1 * executor.isForeignFile(PATH) >> false 0 * batch.addToForeign(PATH) >> null @@ -1043,7 +1043,7 @@ class TaskProcessorTest extends Specification { where: FILE_NAME | FILE_VALUE | ARITY | ERROR - 'file.txt' | [] | '0' | 'Path arity max value must be greater or equals to 1' + 'file.txt' | [] | '0' | 'Path arity max value must be at least 1' 'file.txt' | [] | '1' | 'Incorrect number of input files for process `foo` -- expected 1, found 0' 'f*' | [] | '1..*' | 'Incorrect number of input files for process `foo` -- expected 1..*, found 0' 'f*' | '/some/file.txt' | '2..*' | 'Incorrect number of input files for process `foo` -- expected 2..*, found 1' diff --git a/modules/nextflow/src/test/groovy/nextflow/script/params/ArityParamTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/params/ArityParamTest.groovy index bd4f50ee29..70cbca64fe 100644 --- a/modules/nextflow/src/test/groovy/nextflow/script/params/ArityParamTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/script/params/ArityParamTest.groovy @@ -37,13 +37,15 @@ class ArityParamTest extends Specification { then: param.arity.min == MIN param.arity.max == MAX + param.isNullable() == NULLABLE param.isSingle() == SINGLE where: - VALUE | SINGLE | MIN | MAX - '1' | true | 1 | 1 - '1..*' | false | 1 | Integer.MAX_VALUE - '0..*' | false | 0 | Integer.MAX_VALUE + VALUE | NULLABLE | SINGLE | MIN | MAX + '1' | false | true | 1 | 1 + '0..1' | true | true | 0 | 1 + '1..*' | false | false | 1 | Integer.MAX_VALUE + '0..*' | false | false | 0 | Integer.MAX_VALUE } @Unroll @@ -58,6 +60,7 @@ class ArityParamTest extends Specification { where: MIN | MAX | TWO | STRING 1 | 1 | false | '1' + 0 | 1 | false | '0..1' 1 | Integer.MAX_VALUE | true | '1..*' } diff --git a/tests/checks/process-arity-fails.nf/.checks b/tests/checks/process-arity-fails.nf/.checks new file mode 100644 index 0000000000..346e4a2ea0 --- /dev/null +++ b/tests/checks/process-arity-fails.nf/.checks @@ -0,0 +1,18 @@ +set +e + +# +# run normal mode +# +echo '' +$NXF_RUN +[[ $? == 0 ]] && exit 1 + + +# +# RESUME mode +# +echo '' +$NXF_RUN -resume +[[ $? == 0 ]] && exit 1 + +exit 0 \ No newline at end of file diff --git a/tests/process-arity-fails.nf b/tests/process-arity-fails.nf new file mode 100644 index 0000000000..9f2c62cc01 --- /dev/null +++ b/tests/process-arity-fails.nf @@ -0,0 +1,19 @@ +#!/usr/bin/env nextflow + +process foo { + output: + path('output.txt', arity: '0..1') + script: + true +} + +process bar { + input: + path(file) + script: + true +} + +workflow { + foo | bar +} \ No newline at end of file diff --git a/tests/process-arity.nf b/tests/process-arity.nf index 8a750745f0..155808c506 100644 --- a/tests/process-arity.nf +++ b/tests/process-arity.nf @@ -5,6 +5,8 @@ process foo { path('one.txt', arity: '1') path('pair_*.txt', arity: '2') path('many_*.txt', arity: '1..*') + path('opt_one.txt', arity: '0..1') + path('opt_many_*.txt', arity: '0..*') script: """ echo 'one' > one.txt @@ -21,11 +23,15 @@ process bar { path('one.txt', arity: '1') path('pair_*.txt', arity: '2') path('many_*.txt', arity: '1..*') + path('opt_one.txt', arity: '0..1') + path('opt_many_*.txt', arity: '0..*') script: """ cat one.txt cat pair_*.txt cat many_*.txt + cat opt_one.txt || true + cat opt_many_*.txt || true """ }