mojox-core 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mojox_core/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """mojox-core: pure model layer for Mojo build tooling.
2
+
3
+ Public API:
4
+ - Types: Manifest, Command, Policy, ResolvedEnv, TargetGraph, etc.
5
+ - Transformers: parse_manifest, build_env, discover, resolve, plan, serialize
6
+ - Errors: ConfigError
7
+ """
8
+
9
+ from ._errors import ConfigError
10
+ from ._types import (
11
+ BinaryEntry,
12
+ Command,
13
+ CommandKind,
14
+ Diagnostic,
15
+ DistEntry,
16
+ DistKind,
17
+ HostFacts,
18
+ LintConfig,
19
+ LocalSettings,
20
+ Manifest,
21
+ Policy,
22
+ Profile,
23
+ ResolvedEnv,
24
+ Target,
25
+ TargetGraph,
26
+ TargetKind,
27
+ Toolchain,
28
+ )
29
+ from .environment import build_env
30
+ from .manifest import parse_manifest
31
+ from .metadata import serialize
32
+ from .plan import plan
33
+ from .policy import resolve
34
+ from .settings import parse_settings
35
+ from .targets import discover
36
+
37
+ __all__ = [
38
+ "BinaryEntry",
39
+ "Command",
40
+ "CommandKind",
41
+ "ConfigError",
42
+ "Diagnostic",
43
+ "DistEntry",
44
+ "DistKind",
45
+ "HostFacts",
46
+ "LintConfig",
47
+ "LocalSettings",
48
+ "Manifest",
49
+ "Policy",
50
+ "Profile",
51
+ "ResolvedEnv",
52
+ "Target",
53
+ "TargetGraph",
54
+ "TargetKind",
55
+ "Toolchain",
56
+ "build_env",
57
+ "discover",
58
+ "parse_manifest",
59
+ "parse_settings",
60
+ "plan",
61
+ "resolve",
62
+ "serialize",
63
+ ]
mojox_core/_errors.py ADDED
@@ -0,0 +1,16 @@
1
+ """User-facing configuration errors with structured key paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ConfigError(Exception):
7
+ """A configuration error carrying the offending key path and a remediation.
8
+
9
+ No KeyError, no TypeError, no stack trace reaches the user — every
10
+ rejection produces one of these with a human-readable message.
11
+ """
12
+
13
+ def __init__(self, key_path: str, message: str) -> None:
14
+ self.key_path = key_path
15
+ self.message = message
16
+ super().__init__(f"[{key_path}] {message}")
mojox_core/_types.py ADDED
@@ -0,0 +1,235 @@
1
+ """All frozen dataclasses for the mojox-core model layer.
2
+
3
+ Every type here is immutable (frozen=True). No type in this module imports
4
+ subprocess or performs I/O. Path-valued fields use PurePosixPath for lexical
5
+ paths (manifest-relative) and str for resolved absolute paths (from readers).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from pathlib import PurePosixPath
14
+ from typing import ClassVar, Literal
15
+
16
+ # -- Enums -----------------------------------------------------------------
17
+
18
+
19
+ class CommandKind(Enum):
20
+ """The kind of mojo invocation a Command represents."""
21
+
22
+ COMPILE_PACKAGE = "compile-package"
23
+ COMPILE_BINARY = "compile-binary"
24
+ RUN_TEST = "run-test"
25
+ CHECK_EXAMPLE = "check-example"
26
+ RUN = "run"
27
+
28
+
29
+ class TargetKind(Enum):
30
+ """Discovered target kinds."""
31
+
32
+ LIB = "lib"
33
+ BIN = "bin"
34
+ TEST = "test"
35
+ EXAMPLE = "example"
36
+
37
+
38
+ class DistKind(Enum):
39
+ """How a distribution provides its Mojo packages."""
40
+
41
+ SOURCE = "source"
42
+ PRECOMPILED = "precompiled"
43
+
44
+
45
+ # -- Leaf types ------------------------------------------------------------
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class BinaryEntry:
50
+ """One executable produced by ``mojo build``."""
51
+
52
+ source: str
53
+ name: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class LintConfig:
58
+ """Lint settings that translate to compiler flags."""
59
+
60
+ warnings_as_errors: bool = False
61
+ check_doc_strings: bool = False
62
+ missing_doc_strings: bool = False
63
+ unstable_apis: bool = False
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Diagnostic:
68
+ """A compiler or mojox diagnostic."""
69
+
70
+ kind: Literal["error", "warning", "note"]
71
+ message: str
72
+ file: str | None = None
73
+ line: int | None = None
74
+ column: int | None = None
75
+ source_text: str | None = None
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Target:
80
+ """A discovered build target."""
81
+
82
+ kind: TargetKind
83
+ path: str
84
+ target_id: str
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class DistEntry:
89
+ """One installed distribution contributing to the include sequence."""
90
+
91
+ name: str
92
+ include_dir: str
93
+ kind: DistKind
94
+ packages: tuple[str, ...]
95
+ provenance: str
96
+ native_lib_dirs: tuple[str, ...] = ()
97
+
98
+
99
+ # -- Compound types --------------------------------------------------------
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Profile:
104
+ """A named compilation profile (dev, release, or user-declared)."""
105
+
106
+ optimize: int | None = None
107
+ debug_level: str | None = None
108
+ defines: dict[str, str] = field(default_factory=dict)
109
+ flags: tuple[str, ...] = ()
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Manifest:
114
+ """Parsed pyproject.toml -- portable, published, versioned with the code."""
115
+
116
+ # [project]
117
+ name: str
118
+ version: str
119
+ description: str | None
120
+ readme: str | None
121
+ license_expr: str | None
122
+ license_files: tuple[str, ...]
123
+ requires_python: str | None
124
+ dependencies: tuple[str, ...]
125
+ optional_dependencies: dict[str, tuple[str, ...]]
126
+ keywords: tuple[str, ...]
127
+ authors: tuple[dict[str, str], ...]
128
+ maintainers: tuple[dict[str, str], ...]
129
+ urls: dict[str, str]
130
+ classifiers: tuple[str, ...]
131
+
132
+ # [tool.mojox]
133
+ packages: tuple[str, ...] | None
134
+ package_root: str
135
+ binaries: tuple[BinaryEntry, ...]
136
+ test_roots: tuple[str, ...]
137
+ test_parallel: bool
138
+ defines: dict[str, str]
139
+ flags: tuple[str, ...]
140
+ lints: LintConfig
141
+ optimize: int | None
142
+ debug_level: str | None
143
+ pre_build: tuple[tuple[str, ...], ...]
144
+ native_libs: tuple[str, ...]
145
+ source_include: tuple[str, ...] | None
146
+ source_exclude: tuple[str, ...]
147
+ wheel_exclude: tuple[str, ...]
148
+ profiles: dict[str, Profile]
149
+ build_profile: str
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class TargetGraph:
154
+ """Discovered targets in topological order (lib targets first)."""
155
+
156
+ targets: tuple[Target, ...]
157
+ edges: tuple[tuple[str, str], ...]
158
+ unsearched_test_dirs: tuple[tuple[str, int], ...] = ()
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class ResolvedEnv:
163
+ """The resolved build environment -- include sequence, mojo binary, provenance."""
164
+
165
+ include_sequence: tuple[DistEntry, ...]
166
+ mojo_path: str
167
+ mojo_version: str
168
+ path_mojo: str | None
169
+ lock_version: int | None
170
+ diagnostics: tuple[Diagnostic, ...] = ()
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class Toolchain:
175
+ """Resolved Mojo toolchain identity."""
176
+
177
+ mojo_path: str
178
+ version: str
179
+ subcommand: str
180
+ extension: str
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class HostFacts:
185
+ """Machine-specific facts injected into the planner for determinism."""
186
+
187
+ cpu_count: int
188
+ available_memory_mb: int
189
+ manifest_dir: PurePosixPath
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class LocalSettings:
194
+ """Machine-local settings (from .mojox/config.toml + env vars)."""
195
+
196
+ jobs: int | None = None
197
+ timeout_s: int | None = None
198
+ config_paths: tuple[str, ...] = ()
199
+ env: dict[str, str] = field(default_factory=dict)
200
+
201
+ EMPTY: ClassVar[LocalSettings] # set below
202
+
203
+
204
+ # Bootstrap the EMPTY sentinel after the class is defined.
205
+ LocalSettings.EMPTY = LocalSettings()
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class Policy:
210
+ """The fully resolved flag set the planner consumes."""
211
+
212
+ optimize: int
213
+ debug_level: str
214
+ defines: dict[str, str]
215
+ flags: tuple[str, ...]
216
+ include_paths: tuple[str, ...]
217
+ lints: LintConfig
218
+ jobs: int
219
+ jobs_compile: int
220
+ jobs_tests: int
221
+ timeout_s: int
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class Command:
226
+ """One mojo invocation the planner produces."""
227
+
228
+ argv: tuple[str, ...]
229
+ cwd: PurePosixPath
230
+ env: Mapping[str, str]
231
+ kind: CommandKind
232
+ target_id: str
233
+ timeout_s: int | None
234
+ outputs: tuple[str, ...]
235
+ depends_on: tuple[str, ...]
@@ -0,0 +1,134 @@
1
+ """Build the resolved environment from distribution data.
2
+
3
+ Pure transformer -- receives pre-read distribution info and returns ResolvedEnv.
4
+ The IO reader that enumerates distributions lives in mojox_core.io.environment.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ._errors import ConfigError
12
+ from ._types import Diagnostic, DistEntry, DistKind, ResolvedEnv
13
+
14
+ _MAX_KNOWN_LOCK_VERSION = 1
15
+
16
+
17
+ def build_env(
18
+ dists: list[dict[str, Any]],
19
+ lock_data: dict[str, Any] | None,
20
+ mojo_path: str,
21
+ mojo_version: str,
22
+ *,
23
+ path_mojo: str | None = None,
24
+ ) -> ResolvedEnv:
25
+ """Build a ResolvedEnv from distribution data and optional lockfile.
26
+
27
+ Args:
28
+ dists: List of distribution dicts, each containing 'name',
29
+ 'include_dir', 'kind', 'packages', 'provenance', and
30
+ 'native_lib_dirs' keys.
31
+ lock_data: Parsed lockfile data (with 'version' key), or None.
32
+ mojo_path: Path to the Mojo compiler in the virtual environment.
33
+ mojo_version: Version string of the Mojo compiler.
34
+ path_mojo: Path to Mojo found on PATH, if different from mojo_path.
35
+
36
+ Returns:
37
+ A fully resolved ResolvedEnv instance.
38
+
39
+ Raises:
40
+ ConfigError: If a package appears as both source and precompiled
41
+ in the same include directory.
42
+ """
43
+ diagnostics: list[Diagnostic] = []
44
+ entries: list[DistEntry] = []
45
+
46
+ _check_source_shadows_precompiled(dists)
47
+
48
+ for d in dists:
49
+ entries.append(
50
+ DistEntry(
51
+ name=d["name"],
52
+ include_dir=d["include_dir"],
53
+ kind=d["kind"],
54
+ packages=tuple(d["packages"]),
55
+ provenance=d.get("provenance", "unknown"),
56
+ native_lib_dirs=tuple(d.get("native_lib_dirs", ())),
57
+ )
58
+ )
59
+
60
+ lock_version = _extract_lock_version(lock_data, diagnostics)
61
+
62
+ if path_mojo is not None and path_mojo != mojo_path:
63
+ diagnostics.append(
64
+ Diagnostic(
65
+ kind="warning",
66
+ message=f"PATH mojo ({path_mojo}) differs from environment mojo "
67
+ f"({mojo_path}). The environment mojo will be used.",
68
+ )
69
+ )
70
+
71
+ return ResolvedEnv(
72
+ include_sequence=tuple(entries),
73
+ mojo_path=mojo_path,
74
+ mojo_version=mojo_version,
75
+ path_mojo=path_mojo,
76
+ lock_version=lock_version,
77
+ diagnostics=tuple(diagnostics),
78
+ )
79
+
80
+
81
+ def _check_source_shadows_precompiled(dists: list[dict[str, Any]]) -> None:
82
+ """Raise ConfigError if any package is both source and precompiled in the same dir.
83
+
84
+ Args:
85
+ dists: List of distribution dicts to validate.
86
+
87
+ Raises:
88
+ ConfigError: If a source-shadows-precompiled conflict is detected.
89
+ """
90
+ dir_packages: dict[str, dict[str, list[DistKind]]] = {}
91
+ for d in dists:
92
+ include_dir = d["include_dir"]
93
+ by_name = dir_packages.setdefault(include_dir, {})
94
+ for pkg in d["packages"]:
95
+ by_name.setdefault(pkg, []).append(d["kind"])
96
+
97
+ for include_dir, pkgs_by_name in dir_packages.items():
98
+ for pkg_name, kinds in pkgs_by_name.items():
99
+ if DistKind.SOURCE in kinds and DistKind.PRECOMPILED in kinds:
100
+ raise ConfigError(
101
+ f"environment.{include_dir}",
102
+ f"source-shadows-precompiled: package {pkg_name!r} exists "
103
+ f"as both source and precompiled in {include_dir!r}. The "
104
+ "source silently wins and the consumer is on the slow "
105
+ "path. Remove one.",
106
+ )
107
+
108
+
109
+ def _extract_lock_version(
110
+ lock_data: dict[str, Any] | None,
111
+ diagnostics: list[Diagnostic],
112
+ ) -> int | None:
113
+ """Extract and validate the lockfile schema version.
114
+
115
+ Args:
116
+ lock_data: Parsed lockfile data, or None.
117
+ diagnostics: Mutable list to append warnings to.
118
+
119
+ Returns:
120
+ The lock version integer, or None if no lockfile was provided.
121
+ """
122
+ if lock_data is None:
123
+ return None
124
+ lock_version = lock_data.get("version")
125
+ if lock_version is not None and lock_version > _MAX_KNOWN_LOCK_VERSION:
126
+ diagnostics.append(
127
+ Diagnostic(
128
+ kind="warning",
129
+ message=f"uv.lock schema version {lock_version} is above the "
130
+ f"known maximum ({_MAX_KNOWN_LOCK_VERSION}). Provenance data "
131
+ "may be incomplete.",
132
+ )
133
+ )
134
+ return lock_version
@@ -0,0 +1 @@
1
+ """Effectful IO readers — separated from pure transformers."""
@@ -0,0 +1,114 @@
1
+ """Read the installed environment — the effectful half of environment building.
2
+
3
+ Enumerates installed distributions via importlib.metadata, checks which
4
+ ship mojo_packages/, reads uv.lock when present, and gathers HostFacts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.metadata
10
+ import os
11
+ import sysconfig
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .._types import DistKind, HostFacts
16
+
17
+
18
+ def read_distributions(platlib: str | None = None) -> list[dict[str, Any]]:
19
+ """Enumerate installed distributions that ship Mojo packages.
20
+
21
+ Returns a list of dicts suitable for passing to environment.build_env().
22
+ """
23
+ if platlib is None:
24
+ platlib = sysconfig.get_path("platlib")
25
+ assert platlib is not None
26
+
27
+ mojo_packages_dir = Path(platlib) / "mojo_packages"
28
+ if not mojo_packages_dir.is_dir():
29
+ return []
30
+
31
+ dists: list[dict[str, Any]] = []
32
+ for dist in importlib.metadata.distributions():
33
+ if dist.files is None:
34
+ continue
35
+ has_mojo = False
36
+ packages: list[str] = []
37
+
38
+ for f in dist.files:
39
+ parts = str(f).split("/")
40
+ if len(parts) >= 2 and parts[0] == "mojo_packages":
41
+ has_mojo = True
42
+ pkg_name = parts[1]
43
+ if pkg_name.endswith((".mojoc", ".mojopkg")):
44
+ pkg_name = pkg_name.rsplit(".", 1)[0]
45
+ kind = DistKind.PRECOMPILED
46
+ elif not pkg_name.startswith("_") and pkg_name != "lib":
47
+ kind = DistKind.SOURCE
48
+ else:
49
+ continue
50
+ if pkg_name not in packages:
51
+ packages.append(pkg_name)
52
+
53
+ if has_mojo and packages:
54
+ dists.append(
55
+ {
56
+ "name": dist.metadata["Name"],
57
+ "include_dir": str(mojo_packages_dir),
58
+ "kind": kind,
59
+ "packages": packages,
60
+ "provenance": dist.metadata.get("Version", "unknown"),
61
+ "native_lib_dirs": (),
62
+ }
63
+ )
64
+
65
+ return dists
66
+
67
+
68
+ def read_lockfile(project_root: Path) -> dict[str, Any] | None:
69
+ """Read uv.lock if it exists, returning parsed TOML or None."""
70
+ import sys
71
+
72
+ if sys.version_info >= (3, 11):
73
+ import tomllib
74
+ else:
75
+ import tomli as tomllib # type: ignore[import-not-found, no-redef]
76
+
77
+ lock_path = project_root / "uv.lock"
78
+ if not lock_path.is_file():
79
+ return None
80
+
81
+ try:
82
+ with open(lock_path, "rb") as f:
83
+ return tomllib.load(f)
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ def read_host_facts(manifest_dir: Path) -> HostFacts:
89
+ """Gather machine-specific facts for the planner."""
90
+ from pathlib import PurePosixPath
91
+
92
+ cpu_count = os.cpu_count() or 1
93
+ mem_mb = _available_memory_mb()
94
+
95
+ return HostFacts(
96
+ cpu_count=cpu_count,
97
+ available_memory_mb=mem_mb,
98
+ manifest_dir=PurePosixPath(str(manifest_dir.resolve())),
99
+ )
100
+
101
+
102
+ def _available_memory_mb() -> int:
103
+ """Best-effort available memory in MB."""
104
+ try:
105
+ import resource # noqa: F401
106
+
107
+ # Try /proc/meminfo on Linux
108
+ with open("/proc/meminfo") as f:
109
+ for line in f:
110
+ if line.startswith("MemAvailable:"):
111
+ return int(line.split()[1]) // 1024
112
+ except (ImportError, OSError):
113
+ pass
114
+ return 16384
@@ -0,0 +1,29 @@
1
+ """Read pyproject.toml from disk — the effectful half of manifest parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .._errors import ConfigError
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib # type: ignore[import-not-found, no-redef]
15
+
16
+
17
+ def read(path: Path) -> dict[str, Any]:
18
+ """Read and parse a pyproject.toml file.
19
+
20
+ Returns the parsed dict. Raises ConfigError if the file is missing or
21
+ contains invalid TOML.
22
+ """
23
+ if not path.is_file():
24
+ raise ConfigError("pyproject.toml", f"{path} not found")
25
+ try:
26
+ with open(path, "rb") as f:
27
+ return tomllib.load(f)
28
+ except Exception as e:
29
+ raise ConfigError("pyproject.toml", f"failed to parse: {e}") from e
@@ -0,0 +1,98 @@
1
+ """Resolve the Mojo toolchain from installed distributions.
2
+
3
+ Uses importlib.metadata to find the mojo-compiler distribution, its
4
+ console script entry point, and version. Never invokes mojo --version.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.metadata
10
+ from pathlib import Path
11
+
12
+ from .._errors import ConfigError
13
+ from .._types import Toolchain
14
+
15
+
16
+ def resolve() -> Toolchain:
17
+ """Resolve the installed Mojo toolchain.
18
+
19
+ Reads the mojo-compiler distribution metadata to find the binary path
20
+ and version. Never runs a subprocess.
21
+
22
+ Returns:
23
+ A ``Toolchain`` with the mojo binary path, version string,
24
+ subcommand (``"precompile"``), and extension (``".mojoc"``).
25
+
26
+ Raises:
27
+ ConfigError: If ``mojo-compiler`` is not installed or its ``mojo``
28
+ binary cannot be located in the distribution's file list.
29
+ """
30
+ try:
31
+ dist = importlib.metadata.distribution("mojo-compiler")
32
+ except importlib.metadata.PackageNotFoundError:
33
+ raise ConfigError(
34
+ "toolchain",
35
+ "mojo-compiler is not installed. Install it with: uv add mojo-compiler",
36
+ )
37
+
38
+ version = dist.metadata["Version"]
39
+
40
+ # Find the mojo console script
41
+ mojo_path = _find_console_script(dist)
42
+
43
+ # Determine subcommand and extension based on version
44
+ subcommand = "precompile"
45
+ extension = ".mojoc"
46
+
47
+ return Toolchain(
48
+ mojo_path=mojo_path,
49
+ version=version,
50
+ subcommand=subcommand,
51
+ extension=extension,
52
+ )
53
+
54
+
55
+ def _find_console_script(dist: importlib.metadata.Distribution) -> str:
56
+ """Find the mojo console script path from the distribution.
57
+
58
+ Searches the distribution's recorded file list for a file named ``mojo``
59
+ (either at the top of a scripts directory or under ``bin/``). Falls back
60
+ to inspecting entry_points if the file list doesn't yield a match.
61
+
62
+ Args:
63
+ dist: The ``mojo-compiler`` distribution object.
64
+
65
+ Returns:
66
+ The absolute path to the ``mojo`` binary as a string.
67
+
68
+ Raises:
69
+ ConfigError: If the binary cannot be located.
70
+ """
71
+ # Look in the scripts directory
72
+ if dist.files:
73
+ for f in dist.files:
74
+ parts = str(f).split("/")
75
+ if len(parts) >= 1 and parts[-1] == "mojo":
76
+ located = dist.locate_file(f)
77
+ if located and Path(str(located)).exists():
78
+ return str(Path(str(located)).resolve())
79
+
80
+ # Fallback: try the entry_points
81
+ eps = dist.entry_points
82
+ for ep in eps:
83
+ if ep.name == "mojo":
84
+ # entry_points don't directly give us a path; use the scripts dir
85
+ break
86
+
87
+ # Last resort: look in the same directory as the package
88
+ pkg_files = dist.files or []
89
+ for f in pkg_files:
90
+ if str(f).endswith("/bin/mojo") or str(f) == "bin/mojo":
91
+ located = dist.locate_file(f)
92
+ if located:
93
+ return str(Path(str(located)).resolve())
94
+
95
+ raise ConfigError(
96
+ "toolchain",
97
+ "mojo-compiler is installed but the `mojo` binary was not found in its file list",
98
+ )