Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/workflows/cis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 27 additions & 4 deletions src/bytecode/instr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/bytecode/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 21 additions & 5 deletions tests/test_bytecode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
[
Expand All @@ -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),
],
)
Expand Down Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions tests/test_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <None>: %s" % block[-2:]
Expand Down
78 changes: 61 additions & 17 deletions tests/test_concrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
]
)
)
)
),
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
57 changes: 54 additions & 3 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -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]
Expand Down