From cfb8e81fa0561be4adfdea0856e5accf25ced093 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Wed, 18 Mar 2026 14:34:36 +0100 Subject: [PATCH 01/12] fix: normalize_path always returns POSIX format Remove the Windows branch from normalize_path so it always returns forward slashes, consistent with the method's docstring. Input paths from hatchling's recurse_selected_project_files() are always POSIX format on all platforms, so the backslash conversion was unnecessary and broke test assertions. Co-Authored-By: Claude Sonnet 4.6 --- src/hatch_cython/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index 5f74d2a..c32e0ed 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -127,8 +127,6 @@ def is_windows(self) -> bool: return plat() == "windows" def normalize_path(self, pattern: str) -> str: - if self.is_windows: - return pattern.replace("/", "\\") return pattern.replace("\\", "/") def normalize_glob(self, pattern: str): From d9a6ae762b6076f9dbd6bd6275cabd3ab3083275 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Wed, 18 Mar 2026 11:11:10 +0100 Subject: [PATCH 02/12] chore set mimum python version to 3.10 and fix linter & typing errors --- .gitignore | 1 + pyproject.toml | 25 ++-- src/hatch_cython/config/autoimport.py | 7 +- src/hatch_cython/config/config.py | 40 +++--- src/hatch_cython/config/files.py | 23 ++-- src/hatch_cython/config/flags.py | 48 +++---- src/hatch_cython/config/macros.py | 13 +- src/hatch_cython/config/platform.py | 31 +++-- src/hatch_cython/config/templates.py | 14 +- src/hatch_cython/constants.py | 11 +- src/hatch_cython/plugin.py | 129 +++++++++--------- src/hatch_cython/types.py | 30 +--- src/hatch_cython/utils.py | 16 +-- test_libraries/bootstrap.py | 2 +- .../src/example_lib/mod_a/some_defn.py | 2 +- tests/__init__.py | 16 +-- tests/test_config.py | 4 +- tests/test_platform_pyversion.py | 11 +- tests/test_plugin.py | 16 ++- tests/test_plugin_excludes.py | 2 +- tests/test_setuppy.py | 6 +- tests/utils.py | 13 +- 22 files changed, 230 insertions(+), 230 deletions(-) diff --git a/.gitignore b/.gitignore index 826eae1..fc5b641 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ dist/ .pytest_cache junit.xml .idea/ +.claude \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4c1b8d3..90b8707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,6 @@ classifiers = [ "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -26,7 +24,7 @@ dependencies = [ "Cython", "hatchling", "setuptools", - "typing_extensions; python_version < '3.10'" + "typing_extensions; python_version < '3.12'" ] description = 'Cython build hooks for hatch' dynamic = ["version"] @@ -34,7 +32,7 @@ keywords = [] license = "MIT" name = "hatch-cython" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" [project.entry-points.hatch] cython = "hatch_cython.hooks" @@ -89,7 +87,7 @@ test = "pytest --junitxml=junit.xml -o junit_family=legacy {args:tests} -v" test-cov = "coverage run -m pytest --junitxml=junit.xml -o junit_family=legacy -vv {args:tests}" [tool.hatch.envs.dev] -python = "3.9" +python = "3.10" path = ".venv" skip-install = false extra-dependencies = [ @@ -108,13 +106,16 @@ detached = true [tool.hatch.envs.lint.scripts] all = ["style", "typing"] -fmt = ["black {args:.}", "ruff --fix {args:.}", "style"] -style = ["ruff {args:.}", "black --check --diff {args:.}"] +fmt = ["black {args:.}", "ruff check --ignore PLC0415 --fix {args:.}", "style"] +style = ["ruff check --ignore PLC0415 {args:.}", "black --check --diff {args:.}"] typing = "mypy --install-types --non-interactive {args:src/hatch_cython tests}" [tool.hatch.version] path = "src/hatch_cython/__about__.py" +[tool.mypy] +ignore_missing_imports = true + [tool.ruff] line-length = 120 target-version = "py310" @@ -132,7 +133,11 @@ ignore = [ "PLR0911", "PLR0912", "PLR0913", - "PLR0915" + "PLR0915", + "UP006", + "UP007", + "UP035", + "UP045" ] select = [ "A", @@ -172,4 +177,6 @@ known-first-party = ["hatch_cython"] [tool.ruff.lint.per-file-ignores] "**/__init__.py" = ["F401"] # Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +"tests/**/*" = ["PLR2004", "S101", "TID252", "T201", "A002", "E501"] +# Test libraries contain intentional patterns for testing +"test_libraries/**/*.py" = ["S603", "PLW1510", "PLW0603", "UP004", "T201", "S101"] diff --git a/src/hatch_cython/config/autoimport.py b/src/hatch_cython/config/autoimport.py index f488801..7eeff41 100644 --- a/src/hatch_cython/config/autoimport.py +++ b/src/hatch_cython/config/autoimport.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from typing import Optional @dataclass @@ -6,9 +7,9 @@ class Autoimport: pkg: str include: str - libraries: str = field(default=None) - library_dirs: str = field(default=None) - required_call: str = field(default=None) + libraries: Optional[str] = field(default=None) + library_dirs: Optional[str] = field(default=None) + required_call: Optional[str] = field(default=None) __packages__ = { diff --git a/src/hatch_cython/config/config.py b/src/hatch_cython/config/config.py index 340016f..4ff0e71 100644 --- a/src/hatch_cython/config/config.py +++ b/src/hatch_cython/config/config.py @@ -1,8 +1,9 @@ import os -from collections.abc import Generator +from collections.abc import Callable, Generator from dataclasses import asdict, dataclass, field from importlib import import_module from os import path +from typing import Any, Optional from hatchling.builders.hooks.plugin.interface import BuildHookInterface @@ -15,7 +16,7 @@ from hatch_cython.config.platform import ListedArgs, PlatformArgs, parse_platform_args from hatch_cython.config.templates import Templates, parse_template_kwds from hatch_cython.constants import DIRECTIVES, EXIST_TRIM, INCLUDE, LTPY311, MUST_UNIQUE -from hatch_cython.types import CallableT, ListStr, UnionT +from hatch_cython.types import ListStr # fields tracked by this plugin __known__ = frozenset( @@ -36,7 +37,7 @@ "cythonize_kwargs", "include_all_compiled_src", "compiled_extensions_as_artifacts", - "intermediate_extensions_as_artifacts" + "intermediate_extensions_as_artifacts", ) ) @@ -48,19 +49,14 @@ def parse_from_dict(cls: BuildHookInterface): kwargs = {} for key, val in given.items(): if key in __known__: - parsed: any if key == "files": - val: dict - parsed: FileArgs = FileArgs(**val) + parsed: Any = FileArgs(**val) elif key == "define_macros": - val: list - parsed: DefineMacros = parse_macros(val) + parsed = parse_macros(val) elif key == "templates": - val: dict - parsed: Templates = parse_template_kwds(val) + parsed = parse_template_kwds(val) else: - val: any - parsed: any = val + parsed = val kwargs[key] = parsed passed.pop(key) continue @@ -68,7 +64,7 @@ def parse_from_dict(cls: BuildHookInterface): compile_args = parse_platform_args(kwargs, "compile_args", get_default_compile) link_args = parse_platform_args(kwargs, "extra_link_args", get_default_link) envflags = parse_env_args(kwargs) - cfg = Config(**kwargs, compile_args=compile_args, extra_link_args=link_args, envflags=envflags) + cfg = Config(**kwargs, compile_args=compile_args, extra_link_args=link_args, envflags=envflags) # type: ignore[arg-type] for maybe_dep, spec in passed.copy().items(): is_include = maybe_dep.startswith(INCLUDE) @@ -138,7 +134,7 @@ def parse_from_dict(cls: BuildHookInterface): @dataclass class Config: - src: UnionT[str, None] = field(default=None) + src: Optional[str] = field(default=None) files: FileArgs = field(default_factory=FileArgs) includes: ListStr = field(default_factory=list) define_macros: DefineMacros = field(default_factory=list) @@ -173,9 +169,9 @@ def _post_import_attr( cls: BuildHookInterface, im: Autoimport, att: str, - mod: any, - extend: CallableT[[ListStr], None], - append: CallableT[[str], None], + mod: Any, + extend: Callable[[ListStr], None], + append: Callable[[str], None], ): attr = getattr(im, att) if attr is not None: @@ -187,9 +183,9 @@ def _post_import_attr( if isinstance(libraries, str): append(libraries) elif isinstance(libraries, (list, Generator)): - extend(libraries) + extend(libraries) # type: ignore[arg-type] elif isinstance(libraries, dict): - extend(libraries.values()) + extend(libraries.values()) # type: ignore[arg-type] else: cls.app.display_warning(f"{im.pkg}.{attr} has an invalid type ({type(libraries)})") @@ -234,11 +230,11 @@ def resolve_pkg( cls.app.display_warning(f"{im.pkg}.{im.required_call} is invalid") def _arg_impl(self, target: ListedArgs): - args = {"any": []} + args: dict = {"any": []} def with_argvalue(arg: str): # be careful with e.g. -Ox flags - matched = list(filter(lambda s: arg.startswith(s), MUST_UNIQUE)) + matched = [s for s in MUST_UNIQUE if arg.startswith(s)] if len(matched) != 0: m = matched[0] args[m] = arg.split(" ") @@ -249,7 +245,7 @@ def with_argvalue(arg: str): # if compile-arg format, check platform applies if isinstance(arg, PlatformArgs): if arg.applies() and arg.is_exist(EXIST_TRIM): - with_argvalue(arg.arg) + with_argvalue(arg.arg) # type: ignore[arg-type] # else assume string / user knows what theyre doing and add to the call params else: with_argvalue(arg) diff --git a/src/hatch_cython/config/files.py b/src/hatch_cython/config/files.py index 76834c9..21e155a 100644 --- a/src/hatch_cython/config/files.py +++ b/src/hatch_cython/config/files.py @@ -1,9 +1,9 @@ import re from dataclasses import dataclass, field -from typing import Type +from typing import Optional from hatch_cython.config.platform import PlatformBase -from hatch_cython.types import DictT, ListT, UnionT +from hatch_cython.types import DictT, ListT from hatch_cython.utils import parse_user_glob @@ -18,22 +18,22 @@ class OptInclude(PlatformBase): def _get_file_list( - cls: Type[UnionT[OptInclude, OptExclude]], - files: ListT[UnionT[str, OptInclude, OptExclude]] -) -> ListT[str]: + cls: "type[OptInclude | OptExclude]", + files: "ListT[str | OptInclude | OptExclude]", +) -> "ListT[OptInclude | OptExclude]": return [ - *[cls(**d) for d in files if isinstance(d, dict)], + *[cls(**d) for d in files if isinstance(d, dict)], # type: ignore[arg-type] *[cls(matches=s) for s in files if isinstance(s, str)], ] @dataclass class FileArgs: - targets: ListT[UnionT[str, OptInclude]] = field(default_factory=list) - exclude: ListT[UnionT[str, OptExclude]] = field(default_factory=list) + targets: ListT[str | OptInclude] = field(default_factory=list) + exclude: ListT[str | OptExclude] = field(default_factory=list) aliases: DictT[str, str] = field(default_factory=dict) - exclude_compiled_src: ListT[UnionT[str, OptExclude]] = field(default_factory=list) - include_compiled_src: ListT[UnionT[str, OptInclude]] = field(default_factory=list) + exclude_compiled_src: ListT[str | OptExclude] = field(default_factory=list) + include_compiled_src: ListT[str | OptInclude] = field(default_factory=list) def __post_init__(self): rep = {} @@ -49,7 +49,7 @@ def __post_init__(self): def explicit_targets(self) -> bool: return len(self.targets) > 0 - def matches_alias(self, other: str) -> UnionT[str, None]: + def matches_alias(self, other: str) -> Optional[str]: matched = [re.match(v, other) for v in self.aliases.keys()] if any(matched): first = 0 @@ -58,3 +58,4 @@ def matches_alias(self, other: str) -> UnionT[str, None]: break first += 1 return self.aliases[list(self.aliases.keys())[first]] + return None diff --git a/src/hatch_cython/config/flags.py b/src/hatch_cython/config/flags.py index f31978c..c7c97a6 100644 --- a/src/hatch_cython/config/flags.py +++ b/src/hatch_cython/config/flags.py @@ -1,9 +1,10 @@ +from collections.abc import Callable from dataclasses import dataclass, field from os import environ, pathsep -from typing import ClassVar +from typing import ClassVar, Optional from hatch_cython.config.platform import PlatformArgs, parse_to_plat -from hatch_cython.types import CallableT, DictT +from hatch_cython.types import DictT @dataclass @@ -13,7 +14,7 @@ class EnvFlag(PlatformArgs): sep: str = field(default=" ") def __hash__(self) -> int: - return hash(self.field) + return hash(self.env) __flags__ = ( @@ -34,24 +35,24 @@ def __hash__(self) -> int: @dataclass class EnvFlags: - CC: PlatformArgs = None - CPP: PlatformArgs = None - CXX: PlatformArgs = None + CC: Optional[PlatformArgs] = field(default=None) + CPP: Optional[PlatformArgs] = field(default=None) + CXX: Optional[PlatformArgs] = field(default=None) - CFLAGS: PlatformArgs = None - CCSHARED: PlatformArgs = None + CFLAGS: Optional[PlatformArgs] = field(default=None) + CCSHARED: Optional[PlatformArgs] = field(default=None) - CPPFLAGS: PlatformArgs = None + CPPFLAGS: Optional[PlatformArgs] = field(default=None) - LDFLAGS: PlatformArgs = None - LDSHARED: PlatformArgs = None + LDFLAGS: Optional[PlatformArgs] = field(default=None) + LDSHARED: Optional[PlatformArgs] = field(default=None) - SHLIB_SUFFIX: PlatformArgs = None + SHLIB_SUFFIX: Optional[PlatformArgs] = field(default=None) - AR: PlatformArgs = None - ARFLAGS: PlatformArgs = None + AR: Optional[PlatformArgs] = field(default=None) + ARFLAGS: Optional[PlatformArgs] = field(default=None) - PATH: PlatformArgs = None + PATH: Optional[PlatformArgs] = field(default=None) custom: DictT[str, PlatformArgs] = field(default_factory=dict) env: dict = field(default_factory=environ.copy) @@ -64,14 +65,14 @@ def __post_init__(self): for flag in self.custom.values(): self.merge_to_env(flag, self.get_from_custom) - def merge_to_env(self, flag: EnvFlag, get: CallableT[[str], EnvFlag]): + def merge_to_env(self, flag: EnvFlag, get: Callable[[str], Optional[EnvFlag]]): var = environ.get(flag.env) - override: EnvFlag = get(flag.env) + override: Optional[EnvFlag] = get(flag.env) if override and flag.merges: - add = var + flag.sep if var else "" - self.env[flag.env] = add + override.arg + add = (var or "") + flag.sep + self.env[flag.env] = add + override.arg # type: ignore[operator] elif override: - self.env[flag.env] = override.arg + self.env[flag.env] = override.arg # type: ignore[assignment] def get_from_self(self, attr): return getattr(self, attr) @@ -98,13 +99,12 @@ def parse_env_args( parse_to_plat(EnvFlag, arg, args, i, require_argform=True) except KeyError: args = [] - kw = {"custom": {}} + kw: dict = {"custom": {}} for arg in args: - arg: EnvFlag - if arg.applies(): + if isinstance(arg, EnvFlag) and arg.applies(): if arg.env in EnvFlags.__known__: kw[arg.env] = arg else: kw["custom"][arg.env] = arg - envflags = EnvFlags(**kw) + envflags = EnvFlags(**kw) # type: ignore[arg-type] return envflags diff --git a/src/hatch_cython/config/macros.py b/src/hatch_cython/config/macros.py index 6034af1..67355b4 100644 --- a/src/hatch_cython/config/macros.py +++ b/src/hatch_cython/config/macros.py @@ -1,9 +1,9 @@ -from hatch_cython.types import ListT, TupleT, UnionT +from typing import Optional -DefineMacros = ListT[TupleT[str, UnionT[str, None]]] +DefineMacros = list[tuple[str, Optional[str]]] -def parse_macros(define: ListT[ListT[str]]) -> DefineMacros: +def parse_macros(define: list[list[str]]) -> DefineMacros: """Parses define_macros from list[list[str, ...]] -> list[tuple[str, str|None]] Args: @@ -24,9 +24,8 @@ def parse_macros(define: ListT[ListT[str]]) -> DefineMacros: "where None value denotes #define FOO" ) raise ValueError(msg, inst) - inst: list if size == 1: - define[i] = (inst[0], None) + define[i] = (inst[0], None) # type: ignore[call-overload] else: - define[i] = (inst[0], inst[1]) - return define + define[i] = (inst[0], inst[1]) # type: ignore[call-overload] + return define # type: ignore[return-value] diff --git a/src/hatch_cython/config/platform.py b/src/hatch_cython/config/platform.py index 209ed31..18c8461 100644 --- a/src/hatch_cython/config/platform.py +++ b/src/hatch_cython/config/platform.py @@ -1,21 +1,22 @@ -from collections.abc import Hashable +from collections.abc import Callable, Hashable from dataclasses import dataclass from os import path +from typing import Dict, Optional, Union from packaging.markers import Marker from hatch_cython.constants import ANON -from hatch_cython.types import CallableT, ListStr, ListT, UnionT +from hatch_cython.types import ListStr from hatch_cython.utils import aarch, plat @dataclass class PlatformBase(Hashable): - platforms: UnionT[ListStr, str] = "*" - arch: UnionT[ListStr, str] = "*" + platforms: list[str] | str = "*" + arch: list[str] | str = "*" depends_path: bool = False - marker: str = None - apply_to_marker: CallableT[[], bool] = None + marker: Optional[str] = None + apply_to_marker: Optional[Callable[[], bool]] = None def __post_init__(self): self.do_rewrite("platforms") @@ -52,7 +53,7 @@ def _applies_impl(self, attr: str, defn: str): _anon = ANON == att and defn == "" return (att in (defn, "*")) or _anon - def applies(self, platform: UnionT[None, str] = None, arch: UnionT[None, str] = None): + def applies(self, platform: Optional[str] = None, arch: Optional[str] = None): if platform is None: platform = plat() if arch is None: @@ -64,23 +65,23 @@ def applies(self, platform: UnionT[None, str] = None, arch: UnionT[None, str] = def is_exist(self, trim: int = 0): if self.depends_path: - return path.exists(self.arg[trim:]) + return path.exists(self.arg[trim:]) # type: ignore[attr-defined] return True @dataclass class PlatformArgs(PlatformBase): - arg: str = None + arg: Optional[str] = None def __hash__(self) -> int: return hash(self.arg) -def parse_to_plat(cls, arg, args: UnionT[list, dict], key: UnionT[int, str], require_argform: bool, **kwargs): +def parse_to_plat(cls, arg, args: list | dict, key: int | str, require_argform: bool, **kwargs): if isinstance(arg, cls): pass elif isinstance(arg, dict): - args[key] = cls(**arg, **kwargs) + args[key] = cls(**arg, **kwargs) # type: ignore[index] elif require_argform: msg = f"arg {key} is invalid. must be of type ({{ flag = ... , platform = '*' }}) given {arg} ({type(arg)})" raise ValueError(msg) @@ -89,8 +90,8 @@ def parse_to_plat(cls, arg, args: UnionT[list, dict], key: UnionT[int, str], req def parse_platform_args( kwargs: dict, name: str, - default: CallableT[[], ListT[PlatformArgs]], -) -> ListT[UnionT[str, PlatformArgs]]: + default: Callable[[], list["PlatformArgs"]], +) -> list[Union[str, "PlatformArgs"]]: try: args = [*default(), *kwargs.pop(name)] for i, arg in enumerate(args): @@ -100,7 +101,9 @@ def parse_platform_args( return args -ListedArgs = ListT[UnionT[PlatformArgs, str]] +ListedArgs = list[PlatformArgs | str] """ List[str | PlatformArgs] """ + +__all__ = ["Dict", "ListStr"] diff --git a/src/hatch_cython/config/templates.py b/src/hatch_cython/config/templates.py index f1a6909..016793b 100644 --- a/src/hatch_cython/config/templates.py +++ b/src/hatch_cython/config/templates.py @@ -1,12 +1,13 @@ import re from dataclasses import asdict, dataclass, field from textwrap import dedent +from typing import Optional from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatch_cython.config.platform import PlatformBase from hatch_cython.constants import NORM_GLOB -from hatch_cython.types import ListStr, ListT, UnionT +from hatch_cython.types import ListStr, ListT from hatch_cython.utils import parse_user_glob @@ -20,7 +21,10 @@ def idx_search_mod(s: str): @dataclass class IndexItem(PlatformBase): keyword: str = "*" - matches: UnionT[str, ListStr] = field(default_factory=list) + matches: str | ListStr = field(default_factory=list) + + def __hash__(self) -> int: + return hash(self.keyword) def __post_init__(self): matches = self.matches @@ -31,7 +35,7 @@ def __post_init__(self): self.matches = sorted(matches, key=lambda it: -1 if it == NORM_GLOB else 1) def file_match(self, file: str) -> bool: - for patt in self.matches: + for patt in self.matches: # type: ignore[union-attr] # we take the local part out since we match on extensions if re.match(patt, file.replace("./", "")): return True @@ -42,7 +46,7 @@ class Templates: # noqa: PLW1641 index: ListT[IndexItem] kwargs: dict - def __init__(self, index: ListT[IndexItem] = None, **kwargs): + def __init__(self, index: Optional[ListT[IndexItem]] = None, **kwargs): if index is None: index = [] @@ -70,7 +74,7 @@ def asdict(self): } def find(self, cls: BuildHookInterface, *files: str): - kwds = {} + kwds: dict = {} for can in self.index: for file in files: diff --git a/src/hatch_cython/constants.py b/src/hatch_cython/constants.py index c94f698..eb2db5c 100644 --- a/src/hatch_cython/constants.py +++ b/src/hatch_cython/constants.py @@ -1,4 +1,4 @@ -from hatch_cython.types import CorePlatforms, ListT, Set +from hatch_cython.types import CorePlatforms, ListT, SetT NORM_GLOB = r"([^\s]*)" UAST = "${U_AST}" @@ -14,17 +14,18 @@ MUST_UNIQUE = ["-O", "-arch", "-march"] POSIX_CORE: ListT[CorePlatforms] = ["darwin", "linux"] -precompiled_extensions: Set[str] = { +precompiled_extensions: SetT[str] = { # py is left out as we have it optional / runtime value ".pyx", ".pxd", } -intermediate_extensions: Set[str] = { +intermediate_extensions: SetT[str] = { ".c", ".cpp", } -templated_extensions: Set[str] = {f"{f}.in" for f in {".py", ".pyi", *precompiled_extensions, *intermediate_extensions}} -compiled_extensions: Set[str] = { +_template_srcs: SetT[str] = {".py", ".pyi", *precompiled_extensions, *intermediate_extensions} +templated_extensions: SetT[str] = {f"{f}.in" for f in _template_srcs} +compiled_extensions: SetT[str] = { ".dll", # unix ".so", diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index c32e0ed..122b296 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -1,19 +1,28 @@ +import base64 +import csv +import hashlib +import io import locale import os +import re +import shutil import subprocess import sys -import re +import tempfile +import zipfile +from collections.abc import Callable, Iterable from contextlib import contextmanager from glob import glob, iglob +from pathlib import PurePosixPath from tempfile import TemporaryDirectory -from typing import Dict +from typing import Any, Optional import pathspec from Cython.Tempita import sub as render_template from hatchling.builders.hooks.plugin.interface import BuildHookInterface from pathspec import PathSpec -from hatch_cython.config import parse_from_dict, Config +from hatch_cython.config import Config, parse_from_dict from hatch_cython.constants import ( compiled_extensions, intermediate_extensions, @@ -21,21 +30,15 @@ templated_extensions, ) from hatch_cython.temp import ExtensionArg, setup_py -from hatch_cython.types import CallableT, DictT, ListStr, ListT, P, Set -from hatch_cython.utils import autogenerated, memo, parse_user_glob, plat +from hatch_cython.types import DictT, ListStr, ListT, SetT +try: + from typing import override +except ImportError: + from typing_extensions import override +from hatch_cython.utils import autogenerated, memo, plat -RelAbsPathMap = Dict[str, str] - -import base64 -import csv -import hashlib -import io -import os -import shutil -import tempfile -import zipfile -from pathlib import PurePosixPath +RelAbsPathMap = dict[str, str] def remove_leading_dot(path: str) -> str: @@ -44,15 +47,14 @@ def remove_leading_dot(path: str) -> str: return path -def filter_ensure_wanted(wanted: CallableT[[str], bool] , tgts: ListStr): +def filter_ensure_wanted(wanted: Callable[[str], bool], tgts: ListStr): return list( filter( wanted, tgts, ) ) -from typing import Any, Iterable -from pathlib import PurePosixPath + def _pattern_str(x: Any) -> str: # already a string @@ -76,6 +78,7 @@ def _pattern_str(x: Any) -> str: # last resort: hope __str__ returns a usable glob return str(x) + def _normalize_patterns(pats: Any) -> list[str]: if pats is None: return [] @@ -94,6 +97,7 @@ def _normalize_patterns(pats: Any) -> list[str]: return out return [_pattern_str(pats)] + def _matches_any(path: str, patterns: list[str]) -> bool: p = PurePosixPath(path) return any(p.match(pat) for pat in patterns) @@ -102,12 +106,12 @@ def _matches_any(path: str, patterns: list[str]) -> bool: class CythonBuildHook(BuildHookInterface): PLUGIN_NAME = "cython" - precompiled_extensions: Set[str] - intermediate_extensions: Set[str] - templated_extensions: Set[str] - compiled_extensions: Set[str] + precompiled_extensions: SetT[str] + intermediate_extensions: SetT[str] + templated_extensions: SetT[str] + compiled_extensions: SetT[str] - def __init__(self, *args: P.args, **kwargs: P.kwargs): + def __init__(self, *args: Any, **kwargs: Any): self.precompiled_extensions = precompiled_extensions.copy() self.intermediate_extensions = intermediate_extensions.copy() self.templated_extensions = templated_extensions.copy() @@ -189,30 +193,22 @@ def options_include_compiled_src(self): @property @memo def exclude_spec(self) -> PathSpec: - return PathSpec.from_lines( - pathspec.patterns.GitWildMatchPattern, self.options_exclude - ) + return PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.options_exclude) @property @memo def include_spec(self) -> PathSpec: - return PathSpec.from_lines( - pathspec.patterns.GitWildMatchPattern, self.options_include - ) + return PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.options_include) @property @memo def exclude_compiled_src_spec(self) -> PathSpec: - return PathSpec.from_lines( - pathspec.patterns.GitWildMatchPattern, self.options_exclude_compiled_src - ) + return PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.options_exclude_compiled_src) @property @memo def include_compiled_src_spec(self) -> PathSpec: - return PathSpec.from_lines( - pathspec.patterns.GitWildMatchPattern, self.options_include_compiled_src - ) + return PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.options_include_compiled_src) def path_is_included(self, relative_path: str) -> bool: if self.include_spec is None: # no cov @@ -227,8 +223,7 @@ def path_is_excluded(self, relative_path: str) -> bool: def path_is_wanted(self, relative_path: str) -> bool: if not self.options.files.explicit_targets: return not self.path_is_excluded(relative_path) - return (self.path_is_included(relative_path) and - not self.path_is_excluded(relative_path)) + return self.path_is_included(relative_path) and not self.path_is_excluded(relative_path) def path_is_included_compiled_src(self, relative_path: str) -> bool: if self.include_compiled_src_spec is None: @@ -253,10 +248,10 @@ def path_is_wanted_excluded_compiled_src(self, relative_path: str) -> bool: @memo def included_files(self): included_files = [] - for relative_path in set([f.relative_path for f in self.build_config.builder.recurse_selected_project_files()]): + for relative_path in {f.relative_path for f in self.build_config.builder.recurse_selected_project_files()}: if self.path_is_wanted(relative_path): if any(relative_path.endswith(ext) for ext in self.templated_extensions): - relative_path = os.path.splitext(relative_path)[0] + relative_path = os.path.splitext(relative_path)[0] # noqa: PLW2901 if any(relative_path.endswith(ext) for ext in self.precompiled_extensions): included_files.append(relative_path) return list(set(included_files)) @@ -265,9 +260,10 @@ def included_files(self): @memo def excluded_compiled_src_files(self): excluded_compiled_src_files = [] - for relative_path in set([f.relative_path for f in self.build_config.builder.recurse_selected_project_files()]): - if (any(relative_path.endswith(ext) for ext in self.precompiled_extensions) and - self.path_is_wanted_excluded_compiled_src(relative_path)): + for relative_path in {f.relative_path for f in self.build_config.builder.recurse_selected_project_files()}: + if any( + relative_path.endswith(ext) for ext in self.precompiled_extensions + ) and self.path_is_wanted_excluded_compiled_src(relative_path): excluded_compiled_src_files.append(relative_path) return list(set(excluded_compiled_src_files)) @@ -304,15 +300,15 @@ def grouped_included_files(self) -> ListT[ExtensionArg]: self.app.display_warning(f"attempted to use .pxd file without .py file ({norm})") # TODO: This is all kind of ugly. Be aware if self.is_src: - prefix = 'src/' + prefix = "src/" if root.startswith(prefix): - root = root[len(prefix):] - prefix = f'src{os.path.sep}' + root = root[len(prefix) :] + prefix = f"src{os.path.sep}" if root.startswith(prefix): - root = root[len(prefix):] + root = root[len(prefix) :] root = self.normalize_aliased_filelike(root.replace("/", ".").replace(os.path.sep, ".")) - alias = self.options.files.matches_alias(root) + alias: Optional[str] = self.options.files.matches_alias(root) self.app.display_debug(f"check alias {ok} {root} -> {norm} -> {alias}") if alias: root = alias @@ -353,7 +349,7 @@ def get_aliased_path(self, path: str) -> str: if self.is_src: path_without_src = path.replace("src/", "") path_alias_notation = self.normalize_aliased_filelike(path_without_src.replace("/", ".")) - alias = self.options.files.matches_alias(path_alias_notation) + alias: Optional[str] = self.options.files.matches_alias(path_alias_notation) if alias is not None: included_file = alias.replace(".", "/") if self.is_src: @@ -369,7 +365,7 @@ def _glob_files(self, extensions: ListStr, except_extra: bool, apply_aliases: bo relative_path_patterns = [] for included_file in self.included_files_without_extension: if apply_aliases: - included_file = self.get_aliased_path(included_file) + included_file = self.get_aliased_path(included_file) # noqa: PLW2901 for ext in extensions: relative_path_patterns.append(f"{included_file}{extra}{ext}") for pattern in relative_path_patterns: @@ -406,9 +402,9 @@ def autogenerated_files(self) -> RelAbsPathMap: for k, v in self.templated_files.items(): # remove ending .in (only at the end) if k.endswith(".in"): - k = k[:-3] + k = k[:-3] # noqa: PLW2901 if v.endswith(".in"): - v = v[:-3] + v = v[:-3] # noqa: PLW2901 if os.path.exists(v): autogenerated_files[k] = v return autogenerated_files @@ -422,8 +418,13 @@ def inclusion_map(self): self.app.display_debug(include) return include + @override def clean(self, versions: ListStr) -> None: - files_to_remove = list(self.autogenerated_files.values()) + list(self.intermediate_files.values()) + list(self.compiled_files.values()) + files_to_remove = ( + list(self.autogenerated_files.values()) + + list(self.intermediate_files.values()) + + list(self.compiled_files.values()) + ) self.app.display_info(f"Hatch-cython: Removing {files_to_remove}") self.app.display_info(f"Hatch-cython: intermediates {list(self.intermediate_files.values())}") for f_as_abs in files_to_remove: @@ -474,7 +475,7 @@ def build_ext(self): self.options.validate_include_opts() - process = subprocess.run( # noqa: PLW1510, S603 + process = subprocess.run( # noqa: S603, PLW1510 [ sys.executable, setup_file, @@ -502,7 +503,8 @@ def build_ext(self): self.app.display_success("Post-build artifacts") - def initialize(self, version: str, build_data: dict): + @override + def initialize(self, version: str, build_data: dict[str, Any]) -> None: self.app.display_mini_header(self.PLUGIN_NAME) self.app.display_debug("options") self.app.display_debug(self.options.asdict(), level=1) @@ -515,7 +517,7 @@ def initialize(self, version: str, build_data: dict): self.app.display_info(glob(f"{self.project_dir}/*/**", recursive=True)) if self.sdist and not self.options.compiled_sdist: - self.clean(None) + self.clean([]) build_data["infer_tag"] = True build_data["artifacts"].extend(self.artifacts) @@ -527,7 +529,7 @@ def initialize(self, version: str, build_data: dict): self.build_config.target_config["exclude"] = [] self.build_config.target_config["exclude"].extend(self.excluded) # hacky way to force hatch to update the exclude list internally: by setting it to None - private_attr_name = f"_BuilderConfig__exclude_patterns" + private_attr_name = "_BuilderConfig__exclude_patterns" setattr(self.build_config, private_attr_name, None) self.app.display_debug(f"Hook Config: {self.config}") self.app.display_debug(f"Build config: {self.build_config.build_config}") @@ -536,7 +538,8 @@ def initialize(self, version: str, build_data: dict): self.app.display_info("Extensions complete") self.app.display_debug(f"Build data: {build_data}") - def finalize(self, version: str, build_data: dict, artifact_path: str) -> None: + @override + def finalize(self, version: str, build_data: dict[str, Any], artifact_path: str) -> None: # Only post-process wheels if self.target_name != "wheel": return @@ -560,13 +563,15 @@ def should_drop(member_name: str) -> bool: os.close(tmp_fd) try: - with zipfile.ZipFile(artifact_path, "r") as zin, zipfile.ZipFile( - tmp_path, "w", compression=zipfile.ZIP_DEFLATED - ) as zout: + with ( + zipfile.ZipFile(artifact_path, "r") as zin, + zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zout, + ): # Find RECORD record_name = next((n for n in zin.namelist() if n.endswith(".dist-info/RECORD")), None) if record_name is None: - raise RuntimeError("Could not find *.dist-info/RECORD in wheel") + msg = "Could not find *.dist-info/RECORD in wheel" + raise RuntimeError(msg) written = {} # name -> (hash, size) diff --git a/src/hatch_cython/types.py b/src/hatch_cython/types.py index f0b4116..d393e69 100644 --- a/src/hatch_cython/types.py +++ b/src/hatch_cython/types.py @@ -1,32 +1,16 @@ -from sys import version_info -from typing import Literal, TypeVar, Union +from typing import Literal, ParamSpec, TypeAlias, TypeVar T = TypeVar("T") +P = ParamSpec("P") -vmaj = (version_info[0], version_info[1]) -if vmaj >= (3, 10): - from collections.abc import Callable - from typing import ParamSpec - - TupleT = tuple - DictT = dict - ListT = list - Set = set -else: - from typing import Callable, Dict, List, Set, Tuple # noqa: UP035, F401 - - from typing_extensions import ParamSpec - - TupleT = Tuple # noqa: UP006 - DictT = Dict # noqa: UP006 - ListT = List # noqa: UP006 +TupleT: TypeAlias = tuple +DictT: TypeAlias = dict +ListT: TypeAlias = list +SetT: TypeAlias = set -P = ParamSpec("P") -ListStr = ListT[str] -UnionT = Union +ListStr = list[str] CorePlatforms = Literal[ "darwin", "linux", "windows", ] -CallableT = Callable diff --git a/src/hatch_cython/utils.py b/src/hatch_cython/utils.py index 1dca838..f95ba16 100644 --- a/src/hatch_cython/utils.py +++ b/src/hatch_cython/utils.py @@ -1,12 +1,14 @@ import os import platform +from collections.abc import Callable from textwrap import dedent +from typing import Optional from Cython import __version__ as __cythonversion__ from hatch_cython.__about__ import __version__ from hatch_cython.constants import NORM_GLOB, UAST -from hatch_cython.types import CallableT, P, T, UnionT +from hatch_cython.types import P, T def stale(src: str, dest: str): @@ -15,7 +17,7 @@ def stale(src: str, dest: str): return (os.path.getmtime(src) >= os.path.getmtime(dest)) or (os.path.getctime(src) >= os.path.getctime(dest)) -def memo(func: CallableT[P, T]) -> CallableT[P, T]: +def memo(func: Callable[P, T]) -> Callable[P, T]: keyed = {} def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: @@ -54,8 +56,8 @@ def options_kws(kwds: dict): def parse_user_glob( uglob: str, - variant: UnionT[None, str] = None, - modifier: UnionT[CallableT[[str], str], None] = None, + variant: Optional[str] = None, + modifier: Optional[Callable[[str], str]] = None, ): if variant is None: variant = NORM_GLOB @@ -67,13 +69,11 @@ def parse_user_glob( def autogenerated(keywords: dict): - return dedent( - f"""# DO NOT EDIT. + return dedent(f"""# DO NOT EDIT. # Autoformatted by hatch-cython. # Version: {__version__} # Cython: {__cythonversion__} # Platform: {plat()} # Architecture: {aarch()} # Keywords: {keywords!r} -""" - ) +""") diff --git a/test_libraries/bootstrap.py b/test_libraries/bootstrap.py index 8bd95d3..445659b 100644 --- a/test_libraries/bootstrap.py +++ b/test_libraries/bootstrap.py @@ -12,7 +12,7 @@ logger.info(sys.version_info) ext = "whl" if (len(sys.argv) == 1) else sys.argv[1] artifact = glob(f"dist/example*.{ext}")[0] - proc = subprocess.run( # noqa: PLW1510, S603 + proc = subprocess.run( [sys.executable, "-m", "pip", "install", artifact, "--force-reinstall"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, diff --git a/test_libraries/src_structure/src/example_lib/mod_a/some_defn.py b/test_libraries/src_structure/src/example_lib/mod_a/some_defn.py index 2638c8b..b317990 100644 --- a/test_libraries/src_structure/src/example_lib/mod_a/some_defn.py +++ b/test_libraries/src_structure/src/example_lib/mod_a/some_defn.py @@ -1,7 +1,7 @@ from typing import Optional -class ValueDefn(object): # noqa: UP004 +class ValueDefn: def __init__(self, value: Optional[int] = None): self.value = value if value else 0 diff --git a/tests/__init__.py b/tests/__init__.py index 0971017..d02896d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,19 +1,13 @@ # SPDX-FileCopyrightText: 2023-present joshua-auchincloss # # SPDX-License-Identifier: MIT -from os import getcwd, name +from os import getcwd +from pathlib import Path from sys import path -if name == "nt": - from pathlib import WindowsPath - - p = WindowsPath(getcwd()) / "src" -else: - from pathlib import PosixPath - - p = PosixPath(getcwd()) / "src" +p = Path(getcwd()) / "src" path.append(str(p)) -from hatch_cython.__about__ import __version__ -from hatch_cython.devel import CythonBuildHook, src +from hatch_cython.__about__ import __version__ # noqa: E402 +from hatch_cython.devel import CythonBuildHook, src # noqa: E402 diff --git a/tests/test_config.py b/tests/test_config.py index 5a7764e..f5854d1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -30,7 +30,7 @@ def test_brew_fails_safely(): def test_config_with_brew(): - with pyversion("3", "9"), arch_platform("arm64", "darwin"), patch_path("arm64"), patch_brew("/opt/homebrew"): + with pyversion("3", "10"), arch_platform("arm64", "darwin"), patch_path("arm64"), patch_brew("/opt/homebrew"): ok = parse_from_dict(SimpleNamespace(config={"options": {"parallel": True}})) assert sorted(ok.compile_args_for_platform) == sorted(["-O2", "-I/opt/homebrew/include"]) assert ok.compile_links_for_platform == ["-L/opt/homebrew/lib"] @@ -71,7 +71,7 @@ def test_config_parser(): directives = { boundscheck = false, nonecheck = false, language_level = 3, binding = true } abc_compile_kwarg = "test" - """ # noqa: E501 + """ def gets_include(): return "abc" diff --git a/tests/test_platform_pyversion.py b/tests/test_platform_pyversion.py index d582d6b..ae4480d 100644 --- a/tests/test_platform_pyversion.py +++ b/tests/test_platform_pyversion.py @@ -1,10 +1,13 @@ -from hatch_cython.types import CallableT, DictT, ListT, P, TupleT, UnionT +from typing import Callable, Optional, Union + +from hatch_cython.types import DictT, ListT, P, TupleT # basic test to assert we can use subscriptable generics def test_type_compat(): - TupleT[int, str] + TupleT[int, str] # type: ignore[type-arg] DictT[str, str] ListT[str] - CallableT[P, str] - UnionT[str, None] + Callable[P, str] # type: ignore[type-arg] + Union[str, None] + Optional[str] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 3e40862..fb49f6a 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,12 +1,10 @@ import shutil from os import getcwd, path -from pathlib import Path # noqa: F401 from sys import path as syspath from types import SimpleNamespace -from typing import Optional import pytest -from hatchling.builders.wheel import WheelBuilderConfig, WheelBuilder +from hatchling.builders.wheel import WheelBuilder, WheelBuilderConfig from toml import load from hatch_cython.plugin import CythonBuildHook @@ -39,7 +37,7 @@ def new_src_proj(tmp_path): @pytest.mark.parametrize("include_all_compiled_src", [None, True, False]) -def test_wheel_build_hook(new_src_proj, include_all_compiled_src: Optional[bool]): +def test_wheel_build_hook(new_src_proj, include_all_compiled_src: bool | None): with override_dir(new_src_proj): syspath.insert(0, str(new_src_proj)) build_config = load(new_src_proj / "hatch.toml")["build"] @@ -138,8 +136,14 @@ def test_wheel_build_hook(new_src_proj, include_all_compiled_src: Optional[bool] {"name": "example_lib.mod_a.deep_nest.creates", "files": ["src/example_lib/mod_a/deep_nest/creates.pyx"]}, {"name": "example_lib.mod_a.some_defn", "files": ["src/example_lib/mod_a/some_defn.py"]}, {"name": "example_lib.normal", "files": ["src/example_lib/normal.py"]}, - {"name": "example_lib.normal_exclude_compiled_src", "files": ["src/example_lib/normal_exclude_compiled_src.py"]}, - {"name": "example_lib.normal_include_compiled_src", "files": ["src/example_lib/normal_include_compiled_src.py"]}, + { + "name": "example_lib.normal_exclude_compiled_src", + "files": ["src/example_lib/normal_exclude_compiled_src.py"], + }, + { + "name": "example_lib.normal_include_compiled_src", + "files": ["src/example_lib/normal_include_compiled_src.py"], + }, {"name": f"example_lib.platform.{plat()}", "files": [f"src/example_lib/platform/{plat()}.pyx"]}, {"name": "example_lib.templated", "files": ["src/example_lib/templated.pyx"]}, {"name": "example_lib.test", "files": ["src/example_lib/test.pyx"]}, diff --git a/tests/test_plugin_excludes.py b/tests/test_plugin_excludes.py index 66b0b26..5f93d7e 100644 --- a/tests/test_plugin_excludes.py +++ b/tests/test_plugin_excludes.py @@ -3,7 +3,7 @@ from types import SimpleNamespace import pytest -from hatchling.builders.wheel import WheelBuilderConfig, WheelBuilder +from hatchling.builders.wheel import WheelBuilder, WheelBuilderConfig from toml import load from hatch_cython.plugin import CythonBuildHook diff --git a/tests/test_setuppy.py b/tests/test_setuppy.py index e42e38c..fbddfd0 100644 --- a/tests/test_setuppy.py +++ b/tests/test_setuppy.py @@ -13,8 +13,7 @@ def clean(s: str): return "\n".join(v.strip() for v in s.splitlines() if v.strip() != "") -EXPECT = dedent( - """ +EXPECT = dedent(""" from setuptools import Extension, setup from Cython.Build import cythonize @@ -40,8 +39,7 @@ def clean(s: str): include_path=INCLUDES, abc='def' ) - setup(ext_modules=ext_modules)""" -) + setup(ext_modules=ext_modules)""") def test_setup_py(): diff --git a/tests/utils.py b/tests/utils.py index 4386c3a..e8778e5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,16 +1,15 @@ import os from contextlib import contextmanager from types import SimpleNamespace +from typing import Optional from unittest.mock import patch -from hatch_cython.types import UnionT - def true_if_eq(*vals): def inner(v: str, *extra): merge = [*vals, *extra] ok = any(v == val for val in merge) - print(f"ok: {v} {ok} ", merge) # noqa: T201 + print(f"ok: {v} {ok} ", merge) return ok return inner @@ -42,7 +41,7 @@ def patch_brew(prefix): @contextmanager -def arch_platform(arch: str, platform: str, brew: UnionT[str, None] = True): +def arch_platform(arch: str, platform: str, brew: Optional[str] = True): # type: ignore[assignment] def aarchgetter(): return arch @@ -67,13 +66,13 @@ def platformgetter(): else: yield finally: - print(f"Clean {arch}-{platform}") # noqa: T201 + print(f"Clean {arch}-{platform}") del aarchgetter, platformgetter pass @contextmanager -def pyversion(maj="3", min="10", p="0"): # noqa: A002 +def pyversion(maj="3", min="10", p="0"): try: with patch("platform.python_version_tuple", lambda: (maj, min, p)): yield @@ -84,7 +83,7 @@ def pyversion(maj="3", min="10", p="0"): # noqa: A002 @contextmanager def import_module(gets_include, gets_libraries=None, gets_library_dirs=None, some_setup_op=None): def get_import(name: str): - print(f"patched {name}") # noqa: T201 + print(f"patched {name}") return SimpleNamespace( gets_include=gets_include, gets_libraries=gets_libraries, From a53e7ff3b0cd9344cedd7ff059a881b47d84fadf Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Wed, 18 Mar 2026 11:50:21 +0100 Subject: [PATCH 03/12] refactor: remove obsolete Python 3.8 type aliases from types.py TupleT, DictT, ListT, SetT, and ListStr were compat shims for pre-PEP 585 Python versions. With the minimum now at 3.10, all call sites can use builtins directly (list[str], dict[K, V], set[T], tuple[...]). Also cleaned up platform.py's stale __all__ and removed an unused Dict import. Co-Authored-By: Claude Sonnet 4.6 --- src/hatch_cython/config/config.py | 9 ++++----- src/hatch_cython/config/files.py | 15 +++++++-------- src/hatch_cython/config/flags.py | 5 ++--- src/hatch_cython/config/platform.py | 5 ++--- src/hatch_cython/config/templates.py | 7 +++---- src/hatch_cython/constants.py | 14 +++++++------- src/hatch_cython/plugin.py | 23 +++++++++++------------ src/hatch_cython/temp.py | 5 ++--- src/hatch_cython/types.py | 8 +------- tests/test_platform_pyversion.py | 8 ++++---- 10 files changed, 43 insertions(+), 56 deletions(-) diff --git a/src/hatch_cython/config/config.py b/src/hatch_cython/config/config.py index 4ff0e71..653fafd 100644 --- a/src/hatch_cython/config/config.py +++ b/src/hatch_cython/config/config.py @@ -16,7 +16,6 @@ from hatch_cython.config.platform import ListedArgs, PlatformArgs, parse_platform_args from hatch_cython.config.templates import Templates, parse_template_kwds from hatch_cython.constants import DIRECTIVES, EXIST_TRIM, INCLUDE, LTPY311, MUST_UNIQUE -from hatch_cython.types import ListStr # fields tracked by this plugin __known__ = frozenset( @@ -136,10 +135,10 @@ def parse_from_dict(cls: BuildHookInterface): class Config: src: Optional[str] = field(default=None) files: FileArgs = field(default_factory=FileArgs) - includes: ListStr = field(default_factory=list) + includes: list[str] = field(default_factory=list) define_macros: DefineMacros = field(default_factory=list) - libraries: ListStr = field(default_factory=list) - library_dirs: ListStr = field(default_factory=list) + libraries: list[str] = field(default_factory=list) + library_dirs: list[str] = field(default_factory=list) directives: dict = field(default_factory=lambda: DIRECTIVES) compile_args: ListedArgs = field(default_factory=get_default_compile) compile_kwargs: dict = field(default_factory=dict) @@ -170,7 +169,7 @@ def _post_import_attr( im: Autoimport, att: str, mod: Any, - extend: Callable[[ListStr], None], + extend: Callable[[list[str]], None], append: Callable[[str], None], ): attr = getattr(im, att) diff --git a/src/hatch_cython/config/files.py b/src/hatch_cython/config/files.py index 21e155a..1b6bcaf 100644 --- a/src/hatch_cython/config/files.py +++ b/src/hatch_cython/config/files.py @@ -3,7 +3,6 @@ from typing import Optional from hatch_cython.config.platform import PlatformBase -from hatch_cython.types import DictT, ListT from hatch_cython.utils import parse_user_glob @@ -19,8 +18,8 @@ class OptInclude(PlatformBase): def _get_file_list( cls: "type[OptInclude | OptExclude]", - files: "ListT[str | OptInclude | OptExclude]", -) -> "ListT[OptInclude | OptExclude]": + files: "list[str | OptInclude | OptExclude]", +) -> "list[OptInclude | OptExclude]": return [ *[cls(**d) for d in files if isinstance(d, dict)], # type: ignore[arg-type] *[cls(matches=s) for s in files if isinstance(s, str)], @@ -29,11 +28,11 @@ def _get_file_list( @dataclass class FileArgs: - targets: ListT[str | OptInclude] = field(default_factory=list) - exclude: ListT[str | OptExclude] = field(default_factory=list) - aliases: DictT[str, str] = field(default_factory=dict) - exclude_compiled_src: ListT[str | OptExclude] = field(default_factory=list) - include_compiled_src: ListT[str | OptInclude] = field(default_factory=list) + targets: list[str | OptInclude] = field(default_factory=list) + exclude: list[str | OptExclude] = field(default_factory=list) + aliases: dict[str, str] = field(default_factory=dict) + exclude_compiled_src: list[str | OptExclude] = field(default_factory=list) + include_compiled_src: list[str | OptInclude] = field(default_factory=list) def __post_init__(self): rep = {} diff --git a/src/hatch_cython/config/flags.py b/src/hatch_cython/config/flags.py index c7c97a6..3bd4da2 100644 --- a/src/hatch_cython/config/flags.py +++ b/src/hatch_cython/config/flags.py @@ -4,7 +4,6 @@ from typing import ClassVar, Optional from hatch_cython.config.platform import PlatformArgs, parse_to_plat -from hatch_cython.types import DictT @dataclass @@ -54,10 +53,10 @@ class EnvFlags: PATH: Optional[PlatformArgs] = field(default=None) - custom: DictT[str, PlatformArgs] = field(default_factory=dict) + custom: dict[str, PlatformArgs] = field(default_factory=dict) env: dict = field(default_factory=environ.copy) - __known__: ClassVar[DictT[str, EnvFlag]] = {e.env: e for e in __flags__} + __known__: ClassVar[dict[str, EnvFlag]] = {e.env: e for e in __flags__} def __post_init__(self): for flag in __flags__: diff --git a/src/hatch_cython/config/platform.py b/src/hatch_cython/config/platform.py index 18c8461..287ba89 100644 --- a/src/hatch_cython/config/platform.py +++ b/src/hatch_cython/config/platform.py @@ -1,12 +1,11 @@ from collections.abc import Callable, Hashable from dataclasses import dataclass from os import path -from typing import Dict, Optional, Union +from typing import Optional, Union from packaging.markers import Marker from hatch_cython.constants import ANON -from hatch_cython.types import ListStr from hatch_cython.utils import aarch, plat @@ -106,4 +105,4 @@ def parse_platform_args( List[str | PlatformArgs] """ -__all__ = ["Dict", "ListStr"] +__all__ = ["ListedArgs", "PlatformArgs", "PlatformBase"] diff --git a/src/hatch_cython/config/templates.py b/src/hatch_cython/config/templates.py index 016793b..33d503f 100644 --- a/src/hatch_cython/config/templates.py +++ b/src/hatch_cython/config/templates.py @@ -7,7 +7,6 @@ from hatch_cython.config.platform import PlatformBase from hatch_cython.constants import NORM_GLOB -from hatch_cython.types import ListStr, ListT from hatch_cython.utils import parse_user_glob @@ -21,7 +20,7 @@ def idx_search_mod(s: str): @dataclass class IndexItem(PlatformBase): keyword: str = "*" - matches: str | ListStr = field(default_factory=list) + matches: str | list[str] = field(default_factory=list) def __hash__(self) -> int: return hash(self.keyword) @@ -43,10 +42,10 @@ def file_match(self, file: str) -> bool: class Templates: # noqa: PLW1641 - index: ListT[IndexItem] + index: list[IndexItem] kwargs: dict - def __init__(self, index: Optional[ListT[IndexItem]] = None, **kwargs): + def __init__(self, index: Optional[list[IndexItem]] = None, **kwargs): if index is None: index = [] diff --git a/src/hatch_cython/constants.py b/src/hatch_cython/constants.py index eb2db5c..044324f 100644 --- a/src/hatch_cython/constants.py +++ b/src/hatch_cython/constants.py @@ -1,4 +1,4 @@ -from hatch_cython.types import CorePlatforms, ListT, SetT +from hatch_cython.types import CorePlatforms NORM_GLOB = r"([^\s]*)" UAST = "${U_AST}" @@ -12,20 +12,20 @@ } LTPY311 = "python_version < '3.11'" MUST_UNIQUE = ["-O", "-arch", "-march"] -POSIX_CORE: ListT[CorePlatforms] = ["darwin", "linux"] +POSIX_CORE: list[CorePlatforms] = ["darwin", "linux"] -precompiled_extensions: SetT[str] = { +precompiled_extensions: set[str] = { # py is left out as we have it optional / runtime value ".pyx", ".pxd", } -intermediate_extensions: SetT[str] = { +intermediate_extensions: set[str] = { ".c", ".cpp", } -_template_srcs: SetT[str] = {".py", ".pyi", *precompiled_extensions, *intermediate_extensions} -templated_extensions: SetT[str] = {f"{f}.in" for f in _template_srcs} -compiled_extensions: SetT[str] = { +_template_srcs: set[str] = {".py", ".pyi", *precompiled_extensions, *intermediate_extensions} +templated_extensions: set[str] = {f"{f}.in" for f in _template_srcs} +compiled_extensions: set[str] = { ".dll", # unix ".so", diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index 122b296..aeb2cb7 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -30,7 +30,6 @@ templated_extensions, ) from hatch_cython.temp import ExtensionArg, setup_py -from hatch_cython.types import DictT, ListStr, ListT, SetT try: from typing import override @@ -47,7 +46,7 @@ def remove_leading_dot(path: str) -> str: return path -def filter_ensure_wanted(wanted: Callable[[str], bool], tgts: ListStr): +def filter_ensure_wanted(wanted: Callable[[str], bool], tgts: list[str]): return list( filter( wanted, @@ -106,10 +105,10 @@ def _matches_any(path: str, patterns: list[str]) -> bool: class CythonBuildHook(BuildHookInterface): PLUGIN_NAME = "cython" - precompiled_extensions: SetT[str] - intermediate_extensions: SetT[str] - templated_extensions: SetT[str] - compiled_extensions: SetT[str] + precompiled_extensions: set[str] + intermediate_extensions: set[str] + templated_extensions: set[str] + compiled_extensions: set[str] def __init__(self, *args: Any, **kwargs: Any): self.precompiled_extensions = precompiled_extensions.copy() @@ -286,8 +285,8 @@ def normalize_aliased_filelike(self, path: str): return path @property - def grouped_included_files(self) -> ListT[ExtensionArg]: - grouped: DictT[str, set] = {} + def grouped_included_files(self) -> list[ExtensionArg]: + grouped: dict[str, set] = {} for norm in self.normalized_included_files: root, ext = os.path.splitext(norm) ok = True @@ -333,7 +332,7 @@ def artifacts(self): return [f"/{artifact}" for artifact in to_distribute_files] @property - def excluded(self) -> ListStr: + def excluded(self) -> list[str]: if self.sdist: return [] else: @@ -357,7 +356,7 @@ def get_aliased_path(self, path: str) -> str: return included_file return path - def _glob_files(self, extensions: ListStr, except_extra: bool, apply_aliases: bool = False) -> RelAbsPathMap: + def _glob_files(self, extensions: list[str], except_extra: bool, apply_aliases: bool = False) -> RelAbsPathMap: found_files: RelAbsPathMap = {} extra = "" if except_extra: @@ -377,7 +376,7 @@ def _glob_files(self, extensions: ListStr, except_extra: bool, apply_aliases: bo @property @memo - def included_files_without_extension(self) -> ListStr: + def included_files_without_extension(self) -> list[str]: return [os.path.splitext(f)[0] for f in self.included_files] @property @@ -419,7 +418,7 @@ def inclusion_map(self): return include @override - def clean(self, versions: ListStr) -> None: + def clean(self, versions: list[str]) -> None: files_to_remove = ( list(self.autogenerated_files.values()) + list(self.intermediate_files.values()) diff --git a/src/hatch_cython/temp.py b/src/hatch_cython/temp.py index 6bcb09e..db5f6ac 100644 --- a/src/hatch_cython/temp.py +++ b/src/hatch_cython/temp.py @@ -1,17 +1,16 @@ from typing import TypedDict from hatch_cython.config import Config -from hatch_cython.types import ListStr, ListT from hatch_cython.utils import options_kws class ExtensionArg(TypedDict): name: str - files: ListStr + files: list[str] def setup_py( - *files: ListT[ListStr], + *files: list[list[str]], options: Config, sdist: bool, ): diff --git a/src/hatch_cython/types.py b/src/hatch_cython/types.py index d393e69..9d766e0 100644 --- a/src/hatch_cython/types.py +++ b/src/hatch_cython/types.py @@ -1,14 +1,8 @@ -from typing import Literal, ParamSpec, TypeAlias, TypeVar +from typing import Literal, ParamSpec, TypeVar T = TypeVar("T") P = ParamSpec("P") -TupleT: TypeAlias = tuple -DictT: TypeAlias = dict -ListT: TypeAlias = list -SetT: TypeAlias = set - -ListStr = list[str] CorePlatforms = Literal[ "darwin", "linux", diff --git a/tests/test_platform_pyversion.py b/tests/test_platform_pyversion.py index ae4480d..d7b285c 100644 --- a/tests/test_platform_pyversion.py +++ b/tests/test_platform_pyversion.py @@ -1,13 +1,13 @@ from typing import Callable, Optional, Union -from hatch_cython.types import DictT, ListT, P, TupleT +from hatch_cython.types import P # basic test to assert we can use subscriptable generics def test_type_compat(): - TupleT[int, str] # type: ignore[type-arg] - DictT[str, str] - ListT[str] + tuple[int, str] + dict[str, str] + list[str] Callable[P, str] # type: ignore[type-arg] Union[str, None] Optional[str] From c0d3e40acd5aa07c42a8e106aa33cb38f24aa013 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Wed, 18 Mar 2026 13:04:27 +0100 Subject: [PATCH 04/12] fix: hatch clean not removing generated .c and .so files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `options` property was decorated with `@memo`, which caches results in a closure dict keyed by `id(self)`. Python reuses memory addresses after GC, so a fresh CythonBuildHook instance created after the build hook was destroyed could get the same id, causing @memo to return a stale Config without re-executing the function body — silently skipping the `precompiled_extensions.add(".py")` side effect. Since `compile_py=True` is the default, this caused `included_files` to miss all .py-sourced files on the clean hook, leaving their generated .c/.so artifacts in place. Projects that compile only .py files (no .pyx) were fully unaffected by clean(). Fix: compute parse_from_dict() eagerly in __init__ and store as self._options, applying precompiled_extensions side effects there. The options property becomes a plain accessor. Also add FileNotFoundError handling around os.remove in clean() so that a second invocation (e.g. wheel + sdist targets) doesn't crash when files were already removed by the first call. Add test_clean_removes_generated_files which builds the src_structure project, forces GC (triggering potential memo ID reuse), then calls clean() on a fresh hook and asserts all .c/.so files are gone. Co-Authored-By: Claude Sonnet 4.6 --- src/hatch_cython/plugin.py | 33 ++++++++++++-------- tests/test_plugin.py | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index aeb2cb7..2183ab9 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -118,7 +118,20 @@ def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) - _ = self.options + # Compute options eagerly and store directly on the instance. + # This avoids the @memo ID-reuse bug: Python can reuse a memory address + # for a new object after GC, causing @memo to return a stale Config from + # a prior instance without re-executing side effects such as + # precompiled_extensions.add(".py"). Storing on self bypasses the shared + # id()-keyed cache entirely. + self._options = parse_from_dict(self) + if self._options.compile_py: + self.precompiled_extensions.add(".py") + if self._options.files.explicit_targets: + self.precompiled_extensions.add(".py") + self.precompiled_extensions.add(".c") + self.precompiled_extensions.add(".cc") + self.precompiled_extensions.add(".cpp") @property @memo @@ -425,22 +438,16 @@ def clean(self, versions: list[str]) -> None: + list(self.compiled_files.values()) ) self.app.display_info(f"Hatch-cython: Removing {files_to_remove}") - self.app.display_info(f"Hatch-cython: intermediates {list(self.intermediate_files.values())}") for f_as_abs in files_to_remove: - os.remove(f_as_abs) + try: + os.remove(f_as_abs) + self.app.display_info(f"Hatch-cython: Removed {f_as_abs}") + except FileNotFoundError: + pass # already removed (e.g. second target invocation) @property - @memo def options(self) -> Config: - config = parse_from_dict(self) - if config.compile_py: - self.precompiled_extensions.add(".py") - if config.files.explicit_targets: - self.precompiled_extensions.add(".py") - self.precompiled_extensions.add(".c") - self.precompiled_extensions.add(".cc") - self.precompiled_extensions.add(".cpp") - return config + return self._options @property def sdist(self) -> bool: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index fb49f6a..a2d082d 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,5 +1,7 @@ +import gc import shutil from os import getcwd, path +from pathlib import Path from sys import path as syspath from types import SimpleNamespace @@ -175,3 +177,64 @@ def test_wheel_build_hook(new_src_proj, include_all_compiled_src: bool | None): assert sorted(hook.build_config.target_config["exclude"]) == sorted(expected_exclude) syspath.remove(str(new_src_proj)) + + +def test_clean_removes_generated_files(new_src_proj): + """Test that clean() removes .c and compiled extension files after a build. + + This test deliberately triggers the memo ID-reuse bug: after the build hook is + garbage-collected, a fresh hook may receive the same memory address, causing + @memo to return stale cached data (skipping the compile_py side effect). + """ + with override_dir(new_src_proj): + syspath.insert(0, str(new_src_proj)) + build_config = load(new_src_proj / "hatch.toml")["build"] + cython_config = build_config["hooks"]["custom"] + + def make_hook(): + builder = WheelBuilder(root=str(new_src_proj)) + return CythonBuildHook( + new_src_proj, + cython_config, + WheelBuilderConfig( + builder=builder, + root=str(new_src_proj), + plugin_name="cython", + build_config=build_config, + target_config=build_config["targets"]["wheel"], + ), + SimpleNamespace(name="example_lib"), + directory=new_src_proj, + target_name="wheel", + ) + + # Step 1: Build to generate .c and compiled extension files + build_hook = make_hook() + build_data = {"artifacts": [], "force_include": {}} + build_hook.initialize("0.1.0", build_data) + + # Step 2: Verify generated files exist + src_dir = Path(new_src_proj) / "src" + c_files = list(src_dir.rglob("*.c")) + assert len(c_files) > 0, f"Expected .c files after build, found none under {src_dir}" + + # Step 3: Destroy build hook and force GC so CPython may reuse the memory address. + # This triggers the memo ID-reuse bug: the next hook created at the same address + # would receive stale @memo cache entries (including stale included_files that + # skip .py sources when compile_py side-effect was not re-applied). + del build_hook + gc.collect() + + # Step 4: Create a fresh hook (simulating `hatch clean` — a brand-new instance) + clean_hook = make_hook() + clean_hook.clean([]) + del clean_hook + gc.collect() + + # Step 5: Assert all generated files are gone + c_files_after = list(src_dir.rglob("*.c")) + so_files_after = list(src_dir.rglob("*.so")) + list(src_dir.rglob("*.pyd")) + assert len(c_files_after) == 0, f".c files not removed by clean(): {c_files_after}" + assert len(so_files_after) == 0, f"compiled extension files not removed by clean(): {so_files_after}" + + syspath.remove(str(new_src_proj)) From 1d6a54a5a6db2f33f79c2642d1c4f2ffc6dbb1f5 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Wed, 18 Mar 2026 15:36:59 +0100 Subject: [PATCH 05/12] add manual github actions workflow trigger --- .github/workflows/build.yaml | 1 + .github/workflows/coverage.yaml | 2 +- .github/workflows/ruff.yaml | 2 +- .github/workflows/test.yaml | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e8eeaaa..0733275 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,6 +6,7 @@ on: - v* branches: - main + workflow_dispatch: permissions: contents: write diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index fb6a705..c7fc45e 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -1,5 +1,5 @@ name: Coverage -on: [push, pull_request] +on: [push, pull_request, workflow_dispatch] jobs: cov: runs-on: ubuntu-latest diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index 36a5edf..7c56616 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -1,5 +1,5 @@ name: Ruff -on: [push, pull_request] +on: [push, pull_request, workflow_dispatch] jobs: ruff: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index df09983..09663b2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,6 +5,7 @@ on: branches: [v*] pull_request: branches: [v*, main] + workflow_dispatch: concurrency: group: test-${{ github.head_ref }} From 8f8535929ad11cc82974e99e9d4c536e99f4c555 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Thu, 19 Mar 2026 10:37:18 +0100 Subject: [PATCH 06/12] exclude <3.10 from ci --- .github/workflows/build.yaml | 2 +- .github/workflows/test.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0733275..0d12dc8 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -28,7 +28,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 09663b2..7f90a47 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -23,7 +23,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v6 From 6fa414df2d10c91c12807eecce0879091c1be2d4 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Thu, 19 Mar 2026 10:37:29 +0100 Subject: [PATCH 07/12] fix windows test fails --- src/hatch_cython/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index 2183ab9..4842871 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -357,6 +357,7 @@ def get_build_dirs(self): yield os.path.realpath(temp_dir) def get_aliased_path(self, path: str) -> str: + path = path.replace("\\", "/") path_without_src = path if self.is_src: path_without_src = path.replace("src/", "") From 44b4ba52f741c4b1123170a7f0911e1f6347c480 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Thu, 19 Mar 2026 10:46:46 +0100 Subject: [PATCH 08/12] fix build error on win < 3.12 --- src/hatch_cython/plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index 4842871..6cc2633 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -34,7 +34,9 @@ try: from typing import override except ImportError: - from typing_extensions import override + + def override(f): # type: ignore[misc] + return f from hatch_cython.utils import autogenerated, memo, plat RelAbsPathMap = dict[str, str] From db1d3c035341cc845d3da54e165cab6d2e001e1a Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Thu, 19 Mar 2026 10:54:37 +0100 Subject: [PATCH 09/12] revert windows path related changes --- src/hatch_cython/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index 6cc2633..9d2e212 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -145,6 +145,8 @@ def is_windows(self) -> bool: return plat() == "windows" def normalize_path(self, pattern: str) -> str: + if self.is_windows: + return pattern.replace("/", "\\") return pattern.replace("\\", "/") def normalize_glob(self, pattern: str): @@ -359,7 +361,6 @@ def get_build_dirs(self): yield os.path.realpath(temp_dir) def get_aliased_path(self, path: str) -> str: - path = path.replace("\\", "/") path_without_src = path if self.is_src: path_without_src = path.replace("src/", "") From 4e00caef43466e07e68afcd62240af25725b2b87 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Thu, 19 Mar 2026 11:13:24 +0100 Subject: [PATCH 10/12] fix method signature and typing_extensions error during build --- pyproject.toml | 2 ++ src/hatch_cython/plugin.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90b8707..a1b7ff5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,6 +176,8 @@ known-first-party = ["hatch_cython"] [tool.ruff.lint.per-file-ignores] "**/__init__.py" = ["F401"] +# Plugin implements BuildHookInterface — unused args are required by the interface signature +"src/hatch_cython/plugin.py" = ["ARG002"] # Tests can use magic values, assertions, and relative imports "tests/**/*" = ["PLR2004", "S101", "TID252", "T201", "A002", "E501"] # Test libraries contain intentional patterns for testing diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index 9d2e212..616384e 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -34,9 +34,14 @@ try: from typing import override except ImportError: + try: + from typing_extensions import override # type: ignore + except ImportError: + + def override(f): # type: ignore[misc] + return f + - def override(f): # type: ignore[misc] - return f from hatch_cython.utils import autogenerated, memo, plat RelAbsPathMap = dict[str, str] @@ -436,7 +441,7 @@ def inclusion_map(self): @override def clean(self, versions: list[str]) -> None: - files_to_remove = ( + files_to_remove: list[str] = ( list(self.autogenerated_files.values()) + list(self.intermediate_files.values()) + list(self.compiled_files.values()) From 6c565eb46c905a342c3d80982414453c16b73a1e Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Thu, 19 Mar 2026 11:51:19 +0100 Subject: [PATCH 11/12] fix 3.13 ci error and windows path error --- src/hatch_cython/plugin.py | 9 +-------- src/hatch_cython/utils.py | 34 +++++++++++++++++++--------------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/hatch_cython/plugin.py b/src/hatch_cython/plugin.py index 616384e..f15ff64 100644 --- a/src/hatch_cython/plugin.py +++ b/src/hatch_cython/plugin.py @@ -125,12 +125,6 @@ def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) - # Compute options eagerly and store directly on the instance. - # This avoids the @memo ID-reuse bug: Python can reuse a memory address - # for a new object after GC, causing @memo to return a stale Config from - # a prior instance without re-executing side effects such as - # precompiled_extensions.add(".py"). Storing on self bypasses the shared - # id()-keyed cache entirely. self._options = parse_from_dict(self) if self._options.compile_py: self.precompiled_extensions.add(".py") @@ -150,8 +144,6 @@ def is_windows(self) -> bool: return plat() == "windows" def normalize_path(self, pattern: str) -> str: - if self.is_windows: - return pattern.replace("/", "\\") return pattern.replace("\\", "/") def normalize_glob(self, pattern: str): @@ -366,6 +358,7 @@ def get_build_dirs(self): yield os.path.realpath(temp_dir) def get_aliased_path(self, path: str) -> str: + path = path.replace("\\", "/") path_without_src = path if self.is_src: path_without_src = path.replace("src/", "") diff --git a/src/hatch_cython/utils.py b/src/hatch_cython/utils.py index f95ba16..c1d4105 100644 --- a/src/hatch_cython/utils.py +++ b/src/hatch_cython/utils.py @@ -18,24 +18,28 @@ def stale(src: str, dest: str): def memo(func: Callable[P, T]) -> Callable[P, T]: - keyed = {} + global_cache: dict = {} def wrapped(*args: P.args, **kwargs: P.kwargs) -> T: - nonlocal keyed - - # if we have a class, memo will reserve objects between - # instances, so we need to key this by the id of the instance - # note: dont call hasattr here because hasattr is pretty much - # a try catch for property access - ergo we get infinite recursive - # calls if a property is memoed + # if we have a class, store the cache on the instance itself so that + # the cache lifetime is tied to the instance. Keying by id() alone + # is unsafe because Python reuses memory addresses after GC, causing + # a new instance to get a stale cache hit from a previous one. + # note: access obj.__dict__ directly rather than hasattr/getattr to + # avoid infinite recursion when a property is decorated with @memo. if len(args) != 0 and func.__name__ in dir(args[0]): - idof = id(args[0]) - else: - idof = None - - if idof not in keyed: - keyed[idof] = func(*args, **kwargs) - return keyed[idof] + obj = args[0] + try: + cache = obj.__dict__.setdefault("__memo__", {}) + except AttributeError: + cache = global_cache.setdefault(id(obj), {}) + if func.__name__ not in cache: + cache[func.__name__] = func(*args, **kwargs) + return cache[func.__name__] + + if None not in global_cache: + global_cache[None] = func(*args, **kwargs) + return global_cache[None] return wrapped From 8a8610cfdbfcbc429245682276e76e88558587c2 Mon Sep 17 00:00:00 2001 From: Clemens Kubach Date: Thu, 19 Mar 2026 13:49:57 +0100 Subject: [PATCH 12/12] fix windows path normalization --- src/hatch_cython/config/templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hatch_cython/config/templates.py b/src/hatch_cython/config/templates.py index 33d503f..d42a279 100644 --- a/src/hatch_cython/config/templates.py +++ b/src/hatch_cython/config/templates.py @@ -36,7 +36,7 @@ def __post_init__(self): def file_match(self, file: str) -> bool: for patt in self.matches: # type: ignore[union-attr] # we take the local part out since we match on extensions - if re.match(patt, file.replace("./", "")): + if re.match(patt, file.replace("\\", "/").replace("./", "")): return True return False