diff --git a/README.md b/README.md index aaf5757..b64bd5c 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ See `src/torchada/_mapping.py` for the complete mapping table (380+ mappings). ``` # pyproject.toml or requirements.txt -torchada>=0.1.69 +torchada>=0.1.70 ``` ### Step 2: Conditional Import diff --git a/README_CN.md b/README_CN.md index 1055358..7cbb646 100644 --- a/README_CN.md +++ b/README_CN.md @@ -293,7 +293,7 @@ if torchada.is_gpu_device(device): # 在 CUDA 和 MUSA 上都能工作 ``` # pyproject.toml 或 requirements.txt -torchada>=0.1.69 +torchada>=0.1.70 ``` ### 步骤 2:条件导入 diff --git a/pyproject.toml b/pyproject.toml index 7602e96..c04dd82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "torchada" -version = "0.1.69" +version = "0.1.70" description = "Adapter package for torch_musa to act exactly like PyTorch CUDA" readme = "README.md" license = {text = "MIT"} diff --git a/src/torchada/__init__.py b/src/torchada/__init__.py index 36088e6..59cd793 100644 --- a/src/torchada/__init__.py +++ b/src/torchada/__init__.py @@ -24,7 +24,7 @@ from torch.utils.cpp_extension import CUDAExtension, BuildExtension, CUDA_HOME """ -__version__ = "0.1.69" +__version__ = "0.1.70" from . import cuda, utils diff --git a/src/torchada/_mapping.py b/src/torchada/_mapping.py index 95d8327..f6fe01c 100644 --- a/src/torchada/_mapping.py +++ b/src/torchada/_mapping.py @@ -2,11 +2,14 @@ from ._mappings import MAPPING_RULE as _MAPPING_RULE # noqa: F401 -# Extension file suffix mappings: convert .cu/.cuh to .mu/.muh so torch_musa's -# musa_compile rule (which only adds -x musa for .mu/.muh) treats them correctly. +# Keep source extensions unchanged during porting. Sources are ported in place +# (no _musa mirror), and .cu/.cuh compile as MUSA via the patched +# _is_musa_file (which selects the -x musa rule), so renaming to .mu/.muh is +# unnecessary -- and avoiding it removes any need for include/source-path +# rewriting. EXT_REPLACED_MAPPING = { - 'cuh': 'muh', - 'cu': 'mu', + 'cuh': 'cuh', + 'cu': 'cu', 'cc': 'cc', 'cpp': 'cpp', 'cxx': 'cxx', diff --git a/src/torchada/_mappings/cuda_runtime.py b/src/torchada/_mappings/cuda_runtime.py index 7f5b757..ee176cb 100644 --- a/src/torchada/_mappings/cuda_runtime.py +++ b/src/torchada/_mappings/cuda_runtime.py @@ -1,8 +1,6 @@ """cuda_runtime CUDA->MUSA porting rules.""" MAPPING = { - '.cuh"': '.muh"', - '.cuh>': '.muh>', 'cudaMalloc': 'musaMalloc', 'cudaFree': 'musaFree', 'cudaMemcpy': 'musaMemcpy', diff --git a/src/torchada/_mappings/data_types.py b/src/torchada/_mappings/data_types.py index 42837f2..3a9dc1a 100644 --- a/src/torchada/_mappings/data_types.py +++ b/src/torchada/_mappings/data_types.py @@ -30,4 +30,11 @@ 'CUDA_R_8F_E5M2': 'MUSA_R_8F_E5M2', 'cuda_fp16.h': 'musa_fp16.h', 'cuda_bf16.h': 'musa_bf16.h', + # torch_musa's torch::headeronly snapshot omits these scalar-type headers; + # the c10 variants carry the same definitions. Only the dtype headers move + # (Exception.h / ScalarType.h under torch/headeronly resolve on torch_musa). + '#include ': '#include ', + '#include ': '#include ', + '#include ': '#include ', + '#include ': '#include ', } diff --git a/src/torchada/utils/cpp_extension.py b/src/torchada/utils/cpp_extension.py index eb07254..06aa51a 100644 --- a/src/torchada/utils/cpp_extension.py +++ b/src/torchada/utils/cpp_extension.py @@ -25,9 +25,12 @@ ) """ +import functools +import inspect import logging import os import re +import tempfile from typing import Any, Dict, List, Optional, Tuple from .._mapping import _MAPPING_RULE, EXT_REPLACED_MAPPING @@ -44,6 +47,41 @@ # _ensure_stable_headers_patched. _stable_headers_patched = False +# CUDA/C++ translation units and headers whose CONTENT is CUDA→MUSA ported in +# place. SimplePorting.run() walks every file in a directory; restricting the +# substitution to these extensions keeps in-place porting from rewriting build +# scripts, templates, docs and configs (.py / .jinja / .cmake / .md / .json / +# .gitignore ...) onto themselves. See _patch_simple_porting_modify_file. +_PORTABLE_SOURCE_EXTS = ( + ".cu", + ".cuh", + ".cc", + ".cpp", + ".cxx", + ".c", + ".h", + ".hpp", + ".hh", + ".hxx", + ".inl", + ".inc", + ".ipp", + ".tpp", + ".txx", + ".ixx", + ".cppm", +) + +# MUSA-native sources are already MUSA and must NOT be CUDA→MUSA-substituted: +# the mapping would corrupt valid MUSA constructs (e.g. the asm-volatile +# neutralization rule, which disables un-assemblable CUDA PTX, would disable a +# hand-written MUSA inline-asm block and leave its output uninitialized). They +# compile as-is and are left byte-identical. +_MUSA_NATIVE_EXTS = ( + ".mu", + ".muh", +) + def _get_cuda_home() -> Optional[str]: """ @@ -104,6 +142,105 @@ def _is_musa_file(path: str) -> bool: return ext in [".cu", ".cuh", ".mu", ".muh"] +def _with_explicit_musa_language(flags): + """Return MUSA compiler flags that force filename-independent parsing.""" + result = list(flags or []) + for index, flag in enumerate(result): + if flag == "-x=musa" or flag == "-xmusa": + return result + if flag == "-x" and index + 1 < len(result) and result[index + 1] == "musa": + return result + # Keep this as the final pre-source language option so it overrides any + # earlier ``-x`` supplied by generic CUDA-oriented build configuration. + return [*result, "-x", "musa"] + + +def _patch_musa_ninja_language(musa_ext): + """Force ``mcc`` to parse identity-named ``.cu`` files as MUSA. + + torch_musa chooses its ``musa_compile`` Ninja rule via ``_is_musa_file``, + but that rule historically relied on the ``.mu`` suffix to select the MUSA + language. In-place porting deliberately keeps ``.cu`` names, so inject the + explicit language flag into ``musa_cflags`` (which appears before the input + path) for both setuptools and JIT extension builds. + """ + original = getattr(musa_ext, "_write_ninja_file", None) + if original is None or getattr(original, "_torchada_explicit_musa_language", False): + return + + parameters = list(inspect.signature(original).parameters) + if "musa_cflags" not in parameters: + return + musa_cflags_index = parameters.index("musa_cflags") + + @functools.wraps(original) + def patched_write_ninja_file(*args, **kwargs): + args = list(args) + if "musa_cflags" in kwargs: + kwargs["musa_cflags"] = _with_explicit_musa_language(kwargs["musa_cflags"]) + elif musa_cflags_index < len(args): + args[musa_cflags_index] = _with_explicit_musa_language(args[musa_cflags_index]) + else: + kwargs["musa_cflags"] = _with_explicit_musa_language(None) + return original(*args, **kwargs) + + patched_write_ninja_file._torchada_explicit_musa_language = True + musa_ext._write_ninja_file = patched_write_ninja_file + + +def _path_is_within(path: str, root: str) -> bool: + """Return whether ``path`` resolves to ``root`` or one of its descendants.""" + try: + return os.path.commonpath( + (os.path.realpath(path), os.path.realpath(root)) + ) == os.path.realpath(root) + except ValueError: + return False + + +def _coalesce_port_roots(paths): + """Canonicalize roots and retain only their shallowest non-overlapping set.""" + roots = sorted( + {os.path.realpath(os.path.abspath(path)) for path in paths}, + key=lambda path: (len(path.split(os.sep)), path), + ) + result = [] + for root in roots: + if not any(_path_is_within(root, ancestor) for ancestor in result): + result.append(root) + return result + + +def _validate_portable_symlinks(source_dir: str) -> None: + """Reject portable symlink files before upstream ``realpath`` can escape. + + SimplePorting resolves each filename before writing. Under in-place porting + that would modify a symlink target, potentially outside the project tree. + Failing explicitly preserves both the link and its target. + """ + for root, _dirs, files in os.walk(source_dir): + for name in files: + path = os.path.join(root, name) + if os.path.islink(path) and os.path.splitext(name)[1].lower() in _PORTABLE_SOURCE_EXTS: + raise RuntimeError(f"Refusing to port symlinked CUDA/C++ source in place: {path}") + + +def _create_in_place_porter(musa_sp, source_dir: str, mapping_rule): + """Construct SimplePorting without exposing ``_musa`` to its init. + + Upstream initialization unconditionally removes and recreates the computed + mirror directory. Seed it with a disposable directory, then redirect both + input and output to the real source only after construction has completed. + """ + with tempfile.TemporaryDirectory(prefix="torchada-port-init-") as temp_dir: + seed_dir = os.path.join(temp_dir, "source") + os.makedirs(seed_dir) + porter = musa_sp.SimplePorting(cuda_dir_path=seed_dir, mapping_rule=mapping_rule) + porter.cuda_dir_path = source_dir + porter.musa_dir_path = source_dir + return porter + + def _patch_simple_porting_load_replaced_mapping(musa_sp): """ Patch SimplePorting.load_replaced_mapping to suppress unwanted print output. @@ -154,6 +291,73 @@ def open_with_surrogateescape(file, mode="r", *args, **kwargs): musa_sp.open = open_with_surrogateescape +def _patch_simple_porting_modify_file(musa_sp): + """Make ``SimplePorting.modify_file`` (a) only rewrite compiled C/C++/CUDA + files and (b) read the whole source before writing, so porting a file **in + place** (destination path == source path) is safe. + + Two problems with the stock method under in-place porting: + + 1. ``SimplePorting.run`` walks *every* file in the tree and calls + ``modify_file`` on each, regardless of extension. In the legacy + ``_musa`` mirror mode that only wrote CUDA→MUSA-substituted copies + into the throwaway mirror. In place (dst == src) it would rewrite build + scripts, Jinja templates, docs and configs (``.py`` / ``.jinja`` / + ``.md`` / ``.gitignore`` ...) onto themselves, corrupting committed + tooling (e.g. a codegen ``generate.py`` gets ``cutlass``→``mutlass`` + applied to its imports). So the content substitution is gated to the + CUDA/C++ translation-unit / header extensions in + ``_PORTABLE_SOURCE_EXTS``; any other file is left byte-for-byte + untouched (or copied verbatim if a distinct mirror destination is still + in use). MUSA-native ``.mu``/``.muh`` sources (``_MUSA_NATIVE_EXTS``) are + likewise left untouched: they are already MUSA, so substitution only + corrupts them (e.g. the asm-volatile neutralization rule disables a + hand-written MUSA inline-asm block, leaving its result uninitialized). + + 2. The stock method opens the destination with ``"w"`` (truncating) while + the source handle is open, zeroing the file when src == dst. Reading all + lines up front before opening the destination makes the in-place write + safe. + """ + + def modify_file(self, cuda_filepath, musa_filepath): + ext = os.path.splitext(cuda_filepath)[1].lower() + in_place = os.path.realpath(self.cuda_dir_path) == os.path.realpath(self.musa_dir_path) + if ext in _MUSA_NATIVE_EXTS or ext not in _PORTABLE_SOURCE_EXTS: + # Leave byte-identical: either MUSA-native (.mu/.muh — already MUSA, + # substitution would corrupt it) or not a compiled source at all + # (.py/.jinja/.md/...). In place (dst == src) leave it alone; for a + # distinct destination (legacy mirror mode) copy it verbatim so the + # mirror stays complete. + # SimplePorting.change_filename turns a dotless name such as + # ``Makefile`` into ``.Makefile``. When the roots are in-place, do + # not copy to that synthetic path (or overwrite a real hidden file). + if not in_place and os.path.abspath(cuda_filepath) != os.path.abspath(musa_filepath): + import shutil + + shutil.copyfile(cuda_filepath, musa_filepath) + return + if in_place and not _path_is_within(cuda_filepath, self.cuda_dir_path): + raise RuntimeError( + f"Refusing to port CUDA/C++ source outside the in-place root: {cuda_filepath}" + ) + with open(cuda_filepath, encoding="utf-8", errors="surrogateescape") as f: + lines = f.readlines() + out = [] + for line in lines: + if line.startswith("*") or line.startswith("/") or line == "": + out.append(line) + continue + for k, v in self.mapping_rule: + if "cub/" not in line: + line = line.replace(k, v) + out.append(line) + with open(musa_filepath, "w", encoding="utf-8", errors="surrogateescape") as f_musa: + f_musa.writelines(out) + + musa_sp.SimplePorting.modify_file = modify_file + + # Anchor for the accessor injection: the ``numel()`` definition that stock # torch_musa 2.9 already ships. The backported block is spliced in just before # it. ``mutable_data_ptr`` is the idempotency sentinel — absent in stock @@ -211,8 +415,7 @@ def open_with_surrogateescape(file, mode="r", *args, **kwargs): # keeps already-inline / template / comment lines untouched, which makes the # inline rewrite idempotent. _TENSOR_INL_DEF_RE = re.compile( - r"^(?!\s*(?:inline|template|//|\*))" - r"([A-Za-z_][\w:<>,\s\*&]*?\bTensor::[A-Za-z_]\w*\s*\()" + r"^(?!\s*(?:inline|template|//|\*))" r"([A-Za-z_][\w:<>,\s\*&]*?\bTensor::[A-Za-z_]\w*\s*\()" ) @@ -290,8 +493,14 @@ def _patch_torch_musa_stable_headers() -> None: try: import torch_musa - roots.append(os.path.join(os.path.dirname(torch_musa.__file__), - "share", "generated_cuda_compatible", "include")) + roots.append( + os.path.join( + os.path.dirname(torch_musa.__file__), + "share", + "generated_cuda_compatible", + "include", + ) + ) except ImportError: pass @@ -313,7 +522,9 @@ def _patch_torch_musa_stable_headers() -> None: logger.warning( "torchada: could not backport torch::stable::Tensor " "accessors into %s (numel() anchor not found); " - "libtorch-stable kernels may fail to compile", ts) + "libtorch-stable kernels may fail to compile", + ts, + ) if os.path.exists(ti): with open(ti, encoding="utf-8") as f: contents = f.read() @@ -355,11 +566,16 @@ def _apply_musa_patches(): Apply patches to torch_musa modules for CUDA compatibility. This function patches: - 1. musa_ext._is_musa_file - to recognize .cu/.cuh files - 2. musa_sp.EXT_REPLACED_MAPPING - to convert .cu/.cuh to .mu/.muh - 3. musa_sp._MAPPING_RULE - to apply CUDA->MUSA symbol mapping - - These patches are required to compile .cu files on MUSA platform. + 1. musa_ext._is_musa_file - to recognize .cu/.cuh files as MUSA sources + 2. musa_ext._write_ninja_file - to pass ``-x musa`` before identity-named + .cu inputs instead of relying on the legacy .mu suffix + 3. musa_sp.EXT_REPLACED_MAPPING - an identity map so porting keeps the + original .cu/.cuh names (no .mu/.muh rename), so it can run in place + 4. musa_sp._MAPPING_RULE - to apply CUDA->MUSA symbol mapping + 5. musa_sp.SimplePorting.modify_file - restrict content substitution to + compiled sources/headers and make in-place (dst == src) writes safe + + These patches are required to compile .cu files in place on MUSA platform. """ global _musa_patches_applied @@ -375,8 +591,10 @@ def _apply_musa_patches(): # Patch _is_musa_file to recognize .cu/.cuh files musa_ext._is_musa_file = _is_musa_file + _patch_musa_ninja_language(musa_ext) - # Patch EXT_REPLACED_MAPPING to convert .cu/.cuh to .mu/.muh + # Patch EXT_REPLACED_MAPPING to an identity map: porting keeps the + # original .cu/.cuh names so it runs in place (no .mu/.muh rename) musa_sp.EXT_REPLACED_MAPPING = EXT_REPLACED_MAPPING # Patch _MAPPING_RULE with our comprehensive CUDA->MUSA mappings @@ -390,6 +608,8 @@ def _apply_musa_patches(): # Patch simple_porting.open to tolerate non-UTF-8 source files # This preserves SimplePorting's original logic while allowing undecodable bytes to round-trip _patch_simple_porting_open(musa_sp) + # Patch modify_file to read-all-before-write so in-place porting (dst == src) is safe. + _patch_simple_porting_modify_file(musa_sp) # NOTE: the libtorch-stable header backport is intentionally NOT applied # here. It writes into the torch / torch_musa site-packages headers, so @@ -683,7 +903,8 @@ def _create_musa_extension(name: str, sources: List[str], *args, **kwargs): The patches applied by _apply_musa_patches() make MUSAExtension accept .cu/.cuh files directly by: 1. Patching musa_ext._is_musa_file to recognize .cu/.cuh as valid MUSA files - 2. Patching musa_sp.EXT_REPLACED_MAPPING to convert .cu/.cuh to .mu/.muh + 2. Patching musa_sp.EXT_REPLACED_MAPPING to an identity map so .cu/.cuh keep + their names (no .mu/.muh rename) 3. Patching musa_sp._MAPPING_RULE to convert CUDA symbols to MUSA in source code 4. Translating 'nvcc' compile args key to 'mcc' for MUSA compiler """ @@ -713,12 +934,14 @@ def _get_build_extension_class(): Get the BuildExtension class for the current platform. On MUSA platform, returns a custom class that: - 1. Uses SimplePorting to convert CUDA sources to MUSA in run() (like torch's HIPIFY) + 1. Uses SimplePorting to convert CUDA sources to MUSA in place in run() 2. Registers .cu/.cuh as valid source extensions in build_extensions() 3. Provides extensible mapping rules via get_mapping_rule() method - The porting process is automatic and transparent - developers use csrc/*.cu paths - and the build system handles conversion to csrc_musa/*.cu internally. + The porting is automatic and transparent: developers list csrc/*.cu source + paths and the build ports each project-local include root in place (no + _musa mirror, no .cu->.mu rename), so original #include paths and + source paths resolve as-is and nothing downstream needs rewriting. """ platform = detect_platform() @@ -748,10 +971,12 @@ class _MUSABuildExtension(musa_ext.BuildExtension): """ Custom BuildExtension that handles CUDA->MUSA source porting. - This class works like torch's HIPIFY for ROCm: - - run(): Automatically ports CUDA sources to MUSA using SimplePorting - - build_extensions(): Registers .cu/.cuh as valid extensions - - get_mapping_rule(): Returns mapping rules (override in subclass to extend) + - run(): ports each project-local include root's CUDA sources to + MUSA in place (no _musa mirror, no .cu->.mu rename), so + original includes and source paths resolve as-is without + per-file rewriting and no header is reachable through two trees + - build_extensions(): registers .cu/.cuh as valid extensions + - get_mapping_rule(): returns mapping rules (override to extend) Subclasses can override get_mapping_rule() to add project-specific mappings: @@ -764,7 +989,6 @@ def get_mapping_rule(self): } """ - # Track directories that have been ported (class-level for persistence) _ported_dirs = set() def get_mapping_rule(self): @@ -788,172 +1012,93 @@ def build_extensions(self): super().build_extensions() def _port_directory(self, source_dir, mapping_rule=None): - """ - Port a directory containing CUDA sources to MUSA. - - When both .cu and .mu files exist with the same base name, - the .mu file takes precedence (it's the hand-written MUSA version). - - Args: - source_dir: Path to directory containing CUDA sources - mapping_rule: Optional custom mapping rules (uses get_mapping_rule() if None) - - Returns: - str: Path to the ported directory (source_dir + "_musa") + """Port a directory's CUDA sources to MUSA **in place** (no + ``_musa`` mirror): SimplePorting rewrites each file's + content and, with the identity extension map, keeps its name. + Original ``#include`` paths and source paths therefore stay + valid, so nothing downstream needs rewriting. Idempotent per + process via ``_ported_dirs``. """ if mapping_rule is None: mapping_rule = self.get_mapping_rule() - source_dir = os.path.abspath(source_dir) - musa_dir = source_dir + "_musa" - - if source_dir not in self._ported_dirs: - musa_sp.LOGGER.setLevel(logging.ERROR) - musa_sp.SimplePorting( - cuda_dir_path=source_dir, mapping_rule=mapping_rule - ).run() - self._ported_dirs.add(source_dir) - - # After porting, copy any original .mu/.muh files to ensure - # hand-written MUSA files take precedence over ported .cu files. - # This fixes the case where both foo.cu and foo.mu exist - - # SimplePorting processes both, but order is non-deterministic. - import shutil - - for root, _, files in os.walk(source_dir): - for file in files: - ext = os.path.splitext(file)[1].lower() - if ext in [".mu", ".muh"]: - src_path = os.path.join(root, file) - rel_path = os.path.relpath(root, source_dir) - dst_dir = os.path.join(musa_dir, rel_path) - dst_path = os.path.join(dst_dir, file) - os.makedirs(dst_dir, exist_ok=True) - shutil.copy2(src_path, dst_path) - - return musa_dir - - def _convert_source_path(self, source): - """ - Convert a CUDA source path to its ported MUSA equivalent. - - Args: - source: Original source file path (e.g., "csrc/kernel.cu") - - Returns: - tuple: (converted_path, needs_porting) - - converted_path: Path to ported file (e.g., "csrc_musa/kernel.mu") - - needs_porting: True if the source directory needs porting - """ - source_path = os.path.abspath(source) - source_dir = os.path.dirname(source_path) - source_file = os.path.basename(source_path) - base_name, ext_name = os.path.splitext(source_file) - ext_name_lower = ext_name.lower() - - # Port all source files that may contain CUDA references: - # - .cu/.cuh: CUDA source/header files - # - .cc/.cpp/.cxx: C++ files that may reference CUDA symbols - if ext_name_lower in [".cu", ".cuh", ".cc", ".cpp", ".cxx"]: - # Get the ported extension (kept same with our EXT_REPLACED_MAPPING) - new_ext = EXT_REPLACED_MAPPING.get(ext_name_lower[1:], ext_name_lower[1:]) - musa_dir = source_dir + "_musa" - new_source = os.path.join(musa_dir, base_name + "." + new_ext) - return new_source, True - # Handle .mu/.muh files that don't exist at original path - # Try to find them in the _musa directory (already ported files) - elif ext_name_lower in [".mu", ".muh"]: - if not os.path.exists(source_path): - musa_dir = source_dir + "_musa" - musa_source = os.path.join(musa_dir, source_file) - if os.path.exists(musa_source): - # File exists in _musa dir, use it (no porting needed) - return musa_source, False - # File exists at original path, use it as-is - return source, False - else: - return source, False + source_dir = os.path.realpath(os.path.abspath(source_dir)) + if source_dir in self._ported_dirs: + return source_dir + + _validate_portable_symlinks(source_dir) + musa_sp.LOGGER.setLevel(logging.ERROR) + sp = _create_in_place_porter(musa_sp, source_dir, mapping_rule) + sp.run() + + self._ported_dirs.add(source_dir) + return source_dir + + @staticmethod + def _dir_has_portable_sources(path): + """True if ``path`` recursively holds any file the porter would + rewrite — any extension in ``_PORTABLE_SOURCE_EXTS``. Kept in + sync with the porting allowlist so a directory of only + ``.cc``/``.cpp`` sources (no ``.h``/``.cu``) is not skipped. A + directory of only MUSA-native ``.mu``/``.muh`` sources has + nothing to port and is intentionally not a porting target.""" + try: + for _root, _dirs, files in os.walk(path): + for f in files: + if os.path.splitext(f)[1].lower() in _PORTABLE_SOURCE_EXTS: + return True + except OSError: + pass + return False + + @staticmethod + def _is_system_include_dir(path): + return ( + path.startswith("/usr/") + or path.startswith("/opt/") + or "site-packages" in path + or "dist-packages" in path + ) def run(self): - """ - Run the build process with automatic CUDA->MUSA porting. - - This method: - 1. Identifies CUDA source directories from extension sources - 2. Ports each directory using SimplePorting (like torch's HIPIFY) - 3. Updates source paths to point to ported files - 4. Ports include directories as well - 5. Calls parent run() to perform actual compilation + """Port each project-local include root's CUDA sources to MUSA + **in place** (no ``_musa`` mirror, no ``.cu``->``.mu`` + rename), then compile. + + Because nothing moves or is renamed, every source's original + ``#include`` directives -- relative (``../x``) and root-relative + (``dir/x``) alike -- still resolve against the same include roots, + and the extension's source paths stay valid. So there is no + per-file include rewriting, no source-path remapping, and no + second mirror to cause cross-tree ODR. ``.cu``/``.cuh`` compile as + MUSA via the patched ``_is_musa_file`` and explicit + ``-x musa`` compiler flag. """ mapping_rule = self.get_mapping_rule() - + self._ported_dirs = set() + candidate_dirs = [] for ext in self.extensions: - new_sources = [] - dirs_to_port = set() - - # First pass: identify directories that need porting - for source in ext.sources: - ( - new_source, - needs_porting, - ) = self._convert_source_path(source) - new_sources.append(new_source) - if needs_porting: - source_dir = os.path.dirname(os.path.abspath(source)) - dirs_to_port.add(source_dir) - # Sort directories by depth (deepest first) to ensure proper porting order - dirs_to_port = sorted( - dirs_to_port, key=lambda p: p.count("/"), reverse=True - ) - # Port each unique directory - for cuda_dir in dirs_to_port: - self._port_directory(cuda_dir, mapping_rule) - - # Update extension sources to point to ported files - ext.sources = new_sources - - # Port include directories and update include_dirs - # Only port project-local directories, not system paths - if hasattr(ext, "include_dirs") and ext.include_dirs: - new_include_dirs = [] - for inc_dir in ext.include_dirs: - inc_dir_abs = os.path.abspath(inc_dir) - # Skip system directories - only port project-local dirs - # System dirs typically start with /usr, /opt, or site-packages - is_system_dir = ( - inc_dir_abs.startswith("/usr/") - or inc_dir_abs.startswith("/opt/") - or "site-packages" in inc_dir_abs - or "dist-packages" in inc_dir_abs - ) - if os.path.isdir(inc_dir_abs) and not is_system_dir: - # Check if directory might contain CUDA headers (recursively) - has_cuda_headers = False - try: - for root, dirs, files in os.walk(inc_dir_abs): - for f in files: - if f.endswith( - ( - ".h", - ".hpp", - ".cuh", - ".cu", - ) - ): - has_cuda_headers = True - break - if has_cuda_headers: - break - except OSError: - pass - - if has_cuda_headers: - ported_dir = self._port_directory(inc_dir_abs, mapping_rule) - # Add ported dir first so ported headers take precedence - if os.path.isdir(ported_dir): - new_include_dirs.append(ported_dir) - new_include_dirs.append(inc_dir_abs) - ext.include_dirs = new_include_dirs + # System filtering applies only to include roots. An + # explicit source parent is always project-owned even + # when the checkout lives under /opt, /usr/src, or a + # site-packages tree. + for include_dir in list(getattr(ext, "include_dirs", None) or []): + root = os.path.realpath(os.path.abspath(include_dir)) + if not self._is_system_include_dir(root): + candidate_dirs.append(root) + for src in list(getattr(ext, "sources", None) or []): + candidate_dirs.append(os.path.dirname(os.path.abspath(src))) + + # ext.sources and ext.include_dirs are intentionally left + # unchanged: the tree is ported in place, so the original + # paths and includes resolve as-is. + + # Coalesce all extensions at once so a child include root is + # never ported and then recursively ported again through a + # later source-parent ancestor. + for root in _coalesce_port_roots(candidate_dirs): + if os.path.isdir(root) and self._dir_has_portable_sources(root): + self._port_directory(root, mapping_rule) super().run() diff --git a/tests/test_cpp_extension.py b/tests/test_cpp_extension.py index 358f4b8..f347a2c 100644 --- a/tests/test_cpp_extension.py +++ b/tests/test_cpp_extension.py @@ -7,6 +7,7 @@ """ import os +from types import SimpleNamespace # Import torchada first to apply patches import torchada # noqa: F401 @@ -138,9 +139,10 @@ def test_ext_replaced_mapping(self): if torchada.is_musa_platform(): import torch_musa.utils.simple_porting as musa_sp - # Extensions are converted: .cu -> .mu, .cuh -> .muh for mcc compiler - assert musa_sp.EXT_REPLACED_MAPPING["cu"] == "mu" - assert musa_sp.EXT_REPLACED_MAPPING["cuh"] == "muh" + # In-place porting retains filenames; mcc receives an explicit + # ``-x musa`` flag instead of relying on .mu/.muh suffixes. + assert musa_sp.EXT_REPLACED_MAPPING["cu"] == "cu" + assert musa_sp.EXT_REPLACED_MAPPING["cuh"] == "cuh" def test_mapping_rule_exists(self): """Test _MAPPING_RULE is set.""" @@ -167,3 +169,42 @@ def test_mapping_rule_has_expected_entries(self): assert rules.get("cudaStream_t") == "musaStream_t" assert rules.get("at::cuda") == "at::musa" assert rules.get("c10::cuda") == "c10::musa" + + +class TestMusaNinjaLanguagePatch: + """Test the filename-independent mcc language selection patch.""" + + def test_adds_explicit_language_once(self): + from torchada.utils.cpp_extension import _with_explicit_musa_language + + assert _with_explicit_musa_language(["-O3"]) == ["-O3", "-x", "musa"] + assert _with_explicit_musa_language(["-x", "musa", "-O3"]) == [ + "-x", + "musa", + "-O3", + ] + assert _with_explicit_musa_language(["-x=musa", "-O3"]) == ["-x=musa", "-O3"] + assert _with_explicit_musa_language(["-x", "cuda"]) == [ + "-x", + "cuda", + "-x", + "musa", + ] + + def test_patches_positional_and_keyword_musa_cflags(self): + from torchada.utils.cpp_extension import _patch_musa_ninja_language + + calls = [] + + def write_ninja(path, musa_cflags): + calls.append((path, musa_cflags)) + + module = SimpleNamespace(_write_ninja_file=write_ninja) + _patch_musa_ninja_language(module) + module._write_ninja_file("positional", ["-O2"]) + module._write_ninja_file(path="keyword", musa_cflags=["-g"]) + + assert calls == [ + ("positional", ["-O2", "-x", "musa"]), + ("keyword", ["-g", "-x", "musa"]), + ] diff --git a/tests/test_extension_build.py b/tests/test_extension_build.py index 70d8000..1387fee 100644 --- a/tests/test_extension_build.py +++ b/tests/test_extension_build.py @@ -221,8 +221,8 @@ class TestMixedSourcesBuild: """ Test building extensions with mixed source types (.cu, .cuh, .mu, .muh, .cpp). - This tests the fix for the issue where .mu files required manually specifying - the ported path (e.g., csrc_musa/foo.mu instead of csrc/foo.mu). + In-place porting keeps every source at its original path (no _musa + mirror, no .cu -> .mu rename), so a .mu source is listed as csrc/foo.mu. """ def test_mixed_sources_dir_exists(self): @@ -246,12 +246,12 @@ def test_build_mixed_sources_extension(self): """ Test building an extension with mixed .cu/.cuh/.mu/.muh/.cpp sources. - This is the key e2e test that verifies: - 1. .cu files are ported to .mu in the _musa directory - 2. .cuh files are ported to .muh in the _musa directory - 3. .mu files that don't exist at original path are found in _musa directory - 4. .muh files are handled correctly - 5. .cpp files are ported for CUDA symbol translation + This is the key e2e test that verifies in-place porting: + 1. .cu / .cuh files are ported in place (content substituted, names and + locations unchanged — no .mu/.muh rename) + 2. .mu / .muh files already at their original path resolve directly + 3. .cpp files are ported for CUDA symbol translation + 4. no _musa mirror is created """ if not _is_gpu_available(): pytest.skip("CUDA/MUSA not available") @@ -264,9 +264,8 @@ def test_build_mixed_sources_extension(self): src_dir = os.path.join(tmpdir, "csrc") shutil.copytree(MIXED_SOURCES_DIR, src_dir) - # Create setup.py that uses CUDA-style paths for .mu files - # This tests the fix: users can specify csrc/mul_kernel.mu - # and torchada will find it in csrc_musa/mul_kernel.mu after porting + # setup.py lists sources at their real paths; in-place porting keeps + # them there (no csrc_musa mirror, no .cu -> .mu rename). setup_content = """ import torchada # noqa: F401 - Apply MUSA patches from setuptools import setup @@ -279,8 +278,8 @@ def test_build_mixed_sources_extension(self): name="test_mixed_sources", sources=[ "csrc/bindings.cpp", # C++ file with CUDA symbols - "csrc/add_kernel.cu", # CUDA kernel -> ported to csrc_musa/add_kernel.mu - "csrc/mul_kernel.mu", # MUSA kernel -> found in csrc_musa/mul_kernel.mu + "csrc/add_kernel.cu", # CUDA kernel -> ported in place, name kept + "csrc/mul_kernel.mu", # MUSA-native kernel -> left as-is ], include_dirs=["csrc"], # Include dir for headers ) @@ -311,17 +310,17 @@ def test_build_mixed_sources_extension(self): ext_files = [f for f in os.listdir(tmpdir) if f.endswith(".so") or f.endswith(".pyd")] assert len(ext_files) > 0, "No extension file was built" - # Verify ported directory was created - ported_dir = os.path.join(tmpdir, "csrc_musa") - assert os.path.isdir(ported_dir), "Ported directory csrc_musa was not created" - - # Verify ported files exist + # In-place porting: no _musa mirror, no .cu -> .mu rename. + assert not os.path.isdir( + os.path.join(tmpdir, "csrc_musa") + ), "in-place porting must not create a csrc_musa mirror" + # Sources keep their original names and locations. assert os.path.exists( - os.path.join(ported_dir, "add_kernel.mu") - ), "add_kernel.cu was not ported to add_kernel.mu" + os.path.join(src_dir, "add_kernel.cu") + ), "add_kernel.cu should be ported in place (name/location kept)" assert os.path.exists( - os.path.join(ported_dir, "utils.muh") - ), "utils.cuh was not ported to utils.muh" + os.path.join(src_dir, "mul_kernel.mu") + ), "mul_kernel.mu should resolve at its original path" def test_run_mixed_sources_extension(self): """Test running the mixed sources extension after building.""" @@ -410,14 +409,14 @@ def test_run_mixed_sources_extension(self): ) class TestSameNameFilePrecedence: """ - Test that .mu files take precedence over .cu files when both exist. - - This tests the fix for the issue where SimplePorting's file processing - order is non-deterministic, causing either the ported .cu or original .mu - to end up in the _musa directory depending on which is processed last. - - The fix ensures that original .mu/.muh files are always copied after - porting, so they take precedence over auto-ported .cu/.cuh files. + Test that a .cu and a same-named .mu coexist under in-place porting. + + With in-place porting there is no _musa mirror and no .cu -> .mu + rename, so kernel.cu and kernel.mu stay distinct files at their original + paths. The source listed in the build is the one compiled; a sibling .mu is + left in place. (The old mirror behavior — a .mu silently winning over a + same-named .cu inside the generated mirror — no longer applies; list the + .mu explicitly to build it.) """ def test_same_name_dir_exists(self): @@ -431,27 +430,24 @@ def test_both_cu_and_mu_exist(self): assert os.path.exists(cu_path), f"kernel.cu not found: {cu_path}" assert os.path.exists(mu_path), f"kernel.mu not found: {mu_path}" - def test_mu_file_takes_precedence(self): + def test_cu_and_mu_coexist_in_place(self): """ - Test that .mu file takes precedence when both .cu and .mu exist. - - This is the key test: after porting, the csrc_musa/kernel.mu file - should contain the content from the original kernel.mu (magic number 123), - not the ported kernel.cu (magic number 42). + Build listing kernel.cu (kernel.mu also present): in-place porting keeps + both distinct files at their original paths, creates no csrc_musa mirror, + and builds the listed .cu (ported in place). The sibling .mu is untouched. """ if not _is_gpu_available(): pytest.skip("CUDA/MUSA not available") if not torchada.is_musa_platform(): - pytest.skip("Same name precedence test only applicable on MUSA platform") + pytest.skip("In-place coexistence test only applicable on MUSA platform") with tempfile.TemporaryDirectory() as tmpdir: # Create csrc directory and copy source files csrc_dir = os.path.join(tmpdir, "csrc") shutil.copytree(SAME_NAME_DIR, csrc_dir) - # Create setup.py that only specifies the .cu file - # (simulating user who wants to build CUDA code) + # setup.py lists the .cu (a same-named .mu also exists in csrc/) setup_content = f""" import torchada # noqa: F401 - Apply MUSA patches from setuptools import setup @@ -464,7 +460,7 @@ def test_mu_file_takes_precedence(self): name="test_same_name", sources=[ "csrc/bindings.cpp", - "csrc/kernel.cu", # .mu file exists too - should take precedence + "csrc/kernel.cu", # kernel.mu also present; both coexist in place ], ) ], @@ -485,17 +481,21 @@ def test_mu_file_takes_precedence(self): ) assert result.returncode == 0, f"Build failed: {result.stderr}" - # Check that csrc_musa/kernel.mu contains the MUSA version (magic 123) - ported_mu_path = os.path.join(tmpdir, "csrc_musa", "kernel.mu") - assert os.path.exists(ported_mu_path), f"Ported file not found: {ported_mu_path}" - - with open(ported_mu_path, "r") as f: - content = f.read() - # The MUSA version returns 123, CUDA version returns 42 - assert "return 123" in content, ( - f".mu file should take precedence but found ported .cu content. " - f"Expected 'return 123' but got:\n{content}" - ) - assert "return 42" not in content, ( - f"Found ported .cu content instead of original .mu. " f"Content:\n{content}" - ) + # In-place: no mirror, no rename, both files still present in place. + assert not os.path.exists( + os.path.join(tmpdir, "csrc_musa") + ), "in-place porting must not create a csrc_musa mirror" + assert os.path.exists( + os.path.join(csrc_dir, "kernel.cu") + ), "kernel.cu must remain in place" + assert os.path.exists( + os.path.join(csrc_dir, "kernel.mu") + ), "sibling kernel.mu must be left in place" + + # The listed .cu was ported in place and is the built source. + with open(os.path.join(csrc_dir, "kernel.cu"), "r") as f: + cu_content = f.read() + assert "return 42" in cu_content, ( + "the listed kernel.cu (magic 42) should be the built source; " + f"got:\n{cu_content}" + ) diff --git a/tests/test_inplace_porting.py b/tests/test_inplace_porting.py new file mode 100644 index 0000000..5c563fe --- /dev/null +++ b/tests/test_inplace_porting.py @@ -0,0 +1,138 @@ +"""Regression tests for safe, deterministic in-place CUDA source porting.""" + +import os + +import pytest +from setuptools import Distribution, Extension + +import torchada + +pytestmark = pytest.mark.skipif( + not torchada.is_musa_platform(), + reason="In-place SimplePorting integration requires torch_musa", +) + +TOKEN = "TORCHADA_CUDA_TOKEN" +PORTED_TOKEN = "TORCHADA_MUSA_TOKEN" +MAPPING = {TOKEN: PORTED_TOKEN} + + +def _build_extension_command(): + from torchada.utils.cpp_extension import BuildExtension + + return BuildExtension(Distribution()) + + +def test_existing_mirror_sibling_is_preserved(tmp_path): + source_dir = tmp_path / "csrc" + mirror_dir = tmp_path / "csrc_musa" + source_dir.mkdir() + mirror_dir.mkdir() + source = source_dir / "kernel.cu" + sentinel = mirror_dir / "DO_NOT_DELETE.txt" + source.write_text(f"{TOKEN}\n", encoding="utf-8") + sentinel.write_text("user-managed content\n", encoding="utf-8") + + command = _build_extension_command() + command._port_directory(str(source_dir), MAPPING) + + assert sentinel.read_text(encoding="utf-8") == "user-managed content\n" + assert source.read_text(encoding="utf-8") == f"{PORTED_TOKEN}\n" + + +def test_portable_symlink_fails_without_modifying_target(tmp_path): + source_dir = tmp_path / "csrc" + source_dir.mkdir() + external = tmp_path / "user_owned.h" + external.write_text(f"{TOKEN}\n", encoding="utf-8") + link = source_dir / "linked.h" + link.symlink_to(external) + + command = _build_extension_command() + with pytest.raises(RuntimeError, match="symlinked CUDA/C\\+\\+ source"): + command._port_directory(str(source_dir), MAPPING) + + assert link.is_symlink() + assert external.read_text(encoding="utf-8") == f"{TOKEN}\n" + + +def test_dotless_files_do_not_create_or_overwrite_hidden_siblings(tmp_path): + source_dir = tmp_path / "csrc" + source_dir.mkdir() + (source_dir / "kernel.cu").write_text(f"{TOKEN}\n", encoding="utf-8") + makefile = source_dir / "Makefile" + hidden_makefile = source_dir / ".Makefile" + makefile.write_text("public makefile\n", encoding="utf-8") + hidden_makefile.write_text("private sentinel\n", encoding="utf-8") + + command = _build_extension_command() + command._port_directory(str(source_dir), MAPPING) + + assert makefile.read_text(encoding="utf-8") == "public makefile\n" + assert hidden_makefile.read_text(encoding="utf-8") == "private sentinel\n" + + +@pytest.mark.parametrize("suffix", [".ipp", ".tpp", ".txx", ".ixx", ".cppm"]) +def test_common_cpp_fragments_are_ported(tmp_path, suffix): + source_dir = tmp_path / "csrc" + source_dir.mkdir() + fragment = source_dir / f"fragment{suffix}" + fragment.write_text(f"{TOKEN}\n", encoding="utf-8") + + command = _build_extension_command() + command._port_directory(str(source_dir), MAPPING) + + assert fragment.read_text(encoding="utf-8") == f"{PORTED_TOKEN}\n" + + +def test_overlapping_roots_are_ported_once(tmp_path, monkeypatch): + from torchada.utils.cpp_extension import BuildExtension + + source_dir = tmp_path / "src" + include_dir = source_dir / "include" + include_dir.mkdir(parents=True) + source = source_dir / "kernel.cu" + header = include_dir / "header.h" + source.write_text(f"{TOKEN}\n", encoding="utf-8") + header.write_text(f"{TOKEN}\n", encoding="utf-8") + + class CustomBuildExtension(BuildExtension): + def get_mapping_rule(self): + return {TOKEN: TOKEN + "X"} + + command = CustomBuildExtension(Distribution()) + command.extensions = [ + Extension("test_overlap", sources=[str(source)], include_dirs=[str(include_dir)]) + ] + monkeypatch.setattr(BuildExtension.__mro__[1], "run", lambda self: None) + + command.run() + + assert source.read_text(encoding="utf-8") == f"{TOKEN}X\n" + assert header.read_text(encoding="utf-8") == f"{TOKEN}X\n" + assert command._ported_dirs == {os.path.realpath(source_dir)} + + +def test_source_parent_is_not_filtered_as_system_include(tmp_path, monkeypatch): + from torchada.utils.cpp_extension import BuildExtension + + source_dir = tmp_path / "opt-like-project" + source_dir.mkdir() + source = source_dir / "kernel.cu" + source.write_text(f"{TOKEN}\n", encoding="utf-8") + + class CustomBuildExtension(BuildExtension): + def get_mapping_rule(self): + return MAPPING + + command = CustomBuildExtension(Distribution()) + command.extensions = [ + Extension("test_system_source", sources=[str(source)], include_dirs=[str(source_dir)]) + ] + monkeypatch.setattr(BuildExtension, "_is_system_include_dir", staticmethod(lambda _path: True)) + monkeypatch.setattr(BuildExtension.__mro__[1], "run", lambda self: None) + + command.run() + + assert source.read_text(encoding="utf-8") == f"{PORTED_TOKEN}\n" + assert command._ported_dirs == {os.path.realpath(source_dir)} diff --git a/tests/test_mappings.py b/tests/test_mappings.py index b4a4414..76778b1 100644 --- a/tests/test_mappings.py +++ b/tests/test_mappings.py @@ -532,9 +532,10 @@ def test_mapping_count(self): def test_ext_replaced_mapping(self): from torchada._mapping import EXT_REPLACED_MAPPING - # Extensions are converted: .cu -> .mu, .cuh -> .muh for mcc compiler - assert EXT_REPLACED_MAPPING["cu"] == "mu" - assert EXT_REPLACED_MAPPING["cuh"] == "muh" + # Identity map: porting keeps the original .cu/.cuh names (no .mu/.muh + # rename) so it runs in place and source paths need no rewriting. + assert EXT_REPLACED_MAPPING["cu"] == "cu" + assert EXT_REPLACED_MAPPING["cuh"] == "cuh" class TestMappingRobustness: @@ -617,13 +618,15 @@ def test_port_cuda_source_basic(self): assert "at::cuda" not in result def test_port_cuda_source_includes(self): - """Test include statement porting.""" + """CUDA runtime headers are renamed, but a project .cuh include keeps + its extension: porting is in place, so there is no .cuh -> .muh rename.""" from torchada.utils.cpp_extension import _port_cuda_source source = '#include \n#include "my_file.cuh"' result = _port_cuda_source(source) assert "musa_runtime.h" in result - assert 'my_file.muh"' in result + assert 'my_file.cuh"' in result + assert 'my_file.muh"' not in result def test_port_cuda_source_types(self): """Test type replacement.""" @@ -1171,7 +1174,12 @@ def test_run_device_type_extension(self): del sys.modules["test_device_type"] def test_ported_source_contains_privateuse1(self): - """Verify the ported source code contains PrivateUse1, not MUSA.""" + """Verify porting rewrites CUDA device types to PrivateUse1 in place. + + Porting is in place: the source keeps its original .cu name and + location (no _musa mirror, no .cu -> .mu rename), and its CUDA + device types are rewritten to PrivateUse1. + """ if not torchada.is_musa_platform(): pytest.skip("Source porting test only applicable on MUSA platform") @@ -1200,7 +1208,7 @@ def test_ported_source_contains_privateuse1(self): with open(setup_path, "w") as f: f.write(setup_content) - # Build the extension (this triggers source porting) + # Build the extension (this triggers in-place source porting) result = subprocess.run( [sys.executable, "setup.py", "build_ext", "--inplace"], cwd=tmpdir, @@ -1208,31 +1216,22 @@ def test_ported_source_contains_privateuse1(self): text=True, ) - # Find all ported .mu files - # The ported files are in {tmpdir}_musa directory (created by torchada) - ported_files = [] - musa_dir = f"{tmpdir}_musa" - if os.path.exists(musa_dir): - for root, dirs, files in os.walk(musa_dir): - for f in files: - if f.endswith(".mu"): - ported_files.append(os.path.join(root, f)) - - # Also check inside tmpdir in case porting puts files there - for root, dirs, files in os.walk(tmpdir): - for f in files: - if f.endswith(".mu"): - ported_files.append(os.path.join(root, f)) - - assert ( - len(ported_files) > 0 - ), f"No .mu file found after porting. Build output:\n{result.stdout}" - - # Read ported content - ported_content = "" - for pf in ported_files: - with open(pf, "r") as f: - ported_content += f.read() + # In-place porting must not create a _musa mirror ... + assert not os.path.exists( + f"{tmpdir}_musa" + ), "in-place porting must not create a _musa mirror" + # ... nor rename .cu -> .mu: the source keeps its name and location. + assert not any( + f.endswith(".mu") for f in os.listdir(tmpdir) + ), "in-place porting must not rename .cu -> .mu" + + ported_cu = os.path.join(tmpdir, "device_type_test.cu") + assert os.path.exists(ported_cu), ( + "ported source not found in place. Build output:\n" + f"{result.stdout}\n{result.stderr}" + ) + with open(ported_cu, "r") as f: + ported_content = f.read() # Verify the mappings were applied correctly assert ( diff --git a/tests/test_platform.py b/tests/test_platform.py index f46d974..bfb9b7a 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -2,6 +2,9 @@ Tests for platform detection and initialization. """ +import re +from pathlib import Path + class TestPlatformDetection: """Test platform detection functionality.""" @@ -56,8 +59,21 @@ def test_get_version(self): version = torchada.get_version() assert version == torchada.__version__ + assert version == "0.1.70" assert isinstance(version, str) + def test_project_version_matches_runtime_version(self): + """The package metadata and runtime version must stay in sync.""" + import torchada + + pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml" + match = re.search( + r'^version = "([^"]+)"$', pyproject.read_text(encoding="utf-8"), re.MULTILINE + ) + + assert match is not None + assert match.group(1) == torchada.__version__ + def test_get_platform(self): """Test get_platform helper.""" import torchada diff --git a/tests/test_python_compat.py b/tests/test_python_compat.py index cc77874..4f6ad6d 100644 --- a/tests/test_python_compat.py +++ b/tests/test_python_compat.py @@ -39,7 +39,7 @@ def test_all_files_parse(self): except SyntaxError as e: errors.append(f"{filepath}: {e}") - assert not errors, f"Syntax errors found:\n" + "\n".join(errors) + assert not errors, "Syntax errors found:\n" + "\n".join(errors) def test_all_modules_importable(self): """Test that all torchada modules can be imported.""" @@ -103,27 +103,36 @@ def test_no_union_type_operator(self): In Python 3.10+, you can use `int | str` for union types. In Python 3.8/3.9, you must use Union[int, str] from typing. """ - # Pattern for union type operator in type hints - # Matches things like `: int | str` or `-> str | None` - pattern = r"(?::|->)\s*\w+\s*\|\s*\w+" - errors = [] for filepath in get_python_files(): with open(filepath, "r", encoding="utf-8") as f: - content = f.read() - - for i, line in enumerate(content.split("\n"), 1): - # Skip comments - if line.strip().startswith("#"): - continue - # Skip bitwise OR operations (likely not type hints) - if "==" in line or "!=" in line: - continue - - if re.search(pattern, line): - # Exclude dict merge operations and other valid uses - if " | {" not in line and "} | " not in line: - errors.append(f"{filepath}:{i}: {line.strip()}") + source = f.read() + tree = ast.parse(source, filename=str(filepath)) + + annotations = [] + for node in ast.walk(tree): + if isinstance(node, ast.AnnAssign): + annotations.append(node.annotation) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.returns is not None: + annotations.append(node.returns) + annotations.extend( + arg.annotation + for arg in [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs] + if arg.annotation is not None + ) + if node.args.vararg is not None and node.args.vararg.annotation is not None: + annotations.append(node.args.vararg.annotation) + if node.args.kwarg is not None and node.args.kwarg.annotation is not None: + annotations.append(node.args.kwarg.annotation) + + for annotation in annotations: + if any( + isinstance(child, ast.BinOp) and isinstance(child.op, ast.BitOr) + for child in ast.walk(annotation) + ): + text = ast.get_source_segment(source, annotation) or "" + errors.append(f"{filepath}:{annotation.lineno}: {text}") assert not errors, ( "Found Python 3.10+ union type operator. "