cibuildmp 0.3.0a1__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.
cibuildmp/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """cibuildmp -- build MicroPython native C extensions on CI, and locally."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ __version__ = version("cibuildmp")
10
+ except PackageNotFoundError: # running from a source tree, not installed
11
+ __version__ = "0.0.0.dev0"
12
+
13
+ from .cli import main as _main
14
+
15
+ __all__ = ["__version__", "main"]
16
+
17
+
18
+ def main() -> None:
19
+ sys.exit(_main())
cibuildmp/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from cibuildmp import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
cibuildmp/cli.py ADDED
@@ -0,0 +1,322 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from . import __version__
13
+ from .options import BuildOptions, ConfigError, Options
14
+ from .sources import (
15
+ SourceError,
16
+ build_mpy_cross,
17
+ cache_root,
18
+ fetch_micropython,
19
+ read_mpy_abi,
20
+ )
21
+ from .targets import NATMOD_ARCHS, Target, UnknownArchError, is_abi_known
22
+ from .toolchains import ResolvedToolchain, ToolchainError, resolve
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="cibuildmp",
28
+ description="Build MicroPython native C extensions across every target "
29
+ "a module supports, from one declarative config.",
30
+ epilog="Most options are supplied via cibuildmp.toml or CIBMP_* environment "
31
+ "variables. See docs/BACKLOG.md for the design and what is implemented.",
32
+ )
33
+ parser.add_argument(
34
+ "package_dir",
35
+ nargs="?",
36
+ default=".",
37
+ type=Path,
38
+ help='Directory containing the module and its config (default: ".")',
39
+ )
40
+ parser.add_argument(
41
+ "--version",
42
+ action="version",
43
+ version=f"cibuildmp {__version__}",
44
+ )
45
+ parser.add_argument(
46
+ "--config-file",
47
+ type=Path,
48
+ default=None,
49
+ help="Config file to use instead of <package_dir>/cibuildmp.toml",
50
+ )
51
+ parser.add_argument(
52
+ "--output-dir",
53
+ type=Path,
54
+ default=None,
55
+ help="Where to collect built .mpy files (overrides the config)",
56
+ )
57
+ parser.add_argument(
58
+ "--only",
59
+ default=None,
60
+ metavar="IDENTIFIER",
61
+ help="Build exactly this one identifier, overriding the config's own "
62
+ "build/skip selectors. Opt into a job-per-target CI layout with this; "
63
+ "the default is one invocation building every selected target.",
64
+ )
65
+ parser.add_argument(
66
+ "--archs",
67
+ default=None,
68
+ help="Comma-separated list of architectures to build, or 'all'. Overrides "
69
+ "the config's own archs. There is no 'auto': every natmod arch is a "
70
+ "cross-compile, so none of them depends on what this machine is.",
71
+ )
72
+ parser.add_argument(
73
+ "--toolchain",
74
+ default="auto",
75
+ choices=["auto", "host", "download"],
76
+ help="How to obtain each target's cross toolchain. auto (default) uses "
77
+ "one already on PATH and downloads a pinned tarball otherwise; host "
78
+ "refuses to download; download ignores what is on PATH.",
79
+ )
80
+ parser.add_argument(
81
+ "--clean-cache",
82
+ action="store_true",
83
+ help="Delete cibuildmp's cache (MicroPython checkouts, mpy-cross builds "
84
+ "and downloaded toolchains) and exit",
85
+ )
86
+ parser.add_argument(
87
+ "--dry-run",
88
+ action="store_true",
89
+ help="Print the resolved build plan and exit without building",
90
+ )
91
+ parser.add_argument(
92
+ "--print-build-identifiers",
93
+ action="store_true",
94
+ help="Print the identifiers this config selects, one per line, then exit",
95
+ )
96
+ parser.add_argument(
97
+ "--print-build-matrix",
98
+ action="store_true",
99
+ help="Print a JSON array of {only, os} objects, then exit. Feed it to a "
100
+ "GitHub Actions `strategy.matrix.include` via fromJSON() to get one job "
101
+ "per target. Only worth it when targets need different runners.",
102
+ )
103
+ parser.add_argument(
104
+ "--json",
105
+ action="store_true",
106
+ help="With --print-build-identifiers, emit a JSON array instead of lines",
107
+ )
108
+ parser.add_argument(
109
+ "--allow-empty",
110
+ action="store_true",
111
+ help="Do not report an error code if no target is selected",
112
+ )
113
+ parser.add_argument(
114
+ "--debug-traceback",
115
+ action="store_true",
116
+ default=os.environ.get("CIBMP_DEBUG_TRACEBACK", "") not in {"", "0"},
117
+ help="Print a full traceback for all errors",
118
+ )
119
+ parser.add_argument(
120
+ "--platform",
121
+ default="natmod",
122
+ choices=["natmod"],
123
+ help="Build mode. Only natmod is implemented; usermod is planned "
124
+ "(see docs/BACKLOG.md).",
125
+ )
126
+ return parser
127
+
128
+
129
+ def _plan_line(
130
+ index: int,
131
+ total: int,
132
+ options: BuildOptions,
133
+ chain: ResolvedToolchain | None = None,
134
+ ) -> str:
135
+ make = ["make", "-C", options.module_dir, f"ARCH={options.target.arch}"]
136
+ make += options.extra_make_args
137
+ make.append(options.make_target)
138
+ # The prefix actually in play, which is not always the one dynruntime.mk
139
+ # hardcodes -- showing target.cross here would contradict the CROSS=
140
+ # override sitting in the same line's make command.
141
+ prefix = chain.prefix if chain is not None else options.target.cross
142
+ # Right-align the counter so the columns after it stay put once the
143
+ # index gains a digit ([10/10] is wider than [9/10]).
144
+ counter = f"[{index:>{len(str(total))}}/{total}]"
145
+ return (
146
+ f"{counter} {options.target.identifier:<28} "
147
+ f"CROSS={prefix or '(host)':<22} {' '.join(make)}"
148
+ )
149
+
150
+
151
+ def build(options: Options, targets: list[Target], *, toolchain: str = "auto") -> int:
152
+ """Build every selected target in one invocation.
153
+
154
+ Sequential and in-process on purpose (D9), the same shape cibuildwheel
155
+ uses for the Python versions inside one runner. Fetching MicroPython and
156
+ building mpy-cross are identical for every natmod arch, so doing them
157
+ once here is strictly cheaper than paying for them in each of ten matrix
158
+ legs -- and unlike cibuildwheel, no natmod target needs a runner any
159
+ other target cannot use, so nothing forces a fan-out. Callers who want
160
+ one anyway (failure isolation, wall-clock) opt in with --only.
161
+ """
162
+ resolved = [options.build_options(t) for t in targets]
163
+ total = len(resolved)
164
+
165
+ # Toolchains are resolved before the plan is printed, not after: where a
166
+ # toolchain's prefix is not the one dynruntime.mk hardcodes, resolution
167
+ # adds a CROSS= override, and a plan printed first would show a make
168
+ # command that is not the one about to run.
169
+ print(f"cibuildmp: resolving toolchains for {total} target(s)")
170
+ chains = []
171
+ for build_options in resolved:
172
+ chain = resolve(build_options.target.arch, strategy=toolchain)
173
+ chains.append(chain)
174
+ build_options.extra_make_args = [
175
+ *chain.make_overrides,
176
+ *build_options.extra_make_args,
177
+ ]
178
+
179
+ print(
180
+ f"\ncibuildmp: {total} target(s) against MicroPython {options.micropython} "
181
+ f"(.mpy ABI {options.abi})"
182
+ )
183
+ for index, (build_options, chain) in enumerate(
184
+ zip(resolved, chains, strict=True), 1
185
+ ):
186
+ print(" " + _plan_line(index, total, build_options, chain))
187
+ print(f" {chain.describe()}")
188
+
189
+ # Shared setup, paid once for the whole invocation rather than once per
190
+ # matrix leg -- see build()'s own docstring and D9.
191
+ print("\ncibuildmp: preparing MicroPython")
192
+ mpy_dir = fetch_micropython(
193
+ options.micropython, submodules=options.micropython_submodules
194
+ )
195
+ build_mpy_cross(mpy_dir)
196
+
197
+ # The checkout is authoritative about the ABI; targets.MPY_ABI's table
198
+ # is only a way to answer the question without one. A disagreement means
199
+ # the identifiers already printed are wrong, so it stops here rather
200
+ # than producing files labelled with an ABI they do not have.
201
+ actual_abi = read_mpy_abi(mpy_dir)
202
+ if actual_abi != options.abi:
203
+ raise SourceError(
204
+ f"MicroPython {options.micropython} has .mpy ABI {actual_abi}, but the "
205
+ f"identifiers were built assuming {options.abi}. Set `mpy-abi = "
206
+ f'"{actual_abi}"` in the config, or report the stale entry in '
207
+ f"cibuildmp.targets.MPY_ABI."
208
+ )
209
+
210
+ # M3 lands here: run make under that toolchain, then collect and verify
211
+ # the output.
212
+ sys.stdout.flush() # keep the output above ahead of the stderr note below
213
+ print(
214
+ "\ncibuildmp: the per-target build is not implemented yet (M2 ships "
215
+ "toolchain resolution). Re-run with --dry-run to get the plan as a "
216
+ "success.",
217
+ file=sys.stderr,
218
+ )
219
+ return 1
220
+
221
+
222
+ def main(argv: list[str] | None = None) -> int:
223
+ parser = build_parser()
224
+ args = parser.parse_args(argv)
225
+
226
+ if args.clean_cache:
227
+ root = cache_root()
228
+ if not root.exists():
229
+ print(f"cibuildmp: nothing to clean ({root} does not exist)")
230
+ return 0
231
+ shutil.rmtree(root)
232
+ print(f"cibuildmp: removed {root}")
233
+ return 0
234
+
235
+ try:
236
+ options = Options.load(args.package_dir, args.config_file)
237
+ if args.output_dir is not None:
238
+ options.output_dir = args.output_dir
239
+ if args.archs is not None:
240
+ options.archs = (
241
+ list(NATMOD_ARCHS)
242
+ if args.archs.strip() == "all"
243
+ else [a.strip() for a in args.archs.split(",") if a.strip()]
244
+ )
245
+ targets = options.targets()
246
+ except (ConfigError, UnknownArchError, SourceError) as exc:
247
+ if args.debug_traceback:
248
+ raise
249
+ print(f"cibuildmp: error: {exc}", file=sys.stderr)
250
+ return 2
251
+
252
+ if args.only is not None:
253
+ # --only overrides build/skip, matching cibuildwheel's own semantics
254
+ # for the flag: the caller has already decided what this invocation
255
+ # is for, and a matrix leg that reached here was selected when the
256
+ # matrix was generated.
257
+ targets = [t for t in targets if t.identifier == args.only]
258
+ if not targets:
259
+ print(
260
+ f"cibuildmp: error: --only {args.only!r} matches no target this "
261
+ f"config can produce",
262
+ file=sys.stderr,
263
+ )
264
+ return 2
265
+
266
+ if args.print_build_matrix:
267
+ print(
268
+ json.dumps(
269
+ [
270
+ {"only": bo.target.identifier, "os": bo.runs_on}
271
+ for bo in (options.build_options(t) for t in targets)
272
+ ]
273
+ )
274
+ )
275
+ return 0
276
+
277
+ if args.print_build_identifiers:
278
+ identifiers = [t.identifier for t in targets]
279
+ if args.json:
280
+ print(json.dumps(identifiers))
281
+ else:
282
+ for identifier in identifiers:
283
+ print(identifier)
284
+ return 0
285
+
286
+ if not targets:
287
+ if args.allow_empty:
288
+ print("cibuildmp: no targets selected")
289
+ return 0
290
+ print(
291
+ "cibuildmp: error: no targets selected. Pass --allow-empty if that "
292
+ "is expected.",
293
+ file=sys.stderr,
294
+ )
295
+ return 2
296
+
297
+ if not is_abi_known(options.micropython):
298
+ print(
299
+ f"cibuildmp: warning: no recorded .mpy ABI for MicroPython "
300
+ f"{options.micropython}; assuming {options.abi}. The ABI actually "
301
+ f"encoded in each built .mpy is verified against its identifier, so "
302
+ f"a wrong guess fails the build rather than shipping.",
303
+ file=sys.stderr,
304
+ )
305
+
306
+ if args.dry_run:
307
+ total = len(targets)
308
+ print(
309
+ f"cibuildmp: {total} target(s) against MicroPython "
310
+ f"{options.micropython} (.mpy ABI {options.abi})"
311
+ )
312
+ for index, target in enumerate(targets, 1):
313
+ print(" " + _plan_line(index, total, options.build_options(target)))
314
+ return 0
315
+
316
+ try:
317
+ return build(options, targets, toolchain=args.toolchain)
318
+ except (SourceError, ToolchainError) as exc:
319
+ if args.debug_traceback:
320
+ raise
321
+ print(f"cibuildmp: error: {exc}", file=sys.stderr)
322
+ return 2
cibuildmp/options.py ADDED
@@ -0,0 +1,213 @@
1
+ """Config loading and option resolution.
2
+
3
+ Precedence, lowest to highest:
4
+ defaults -> config file -> matching [[overrides]] -> environment -> CLI
5
+
6
+ Config lives in cibuildmp.toml at the package root, with the same tree
7
+ accepted under [tool.cibuildmp] in pyproject.toml for the rare MicroPython
8
+ C-module repo that has one. cibuildmp.toml wins when both exist.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shlex
15
+ import tomllib
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from .targets import (
22
+ NATMOD_ARCHS,
23
+ Target,
24
+ abi_for_tag,
25
+ matches,
26
+ natmod_targets,
27
+ parse_selector,
28
+ select,
29
+ )
30
+
31
+ CONFIG_FILENAME = "cibuildmp.toml"
32
+
33
+ DEFAULT_MICROPYTHON = "v1.28.0"
34
+ DEFAULT_OUTPUT_DIR = "mpyhouse"
35
+ DEFAULT_MODULE_DIR = "natmod"
36
+ DEFAULT_MAKE_TARGET = "dist"
37
+
38
+
39
+ class ConfigError(Exception):
40
+ pass
41
+
42
+
43
+ def _as_list(value: Any, key: str) -> list[str]:
44
+ """Accept a list, or a shell-ish string, for list-valued options.
45
+
46
+ The string form exists for the environment layer -- CIBMP_EXTRA_MAKE_ARGS
47
+ can only ever be a string -- and is accepted in the file too so the two
48
+ layers do not disagree about what a valid value looks like.
49
+ """
50
+ if value is None:
51
+ return []
52
+ if isinstance(value, str):
53
+ return shlex.split(value)
54
+ if isinstance(value, list):
55
+ return [str(v) for v in value]
56
+ raise ConfigError(f"{key}: expected a list or a string, got {type(value).__name__}")
57
+
58
+
59
+ @dataclass
60
+ class BuildOptions:
61
+ """Fully resolved options for a single target."""
62
+
63
+ target: Target
64
+ micropython: str
65
+ output_dir: Path
66
+ module_dir: str
67
+ make_target: str
68
+ runs_on: str = ""
69
+ extra_make_args: list[str] = field(default_factory=list)
70
+ pre_build_command: str = ""
71
+
72
+ @property
73
+ def identifier(self) -> str:
74
+ return self.target.identifier
75
+
76
+
77
+ @dataclass
78
+ class Options:
79
+ """The whole config, before it is narrowed to a single target."""
80
+
81
+ package_dir: Path
82
+ config_path: Path | None
83
+ micropython: str
84
+ output_dir: Path
85
+ build: list[str]
86
+ skip: list[str]
87
+ archs: list[str]
88
+ micropython_submodules: list[str]
89
+ mpy_abi: str | None
90
+ natmod: dict[str, Any]
91
+ overrides: list[dict[str, Any]]
92
+
93
+ # ── Loading ───────────────────────────────────────────────────────────
94
+
95
+ @classmethod
96
+ def load(
97
+ cls,
98
+ package_dir: Path,
99
+ config_file: Path | None = None,
100
+ env: Mapping[str, str] | None = None,
101
+ ) -> Options:
102
+ environ: Mapping[str, str] = os.environ if env is None else env
103
+ config_path, raw = _read_config(package_dir, config_file)
104
+
105
+ natmod = dict(raw.get("natmod") or {})
106
+ overrides = list(raw.get("overrides") or [])
107
+ if not isinstance(overrides, list):
108
+ raise ConfigError("[[overrides]] must be an array of tables")
109
+
110
+ def opt(key: str, default: Any = None) -> Any:
111
+ # Environment beats the file for every global option. Keys are
112
+ # kebab-case in TOML (matching cibuildwheel) and
113
+ # CIBMP_SCREAMING_SNAKE in the environment.
114
+ env_value = environ.get("CIBMP_" + key.replace("-", "_").upper())
115
+ if env_value is not None:
116
+ return env_value
117
+ return raw.get(key, default)
118
+
119
+ archs_value = opt("archs") or natmod.get("archs") or list(NATMOD_ARCHS)
120
+
121
+ return cls(
122
+ package_dir=package_dir,
123
+ config_path=config_path,
124
+ micropython=str(opt("micropython", DEFAULT_MICROPYTHON)),
125
+ output_dir=Path(str(opt("output-dir", DEFAULT_OUTPUT_DIR))),
126
+ build=parse_selector(opt("build", "*")),
127
+ skip=parse_selector(opt("skip", "")),
128
+ archs=_as_list(archs_value, "archs"),
129
+ micropython_submodules=_as_list(
130
+ opt("micropython-submodules"), "micropython-submodules"
131
+ ),
132
+ mpy_abi=(str(opt("mpy-abi")) if opt("mpy-abi") is not None else None),
133
+ natmod=natmod,
134
+ overrides=overrides,
135
+ )
136
+
137
+ # ── Resolution ────────────────────────────────────────────────────────
138
+
139
+ @property
140
+ def abi(self) -> str:
141
+ return abi_for_tag(self.micropython, self.mpy_abi)
142
+
143
+ def targets(self) -> list[Target]:
144
+ """Every target this config selects, in NATMOD_ARCHS order."""
145
+ return select(natmod_targets(self.archs, self.abi), self.build, self.skip)
146
+
147
+ def build_options(
148
+ self, target: Target, env: Mapping[str, str] | None = None
149
+ ) -> BuildOptions:
150
+ """Resolve per-target options: file -> overrides -> environment."""
151
+ environ: Mapping[str, str] = os.environ if env is None else env
152
+
153
+ layers: list[dict[str, Any]] = [self.natmod]
154
+ for override in self.overrides:
155
+ selector = override.get("select")
156
+ if selector is None:
157
+ raise ConfigError("every [[overrides]] table needs a `select` key")
158
+ if matches(target.identifier, parse_selector(selector)):
159
+ layers.append(override)
160
+
161
+ def opt(key: str, default: Any = None) -> Any:
162
+ env_value = environ.get("CIBMP_" + key.replace("-", "_").upper())
163
+ if env_value is not None:
164
+ return env_value
165
+ # Later layers win: a matching override beats [natmod].
166
+ for layer in reversed(layers):
167
+ if key in layer:
168
+ return layer[key]
169
+ return default
170
+
171
+ return BuildOptions(
172
+ target=target,
173
+ micropython=self.micropython,
174
+ output_dir=self.output_dir,
175
+ module_dir=str(opt("module-dir", DEFAULT_MODULE_DIR)),
176
+ make_target=str(opt("make-target", DEFAULT_MAKE_TARGET)),
177
+ runs_on=str(opt("runs-on", target.default_runner)),
178
+ extra_make_args=_as_list(opt("extra-make-args"), "extra-make-args"),
179
+ pre_build_command=str(opt("pre-build-command", "")),
180
+ )
181
+
182
+
183
+ def _read_config(
184
+ package_dir: Path, config_file: Path | None
185
+ ) -> tuple[Path | None, dict[str, Any]]:
186
+ if config_file is not None:
187
+ if not config_file.is_file():
188
+ raise ConfigError(f"config file not found: {config_file}")
189
+ return config_file, _load_toml_tree(config_file)
190
+
191
+ standalone = package_dir / CONFIG_FILENAME
192
+ if standalone.is_file():
193
+ return standalone, _load_toml_tree(standalone)
194
+
195
+ pyproject = package_dir / "pyproject.toml"
196
+ if pyproject.is_file():
197
+ with pyproject.open("rb") as f:
198
+ data = tomllib.load(f)
199
+ tool = (data.get("tool") or {}).get("cibuildmp")
200
+ if tool is not None:
201
+ return pyproject, dict(tool)
202
+
203
+ # No config at all is legitimate: every option has a default, so a repo
204
+ # following the conventional natmod/ layout builds with none.
205
+ return None, {}
206
+
207
+
208
+ def _load_toml_tree(path: Path) -> dict[str, Any]:
209
+ with path.open("rb") as f:
210
+ data = tomllib.load(f)
211
+ if path.name == "pyproject.toml":
212
+ return dict((data.get("tool") or {}).get("cibuildmp") or {})
213
+ return data