Skip to content

Commit cf56084

Browse files
authored
Support declaring compatible PyTeal version in source code (#429)
* Add semantic version dependency * Add `pragma` to enforce compatible compiler versions * Raise `TealInternalError` instead of `TealInputError` * Switch back to `pkg_resources` and convert PEP 440 to semantic version 2.0.0 * Fix linter errors * Make pep 440 converter private * Add `Pragma` expression * Refactor pragma functions from compiler to ast to avoid circular dependencies * Add `is_valid_compiler_version` check helper for the `Pragma` expression * Use `TealProgramError` instead of `TealInternalError` * Test underlying TEAL is unchanged * Refactor underlying pragma methods to parent directory * Inherit from `Expr` instead * Fix unclear docstring * Document init Document init * Add caret compiler version tests * Ignore unused fixture imports
1 parent 7f78f9b commit cf56084

File tree

13 files changed

+367
-1
lines changed

13 files changed

+367
-1
lines changed

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
[mypy]
22

3+
[mypy-semantic_version.*]
4+
ignore_missing_imports = True
5+
36
[mypy-pytest.*]
47
ignore_missing_imports = True
58

pyteal/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pyteal.ast import *
22
from pyteal.ast import __all__ as ast_all
3+
from pyteal.pragma import pragma
34
from pyteal.ir import *
45
from pyteal.ir import __all__ as ir_all
56
from pyteal.compiler import (
@@ -16,6 +17,7 @@
1617
TealTypeError,
1718
TealInputError,
1819
TealCompileError,
20+
TealPragmaError,
1921
)
2022
from pyteal.config import (
2123
MAX_GROUP_SIZE,
@@ -33,13 +35,15 @@
3335
"MIN_TEAL_VERSION",
3436
"DEFAULT_TEAL_VERSION",
3537
"CompileOptions",
38+
"pragma",
3639
"compileTeal",
3740
"OptimizeOptions",
3841
"TealType",
3942
"TealInternalError",
4043
"TealTypeError",
4144
"TealInputError",
4245
"TealCompileError",
46+
"TealPragmaError",
4347
"MAX_GROUP_SIZE",
4448
"NUM_SLOTS",
4549
"RETURN_HASH_PREFIX",

pyteal/__init__.pyi

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from pyteal.ast import *
55
from pyteal.ast import __all__ as ast_all
6+
from pyteal.pragma import pragma
67
from pyteal.ir import *
78
from pyteal.ir import __all__ as ir_all
89
from pyteal.compiler import (
@@ -19,6 +20,7 @@ from pyteal.errors import (
1920
TealTypeError,
2021
TealInputError,
2122
TealCompileError,
23+
TealPragmaError,
2224
)
2325
from pyteal.config import (
2426
MAX_GROUP_SIZE,
@@ -152,6 +154,7 @@ __all__ = [
152154
"OptimizeOptions",
153155
"Or",
154156
"Pop",
157+
"Pragma",
155158
"RETURN_HASH_PREFIX",
156159
"Reject",
157160
"Return",
@@ -185,6 +188,7 @@ __all__ = [
185188
"TealInternalError",
186189
"TealLabel",
187190
"TealOp",
191+
"TealPragmaError",
188192
"TealSimpleBlock",
189193
"TealType",
190194
"TealTypeError",
@@ -202,4 +206,5 @@ __all__ = [
202206
"WideRatio",
203207
"abi",
204208
"compileTeal",
209+
"pragma",
205210
]

pyteal/ast/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from pyteal.ast.array import Array
4141
from pyteal.ast.tmpl import Tmpl
4242
from pyteal.ast.nonce import Nonce
43+
from pyteal.ast.pragma import Pragma
4344

4445
# unary ops
4546
from pyteal.ast.unaryexpr import (
@@ -199,6 +200,7 @@
199200
"Array",
200201
"Tmpl",
201202
"Nonce",
203+
"Pragma",
202204
"UnaryExpr",
203205
"Btoi",
204206
"Itob",

pyteal/ast/pragma.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from typing import TYPE_CHECKING, Any
2+
3+
from pyteal.ast.expr import Expr
4+
from pyteal.pragma import is_valid_compiler_version, pragma
5+
6+
if TYPE_CHECKING:
7+
from pyteal.compiler import CompileOptions
8+
9+
10+
class Pragma(Expr):
11+
"""A meta expression which defines a pragma for a specific subsection of PyTeal code.
12+
13+
This expression does not affect the underlying compiled TEAL code in any way."""
14+
15+
def __init__(self, child: Expr, *, compiler_version: str, **kwargs: Any) -> None:
16+
"""Define a pragma for a specific subsection of PyTeal code.
17+
18+
The Pragma expression does not affect the underlying compiled TEAL code in any way,
19+
it merely sets a pragma for the underlying expression.
20+
21+
Args:
22+
child: The expression to wrap.
23+
compiler_version: Acceptable versions of the compiler. Will fail if the current PyTeal version
24+
is not contained in the range. Follows the npm `semver range scheme <https://github.com/npm/node-semver#ranges>`_
25+
for specifying compatible versions.
26+
"""
27+
super().__init__()
28+
29+
self.child = child
30+
31+
if not is_valid_compiler_version(compiler_version):
32+
raise ValueError("Invalid compiler version: {}".format(compiler_version))
33+
self.compiler_version = compiler_version
34+
35+
def __teal__(self, options: "CompileOptions"):
36+
pragma(compiler_version=self.compiler_version)
37+
38+
return self.child.__teal__(options)
39+
40+
def __str__(self):
41+
return "(pragma {})".format(self.child)
42+
43+
def type_of(self):
44+
return self.child.type_of()
45+
46+
def has_return(self):
47+
return self.child.has_return()
48+
49+
50+
Pragma.__module__ = "pyteal"

pyteal/ast/pragma_test.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import pytest
2+
from tests.mock_version import mock_version # noqa: F401
3+
4+
import pyteal as pt
5+
6+
7+
@pytest.mark.usefixtures("mock_version")
8+
@pytest.mark.parametrize(
9+
"version, compiler_version, should_error",
10+
[
11+
# valid
12+
("0.12.0", "0.12.0", False),
13+
(
14+
"1.0.0+AVM7.1",
15+
"=1.0.0",
16+
False,
17+
),
18+
# invalid
19+
("0.13.0", "0.13.1", True),
20+
("1.2.3a2", "<0.8.0 || >=0.12.0", True),
21+
],
22+
)
23+
def test_pragma_expr(compiler_version, should_error):
24+
program = pt.Pragma(pt.Approve(), compiler_version=compiler_version)
25+
26+
if should_error:
27+
with pytest.raises(pt.TealPragmaError):
28+
pt.compileTeal(program, mode=pt.Mode.Application, version=6)
29+
else:
30+
pt.compileTeal(program, mode=pt.Mode.Application, version=6)
31+
32+
33+
def test_pragma_expr_does_not_change():
34+
without_pragma = pt.Seq(pt.Pop(pt.Add(pt.Int(1), pt.Int(2))), pt.Return(pt.Int(1)))
35+
pragma = pt.Pragma(without_pragma, compiler_version=">=0.0.0")
36+
37+
compiled_with_pragma = pt.compileTeal(pragma, mode=pt.Mode.Application, version=6)
38+
compiled_without_pragma = pt.compileTeal(
39+
without_pragma, mode=pt.Mode.Application, version=6
40+
)
41+
42+
assert compiled_with_pragma == compiled_without_pragma
43+
44+
45+
def test_pragma_expr_has_return():
46+
exprWithReturn = pt.Pragma(pt.Return(pt.Int(1)), compiler_version=">=0.0.0")
47+
assert exprWithReturn.has_return()
48+
49+
exprWithoutReturn = pt.Pragma(pt.Int(1), compiler_version=">=0.0.0")
50+
assert not exprWithoutReturn.has_return()
51+
52+
53+
@pytest.mark.parametrize(
54+
"compiler_version",
55+
["not a version", ">=0.1.1,<0.3.0", "1.2.3aq"], # incorrect spec # invalid PEP 440
56+
)
57+
def test_pragma_expr_invalid_compiler_version(compiler_version):
58+
with pytest.raises(ValueError):
59+
pt.Pragma(pt.Approve(), compiler_version=compiler_version)

pyteal/errors.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ def __eq__(self, other) -> bool:
6464
TealCompileError.__module__ = "pyteal"
6565

6666

67+
class TealPragmaError(Exception):
68+
def __init__(self, message: str) -> None:
69+
self.message = message
70+
71+
def __str__(self):
72+
return self.message
73+
74+
75+
TealPragmaError.__module__ = "pyteal"
76+
77+
6778
def verifyTealVersion(minVersion: int, version: int, msg: str):
6879
if minVersion > version:
6980
msg = "{}. Minimum version needed is {}, but current version being compiled is {}".format(

pyteal/pragma/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pyteal.pragma.pragma import is_valid_compiler_version, pragma
2+
3+
__all__ = [
4+
"is_valid_compiler_version",
5+
"pragma",
6+
]

pyteal/pragma/pragma.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import re
2+
import pkg_resources
3+
from typing import Any
4+
from semantic_version import Version, NpmSpec
5+
6+
from pyteal.errors import TealPragmaError
7+
8+
9+
def __convert_pep440_compiler_version(compiler_version: str):
10+
"""Convert PEP 440 version identifiers to valid NPM versions.
11+
12+
For example:
13+
"1.0.0" -> "1.0.0"
14+
"1.0.0a1" -> "1.0.0-a1"
15+
"<0.5.0+local || >=1.0.0a9.post1.dev2" -> "<0.5.0 || >=1.0.0-alpha9.1.2"
16+
"""
17+
NUMBER = r"(?:x|X|\*|0|[1-9][0-9]*)"
18+
LOCAL = r"[a-zA-Z0-9.]*"
19+
TRIM_PREFIX_RE = re.compile(
20+
r"""
21+
(?:v)? # Strip optional initial v
22+
(?P<op><|<=|>=|>|=|\^|~|) # Operator, can be empty
23+
(?P<major>{nb})(?:\.(?P<minor>{nb})(?:\.(?P<patch>{nb}))?)?
24+
(?:(?P<prerel_type>a|b|rc)(?P<prerel>{nb}))? # Optional pre-release
25+
(?:\.post(?P<postrel>{nb}))? # Optional post-release
26+
(?:\.dev(?P<dev>{nb}))? # Optional dev release
27+
(?:\+(?P<local>{lcl}))? # Optional local version
28+
""".format(
29+
nb=NUMBER,
30+
lcl=LOCAL,
31+
),
32+
re.VERBOSE,
33+
)
34+
35+
def match_replacer(match: re.Match):
36+
(
37+
op,
38+
major,
39+
minor,
40+
patch,
41+
prerel_type,
42+
prerel,
43+
postrel,
44+
dev,
45+
local,
46+
) = match.groups()
47+
48+
# Base version (major/minor/patch)
49+
base_version = "{}.{}.{}".format(major or "0", minor or "0", patch or "0")
50+
51+
# Combine prerel, postrel, and dev
52+
combined_additions = []
53+
short_prerel_type_to_long = {
54+
"a": "alpha",
55+
"b": "beta",
56+
"rc": "rc",
57+
}
58+
if prerel_type is not None:
59+
combined_additions.append(short_prerel_type_to_long[prerel_type] + prerel)
60+
if len(combined_additions) > 0 or postrel is not None or dev is not None:
61+
combined_additions.append(postrel or "0")
62+
if len(combined_additions) > 0 or dev is not None:
63+
combined_additions.append(dev or "0")
64+
combined_additions_str = ".".join(combined_additions)
65+
66+
# Build full_version
67+
full_version = base_version
68+
if len(combined_additions) > 0:
69+
full_version += "-" + combined_additions_str
70+
if local is not None:
71+
full_version += "+" + local.lower()
72+
73+
if op is not None:
74+
return op + full_version
75+
return full_version
76+
77+
return re.sub(TRIM_PREFIX_RE, match_replacer, compiler_version)
78+
79+
80+
def is_valid_compiler_version(compiler_version: str):
81+
"""Check if the compiler version is valid.
82+
83+
Args:
84+
compiler_version: The compiler version to check.
85+
86+
Returns:
87+
True if the compiler version is a valid NPM specification range
88+
using either the PEP 440 or semantic version format, otherwise False.
89+
"""
90+
try:
91+
pep440_converted = __convert_pep440_compiler_version(compiler_version)
92+
NpmSpec(pep440_converted)
93+
return True
94+
except ValueError:
95+
return False
96+
97+
98+
def pragma(
99+
*,
100+
compiler_version: str,
101+
**kwargs: Any,
102+
) -> None:
103+
"""
104+
Specify pragmas for the compiler.
105+
106+
Args:
107+
compiler_version: Acceptable versions of the compiler. Will fail if the current PyTeal version
108+
is not contained in the range. Follows the npm `semver range scheme <https://github.com/npm/node-semver#ranges>`_
109+
for specifying compatible versions.
110+
"""
111+
pkg_version = pkg_resources.require("pyteal")[0].version
112+
pyteal_version = Version(__convert_pep440_compiler_version(pkg_version))
113+
if pyteal_version not in NpmSpec(
114+
__convert_pep440_compiler_version(compiler_version)
115+
):
116+
raise TealPragmaError(
117+
"PyTeal version {} is not compatible with compiler version {}".format(
118+
pkg_version, compiler_version
119+
)
120+
)

0 commit comments

Comments
 (0)