diff --git a/.github/workflows/cis.yml b/.github/workflows/cis.yml index 02488f9a..e91288ae 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@v7 - name: Get history and tags for SCM versioning to work diff --git a/.github/workflows/frameworks.yml b/.github/workflows/frameworks.yml index 93d54835..59154953 100644 --- a/.github/workflows/frameworks.yml +++ b/.github/workflows/frameworks.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v7 diff --git a/pyproject.toml b/pyproject.toml index 583831a5..3924b0e0 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/bytecode.py b/src/bytecode/bytecode.py index bd9c6c46..e5351ac6 100644 --- a/src/bytecode/bytecode.py +++ b/src/bytecode/bytecode.py @@ -40,7 +40,7 @@ def __init__(self) -> None: self.name = "" self.qualname = self.name self.filename = "" - self.docstring: Union[str, None, _UNSET] = UNSET + self.docstring: Union[str, _UNSET, None] = UNSET # We cannot recreate cellvars/freevars from instructions because of super() # special-case, which involves an implicit __class__ cell/free variable # We could try to detect it. diff --git a/src/bytecode/concrete.py b/src/bytecode/concrete.py index 66a3054b..1517722c 100644 --- a/src/bytecode/concrete.py +++ b/src/bytecode/concrete.py @@ -105,7 +105,7 @@ def __init__( name: str, arg: int = UNSET, *, - lineno: Union[int, None, _UNSET] = UNSET, + lineno: Union[int, _UNSET, None] = UNSET, location: Optional[InstrLocation] = None, extended_args: Optional[int] = None, ): diff --git a/src/bytecode/instr.py b/src/bytecode/instr.py index c238ad2b..9b8cc4ca 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 @@ -39,7 +39,9 @@ ) BITFLAG2_OPCODES: Final[set[int]] = ( - {_opcode.opmap["LOAD_SUPER_ATTR"]} if PY312 else set() + {_opcode.opmap["LOAD_SUPER_ATTR"], _opcode.opmap["IMPORT_NAME"]} + if PY315 + else ({_opcode.opmap["LOAD_SUPER_ATTR"]} if PY312 else set()) ) # Binary op opcode which has a dedicated arg @@ -53,6 +55,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() @@ -265,6 +272,15 @@ class CommonConstant(enum.IntEnum): BUILTIN_ALL = 3 BUILTIN_ANY = 4 + if PY315: + 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 # statically. We would need guard on object attribute narrowed down through methods @@ -419,7 +435,8 @@ 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_ITER": (-1, 2) if PY315 else (-1, 1), + # removed in 3.15, filtered by if k in _opcode.opmap "GET_YIELD_FROM_ITER": (-1, 1), "GET_AWAITABLE": (-1, 1), "GET_AITER": (-1, 1), @@ -503,7 +520,14 @@ 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) as two stack slots, so + # FOR_ITER now requires both on the stack (-2) and always pushes them back + # plus one more slot (+3): the next value when continuing, or a marker + # consumed by END_FOR when exhausted before POP_ITER cleans up (iter, + # null_or_index). Net effect is +1 in both cases, matching dis.stack_effect. + "FOR_ITER": (lambda __effect, __arg, __jump: (-2, 3)) + 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 @@ -701,7 +725,7 @@ def __init__( name: str, arg: A = UNSET, # type: ignore *, - lineno: int | None | _UNSET = UNSET, + lineno: int | _UNSET | None = UNSET, location: Optional[InstrLocation] = None, ) -> None: self._set(name, arg) @@ -914,6 +938,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 014df039..2a841b24 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 @@ -169,6 +169,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, [ @@ -179,12 +191,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), ], ) @@ -293,7 +305,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 3ce8089b..9e41bcfb 100644 --- a/tests/test_cfg.py +++ b/tests/test_cfg.py @@ -21,7 +21,8 @@ TryEnd, 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 @@ -35,15 +36,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_code.py b/tests/test_code.py index 37b1ed45..e38f94d2 100644 --- a/tests/test_code.py +++ b/tests/test_code.py @@ -84,6 +84,17 @@ async def func(arg, arg2): function=True, ) + def test_import(self): + # In 3.15 IMPORT_NAME gained lazy/eager flag bits packed into its arg + # (like LOAD_SUPER_ATTR), which a naive plain-name decode misses. + self.check( + """ + import os + import os.path as osp + from os import path + """ + ) + def test_async_gen(self): self.check( """ diff --git a/tests/test_concrete.py b/tests/test_concrete.py index 3c09d090..1b061fce 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), + ] + ) + ) ) ), ) @@ -321,7 +335,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 @@ -445,7 +461,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 @@ -740,6 +758,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]