Skip to content

Commit ebb0890

Browse files
authored
Support multiple config option types in config spec (nextflow-io#6720)
1 parent c07cc2e commit ebb0890

File tree

6 files changed

+111
-26
lines changed

6 files changed

+111
-26
lines changed

modules/nextflow/src/main/groovy/nextflow/config/ConfigValidator.groovy

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,11 @@ class ConfigValidator {
178178
}
179179

180180
/**
181-
* Determine whether a config option is a map option or a
182-
* property thereof.
181+
* Determine whether a config option is a map option.
182+
*
183+
* This method is needed to distinguish between config scopes
184+
* and config options that happen to be maps, since this distinction
185+
* is lost when the config is resolved.
183186
*
184187
* @param names
185188
*/
@@ -190,7 +193,7 @@ class ConfigValidator {
190193

191194
private static boolean isMapOption0(SpecNode.Scope scope, List<String> names) {
192195
final node = scope.getOption(names)
193-
return node != null && node.type() == Map.class
196+
return node != null && node.types().contains(Map.class)
194197
}
195198

196199
/**

modules/nextflow/src/main/groovy/nextflow/plugin/spec/ConfigSpec.groovy

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,15 @@ class ConfigSpec {
4444

4545
private static Map<String,?> fromOption(SpecNode.Option node, String name) {
4646
final description = node.description().stripIndent(true).trim()
47-
final type = fromType(new ClassNode(node.type()))
47+
final types = node.types().collect { t -> fromType(new ClassNode(t)) }
4848

4949
return [
5050
type: 'ConfigOption',
5151
spec: [
5252
name: name,
5353
description: description,
54-
type: type
54+
type: types.head(),
55+
additionalTypes: types.tail()
5556
]
5657
]
5758
}

modules/nextflow/src/test/groovy/nextflow/config/ConfigValidatorTest.groovy

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,28 @@ class ConfigValidatorTest extends Specification {
112112
}
113113

114114
def 'should support map options' () {
115+
when:
116+
new ConfigValidator().validate([
117+
k8s: [
118+
pod: [env: 'MESSAGE', value: 'hello world']
119+
]
120+
])
121+
then:
122+
!capture.toString().contains('Unrecognized config option')
123+
124+
when:
125+
new ConfigValidator().validate([
126+
process: [
127+
publishDir: [
128+
path: { "results/foo" },
129+
mode: 'copy',
130+
saveAs: { filename -> filename }
131+
]
132+
]
133+
])
134+
then:
135+
!capture.toString().contains('Unrecognized config option')
136+
115137
when:
116138
new ConfigValidator().validate([
117139
process: [

modules/nextflow/src/test/groovy/nextflow/plugin/spec/PluginSpecTest.groovy

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ class PluginSpecTest extends Specification {
5959
spec: [
6060
name: 'message',
6161
description: 'Message to print to standard output when the plugin is enabled.',
62-
type: 'String'
62+
type: 'String',
63+
additionalTypes: []
6364
]
6465
]
6566
]

modules/nf-lang/src/main/java/nextflow/config/spec/SpecNode.java

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.lang.reflect.Field;
2020
import java.lang.reflect.Method;
2121
import java.lang.reflect.ParameterizedType;
22+
import java.util.ArrayList;
2223
import java.util.HashMap;
2324
import java.util.List;
2425
import java.util.Map;
@@ -58,9 +59,9 @@ private static SpecNode nextflowScope() {
5859
var simpleName = names[names.length - 1];
5960
var desc = annotatedDescription(field, "");
6061
if( fqName.startsWith("nextflow.enable.") )
61-
enableOpts.put(simpleName, new Option(desc, optionType(field)));
62+
enableOpts.put(simpleName, new Option(desc, optionTypes(field)));
6263
else if( fqName.startsWith("nextflow.preview.") )
63-
previewOpts.put(simpleName, new Option(desc, optionType(field)));
64+
previewOpts.put(simpleName, new Option(desc, optionTypes(field)));
6465
else
6566
throw new IllegalArgumentException();
6667
}
@@ -73,6 +74,15 @@ else if( fqName.startsWith("nextflow.preview.") )
7374
);
7475
}
7576

77+
/**
78+
* Initialize the `process` config scope from the set of
79+
* process directives.
80+
*
81+
* Directives with multiple method overloads are treated as
82+
* options with multiple supported types. Method overloads with
83+
* multiple parameters are ignored because they are not supported
84+
* in the configuration.
85+
*/
7686
private static SpecNode processScope() {
7787
var description = """
7888
The `process` scope allows you to specify default directives for processes in your pipeline.
@@ -81,8 +91,15 @@ private static SpecNode processScope() {
8191
""";
8292
var children = new HashMap<String, SpecNode>();
8393
for( var method : ProcessDsl.DirectiveDsl.class.getDeclaredMethods() ) {
84-
var desc = annotatedDescription(method, "");
85-
children.put(method.getName(), new Option(desc, optionType(method)));
94+
if( method.getParameters().length != 1 )
95+
continue;
96+
if( !children.containsKey(method.getName()) ) {
97+
var desc = annotatedDescription(method, "");
98+
children.put(method.getName(), new Option(desc, new ArrayList<>()));
99+
}
100+
var option = (Option) children.get(method.getName());
101+
var paramType = method.getParameterTypes()[0];
102+
option.types.add(paramType);
86103
}
87104
return new Scope(description, children);
88105
}
@@ -92,21 +109,17 @@ private static String annotatedDescription(AnnotatedElement el, String defaultVa
92109
return annot != null ? annot.value() : defaultValue;
93110
}
94111

95-
private static Class optionType(AnnotatedElement element) {
96-
if( element instanceof Field field ) {
97-
return field.getType();
112+
private static List<Class> optionTypes(Field field) {
113+
var result = new ArrayList<Class>();
114+
// use the field type
115+
result.add(field.getType());
116+
// append types from ConfigOption annotation if specified
117+
var annot = field.getAnnotation(ConfigOption.class);
118+
if( annot != null ) {
119+
for( var type : annot.types() )
120+
result.add(type);
98121
}
99-
if( element instanceof Method method ) {
100-
// use the return type if config option is not a directive
101-
var returnType = method.getReturnType();
102-
if( returnType != void.class )
103-
return returnType;
104-
// other use the type of the last parameter
105-
var paramTypes = method.getParameterTypes();
106-
if( paramTypes.length > 0 )
107-
return paramTypes[paramTypes.length - 1];
108-
}
109-
return null;
122+
return result;
110123
}
111124

112125
/**
@@ -123,7 +136,7 @@ public static record DslOption(
123136
*/
124137
public static record Option(
125138
String description,
126-
Class type
139+
List<Class> types
127140
) implements SpecNode {}
128141

129142
/**
@@ -207,7 +220,7 @@ public static Scope of(Class<? extends ConfigScope> scope, String description) {
207220
if( DslScope.class.isAssignableFrom(type) )
208221
children.put(name, new DslOption(desc, type));
209222
else
210-
children.put(name, new Option(desc, optionType(field)));
223+
children.put(name, new Option(desc, optionTypes(field)));
211224
}
212225
// fields of type ConfigScope are nested config scopes
213226
else if( ConfigScope.class.isAssignableFrom(type) ) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2024-2025, Seqera Labs
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package nextflow.config.spec
18+
19+
import nextflow.script.types.Duration
20+
import nextflow.script.types.MemoryUnit
21+
import spock.lang.Specification
22+
23+
/**
24+
*
25+
* @author Ben Sherman <bentshermann@gmail.com>
26+
*/
27+
class SpecNodeTest extends Specification {
28+
29+
def 'should infer process config options from process directives' () {
30+
given:
31+
def scope = SpecNode.ROOT.children.get('process')
32+
33+
expect:
34+
scope.children.get('clusterOptions').types as Set == [ String, List, String[] ] as Set
35+
scope.children.get('cpus').types == [ Integer ]
36+
scope.children.get('errorStrategy').types == [ String ]
37+
scope.children.get('executor').types == [ String ]
38+
scope.children.get('ext').types == [ Map ]
39+
scope.children.get('memory').types == [ MemoryUnit ]
40+
scope.children.get('publishDir').types as Set == [ Map, String, List ] as Set
41+
scope.children.get('resourceLimits').types == [ Map ]
42+
scope.children.get('time').types == [ Duration ]
43+
}
44+
45+
}

0 commit comments

Comments
 (0)