Skip to content
Merged
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@v7
- name: Get history and tags for SCM versioning to work
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/frameworks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
2 changes: 1 addition & 1 deletion src/bytecode/bytecode.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def __init__(self) -> None:
self.name = "<module>"
self.qualname = self.name
self.filename = "<string>"
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.
Expand Down
2 changes: 1 addition & 1 deletion src/bytecode/concrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down
37 changes: 32 additions & 5 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 All @@ -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
Expand All @@ -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()

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

Expand All @@ -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 <None>: %s" % block[-2:]
Expand Down
11 changes: 11 additions & 0 deletions tests/test_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ async def func(arg, arg2):
function=True,
)

def test_import(self):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment explaining why this is worth testing would be welcome.

# 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(
"""
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 @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Loading