Skip to content

Commit 95f3e4e

Browse files
ewelsclaude
andcommitted
Fall back to nextflow_schema.json types for CLI param coercion
Under syntax parser v2, CLI params arrive as strings unless the pipeline declares typed params in main.nf or non-null defaults in nextflow.config. This breaks pipelines moving to v26.04.0 that rely on type-coerced params (e.g. numeric comparisons on `params.max_cpus`). When a `nextflow_schema.json` lives next to main.nf, read property types from it and coerce CLI string values before they reach ConfigBuilder. Coercion is best-effort: missing or malformed schemas are ignored, and values that don't match a declared type are left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Phil Ewels <phil.ewels@seqera.io>
1 parent fe4ae52 commit 95f3e4e

3 files changed

Lines changed: 448 additions & 0 deletions

File tree

modules/nextflow/src/main/groovy/nextflow/cli/CmdRun.groovy

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import nextflow.config.ConfigBuilder
3939
import nextflow.config.ConfigMap
4040
import nextflow.config.ConfigValidator
4141
import nextflow.config.Manifest
42+
import nextflow.config.SchemaParamsHelper
4243
import nextflow.exception.AbortOperationException
4344
import nextflow.file.FileHelper
4445
import nextflow.plugin.Plugins
@@ -339,6 +340,8 @@ class CmdRun extends CmdBase implements HubOptions {
339340
// -- load command line params
340341
final baseDir = scriptFile.parent
341342
final cliParams = parsedParams(ConfigBuilder.getConfigVars(baseDir, null))
343+
// under v2 syntax parser, CLI args arrive as strings; coerce via nextflow_schema.json types if available
344+
SchemaParamsHelper.applySchemaTypes(baseDir, cliParams)
342345

343346
/*
344347
* 2-PHASE CONFIGURATION LOADING STRATEGY
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright 2013-2026, 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
18+
19+
import java.nio.file.Files
20+
import java.nio.file.Path
21+
22+
import groovy.json.JsonSlurper
23+
import groovy.transform.CompileStatic
24+
import groovy.util.logging.Slf4j
25+
import nextflow.NF
26+
import nextflow.SysEnv
27+
28+
/**
29+
* Coerces CLI param string values to typed values using a
30+
* {@code nextflow_schema.json} file (JSON Schema, as used by nf-core
31+
* pipelines) as a fallback type source.
32+
*
33+
* Under syntax parser v2, CLI params arrive as strings. When the pipeline
34+
* has not declared typed params in main.nf or non-null defaults in
35+
* nextflow.config, those strings stay strings -- and may break param logic
36+
* that expects e.g. numeric comparison. If a {@code nextflow_schema.json}
37+
* lives next to main.nf, this helper reads property types from it and
38+
* coerces CLI values accordingly, giving the pipeline typed params for
39+
* free without requiring main.nf changes.
40+
*
41+
* Coercion is best-effort and non-destructive: values that don't match a
42+
* declared type are left as strings, and a missing or malformed schema
43+
* is silently ignored.
44+
*
45+
* @author Phil Ewels <phil.ewels@seqera.io>
46+
*/
47+
@Slf4j
48+
@CompileStatic
49+
class SchemaParamsHelper {
50+
51+
static final String SCHEMA_FILENAME = 'nextflow_schema.json'
52+
53+
/**
54+
* Apply schema-based type coercion in place on a CLI params map.
55+
*
56+
* @param baseDir pipeline project base directory (where main.nf lives)
57+
* @param cliParams CLI params map; mutated in place
58+
*/
59+
static void applySchemaTypes(Path baseDir, Map<String,?> cliParams) {
60+
if( !cliParams || baseDir == null )
61+
return
62+
if( !NF.isSyntaxParserV2() )
63+
return
64+
if( SysEnv.get('NXF_DISABLE_PARAMS_TYPE_DETECTION') )
65+
return
66+
67+
final schemaFile = baseDir.resolve(SCHEMA_FILENAME)
68+
if( !Files.exists(schemaFile) )
69+
return
70+
71+
final types = readSchemaTypes(schemaFile)
72+
if( !types )
73+
return
74+
75+
log.debug "Applying types from ${schemaFile} to ${cliParams.size()} CLI param(s) -- ${types.size()} param type(s) declared in schema"
76+
coerceInPlace(cliParams, types)
77+
}
78+
79+
/**
80+
* Parse a JSON schema file and return a map of {@code paramName -> jsonType}
81+
* (e.g. {@code "integer"}, {@code "number"}, {@code "boolean"}).
82+
*/
83+
static Map<String,String> readSchemaTypes(Path schemaFile) {
84+
try {
85+
final root = new JsonSlurper().parse(schemaFile)
86+
final types = new LinkedHashMap<String,String>()
87+
collectProperties(root, types)
88+
return types
89+
}
90+
catch( Exception e ) {
91+
log.warn "Unable to parse ${schemaFile} for fallback param typing -- ${e.message}"
92+
return Collections.<String,String>emptyMap()
93+
}
94+
}
95+
96+
/**
97+
* Recursively walk a JSON Schema fragment, collecting top-level property
98+
* names and their declared {@code type}. Handles nf-core-style schemas
99+
* that nest properties under {@code definitions} or {@code $defs}, plus
100+
* any {@code allOf}/{@code oneOf}/{@code anyOf} compositions.
101+
*/
102+
private static void collectProperties(Object node, Map<String,String> types) {
103+
if( node !instanceof Map )
104+
return
105+
final map = (Map) node
106+
107+
final props = map.get('properties')
108+
if( props instanceof Map ) {
109+
for( final entry in (Map<String,Object>) props ) {
110+
final schemaMap = entry.value instanceof Map ? (Map) entry.value : null
111+
if( schemaMap == null )
112+
continue
113+
final type = schemaMap.get('type')
114+
if( type instanceof String && !types.containsKey(entry.key) )
115+
types.put(entry.key, (String) type)
116+
}
117+
}
118+
119+
for( final key in ['definitions', '$defs', 'allOf', 'oneOf', 'anyOf'] ) {
120+
final sub = map.get(key)
121+
final children = sub instanceof Map ? ((Map) sub).values()
122+
: sub instanceof List ? (List) sub
123+
: null
124+
if( children == null )
125+
continue
126+
for( final child in children )
127+
collectProperties(child, types)
128+
}
129+
}
130+
131+
private static void coerceInPlace(Map<String,?> params, Map<String,String> types) {
132+
for( final name : new ArrayList<String>(params.keySet()) ) {
133+
final value = params.get(name)
134+
final coerced = coerceValue(value, types.get(name))
135+
// coerceValue returns the same reference when no coercion applied
136+
if( coerced !== value )
137+
((Map) params).put(name, coerced)
138+
}
139+
}
140+
141+
private static Object coerceValue(Object value, String type) {
142+
if( value !instanceof CharSequence )
143+
return value
144+
if( !type )
145+
return value
146+
final str = value.toString()
147+
switch( type ) {
148+
case 'boolean':
149+
if( str.equalsIgnoreCase('true') ) return Boolean.TRUE
150+
if( str.equalsIgnoreCase('false') ) return Boolean.FALSE
151+
break
152+
case 'integer':
153+
if( str.isInteger() ) return str.toInteger()
154+
if( str.isLong() ) return str.toLong()
155+
if( str.isBigInteger() ) return str.toBigInteger()
156+
break
157+
case 'number':
158+
if( str.isInteger() ) return str.toInteger()
159+
if( str.isLong() ) return str.toLong()
160+
if( str.isFloat() ) return str.toFloat()
161+
if( str.isDouble() ) return str.toDouble()
162+
if( str.isBigDecimal() ) return str.toBigDecimal()
163+
break
164+
}
165+
return value
166+
}
167+
}

0 commit comments

Comments
 (0)