diff --git a/CHANGELOG.md b/CHANGELOG.md index 8baba7e..c1ac585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `output_format` argument to `compile_source` and `compile_files` ([#21](https://github.com/vyperlang/vvm/pull/21)) - New public function `detect_vyper_version_from_source` ([#23](https://github.com/vyperlang/vvm/pull/23)) - Fix `combine_json` for versions `>0.3.10` ([#29](https://github.com/vyperlang/vvm/pull/29)) +- Relax version detection checks ([#30](https://github.com/vyperlang/vvm/pull/30)) ## [0.1.0](https://github.com/vyperlang/vvm/tree/v0.1.0) - 2020-10-07 ### Added diff --git a/tests/test_versioning.py b/tests/test_versioning.py index a451a6b..a24374e 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -1,32 +1,38 @@ import pytest -from packaging.specifiers import Specifier +from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import Version from vvm import detect_vyper_version_from_source from vvm.exceptions import UnexpectedVersionError -from vvm.utils.versioning import _detect_version_specifier, _pick_vyper_version +from vvm.utils.versioning import _pick_vyper_version, detect_version_specifier_set def test_foo_vyper_version(foo_source, vyper_version): - specifier = _detect_version_specifier(foo_source) + specifier = detect_version_specifier_set(foo_source) assert str(specifier) == f"=={vyper_version}" assert vyper_version.major == 0 assert _pick_vyper_version(specifier) == vyper_version @pytest.mark.parametrize( - "version_str,decorator,pragma,expected_specifier,expected_version", + "version_str,decorator,pragma,expected_specifier_set,expected_version", [ + # npm's ^ gets converted to ~= ("^0.2.0", "public", "@version", "~=0.2.0", "0.2.16"), - ("~0.3.0", "external", "pragma version", "~=0.3.0", "0.3.10"), - ("0.1.0b17", "public", "@version", "==0.1.0b17", "0.1.0b17"), + ("^0.4.0", "external", "pragma version", "~=0.4.0", "0.4.0"), ("^0.1.0b16", "public", "@version", "~=0.1.0b16", "0.1.0b17"), - (">=0.3.0-beta17", "external", "@version", ">=0.3.0-beta17", "latest"), + # indented comment is supported + ("0.4.0", "external", " pragma version", "==0.4.0", "0.4.0"), + # pep440 >= and < are preserved + (">=0.3.10, <0.4.0", "external", "pragma version", ">=0.3.10, <0.4.0", "0.3.10"), + # beta and release candidate are supported + ("0.1.0b17", "public", "@version", "==0.1.0b17", "0.1.0b17"), ("0.4.0rc6", "external", "pragma version", "==0.4.0rc6", "0.4.0rc6"), + (">=0.3.0-beta17", "external", "@version", ">=0.3.0b17", "latest"), ], ) def test_vyper_version( - version_str, decorator, pragma, expected_specifier, expected_version, latest_version + version_str, decorator, pragma, expected_specifier_set, expected_version, latest_version ): source = f""" # {pragma} {version_str} @@ -35,13 +41,32 @@ def test_vyper_version( def foo() -> int128: return 42 """ - detected = _detect_version_specifier(source) - assert detected == Specifier(expected_specifier) + detected = detect_version_specifier_set(source) + assert detected == SpecifierSet(expected_specifier_set) if expected_version == "latest": expected_version = str(latest_version) assert detect_vyper_version_from_source(source) == Version(expected_version) +@pytest.mark.parametrize( + "version_str", + [ + "~0.2.0", + ">= 0.3.1 < 0.4.0", + "0.3.1 - 0.3.2", + "0.3.1 || 0.3.2", + "=0.3.1", + ], +) +def test_unsported_vyper_version(version_str): + # npm's complex ranges are not supported although old vyper versions can handle them + source = f""" +# @version {version_str} + """ + with pytest.raises(InvalidSpecifier): + detect_version_specifier_set(source) + + def test_no_version_in_source(): assert detect_vyper_version_from_source("def foo() -> int128: return 42") is None @@ -50,12 +75,3 @@ def test_version_does_not_exist(): with pytest.raises(UnexpectedVersionError) as excinfo: detect_vyper_version_from_source("# pragma version 2024.0.1") assert str(excinfo.value) == "No installable Vyper satisfies the specifier ==2024.0.1" - - -def test_npm_version_for_04_release(): - with pytest.raises(UnexpectedVersionError) as excinfo: - detect_vyper_version_from_source("# pragma version ^0.4.1") - - expected_msg = "Please use the pypi-style version specifier " - expected_msg += "for vyper versions >= 0.4.0 (hint: try ~=0.4.1)" - assert str(excinfo.value) == expected_msg diff --git a/vvm/main.py b/vvm/main.py index 50b404e..97ad5d5 100644 --- a/vvm/main.py +++ b/vvm/main.py @@ -139,7 +139,6 @@ def _compile( output_format: Optional[str], **kwargs: Any, ) -> Any: - if vyper_binary is None: vyper_binary = get_executable(vyper_version) if output_format is None: diff --git a/vvm/utils/versioning.py b/vvm/utils/versioning.py index 69c88cd..0ce599d 100644 --- a/vvm/utils/versioning.py +++ b/vvm/utils/versioning.py @@ -2,62 +2,67 @@ import re from typing import Any, Optional -from packaging.specifiers import Specifier +from packaging.specifiers import SpecifierSet from packaging.version import Version from vvm.exceptions import UnexpectedVersionError from vvm.install import get_installable_vyper_versions, get_installed_vyper_versions -_VERSION_RE = re.compile(r"\s*#\s*(?:pragma\s+|@)version\s+([=><^~]*)(\d+\.\d+\.\d+\S*)") +# Find the first occurence of version specifier in the source code. +# allow for indented comment (as the compiler allows it (as of 0.4.0)). +# might have false positive if a triple quoted string contains a line +# that looks like a version specifier and is before the actual version +# specifier in the code, but this is accepted as it is an unlikely edge case. +_VERSION_RE = re.compile(r"^\s*(?:#\s*(?:@version|pragma\s+version)\s+(.*))", re.MULTILINE) -def _detect_version_specifier(source_code: str) -> Optional[Specifier]: +def detect_version_specifier_set(source_code: str) -> Optional[SpecifierSet]: """ - Detect the version given by the pragma version in the source code. + Detect the specifier set given by the pragma version in the source code. Arguments --------- source_code : str - Source code to detect the version from. + Source code to detect the specifier set from. Returns ------- - str - vyper version specifier, or None if none could be detected. + Optional[SpecifierSet] + vyper version specifier set, or None if none could be detected. """ match = _VERSION_RE.search(source_code) if match is None: return None - specifier, version_str = match.groups() - if specifier in ("~", "^"): # convert from npm-style to pypi-style - if Version(version_str) >= Version("0.4.0"): - error = "Please use the pypi-style version specifier " - error += f"for vyper versions >= 0.4.0 (hint: try ~={version_str})" - raise UnexpectedVersionError(error) - # for v0.x, both specifiers are equivalent - specifier = "~=" # finds compatible versions + version_str = match.group(1) + + # X.Y.Z or vX.Y.Z => ==X.Y.Z, ==vX.Y.Z + if re.match("[v0-9]", version_str): + version_str = "==" + version_str + # adapted from vyper/ast/pre_parse.py at commit c32b9b4c6f0d8 + # partially convert npm to pep440 + # - <0.4.0 contracts with complex npm version range might fail + # - in versions >=1.0.0, the below conversion will be invalid + version_str = re.sub("^\\^", "~=", version_str) - if specifier == "": - specifier = "==" - return Specifier(specifier + version_str) + return SpecifierSet(version_str) def _pick_vyper_version( - specifier: Specifier, + specifier_set: SpecifierSet, prereleases: Optional[bool] = None, check_installed: bool = True, check_installable: bool = True, ) -> Version: """ - Pick the latest vyper version that is installed and satisfies the given specifier. - If None of the installed versions satisfy the specifier, pick the latest installable + Pick the latest vyper version that is installed and satisfies the given specifier set. + If None of the installed versions satisfy the specifier set, pick the latest installable version. Arguments --------- - specifier : Specifier - Specifier to pick a version for. + specifier_set : SpecifierSet + Specifier set to pick a version for. prereleases : bool, optional Whether to allow prereleases in the returned iterator. If set to ``None`` (the default), it will be intelligently decide whether to allow @@ -71,14 +76,16 @@ def _pick_vyper_version( Returns ------- Version - Vyper version that satisfies the specifier, or None if no version satisfies the specifier. + Vyper version that satisfies the specifier set, or None if no version satisfies the set. """ versions = itertools.chain( get_installed_vyper_versions() if check_installed else [], get_installable_vyper_versions() if check_installable else [], ) - if (ret := next(specifier.filter(versions, prereleases), None)) is None: - raise UnexpectedVersionError(f"No installable Vyper satisfies the specifier {specifier}") + if (ret := next(specifier_set.filter(versions, prereleases), None)) is None: + raise UnexpectedVersionError( + f"No installable Vyper satisfies the specifier {specifier_set}" + ) return ret @@ -98,7 +105,7 @@ def detect_vyper_version_from_source(source_code: str, **kwargs: Any) -> Optiona Optional[Version] vyper version, or None if no version could be detected. """ - specifier = _detect_version_specifier(source_code) - if specifier is None: + specifier_set = detect_version_specifier_set(source_code) + if specifier_set is None: return None - return _pick_vyper_version(specifier, **kwargs) + return _pick_vyper_version(specifier_set, **kwargs)