Skip to content

Commit 1ce3911

Browse files
committed
feat: add support for CPython 3.15
1 parent 317dd0f commit 1ce3911

7 files changed

Lines changed: 133 additions & 24 deletions

File tree

.github/workflows/cis.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ jobs:
5252
toxenv: py312
5353
- python-version: "3.13"
5454
toxenv: py313
55-
- python-version: "3.14-dev"
55+
- python-version: "3.14"
5656
toxenv: py314
57+
- python-version: "3.15-dev"
58+
toxenv: py315
5759
steps:
5860
- uses: actions/checkout@v5
5961
- name: Get history and tags for SCM versioning to work

src/bytecode/instr.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from typing_extensions import TypeGuard # type: ignore
1515

1616
import bytecode as _bytecode
17-
from bytecode.utils import PY311, PY312, PY313, PY314
17+
from bytecode.utils import PY311, PY312, PY313, PY314, PY315
1818

1919
# --- Instruction argument tools and
2020

@@ -49,6 +49,11 @@
4949
# Small integer related opcode
5050
SMALL_INT_OPS = {_opcode.opmap["LOAD_SMALL_INT"]} if PY314 else set()
5151

52+
# Opcodes that gained a cache-only argument in 3.15 (arg is always 0 and not user-visible)
53+
CACHE_ONLY_ARG_OPCODES: Set[int] = (
54+
{_opcode.opmap["GET_ITER"]} if PY315 else set()
55+
)
56+
5257
# Special method loading related opcodes
5358
SPECIAL_OPS = {_opcode.opmap["LOAD_SPECIAL"]} if PY314 else set()
5459

@@ -371,8 +376,8 @@ def opcode_has_argument(opcode: int) -> bool:
371376
"DUP_TOP": (-1, 2),
372377
"DUP_TOP_TWO": (-2, 4),
373378
"GET_LEN": (-1, 2),
374-
"GET_ITER": (-1, 1),
375-
"GET_YIELD_FROM_ITER": (-1, 1),
379+
"GET_ITER": (-1, 2) if PY315 else (-1, 1),
380+
**({} if PY315 else {"GET_YIELD_FROM_ITER": (-1, 1)}),
376381
"GET_AWAITABLE": (-1, 1),
377382
"GET_AITER": (-1, 1),
378383
"GET_ANEXT": (-1, 2),
@@ -451,7 +456,16 @@ def opcode_has_argument(opcode: int) -> bool:
451456
"MAP_ADD": lambda effect, arg, jump: (-arg, arg - 2),
452457
"FORMAT_VALUE": lambda effect, arg, jump: (effect - 1, 1),
453458
# FOR_ITER needs TOS to be an iterator, hence a prerequisite of 1 on the stack
454-
"FOR_ITER": lambda effect, arg, jump: (effect, 0) if jump else (-1, 2),
459+
# In 3.15, GET_ITER pushes (iter, null_or_index) so FOR_ITER keeps those and
460+
# pushes/keeps the value; when exhausted it jumps to POP_ITER leaving stack unchanged.
461+
"FOR_ITER": (
462+
# In 3.15, GET_ITER pushes (iter, null_or_index); FOR_ITER always pushes the
463+
# next value (+1). When exhausted it jumps to END_FOR (which pops it) then
464+
# POP_ITER cleans up (iter, null_or_index). Matches dis.stack_effect = 1 always.
465+
lambda __effect, __arg, __jump: (0, 1)
466+
) if PY315 else (
467+
lambda effect, __arg, jump: (effect, 0) if jump else (-1, 2)
468+
),
455469
"BUILD_INTERPOLATION": lambda effect, arg, jump: (-(2 + (arg & 1)), 1),
456470
**{
457471
# Instr(UNPACK_* , n) pops 1 and pushes n
@@ -842,6 +856,9 @@ def _set(self, name: str, arg: A) -> None:
842856
"Only base opcodes are supported"
843857
)
844858

859+
if arg is UNSET and opcode in CACHE_ONLY_ARG_OPCODES:
860+
arg = 0 # type: ignore
861+
845862
self._check_arg(name, opcode, arg)
846863

847864
self._name = name

src/bytecode/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
PY312: Final[bool] = sys.version_info >= (3, 12)
77
PY313: Final[bool] = sys.version_info >= (3, 13)
88
PY314: Final[bool] = sys.version_info >= (3, 14)
9+
PY315: Final[bool] = sys.version_info >= (3, 15)

tests/test_cfg.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
dump_bytecode,
2121
)
2222
from bytecode.concrete import OFFSET_AS_INSTRUCTION
23-
from bytecode.utils import PY311, PY312, PY313, PY314
23+
from bytecode.utils import PY311, PY312, PY313, PY314, PY315
2424

2525
from . import TestCase, disassemble as _disassemble
2626

tests/test_concrete.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
SetLineno,
2222
)
2323
from bytecode.concrete import OFFSET_AS_INSTRUCTION, ExceptionTableEntry
24-
from bytecode.utils import PY310, PY311, PY312, PY313, PY314
24+
from bytecode.utils import PY310, PY311, PY312, PY313, PY314, PY315
2525

2626
from . import TestCase, get_code
2727

@@ -256,24 +256,37 @@ def test_attr(self):
256256
self.assertEqual(code.freevars, [])
257257
self.assertInstructionListEqual(
258258
list(code),
259-
([ConcreteInstr("RESUME", 0, lineno=0)] if PY311 else [])
260-
+ [
261-
ConcreteInstr("LOAD_CONST", 0, lineno=1),
262-
ConcreteInstr("STORE_NAME", 0, lineno=1),
263-
]
264-
+ (
259+
(
265260
[
266-
ConcreteInstr("LOAD_SMALL_INT", 1, lineno=1),
261+
ConcreteInstr("RESUME", 0, lineno=0),
262+
ConcreteInstr("CACHE", 0, lineno=0),
263+
ConcreteInstr("LOAD_SMALL_INT", 5, lineno=1),
264+
ConcreteInstr("STORE_NAME", 0, lineno=1),
265+
ConcreteInstr("LOAD_CONST", 1, lineno=1),
267266
ConcreteInstr("RETURN_VALUE", lineno=1),
268267
]
269-
if PY314
268+
if PY315
270269
else (
271-
[ConcreteInstr("RETURN_CONST", 1, lineno=1)]
272-
if PY312
273-
else [
274-
ConcreteInstr("LOAD_CONST", 1, lineno=1),
275-
ConcreteInstr("RETURN_VALUE", lineno=1),
270+
([ConcreteInstr("RESUME", 0, lineno=0)] if PY311 else [])
271+
+ [
272+
ConcreteInstr("LOAD_CONST", 0, lineno=1),
273+
ConcreteInstr("STORE_NAME", 0, lineno=1),
276274
]
275+
+ (
276+
[
277+
ConcreteInstr("LOAD_SMALL_INT", 1, lineno=1),
278+
ConcreteInstr("RETURN_VALUE", lineno=1),
279+
]
280+
if PY314
281+
else (
282+
[ConcreteInstr("RETURN_CONST", 1, lineno=1)]
283+
if PY312
284+
else [
285+
ConcreteInstr("LOAD_CONST", 1, lineno=1),
286+
ConcreteInstr("RETURN_VALUE", lineno=1),
287+
]
288+
)
289+
)
277290
)
278291
),
279292
)
@@ -824,6 +837,31 @@ def foo(x: int, y: int):
824837

825838
# without EXTENDED_ARG
826839
concrete = ConcreteBytecode.from_code(code_obj)
840+
if PY315:
841+
ann_code = concrete.consts[0]
842+
func_code = concrete.consts[1]
843+
expected_py315 = [
844+
ConcreteInstr("RESUME", 0, lineno=0),
845+
ConcreteInstr("CACHE", 0, lineno=0),
846+
ConcreteInstr("LOAD_CONST", 0, lineno=1),
847+
ConcreteInstr("MAKE_FUNCTION", lineno=1),
848+
ConcreteInstr("LOAD_CONST", 1, lineno=1),
849+
ConcreteInstr("MAKE_FUNCTION", lineno=1),
850+
ConcreteInstr("SET_FUNCTION_ATTRIBUTE", 16, lineno=1),
851+
ConcreteInstr("STORE_NAME", 0, lineno=1),
852+
ConcreteInstr("LOAD_CONST", 2, lineno=1),
853+
ConcreteInstr("RETURN_VALUE", lineno=1),
854+
]
855+
self.assertSequenceEqual(concrete.names, ["foo"])
856+
self.assertSequenceEqual(concrete.consts, [ann_code, func_code, None])
857+
self.assertInstructionListEqual(list(concrete), expected_py315)
858+
concrete = ConcreteBytecode.from_code(code_obj, extended_arg=True)
859+
ann_code = concrete.consts[0]
860+
func_code = concrete.consts[1]
861+
self.assertEqual(concrete.names, ["foo"])
862+
self.assertEqual(concrete.consts, [ann_code, func_code, None])
863+
self.assertInstructionListEqual(list(concrete), expected_py315)
864+
return
827865
if PY314:
828866
ann_code = concrete.consts[0]
829867
func_code = concrete.consts[1]

tests/test_misc.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import bytecode
99
from bytecode import BasicBlock, Bytecode, ControlFlowGraph, Instr, Label
1010
from bytecode.concrete import OFFSET_AS_INSTRUCTION
11-
from bytecode.utils import PY311, PY312, PY313, PY314
11+
from bytecode.utils import PY311, PY312, PY313, PY314, PY315
1212

1313
from . import disassemble
1414

@@ -529,7 +529,33 @@ def func(test):
529529
code = code.to_concrete_bytecode()
530530

531531
# without line numbers
532-
if PY314:
532+
if PY315:
533+
# RESUME gained a CACHE entry in 3.15, shifting all offsets by 2
534+
expected = """
535+
0 RESUME 0
536+
2 CACHE 0
537+
4 LOAD_FAST_BORROW 0
538+
6 LOAD_SMALL_INT 1
539+
8 COMPARE_OP 88
540+
10 CACHE 0
541+
12 POP_JUMP_IF_FALSE 3
542+
14 CACHE 0
543+
16 NOT_TAKEN
544+
18 LOAD_SMALL_INT 1
545+
20 RETURN_VALUE
546+
22 LOAD_FAST_BORROW 0
547+
24 LOAD_SMALL_INT 2
548+
26 COMPARE_OP 88
549+
28 CACHE 0
550+
30 POP_JUMP_IF_FALSE 3
551+
32 CACHE 0
552+
34 NOT_TAKEN
553+
36 LOAD_SMALL_INT 2
554+
38 RETURN_VALUE
555+
40 LOAD_SMALL_INT 3
556+
42 RETURN_VALUE
557+
"""
558+
elif PY314:
533559
# COMPARE_OP use the 4 lowest bits as a cache
534560
expected = """
535561
0 RESUME 0
@@ -635,7 +661,32 @@ def func(test):
635661
self.check_dump_bytecode(code, expected.lstrip("\n"))
636662

637663
# with line numbers
638-
if PY314:
664+
if PY315:
665+
expected = """
666+
L. 1 0: RESUME 0
667+
2: CACHE 0
668+
L. 2 4: LOAD_FAST_BORROW 0
669+
6: LOAD_SMALL_INT 1
670+
8: COMPARE_OP 88
671+
10: CACHE 0
672+
12: POP_JUMP_IF_FALSE 3
673+
14: CACHE 0
674+
16: NOT_TAKEN
675+
L. 3 18: LOAD_SMALL_INT 1
676+
20: RETURN_VALUE
677+
L. 4 22: LOAD_FAST_BORROW 0
678+
24: LOAD_SMALL_INT 2
679+
26: COMPARE_OP 88
680+
28: CACHE 0
681+
30: POP_JUMP_IF_FALSE 3
682+
32: CACHE 0
683+
34: NOT_TAKEN
684+
L. 5 36: LOAD_SMALL_INT 2
685+
38: RETURN_VALUE
686+
L. 6 40: LOAD_SMALL_INT 3
687+
42: RETURN_VALUE
688+
"""
689+
elif PY314:
639690
expected = """
640691
L. 1 0: RESUME 0
641692
L. 2 2: LOAD_FAST_BORROW 0

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[tox]
2-
envlist = py3, py38, py39, py310, py311, py312, py313, py314, fmt, docs
2+
envlist = py3, py38, py39, py310, py311, py312, py313, py314, py315, fmt, docs
33
isolated_build = true
44

55
[testenv]

0 commit comments

Comments
 (0)