mojox-build 0.2.0__tar.gz

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,9 @@
1
+ # Agent plans and skill artifacts
2
+ docs/plans/
3
+ docs/superpowers/
4
+
5
+ __pycache__/
6
+ *.py[cod]
7
+ dist/
8
+ *.egg-info/
9
+ .venv/
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: mojox-build
3
+ Version: 0.2.0
4
+ Summary: PEP 517 / PEP 660 build backend for Mojo libraries (compiles .mojo → .mojopkg → wheel).
5
+ Project-URL: Repository, https://github.com/Conobi/mojox
6
+ Author: Conobi
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: packaging>=23.0
10
+ Requires-Dist: tomli>=1.1.0; python_version < '3.11'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # mojox-build
14
+
15
+ PEP 517 + PEP 660 build backend that compiles a Mojo library into a `.whl`.
16
+
17
+ ## Quickstart
18
+
19
+ ```toml
20
+ # pyproject.toml
21
+ [project]
22
+ name = "boucle"
23
+ version = "0.2.0"
24
+ description = "Async event loop primitives for Mojo"
25
+ readme = "README.md"
26
+ license = "Apache-2.0"
27
+ requires-python = ">=3.10"
28
+ dependencies = ["mojox", "mojo-compiler>=0.26,<0.27"]
29
+
30
+ [build-system]
31
+ requires = ["mojox-build>=0.2", "mojo-compiler>=0.26,<0.27"]
32
+ build-backend = "mojox_build"
33
+
34
+ [tool.mojox-build]
35
+ # Either a src/ layout (default):
36
+ package-root = "src"
37
+ # Or an explicit list of packages at the repo root:
38
+ # packages = ["boucle"]
39
+ ```
40
+
41
+ `uv build` produces `dist/boucle-0.2.0-py3-none-<platform>.whl` containing `boucle.mojopkg` at `boucle-0.2.0.data/platlib/mojo_packages/boucle.mojopkg`. When installed, it lands in the venv's `site-packages/mojo_packages/`, which `mojox` discovers automatically.
42
+
43
+ ## `[tool.mojox-build]` reference
44
+
45
+ | Key | Type | Default | Purpose |
46
+ |---|---|---|---|
47
+ | `package-root` | str | `"src"` | Directory containing top-level package dirs to compile. |
48
+ | `packages` | list[str] | (auto-scan `package-root`) | Explicit list of source directories. Each becomes one `.mojopkg`. |
49
+ | `native-libs` | list[str] | `[]` | Pre-built `.so` / `.dylib` files to copy into `mojo_packages/lib/`. |
50
+ | `defines` | table | `{}` | `-D KEY=VALUE` flags passed to `mojo package`. |
51
+ | `flags` | list[str] | `[]` | Extra flags appended to every `mojo package` invocation. |
52
+ | `source-include` | list[str] | (sensible default) | Glob patterns of files to include in the **sdist**. |
53
+ | `source-exclude` | list[str] | `[]` | Glob patterns to exclude from the sdist. |
54
+ | `wheel-exclude` | list[str] | `[]` | Glob patterns to exclude from the **wheel**. |
55
+
56
+ ## What you get
57
+
58
+ - **Platform-tagged wheels.** `.mojopkg` is compiled native code, so wheels are tagged with the host platform (e.g. `manylinux_2_34_x86_64`, `macosx_13_0_arm64`). Cross-platform installs are correctly rejected by uv/pip.
59
+ - **Native lib bundling.** Drop `.so` / `.dylib` paths in `native-libs`; they ride along in `mojo_packages/lib/`, where mojox's runtime adds them to `LD_LIBRARY_PATH`.
60
+ - **PEP 660 editable installs.** `uv pip install -e .` works. For rebuild-on-change semantics, add `cache-keys = [{ file = "pyproject.toml" }, { file = "**/*.mojo" }]` to `[tool.uv]`.
61
+ - **Reproducible builds.** ZIP and tar timestamps respect `SOURCE_DATE_EPOCH`.
62
+ - **Parallel compilation.** Multi-package repos compile their `.mojopkg` files in parallel (capped at 8 workers).
63
+ - **Preflight checks.** Missing `mojo`, missing dirs, missing native libs, dynamic-version configs → one clean error message, not a stderr dump.
64
+ - **Full PEP 621 / PEP 639 metadata.** `authors`, `maintainers`, `urls`, `keywords`, `classifiers`, `optional-dependencies`, `license-files` (copied into `.dist-info/licenses/`) all flow into the wheel METADATA.
65
+ - **`--config-setting verbose=true`.** Streams `mojo package` output during builds when you need to debug.
66
+
67
+ ## How it works (architecture in five lines)
68
+
69
+ `__init__.py` re-exports the hook surface. `_hooks` is thin glue that calls into `_config` (parse pyproject), `_preflight` (environment checks), `_build` (compile + assemble), and `_metadata` (METADATA/WHEEL rendering). The whole backend has only one runtime dep beyond the stdlib: `packaging` for platform tags.
70
+
71
+ ## Limitations / known gaps
72
+
73
+ - **Dynamic `project.version`** is not supported. Declare it statically.
74
+ - **Editable installs** rebuild the full wheel on every invocation; there's no source-import fast path because `.mojopkg` is compiled bytecode.
75
+ - **Cross-compilation** (`--target` per platform) is not exposed yet. Today's wheels are host-platform only.
@@ -0,0 +1,63 @@
1
+ # mojox-build
2
+
3
+ PEP 517 + PEP 660 build backend that compiles a Mojo library into a `.whl`.
4
+
5
+ ## Quickstart
6
+
7
+ ```toml
8
+ # pyproject.toml
9
+ [project]
10
+ name = "boucle"
11
+ version = "0.2.0"
12
+ description = "Async event loop primitives for Mojo"
13
+ readme = "README.md"
14
+ license = "Apache-2.0"
15
+ requires-python = ">=3.10"
16
+ dependencies = ["mojox", "mojo-compiler>=0.26,<0.27"]
17
+
18
+ [build-system]
19
+ requires = ["mojox-build>=0.2", "mojo-compiler>=0.26,<0.27"]
20
+ build-backend = "mojox_build"
21
+
22
+ [tool.mojox-build]
23
+ # Either a src/ layout (default):
24
+ package-root = "src"
25
+ # Or an explicit list of packages at the repo root:
26
+ # packages = ["boucle"]
27
+ ```
28
+
29
+ `uv build` produces `dist/boucle-0.2.0-py3-none-<platform>.whl` containing `boucle.mojopkg` at `boucle-0.2.0.data/platlib/mojo_packages/boucle.mojopkg`. When installed, it lands in the venv's `site-packages/mojo_packages/`, which `mojox` discovers automatically.
30
+
31
+ ## `[tool.mojox-build]` reference
32
+
33
+ | Key | Type | Default | Purpose |
34
+ |---|---|---|---|
35
+ | `package-root` | str | `"src"` | Directory containing top-level package dirs to compile. |
36
+ | `packages` | list[str] | (auto-scan `package-root`) | Explicit list of source directories. Each becomes one `.mojopkg`. |
37
+ | `native-libs` | list[str] | `[]` | Pre-built `.so` / `.dylib` files to copy into `mojo_packages/lib/`. |
38
+ | `defines` | table | `{}` | `-D KEY=VALUE` flags passed to `mojo package`. |
39
+ | `flags` | list[str] | `[]` | Extra flags appended to every `mojo package` invocation. |
40
+ | `source-include` | list[str] | (sensible default) | Glob patterns of files to include in the **sdist**. |
41
+ | `source-exclude` | list[str] | `[]` | Glob patterns to exclude from the sdist. |
42
+ | `wheel-exclude` | list[str] | `[]` | Glob patterns to exclude from the **wheel**. |
43
+
44
+ ## What you get
45
+
46
+ - **Platform-tagged wheels.** `.mojopkg` is compiled native code, so wheels are tagged with the host platform (e.g. `manylinux_2_34_x86_64`, `macosx_13_0_arm64`). Cross-platform installs are correctly rejected by uv/pip.
47
+ - **Native lib bundling.** Drop `.so` / `.dylib` paths in `native-libs`; they ride along in `mojo_packages/lib/`, where mojox's runtime adds them to `LD_LIBRARY_PATH`.
48
+ - **PEP 660 editable installs.** `uv pip install -e .` works. For rebuild-on-change semantics, add `cache-keys = [{ file = "pyproject.toml" }, { file = "**/*.mojo" }]` to `[tool.uv]`.
49
+ - **Reproducible builds.** ZIP and tar timestamps respect `SOURCE_DATE_EPOCH`.
50
+ - **Parallel compilation.** Multi-package repos compile their `.mojopkg` files in parallel (capped at 8 workers).
51
+ - **Preflight checks.** Missing `mojo`, missing dirs, missing native libs, dynamic-version configs → one clean error message, not a stderr dump.
52
+ - **Full PEP 621 / PEP 639 metadata.** `authors`, `maintainers`, `urls`, `keywords`, `classifiers`, `optional-dependencies`, `license-files` (copied into `.dist-info/licenses/`) all flow into the wheel METADATA.
53
+ - **`--config-setting verbose=true`.** Streams `mojo package` output during builds when you need to debug.
54
+
55
+ ## How it works (architecture in five lines)
56
+
57
+ `__init__.py` re-exports the hook surface. `_hooks` is thin glue that calls into `_config` (parse pyproject), `_preflight` (environment checks), `_build` (compile + assemble), and `_metadata` (METADATA/WHEEL rendering). The whole backend has only one runtime dep beyond the stdlib: `packaging` for platform tags.
58
+
59
+ ## Limitations / known gaps
60
+
61
+ - **Dynamic `project.version`** is not supported. Declare it statically.
62
+ - **Editable installs** rebuild the full wheel on every invocation; there's no source-import fast path because `.mojopkg` is compiled bytecode.
63
+ - **Cross-compilation** (`--target` per platform) is not exposed yet. Today's wheels are host-platform only.
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "mojox-build"
3
+ version = "0.2.0"
4
+ description = "PEP 517 / PEP 660 build backend for Mojo libraries (compiles .mojo → .mojopkg → wheel)."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "Conobi" }]
9
+ dependencies = [
10
+ "packaging>=23.0",
11
+ 'tomli >= 1.1.0; python_version < "3.11"',
12
+ ]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/Conobi/mojox"
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/mojox_build"]
@@ -0,0 +1,29 @@
1
+ """mojox-build: PEP 517 + PEP 660 build backend for Mojo libraries.
2
+
3
+ PEP 517 frontends call these top-level names; the actual implementations live
4
+ in `_hooks` so this file can stay a flat re-export surface.
5
+ """
6
+
7
+ from ._build import GENERATOR_VERSION as __version__
8
+ from ._hooks import (
9
+ hook_build_editable as build_editable,
10
+ hook_build_sdist as build_sdist,
11
+ hook_build_wheel as build_wheel,
12
+ hook_get_requires_for_build_editable as get_requires_for_build_editable,
13
+ hook_get_requires_for_build_sdist as get_requires_for_build_sdist,
14
+ hook_get_requires_for_build_wheel as get_requires_for_build_wheel,
15
+ hook_prepare_metadata_for_build_editable as prepare_metadata_for_build_editable,
16
+ hook_prepare_metadata_for_build_wheel as prepare_metadata_for_build_wheel,
17
+ )
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "build_editable",
22
+ "build_sdist",
23
+ "build_wheel",
24
+ "get_requires_for_build_editable",
25
+ "get_requires_for_build_sdist",
26
+ "get_requires_for_build_wheel",
27
+ "prepare_metadata_for_build_editable",
28
+ "prepare_metadata_for_build_wheel",
29
+ ]
@@ -0,0 +1,303 @@
1
+ """Compile Mojo packages and assemble wheels / sdists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import concurrent.futures
6
+ import hashlib
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import sysconfig
12
+ import tarfile
13
+ import tempfile
14
+ import time
15
+ import zipfile
16
+ from base64 import urlsafe_b64encode
17
+ from fnmatch import fnmatch
18
+ from pathlib import Path
19
+
20
+ from ._config import BackendConfig, ProjectMetadata, normalize_name
21
+ from ._metadata import render_metadata, render_wheel_file
22
+
23
+ GENERATOR_VERSION = "0.2.0"
24
+
25
+ # ZIP can't represent dates before 1980; SOURCE_DATE_EPOCH=0 must clamp up.
26
+ _ZIP_EPOCH_FLOOR = 315532800 # 1980-01-01 UTC
27
+
28
+
29
+ def host_platform_tag() -> str:
30
+ """Return a PEP 425 platform tag for the host."""
31
+ try:
32
+ from packaging.tags import sys_tags # type: ignore[import-not-found]
33
+
34
+ for tag in sys_tags():
35
+ if tag.platform != "any":
36
+ return tag.platform
37
+ except ImportError:
38
+ pass
39
+ return sysconfig.get_platform().replace("-", "_").replace(".", "_")
40
+
41
+
42
+ # ============================================================
43
+ # Compilation
44
+ # ============================================================
45
+
46
+
47
+ def _compile_mojopkg(
48
+ source_dir: Path,
49
+ output: Path,
50
+ cfg: BackendConfig,
51
+ *,
52
+ verbose: bool,
53
+ ) -> None:
54
+ cmd = ["mojo", "package", str(source_dir), "-o", str(output)]
55
+ for key, value in cfg.defines.items():
56
+ cmd.extend(["-D", f"{key}={value}"])
57
+ cmd.extend(cfg.flags)
58
+
59
+ result = subprocess.run(cmd, capture_output=True, text=True)
60
+ if result.returncode != 0:
61
+ raise RuntimeError(
62
+ f"`mojo package` failed for {source_dir.name}:\n"
63
+ f" cmd: {' '.join(cmd)}\n"
64
+ f" stderr: {result.stderr.strip()}"
65
+ )
66
+ if verbose:
67
+ if result.stdout:
68
+ print(result.stdout, file=sys.stderr, end="")
69
+ if result.stderr:
70
+ print(result.stderr, file=sys.stderr, end="")
71
+
72
+
73
+ def _resolve_package_dirs(root: Path, cfg: BackendConfig) -> list[Path]:
74
+ if cfg.packages is not None:
75
+ return [root / name for name in cfg.packages]
76
+ pkg_root = root / cfg.package_root
77
+ return [p for p in sorted(pkg_root.iterdir()) if p.is_dir()]
78
+
79
+
80
+ def _compile_all(
81
+ packages: list[Path],
82
+ out_dir: Path,
83
+ cfg: BackendConfig,
84
+ *,
85
+ verbose: bool,
86
+ ) -> None:
87
+ out_dir.mkdir(parents=True, exist_ok=True)
88
+ tasks = [(src, out_dir / f"{src.name}.mojopkg") for src in packages]
89
+
90
+ if len(tasks) <= 1:
91
+ for src, out in tasks:
92
+ _compile_mojopkg(src, out, cfg, verbose=verbose)
93
+ return
94
+
95
+ workers = min(len(tasks), 8)
96
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
97
+ futures = [
98
+ pool.submit(_compile_mojopkg, src, out, cfg, verbose=verbose)
99
+ for src, out in tasks
100
+ ]
101
+ for f in concurrent.futures.as_completed(futures):
102
+ f.result()
103
+
104
+
105
+ def _copy_native_libs(root: Path, lib_dir: Path, native_libs: list[str]) -> None:
106
+ if not native_libs:
107
+ return
108
+ lib_dir.mkdir(parents=True, exist_ok=True)
109
+ for rel in native_libs:
110
+ src = root / rel
111
+ shutil.copy2(src, lib_dir / src.name)
112
+
113
+
114
+ def _copy_license_files(
115
+ root: Path, dist_info: Path, license_files: list[str]
116
+ ) -> list[str]:
117
+ if not license_files:
118
+ return []
119
+ licenses_dir = dist_info / "licenses"
120
+ licenses_dir.mkdir(parents=True, exist_ok=True)
121
+ copied: list[str] = []
122
+ seen: set[str] = set()
123
+ for pattern in license_files:
124
+ for src in sorted(root.glob(pattern)):
125
+ if src.is_file() and src.name not in seen:
126
+ shutil.copy2(src, licenses_dir / src.name)
127
+ copied.append(f"licenses/{src.name}")
128
+ seen.add(src.name)
129
+ return copied
130
+
131
+
132
+ # ============================================================
133
+ # Deterministic timestamps
134
+ # ============================================================
135
+
136
+
137
+ def _zip_date_time() -> tuple[int, int, int, int, int, int]:
138
+ epoch = int(os.environ.get("SOURCE_DATE_EPOCH", _ZIP_EPOCH_FLOOR))
139
+ epoch = max(epoch, _ZIP_EPOCH_FLOOR)
140
+ t = time.gmtime(epoch)
141
+ return (t.tm_year, t.tm_mon, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec)
142
+
143
+
144
+ def _tar_epoch() -> int:
145
+ return int(os.environ.get("SOURCE_DATE_EPOCH", _ZIP_EPOCH_FLOOR))
146
+
147
+
148
+ # ============================================================
149
+ # Wheel assembly
150
+ # ============================================================
151
+
152
+
153
+ def _zip_dir(
154
+ staging: Path,
155
+ wheel_path: Path,
156
+ dist_info_name: str,
157
+ wheel_exclude: list[str],
158
+ ) -> None:
159
+ date_time = _zip_date_time()
160
+ record_lines: list[str] = []
161
+
162
+ with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as zf:
163
+ files = sorted(p for p in staging.rglob("*") if p.is_file())
164
+ for path in files:
165
+ arcname = str(path.relative_to(staging)).replace(os.sep, "/")
166
+ if any(fnmatch(arcname, pat) for pat in wheel_exclude):
167
+ continue
168
+ content = path.read_bytes()
169
+ digest = (
170
+ "sha256="
171
+ + urlsafe_b64encode(hashlib.sha256(content).digest())
172
+ .rstrip(b"=")
173
+ .decode()
174
+ )
175
+ zinfo = zipfile.ZipInfo(arcname, date_time=date_time)
176
+ zinfo.compress_type = zipfile.ZIP_DEFLATED
177
+ zf.writestr(zinfo, content)
178
+ record_lines.append(f"{arcname},{digest},{len(content)}")
179
+
180
+ record_arc = f"{dist_info_name}/RECORD"
181
+ record_lines.append(f"{record_arc},,")
182
+ zinfo = zipfile.ZipInfo(record_arc, date_time=date_time)
183
+ zinfo.compress_type = zipfile.ZIP_DEFLATED
184
+ zf.writestr(zinfo, "\n".join(record_lines) + "\n")
185
+
186
+
187
+ def build_wheel(
188
+ root: Path,
189
+ project: ProjectMetadata,
190
+ backend: BackendConfig,
191
+ *,
192
+ wheel_directory: Path,
193
+ verbose: bool = False,
194
+ ) -> str:
195
+ name = normalize_name(project.name)
196
+ version = project.version
197
+ platform_tag = host_platform_tag()
198
+ tag = f"py3-none-{platform_tag}"
199
+ wheel_name = f"{name}-{version}-{tag}.whl"
200
+
201
+ with tempfile.TemporaryDirectory() as tmpdir:
202
+ staging = Path(tmpdir)
203
+ data_dir = staging / f"{name}-{version}.data"
204
+ platlib = data_dir / "platlib"
205
+ pkg_dir = platlib / "mojo_packages"
206
+ lib_dir = pkg_dir / "lib"
207
+ dist_info = staging / f"{name}-{version}.dist-info"
208
+ dist_info.mkdir()
209
+
210
+ packages = _resolve_package_dirs(root, backend)
211
+ _compile_all(packages, pkg_dir, backend, verbose=verbose)
212
+ _copy_native_libs(root, lib_dir, backend.native_libs)
213
+
214
+ license_relpaths = _copy_license_files(
215
+ root, dist_info, project.license_files
216
+ )
217
+ (dist_info / "METADATA").write_text(
218
+ render_metadata(project, root, license_relpaths)
219
+ )
220
+ (dist_info / "WHEEL").write_text(
221
+ render_wheel_file(
222
+ tag=tag,
223
+ root_is_purelib=False,
224
+ generator_version=GENERATOR_VERSION,
225
+ )
226
+ )
227
+
228
+ _zip_dir(staging, wheel_directory / wheel_name, dist_info.name, backend.wheel_exclude)
229
+
230
+ return wheel_name
231
+
232
+
233
+ # ============================================================
234
+ # Sdist assembly
235
+ # ============================================================
236
+
237
+
238
+ _DEFAULT_SDIST_SKIP_TOP = {"dist", "build", "__pycache__", ".venv", ".git", ".tox", ".mypy_cache", ".pytest_cache"}
239
+
240
+
241
+ def _match_any(rel: str, patterns: list[str]) -> bool:
242
+ return any(fnmatch(rel, pat) for pat in patterns)
243
+
244
+
245
+ def _sdist_files(root: Path, cfg: BackendConfig) -> list[Path]:
246
+ if cfg.source_include:
247
+ files: list[Path] = []
248
+ for pattern in cfg.source_include:
249
+ files.extend(p for p in root.glob(pattern) if p.is_file())
250
+ else:
251
+ files = []
252
+ for p in root.rglob("*"):
253
+ if not p.is_file():
254
+ continue
255
+ rel = p.relative_to(root)
256
+ if any(part.startswith(".") for part in rel.parts):
257
+ continue
258
+ if rel.parts and rel.parts[0] in _DEFAULT_SDIST_SKIP_TOP:
259
+ continue
260
+ files.append(p)
261
+
262
+ if cfg.source_exclude:
263
+ files = [
264
+ p for p in files
265
+ if not _match_any(str(p.relative_to(root)).replace(os.sep, "/"), cfg.source_exclude)
266
+ ]
267
+
268
+ # Always include pyproject.toml + readme + license files if they exist.
269
+ extras: list[Path] = []
270
+ for name in ("pyproject.toml",):
271
+ p = root / name
272
+ if p.is_file():
273
+ extras.append(p)
274
+ return sorted(set(files) | set(extras))
275
+
276
+
277
+ def build_sdist(
278
+ root: Path,
279
+ project: ProjectMetadata,
280
+ backend: BackendConfig,
281
+ *,
282
+ sdist_directory: Path,
283
+ ) -> str:
284
+ name = normalize_name(project.name)
285
+ version = project.version
286
+ sdist_name = f"{name}-{version}.tar.gz"
287
+ sdist_path = sdist_directory / sdist_name
288
+
289
+ files = _sdist_files(root, backend)
290
+ epoch = _tar_epoch()
291
+
292
+ def _reset(info: tarfile.TarInfo) -> tarfile.TarInfo:
293
+ info.mtime = epoch
294
+ info.uid = info.gid = 0
295
+ info.uname = info.gname = ""
296
+ return info
297
+
298
+ with tarfile.open(sdist_path, "w:gz") as tar:
299
+ for path in files:
300
+ arc = f"{name}-{version}/{path.relative_to(root)}"
301
+ tar.add(path, arcname=arc, filter=_reset, recursive=False)
302
+
303
+ return sdist_name
@@ -0,0 +1,120 @@
1
+ """Parse pyproject.toml into typed dataclasses, validate required fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib
11
+ else:
12
+ import tomli as tomllib # type: ignore[import-not-found, no-redef]
13
+
14
+
15
+ class BuildConfigError(RuntimeError):
16
+ """User-facing configuration error (clean message, no Python stack)."""
17
+
18
+
19
+ @dataclass
20
+ class BackendConfig:
21
+ package_root: str = "src"
22
+ packages: list[str] | None = None
23
+ native_libs: list[str] = field(default_factory=list)
24
+ defines: dict[str, str] = field(default_factory=dict)
25
+ flags: list[str] = field(default_factory=list)
26
+ source_include: list[str] | None = None
27
+ source_exclude: list[str] = field(default_factory=list)
28
+ wheel_exclude: list[str] = field(default_factory=list)
29
+
30
+ @classmethod
31
+ def from_dict(cls, d: dict) -> "BackendConfig":
32
+ return cls(
33
+ package_root=d.get("package-root", "src"),
34
+ packages=list(d["packages"]) if "packages" in d else None,
35
+ native_libs=list(d.get("native-libs", [])),
36
+ defines={str(k): str(v) for k, v in d.get("defines", {}).items()},
37
+ flags=list(d.get("flags", [])),
38
+ source_include=list(d["source-include"]) if "source-include" in d else None,
39
+ source_exclude=list(d.get("source-exclude", [])),
40
+ wheel_exclude=list(d.get("wheel-exclude", [])),
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class ProjectMetadata:
46
+ name: str
47
+ version: str
48
+ description: str | None = None
49
+ readme: str | None = None
50
+ license: str | None = None
51
+ license_files: list[str] = field(default_factory=list)
52
+ requires_python: str | None = None
53
+ keywords: list[str] = field(default_factory=list)
54
+ authors: list[dict] = field(default_factory=list)
55
+ maintainers: list[dict] = field(default_factory=list)
56
+ urls: dict[str, str] = field(default_factory=dict)
57
+ classifiers: list[str] = field(default_factory=list)
58
+ dependencies: list[str] = field(default_factory=list)
59
+ optional_dependencies: dict[str, list[str]] = field(default_factory=dict)
60
+
61
+ @classmethod
62
+ def from_dict(cls, project: dict) -> "ProjectMetadata":
63
+ dynamic = set(project.get("dynamic", []))
64
+ if "name" not in project:
65
+ raise BuildConfigError("[project] is missing required field `name`.")
66
+ if "version" not in project:
67
+ if "version" in dynamic:
68
+ raise BuildConfigError(
69
+ "[project] declares `version` as dynamic, which mojox-build does not "
70
+ "currently support — set project.version statically in pyproject.toml."
71
+ )
72
+ raise BuildConfigError("[project] is missing required field `version`.")
73
+
74
+ license_str: str | None = None
75
+ license_field = project.get("license")
76
+ if isinstance(license_field, str):
77
+ license_str = license_field
78
+ elif isinstance(license_field, dict):
79
+ license_str = license_field.get("text") or license_field.get("file")
80
+
81
+ readme_field = project.get("readme")
82
+ readme_str = readme_field if isinstance(readme_field, str) else None
83
+
84
+ return cls(
85
+ name=project["name"],
86
+ version=project["version"],
87
+ description=project.get("description"),
88
+ readme=readme_str,
89
+ license=license_str,
90
+ license_files=list(project.get("license-files", [])),
91
+ requires_python=project.get("requires-python"),
92
+ keywords=list(project.get("keywords", [])),
93
+ authors=list(project.get("authors", [])),
94
+ maintainers=list(project.get("maintainers", [])),
95
+ urls=dict(project.get("urls", {})),
96
+ classifiers=list(project.get("classifiers", [])),
97
+ dependencies=list(project.get("dependencies", [])),
98
+ optional_dependencies={
99
+ k: list(v) for k, v in project.get("optional-dependencies", {}).items()
100
+ },
101
+ )
102
+
103
+
104
+ def load(pyproject_path: Path) -> tuple[ProjectMetadata, BackendConfig]:
105
+ if not pyproject_path.is_file():
106
+ raise BuildConfigError(f"{pyproject_path} not found")
107
+ with open(pyproject_path, "rb") as f:
108
+ data = tomllib.load(f)
109
+ if "project" not in data:
110
+ raise BuildConfigError(
111
+ f"{pyproject_path}: missing [project] table (required by PEP 621)."
112
+ )
113
+ project = ProjectMetadata.from_dict(data["project"])
114
+ backend = BackendConfig.from_dict(data.get("tool", {}).get("mojox-build", {}))
115
+ return project, backend
116
+
117
+
118
+ def normalize_name(name: str) -> str:
119
+ """PEP 503 / PEP 491 normalization for wheel filenames."""
120
+ return name.lower().replace("-", "_").replace(".", "_")
@@ -0,0 +1,148 @@
1
+ """PEP 517 + PEP 660 hook implementations.
2
+
3
+ The hooks live here so `__init__.py` can stay a clean re-export surface.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from ._build import GENERATOR_VERSION, build_sdist, build_wheel, host_platform_tag
12
+ from ._config import BuildConfigError, load, normalize_name
13
+ from ._metadata import render_metadata, render_wheel_file
14
+ from ._preflight import check as _preflight
15
+
16
+
17
+ def _verbose_from(config_settings: dict | None) -> bool:
18
+ if not config_settings:
19
+ return False
20
+ v = config_settings.get("verbose")
21
+ if isinstance(v, bool):
22
+ return v
23
+ return str(v).lower() in {"1", "true", "yes", "on"}
24
+
25
+
26
+ def _run(action, *args, **kwargs):
27
+ """Run a hook, converting BuildConfigError into a clean fatal message."""
28
+ try:
29
+ return action(*args, **kwargs)
30
+ except BuildConfigError as e:
31
+ print(f"\nmojox-build: {e}\n", file=sys.stderr)
32
+ raise SystemExit(1) from e
33
+
34
+
35
+ # ============================================================
36
+ # PEP 517 — wheels
37
+ # ============================================================
38
+
39
+
40
+ def hook_build_wheel(
41
+ wheel_directory: str,
42
+ config_settings: dict | None = None,
43
+ metadata_directory: str | None = None,
44
+ ) -> str:
45
+ del metadata_directory
46
+
47
+ def _do() -> str:
48
+ root = Path.cwd()
49
+ project, backend = load(root / "pyproject.toml")
50
+ _preflight(root, project, backend)
51
+ return build_wheel(
52
+ root,
53
+ project,
54
+ backend,
55
+ wheel_directory=Path(wheel_directory),
56
+ verbose=_verbose_from(config_settings),
57
+ )
58
+
59
+ return _run(_do)
60
+
61
+
62
+ def hook_get_requires_for_build_wheel(config_settings: dict | None = None) -> list[str]:
63
+ del config_settings
64
+ return []
65
+
66
+
67
+ def hook_prepare_metadata_for_build_wheel(
68
+ metadata_directory: str,
69
+ config_settings: dict | None = None,
70
+ ) -> str:
71
+ del config_settings
72
+
73
+ def _do() -> str:
74
+ root = Path.cwd()
75
+ project, _ = load(root / "pyproject.toml")
76
+ name = normalize_name(project.name)
77
+ dist_info_name = f"{name}-{project.version}.dist-info"
78
+ dist_info = Path(metadata_directory) / dist_info_name
79
+ dist_info.mkdir()
80
+ (dist_info / "METADATA").write_text(render_metadata(project, root, []))
81
+ (dist_info / "WHEEL").write_text(
82
+ render_wheel_file(
83
+ tag=f"py3-none-{host_platform_tag()}",
84
+ root_is_purelib=False,
85
+ generator_version=GENERATOR_VERSION,
86
+ )
87
+ )
88
+ return dist_info_name
89
+
90
+ return _run(_do)
91
+
92
+
93
+ # ============================================================
94
+ # PEP 517 — sdists
95
+ # ============================================================
96
+
97
+
98
+ def hook_build_sdist(
99
+ sdist_directory: str,
100
+ config_settings: dict | None = None,
101
+ ) -> str:
102
+ del config_settings
103
+
104
+ def _do() -> str:
105
+ root = Path.cwd()
106
+ project, backend = load(root / "pyproject.toml")
107
+ # Preflight is skipped for sdist (no compilation happens).
108
+ return build_sdist(
109
+ root, project, backend, sdist_directory=Path(sdist_directory)
110
+ )
111
+
112
+ return _run(_do)
113
+
114
+
115
+ def hook_get_requires_for_build_sdist(config_settings: dict | None = None) -> list[str]:
116
+ del config_settings
117
+ return []
118
+
119
+
120
+ # ============================================================
121
+ # PEP 660 — editable installs
122
+ # ============================================================
123
+ # Mojo compiles to .mojopkg artifacts; there is no source-import mode. So
124
+ # "editable" effectively means "build a normal wheel". Consumers who want
125
+ # rebuild-on-change semantics should add a uv cache-keys entry like:
126
+ #
127
+ # [tool.uv]
128
+ # cache-keys = [{ file = "pyproject.toml" }, { file = "**/*.mojo" }]
129
+
130
+
131
+ def hook_build_editable(
132
+ wheel_directory: str,
133
+ config_settings: dict | None = None,
134
+ metadata_directory: str | None = None,
135
+ ) -> str:
136
+ return hook_build_wheel(wheel_directory, config_settings, metadata_directory)
137
+
138
+
139
+ def hook_get_requires_for_build_editable(config_settings: dict | None = None) -> list[str]:
140
+ del config_settings
141
+ return []
142
+
143
+
144
+ def hook_prepare_metadata_for_build_editable(
145
+ metadata_directory: str,
146
+ config_settings: dict | None = None,
147
+ ) -> str:
148
+ return hook_prepare_metadata_for_build_wheel(metadata_directory, config_settings)
@@ -0,0 +1,98 @@
1
+ """Render PEP 621 / 643 METADATA and PEP 427 WHEEL files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ._config import ProjectMetadata
8
+
9
+
10
+ def _person(p: dict) -> tuple[str | None, str | None]:
11
+ name = p.get("name", "").strip() or None
12
+ email = p.get("email", "").strip() or None
13
+ return name, email
14
+
15
+
16
+ def _render_person_line(p: dict, *, kind: str) -> str | None:
17
+ name, email = _person(p)
18
+ if email:
19
+ rendered = f"{name} <{email}>" if name else email
20
+ return f"{kind}-email: {rendered}"
21
+ if name:
22
+ return f"{kind}: {name}"
23
+ return None
24
+
25
+
26
+ def render_metadata(
27
+ project: ProjectMetadata,
28
+ root: Path,
29
+ license_relpaths: list[str],
30
+ ) -> str:
31
+ lines: list[str] = [
32
+ "Metadata-Version: 2.4",
33
+ f"Name: {project.name}",
34
+ f"Version: {project.version}",
35
+ ]
36
+ if project.description:
37
+ lines.append(f"Summary: {project.description}")
38
+ if project.requires_python:
39
+ lines.append(f"Requires-Python: {project.requires_python}")
40
+ if project.license:
41
+ lines.append(f"License-Expression: {project.license}")
42
+ for rel in license_relpaths:
43
+ lines.append(f"License-File: {rel}")
44
+
45
+ for kw in project.keywords:
46
+ lines.append(f"Keywords: {kw}")
47
+ for cls in project.classifiers:
48
+ lines.append(f"Classifier: {cls}")
49
+
50
+ for person in project.authors:
51
+ rendered = _render_person_line(person, kind="Author")
52
+ if rendered:
53
+ lines.append(rendered)
54
+ for person in project.maintainers:
55
+ rendered = _render_person_line(person, kind="Maintainer")
56
+ if rendered:
57
+ lines.append(rendered)
58
+
59
+ for label, url in project.urls.items():
60
+ lines.append(f"Project-URL: {label}, {url}")
61
+
62
+ for dep in project.dependencies:
63
+ lines.append(f"Requires-Dist: {dep}")
64
+ for extra, deps in project.optional_dependencies.items():
65
+ lines.append(f"Provides-Extra: {extra}")
66
+ for dep in deps:
67
+ lines.append(f"Requires-Dist: {dep} ; extra == '{extra}'")
68
+
69
+ body = ""
70
+ if project.readme:
71
+ readme_path = root / project.readme
72
+ if readme_path.is_file():
73
+ body = readme_path.read_text(encoding="utf-8")
74
+ lower = project.readme.lower()
75
+ content_type = (
76
+ "text/markdown"
77
+ if lower.endswith(".md")
78
+ else "text/x-rst"
79
+ if lower.endswith(".rst")
80
+ else "text/plain"
81
+ )
82
+ lines.append(f"Description-Content-Type: {content_type}")
83
+
84
+ return "\n".join(lines) + "\n\n" + body
85
+
86
+
87
+ def render_wheel_file(
88
+ *,
89
+ tag: str,
90
+ root_is_purelib: bool,
91
+ generator_version: str,
92
+ ) -> str:
93
+ return (
94
+ "Wheel-Version: 1.0\n"
95
+ f"Generator: mojox-build {generator_version}\n"
96
+ f"Root-Is-Purelib: {'true' if root_is_purelib else 'false'}\n"
97
+ f"Tag: {tag}\n"
98
+ )
@@ -0,0 +1,72 @@
1
+ """Preflight checks: validate environment + config before a build."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from ._config import BackendConfig, BuildConfigError, ProjectMetadata
10
+
11
+
12
+ def check(root: Path, project: ProjectMetadata, backend: BackendConfig) -> None:
13
+ """Raise BuildConfigError with a clean message if anything is off."""
14
+ _check_mojo_binary()
15
+ _check_package_dirs(root, backend)
16
+ _check_native_libs(root, backend)
17
+ _check_readme(root, project)
18
+
19
+
20
+ def _check_mojo_binary() -> None:
21
+ if not shutil.which("mojo"):
22
+ raise BuildConfigError(
23
+ "cannot find `mojo` on PATH. Add a `mojo-compiler` requirement to "
24
+ "[build-system].requires so uv installs it in the build environment."
25
+ )
26
+ result = subprocess.run(
27
+ ["mojo", "--version"], capture_output=True, text=True
28
+ )
29
+ if result.returncode != 0:
30
+ raise BuildConfigError(
31
+ f"`mojo --version` failed (exit {result.returncode}). stderr:\n"
32
+ f" {result.stderr.strip()}"
33
+ )
34
+
35
+
36
+ def _check_package_dirs(root: Path, backend: BackendConfig) -> None:
37
+ if backend.packages is not None:
38
+ missing = [p for p in backend.packages if not (root / p).is_dir()]
39
+ if missing:
40
+ raise BuildConfigError(
41
+ f"[tool.mojox-build].packages references nonexistent directories: "
42
+ f"{missing} (relative to {root})."
43
+ )
44
+ return
45
+
46
+ pkg_root = root / backend.package_root
47
+ if not pkg_root.is_dir():
48
+ raise BuildConfigError(
49
+ f"[tool.mojox-build].package-root = {backend.package_root!r} not found "
50
+ f"at {pkg_root}. Either create it, or set `packages = [...]` explicitly."
51
+ )
52
+ if not any(p.is_dir() for p in pkg_root.iterdir()):
53
+ raise BuildConfigError(
54
+ f"no package directories found under {pkg_root}. Each top-level "
55
+ f"directory becomes one .mojopkg in the wheel."
56
+ )
57
+
58
+
59
+ def _check_native_libs(root: Path, backend: BackendConfig) -> None:
60
+ missing = [p for p in backend.native_libs if not (root / p).is_file()]
61
+ if missing:
62
+ raise BuildConfigError(
63
+ f"[tool.mojox-build].native-libs references files that do not exist: "
64
+ f"{missing}. Build them before invoking `uv build`."
65
+ )
66
+
67
+
68
+ def _check_readme(root: Path, project: ProjectMetadata) -> None:
69
+ if project.readme and not (root / project.readme).is_file():
70
+ raise BuildConfigError(
71
+ f"[project].readme = {project.readme!r} does not exist."
72
+ )