Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 99 additions & 23 deletions ape_vyper/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import re
import subprocess
import time
from collections.abc import Iterable
from collections.abc import Iterable, Iterator
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any
Expand All @@ -15,14 +15,16 @@
from eth_utils import is_0x_prefixed
from ethpm_types import ASTNode, PCMap, SourceMapItem
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import Version
from semantic_version import NpmSpec # type: ignore[import-untyped]
from semantic_version import Version as NpmVersion # type: ignore[import-untyped]
from vvm.exceptions import UnknownOption, UnknownValue # type: ignore

from ape_vyper.exceptions import RuntimeErrorType, VyperError, VyperInstallError
from ape_vyper.exceptions import RuntimeErrorType, VyperCompileError, VyperError, VyperInstallError

if TYPE_CHECKING:
from ape.types.trace import SourceTraceback
from ethpm_types.source import Function
from packaging.version import Version

Optimization = str | bool
EVM_VERSION_DEFAULT = {
Expand Down Expand Up @@ -82,34 +84,106 @@ def install_vyper(version: "Version"):
) from err


def get_version_pragma_spec(source: str | Path) -> SpecifierSet | None:
VERSION_PRAGMA_PATTERN = re.compile(
r"(?:\n|^)\s*#\s*(?P<style>@version|pragma\s+version)\s*(?P<version>[^\n]*)"
)
# Vyper 0.3.10 switched version pragma matching from NpmSpec to SpecifierSet.
VYPER_PEP440_PRAGMA_START_VERSION = Version("0.3.10")


def _as_pep440_spec(pragma_str: str) -> str:
pragma_str = pragma_str.replace("^", "~=")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong for post 1.0 vyper

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a direct port of how vyper itself does it https://github.com/vyperlang/vyper/blob/c219e6be2f230931be19a8eb945dc432538f9245/vyper/ast/pre_parser.py#L30

from my understanding ^1.0.0 in semver is >=1.0.0 <2.0.0
while the rewritten ~=1.0.0 in pep440 becomes >=1.0.0 <1.1.0

if pragma_str and pragma_str[0].isnumeric():
return f"=={pragma_str}"

return pragma_str


def _as_npm_version(version: Version) -> NpmVersion:
version_str = str(version)
version_str = re.sub(r"(?<=\d)a(?=\d)", "-alpha.", version_str)
version_str = re.sub(r"(?<=\d)b(?=\d)", "-beta.", version_str)
version_str = re.sub(r"(?<=\d)rc(?=\d)", "-rc.", version_str)
return NpmVersion(version_str)


class VyperVersionSpecifier:
def __init__(self, pragma_str: str, style: str, source_path: Path | None = None):
self.pragma_str = pragma_str
self.style = style
self.source_path = source_path
self._npm_spec: NpmSpec | None = None
self._pep440_spec: SpecifierSet | None = None

try:
self._npm_spec = NpmSpec(pragma_str)
except ValueError:
pass

try:
self._pep440_spec = SpecifierSet(_as_pep440_spec(pragma_str))
except InvalidSpecifier:
pass

if not self._npm_spec and not self._pep440_spec:
raise self._error()

if self.is_modern_pragma and not self._pep440_spec:
raise self._error()

@property
def is_modern_pragma(self) -> bool:
return self.style.startswith("pragma")

def match(self, version: Version) -> bool:
if version >= VYPER_PEP440_PRAGMA_START_VERSION:
return bool(self._pep440_spec and self._pep440_spec.contains(version, prereleases=True))

return not self.is_modern_pragma and bool(
self._npm_spec and self._npm_spec.match(_as_npm_version(version))
)

def contains(self, version: str | Version) -> bool:
return self.match(version if isinstance(version, Version) else Version(version))

def filter(self, versions: Iterable[Version]) -> Iterator[Version]:
return (version for version in versions if self.match(version))

def _error(self) -> VyperCompileError:
source_label = f" in '{self.source_path}'" if self.source_path else ""
return VyperCompileError(
f"Invalid Vyper version pragma{source_label}: '{self.pragma_str}'."
)

def __str__(self) -> str:
return self.pragma_str


def get_version_pragma_spec(source: str | Path) -> VyperVersionSpecifier | None:
"""
Extracts version pragma information from Vyper source code.

Args:
source (str): Vyper source code

Returns:
``packaging.specifiers.SpecifierSet``, or None if no valid pragma is found.
``VyperVersionSpecifier``, or None if no pragma is found.
"""
_version_pragma_patterns: tuple[str, str] = (
r"(?:\n|^)\s*#\s*@version\s*([^\n]*)",
r"(?:\n|^)\s*#\s*pragma\s+version\s*([^\n]*)",
)

source_str = source if isinstance(source, str) else source.read_text(encoding="utf8")
for pattern in _version_pragma_patterns:
for match in re.finditer(pattern, source_str):
raw_pragma = match.groups()[0]
pragma_str = " ".join(raw_pragma.split()).replace("^", "~=")
if pragma_str and pragma_str[0].isnumeric():
pragma_str = f"=={pragma_str}"

try:
return SpecifierSet(pragma_str)
except InvalidSpecifier:
logger.warning(f"Invalid pragma spec: '{raw_pragma}'. Trying latest.")
return None
source_path = source if isinstance(source, Path) else None
if pragma_match := next(re.finditer(VERSION_PRAGMA_PATTERN, source_str), None):
raw_pragma = pragma_match.group("version")
pragma_str = " ".join(raw_pragma.split())
if not pragma_str:
source_label = f" in '{source_path}'" if source_path else ""
raise VyperCompileError(f"Invalid Vyper version pragma{source_label}: missing version.")

return VyperVersionSpecifier(
pragma_str,
style=" ".join(pragma_match.group("style").split()),
source_path=source_path,
)

return None


Expand Down Expand Up @@ -260,7 +334,9 @@ def seek() -> Path | None:
return None


def safe_append(data: dict, version: "Version | SpecifierSet", paths: Path | set):
def safe_append(
data: dict, version: "Version | SpecifierSet | VyperVersionSpecifier", paths: Path | set
):
if isinstance(paths, Path):
paths = {paths}
if version in data:
Expand Down
10 changes: 8 additions & 2 deletions ape_vyper/compiler/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@
from ethpm_types.source import Compiler, Content, ContractSource
from packaging.version import Version

from ape_vyper._utils import FileType, get_version_pragma_spec, install_vyper, safe_append
from ape_vyper._utils import (
FileType,
VyperVersionSpecifier,
get_version_pragma_spec,
install_vyper,
safe_append,
)
from ape_vyper.compiler._versions import (
BaseVyperCompiler,
Vyper02Compiler,
Expand Down Expand Up @@ -396,7 +402,7 @@ def _get_version_map_from_import_map(
self.compiler_settings = {**self.compiler_settings}
config = config or self.get_config(pm)
version_map: dict[Version, set[Path]] = {}
source_path_by_version_spec: dict[SpecifierSet, set[Path]] = {}
source_path_by_version_spec: dict[SpecifierSet | VyperVersionSpecifier, set[Path]] = {}
source_paths_without_pragma = set()

# Sort contract_filepaths to promote consistent, reproduce-able behavior
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ requires-python = ">=3.10,<4"
dependencies = [
"eth-ape>=0.8.25,<0.9",
"ethpm-types", # Use same version as eth-ape
"semantic-version>=2.10,<3",
"tqdm", # Use same version as eth-ape
"vvm>=0.2.0,<0.4",
"vyper>=0.3.7,<0.6",
Expand Down
101 changes: 100 additions & 1 deletion tests/functional/test_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from packaging.version import Version
from vvm.exceptions import VyperError # type: ignore

from ape_vyper._utils import EVM_VERSION_DEFAULT
from ape_vyper._utils import EVM_VERSION_DEFAULT, get_version_pragma_spec
from ape_vyper.exceptions import (
FallbackNotDefinedError,
IntegerOverflowError,
Expand All @@ -30,6 +30,30 @@
VERSION_37 = Version("0.3.7")
VERSION_FROM_PRAGMA = Version("0.3.10")

VYPER_PEP440_VALID_PRAGMAS = (
"0.3.10",
">0.3.9",
"^0.3.10",
"<=1.0.0,>=0.3.10",
"~=0.3.10",
)
VYPER_PEP440_INVALID_PRAGMAS = (
"0.3.9",
">1.0.0",
"^0.4.0",
"0.2",
"1",
)
VYPER_PEP440_INVALID_SYNTAX_PRAGMAS = (
"<=0.3.9 >=1.1.1",
"1.0.0 - 2.0.0",
"~1.0.0",
"1.x",
"0.2.x",
"0.2.0 || 0.1.3",
"abc",
)


@pytest.fixture
def dev_revert_source(project):
Expand Down Expand Up @@ -241,6 +265,81 @@ def test_install_failure(compiler):
list(compiler.compile((path,), project=failing_project))


@pytest.mark.parametrize(
"source,version,expected",
(
("# @version ^0.3.8", VERSION_FROM_PRAGMA, True),
("# @version ~=0.3.10", Version("0.3.10"), True),
("# @version >=0.3.8 <0.4.0", Version("0.3.9"), True),
("# @version >=0.3.8 <0.4.0", VERSION_FROM_PRAGMA, False),
("# pragma version ^0.3.10", VERSION_FROM_PRAGMA, True),
("# pragma version ^0.3.8", Version("0.3.9"), False),
("#pragma version >=0.4.2", Version("0.4.2"), True),
),
)
def test_get_version_pragma_spec(source, version, expected):
pragma_spec = get_version_pragma_spec(source)
assert pragma_spec is not None
assert pragma_spec.match(version) is expected


def test_get_version_pragma_spec_no_pragma():
assert get_version_pragma_spec("# dev: no compiler version here") is None


def test_get_version_pragma_spec_invalid():
with pytest.raises(
VyperCompileError, match="Invalid Vyper version pragma.*definitely-not-a-spec"
):
get_version_pragma_spec("# pragma version definitely-not-a-spec")


def test_get_version_pragma_spec_invalid_modern_grammar():
with pytest.raises(VyperCompileError, match="Invalid Vyper version pragma.*>=0.3.8"):
get_version_pragma_spec("# pragma version >=0.3.8 <0.4.0")


@pytest.mark.parametrize("pragma_string", VYPER_PEP440_VALID_PRAGMAS)
def test_get_version_pragma_spec_vyper_pep440_valid_versions(pragma_string):
pragma_spec = get_version_pragma_spec(f"# pragma version {pragma_string}")
assert pragma_spec is not None
assert pragma_spec.match(VERSION_FROM_PRAGMA)


@pytest.mark.parametrize("pragma_string", VYPER_PEP440_INVALID_PRAGMAS)
def test_get_version_pragma_spec_vyper_pep440_invalid_versions(pragma_string):
pragma_spec = get_version_pragma_spec(f"# pragma version {pragma_string}")
assert pragma_spec is not None
assert not pragma_spec.match(VERSION_FROM_PRAGMA)


@pytest.mark.parametrize("pragma_string", VYPER_PEP440_INVALID_SYNTAX_PRAGMAS)
def test_get_version_pragma_spec_vyper_pep440_invalid_syntax(pragma_string):
with pytest.raises(VyperCompileError, match="Invalid Vyper version pragma"):
get_version_pragma_spec(f"# pragma version {pragma_string}")


def test_get_version_map_modern_pragma(tmp_path, compiler, monkeypatch):
versions = [Version("0.3.9"), VERSION_FROM_PRAGMA]
monkeypatch.setattr(type(compiler), "installed_versions", property(lambda _: versions))
contracts = tmp_path / "contracts"
contracts.mkdir()
path = contracts / "modern_pragma.vy"
path.write_text("# pragma version ^0.3.8\n", encoding="utf8")
actual = compiler.get_version_map([path], project=ape.Project(tmp_path))
assert actual == {VERSION_FROM_PRAGMA: {path}}


def test_get_version_map_invalid_pragma(tmp_path, compiler):
project = ape.Project(tmp_path)
contracts = tmp_path / "contracts"
contracts.mkdir()
path = contracts / "invalid_pragma.vy"
path.write_text("# pragma version definitely-not-a-spec\n", encoding="utf8")
with pytest.raises(VyperCompileError, match=f"Invalid Vyper version pragma.*{path}"):
compiler.get_version_map([path], project=project)


def test_get_version_map(project, compiler, all_versions):
vyper_files = [
x
Expand Down
Loading