Skip to content

Commit 32f3134

Browse files
authored
Merge branch 'main' into refactor/use-prek
2 parents 7374260 + 72c434f commit 32f3134

5 files changed

Lines changed: 215 additions & 28 deletions

File tree

ape_vyper/_utils.py

Lines changed: 99 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33
import subprocess
44
import time
5-
from collections.abc import Iterable
5+
from collections.abc import Iterable, Iterator
66
from enum import Enum
77
from pathlib import Path
88
from typing import TYPE_CHECKING, Any
@@ -15,14 +15,16 @@
1515
from eth_utils import is_0x_prefixed
1616
from ethpm_types import ASTNode, PCMap, SourceMapItem
1717
from packaging.specifiers import InvalidSpecifier, SpecifierSet
18+
from packaging.version import Version
19+
from semantic_version import NpmSpec # type: ignore[import-untyped]
20+
from semantic_version import Version as NpmVersion # type: ignore[import-untyped]
1821
from vvm.exceptions import UnknownOption, UnknownValue # type: ignore
1922

20-
from ape_vyper.exceptions import RuntimeErrorType, VyperError, VyperInstallError
23+
from ape_vyper.exceptions import RuntimeErrorType, VyperCompileError, VyperError, VyperInstallError
2124

2225
if TYPE_CHECKING:
2326
from ape.types.trace import SourceTraceback
2427
from ethpm_types.source import Function
25-
from packaging.version import Version
2628

2729
Optimization = str | bool
2830
EVM_VERSION_DEFAULT = {
@@ -82,34 +84,106 @@ def install_vyper(version: "Version"):
8284
) from err
8385

8486

85-
def get_version_pragma_spec(source: str | Path) -> SpecifierSet | None:
87+
VERSION_PRAGMA_PATTERN = re.compile(
88+
r"(?:\n|^)\s*#\s*(?P<style>@version|pragma\s+version)\s*(?P<version>[^\n]*)"
89+
)
90+
# Vyper 0.3.10 switched version pragma matching from NpmSpec to SpecifierSet.
91+
VYPER_PEP440_PRAGMA_START_VERSION = Version("0.3.10")
92+
93+
94+
def _as_pep440_spec(pragma_str: str) -> str:
95+
pragma_str = pragma_str.replace("^", "~=")
96+
if pragma_str and pragma_str[0].isnumeric():
97+
return f"=={pragma_str}"
98+
99+
return pragma_str
100+
101+
102+
def _as_npm_version(version: Version) -> NpmVersion:
103+
version_str = str(version)
104+
version_str = re.sub(r"(?<=\d)a(?=\d)", "-alpha.", version_str)
105+
version_str = re.sub(r"(?<=\d)b(?=\d)", "-beta.", version_str)
106+
version_str = re.sub(r"(?<=\d)rc(?=\d)", "-rc.", version_str)
107+
return NpmVersion(version_str)
108+
109+
110+
class VyperVersionSpecifier:
111+
def __init__(self, pragma_str: str, style: str, source_path: Path | None = None):
112+
self.pragma_str = pragma_str
113+
self.style = style
114+
self.source_path = source_path
115+
self._npm_spec: NpmSpec | None = None
116+
self._pep440_spec: SpecifierSet | None = None
117+
118+
try:
119+
self._npm_spec = NpmSpec(pragma_str)
120+
except ValueError:
121+
pass
122+
123+
try:
124+
self._pep440_spec = SpecifierSet(_as_pep440_spec(pragma_str))
125+
except InvalidSpecifier:
126+
pass
127+
128+
if not self._npm_spec and not self._pep440_spec:
129+
raise self._error()
130+
131+
if self.is_modern_pragma and not self._pep440_spec:
132+
raise self._error()
133+
134+
@property
135+
def is_modern_pragma(self) -> bool:
136+
return self.style.startswith("pragma")
137+
138+
def match(self, version: Version) -> bool:
139+
if version >= VYPER_PEP440_PRAGMA_START_VERSION:
140+
return bool(self._pep440_spec and self._pep440_spec.contains(version, prereleases=True))
141+
142+
return not self.is_modern_pragma and bool(
143+
self._npm_spec and self._npm_spec.match(_as_npm_version(version))
144+
)
145+
146+
def contains(self, version: str | Version) -> bool:
147+
return self.match(version if isinstance(version, Version) else Version(version))
148+
149+
def filter(self, versions: Iterable[Version]) -> Iterator[Version]:
150+
return (version for version in versions if self.match(version))
151+
152+
def _error(self) -> VyperCompileError:
153+
source_label = f" in '{self.source_path}'" if self.source_path else ""
154+
return VyperCompileError(
155+
f"Invalid Vyper version pragma{source_label}: '{self.pragma_str}'."
156+
)
157+
158+
def __str__(self) -> str:
159+
return self.pragma_str
160+
161+
162+
def get_version_pragma_spec(source: str | Path) -> VyperVersionSpecifier | None:
86163
"""
87164
Extracts version pragma information from Vyper source code.
88165
89166
Args:
90167
source (str): Vyper source code
91168
92169
Returns:
93-
``packaging.specifiers.SpecifierSet``, or None if no valid pragma is found.
170+
``VyperVersionSpecifier``, or None if no pragma is found.
94171
"""
95-
_version_pragma_patterns: tuple[str, str] = (
96-
r"(?:\n|^)\s*#\s*@version\s*([^\n]*)",
97-
r"(?:\n|^)\s*#\s*pragma\s+version\s*([^\n]*)",
98-
)
99-
100172
source_str = source if isinstance(source, str) else source.read_text(encoding="utf8")
101-
for pattern in _version_pragma_patterns:
102-
for match in re.finditer(pattern, source_str):
103-
raw_pragma = match.groups()[0]
104-
pragma_str = " ".join(raw_pragma.split()).replace("^", "~=")
105-
if pragma_str and pragma_str[0].isnumeric():
106-
pragma_str = f"=={pragma_str}"
107-
108-
try:
109-
return SpecifierSet(pragma_str)
110-
except InvalidSpecifier:
111-
logger.warning(f"Invalid pragma spec: '{raw_pragma}'. Trying latest.")
112-
return None
173+
source_path = source if isinstance(source, Path) else None
174+
if pragma_match := next(re.finditer(VERSION_PRAGMA_PATTERN, source_str), None):
175+
raw_pragma = pragma_match.group("version")
176+
pragma_str = " ".join(raw_pragma.split())
177+
if not pragma_str:
178+
source_label = f" in '{source_path}'" if source_path else ""
179+
raise VyperCompileError(f"Invalid Vyper version pragma{source_label}: missing version.")
180+
181+
return VyperVersionSpecifier(
182+
pragma_str,
183+
style=" ".join(pragma_match.group("style").split()),
184+
source_path=source_path,
185+
)
186+
113187
return None
114188

115189

@@ -260,7 +334,9 @@ def seek() -> Path | None:
260334
return None
261335

262336

263-
def safe_append(data: dict, version: "Version | SpecifierSet", paths: Path | set):
337+
def safe_append(
338+
data: dict, version: "Version | SpecifierSet | VyperVersionSpecifier", paths: Path | set
339+
):
264340
if isinstance(paths, Path):
265341
paths = {paths}
266342
if version in data:

ape_vyper/compiler/api.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@
1717
from ethpm_types.source import Compiler, Content, ContractSource
1818
from packaging.version import Version
1919

20-
from ape_vyper._utils import FileType, get_version_pragma_spec, install_vyper, safe_append
20+
from ape_vyper._utils import (
21+
FileType,
22+
VyperVersionSpecifier,
23+
get_version_pragma_spec,
24+
install_vyper,
25+
safe_append,
26+
)
2127
from ape_vyper.compiler._versions import (
2228
BaseVyperCompiler,
2329
Vyper02Compiler,
@@ -396,7 +402,7 @@ def _get_version_map_from_import_map(
396402
self.compiler_settings = {**self.compiler_settings}
397403
config = config or self.get_config(pm)
398404
version_map: dict[Version, set[Path]] = {}
399-
source_path_by_version_spec: dict[SpecifierSet, set[Path]] = {}
405+
source_path_by_version_spec: dict[SpecifierSet | VyperVersionSpecifier, set[Path]] = {}
400406
source_paths_without_pragma = set()
401407

402408
# Sort contract_filepaths to promote consistent, reproduce-able behavior

ape_vyper/flattener.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from ape.utils import ManagerAccessMixin, get_relative_path
77
from ethpm_types.source import Content
88

9-
from ape_vyper.ast import source_to_abi
109
from ape_vyper.interface import (
1110
extract_import_aliases,
1211
extract_imports,
@@ -122,6 +121,12 @@ def _flatten_source(
122121
else:
123122
# Vyper <0.4 interface from folder other than interfaces/
124123
# such as a .vyi file in the contracts folder.
124+
try:
125+
from ape_vyper.ast import source_to_abi
126+
127+
except ImportError as err:
128+
raise RuntimeError("Must install `vyper` for this feature to work") from err
129+
125130
abis = source_to_abi(import_path.read_text(encoding="utf8"))
126131
interfaces_source += generate_interface(abis, import_name)
127132

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ requires-python = ">=3.10,<4"
3535
dependencies = [
3636
"eth-ape>=0.8.25,<0.9",
3737
"ethpm-types", # Use same version as eth-ape
38+
"semantic-version>=2.10,<3",
3839
"tqdm", # Use same version as eth-ape
3940
"vvm>=0.2.0,<0.4",
40-
"vyper>=0.3.7,<0.5",
4141
]
4242

4343
[project.entry-points."ape_cli_subcommands"]
@@ -48,6 +48,7 @@ test = [
4848
"pytest-cov", # Coverage analyzer (also supported by plugin)
4949
"pytest-mock", # Needed to mock certain CLI interactions
5050
"snekmate", # Python package source for integration testing
51+
"vyper>=0.3.7", # For testing flattener
5152
]
5253
lint = [
5354
"ruff>=0.14,<1", # Auto-formatter and linter

tests/functional/test_compiler.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from packaging.version import Version
1313
from vvm.exceptions import VyperError # type: ignore
1414

15-
from ape_vyper._utils import EVM_VERSION_DEFAULT
15+
from ape_vyper._utils import EVM_VERSION_DEFAULT, get_version_pragma_spec
1616
from ape_vyper.exceptions import (
1717
FallbackNotDefinedError,
1818
IntegerOverflowError,
@@ -30,6 +30,30 @@
3030
VERSION_37 = Version("0.3.7")
3131
VERSION_FROM_PRAGMA = Version("0.3.10")
3232

33+
VYPER_PEP440_VALID_PRAGMAS = (
34+
"0.3.10",
35+
">0.3.9",
36+
"^0.3.10",
37+
"<=1.0.0,>=0.3.10",
38+
"~=0.3.10",
39+
)
40+
VYPER_PEP440_INVALID_PRAGMAS = (
41+
"0.3.9",
42+
">1.0.0",
43+
"^0.4.0",
44+
"0.2",
45+
"1",
46+
)
47+
VYPER_PEP440_INVALID_SYNTAX_PRAGMAS = (
48+
"<=0.3.9 >=1.1.1",
49+
"1.0.0 - 2.0.0",
50+
"~1.0.0",
51+
"1.x",
52+
"0.2.x",
53+
"0.2.0 || 0.1.3",
54+
"abc",
55+
)
56+
3357

3458
@pytest.fixture
3559
def dev_revert_source(project):
@@ -241,6 +265,81 @@ def test_install_failure(compiler):
241265
list(compiler.compile((path,), project=failing_project))
242266

243267

268+
@pytest.mark.parametrize(
269+
"source,version,expected",
270+
(
271+
("# @version ^0.3.8", VERSION_FROM_PRAGMA, True),
272+
("# @version ~=0.3.10", Version("0.3.10"), True),
273+
("# @version >=0.3.8 <0.4.0", Version("0.3.9"), True),
274+
("# @version >=0.3.8 <0.4.0", VERSION_FROM_PRAGMA, False),
275+
("# pragma version ^0.3.10", VERSION_FROM_PRAGMA, True),
276+
("# pragma version ^0.3.8", Version("0.3.9"), False),
277+
("#pragma version >=0.4.2", Version("0.4.2"), True),
278+
),
279+
)
280+
def test_get_version_pragma_spec(source, version, expected):
281+
pragma_spec = get_version_pragma_spec(source)
282+
assert pragma_spec is not None
283+
assert pragma_spec.match(version) is expected
284+
285+
286+
def test_get_version_pragma_spec_no_pragma():
287+
assert get_version_pragma_spec("# dev: no compiler version here") is None
288+
289+
290+
def test_get_version_pragma_spec_invalid():
291+
with pytest.raises(
292+
VyperCompileError, match="Invalid Vyper version pragma.*definitely-not-a-spec"
293+
):
294+
get_version_pragma_spec("# pragma version definitely-not-a-spec")
295+
296+
297+
def test_get_version_pragma_spec_invalid_modern_grammar():
298+
with pytest.raises(VyperCompileError, match="Invalid Vyper version pragma.*>=0.3.8"):
299+
get_version_pragma_spec("# pragma version >=0.3.8 <0.4.0")
300+
301+
302+
@pytest.mark.parametrize("pragma_string", VYPER_PEP440_VALID_PRAGMAS)
303+
def test_get_version_pragma_spec_vyper_pep440_valid_versions(pragma_string):
304+
pragma_spec = get_version_pragma_spec(f"# pragma version {pragma_string}")
305+
assert pragma_spec is not None
306+
assert pragma_spec.match(VERSION_FROM_PRAGMA)
307+
308+
309+
@pytest.mark.parametrize("pragma_string", VYPER_PEP440_INVALID_PRAGMAS)
310+
def test_get_version_pragma_spec_vyper_pep440_invalid_versions(pragma_string):
311+
pragma_spec = get_version_pragma_spec(f"# pragma version {pragma_string}")
312+
assert pragma_spec is not None
313+
assert not pragma_spec.match(VERSION_FROM_PRAGMA)
314+
315+
316+
@pytest.mark.parametrize("pragma_string", VYPER_PEP440_INVALID_SYNTAX_PRAGMAS)
317+
def test_get_version_pragma_spec_vyper_pep440_invalid_syntax(pragma_string):
318+
with pytest.raises(VyperCompileError, match="Invalid Vyper version pragma"):
319+
get_version_pragma_spec(f"# pragma version {pragma_string}")
320+
321+
322+
def test_get_version_map_modern_pragma(tmp_path, compiler, monkeypatch):
323+
versions = [Version("0.3.9"), VERSION_FROM_PRAGMA]
324+
monkeypatch.setattr(type(compiler), "installed_versions", property(lambda _: versions))
325+
contracts = tmp_path / "contracts"
326+
contracts.mkdir()
327+
path = contracts / "modern_pragma.vy"
328+
path.write_text("# pragma version ^0.3.8\n", encoding="utf8")
329+
actual = compiler.get_version_map([path], project=ape.Project(tmp_path))
330+
assert actual == {VERSION_FROM_PRAGMA: {path}}
331+
332+
333+
def test_get_version_map_invalid_pragma(tmp_path, compiler):
334+
project = ape.Project(tmp_path)
335+
contracts = tmp_path / "contracts"
336+
contracts.mkdir()
337+
path = contracts / "invalid_pragma.vy"
338+
path.write_text("# pragma version definitely-not-a-spec\n", encoding="utf8")
339+
with pytest.raises(VyperCompileError, match=f"Invalid Vyper version pragma.*{path}"):
340+
compiler.get_version_map([path], project=project)
341+
342+
244343
def test_get_version_map(project, compiler, all_versions):
245344
vyper_files = [
246345
x

0 commit comments

Comments
 (0)