@@ -186,6 +186,166 @@ def parse_version(version_str):
186186 return tuple (map (int , v ))
187187
188188
189+ _EXACT_VERSION_RE = re .compile (r'^v?\d+\.\d+\.\d+(\+\S*)?$' )
190+
191+ _COMPARATOR_RE = re .compile (
192+ r'^(?P<op>\^|~|>=|<=|>|<|=)?\s*'
193+ r'v?(?P<major>\d+|[xX*])'
194+ r'(?:\.(?P<minor>\d+|[xX*]))?'
195+ r'(?:\.(?P<patch>\d+|[xX*]))?$'
196+ )
197+
198+ _OPERATORS = {
199+ '>=' : operator .ge ,
200+ '>' : operator .gt ,
201+ '<=' : operator .le ,
202+ '<' : operator .lt ,
203+ '=' : operator .eq ,
204+ }
205+
206+
207+ def _pad_version (version ):
208+ """
209+ Pad a version tuple to (major, minor, patch)
210+ """
211+ parts = tuple (version )[:3 ]
212+ return parts + (0 ,) * (3 - len (parts ))
213+
214+
215+ def _is_exact_version (version_str ):
216+ """
217+ Check that the string is a complete version and needs no resolving
218+ """
219+ return _EXACT_VERSION_RE .match (version_str ) is not None
220+
221+
222+ def _is_wildcard (part ):
223+ """
224+ Check that a version part is missing or a wildcard
225+ """
226+ return part is None or part in ('x' , 'X' , '*' )
227+
228+
229+ def _comparator_constraints (match ):
230+ """
231+ Expand a single semver comparator to a list of (operator, version)
232+
233+ Partial versions round up to the next release, as npm does:
234+ `>4.3` means `>=4.4.0` and `<=4.3` means `<4.4.0`.
235+ """
236+ op = match .group ('op' ) or '='
237+ major , minor , patch = (
238+ match .group ('major' ), match .group ('minor' ), match .group ('patch' ))
239+
240+ if _is_wildcard (major ):
241+ return []
242+
243+ major = int (major )
244+ has_minor = not _is_wildcard (minor )
245+ has_patch = has_minor and not _is_wildcard (patch )
246+ minor = int (minor ) if has_minor else 0
247+ patch = int (patch ) if has_patch else 0
248+ low = (major , minor , patch )
249+
250+ next_major = (major + 1 , 0 , 0 )
251+ next_minor = (major , minor + 1 , 0 )
252+
253+ if op == '^' :
254+ # allow changes that do not modify the leftmost non-zero part
255+ if not has_minor or major > 0 :
256+ return [('>=' , low ), ('<' , next_major )]
257+ if not has_patch or minor > 0 :
258+ return [('>=' , low ), ('<' , next_minor )]
259+ return [('>=' , low ), ('<' , (0 , 0 , patch + 1 ))]
260+
261+ if op == '~' :
262+ if not has_minor :
263+ return [('>=' , low ), ('<' , next_major )]
264+ return [('>=' , low ), ('<' , next_minor )]
265+
266+ if op == '=' :
267+ if not has_minor :
268+ return [('>=' , low ), ('<' , next_major )]
269+ if not has_patch :
270+ return [('>=' , low ), ('<' , next_minor )]
271+ return [('>=' , low ), ('<=' , low )]
272+
273+ if op == '>' :
274+ if not has_minor :
275+ return [('>=' , next_major )]
276+ if not has_patch :
277+ return [('>=' , next_minor )]
278+ return [('>' , low )]
279+
280+ if op == '<=' :
281+ if not has_minor :
282+ return [('<' , next_major )]
283+ if not has_patch :
284+ return [('<' , next_minor )]
285+ return [('<=' , low )]
286+
287+ # '>=' and '<' take the version padded with zeros
288+ return [(op , low )]
289+
290+
291+ def _parse_comparator (token ):
292+ """
293+ Parse one comparator, return None if it is not valid
294+ """
295+ match = _COMPARATOR_RE .match (token )
296+ if match is None :
297+ return None
298+ return _comparator_constraints (match )
299+
300+
301+ def parse_node_range (spec ):
302+ """
303+ Parse an npm-style semver range
304+
305+ Return a list of alternatives, each a list of (operator, version)
306+ constraints that must all hold, or None if `spec` is not a range.
307+ """
308+ if not spec :
309+ return None
310+
311+ ranges = []
312+ for alternative in spec .split ('||' ):
313+ tokens = alternative .split ()
314+ if not tokens :
315+ return None
316+
317+ if '-' in tokens :
318+ # hyphen range: `4.3.1 - 6.2.0`
319+ if len (tokens ) != 3 or tokens [1 ] != '-' :
320+ return None
321+ groups = [
322+ _parse_comparator ('>=' + tokens [0 ]),
323+ _parse_comparator ('<=' + tokens [2 ]),
324+ ]
325+ else :
326+ groups = [_parse_comparator (token ) for token in tokens ]
327+
328+ constraints = []
329+ for group in groups :
330+ if group is None :
331+ return None
332+ constraints .extend (group )
333+ ranges .append (constraints )
334+
335+ return ranges
336+
337+
338+ def match_node_range (version , ranges ):
339+ """
340+ Check that a version tuple satisfies any of the parsed alternatives
341+ """
342+ version = _pad_version (version )
343+ return any (
344+ all (_OPERATORS [op ](version , other ) for op , other in constraints )
345+ for constraints in ranges
346+ )
347+
348+
189349def node_version_from_args (args ):
190350 """
191351 Parse the node version from the argparse args
@@ -247,6 +407,9 @@ def make_parser():
247407 help = 'The node.js version to use, e.g., '
248408 '--node=0.4.3 will use the node-v0.4.3 '
249409 'to create the new environment. '
410+ 'Accepts npm-style semver ranges too, e.g. --node=22, '
411+ '--node=4.x or --node="^4.3.1", resolved to the highest '
412+ 'matching release. '
250413 'The default is last stable version (`latest`). '
251414 'Use `lts` to use the latest LTS release. '
252415 'Use `system` to use system-wide node.' )
@@ -1088,6 +1251,18 @@ def print_node_versions():
10881251 logger .info ('\t ' .join (chunk ))
10891252
10901253
1254+ def _has_platform_build (version_entry ):
1255+ """
1256+ Check that the version ships a prebuilt package for the host platform
1257+ """
1258+ if is_x86_64_musl () and "linux-x64-musl" not in version_entry ['files' ]:
1259+ return False
1260+ elif is_riscv64 () and "linux-riscv64" not in version_entry ['files' ]:
1261+ return False
1262+
1263+ return True
1264+
1265+
10911266def _get_last_node_version (lts = False ):
10921267 """
10931268 Return last node.js version matching the filter
@@ -1100,12 +1275,7 @@ def version_filter(v):
11001275 if lts and not v ['lts' ]:
11011276 return False
11021277
1103- if is_x86_64_musl () and "linux-x64-musl" not in v ['files' ]:
1104- return False
1105- elif is_riscv64 () and "linux-riscv64" not in v ['files' ]:
1106- return False
1107-
1108- return True
1278+ return _has_platform_build (v )
11091279
11101280 return next ((v ['version' ].lstrip ('v' )
11111281 for v in _get_versions_json () if version_filter (v )), None )
@@ -1125,6 +1295,32 @@ def get_last_lts_node_version():
11251295 return _get_last_node_version (lts = True )
11261296
11271297
1298+ def resolve_node_version (spec ):
1299+ """
1300+ Resolve a semver range to the highest matching node.js version
1301+
1302+ Strings that are not a valid range are returned unchanged, so custom
1303+ and nightly version strings keep working.
1304+ """
1305+ ranges = parse_node_range (spec )
1306+ if ranges is None :
1307+ return spec
1308+
1309+ matched = []
1310+ for version_entry in _get_versions_json ():
1311+ if not _has_platform_build (version_entry ):
1312+ continue
1313+ version = _pad_version (parse_version (version_entry ['version' ]))
1314+ if match_node_range (version , ranges ):
1315+ matched .append (version )
1316+
1317+ if not matched :
1318+ logger .error ("No available node.js version matches '%s'" % spec )
1319+ sys .exit (1 )
1320+
1321+ return '.' .join (str (part ) for part in max (matched ))
1322+
1323+
11281324def get_env_dir (args ):
11291325 if args .python_virtualenv :
11301326 if hasattr (sys , 'real_prefix' ):
@@ -1189,6 +1385,11 @@ def main():
11891385 args .node = get_last_stable_node_version ()
11901386 elif args .node .lower () == 'lts' :
11911387 args .node = get_last_lts_node_version ()
1388+ elif args .node .lower () != 'system' and not _is_exact_version (args .node ):
1389+ resolved = resolve_node_version (args .node )
1390+ if resolved != args .node :
1391+ logger .info (" * Resolved '%s' to %s" % (args .node , resolved ))
1392+ args .node = resolved
11921393
11931394 if args .list :
11941395 print_node_versions ()
0 commit comments