diff --git a/.github/workflows/cis.yml b/.github/workflows/cis.yml index f274cb0d..edfc9236 100644 --- a/.github/workflows/cis.yml +++ b/.github/workflows/cis.yml @@ -50,6 +50,8 @@ jobs: toxenv: py313 - python-version: "3.14" toxenv: py314 + - python-version: "3.15-dev" + toxenv: py315 steps: - uses: actions/checkout@v6 - name: Get history and tags for SCM versioning to work diff --git a/pyproject.toml b/pyproject.toml index dd61b4df..5f5d33b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ ] [tool.setuptools_scm] + fallback_version = "0.0.0" write_to = "src/bytecode/version.py" write_to_template = """ # This file is auto-generated by setuptools-scm do NOT edit it. diff --git a/src/bytecode/instr.py b/src/bytecode/instr.py index 56240eac..3c612dce 100644 --- a/src/bytecode/instr.py +++ b/src/bytecode/instr.py @@ -16,7 +16,7 @@ from typing_extensions import TypeGuard # type: ignore import bytecode as _bytecode -from bytecode.utils import PY312, PY313, PY314 +from bytecode.utils import PY312, PY313, PY314, PY315 # --- Instruction argument tools and @@ -53,6 +53,11 @@ # Small integer related opcode SMALL_INT_OPS: Final[set[int]] = {_opcode.opmap["LOAD_SMALL_INT"]} if PY314 else set() +# Opcodes that gained a cache-only argument in 3.15 (arg is always 0 and not user-visible) +CACHE_ONLY_ARG_OPCODES: Final[set[int]] = ( + {_opcode.opmap["GET_ITER"]} if PY315 else set() +) + # Special method loading related opcodes SPECIAL_OPS: Final[set[int]] = {_opcode.opmap["LOAD_SPECIAL"]} if PY314 else set() @@ -264,6 +269,13 @@ class CommonConstant(enum.IntEnum): BUILTIN_TUPLE = 2 BUILTIN_ALL = 3 BUILTIN_ANY = 4 + BUILTIN_LIST = 5 + BUILTIN_SET = 6 + CONSTANT_NONE = 7 + CONSTANT_EMPTY_STR = 8 + CONSTANT_TRUE = 9 + CONSTANT_FALSE = 10 + CONSTANT_MINUS_ONE = 11 # This make type checking happy but means it won't catch attempt to manipulate an unset @@ -419,8 +431,11 @@ def opcode_has_argument(opcode: int) -> bool: "DUP_TOP": (-1, 2), "DUP_TOP_TWO": (-2, 4), "GET_LEN": (-1, 2), - "GET_ITER": (-1, 1), - "GET_YIELD_FROM_ITER": (-1, 1), + "GET_ITER": (-1, 2) if PY315 else (-1, 1), + "GET_YIELD_FROM_ITER": ( + -1, + 1, + ), # removed in 3.15, filtered by if k in _opcode.opmap "GET_AWAITABLE": (-1, 1), "GET_AITER": (-1, 1), "GET_ANEXT": (-1, 2), @@ -503,7 +518,12 @@ def opcode_has_argument(opcode: int) -> bool: "MAP_ADD": lambda effect, arg, jump: (-arg, arg - 2), "FORMAT_VALUE": lambda effect, arg, jump: (effect - 1, 1), # FOR_ITER needs TOS to be an iterator, hence a prerequisite of 1 on the stack - "FOR_ITER": lambda effect, arg, jump: (effect, 0) if jump else (-1, 2), + # In 3.15, GET_ITER pushes (iter, null_or_index); FOR_ITER always pushes the + # next value (+1). When exhausted it jumps to END_FOR (which pops it) then + # POP_ITER cleans up (iter, null_or_index). Matches dis.stack_effect = 1 always. + "FOR_ITER": (lambda __effect, __arg, __jump: (0, 1)) + if PY315 + else (lambda effect, __arg, jump: (effect, 0) if jump else (-1, 2)), "BUILD_INTERPOLATION": lambda effect, arg, jump: (-(2 + (arg & 1)), 1), **{ # Instr(UNPACK_* , n) pops 1 and pushes n @@ -912,6 +932,9 @@ def _set(self, name: str, arg: A) -> None: "Only base opcodes are supported" ) + if arg is UNSET and opcode in CACHE_ONLY_ARG_OPCODES: + arg = 0 # type: ignore + self._check_arg(name, opcode, arg) self._name = name diff --git a/src/bytecode/utils.py b/src/bytecode/utils.py index 7a1371c3..a02ad96f 100644 --- a/src/bytecode/utils.py +++ b/src/bytecode/utils.py @@ -4,3 +4,4 @@ PY312: Final[bool] = sys.version_info >= (3, 12) PY313: Final[bool] = sys.version_info >= (3, 13) PY314: Final[bool] = sys.version_info >= (3, 14) +PY315: Final[bool] = sys.version_info >= (3, 15) diff --git a/tests/test_bytecode.py b/tests/test_bytecode.py index 484b9a21..05ab859c 100644 --- a/tests/test_bytecode.py +++ b/tests/test_bytecode.py @@ -7,8 +7,8 @@ import unittest from bytecode import Bytecode, ConcreteInstr, FreeVar, Instr, Label, SetLineno -from bytecode.instr import BinaryOp, FormatValue, InstrLocation -from bytecode.utils import PY312, PY313, PY314 +from bytecode.instr import BinaryOp, CommonConstant, FormatValue, InstrLocation +from bytecode.utils import PY312, PY313, PY314, PY315 from . import TestCase, get_code @@ -168,6 +168,18 @@ def test_from_code(self): bytecode = Bytecode.from_code(code) label_else = Label() if PY314: + + def _ret_none(lineno): + return ( + Instr( + "LOAD_COMMON_CONSTANT", + CommonConstant.CONSTANT_NONE, + lineno=lineno, + ) + if PY315 + else Instr("LOAD_CONST", None, lineno=lineno) + ) + self.assertInstructionListEqual( bytecode, [ @@ -178,12 +190,12 @@ def test_from_code(self): Instr("NOT_TAKEN", lineno=1), Instr("LOAD_SMALL_INT", 1, lineno=2), Instr("STORE_NAME", "x", lineno=2), - Instr("LOAD_CONST", None, lineno=2), + _ret_none(2), Instr("RETURN_VALUE", lineno=2), label_else, Instr("LOAD_SMALL_INT", 2, lineno=4), Instr("STORE_NAME", "x", lineno=4), - Instr("LOAD_CONST", None, lineno=4), + _ret_none(4), Instr("RETURN_VALUE", lineno=4), ], ) @@ -292,7 +304,11 @@ def func(): ] + ( [ - Instr("LOAD_CONST", None, lineno=3), + Instr( + "LOAD_COMMON_CONSTANT" if PY315 else "LOAD_CONST", + CommonConstant.CONSTANT_NONE if PY315 else None, + lineno=3, + ), Instr("RETURN_VALUE", lineno=3), ] if PY314 diff --git a/tests/test_cfg.py b/tests/test_cfg.py index 1d29e7eb..c7eff4fe 100644 --- a/tests/test_cfg.py +++ b/tests/test_cfg.py @@ -19,7 +19,8 @@ SetLineno, dump_bytecode, ) -from bytecode.utils import PY312, PY313, PY314 +from bytecode.instr import CommonConstant +from bytecode.utils import PY312, PY313, PY314, PY315 from . import TestCase, disassemble as _disassemble @@ -33,15 +34,20 @@ def disassemble( # drop LOAD_CONST+RETURN_VALUE to only keep 2 instructions, # to make unit tests shorter block = blocks[-1] - test = ( - (block[-1].name == "RETURN_CONST" and block[-1].arg is None) - if PY312 and not PY314 - else ( + if PY315: + test = ( + block[-2].name == "LOAD_COMMON_CONSTANT" + and block[-2].arg == CommonConstant.CONSTANT_NONE + and block[-1].name == "RETURN_VALUE" + ) + elif PY312 and not PY314: + test = block[-1].name == "RETURN_CONST" and block[-1].arg is None + else: + test = ( block[-2].name == "LOAD_CONST" and block[-2].arg is None and block[-1].name == "RETURN_VALUE" ) - ) if not test: raise ValueError( "unable to find implicit RETURN_VALUE : %s" % block[-2:] diff --git a/tests/test_concrete.py b/tests/test_concrete.py index 4eb0db25..c184b72f 100644 --- a/tests/test_concrete.py +++ b/tests/test_concrete.py @@ -21,7 +21,8 @@ SetLineno, ) from bytecode.concrete import ExceptionTableEntry -from bytecode.utils import PY312, PY313, PY314 +from bytecode.instr import CommonConstant +from bytecode.utils import PY312, PY313, PY314, PY315 from . import TestCase, get_code @@ -244,30 +245,43 @@ def test_eq(self): def test_attr(self): code_obj = get_code("x = 5") code = ConcreteBytecode.from_code(code_obj) - self.assertEqual(code.consts, [5, None]) + self.assertEqual(code.consts, [5] if PY315 else [5, None]) self.assertEqual(code.names, ["x"]) self.assertEqual(code.varnames, []) self.assertEqual(code.freevars, []) self.assertInstructionListEqual( list(code), - ([ConcreteInstr("RESUME", 0, lineno=0)]) - + [ - ConcreteInstr("LOAD_CONST", 0, lineno=1), - ConcreteInstr("STORE_NAME", 0, lineno=1), - ] - + ( + ( [ - ConcreteInstr("LOAD_SMALL_INT", 1, lineno=1), + ConcreteInstr("RESUME", 0, lineno=0), + ConcreteInstr("CACHE", 0, lineno=0), + ConcreteInstr("LOAD_SMALL_INT", 5, lineno=1), + ConcreteInstr("STORE_NAME", 0, lineno=1), + ConcreteInstr("LOAD_COMMON_CONSTANT", 7, lineno=1), ConcreteInstr("RETURN_VALUE", lineno=1), ] - if PY314 + if PY315 else ( - [ConcreteInstr("RETURN_CONST", 1, lineno=1)] - if PY312 - else [ - ConcreteInstr("LOAD_CONST", 1, lineno=1), - ConcreteInstr("RETURN_VALUE", lineno=1), + [ConcreteInstr("RESUME", 0, lineno=0)] + + [ + ConcreteInstr("LOAD_CONST", 0, lineno=1), + ConcreteInstr("STORE_NAME", 0, lineno=1), ] + + ( + [ + ConcreteInstr("LOAD_SMALL_INT", 1, lineno=1), + ConcreteInstr("RETURN_VALUE", lineno=1), + ] + if PY314 + else ( + [ConcreteInstr("RETURN_CONST", 1, lineno=1)] + if PY312 + else [ + ConcreteInstr("LOAD_CONST", 1, lineno=1), + ConcreteInstr("RETURN_VALUE", lineno=1), + ] + ) + ) ) ), ) @@ -320,7 +334,9 @@ def f(): ] + ( [ - ConcreteInstr("LOAD_CONST", 1), + ConcreteInstr("LOAD_COMMON_CONSTANT", CommonConstant.CONSTANT_NONE) + if PY315 + else ConcreteInstr("LOAD_CONST", 1), ConcreteInstr("RETURN_VALUE"), ] if PY314 @@ -444,7 +460,9 @@ def test_extended_lnotab2(self): ] + ( [ - ConcreteInstr("LOAD_CONST", 1), + ConcreteInstr("LOAD_COMMON_CONSTANT", CommonConstant.CONSTANT_NONE) + if PY315 + else ConcreteInstr("LOAD_CONST", 1), ConcreteInstr("RETURN_VALUE"), ] if PY314 @@ -739,6 +757,32 @@ def foo(x: int, y: int): # without EXTENDED_ARG concrete = ConcreteBytecode.from_code(code_obj) + if PY315: + ann_code = concrete.consts[0] + func_code = concrete.consts[1] + expected_py315 = [ + ConcreteInstr("RESUME", 0, lineno=0), + ConcreteInstr("CACHE", 0, lineno=0), + ConcreteInstr("LOAD_CONST", 0, lineno=1), + ConcreteInstr("MAKE_FUNCTION", lineno=1), + ConcreteInstr("LOAD_CONST", 1, lineno=1), + ConcreteInstr("MAKE_FUNCTION", lineno=1), + ConcreteInstr("SET_FUNCTION_ATTRIBUTE", 16, lineno=1), + ConcreteInstr("STORE_NAME", 0, lineno=1), + ConcreteInstr("LOAD_COMMON_CONSTANT", 7, lineno=1), + ConcreteInstr("RETURN_VALUE", lineno=1), + ] + expected_consts = [ann_code, func_code] + self.assertSequenceEqual(concrete.names, ["foo"]) + self.assertSequenceEqual(concrete.consts, expected_consts) + self.assertInstructionListEqual(list(concrete), expected_py315) + concrete = ConcreteBytecode.from_code(code_obj, extended_arg=True) + ann_code = concrete.consts[0] + func_code = concrete.consts[1] + self.assertEqual(concrete.names, ["foo"]) + self.assertEqual(concrete.consts, expected_consts) + self.assertInstructionListEqual(list(concrete), expected_py315) + return if PY314: ann_code = concrete.consts[0] func_code = concrete.consts[1] diff --git a/tests/test_misc.py b/tests/test_misc.py index 6d990503..b488f0aa 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -7,7 +7,7 @@ import bytecode from bytecode import BasicBlock, Bytecode, ControlFlowGraph, Instr, Label -from bytecode.utils import PY312, PY313, PY314 +from bytecode.utils import PY312, PY313, PY314, PY315 from . import disassemble @@ -422,7 +422,33 @@ def func(test): code = code.to_concrete_bytecode() # without line numbers - if PY314: + if PY315: + # RESUME gained a CACHE entry in 3.15, shifting all offsets by 2 + expected = """ + 0 RESUME 0 + 2 CACHE 0 + 4 LOAD_FAST_BORROW 0 + 6 LOAD_SMALL_INT 1 + 8 COMPARE_OP 88 + 10 CACHE 0 + 12 POP_JUMP_IF_FALSE 3 + 14 CACHE 0 + 16 NOT_TAKEN + 18 LOAD_SMALL_INT 1 + 20 RETURN_VALUE + 22 LOAD_FAST_BORROW 0 + 24 LOAD_SMALL_INT 2 + 26 COMPARE_OP 88 + 28 CACHE 0 + 30 POP_JUMP_IF_FALSE 3 + 32 CACHE 0 + 34 NOT_TAKEN + 36 LOAD_SMALL_INT 2 + 38 RETURN_VALUE + 40 LOAD_SMALL_INT 3 + 42 RETURN_VALUE +""" + elif PY314: # COMPARE_OP use the 4 lowest bits as a cache expected = """ 0 RESUME 0 @@ -511,7 +537,32 @@ def func(test): self.check_dump_bytecode(code, expected.lstrip("\n")) # with line numbers - if PY314: + if PY315: + expected = """ +L. 1 0: RESUME 0 + 2: CACHE 0 +L. 2 4: LOAD_FAST_BORROW 0 + 6: LOAD_SMALL_INT 1 + 8: COMPARE_OP 88 + 10: CACHE 0 + 12: POP_JUMP_IF_FALSE 3 + 14: CACHE 0 + 16: NOT_TAKEN +L. 3 18: LOAD_SMALL_INT 1 + 20: RETURN_VALUE +L. 4 22: LOAD_FAST_BORROW 0 + 24: LOAD_SMALL_INT 2 + 26: COMPARE_OP 88 + 28: CACHE 0 + 30: POP_JUMP_IF_FALSE 3 + 32: CACHE 0 + 34: NOT_TAKEN +L. 5 36: LOAD_SMALL_INT 2 + 38: RETURN_VALUE +L. 6 40: LOAD_SMALL_INT 3 + 42: RETURN_VALUE +""" + elif PY314: expected = """ L. 1 0: RESUME 0 L. 2 2: LOAD_FAST_BORROW 0 diff --git a/tox.ini b/tox.ini index 23e3d608..64511032 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3, py38, py39, py310, py311, py312, py313, py314, fmt, docs +envlist = py3, py38, py39, py310, py311, py312, py313, py314, py315, fmt, docs isolated_build = true [testenv]