Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: relax version detection checks #30

Merged
merged 15 commits into from
Nov 21, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 35 additions & 19 deletions tests/test_versioning.py
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -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

Expand All @@ -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
1 change: 0 additions & 1 deletion vvm/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
65 changes: 36 additions & 29 deletions vvm/utils/versioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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)
Loading