From bd4f084348f748b80f4220d4f1cb8c9c9941605b Mon Sep 17 00:00:00 2001 From: Jared Hance Date: Wed, 30 Sep 2026 16:53:13 +0000 Subject: [PATCH 1/3] [mypyc] Add undocumented flag to control target After discussion with jukka we decided to bikeshed the exact interface for the cli to specify the target python version. For now it is --target-python (t?) This requires passing through the target python which is represented as a class with version and free threaded fields as well as properties representing semantics. --- mypyc/__main__.py | 25 +++++++++- mypyc/build.py | 38 ++++++++++++--- mypyc/codegen/emit.py | 17 ++++--- mypyc/codegen/emitclass.py | 7 ++- mypyc/codegen/emitfunc.py | 10 ++-- mypyc/codegen/emitmodule.py | 14 +++--- mypyc/ir/ops.py | 4 ++ mypyc/ir/rtypes.py | 4 +- mypyc/irbuild/builder.py | 3 +- mypyc/irbuild/callable_class.py | 10 +--- mypyc/irbuild/expression.py | 17 +++---- mypyc/irbuild/for_helpers.py | 4 +- mypyc/irbuild/ll_builder.py | 7 +-- mypyc/options.py | 83 ++++++++++++++++++++++++++++++-- mypyc/primitives/list_ops.py | 70 ++++++++++++++------------- mypyc/primitives/registry.py | 3 ++ mypyc/test/test_emitclass.py | 11 +++-- mypyc/test/test_emitfunc.py | 14 ++++-- mypyc/test/test_exceptions.py | 16 +++--- mypyc/test/test_irbuild.py | 8 +-- mypyc/test/test_lowering.py | 8 +-- mypyc/test/test_optimizations.py | 16 +++--- mypyc/test/test_refcount.py | 8 +-- mypyc/test/testutil.py | 20 +++++++- 24 files changed, 270 insertions(+), 147 deletions(-) diff --git a/mypyc/__main__.py b/mypyc/__main__.py index 9b3973710efac..0c472db0be3eb 100644 --- a/mypyc/__main__.py +++ b/mypyc/__main__.py @@ -31,11 +31,32 @@ debug_level="{}", strict_dunder_typing={}, log_trace={}, + target_python={!r}, ), ) """ +def extract_target_python(args: list[str]) -> tuple[list[str], str | None]: + """Remove --target-python from args (it's not a mypy option). + + Return (remaining args, target python or None). + """ + remaining = [] + target_python = None + it = iter(args) + for arg in it: + if arg == "--target-python": + target_python = next(it, None) + if target_python is None: + sys.exit("error: --target-python requires an argument (e.g. 3.13 or 3.14t)") + elif arg.startswith("--target-python="): + target_python = arg.split("=", 1)[1] + else: + remaining.append(arg) + return remaining, target_python + + def main() -> None: build_dir = "build" # can this be overridden?? try: @@ -50,11 +71,13 @@ def main() -> None: # mypyc_trace.txt. log_trace = bool(int(os.getenv("MYPYC_LOG_TRACE", "0"))) + args, target_python = extract_target_python(sys.argv[1:]) + setup_file = os.path.join(build_dir, "setup.py") with open(setup_file, "w") as f: f.write( setup_format.format( - sys.argv[1:], opt_level, debug_level, strict_dunder_typing, log_trace + args, opt_level, debug_level, strict_dunder_typing, log_trace, target_python ) ) diff --git a/mypyc/build.py b/mypyc/build.py index 8c6eabead17c9..ba5989255ed8c 100644 --- a/mypyc/build.py +++ b/mypyc/build.py @@ -37,12 +37,12 @@ from mypy.util import write_junit_xml from mypyc.annotate import generate_annotated_html from mypyc.codegen import emitmodule -from mypyc.common import IS_FREE_THREADED, RUNTIME_C_FILES, shared_lib_name +from mypyc.common import RUNTIME_C_FILES, shared_lib_name from mypyc.errors import Errors from mypyc.ir.deps import SourceDep from mypyc.ir.pprint import format_modules from mypyc.namegen import exported_name -from mypyc.options import CompilerOptions +from mypyc.options import CompilerOptions, TargetPython class ModDesc(NamedTuple): @@ -219,6 +219,9 @@ def get_mypy_config( fscache: FileSystemCache | None, ) -> tuple[list[BuildSource], list[BuildSource], Options]: """Construct mypy BuildSources and Options from file and options lists""" + for arg in mypy_options: + if arg == "--python-version" or arg.startswith("--python-version="): + fail("error: mypyc does not accept --python-version") all_sources, options = process_options(mypy_options, fscache=fscache, mypyc=True) if only_compile_paths is not None: paths_set = set(only_compile_paths) @@ -233,8 +236,8 @@ def get_mypy_config( return mypyc_sources, all_sources, options # Override whatever python_version is inferred from the .ini file, - # and set the python_version to be the currently used version. - options.python_version = sys.version_info[:2] + # and set the python_version to be the target version. + options.python_version = compiler_options.target_python.version if options.python_version[0] == 2: fail("Python 2 not supported") @@ -258,7 +261,11 @@ def is_package_source(source: BuildSource) -> bool: def generate_c_extension_shim( - full_module_name: str, module_name: str, dir_name: str, group_name: str + full_module_name: str, + module_name: str, + dir_name: str, + group_name: str, + target_python: TargetPython | None = None, ) -> str: """Create a C extension shim with a passthrough PyInit function. @@ -267,11 +274,13 @@ def generate_c_extension_shim( module_name: the final component of the module name dir_name: the directory to place source code group_name: the name of the group + target_python: the Python build to generate code for (default: running Python) """ + target_python = target_python or TargetPython.host() cname = "%s.c" % full_module_name.replace(".", os.sep) cpath = os.path.join(dir_name, cname) - if IS_FREE_THREADED: + if target_python.free_threaded: # We use multi-phase init in free-threaded builds to enable free threading. shim_name = "module_shim_no_gil_multiphase.tmpl" else: @@ -374,6 +383,7 @@ def build_using_shared_lib( build_dir: str, extra_compile_args: list[str], extra_include_dirs: list[str], + target_python: TargetPython | None = None, ) -> list[Extension]: """Produce the list of extension modules when a shared library is needed. @@ -398,7 +408,9 @@ def build_using_shared_lib( for source in sources: module_name = source.module.split(".")[-1] - shim_file = generate_c_extension_shim(source.module, module_name, build_dir, group_name) + shim_file = generate_c_extension_shim( + source.module, module_name, build_dir, group_name, target_python + ) # We include the __init__ in the "module name" we stick in the Extension, # since this seems to be needed for it to end up in the right place. @@ -893,6 +905,7 @@ def mypycify( depends_on_librt_internal: bool = False, install_librt: bool = False, experimental_features: bool = False, + target_python: str | TargetPython | None = None, ) -> list[Extension]: """Main entry point to building using mypyc. @@ -951,7 +964,16 @@ def mypycify( experimental_features: Enable experimental features (install_librt=True is also needed if using experimental librt features). These have no backward compatibility guarantees! + target_python: The Python build to generate code for, such as "3.13" or "3.14t" + (a "t" suffix means free-threaded). Defaults to the running + Python. The generated C must be compiled against the headers of + the target Python. """ + if isinstance(target_python, str): + try: + target_python = TargetPython.parse(target_python) + except ValueError as e: + fail(f"error: {e}") # Skip redundant inplace .so copies on every build_ext invocation. _patch_setuptools_copy_extensions_to_source() @@ -969,6 +991,7 @@ def mypycify( log_trace=log_trace, depends_on_librt_internal=depends_on_librt_internal, experimental_features=experimental_features, + target_python=target_python, ) # Generate all the actual important C code @@ -1039,6 +1062,7 @@ def mypycify( build_dir, cflags, extra_include_dirs, + compiler_options.target_python, ) ) else: diff --git a/mypyc/codegen/emit.py b/mypyc/codegen/emit.py index e78b3f343b283..270cd6ada1016 100644 --- a/mypyc/codegen/emit.py +++ b/mypyc/codegen/emit.py @@ -2,7 +2,6 @@ from __future__ import annotations -import sys from collections.abc import Callable from typing import Final @@ -12,7 +11,6 @@ ATTR_PREFIX, BITMAP_BITS, FAST_ISINSTANCE_MAX_SUBCLASSES, - HAVE_IMMORTAL, MODULE_PREFIX, NATIVE_PREFIX, PREFIX, @@ -73,6 +71,7 @@ vec_item_type_tags, ) from mypyc.namegen import NameGenerator, exported_name +from mypyc.options import TargetPython from mypyc.primitives.registry import builtin_names from mypyc.sametype import is_same_type @@ -141,6 +140,7 @@ def __init__( strict_traceback_checks: bool, group_name: str | None = None, group_map: dict[str, str | None] | None = None, + target_python: TargetPython | None = None, ) -> None: """Setup shared emitter state. @@ -148,6 +148,7 @@ def __init__( names: The name generator to use group_map: Map from module names to group name group_name: Current group name + target_python: The Python build to generate code for (default: running Python) """ self.temp_counter = 0 self.names = names @@ -168,6 +169,7 @@ def __init__( self.literals = Literals() # See mypyc/options.py for context. self.strict_traceback_checks = strict_traceback_checks + self.target_python = target_python or TargetPython.host() class ErrorHandler: @@ -211,17 +213,20 @@ def __init__( self, context: EmitterContext, value_names: dict[Value, str] | None = None, - capi_version: tuple[int, int] | None = None, filepath: str | None = None, ) -> None: self.context = context - self.capi_version = capi_version or sys.version_info[:2] + self.target_python = context.target_python self.names = context.names self.value_names = value_names or {} self.fragments: list[str] = [] self._indent = 0 self.filepath = filepath + @property + def capi_version(self) -> tuple[int, int]: + return self.target_python.version + # Low-level operations def indent(self) -> None: @@ -598,7 +603,7 @@ def emit_inc_ref(self, dest: str, rtype: RType, *, rare: bool = False) -> None: self.emit_line(f"{prefix}_INCREF({dest});") elif not rtype.is_unboxed: # Always inline, since this is a simple but very hot op - if rtype.may_be_immortal or not HAVE_IMMORTAL: + if rtype.may_be_immortal or not self.target_python.have_immortal: self.emit_line("CPy_INCREF(%s);" % dest) else: self.emit_line("CPy_INCREF_NO_IMM(%s);" % dest) @@ -632,7 +637,7 @@ def emit_dec_ref( self.emit_line(f"CPy_{x}DecRef({dest});") else: # Inlined - if rtype.may_be_immortal or not HAVE_IMMORTAL: + if rtype.may_be_immortal or not self.target_python.have_immortal: self.emit_line(f"CPy_{x}DECREF({dest});") else: self.emit_line(f"CPy_{x}DECREF_NO_IMM({dest});") diff --git a/mypyc/codegen/emitclass.py b/mypyc/codegen/emitclass.py index 0567a8c8af199..df1464ea0775d 100644 --- a/mypyc/codegen/emitclass.py +++ b/mypyc/codegen/emitclass.py @@ -31,7 +31,6 @@ BITMAP_TYPE, CPYFUNCTION_NAME, GENERATOR_HELPER_NAME, - IS_FREE_THREADED, MYPYC_DEFAULTS_SETUP, NATIVE_PREFIX, PREFIX, @@ -1255,7 +1254,7 @@ def generate_getter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N emitter.emit_line("{") attr_expr = f"self->{attr_field}" - if IS_FREE_THREADED and is_simple_refcounted_pointer(rtype): + if emitter.target_python.free_threaded and is_simple_refcounted_pointer(rtype): # In free-threaded builds, load the attribute and take a new reference with # an optimistic validated incref to avoid racing with a concurrent setter. # CPy_GetAttrRef returns NULL if the attribute is undefined (NULL field), @@ -1319,7 +1318,7 @@ def generate_setter(cl: ClassIR, attr: str, rtype: RType, emitter: Emitter) -> N emitter.emit_line("return -1;") emitter.emit_line("}") - if IS_FREE_THREADED and is_simple_refcounted_pointer(rtype): + if emitter.target_python.free_threaded and is_simple_refcounted_pointer(rtype): # In free-threaded builds, publish the new value via CPy_SetAttrRef, which # takes the owner's critical section so a concurrent reader (see # CPy_GetAttrRef) can always secure a reference to the value it observes, @@ -1498,7 +1497,7 @@ def success() -> None: wrapper_name = emit_instance(cl.methods["__call__"], cl.coroutine_name) struct_name = cl.struct_name(emitter.names) attr = emitter.attr(CPYFUNCTION_NAME) - if IS_FREE_THREADED: + if emitter.target_python.free_threaded: emitter.emit_line( f"CPy_InitAttrRefIfNull(type, (PyObject **)&(({struct_name} *)type)->{attr}, " f"{wrapper_name});" diff --git a/mypyc/codegen/emitfunc.py b/mypyc/codegen/emitfunc.py index 8637a2565006d..00734aa4d8a6d 100644 --- a/mypyc/codegen/emitfunc.py +++ b/mypyc/codegen/emitfunc.py @@ -13,8 +13,6 @@ c_array_initializer, ) from mypyc.common import ( - HAVE_IMMORTAL, - IS_FREE_THREADED, NATIVE_PREFIX, REG_PREFIX, RUNNING_FIELD, @@ -466,7 +464,7 @@ def emit_load_attr_take_ref( ClassIR.attrs_are_thread_confined. """ use_get_attr_ref = ( - IS_FREE_THREADED + self.emitter.target_python.free_threaded and is_simple_refcounted_pointer(attr_rtype) and not op.is_borrowed and not cl.attrs_are_thread_confined() @@ -629,7 +627,7 @@ def visit_set_attr(self, op: SetAttr) -> None: self.emit_line(f"{dest} = 1;") self.emitter.emit_error_check(tmp, ret_type, f"{dest} = 0;") elif ( - IS_FREE_THREADED + self.emitter.target_python.free_threaded and is_simple_refcounted_pointer(attr_rtype) and not cl.attrs_are_thread_confined() ): @@ -763,12 +761,12 @@ def visit_inc_ref(self, op: IncRef) -> None: if ( isinstance(op.src, Box) and (is_none_rprimitive(op.src.src.type) or is_bool_or_bit_rprimitive(op.src.src.type)) - and HAVE_IMMORTAL + and self.emitter.target_python.have_immortal ): # On Python 3.12+, None/True/False are immortal, and we can skip inc ref return - if isinstance(op.src, LoadLiteral) and HAVE_IMMORTAL: + if isinstance(op.src, LoadLiteral) and self.emitter.target_python.have_immortal: value = op.src.value # We can skip inc ref for immortal literals on Python 3.12+ if type(value) is int and -5 <= value <= 256: diff --git a/mypyc/codegen/emitmodule.py b/mypyc/codegen/emitmodule.py index 1b21b51709db6..94bca3d003abc 100644 --- a/mypyc/codegen/emitmodule.py +++ b/mypyc/codegen/emitmodule.py @@ -7,7 +7,6 @@ import json import os -import sys from collections.abc import Iterable from typing import TypeVar @@ -48,7 +47,6 @@ from mypyc.common import ( EXT_SUFFIX, GENERATOR_HELPER_NAME, - IS_FREE_THREADED, MODULE_PREFIX, PREFIX, RUNTIME_C_FILES, @@ -651,7 +649,11 @@ def __init__( self.modules = modules self.source_paths = source_paths self.context = EmitterContext( - names, compiler_options.strict_traceback_checks, group_name, group_map + names, + compiler_options.strict_traceback_checks, + group_name, + group_map, + target_python=compiler_options.target_python, ) self.names = names # Initializations of globals to simple values that we can't @@ -663,7 +665,7 @@ def __init__( self.multi_file = compiler_options.multi_file # Multi-phase init is needed to enable free-threading. In the future we'll # probably want to enable it always, but we'll wait until it's stable. - self.multi_phase_init = IS_FREE_THREADED + self.multi_phase_init = compiler_options.target_python.free_threaded @property def group_suffix(self) -> str: @@ -1186,13 +1188,13 @@ def emit_module_def_slots( emitter.emit_line(f"static PyModuleDef_Slot {name}[] = {{") emitter.emit_line(f"{{Py_mod_exec, {exec_name}}},") - if sys.version_info >= (3, 12): + if emitter.capi_version >= (3, 12): # Multiple interpreter support requires not using any C global state, # which we don't support yet. emitter.emit_line( "{Py_mod_multiple_interpreters, Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED}," ) - if sys.version_info >= (3, 13): + if emitter.capi_version >= (3, 13): # Declare support for free-threading to enable experimentation, # even if we don't properly support it. emitter.emit_line("{Py_mod_gil, Py_MOD_GIL_NOT_USED},") diff --git a/mypyc/ir/ops.py b/mypyc/ir/ops.py index febd0764c0837..bde6730e88c79 100644 --- a/mypyc/ir/ops.py +++ b/mypyc/ir/ops.py @@ -718,6 +718,7 @@ def __init__( experimental: bool, dependencies: list[Dependency] | None, type_params: list[RTypeVar] | None, + gil_only: bool = False, ) -> None: # Each primitive much have a distinct name, but otherwise they are arbitrary. self.name: Final = name @@ -743,6 +744,9 @@ def __init__( # Experimental primitives are not used unless mypyc experimental features are # explicitly enabled self.experimental = experimental + # GIL-only primitives are not used when targeting a free-threaded build + # (e.g. because they return a borrowed reference that could be unsafe) + self.gil_only = gil_only # Dependencies for the primitive, such as a capsule that needs to imported # and configured to call the primitive. self.dependencies = dependencies diff --git a/mypyc/ir/rtypes.py b/mypyc/ir/rtypes.py index afe3d3e9df39b..4663d1e88d7fd 100644 --- a/mypyc/ir/rtypes.py +++ b/mypyc/ir/rtypes.py @@ -40,7 +40,7 @@ class to enable the new behavior. In rare cases, adding a new from abc import abstractmethod from typing import TYPE_CHECKING, ClassVar, Final, Generic, TypeGuard, TypeVar, Union, final -from mypyc.common import HAVE_IMMORTAL, IS_32_BIT_PLATFORM, PLATFORM_SIZE, JsonDict, short_name +from mypyc.common import IS_32_BIT_PLATFORM, PLATFORM_SIZE, JsonDict, short_name from mypyc.ir.deps import LIBRT_RANDOM, LIBRT_STRINGS, LIBRT_THREADING, LIBRT_VECS, Dependency from mypyc.namegen import NameGenerator @@ -259,7 +259,7 @@ def __init__( self._ctype = ctype self.size = size self.error_overlap = error_overlap - self._may_be_immortal = may_be_immortal and HAVE_IMMORTAL + self._may_be_immortal = may_be_immortal self.dependencies = dependencies if ctype == "CPyTagged": self.c_undefined = "CPY_INT_TAG" diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index 957cc27044410..dc7c275b1d48d 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -71,7 +71,6 @@ BITMAP_BITS, EXT_SUFFIX, GENERATOR_ATTRIBUTE_PREFIX, - IS_FREE_THREADED, KEEP_ALIVE_SHORT_LIVED, KEEP_ALIVE_WHOLE_EXPRESSION, SELF_NAME, @@ -993,7 +992,7 @@ def process_sequence_assignment( index: Value if is_list_rprimitive(rvalue.type): index = Integer(i, c_pyssize_t_rprimitive) - if not IS_FREE_THREADED: + if not self.options.target_python.free_threaded: item_value = self.primitive_op(list_get_item_unsafe_op, [rvalue, index], line) else: item_value = self.primitive_op(list_get_item_int64_op, [rvalue, index], line) diff --git a/mypyc/irbuild/callable_class.py b/mypyc/irbuild/callable_class.py index 3adda1599a71a..c0022efa379b3 100644 --- a/mypyc/irbuild/callable_class.py +++ b/mypyc/irbuild/callable_class.py @@ -6,13 +6,7 @@ from __future__ import annotations -from mypyc.common import ( - CPYFUNCTION_NAME, - ENV_ATTR_NAME, - IS_FREE_THREADED, - PROPSET_PREFIX, - SELF_NAME, -) +from mypyc.common import CPYFUNCTION_NAME, ENV_ATTR_NAME, PROPSET_PREFIX, SELF_NAME from mypyc.ir.class_ir import ClassIR from mypyc.ir.func_ir import FuncDecl, FuncIR, FuncSignature, RuntimeArg from mypyc.ir.ops import ( @@ -144,7 +138,7 @@ def get_func_wrapper() -> Value: self_reg, CPYFUNCTION_NAME, line, - borrow=not IS_FREE_THREADED, + borrow=not builder.options.target_python.free_threaded, allow_error_value=True, ) ) diff --git a/mypyc/irbuild/expression.py b/mypyc/irbuild/expression.py index 8bc4568e0d477..83181507a257b 100644 --- a/mypyc/irbuild/expression.py +++ b/mypyc/irbuild/expression.py @@ -58,12 +58,7 @@ TypeType, get_proper_type, ) -from mypyc.common import ( - IS_FREE_THREADED, - KEEP_ALIVE_SHORT_LIVED, - KEEP_ALIVE_WHOLE_EXPRESSION, - MAX_SHORT_INT, -) +from mypyc.common import KEEP_ALIVE_SHORT_LIVED, KEEP_ALIVE_WHOLE_EXPRESSION, MAX_SHORT_INT from mypyc.ir.class_ir import ClassIR from mypyc.ir.func_ir import FUNC_CLASSMETHOD, FUNC_STATICMETHOD from mypyc.ir.ops import ( @@ -287,7 +282,9 @@ def transform_member_expr(builder: IRBuilder, expr: MemberExpr) -> Value: # - Native Final attributes are read-only at runtime, so they can never be reassigned. # - Vec-typed attributes require manual synchronization, so we borrow them liberally. can_borrow = builder.is_native_attr_ref(expr) and ( - not IS_FREE_THREADED or isinstance(rtype, RVec) or builder.is_final_native_attr_ref(expr) + not builder.options.target_python.free_threaded + or isinstance(rtype, RVec) + or builder.is_final_native_attr_ref(expr) ) obj = builder.accept(expr.expr, can_borrow=can_borrow) @@ -827,9 +824,9 @@ def transform_index_expr(builder: IRBuilder, expr: IndexExpr) -> Value: base_type = builder.node_type(expr.base) # We can borrow a list item safely only if GIL is enabled. The vec type is optimized for # performance, so we'll do unsafe borrowing. - can_borrow = (is_list_rprimitive(base_type) and not IS_FREE_THREADED) or isinstance( - base_type, RVec - ) + can_borrow = ( + is_list_rprimitive(base_type) and not builder.options.target_python.free_threaded + ) or isinstance(base_type, RVec) can_borrow_base = ( is_list_rprimitive(base_type) or isinstance(base_type, RVec) ) and is_borrow_friendly_expr(builder, index) diff --git a/mypyc/irbuild/for_helpers.py b/mypyc/irbuild/for_helpers.py index f1536fb3111a6..b6904a6537114 100644 --- a/mypyc/irbuild/for_helpers.py +++ b/mypyc/irbuild/for_helpers.py @@ -28,7 +28,7 @@ Var, ) from mypy.types import LiteralType, TupleType, get_proper_type, get_proper_types -from mypyc.common import GENERATOR_HELPER_NAME, IS_FREE_THREADED +from mypyc.common import GENERATOR_HELPER_NAME from mypyc.ir.ops import ( ERR_NEVER, BasicBlock, @@ -868,7 +868,7 @@ def unsafe_index(builder: IRBuilder, target: Value, index: Value, line: int) -> # since we want to use __getitem__ if we don't have an unsafe version, # so we just check manually. if is_list_rprimitive(target.type): - if not IS_FREE_THREADED: + if not builder.options.target_python.free_threaded: return builder.primitive_op(list_get_item_unsafe_op, [target, index], line) else: return builder.primitive_op(list_get_item_int64_op, [target, index], line) diff --git a/mypyc/irbuild/ll_builder.py b/mypyc/irbuild/ll_builder.py index aafdb73fed84e..072816be7141b 100644 --- a/mypyc/irbuild/ll_builder.py +++ b/mypyc/irbuild/ll_builder.py @@ -6,7 +6,6 @@ from __future__ import annotations -import sys from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from typing import Final, TypeGuard, cast @@ -19,7 +18,6 @@ BITMAP_BITS, FAST_ISINSTANCE_MAX_SUBCLASSES, FAST_PREFIX, - IS_FREE_THREADED, KEEP_ALIVE_SHORT_LIVED, MAX_LITERAL_SHORT_INT, MAX_SHORT_INT, @@ -2513,6 +2511,8 @@ def matching_primitive_op( continue if desc.experimental and not self.options.experimental_features: continue + if desc.gil_only and self.options.target_python.free_threaded: + continue if all( # formal is not None and # TODO is_subtype(actual.type, formal, relaxed=not strict) @@ -2835,7 +2835,8 @@ def int_to_float(self, n: Value, line: int) -> Value: def set_immortal_if_free_threaded(self, v: Value, line: int) -> None: """Make an object immortal on free-threaded builds (to avoid contention).""" - if IS_FREE_THREADED and sys.version_info >= (3, 14): + target = self.options.target_python + if target.free_threaded and target.version >= (3, 14): self.primitive_op(set_immortal_op, [v], line) # Internal helpers diff --git a/mypyc/options.py b/mypyc/options.py index 8eb33b2282567..d3003e9abbfa6 100644 --- a/mypyc/options.py +++ b/mypyc/options.py @@ -1,6 +1,64 @@ from __future__ import annotations +import re import sys +from typing import Final + +from mypyc.common import IS_FREE_THREADED + + +class TargetPython: + """The Python build that generated code targets. + + This defaults to the running interpreter, but can be overridden so that + the C code can be generated on a different Python than the one that will + compile and run it. + """ + + def __init__(self, version: tuple[int, int], free_threaded: bool = False) -> None: + # Python (C API) version, such as (3, 13) + self.version: Final = version + # Is this a free-threaded (GIL disabled) build? + self.free_threaded: Final = free_threaded + + @classmethod + def host(cls) -> TargetPython: + """Target the running interpreter.""" + return cls(sys.version_info[:2], IS_FREE_THREADED) + + @classmethod + def parse(cls, target: str) -> TargetPython: + """Parse a target such as "3.13" or "3.14t" ("t" means free-threaded).""" + m = re.fullmatch(r"3\.(\d+)(t?)", target.strip()) + if m is None: + raise ValueError(f'Invalid target Python "{target}" (expected e.g. "3.13" or "3.14t")') + result = cls((3, int(m.group(1))), m.group(2) == "t") + if result.version < (3, 10): + raise ValueError(f'Unsupported target Python "{target}" (3.10 or later is required)') + if result.free_threaded and result.version < (3, 13): + raise ValueError(f'Free-threaded builds require Python 3.13 or later (got "{target}")') + return result + + @property + def have_immortal(self) -> bool: + """Does the target have immortal objects (introduced in 3.12, see PEP 683)?""" + return self.version >= (3, 12) + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, TargetPython) + and self.version == other.version + and self.free_threaded == other.free_threaded + ) + + def __hash__(self) -> int: + return hash((self.version, self.free_threaded)) + + def __str__(self) -> str: + return f"{self.version[0]}.{self.version[1]}{'t' if self.free_threaded else ''}" + + def __repr__(self) -> str: + return f"TargetPython({str(self)!r})" class CompilerOptions: @@ -20,6 +78,7 @@ def __init__( depends_on_librt_internal: bool = False, experimental_features: bool = False, strict_traceback_checks: bool = False, + target_python: TargetPython | None = None, ) -> None: self.strip_asserts = strip_asserts self.multi_file = multi_file @@ -30,11 +89,11 @@ def __init__( self.include_runtime_files = ( include_runtime_files if include_runtime_files is not None else not multi_file ) - # The target Python C API version. Overriding this is mostly - # useful in IR tests, since there's no guarantee that - # binaries are backward compatible even if no recent API - # features are used. - self.capi_version = capi_version or sys.version_info[:2] + # The Python build to generate code for (see TargetPython). The generated + # C must be compiled against the headers of this Python build. + self.target_python = target_python or TargetPython.host() + if capi_version is not None: + self.capi_version = capi_version self.python_version = python_version # Make possible to inline dunder methods in the generated code. # Typically, the convention is the dunder methods can return `NotImplemented` @@ -73,3 +132,17 @@ def __init__( # tests to make sure that no new code which leads to incorrect tracebacks is # added. self.strict_traceback_checks = strict_traceback_checks + + @property + def capi_version(self) -> tuple[int, int]: + """The target Python C API version. + + Overriding only this is mostly useful in IR tests, since there's no + guarantee that binaries are backward compatible even if no recent API + features are used. + """ + return self.target_python.version + + @capi_version.setter + def capi_version(self, version: tuple[int, int]) -> None: + self.target_python = TargetPython(version, self.target_python.free_threaded) diff --git a/mypyc/primitives/list_ops.py b/mypyc/primitives/list_ops.py index 8af7e9e71df7e..f5548e88592f1 100644 --- a/mypyc/primitives/list_ops.py +++ b/mypyc/primitives/list_ops.py @@ -2,7 +2,6 @@ from __future__ import annotations -from mypyc.common import IS_FREE_THREADED from mypyc.ir.ops import ERR_FALSE, ERR_MAGIC, ERR_NEVER from mypyc.ir.rtypes import ( bit_rprimitive, @@ -119,39 +118,42 @@ priority=5, ) -if not IS_FREE_THREADED: - # list[index] that produces a borrowed result - method_op( - name="__getitem__", - arg_types=[list_rprimitive, int_rprimitive], - return_type=object_rprimitive, - c_function_name="CPyList_GetItemBorrow", - error_kind=ERR_MAGIC, - is_borrowed=True, - priority=3, - ) - - # list[index] that produces a borrowed result and index is known to be short - method_op( - name="__getitem__", - arg_types=[list_rprimitive, short_int_rprimitive], - return_type=object_rprimitive, - c_function_name="CPyList_GetItemShortBorrow", - error_kind=ERR_MAGIC, - is_borrowed=True, - priority=4, - ) - - # Version with native int index - method_op( - name="__getitem__", - arg_types=[list_rprimitive, int64_rprimitive], - return_type=object_rprimitive, - c_function_name="CPyList_GetItemInt64Borrow", - is_borrowed=True, - error_kind=ERR_MAGIC, - priority=6, - ) +# list[index] that produces a borrowed result. Borrowed variants are unsafe on +# free-threaded builds (another thread could remove the item and free it). +method_op( + name="__getitem__", + arg_types=[list_rprimitive, int_rprimitive], + return_type=object_rprimitive, + c_function_name="CPyList_GetItemBorrow", + error_kind=ERR_MAGIC, + is_borrowed=True, + gil_only=True, + priority=3, +) + +# list[index] that produces a borrowed result and index is known to be short +method_op( + name="__getitem__", + arg_types=[list_rprimitive, short_int_rprimitive], + return_type=object_rprimitive, + c_function_name="CPyList_GetItemShortBorrow", + error_kind=ERR_MAGIC, + is_borrowed=True, + gil_only=True, + priority=4, +) + +# Version with native int index +method_op( + name="__getitem__", + arg_types=[list_rprimitive, int64_rprimitive], + return_type=object_rprimitive, + c_function_name="CPyList_GetItemInt64Borrow", + is_borrowed=True, + gil_only=True, + error_kind=ERR_MAGIC, + priority=6, +) # This is unsafe because it assumes that the index is a non-negative integer # that is in-bounds for the list. diff --git a/mypyc/primitives/registry.py b/mypyc/primitives/registry.py index 060af0f88020c..51b53596f2b75 100644 --- a/mypyc/primitives/registry.py +++ b/mypyc/primitives/registry.py @@ -108,6 +108,7 @@ def method_op( is_pure: bool = False, experimental: bool = False, dependencies: list[Dependency] | None = None, + gil_only: bool = False, ) -> PrimitiveDescription: """Define a c function call op that replaces a method call. @@ -134,6 +135,7 @@ def method_op( priority: if multiple ops match, the one with the highest priority is picked is_pure: if True, declare that the C function has no side effects, takes immutable arguments, and never raises an exception + gil_only: if True, don't use this op when targeting a free-threaded build """ if extra_int_constants is None: extra_int_constants = [] @@ -153,6 +155,7 @@ def method_op( priority, is_pure=is_pure, experimental=experimental, + gil_only=gil_only, dependencies=dependencies, type_params=None, ) diff --git a/mypyc/test/test_emitclass.py b/mypyc/test/test_emitclass.py index 4e34f35ef464e..a1cd2de3fcc80 100644 --- a/mypyc/test/test_emitclass.py +++ b/mypyc/test/test_emitclass.py @@ -11,10 +11,10 @@ setter_name, slot_key, ) -from mypyc.common import IS_FREE_THREADED from mypyc.ir.class_ir import ClassIR from mypyc.ir.rtypes import int32_rprimitive, object_rprimitive from mypyc.namegen import NameGenerator +from mypyc.options import TargetPython class TestEmitClass(unittest.TestCase): @@ -44,15 +44,16 @@ def test_getter_name(self) -> None: assert getter_name(cls, "down", generator) == "testing___SomeClass_get_down" - @unittest.skipUnless(IS_FREE_THREADED, "requires a free threaded build") def test_free_threaded_ref_attribute_getter_and_setter_use_owner(self) -> None: - # Note: We can't monkey patch IS_FREE_THREADED to test this on a build with - # the GIL enabled, since monkey patching doesn't work if mypyc is compiled. cl = ClassIR("A", "mod") cl.attributes = {"o": object_rprimitive} cl.deletable = ["o"] cl.mro = cl.base_mro = [cl] - emitter = Emitter(EmitterContext(NameGenerator([["mod"]]), True)) + emitter = Emitter( + EmitterContext( + NameGenerator([["mod"]]), True, target_python=TargetPython((3, 14), True) + ) + ) generate_getter(cl, "o", object_rprimitive, emitter) generate_setter(cl, "o", object_rprimitive, emitter) diff --git a/mypyc/test/test_emitfunc.py b/mypyc/test/test_emitfunc.py index b6e8d1330f6fc..089a425166d43 100644 --- a/mypyc/test/test_emitfunc.py +++ b/mypyc/test/test_emitfunc.py @@ -5,7 +5,7 @@ from mypy.test.helpers import assert_string_arrays_equal from mypyc.codegen.emit import Emitter, EmitterContext from mypyc.codegen.emitfunc import FunctionEmitterVisitor, generate_native_function -from mypyc.common import HAVE_IMMORTAL, IS_FREE_THREADED, PLATFORM_SIZE +from mypyc.common import HAVE_IMMORTAL, PLATFORM_SIZE from mypyc.ir.class_ir import ClassIR from mypyc.ir.func_ir import ( FUNC_CLASSMETHOD, @@ -77,6 +77,7 @@ ) from mypyc.irbuild.vtable import compute_vtable from mypyc.namegen import NameGenerator +from mypyc.options import TargetPython from mypyc.primitives.dict_ops import ( dict_get_item_op, dict_new_op, @@ -148,6 +149,11 @@ def add_local(name: str, rtype: RType) -> Register: self.context = EmitterContext(NameGenerator([["mod"]]), True) + def use_free_threaded_target(self) -> None: + self.context = EmitterContext( + NameGenerator([["mod"]]), True, target_python=TargetPython((3, 14), True) + ) + def test_goto(self) -> None: self.assert_emit(Goto(BasicBlock(2)), "goto CPyL2;") @@ -539,10 +545,8 @@ def test_get_attr_non_refcounted(self) -> None: """, ) - # Note: We can't monkey patch IS_FREE_THREADED to test this on a build with the - # GIL enabled, since monkey patching doesn't work if mypyc is compiled. - @unittest.skipUnless(IS_FREE_THREADED, "requires a free threaded build") def test_get_attr_ref_free_threaded(self) -> None: + self.use_free_threaded_target() self.assert_emit( GetAttr(self.r, "o", 1), """\ @@ -604,8 +608,8 @@ def test_set_attr(self) -> None: """, ) - @unittest.skipUnless(IS_FREE_THREADED, "requires a free threaded build") def test_set_attr_ref_free_threaded(self) -> None: + self.use_free_threaded_target() self.assert_emit( SetAttr(self.r, "o", self.o, 1), """\ diff --git a/mypyc/test/test_exceptions.py b/mypyc/test/test_exceptions.py index d842ea1a85eda..9a356dbe25058 100644 --- a/mypyc/test/test_exceptions.py +++ b/mypyc/test/test_exceptions.py @@ -11,13 +11,15 @@ from mypy.test.config import test_temp_dir from mypy.test.data import DataDrivenTestCase from mypyc.analysis.blockfreq import frequently_executed_blocks -from mypyc.common import IS_FREE_THREADED, TOP_LEVEL_NAME +from mypyc.common import TOP_LEVEL_NAME from mypyc.ir.pprint import format_func +from mypyc.options import CompilerOptions, TargetPython from mypyc.test.testutil import ( ICODE_GEN_BUILTINS, MypycDataSuite, assert_test_output, build_ir_for_single_file, + infer_free_threaded_from_test_name, remove_comment_lines, use_custom_builtins, ) @@ -35,17 +37,13 @@ class TestExceptionTransform(MypycDataSuite): def run_case(self, testcase: DataDrivenTestCase) -> None: """Perform a runtime checking transformation test case.""" - if "_withgil" in testcase.name and IS_FREE_THREADED: - # Test case should only run on a non-free-threaded build. - return - if "_nogil" in testcase.name and not IS_FREE_THREADED: - # Test case should only run on a free-threaded build. - return - + options = CompilerOptions( + target_python=TargetPython((3, 10), infer_free_threaded_from_test_name(testcase.name)) + ) with use_custom_builtins(os.path.join(self.data_prefix, ICODE_GEN_BUILTINS), testcase): expected_output = remove_comment_lines(testcase.output) try: - ir = build_ir_for_single_file(testcase.input) + ir = build_ir_for_single_file(testcase.input, options) except CompileError as e: actual = e.messages else: diff --git a/mypyc/test/test_irbuild.py b/mypyc/test/test_irbuild.py index 1484d47ce9554..0dc19b62a60fb 100644 --- a/mypyc/test/test_irbuild.py +++ b/mypyc/test/test_irbuild.py @@ -8,7 +8,7 @@ from mypy.errors import CompileError from mypy.test.config import test_temp_dir from mypy.test.data import DataDrivenTestCase -from mypyc.common import IS_FREE_THREADED, TOP_LEVEL_NAME +from mypyc.common import TOP_LEVEL_NAME from mypyc.ir.pprint import format_func from mypyc.test.testutil import ( ICODE_GEN_BUILTINS, @@ -88,12 +88,6 @@ def run_case(self, testcase: DataDrivenTestCase) -> None: if options is None: # Skipped test case return - if "_withgil" in testcase.name and IS_FREE_THREADED: - # Test case should only run on a non-free-threaded build. - return - if "_nogil" in testcase.name and not IS_FREE_THREADED: - # Test case should only run on a free-threaded build. - return with use_custom_builtins(os.path.join(self.data_prefix, ICODE_GEN_BUILTINS), testcase): expected_output = remove_comment_lines(testcase.output) expected_output = replace_word_size(expected_output) diff --git a/mypyc/test/test_lowering.py b/mypyc/test/test_lowering.py index 75351cd50e4a7..3eb4698c68793 100644 --- a/mypyc/test/test_lowering.py +++ b/mypyc/test/test_lowering.py @@ -7,7 +7,7 @@ from mypy.errors import CompileError from mypy.test.config import test_temp_dir from mypy.test.data import DataDrivenTestCase -from mypyc.common import IS_FREE_THREADED, TOP_LEVEL_NAME +from mypyc.common import TOP_LEVEL_NAME from mypyc.ir.pprint import format_func from mypyc.options import CompilerOptions from mypyc.test.testutil import ( @@ -36,12 +36,6 @@ def run_case(self, testcase: DataDrivenTestCase) -> None: if options is None: # Skipped test case return - if "_withgil" in testcase.name and IS_FREE_THREADED: - # Test case should only run on a non-free-threaded build. - return - if "_nogil" in testcase.name and not IS_FREE_THREADED: - # Test case should only run on a free-threaded build. - return with use_custom_builtins(os.path.join(self.data_prefix, ICODE_GEN_BUILTINS), testcase): expected_output = remove_comment_lines(testcase.output) expected_output = replace_word_size(expected_output) diff --git a/mypyc/test/test_optimizations.py b/mypyc/test/test_optimizations.py index c6a40d08e2e67..24ae9ad5e69fa 100644 --- a/mypyc/test/test_optimizations.py +++ b/mypyc/test/test_optimizations.py @@ -7,15 +7,16 @@ from mypy.errors import CompileError from mypy.test.config import test_temp_dir from mypy.test.data import DataDrivenTestCase -from mypyc.common import IS_FREE_THREADED, TOP_LEVEL_NAME +from mypyc.common import TOP_LEVEL_NAME from mypyc.ir.func_ir import FuncIR from mypyc.ir.pprint import format_func -from mypyc.options import CompilerOptions +from mypyc.options import CompilerOptions, TargetPython from mypyc.test.testutil import ( ICODE_GEN_BUILTINS, MypycDataSuite, assert_test_output, build_ir_for_single_file, + infer_free_threaded_from_test_name, remove_comment_lines, use_custom_builtins, ) @@ -33,16 +34,13 @@ class OptimizationSuite(MypycDataSuite): base_path = test_temp_dir def run_case(self, testcase: DataDrivenTestCase) -> None: - if "_withgil" in testcase.name and IS_FREE_THREADED: - # Test case should only run on a non-free-threaded build. - return - if "_nogil" in testcase.name and not IS_FREE_THREADED: - # Test case should only run on a free-threaded build. - return + options = CompilerOptions( + target_python=TargetPython((3, 10), infer_free_threaded_from_test_name(testcase.name)) + ) with use_custom_builtins(os.path.join(self.data_prefix, ICODE_GEN_BUILTINS), testcase): expected_output = remove_comment_lines(testcase.output) try: - ir = build_ir_for_single_file(testcase.input) + ir = build_ir_for_single_file(testcase.input, options) except CompileError as e: actual = e.messages else: diff --git a/mypyc/test/test_refcount.py b/mypyc/test/test_refcount.py index 896ba14e2e68b..7efa861e784fb 100644 --- a/mypyc/test/test_refcount.py +++ b/mypyc/test/test_refcount.py @@ -11,7 +11,7 @@ from mypy.errors import CompileError from mypy.test.config import test_temp_dir from mypy.test.data import DataDrivenTestCase -from mypyc.common import IS_FREE_THREADED, TOP_LEVEL_NAME +from mypyc.common import TOP_LEVEL_NAME from mypyc.ir.pprint import format_func from mypyc.test.testutil import ( ICODE_GEN_BUILTINS, @@ -40,12 +40,6 @@ def run_case(self, testcase: DataDrivenTestCase) -> None: if options is None: # Skipped test case return - if "_withgil" in testcase.name and IS_FREE_THREADED: - # Test case should only run on a non-free-threaded build. - return - if "_nogil" in testcase.name and not IS_FREE_THREADED: - # Test case should only run on a free-threaded build. - return with use_custom_builtins(os.path.join(self.data_prefix, ICODE_GEN_BUILTINS), testcase): expected_output = remove_comment_lines(testcase.output) expected_output = replace_word_size(expected_output) diff --git a/mypyc/test/testutil.py b/mypyc/test/testutil.py index 2d139ace7c8e9..c14ed22493135 100644 --- a/mypyc/test/testutil.py +++ b/mypyc/test/testutil.py @@ -19,13 +19,13 @@ from mypy.test.helpers import assert_string_arrays_equal from mypy.types import Type from mypyc.analysis.ircheck import assert_func_ir_valid -from mypyc.common import IS_32_BIT_PLATFORM, PLATFORM_SIZE +from mypyc.common import IS_32_BIT_PLATFORM, IS_FREE_THREADED, PLATFORM_SIZE from mypyc.errors import Errors from mypyc.ir.func_ir import FuncIR from mypyc.ir.module_ir import ModuleIR from mypyc.irbuild.main import build_ir from mypyc.irbuild.mapper import Mapper -from mypyc.options import CompilerOptions +from mypyc.options import CompilerOptions, TargetPython from mypyc.test.config import test_data_prefix # The builtins stub used during icode generation test cases. @@ -301,11 +301,27 @@ def infer_ir_build_options_from_test_name(name: str) -> CompilerOptions | None: options.python_version = options.capi_version elif "_py" in name or "_Python" in name: assert False, f"Invalid _py* suffix (should be _pythonX_Y): {name}" + options.target_python = TargetPython( + options.capi_version, infer_free_threaded_from_test_name(name) + ) if has_test_name_tag(name, "experimental"): options.experimental_features = True return options +def infer_free_threaded_from_test_name(name: str) -> bool: + """Should a test case target a free-threaded build? + + A _nogil suffix targets a free-threaded build and _withgil targets a build + with the GIL. Otherwise, match the running interpreter. + """ + if "_nogil" in name: + return True + if "_withgil" in name: + return False + return IS_FREE_THREADED + + def has_test_name_tag(name: str, tag: str) -> bool: """Check if a test case name contains a tag token like ``_experimental``. From 07a3efa9329b900afc47aa94fe1d815234c1661a Mon Sep 17 00:00:00 2001 From: Jared Hance Date: Wed, 30 Sep 2026 17:01:14 +0000 Subject: [PATCH 2/3] update --- mypyc/codegen/emitmodule.py | 6 +++--- mypyc/irbuild/builder.py | 3 +-- mypyc/options.py | 17 ++++++++++++++++- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/mypyc/codegen/emitmodule.py b/mypyc/codegen/emitmodule.py index 94bca3d003abc..b9fee22a7ac5a 100644 --- a/mypyc/codegen/emitmodule.py +++ b/mypyc/codegen/emitmodule.py @@ -45,7 +45,6 @@ ) from mypyc.codegen.literals import Literals from mypyc.common import ( - EXT_SUFFIX, GENERATOR_HELPER_NAME, MODULE_PREFIX, PREFIX, @@ -1467,6 +1466,7 @@ def emit_module_init_func( emitter.emit_line(f'modname = PyUnicode_FromString("{module_name}");') emitter.emit_line("if (modname == NULL) CPyError_OutOfMemory();") emitter.emit_line("int rv = 0;") + ext_suffix = emitter.target_python.ext_suffix if self.group_name: shared_lib_mod_name = shared_lib_name(self.group_name) emitter.emit_line("PyObject *mod_dict = PyImport_GetModuleDict();") @@ -1481,10 +1481,10 @@ def emit_module_init_func( emitter.emit_line("if (shared_lib_file == NULL) goto fail;") else: emitter.emit_line( - f'PyObject *shared_lib_file = PyUnicode_FromString("{module_name + EXT_SUFFIX}");' + f'PyObject *shared_lib_file = PyUnicode_FromString("{module_name + ext_suffix}");' ) emitter.emit_line("if (shared_lib_file == NULL) CPyError_OutOfMemory();") - emitter.emit_line(f'PyObject *ext_suffix = PyUnicode_FromString("{EXT_SUFFIX}");') + emitter.emit_line(f'PyObject *ext_suffix = PyUnicode_FromString("{ext_suffix}");') emitter.emit_line("if (ext_suffix == NULL) CPyError_OutOfMemory();") is_pkg = int(self.source_paths[module_name].endswith("__init__.py")) emitter.emit_line(f"Py_ssize_t is_pkg = {is_pkg};") diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index dc7c275b1d48d..37be72c66421f 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -69,7 +69,6 @@ from mypy.visitor import ExpressionVisitor, StatementVisitor from mypyc.common import ( BITMAP_BITS, - EXT_SUFFIX, GENERATOR_ATTRIBUTE_PREFIX, KEEP_ALIVE_SHORT_LIVED, KEEP_ALIVE_WHOLE_EXPRESSION, @@ -588,7 +587,7 @@ def gen_import(self, module: str, line: int) -> Value | None: shared_lib_file = self.py_get_attr(shared_lib_obj, "__file__", line) else: shared_lib_file = self.none_object(line) - ext_suffix = self.load_str(EXT_SUFFIX, line) + ext_suffix = self.load_str(self.options.target_python.ext_suffix, line) is_pkg = self.is_package_module(module) value = self.call_c( native_import_op, diff --git a/mypyc/options.py b/mypyc/options.py index d3003e9abbfa6..10cf2d8208385 100644 --- a/mypyc/options.py +++ b/mypyc/options.py @@ -4,7 +4,7 @@ import sys from typing import Final -from mypyc.common import IS_FREE_THREADED +from mypyc.common import EXT_SUFFIX, IS_FREE_THREADED class TargetPython: @@ -44,6 +44,15 @@ def have_immortal(self) -> bool: """Does the target have immortal objects (introduced in 3.12, see PEP 683)?""" return self.version >= (3, 12) + @property + def ext_suffix(self) -> str: + """File name suffix of extension modules, e.g. ".cpython-314t-x86_64-linux-gnu.so". + + This is the running interpreter's suffix with the version tag replaced, since + only the Python version (not the platform) can differ from the host. + """ + return replace_ext_suffix_version(EXT_SUFFIX, self) + def __eq__(self, other: object) -> bool: return ( isinstance(other, TargetPython) @@ -61,6 +70,12 @@ def __repr__(self) -> str: return f"TargetPython({str(self)!r})" +def replace_ext_suffix_version(suffix: str, target: TargetPython) -> str: + """Replace the Python version tag in an extension suffix such as ".cp313-win_amd64.pyd".""" + tag = f"{target.version[0]}{target.version[1]}{'t' if target.free_threaded else ''}" + return re.sub(r"(\.cpython-|\.cp)\d+t?(?=-)", rf"\g<1>{tag}", suffix, count=1) + + class CompilerOptions: def __init__( self, From 9f7494c078e9e9ae191ff4a10eaa67fe8b58fb0c Mon Sep 17 00:00:00 2001 From: Jared Hance Date: Wed, 30 Sep 2026 17:06:47 +0000 Subject: [PATCH 3/3] git add --- mypyc/test/test_target_python.py | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 mypyc/test/test_target_python.py diff --git a/mypyc/test/test_target_python.py b/mypyc/test/test_target_python.py new file mode 100644 index 0000000000000..0b80d9d1ecf33 --- /dev/null +++ b/mypyc/test/test_target_python.py @@ -0,0 +1,88 @@ +"""Tests for targeting a Python build other than the running one.""" + +from __future__ import annotations + +import os +import tempfile +import unittest + +from mypyc.__main__ import extract_target_python +from mypyc.build import generate_c_extension_shim, get_mypy_config +from mypyc.codegen.emit import Emitter, EmitterContext +from mypyc.codegen.emitmodule import GroupGenerator +from mypyc.common import EXT_SUFFIX +from mypyc.namegen import NameGenerator +from mypyc.options import CompilerOptions, TargetPython, replace_ext_suffix_version + + +class TestTargetPython(unittest.TestCase): + def test_parse(self) -> None: + assert TargetPython.parse("3.13") == TargetPython((3, 13), False) + assert TargetPython.parse("3.14t") == TargetPython((3, 14), True) + assert TargetPython.parse("3.10") == TargetPython((3, 10), False) + + def test_parse_invalid(self) -> None: + for target in ["3", "3.13x", "313", "2.7", "4.0", "3.9", "3.12t", "t"]: + with self.assertRaises(ValueError, msg=target): + TargetPython.parse(target) + + def test_str(self) -> None: + assert str(TargetPython((3, 13), False)) == "3.13" + assert str(TargetPython((3, 14), True)) == "3.14t" + + def test_have_immortal(self) -> None: + assert not TargetPython((3, 11)).have_immortal + assert TargetPython((3, 12)).have_immortal + + def test_ext_suffix(self) -> None: + assert TargetPython.host().ext_suffix == EXT_SUFFIX + for host, target, expected in [ + (".cpython-313-x86_64-linux-gnu.so", "3.14t", ".cpython-314t-x86_64-linux-gnu.so"), + (".cpython-314t-darwin.so", "3.13", ".cpython-313-darwin.so"), + (".cp313-win_amd64.pyd", "3.14t", ".cp314t-win_amd64.pyd"), + ("_d.cp313t-win_amd64.pyd", "3.12", "_d.cp312-win_amd64.pyd"), + ]: + assert replace_ext_suffix_version(host, TargetPython.parse(target)) == expected + + def test_compiler_options(self) -> None: + options = CompilerOptions(target_python=TargetPython((3, 14), True)) + assert options.capi_version == (3, 14) + # Overriding only the C API version preserves free-threading + options.capi_version = (3, 13) + assert options.target_python == TargetPython((3, 13), True) + options = CompilerOptions(capi_version=(3, 12), target_python=TargetPython((3, 14), True)) + assert options.target_python == TargetPython((3, 12), True) + + def test_extract_target_python(self) -> None: + assert extract_target_python(["a.py", "--strict"]) == (["a.py", "--strict"], None) + assert extract_target_python(["--target-python", "3.14t", "a.py"]) == (["a.py"], "3.14t") + assert extract_target_python(["a.py", "--target-python=3.13"]) == (["a.py"], "3.13") + + def test_module_def_slots(self) -> None: + def slots(target: TargetPython) -> str: + context = EmitterContext(NameGenerator([["mod"]]), True, target_python=target) + emitter = Emitter(context) + generator = GroupGenerator({}, {}, None, {}, context.names, CompilerOptions()) + generator.emit_module_def_slots(emitter, "prefix", "mod") + return "".join(emitter.fragments) + + assert "Py_mod_gil" not in slots(TargetPython((3, 12))) + assert "Py_mod_multiple_interpreters" in slots(TargetPython((3, 12))) + assert "Py_mod_gil" in slots(TargetPython((3, 13))) + + def test_extension_shim(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + for free_threaded in (False, True): + path = generate_c_extension_shim( + "pkg.mod", "mod", tmp, "group", TargetPython((3, 14), free_threaded) + ) + with open(path) as f: + shim = f.read() + assert ("PyModuleDef_Init" in shim) == free_threaded, shim + os.remove(path) + + def test_python_version_rejected(self) -> None: + for args in (["--python-version", "3.13", "a.py"], ["--python-version=3.13", "a.py"]): + with self.assertRaises(SystemExit) as cm: + get_mypy_config(args, None, CompilerOptions(), None) + assert str(cm.exception.code) == "error: mypyc does not accept --python-version"