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.
- mojox_build-0.2.0/.gitignore +9 -0
- mojox_build-0.2.0/PKG-INFO +75 -0
- mojox_build-0.2.0/README.md +63 -0
- mojox_build-0.2.0/pyproject.toml +22 -0
- mojox_build-0.2.0/src/mojox_build/__init__.py +29 -0
- mojox_build-0.2.0/src/mojox_build/_build.py +303 -0
- mojox_build-0.2.0/src/mojox_build/_config.py +120 -0
- mojox_build-0.2.0/src/mojox_build/_hooks.py +148 -0
- mojox_build-0.2.0/src/mojox_build/_metadata.py +98 -0
- mojox_build-0.2.0/src/mojox_build/_preflight.py +72 -0
|
@@ -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
|
+
)
|