Skip to content

Commit bb12812

Browse files
authored
feat(nodeenv): accept npm-style semver ranges in --node (#393)
* feat(nodeenv): parse npm-style semver ranges * feat(nodeenv): match versions against parsed semver ranges * feat(nodeenv): resolve semver ranges against released versions * feat(nodeenv): accept semver ranges in --node. fixes #152
1 parent f1bd387 commit bb12812

3 files changed

Lines changed: 442 additions & 9 deletions

File tree

README.rst

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,17 @@ Install node.js from a mirror::
141141

142142
$ nodeenv --node=10.19.0 --mirror=https://npm.taobao.org/mirrors/node
143143

144+
Install the highest node.js release matching a version range::
145+
146+
$ nodeenv --node=22 env-22
147+
$ nodeenv --node=4.x env-4
148+
$ nodeenv --node="^4.3.1" env-4.3
149+
$ nodeenv --node=">=20 <22" env-20
150+
151+
Ranges use `npm semver syntax`_ and also work in a ``.node-version`` file.
152+
153+
.. _npm semver syntax: https://docs.npmjs.com/cli/v10/using-npm/semver
154+
144155
It's much faster to install from the prebuilt package than Install & compile
145156
node.js from source::
146157

@@ -242,9 +253,12 @@ Basic options
242253
^^^^^^^^^^^^^
243254

244255
``-n NODE_VER, --node=NODE_VER``
245-
The node.js version to use, e.g., ``--node=22.11.0``. The default is the
246-
last stable version (``latest``). Use ``lts`` for the latest LTS release.
247-
Use ``system`` to use system-wide node.
256+
The node.js version to use, e.g., ``--node=22.11.0``. Also accepts an
257+
npm-style semver range, which is resolved to the highest matching
258+
release: ``--node=22``, ``--node=4.x``, ``--node="^4.3.1"``,
259+
``--node="~4.3"``, ``--node=">=20 <22"``, ``--node="8 || 10"``.
260+
The default is the last stable version (``latest``). Use ``lts`` for the
261+
latest LTS release. Use ``system`` to use system-wide node.
248262

249263
``-l, --list``
250264
Lists available node.js versions.

nodeenv.py

Lines changed: 207 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
189349
def 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+
10911266
def _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+
11281324
def 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

Comments
 (0)