diff --git a/CHANGELOG.md b/CHANGELOG.md index 6629a96..d19f7b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update contact information in `CONTRIBUTING.md` - Update dependencies. Minimum python version is now 3.8 ([#22](https://github.com/vyperlang/vvm/pull/22)) - 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)) ## [0.1.0](https://github.com/vyperlang/vvm/tree/v0.1.0) - 2020-10-07 ### Added diff --git a/tests/conftest.py b/tests/conftest.py index 5f90cb3..ff37fc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,6 +64,12 @@ def vyper_version(request): return version +@pytest.fixture +def latest_version(): + global VERSIONS + return VERSIONS[0] + + @pytest.fixture def foo_source(vyper_version): visibility = "external" if vyper_version >= Version("0.2.0") else "public" diff --git a/tests/test_versioning.py b/tests/test_versioning.py new file mode 100644 index 0000000..3ed491d --- /dev/null +++ b/tests/test_versioning.py @@ -0,0 +1,59 @@ +import pytest +from packaging.specifiers import Specifier +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 + +LAST_PER_MINOR = { + 1: Version("0.1.0b17"), + 2: Version("0.2.16"), + 3: Version("0.3.10"), +} + + +def test_foo_vyper_version(foo_source, vyper_version): + specifier = _detect_version_specifier(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", + [ + ("^0.1.1", "public", "@version", "~=0.1", "latest"), + ("~0.3.0", "external", "pragma version", "~=0.3.0", "0.3.10"), + ("0.1.0b17", "public", "@version", "==0.1.0b17", "0.1.0b17"), + (">=0.3.0-beta17", "external", "@version", ">=0.3.0-beta17", "latest"), + ("0.4.0rc6", "external", "pragma version", "==0.4.0rc6", "0.4.0rc6"), + ], +) +def test_vyper_version( + version_str, decorator, pragma, expected_specifier, expected_version, latest_version +): + source = f""" +# {pragma} {version_str} + +@{decorator} +def foo() -> int128: + return 42 + """ + detected = _detect_version_specifier(source) + assert detected == Specifier(expected_specifier) + if expected_version == "latest": + expected_version = str(latest_version) + assert detect_vyper_version_from_source(source) == Version(expected_version) + + +def test_no_version_in_source(): + with pytest.raises(UnexpectedVersionError) as excinfo: + detect_vyper_version_from_source("def foo() -> int128: return 42") + assert str(excinfo.value) == "No version detected in source code" + + +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" diff --git a/vvm/__init__.py b/vvm/__init__.py index 101c423..b1c38c4 100644 --- a/vvm/__init__.py +++ b/vvm/__init__.py @@ -6,3 +6,4 @@ set_vyper_version, ) from vvm.main import compile_files, compile_source, compile_standard, get_vyper_version +from vvm.utils.versioning import detect_vyper_version_from_source diff --git a/vvm/install.py b/vvm/install.py index a62d9ef..a311021 100644 --- a/vvm/install.py +++ b/vvm/install.py @@ -35,6 +35,7 @@ VVM_BINARY_PATH_VARIABLE = "VVM_BINARY_PATH" _default_vyper_binary = None +_installable_vyper_versions: Optional[List[Version]] = None def _get_os_name() -> str: @@ -166,11 +167,19 @@ def get_installable_vyper_versions(headers: Dict = None) -> List[Version]: """ Return a list of all `vyper` versions that can be installed by vvm. + Note: this function is cached, so subsequent calls will not change the result. + When new versions of vyper are released, the cache will need to be cleared + manually or the application restarted. + Returns ------- List List of Versions objects of installable `vyper` versions. """ + global _installable_vyper_versions + if _installable_vyper_versions is not None: + return _installable_vyper_versions + version_list = [] headers = _get_headers(headers) @@ -180,7 +189,9 @@ def get_installable_vyper_versions(headers: Dict = None) -> List[Version]: asset = next((i for i in release["assets"] if _get_os_name() in i["name"]), False) if asset: version_list.append(version) - return sorted(version_list, reverse=True) + + _installable_vyper_versions = sorted(version_list, reverse=True) + return _installable_vyper_versions def get_installed_vyper_versions(vvm_binary_path: Union[Path, str] = None) -> List[Version]: diff --git a/vvm/utils/versioning.py b/vvm/utils/versioning.py new file mode 100644 index 0000000..b59ebb0 --- /dev/null +++ b/vvm/utils/versioning.py @@ -0,0 +1,99 @@ +import itertools +import re +from typing import Any, Optional + +from packaging.specifiers import Specifier +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*)") + + +def _detect_version_specifier(source_code: str) -> Specifier: + """ + Detect the version given by the pragma version in the source code. + + Arguments + --------- + source_code : str + Source code to detect the version from. + + Returns + ------- + str + vyper version specifier, or None if none could be detected. + """ + match = _VERSION_RE.search(source_code) + if match is None: + raise UnexpectedVersionError("No version detected in source code") + + specifier, version_str = match.groups() + if specifier in ("~", "^"): # convert from npm-style to pypi-style + if specifier == "^": # minor match, remove the patch from the version + version_str = ".".join(version_str.split(".")[:-1]) + specifier = "~=" # finds compatible versions + + if specifier == "": + specifier = "==" + return Specifier(specifier + version_str) + + +def _pick_vyper_version( + specifier: Specifier, + 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 + version. + + Arguments + --------- + specifier : Specifier + Specifier 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 + prereleases or not (based on the specifier.prereleases attribute, and + whether the only versions matching are prereleases). + check_installed : bool, optional + Whether to check the installed versions. Defaults to True. + check_installable : bool, optional + Whether to check the installable versions. Defaults to True. + + Returns + ------- + Version + Vyper version that satisfies the specifier, or None if no version satisfies the specifier. + """ + 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}") + return ret + + +def detect_vyper_version_from_source(source_code: str, **kwargs: Any) -> Version: + """ + Detect the version given by the pragma version in the source code. + + Arguments + --------- + source_code : str + Source code to detect the version from. + kwargs : Any + Keyword arguments to pass to `pick_vyper_version`. + + Returns + ------- + Version + vyper version, or None if no version could be detected. + """ + specifier = _detect_version_specifier(source_code) + return _pick_vyper_version(specifier, **kwargs)