From 318e97afb72143eb34ff433f93864296c9df9dbe Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 21:16:08 +0300 Subject: [PATCH 1/4] feat(nodeenv): parse npm-style semver ranges --- nodeenv.py | 149 ++++++++++++++++++++++++++++++++++++++++++ tests/nodeenv_test.py | 76 +++++++++++++++++++++ 2 files changed, 225 insertions(+) diff --git a/nodeenv.py b/nodeenv.py index 7158c09..41d4de1 100644 --- a/nodeenv.py +++ b/nodeenv.py @@ -182,6 +182,155 @@ def parse_version(version_str): return tuple(map(int, v)) +_EXACT_VERSION_RE = re.compile(r'^v?\d+\.\d+\.\d+(\+\S*)?$') + +_COMPARATOR_RE = re.compile( + r'^(?P\^|~|>=|<=|>|<|=)?\s*' + r'v?(?P\d+|[xX*])' + r'(?:\.(?P\d+|[xX*]))?' + r'(?:\.(?P\d+|[xX*]))?$' +) + +_OPERATORS = { + '>=': operator.ge, + '>': operator.gt, + '<=': operator.le, + '<': operator.lt, + '=': operator.eq, +} + + +def _pad_version(version): + """ + Pad a version tuple to (major, minor, patch) + """ + parts = tuple(version)[:3] + return parts + (0,) * (3 - len(parts)) + + +def _is_exact_version(version_str): + """ + Check that the string is a complete version and needs no resolving + """ + return _EXACT_VERSION_RE.match(version_str) is not None + + +def _is_wildcard(part): + """ + Check that a version part is missing or a wildcard + """ + return part is None or part in ('x', 'X', '*') + + +def _comparator_constraints(match): + """ + Expand a single semver comparator to a list of (operator, version) + + Partial versions round up to the next release, as npm does: + `>4.3` means `>=4.4.0` and `<=4.3` means `<4.4.0`. + """ + op = match.group('op') or '=' + major, minor, patch = ( + match.group('major'), match.group('minor'), match.group('patch')) + + if _is_wildcard(major): + return [] + + major = int(major) + has_minor = not _is_wildcard(minor) + has_patch = has_minor and not _is_wildcard(patch) + minor = int(minor) if has_minor else 0 + patch = int(patch) if has_patch else 0 + low = (major, minor, patch) + + next_major = (major + 1, 0, 0) + next_minor = (major, minor + 1, 0) + + if op == '^': + # allow changes that do not modify the leftmost non-zero part + if not has_minor or major > 0: + return [('>=', low), ('<', next_major)] + if not has_patch or minor > 0: + return [('>=', low), ('<', next_minor)] + return [('>=', low), ('<', (0, 0, patch + 1))] + + if op == '~': + if not has_minor: + return [('>=', low), ('<', next_major)] + return [('>=', low), ('<', next_minor)] + + if op == '=': + if not has_minor: + return [('>=', low), ('<', next_major)] + if not has_patch: + return [('>=', low), ('<', next_minor)] + return [('>=', low), ('<=', low)] + + if op == '>': + if not has_minor: + return [('>=', next_major)] + if not has_patch: + return [('>=', next_minor)] + return [('>', low)] + + if op == '<=': + if not has_minor: + return [('<', next_major)] + if not has_patch: + return [('<', next_minor)] + return [('<=', low)] + + # '>=' and '<' take the version padded with zeros + return [(op, low)] + + +def _parse_comparator(token): + """ + Parse one comparator, return None if it is not valid + """ + match = _COMPARATOR_RE.match(token) + if match is None: + return None + return _comparator_constraints(match) + + +def parse_node_range(spec): + """ + Parse an npm-style semver range + + Return a list of alternatives, each a list of (operator, version) + constraints that must all hold, or None if `spec` is not a range. + """ + if not spec: + return None + + ranges = [] + for alternative in spec.split('||'): + tokens = alternative.split() + if not tokens: + return None + + if '-' in tokens: + # hyphen range: `4.3.1 - 6.2.0` + if len(tokens) != 3 or tokens[1] != '-': + return None + groups = [ + _parse_comparator('>=' + tokens[0]), + _parse_comparator('<=' + tokens[2]), + ] + else: + groups = [_parse_comparator(token) for token in tokens] + + constraints = [] + for group in groups: + if group is None: + return None + constraints.extend(group) + ranges.append(constraints) + + return ranges + + def node_version_from_args(args): """ Parse the node version from the argparse args diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index b834c3f..cc17559 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -270,6 +270,82 @@ def test_parse_version(): assert nodeenv.parse_version("v21.7.3+0-b20240228T18452699") == (21, 7, 3) +def test_pad_version(): + assert nodeenv._pad_version((4,)) == (4, 0, 0) + assert nodeenv._pad_version((4, 3)) == (4, 3, 0) + assert nodeenv._pad_version((4, 3, 1)) == (4, 3, 1) + + +@pytest.mark.parametrize( + ('spec', 'expected'), + ( + ('22', [[('>=', (22, 0, 0)), ('<', (23, 0, 0))]]), + ('21.7', [[('>=', (21, 7, 0)), ('<', (21, 8, 0))]]), + ('4.x', [[('>=', (4, 0, 0)), ('<', (5, 0, 0))]]), + ('4.*', [[('>=', (4, 0, 0)), ('<', (5, 0, 0))]]), + ('*', [[]]), + ('^4.3.1', [[('>=', (4, 3, 1)), ('<', (5, 0, 0))]]), + ('^0.4.3', [[('>=', (0, 4, 3)), ('<', (0, 5, 0))]]), + ('^0.0.3', [[('>=', (0, 0, 3)), ('<', (0, 0, 4))]]), + ('^0.x', [[('>=', (0, 0, 0)), ('<', (1, 0, 0))]]), + ('^0.0.x', [[('>=', (0, 0, 0)), ('<', (0, 1, 0))]]), + ('~4.3.1', [[('>=', (4, 3, 1)), ('<', (4, 4, 0))]]), + ('~4.3', [[('>=', (4, 3, 0)), ('<', (4, 4, 0))]]), + ('~4', [[('>=', (4, 0, 0)), ('<', (5, 0, 0))]]), + ('>=20.0.0', [[('>=', (20, 0, 0))]]), + ('>=20', [[('>=', (20, 0, 0))]]), + ('<21.0.0', [[('<', (21, 0, 0))]]), + ('>4.3', [[('>=', (4, 4, 0))]]), + ('>4', [[('>=', (5, 0, 0))]]), + ('<=4.3', [[('<', (4, 4, 0))]]), + ('<=4.3.1', [[('<=', (4, 3, 1))]]), + ('=22.11.0', [[('>=', (22, 11, 0)), ('<=', (22, 11, 0))]]), + ('>=20 <22', [[('>=', (20, 0, 0)), ('<', (22, 0, 0))]]), + ('4 - 6', [[('>=', (4, 0, 0)), ('<', (7, 0, 0))]]), + ('4.3.1 - 6.2.0', [[('>=', (4, 3, 1)), ('<=', (6, 2, 0))]]), + ( + '8 || 10', + [ + [('>=', (8, 0, 0)), ('<', (9, 0, 0))], + [('>=', (10, 0, 0)), ('<', (11, 0, 0))], + ], + ), + ), +) +def test_parse_node_range(spec, expected): + assert nodeenv.parse_node_range(spec) == expected + + +@pytest.mark.parametrize( + 'spec', + ( + '', 'abc', 'system', 'latest', 'lts', '1.2.3.4', '>=', 'v', '4-6', + '4 - 6 - 8', '>=20 Date: Fri, 14 Aug 2026 21:17:06 +0300 Subject: [PATCH 2/4] feat(nodeenv): match versions against parsed semver ranges --- nodeenv.py | 11 +++++++++++ tests/nodeenv_test.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/nodeenv.py b/nodeenv.py index 41d4de1..98d02cf 100644 --- a/nodeenv.py +++ b/nodeenv.py @@ -331,6 +331,17 @@ def parse_node_range(spec): return ranges +def match_node_range(version, ranges): + """ + Check that a version tuple satisfies any of the parsed alternatives + """ + version = _pad_version(version) + return any( + all(_OPERATORS[op](version, other) for op, other in constraints) + for constraints in ranges + ) + + def node_version_from_args(args): """ Parse the node version from the argparse args diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index cc17559..46cba81 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -346,6 +346,40 @@ def test_is_exact_version(spec, expected): assert nodeenv._is_exact_version(spec) is expected +@pytest.mark.parametrize( + ('spec', 'version', 'expected'), + ( + ('^4.3.1', (4, 3, 1), True), + ('^4.3.1', (4, 9, 1), True), + ('^4.3.1', (4, 3, 0), False), + ('^4.3.1', (5, 0, 0), False), + ('~4.3.1', (4, 3, 9), True), + ('~4.3.1', (4, 4, 0), False), + ('4.x', (4, 0, 0), True), + ('4.x', (3, 9, 9), False), + ('*', (0, 1, 14), True), + ('>=20 <22', (21, 5, 0), True), + ('>=20 <22', (22, 0, 0), False), + ('>=20 <22', (19, 9, 9), False), + ('8 || 10', (8, 1, 0), True), + ('8 || 10', (10, 1, 0), True), + ('8 || 10', (9, 1, 0), False), + ('4 - 6', (6, 17, 1), True), + ('4 - 6', (7, 0, 0), False), + ('4.3.1 - 6.2.0', (6, 2, 0), True), + ('4.3.1 - 6.2.0', (6, 2, 1), False), + ('>4.3', (4, 4, 0), True), + ('>4.3', (4, 3, 9), False), + ('22', (22, 0, 0), True), + # a two-part version tuple is padded before comparing + ('~4.3', (4, 3), True), + ), +) +def test_match_node_range(spec, version, expected): + ranges = nodeenv.parse_node_range(spec) + assert nodeenv.match_node_range(version, ranges) is expected + + def test_clear_output(): assert nodeenv.clear_output( bytes('some \ntext', 'utf-8')) == 'some text' From 896fae3d220f4f73cbdf59cd41bcc1c4bc708f3b Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 21:18:01 +0300 Subject: [PATCH 3/4] feat(nodeenv): resolve semver ranges against released versions --- nodeenv.py | 45 ++++++++++++++++++++++++---- tests/nodeenv_test.py | 69 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/nodeenv.py b/nodeenv.py index 98d02cf..28882fb 100644 --- a/nodeenv.py +++ b/nodeenv.py @@ -1215,6 +1215,18 @@ def print_node_versions(): logger.info('\t'.join(chunk)) +def _has_platform_build(version_entry): + """ + Check that the version ships a prebuilt package for the host platform + """ + if is_x86_64_musl() and "linux-x64-musl" not in version_entry['files']: + return False + elif is_riscv64() and "linux-riscv64" not in version_entry['files']: + return False + + return True + + def _get_last_node_version(lts=False): """ Return last node.js version matching the filter @@ -1227,12 +1239,7 @@ def version_filter(v): if lts and not v['lts']: return False - if is_x86_64_musl() and "linux-x64-musl" not in v['files']: - return False - elif is_riscv64() and "linux-riscv64" not in v['files']: - return False - - return True + return _has_platform_build(v) return next((v['version'].lstrip('v') for v in _get_versions_json() if version_filter(v)), None) @@ -1252,6 +1259,32 @@ def get_last_lts_node_version(): return _get_last_node_version(lts=True) +def resolve_node_version(spec): + """ + Resolve a semver range to the highest matching node.js version + + Strings that are not a valid range are returned unchanged, so custom + and nightly version strings keep working. + """ + ranges = parse_node_range(spec) + if ranges is None: + return spec + + matched = [] + for version_entry in _get_versions_json(): + if not _has_platform_build(version_entry): + continue + version = _pad_version(parse_version(version_entry['version'])) + if match_node_range(version, ranges): + matched.append(version) + + if not matched: + logger.error("No available node.js version matches '%s'" % spec) + sys.exit(1) + + return '.'.join(str(part) for part in max(matched)) + + def get_env_dir(args): if args.python_virtualenv: if hasattr(sys, 'real_prefix'): diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index 46cba81..50d378a 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -91,6 +91,13 @@ def mock_riscv64_platform(): yield +@pytest.fixture +def mock_musl_platform(): + with mock.patch.object(nodeenv, 'is_x86_64_musl', return_value=True): + with mock.patch.object(nodeenv, 'is_riscv64', return_value=False): + yield + + def mck_to_out(mck): return '\n'.join(call[0][0] for call in mck.call_args_list) @@ -380,6 +387,68 @@ def test_match_node_range(spec, version, expected): assert nodeenv.match_node_range(version, ranges) is expected +@pytest.mark.usefixtures('mock_index_json', 'mock_host_platform') +@pytest.mark.parametrize( + ('spec', 'expected'), + ( + ('4.x', '4.9.1'), + ('^4.3.1', '4.9.1'), + ('~4.3.1', '4.3.2'), + ('>=10 <12', '11.15.0'), + ('0.10', '0.10.48'), + ('^0.4.3', '0.4.12'), + ('8 || 10', '10.18.0'), + ('4 - 6', '6.17.1'), + ('>4.3', '13.5.0'), + ('*', '13.5.0'), + ), +) +def test_resolve_node_version(spec, expected): + assert nodeenv.resolve_node_version(spec) == expected + + +@pytest.mark.usefixtures('mock_index_json', 'mock_host_platform') +def test_resolve_node_version_no_match(): + # the fixture index has no 0.0.x releases + with pytest.raises(SystemExit) as excinfo: + nodeenv.resolve_node_version('^0.0.3') + assert excinfo.value.code == 1 + + +@pytest.mark.parametrize('spec', ('abc', '23.0.0-nightly20240101abcdef')) +def test_resolve_node_version_passes_through_non_ranges(spec): + # no index fixture: a non-range must not hit the network at all + with mock.patch.object(nodeenv, 'urlopen') as mck: + assert nodeenv.resolve_node_version(spec) == spec + assert mck.call_count == 0 + + +@pytest.mark.usefixtures('mock_index_json', 'mock_musl_platform') +def test_resolve_node_version_skips_versions_without_platform_build(): + # no release in the fixture index ships a linux-x64-musl build + with pytest.raises(SystemExit) as excinfo: + nodeenv.resolve_node_version('4.x') + assert excinfo.value.code == 1 + + +@pytest.mark.usefixtures('mock_host_platform') +def test_has_platform_build_without_special_platform(): + assert nodeenv._has_platform_build({'files': []}) is True + + +@pytest.mark.usefixtures('mock_musl_platform') +def test_has_platform_build_musl(): + assert nodeenv._has_platform_build({'files': []}) is False + assert nodeenv._has_platform_build( + {'files': ['linux-x64-musl']}) is True + + +@pytest.mark.usefixtures('mock_riscv64_platform') +def test_has_platform_build_riscv64(): + assert nodeenv._has_platform_build({'files': []}) is False + assert nodeenv._has_platform_build({'files': ['linux-riscv64']}) is True + + def test_clear_output(): assert nodeenv.clear_output( bytes('some \ntext', 'utf-8')) == 'some text' From 13ccc0bff33364f409ad123b3589a4b4750b9a88 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 14 Aug 2026 21:20:57 +0300 Subject: [PATCH 4/4] feat(nodeenv): accept semver ranges in --node. fixes #152 --- README.rst | 20 +++++++++++++++++--- nodeenv.py | 8 ++++++++ tests/nodeenv_test.py | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 09880b8..0e0eb41 100644 --- a/README.rst +++ b/README.rst @@ -141,6 +141,17 @@ Install node.js from a mirror:: $ nodeenv --node=10.19.0 --mirror=https://npm.taobao.org/mirrors/node +Install the highest node.js release matching a version range:: + + $ nodeenv --node=22 env-22 + $ nodeenv --node=4.x env-4 + $ nodeenv --node="^4.3.1" env-4.3 + $ nodeenv --node=">=20 <22" env-20 + +Ranges use `npm semver syntax`_ and also work in a ``.node-version`` file. + +.. _npm semver syntax: https://docs.npmjs.com/cli/v10/using-npm/semver + It's much faster to install from the prebuilt package than Install & compile node.js from source:: @@ -242,9 +253,12 @@ Basic options ^^^^^^^^^^^^^ ``-n NODE_VER, --node=NODE_VER`` - The node.js version to use, e.g., ``--node=22.11.0``. The default is the - last stable version (``latest``). Use ``lts`` for the latest LTS release. - Use ``system`` to use system-wide node. + The node.js version to use, e.g., ``--node=22.11.0``. Also accepts an + npm-style semver range, which is resolved to the highest matching + release: ``--node=22``, ``--node=4.x``, ``--node="^4.3.1"``, + ``--node="~4.3"``, ``--node=">=20 <22"``, ``--node="8 || 10"``. + The default is the last stable version (``latest``). Use ``lts`` for the + latest LTS release. Use ``system`` to use system-wide node. ``-l, --list`` Lists available node.js versions. diff --git a/nodeenv.py b/nodeenv.py index 28882fb..237b545 100644 --- a/nodeenv.py +++ b/nodeenv.py @@ -403,6 +403,9 @@ def make_parser(): help='The node.js version to use, e.g., ' '--node=0.4.3 will use the node-v0.4.3 ' 'to create the new environment. ' + 'Accepts npm-style semver ranges too, e.g. --node=22, ' + '--node=4.x or --node="^4.3.1", resolved to the highest ' + 'matching release. ' 'The default is last stable version (`latest`). ' 'Use `lts` to use the latest LTS release. ' 'Use `system` to use system-wide node.') @@ -1346,6 +1349,11 @@ def main(): args.node = get_last_stable_node_version() elif args.node.lower() == 'lts': args.node = get_last_lts_node_version() + elif args.node.lower() != 'system' and not _is_exact_version(args.node): + resolved = resolve_node_version(args.node) + if resolved != args.node: + logger.info(" * Resolved '%s' to %s" % (args.node, resolved)) + args.node = resolved if args.list: print_node_versions() diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index 50d378a..1802af0 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -449,6 +449,45 @@ def test_has_platform_build_riscv64(): assert nodeenv._has_platform_build({'files': ['linux-riscv64']}) is True +def _run_main_resolving(argv): + """ + Run main() far enough to resolve args.node, then stop + """ + with mock.patch.object(sys, 'argv', ['nodeenv'] + argv): + with mock.patch.object( + nodeenv, 'create_environment') as create_environment: + nodeenv.main() + assert create_environment.call_count == 1 + return create_environment.call_args[0][1].node + + +@pytest.mark.usefixtures('mock_index_json', 'mock_host_platform') +def test_main_resolves_range(): + assert _run_main_resolving(['--node', '4.x', 'env']) == '4.9.1' + + +@pytest.mark.usefixtures('mock_host_platform') +def test_main_keeps_exact_version_without_network(): + with mock.patch.object(nodeenv, 'urlopen') as mck: + assert _run_main_resolving(['--node', '22.11.0', 'env']) == '22.11.0' + assert mck.call_count == 0 + + +@pytest.mark.usefixtures('mock_host_platform') +def test_main_keeps_unparseable_version_without_network(): + version = '23.0.0-nightly20240101abcdef' + with mock.patch.object(nodeenv, 'urlopen') as mck: + assert _run_main_resolving(['--node', version, 'env']) == version + assert mck.call_count == 0 + + +@pytest.mark.skipif(nodeenv.is_WIN, reason='-n system is posix only') +def test_main_keeps_system_without_network(): + with mock.patch.object(nodeenv, 'urlopen') as mck: + assert _run_main_resolving(['--node', 'system', 'env']) == 'system' + assert mck.call_count == 0 + + def test_clear_output(): assert nodeenv.clear_output( bytes('some \ntext', 'utf-8')) == 'some text'