ksp-builder 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ """ksp_builder — PEP 517 build backend for KSProject-based packages.
2
+
3
+ Wraps setuptools and combines functionality from pyjnius-builder and
4
+ pyswiftkit-builder into a single backend. User-supplied ``before_build`` /
5
+ ``after_build`` scripts configured under ``[tool.ksp-builder]`` bracket every
6
+ build, package sources can be compiled with Cython (``cythonize`` /
7
+ ``py_to_pyx``, replacing the old cythonized-app setup.py), Kivy ``.kv`` files
8
+ can be compiled into the modules beside them (``compile_kv``), and three
9
+ optional injection steps run after the base setuptools wheel/sdist is
10
+ produced:
11
+
12
+ 1. **Java sources** — if ``[tool.pyjnius]`` is present, Java files from the
13
+ configured ``java-paths`` are injected under ``.java/`` (same convention
14
+ as pyjnius-builder).
15
+
16
+ 2. **Swift artifacts** — if ``[tool.pyswiftkit]`` is present *and*
17
+ ``pyswiftkit_builder`` is installed, ``swift build`` is executed and the
18
+ compiled ``.so`` / ``.dylib`` files are injected into the wheel.
19
+
20
+ 3. **Android gradle config** — if ``[tool.kivy-school.android]`` is present,
21
+ a ``.gradle/<package_name>.json`` file containing ``gradle_dependencies``,
22
+ ``permissions``, and ``meta_data`` is injected into the wheel/sdist.
23
+ ``ksproject`` can then discover and merge these JSON files from all
24
+ installed packages to assemble the final Gradle build configuration.
25
+
26
+ The backend's own section::
27
+
28
+ [tool.ksp-builder]
29
+ before_build = "path/to/before_build_script.py"
30
+ after_build = "path/to/after_build_script.py"
31
+ cythonize = true
32
+ py_to_pyx = true
33
+ compile_kv = true
34
+ """
35
+ from __future__ import annotations
36
+
37
+ from pathlib import Path
38
+
39
+ from setuptools import build_meta as _setuptools_backend
40
+
41
+ __all__ = [
42
+ "build_wheel",
43
+ "build_sdist",
44
+ "get_requires_for_build_wheel",
45
+ "get_requires_for_build_sdist",
46
+ "prepare_metadata_for_build_wheel",
47
+ ]
48
+
49
+ # Pass-through hooks that need no extra logic.
50
+ get_requires_for_build_wheel = _setuptools_backend.get_requires_for_build_wheel
51
+ get_requires_for_build_sdist = _setuptools_backend.get_requires_for_build_sdist
52
+ prepare_metadata_for_build_wheel = _setuptools_backend.prepare_metadata_for_build_wheel
53
+
54
+ _st_build_editable = getattr(_setuptools_backend, "build_editable", None)
55
+ _st_get_requires_for_build_editable = getattr(
56
+ _setuptools_backend, "get_requires_for_build_editable", None
57
+ )
58
+ _st_prepare_metadata_for_build_editable = getattr(
59
+ _setuptools_backend, "prepare_metadata_for_build_editable", None
60
+ )
61
+
62
+ if _st_get_requires_for_build_editable is not None:
63
+ get_requires_for_build_editable = _st_get_requires_for_build_editable
64
+ __all__.append("get_requires_for_build_editable")
65
+
66
+ if _st_prepare_metadata_for_build_editable is not None:
67
+ prepare_metadata_for_build_editable = _st_prepare_metadata_for_build_editable
68
+ __all__.append("prepare_metadata_for_build_editable")
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Build hooks
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def build_wheel(
76
+ wheel_directory: str,
77
+ config_settings: dict | None = None,
78
+ metadata_directory: str | None = None,
79
+ ) -> str:
80
+ project_dir = Path.cwd()
81
+
82
+ from ._pre_post_build import run_before_build, run_after_build
83
+ run_before_build(project_dir, "wheel")
84
+
85
+ # Swift build must happen before setuptools assembles the wheel so that
86
+ # the compiled .so/.dylib files can be picked up as package data.
87
+ swift_config = _load_swift_config(project_dir)
88
+ if swift_config is not None:
89
+ _run_swift_build(project_dir, swift_config)
90
+ _force_platform_wheel()
91
+
92
+ from ._build import ksp_build
93
+ with ksp_build(project_dir):
94
+ wheel_name = _setuptools_backend.build_wheel(
95
+ wheel_directory, config_settings, metadata_directory
96
+ )
97
+ wheel_path = Path(wheel_directory) / wheel_name
98
+
99
+ if swift_config is not None:
100
+ _inject_swift_artifacts(wheel_path, swift_config, project_dir)
101
+
102
+ from ._java import get_java_source_dirs, add_java_sources_to_wheel
103
+ java_dirs = get_java_source_dirs(
104
+ config_settings=config_settings, project_root=project_dir
105
+ )
106
+ add_java_sources_to_wheel(wheel_path, java_dirs)
107
+
108
+ from ._kivy_school import read_android_config
109
+ from ._gradle import inject_gradle_config_to_wheel
110
+ android_config = read_android_config(project_dir)
111
+ if android_config is not None:
112
+ inject_gradle_config_to_wheel(wheel_path, android_config)
113
+
114
+ run_after_build(project_dir, "wheel", wheel_path)
115
+
116
+ return wheel_name
117
+
118
+
119
+ def build_sdist(sdist_directory: str, config_settings: dict | None = None) -> str:
120
+ project_dir = Path.cwd()
121
+
122
+ from ._pre_post_build import run_before_build, run_after_build
123
+ run_before_build(project_dir, "sdist")
124
+
125
+ sdist_name = _setuptools_backend.build_sdist(sdist_directory, config_settings)
126
+ sdist_path = Path(sdist_directory) / sdist_name
127
+
128
+ from ._java import get_java_source_dirs, add_java_sources_to_sdist
129
+ java_dirs = get_java_source_dirs(
130
+ config_settings=config_settings, project_root=project_dir
131
+ )
132
+ add_java_sources_to_sdist(sdist_path, java_dirs)
133
+
134
+ from ._kivy_school import read_android_config
135
+ from ._gradle import inject_gradle_config_to_sdist
136
+ android_config = read_android_config(project_dir)
137
+ if android_config is not None:
138
+ inject_gradle_config_to_sdist(sdist_path, android_config)
139
+
140
+ run_after_build(project_dir, "sdist", sdist_path)
141
+
142
+ return sdist_name
143
+
144
+
145
+ if _st_build_editable is not None:
146
+ def build_editable(
147
+ wheel_directory: str,
148
+ config_settings: dict | None = None,
149
+ metadata_directory: str | None = None,
150
+ ) -> str:
151
+ project_dir = Path.cwd()
152
+
153
+ from ._pre_post_build import run_before_build, run_after_build
154
+ run_before_build(project_dir, "editable")
155
+
156
+ swift_config = _load_swift_config(project_dir)
157
+ if swift_config is not None:
158
+ _run_swift_build(project_dir, swift_config)
159
+ _force_platform_wheel()
160
+ if _st_build_editable is None:
161
+ raise RuntimeError("Editable builds are not supported by this setuptools version.")
162
+
163
+ # Editable installs expose the source tree, so .py modules and .kv
164
+ # files are left alone; only real .pyx sources are compiled.
165
+ from ._build import ksp_build
166
+ with ksp_build(project_dir, editable=True):
167
+ wheel_name = _st_build_editable(
168
+ wheel_directory, config_settings, metadata_directory
169
+ )
170
+ wheel_path = Path(wheel_directory) / wheel_name
171
+
172
+ if swift_config is not None:
173
+ _inject_swift_artifacts(wheel_path, swift_config, project_dir)
174
+
175
+ from ._java import get_java_source_dirs, add_java_sources_to_wheel
176
+ java_dirs = get_java_source_dirs(
177
+ config_settings=config_settings, project_root=project_dir
178
+ )
179
+ add_java_sources_to_wheel(wheel_path, java_dirs)
180
+
181
+ from ._kivy_school import read_android_config
182
+ from ._gradle import inject_gradle_config_to_wheel
183
+ android_config = read_android_config(project_dir)
184
+ if android_config is not None:
185
+ inject_gradle_config_to_wheel(wheel_path, android_config)
186
+
187
+ run_after_build(project_dir, "editable", wheel_path)
188
+
189
+ return wheel_name
190
+
191
+ __all__.append("build_editable")
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Swift helpers (pyswiftkit_builder is an optional runtime dependency)
196
+ # ---------------------------------------------------------------------------
197
+
198
+ def _load_swift_config(project_dir: Path):
199
+ """Return a PSKConfig if [tool.pyswiftkit] is configured, else None."""
200
+ try:
201
+ from pyswiftkit_builder._config import read_config # type: ignore[import-untyped]
202
+ return read_config(project_dir)
203
+ except ImportError:
204
+ return None
205
+ except (FileNotFoundError, KeyError, ValueError):
206
+ return None
207
+
208
+
209
+ def _run_swift_build(project_dir: Path, config) -> None:
210
+ from pyswiftkit_builder._swift_build import build_swift # type: ignore[import-untyped]
211
+ build_swift(project_dir, config)
212
+
213
+
214
+ def _inject_swift_artifacts(wheel_path: Path, config, project_dir: Path) -> None:
215
+ from pyswiftkit_builder._swift_build import inject_artifacts # type: ignore[import-untyped]
216
+ inject_artifacts(wheel_path, config, project_dir)
217
+
218
+
219
+ def _force_platform_wheel() -> None:
220
+ """Tell setuptools this is a binary wheel (has native extensions)."""
221
+ import setuptools
222
+ setuptools.Distribution.has_ext_modules = lambda self: True # type: ignore[method-assign]
ksp_builder/_build.py ADDED
@@ -0,0 +1,250 @@
1
+ """ksp_builder._build — hooks the generated sources into the setuptools build.
2
+
3
+ ``setuptools.build_meta`` runs ``from setuptools import setup; setup()`` when a
4
+ project has no setup.py, so there is no place to declare ``ext_modules`` or a
5
+ ``cmdclass``. :func:`ksp_build` wraps ``setuptools.setup`` for the duration of
6
+ the build and passes them in there.
7
+
8
+ It is also the one place that sees both compilation steps at once, which is
9
+ what the three-switch combination needs::
10
+
11
+ [tool.ksp-builder]
12
+ cythonize = true
13
+ py_to_pyx = true
14
+ compile_kv = true
15
+
16
+ With all three on, a ``.kv`` is compiled into a module, staged as a ``.pyx``
17
+ and compiled to an extension; the ``.py`` that fed it is not shipped, and
18
+ neither is the ``.kv``.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import os
24
+ import warnings
25
+ from pathlib import Path
26
+ from typing import Iterator
27
+
28
+ from ._compile_kv import KvModule, compile_kv_modules, read_compile_kv
29
+ from ._cythonize import CyModule, build_extensions, collect_modules, read_cythonize_config
30
+ from ._packages import find_package_assets, prune_staging, read_section
31
+
32
+ __all__ = ["ksp_build"]
33
+
34
+
35
+ @contextlib.contextmanager
36
+ def ksp_build(project_root: Path, *, editable: bool = False) -> Iterator[None]:
37
+ """Compile what ``[tool.ksp-builder]`` asks for, for this block's build.
38
+
39
+ A no-op when neither Cython nor KV compilation is configured. An editable
40
+ install exposes the source tree itself, so nothing generated would ever be
41
+ imported: ``.py`` modules are left alone there and ``.kv`` files stay as
42
+ they are, and only hand-written ``.pyx`` sources are still compiled.
43
+ """
44
+ section = read_section(project_root)
45
+ config = read_cythonize_config(project_root)
46
+ compile_kv = read_compile_kv(project_root) and not editable
47
+
48
+ if config is None and not compile_kv:
49
+ yield
50
+ return
51
+
52
+ if config is not None and config.py_to_pyx and not config.cythonize:
53
+ print(
54
+ "ksp-builder: py_to_pyx is set but cythonize is not — "
55
+ "nothing will be compiled.",
56
+ flush=True,
57
+ )
58
+
59
+ convert_py = (
60
+ config is not None and config.cythonize and config.py_to_pyx and not editable
61
+ )
62
+
63
+ kv_modules: list[KvModule] = []
64
+ if compile_kv:
65
+ kv_modules = compile_kv_modules(
66
+ project_root,
67
+ as_pyx=convert_py,
68
+ exclude=config.exclude if config is not None else (),
69
+ )
70
+ for module in kv_modules:
71
+ print(f"ksp-builder: compile_kv {module.kv} -> {module.dotted}", flush=True)
72
+
73
+ cy_modules: list[CyModule] = []
74
+ if config is not None:
75
+ cy_modules = collect_modules(
76
+ project_root,
77
+ config,
78
+ allow_py_to_pyx=not editable,
79
+ skip={(m.package, m.name) for m in kv_modules},
80
+ )
81
+ cy_modules += [
82
+ CyModule(
83
+ package=module.package,
84
+ name=module.name,
85
+ source=module.source,
86
+ origin=module.kv,
87
+ converted=True,
88
+ )
89
+ for module in kv_modules
90
+ if module.as_pyx
91
+ ]
92
+
93
+ prune_staging(
94
+ project_root,
95
+ {project_root / m.source for m in cy_modules if m.converted}
96
+ | {project_root / m.source for m in kv_modules},
97
+ )
98
+
99
+ if not cy_modules and not kv_modules:
100
+ yield
101
+ return
102
+
103
+ for module in cy_modules:
104
+ print(f"ksp-builder: cythonize {module.origin} -> {module.dotted}", flush=True)
105
+
106
+ ext_modules = build_extensions(cy_modules, config) if cy_modules else []
107
+
108
+ keep_py = config is not None and config.keep_py
109
+ excluded: set[tuple[str, str]] = set()
110
+ if not keep_py:
111
+ excluded = {(m.package, m.name) for m in cy_modules if m.converted}
112
+
113
+ # A KV module that was not staged for Cython ships as source, generated
114
+ # from the .kv plus the .py beside it — so it replaces that .py, and adds a
115
+ # module of its own when there was no .py to begin with.
116
+ replacements = {
117
+ (m.package, m.name): str(project_root / m.source)
118
+ for m in kv_modules
119
+ if not m.as_pyx
120
+ }
121
+ # The .kv has been compiled into the module; shipping it too would only
122
+ # invite a second, conflicting set of rules at runtime.
123
+ compiled_kv = {os.path.abspath(project_root / m.kv) for m in kv_modules}
124
+
125
+ with _patched_setup(
126
+ ext_modules,
127
+ excluded=excluded,
128
+ replacements=replacements,
129
+ dropped_data=compiled_kv,
130
+ include_assets=bool(section.get("include_assets", True)),
131
+ ):
132
+ yield
133
+
134
+
135
+ @contextlib.contextmanager
136
+ def _patched_setup(
137
+ ext_modules: list,
138
+ *,
139
+ excluded: set[tuple[str, str]],
140
+ replacements: dict[tuple[str, str], str],
141
+ dropped_data: set[str],
142
+ include_assets: bool,
143
+ ) -> Iterator[None]:
144
+ import setuptools
145
+
146
+ original = setuptools.setup
147
+
148
+ def setup(**attrs):
149
+ attrs["ext_modules"] = list(attrs.get("ext_modules") or []) + list(ext_modules)
150
+ cmdclass = dict(attrs.get("cmdclass") or {})
151
+ cmdclass.setdefault(
152
+ "build_py",
153
+ _make_build_py(excluded, replacements, dropped_data, include_assets),
154
+ )
155
+ attrs["cmdclass"] = cmdclass
156
+ return original(**attrs)
157
+
158
+ setuptools.setup = setup
159
+ try:
160
+ with _quiet_data_directory_warning(include_assets):
161
+ yield
162
+ finally:
163
+ setuptools.setup = original
164
+
165
+
166
+ @contextlib.contextmanager
167
+ def _quiet_data_directory_warning(active: bool) -> Iterator[None]:
168
+ """Silence setuptools' "Package would be ignored" notice for asset dirs.
169
+
170
+ Shipping ``images/`` or ``fonts/`` as package data is the whole point here,
171
+ but the files reach egg-info's manifest, where setuptools sees importable
172
+ directories missing from ``packages`` and emits ~30 lines of advice for
173
+ each one. Only that one warning is filtered, and only during our build.
174
+ """
175
+ category = None
176
+ if active:
177
+ from setuptools.command import build_py as _build_py_module
178
+
179
+ abuse = getattr(_build_py_module, "_IncludePackageDataAbuse", None)
180
+ category = getattr(abuse, "_Warning", None)
181
+
182
+ if category is None:
183
+ yield
184
+ return
185
+
186
+ with warnings.catch_warnings():
187
+ warnings.filterwarnings("ignore", category=category)
188
+ yield
189
+
190
+
191
+ def _make_build_py(
192
+ excluded: set[tuple[str, str]],
193
+ replacements: dict[tuple[str, str], str],
194
+ dropped_data: set[str],
195
+ include_assets: bool,
196
+ ):
197
+ """A ``build_py`` that ships what this build generated, not what it read."""
198
+ from setuptools.command.build_py import build_py as _build_py
199
+
200
+ class build_py(_build_py):
201
+ ksp_excluded = excluded
202
+ ksp_replacements = replacements
203
+ ksp_dropped_data = dropped_data
204
+ ksp_include_assets = include_assets
205
+
206
+ def find_package_modules(self, package, package_dir):
207
+ found = [
208
+ entry
209
+ for entry in super().find_package_modules(package, package_dir)
210
+ if (entry[0], entry[1]) not in excluded
211
+ ]
212
+ generated = {
213
+ name: path
214
+ for (owner, name), path in replacements.items()
215
+ if owner == package
216
+ }
217
+ if not generated:
218
+ return found
219
+
220
+ modules = [
221
+ (pkg, name, generated.get(name, source)) for pkg, name, source in found
222
+ ]
223
+ present = {name for _, name, _ in found}
224
+ modules += [
225
+ (package, name, path)
226
+ for name, path in sorted(generated.items())
227
+ if name not in present
228
+ ]
229
+ return modules
230
+
231
+ def find_data_files(self, package, src_dir):
232
+ files = list(super().find_data_files(package, src_dir))
233
+ if include_assets and src_dir:
234
+ # exclude_data_files applies the project's exclude-package-data,
235
+ # which super() has already applied to its own half of the list.
236
+ found = self.exclude_data_files(
237
+ package, src_dir, find_package_assets(Path(src_dir))
238
+ )
239
+ seen = set(files)
240
+ for path in found:
241
+ if path not in seen:
242
+ seen.add(path)
243
+ files.append(path)
244
+ if dropped_data:
245
+ files = [
246
+ path for path in files if os.path.abspath(path) not in dropped_data
247
+ ]
248
+ return files
249
+
250
+ return build_py
@@ -0,0 +1,158 @@
1
+ """ksp_builder._compile_kv — compiles Kivy ``.kv`` files into Python modules.
2
+
3
+ Configured under ``[tool.ksp-builder]``::
4
+
5
+ [tool.ksp-builder]
6
+ compile_kv = true
7
+
8
+ Every ``.kv`` sitting in a package directory is handed to the ``compilekv``
9
+ library together with the ``.py`` of the same name beside it, and the module
10
+ the two produce is written to the staging directory. That generated module is
11
+ what ships: it carries the hand-written code *and* the widget classes the KV
12
+ rules describe, so the ``.kv`` itself is no longer needed at runtime and is
13
+ left out of the wheel.
14
+
15
+ With ``cythonize`` and ``py_to_pyx`` also on, the generated module is staged as
16
+ a ``.pyx`` and compiled to an extension instead of shipping as source.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Sequence
23
+
24
+ from ._packages import (
25
+ NEVER_CONVERTED,
26
+ STAGING_DIR,
27
+ find_packages,
28
+ is_excluded,
29
+ stage_file,
30
+ )
31
+
32
+ __all__ = [
33
+ "KvModule",
34
+ "read_compile_kv",
35
+ "find_kv_sources",
36
+ "compile_kv_modules",
37
+ ]
38
+
39
+
40
+ @dataclass
41
+ class KvModule:
42
+ """One module compiled out of a ``.kv`` file."""
43
+
44
+ package: str # dotted package name, e.g. "myapp.screens"
45
+ name: str # module name without extension, e.g. "home"
46
+ source: Path # the generated module, relative to the project root
47
+ kv: Path # the .kv it was compiled from, relative to the project root
48
+ py: Path | None # the hand-written .py merged into it, if there was one
49
+ as_pyx: bool # True when the module was staged for Cython
50
+
51
+ @property
52
+ def dotted(self) -> str:
53
+ return f"{self.package}.{self.name}" if self.package else self.name
54
+
55
+
56
+ def read_compile_kv(project_root: Path) -> bool:
57
+ """Whether ``[tool.ksp-builder] compile_kv`` is on."""
58
+ from ._packages import read_section
59
+
60
+ return bool(read_section(project_root).get("compile_kv", False))
61
+
62
+
63
+ def _compilekv():
64
+ """The ``compilekv`` library, with a build-time error if it is missing."""
65
+ try:
66
+ import compilekv
67
+ except ImportError as exc: # pragma: no cover - compilekv is a dependency
68
+ raise RuntimeError(
69
+ "ksp-builder: compile_kv is enabled but compilekv is not installed."
70
+ ) from exc
71
+ return compilekv
72
+
73
+
74
+ def find_kv_sources(project_root: Path) -> list[tuple[str, Path]]:
75
+ """The ``(package, kv path)`` pairs this project can compile.
76
+
77
+ Only ``.kv`` files directly inside a package are collected — a compiled KV
78
+ file becomes an importable module, and there is no module name for one
79
+ sitting in a plain data directory. Those stay ordinary package data.
80
+ """
81
+ find_kv_files = _compilekv().find_kv_files
82
+
83
+ sources: list[tuple[str, Path]] = []
84
+ for package, directory in sorted(find_packages(project_root).items()):
85
+ for kv_path in find_kv_files(directory, recursive=False):
86
+ sources.append((package, kv_path))
87
+ return sources
88
+
89
+
90
+ def compile_kv_modules(
91
+ project_root: Path,
92
+ *,
93
+ as_pyx: bool = False,
94
+ exclude: Sequence[str] = (),
95
+ ) -> list[KvModule]:
96
+ """Compile every package ``.kv`` into the staging directory.
97
+
98
+ ``as_pyx`` stages the result for Cython instead of shipping it as source.
99
+ ``exclude`` is ``cythonize_exclude``: a module it names is still compiled
100
+ out of its KV file, it just stays a plain ``.py``.
101
+ """
102
+ sources = find_kv_sources(project_root)
103
+ if not sources:
104
+ return []
105
+
106
+ compilekv = _compilekv()
107
+ KvCompileError = compilekv.KvCompileError
108
+ source_path_for = compilekv.source_path_for
109
+
110
+ compiler = compilekv.default_compiler()
111
+ patterns = list(exclude)
112
+ modules: list[KvModule] = []
113
+
114
+ for package, kv_path in sources:
115
+ relative_kv = kv_path.relative_to(project_root)
116
+ py_path = source_path_for(kv_path)
117
+ has_py = py_path.is_file()
118
+ py_source = py_path.read_text(encoding="utf-8") if has_py else ""
119
+
120
+ try:
121
+ generated = compiler.compile_source(
122
+ kv_path.read_text(encoding="utf-8"), py_source
123
+ )
124
+ except KvCompileError as exc:
125
+ raise RuntimeError(f"ksp-builder: cannot compile {relative_kv}: {exc}") from exc
126
+
127
+ relative_py = py_path.relative_to(project_root) if has_py else None
128
+ staged_as_pyx = as_pyx and _compilable(kv_path.stem, relative_kv, relative_py, patterns)
129
+ suffix = ".pyx" if staged_as_pyx else ".py"
130
+ staged = Path(STAGING_DIR, *package.split("."), f"{kv_path.stem}{suffix}")
131
+ stage_file(project_root / staged, generated.encode("utf-8"))
132
+
133
+ modules.append(
134
+ KvModule(
135
+ package=package,
136
+ name=kv_path.stem,
137
+ source=staged,
138
+ kv=relative_kv,
139
+ py=relative_py,
140
+ as_pyx=staged_as_pyx,
141
+ )
142
+ )
143
+
144
+ return modules
145
+
146
+
147
+ def _compilable(
148
+ stem: str,
149
+ relative_kv: Path,
150
+ relative_py: Path | None,
151
+ patterns: list[str],
152
+ ) -> bool:
153
+ """Whether the module built from this KV file may become an extension."""
154
+ if stem in NEVER_CONVERTED:
155
+ return False
156
+ if is_excluded(relative_kv, patterns):
157
+ return False
158
+ return relative_py is None or not is_excluded(relative_py, patterns)