diff --git a/.github/workflows/cis.yml b/.github/workflows/cis.yml index f274cb0d..229a8466 100644 --- a/.github/workflows/cis.yml +++ b/.github/workflows/cis.yml @@ -36,20 +36,13 @@ jobs: tox tests: - name: Unit tests + name: "Unit tests (Python ${{ matrix.python-version }}, ${{ matrix.pure_python && 'pure' || 'cython' }})" runs-on: ubuntu-latest strategy: fail-fast: false matrix: - include: - - python-version: "3.11" - toxenv: py311 - - python-version: "3.12" - toxenv: py312 - - python-version: "3.13" - toxenv: py313 - - python-version: "3.14" - toxenv: py314 + python-version: ["3.11", "3.12", "3.13", "3.14"] + pure_python: ["", "1"] steps: - uses: actions/checkout@v6 - name: Get history and tags for SCM versioning to work @@ -66,9 +59,9 @@ jobs: python -m pip install tox - name: Test env: - TOXENV: ${{ matrix.toxenv }} + BYTECODE_PURE_PYTHON: ${{ matrix.pure_python }} run: | - tox + tox -e py$(echo '${{ matrix.python-version }}' | tr -d .) - name: Upload coverage to Codecov uses: codecov/codecov-action@v6 if: github.event_name != 'schedule' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34b6e1b4..da3bbcd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,8 +38,8 @@ jobs: name: cibw-sdist path: dist/* - build_wheel: - name: Build wheel + build_pure_wheel: + name: Build pure-Python wheel runs-on: ubuntu-latest steps: - name: Checkout @@ -52,7 +52,9 @@ jobs: uses: actions/setup-python@v6 with: python-version: '3.x' - - name: Build wheels + - name: Build pure-Python wheel + env: + BYTECODE_PURE_PYTHON: '1' run: | pip install --upgrade pip pip install wheel build @@ -65,12 +67,39 @@ jobs: - name: Store artifacts uses: actions/upload-artifact@v7 with: - name: cibw-wheel + name: cibw-wheel-pure path: dist/*.whl + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-13, macos-latest, windows-latest] + + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Get history and tags for SCM versioning to work + run: | + git fetch --prune --unshallow + git fetch --depth=1 origin +refs/tags/*:refs/tags/* + - name: Build wheels + uses: pypa/cibuildwheel@v2.23.3 + env: + CIBW_BUILD: cp311-* cp312-* cp313-* cp314-* + CIBW_TEST_REQUIRES: pytest + CIBW_TEST_COMMAND: python -X dev -m pytest {project}/tests + - name: Store artifacts + uses: actions/upload-artifact@v6 + with: + name: cibw-wheels-${{ matrix.os }} + path: wheelhouse/*.whl + publish: if: github.event_name == 'push' - needs: [build_wheel, build_sdist] + needs: [build_wheels, build_pure_wheel, build_sdist] runs-on: ubuntu-latest environment: name: pypi @@ -126,4 +155,4 @@ jobs: run: >- gh release upload '${{ github.ref_name }}' dist/** - --repo '${{ github.repository }}' \ No newline at end of file + --repo '${{ github.repository }}' diff --git a/.gitignore b/.gitignore index 398e93d8..314d76e4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ coverage.xml .pytest_cache .cache .venv +*.c +*.so diff --git a/pyproject.toml b/pyproject.toml index dd61b4df..2dfd0f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,18 +31,20 @@ [build-system] - requires = ["setuptools>=61.2", "wheel", "setuptools_scm[toml]>=3.4.3"] + requires = ["setuptools>=61.2", "wheel", "setuptools_scm[toml]>=3.4.3", "cython"] build-backend = "setuptools.build_meta" [dependency-groups] dev = [ "mypy>=1.16.1", "pytest>=8", + "pytest-benchmark>=5", "pytest-cov>=6", "ruff>=0.12.0", ] test = [ "pytest>=8", + "pytest-benchmark>=5", "pytest-cov", ] @@ -90,5 +92,11 @@ __version__ = "{version}" follow_imports = "normal" strict_optional = true +[[tool.mypy.overrides]] + # cython stubs are not available to mypy; the cythonize branch intentionally + # removes Generic[A] from BaseInstr which cascades type errors. + module = ["bytecode.instr", "bytecode.concrete", "bytecode.bytecode", "bytecode.cfg"] + ignore_errors = true + [tool.pytest.ini_options] minversion = "6.0" diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..a453319d --- /dev/null +++ b/setup.py @@ -0,0 +1,52 @@ +import os + +from setuptools import setup # isort: skip + +from pathlib import Path + +import Cython.Distutils +from Cython.Build import cythonize # noqa: I100 + +ROOT = Path(__file__).parent / "src" + + +# Get all the py files under the src folder +def get_py_files(path): + return [ + p.relative_to(ROOT) for p in Path(path).rglob("*.py") if p.name != "__init__.py" + ] + + +def pretend_cython(): + return [ + Cython.Distutils.Extension( + str(p.with_suffix("")).replace(os.sep, "."), + sources=[str(Path("src") / p)], + language="c", + ) + for p in get_py_files(ROOT) + ] + + +_pure_python = os.getenv("BYTECODE_PURE_PYTHON") +print(f"bytecode: building {'pure-Python' if _pure_python else 'Cython'} version") + +# Always include .pyi stubs; also include .pxd declaration files in Cython +# builds so downstream Cython users can cimport from bytecode. +_package_data: dict = {"bytecode": ["*.pyi"]} +if not _pure_python: + _package_data["bytecode"].append("*.pxd") + +setup( + name="bytecode", + setup_requires=["setuptools_scm[toml]>=4"] + ([] if _pure_python else ["cython"]), + package_data=_package_data, + ext_modules=[] if _pure_python else cythonize( + pretend_cython(), + force=True, + compiler_directives={ + "language_level": "3", + "annotation_typing": False, + }, + ), +) diff --git a/src/bytecode/concrete.pxd b/src/bytecode/concrete.pxd new file mode 100644 index 00000000..6e095082 --- /dev/null +++ b/src/bytecode/concrete.pxd @@ -0,0 +1,5 @@ +from bytecode.instr cimport BaseInstr + +cdef class ConcreteInstr(BaseInstr): + cdef public object _extended_args + cdef public int _size diff --git a/src/bytecode/concrete.py b/src/bytecode/concrete.py index e99a7980..28b24d5f 100644 --- a/src/bytecode/concrete.py +++ b/src/bytecode/concrete.py @@ -1,5 +1,17 @@ from __future__ import annotations +try: + import cython +except ImportError: + + class cython: # type: ignore[no-redef] + compiled = False + + @staticmethod + def cclass(cls: Any) -> Any: + return cls + + import dis import inspect import itertools @@ -85,7 +97,8 @@ def _set_docstring(code: _bytecode.BaseBytecode, consts: Sequence) -> None: T = TypeVar("T", bound="ConcreteInstr") -class ConcreteInstr(BaseInstr[int]): +@cython.cclass +class ConcreteInstr(BaseInstr): """Concrete instruction. arg must be an integer in the range 0..2147483647. @@ -94,9 +107,6 @@ class ConcreteInstr(BaseInstr[int]): """ - # For ConcreteInstr the argument is always an integer - _arg: int - __slots__ = ("_extended_args", "_size") def __init__( @@ -190,7 +200,7 @@ def _from_opcode( location: Optional[InstrLocation], ) -> T: """Fast path for from_code: arg is a raw byte (0-255), size is always 2.""" - new = object.__new__(cls) + new = cls.__new__(cls) new._name = name new._opcode = opcode new._arg = arg @@ -208,7 +218,7 @@ def _from_trusted( location: Optional[InstrLocation], ) -> T: """Fast path for concrete_instructions: skip validation, compute size from arg.""" - new = object.__new__(cls) + new = cls.__new__(cls) new._name = name new._opcode = opcode new._arg = arg diff --git a/src/bytecode/flags.py b/src/bytecode/flags.py index 82ae8b40..91edfdd4 100644 --- a/src/bytecode/flags.py +++ b/src/bytecode/flags.py @@ -120,8 +120,9 @@ def infer_flags( elif opcode in ASYNC_OPCODES: known_async = True elif opcode == YIELD_VALUE_OPCODE: + ni = next(instr_iter) while isinstance( - ni := next(instr_iter), + ni, ( _bytecode.SetLineno, _bytecode.Label, @@ -129,7 +130,7 @@ def infer_flags( _bytecode.TryEnd, ), ): - pass + ni = next(instr_iter) assert ni._opcode == RESUME_OPCODE if (ni.arg & 3) != 3: known_generator = True diff --git a/src/bytecode/instr.pxd b/src/bytecode/instr.pxd new file mode 100644 index 00000000..d47af54b --- /dev/null +++ b/src/bytecode/instr.pxd @@ -0,0 +1,15 @@ +cdef class InstrLocation: + # Must be `object` (not `int`) because these fields are Optional[int] and can be None. + cdef readonly object lineno + cdef readonly object end_lineno + cdef readonly object col_offset + cdef readonly object end_col_offset + +cdef class BaseInstr: + cdef public str _name + cdef public object _location + cdef public int _opcode + cdef public object _arg + +cdef class Instr(BaseInstr): + pass diff --git a/src/bytecode/instr.py b/src/bytecode/instr.py index 56240eac..004dc592 100644 --- a/src/bytecode/instr.py +++ b/src/bytecode/instr.py @@ -3,12 +3,24 @@ import dis import enum import opcode as _opcode -import sys +import types from abc import abstractmethod from dataclasses import dataclass from functools import cache from marshal import dumps as _dumps -from typing import Any, Callable, Final, Generic, Optional, TypeVar, Union +from typing import Any, Callable, Final, Optional, TypeVar, Union + +try: + import cython +except ImportError: + + class cython: # type: ignore[no-redef] + compiled = False + + @staticmethod + def cclass(cls: Any) -> Any: + return cls + try: from typing import TypeGuard @@ -545,6 +557,7 @@ def _check_location( ) +@cython.cclass @dataclass(frozen=True) class InstrLocation: """Location information for an instruction.""" @@ -564,6 +577,22 @@ class InstrLocation: __slots__ = ["col_offset", "end_col_offset", "end_lineno", "lineno"] + def _unsafe_set( + self, + lineno: Optional[int], + end_lineno: Optional[int], + col_offset: Optional[int], + end_col_offset: Optional[int], + ) -> None: + # When Cython-compiled, `self` is statically typed as InstrLocation so + # these assignments compile to direct C struct writes, bypassing the + # readonly Python descriptor. In pure Python mode callers use + # object.__setattr__ instead and never reach this method. + self.lineno = lineno # type: ignore[misc] + self.end_lineno = end_lineno # type: ignore[misc] + self.col_offset = col_offset # type: ignore[misc] + self.end_col_offset = end_col_offset # type: ignore[misc] + def __init__( self, lineno: Optional[int], @@ -571,11 +600,14 @@ def __init__( col_offset: Optional[int], end_col_offset: Optional[int], ) -> None: - # Needed because we want the class to be frozen - object.__setattr__(self, "lineno", lineno) - object.__setattr__(self, "end_lineno", end_lineno) - object.__setattr__(self, "col_offset", col_offset) - object.__setattr__(self, "end_col_offset", end_col_offset) + if cython.compiled: + self._unsafe_set(lineno, end_lineno, col_offset, end_col_offset) + else: + # Needed because we want the class to be frozen in pure Python + object.__setattr__(self, "lineno", lineno) + object.__setattr__(self, "end_lineno", end_lineno) + object.__setattr__(self, "col_offset", col_offset) + object.__setattr__(self, "end_col_offset", end_col_offset) # In Python 3.11 0 is a valid lineno for some instructions (RESUME for example) _check_location(lineno, "lineno", 0) _check_location(end_lineno, "end_lineno", 1) @@ -630,11 +662,14 @@ def _from_tuple( end_col_offset: Optional[int], ) -> InstrLocation: """Fast path for trusted position data (e.g. from co_positions()).""" - new = object.__new__(cls) - object.__setattr__(new, "lineno", lineno) - object.__setattr__(new, "end_lineno", end_lineno) - object.__setattr__(new, "col_offset", col_offset) - object.__setattr__(new, "end_col_offset", end_col_offset) + new: InstrLocation = cls.__new__(cls) + if cython.compiled: + new._unsafe_set(lineno, end_lineno, col_offset, end_col_offset) + else: + object.__setattr__(new, "lineno", lineno) + object.__setattr__(new, "end_lineno", end_lineno) + object.__setattr__(new, "col_offset", col_offset) + object.__setattr__(new, "end_col_offset", end_col_offset) return new @@ -690,11 +725,15 @@ def copy(self) -> TryEnd: A = TypeVar("A", bound=object) -class BaseInstr(Generic[A]): +@cython.cclass +class BaseInstr: """Abstract instruction.""" __slots__ = ("_arg", "_location", "_name", "_opcode") + def __class_getitem__(cls, item: Any) -> types.GenericAlias: + return types.GenericAlias(cls, item) + # Work around an issue with the default value of arg def __init__( self, @@ -759,6 +798,10 @@ def arg(self) -> A: def arg(self, arg: A): self._set(self._name, arg) + @arg.deleter + def arg(self) -> None: + raise AttributeError("can't delete attribute") + @property def lineno(self) -> int | _UNSET | None: return self._location.lineno if self._location is not None else UNSET @@ -828,7 +871,7 @@ def pre_and_post_stack_effect(self, jump: Optional[bool] = None) -> tuple[int, i return (_effect, 0) def copy(self: T) -> T: - new = object.__new__(self.__class__) + new = self.__class__.__new__(self.__class__) new._name = self._name new._opcode = self._opcode new._arg = self._arg @@ -844,7 +887,7 @@ def _from_trusted( location: Optional[InstrLocation], ) -> T: """Fast path for internal construction from already-validated data.""" - new = object.__new__(cls) + new = cls.__new__(cls) new._name = name new._opcode = opcode new._arg = arg @@ -883,21 +926,13 @@ def __repr__(self) -> str: else: return "<%s location=%s>" % (self._name, self._location) - def __eq__(self, other: Any) -> bool: - if type(self) is not type(other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, BaseInstr): return False return self._cmp_key() == other._cmp_key() # --- Private API - _name: str - - _location: Optional[InstrLocation] - - _opcode: int - - _arg: A - def _set(self, name: str, arg: A) -> None: if not isinstance(name, str): raise TypeError("operation name must be a str") @@ -952,7 +987,8 @@ def _cmp_key(self) -> tuple[Optional[InstrLocation], str, Any]: ] -class Instr(BaseInstr[InstrArg]): +@cython.cclass +class Instr(BaseInstr): __slots__ = () def _cmp_key(self) -> tuple[InstrLocation | None, str, Any]: diff --git a/src/bytecode/instr.pyi b/src/bytecode/instr.pyi new file mode 100644 index 00000000..f4c39099 --- /dev/null +++ b/src/bytecode/instr.pyi @@ -0,0 +1,227 @@ +"""Type stubs for instr.py. + +Cython cdef classes cannot inherit from Generic[], so this stub restores the +generic type-checking behaviour for BaseInstr[A] and Instr. +""" + +import enum +import types +from typing import Any, Final, Generic, Optional, TypeGuard, TypeVar, Union + +import bytecode as _bytecode + +# ── type variables ──────────────────────────────────────────────────────────── + +A = TypeVar("A", bound=object) +T = TypeVar("T", bound="BaseInstr[Any]") + +# ── opcode sets / constants ─────────────────────────────────────────────────── + +MIN_INSTRUMENTED_OPCODE: Final[int] +BITFLAG_OPCODES: Final[set[int]] +BITFLAG2_OPCODES: Final[set[int]] +BINARY_OPS: Final[set[int]] +INTRINSIC_1OP: Final[set[int]] +INTRINSIC_2OP: Final[set[int]] +INTRINSIC: Final[set[int]] +COMMON_CONSTANT_OPS: Final[set[int]] +FORMAT_VALUE_OPS: Final[set[int]] +SMALL_INT_OPS: Final[set[int]] +SPECIAL_OPS: Final[set[int]] +HAS_ABSOLUTE_JUMP: Final[set[int]] +HAS_FORWARD_RELATIVE_JUMP: Final[set[int]] +HAS_BACKWARD_RELATIVE_JUMP: Final[set[int]] +HAS_JUMP: Final[set[int]] +HAS_CONDITIONAL_JUMP: Final[set[int]] +HAS_UNCONDITIONAL_JUMP: Final[set[int]] +IS_INSTR_FINAL: Final[set[int]] +DUAL_ARG_OPCODES: Final[set[int]] +DUAL_ARG_OPCODES_SINGLE_OPS: Final[dict[int, tuple[str, str]]] +EXTENDEDARG_OPCODE: Final[int] +NOP_OPCODE: Final[int] +CACHE_OPCODE: Final[int] +RESUME_OPCODE: Final[int] +STATIC_STACK_EFFECTS: Final[dict[int, tuple[int, int]]] +DYNAMIC_STACK_EFFECTS: Final[dict[int, Any]] + +# ── enums ───────────────────────────────────────────────────────────────────── + +class Compare(enum.IntEnum): + LT = 0 + LE = 1 + EQ = 2 + NE = 3 + GT = 4 + GE = 5 + LT_CAST = 16 + LE_CAST = 17 + EQ_CAST = 18 + NE_CAST = 19 + GT_CAST = 20 + GE_CAST = 21 + +class BinaryOp(enum.IntEnum): ... +class Intrinsic1Op(enum.IntEnum): ... +class Intrinsic2Op(enum.IntEnum): ... +class FormatValue(enum.IntEnum): ... +class SpecialMethod(enum.IntEnum): ... +class CommonConstant(enum.IntEnum): ... + +# ── sentinel ────────────────────────────────────────────────────────────────── + +class _UNSET(int): ... + +UNSET: _UNSET + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def const_key(obj: Any) -> bytes | tuple[type, int]: ... +def _check_arg_int(arg: Any, name: str) -> TypeGuard[int]: ... +def opcode_has_argument(opcode: int) -> bool: ... + +# ── label / variable types ──────────────────────────────────────────────────── + +class Label: ... + +PLACEHOLDER_LABEL: Label + +class _Variable: + name: str + def __init__(self, name: str) -> None: ... + def __eq__(self, other: Any) -> bool: ... + def __ne__(self, other: Any) -> bool: ... + def __repr__(self) -> str: ... + +class CellVar(_Variable): ... +class FreeVar(_Variable): ... + +# ── InstrLocation ───────────────────────────────────────────────────────────── + +class InstrLocation: + lineno: Optional[int] + end_lineno: Optional[int] + col_offset: Optional[int] + end_col_offset: Optional[int] + def __init__( + self, + lineno: Optional[int], + end_lineno: Optional[int], + col_offset: Optional[int], + end_col_offset: Optional[int], + ) -> None: ... + @classmethod + def from_positions(cls, position: Any) -> InstrLocation: ... + @classmethod + def _from_tuple( + cls, + lineno: Optional[int], + end_lineno: Optional[int], + col_offset: Optional[int], + end_col_offset: Optional[int], + ) -> InstrLocation: ... + +# ── pseudo-instructions ─────────────────────────────────────────────────────── + +class SetLineno: + def __init__(self, lineno: int) -> None: ... + @property + def lineno(self) -> int: ... + def __eq__(self, other: Any) -> bool: ... + +class TryBegin: + target: Label | _bytecode.BasicBlock + push_lasti: bool + stack_depth: int | _UNSET + def __init__( + self, + target: Label | _bytecode.BasicBlock, + push_lasti: bool, + stack_depth: int | _UNSET = ..., + ) -> None: ... + def copy(self) -> TryBegin: ... + +class TryEnd: + entry: TryBegin + def __init__(self, entry: TryBegin) -> None: ... + def copy(self) -> TryEnd: ... + +# ── InstrArg ────────────────────────────────────────────────────────────────── + +InstrArg = Union[ + int, + str, + Label, + CellVar, + FreeVar, + _bytecode.BasicBlock, + Compare, + FormatValue, + BinaryOp, + Intrinsic1Op, + Intrinsic2Op, + CommonConstant, + SpecialMethod, + tuple[bool, str], + tuple[bool, bool, str], + tuple[bool, FormatValue], + tuple[str | CellVar | FreeVar, str | CellVar | FreeVar], +] + +# ── BaseInstr / Instr ───────────────────────────────────────────────────────── + +class BaseInstr(Generic[A]): + def __init__( + self, + name: str, + arg: A = ..., + *, + lineno: int | None | _UNSET = ..., + location: Optional[InstrLocation] = None, + ) -> None: ... + def __class_getitem__(cls, item: Any) -> types.GenericAlias: ... + def set(self, name: str, arg: A = ...) -> None: ... + def require_arg(self) -> bool: ... + @property + def name(self) -> str: ... + @name.setter + def name(self, name: str) -> None: ... + @property + def opcode(self) -> int: ... + @opcode.setter + def opcode(self, op: int) -> None: ... + @property + def arg(self) -> A: ... + @arg.setter + def arg(self, arg: A) -> None: ... + @property + def lineno(self) -> int | _UNSET | None: ... + @lineno.setter + def lineno(self, lineno: int | _UNSET | None) -> None: ... + @property + def location(self) -> Optional[InstrLocation]: ... + @location.setter + def location(self, location: Optional[InstrLocation]) -> None: ... + def stack_effect(self, jump: Optional[bool] = None) -> int: ... + def pre_and_post_stack_effect( + self, jump: Optional[bool] = None + ) -> tuple[int, int]: ... + def copy(self: T) -> T: ... + @classmethod + def _from_trusted( + cls: type[T], + name: str, + opcode: int, + arg: A, + location: Optional[InstrLocation], + ) -> T: ... + def has_jump(self) -> bool: ... + def is_cond_jump(self) -> bool: ... + def is_uncond_jump(self) -> bool: ... + def is_abs_jump(self) -> bool: ... + def is_forward_rel_jump(self) -> bool: ... + def is_backward_rel_jump(self) -> bool: ... + def is_final(self) -> bool: ... + def __eq__(self, other: object) -> bool: ... + def _set(self, name: str, arg: A) -> None: ... + +class Instr(BaseInstr[InstrArg]): ... diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..cb22c5a2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +def pytest_report_header(): + import importlib.util + + spec = importlib.util.find_spec("bytecode.concrete") + is_pure = spec and spec.origin and spec.origin.endswith(".py") + kind = "pure-Python" if is_pure else "Cython" + return f"bytecode: {kind} build" diff --git a/tests/test_bench_roundtrip.py b/tests/test_bench_roundtrip.py new file mode 100644 index 00000000..1b86f163 --- /dev/null +++ b/tests/test_bench_roundtrip.py @@ -0,0 +1,44 @@ +"""Round-trip decompile/recompile benchmarks. + +Run with: pytest tests/test_bench_roundtrip.py --benchmark-compare +""" + +import types + +import pytest + +from bytecode import Bytecode + + +def _collect_code_objects(root: types.CodeType, depth: int = 1) -> list[types.CodeType]: + result = [root] + if depth > 0: + for const in root.co_consts: + if isinstance(const, types.CodeType): + result.extend(_collect_code_objects(const, depth - 1)) + return result + + +def _dis_corpus() -> list[types.CodeType]: + import importlib.util + + spec = importlib.util.find_spec("dis") + assert spec and spec.origin + src = open(spec.origin).read() + top = compile(src, spec.origin, "exec") + return _collect_code_objects(top) + + +_CORPUS = _dis_corpus() + + +@pytest.fixture(params=_CORPUS, ids=[c.co_name for c in _CORPUS]) +def code_object(request): + return request.param + + +def test_roundtrip(benchmark, code_object): + def roundtrip(): + Bytecode.from_code(code_object).to_code() + + benchmark(roundtrip) diff --git a/tests/test_instr.py b/tests/test_instr.py index 189ab0c7..7f6ce4cc 100644 --- a/tests/test_instr.py +++ b/tests/test_instr.py @@ -113,6 +113,13 @@ def test_init(self): else: InstrLocation(*args) + def test_immutable(self): + loc = InstrLocation(1, 2, 3, 4) + with self.assertRaises((AttributeError, TypeError)): + loc.lineno = 99 # type: ignore[misc] + with self.assertRaises((AttributeError, TypeError)): + del loc.lineno # type: ignore[misc] + class InstrTests(TestCase): def test_constructor(self): diff --git a/tox.ini b/tox.ini index 23e3d608..edbfe5a1 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,10 @@ envlist = py3, py38, py39, py310, py311, py312, py313, py314, fmt, docs isolated_build = true [testenv] +passenv = BYTECODE_PURE_PYTHON deps= pytest + pytest-benchmark pytest-cov pytest-subtests commands = pytest --cov bytecode --cov-report=xml -v tests