mkrun 0.1.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.

Potentially problematic release.


This version of mkrun might be problematic. Click here for more details.

make/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """make -- a command runner whose recipes are Python.
2
+
3
+ # Makefile.py
4
+ from make import recipe, sh
5
+
6
+ @recipe(group="app", requires=["cargo"])
7
+ def test(*, fast: bool = False) -> None:
8
+ \"\"\"Run the test suite.\"\"\"
9
+ sh("cargo", "test", *(["--lib"] if fast else []))
10
+
11
+ $ make app.test --fast
12
+
13
+ The command line comes from the signature, so there is no second schema to keep
14
+ in sync. Recipes are ordinary functions: importable, unit-testable, and
15
+ distributable as versioned packages instead of a directory someone `git clone`d.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ from . import config, env, fs
23
+ from .context import Context, confirm, ctx, echo, info, note, paint, path, step, warn
24
+ from .errors import Aborted, CommandFailed, ConfigError, MakeError, RecipeError, ToolMissing, UsageError
25
+ from .params import Arg, arg
26
+ from .recipes import Group, Recipe, group, recipe, registry
27
+ from .runner import invoke
28
+ from .sh import Result, sh
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ # authoring
33
+ "recipe",
34
+ "group",
35
+ "invoke",
36
+ "sh",
37
+ "ctx",
38
+ "path",
39
+ "env",
40
+ "fs",
41
+ "config",
42
+ "arg",
43
+ # output
44
+ "step",
45
+ "info",
46
+ "note",
47
+ "warn",
48
+ "echo",
49
+ "paint",
50
+ "confirm",
51
+ # types
52
+ "Recipe",
53
+ "Group",
54
+ "Context",
55
+ "Result",
56
+ "Arg",
57
+ "registry",
58
+ # errors
59
+ "MakeError",
60
+ "UsageError",
61
+ "RecipeError",
62
+ "ConfigError",
63
+ "ToolMissing",
64
+ "CommandFailed",
65
+ "Aborted",
66
+ ]
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ """Programmatic entry point, equivalent to running `make` on the command line."""
71
+ from .cli import main as _main
72
+
73
+ return _main(argv)
make/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """`python -m make` -- also how the uv bootstrap re-enters the tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
make/bootstrap.py ADDED
@@ -0,0 +1,360 @@
1
+ """Making shared recipes an ordinary dependency.
2
+
3
+ This is the feature the whole project exists for. `just` shares recipes by
4
+ `git clone --depth 1` into a gitignored directory, driven by a `_shared` recipe
5
+ copy-pasted into every consumer -- no versions, no pinning, no lockfile, and a
6
+ `git pull --ff-only || true` that fails silently and leaves you on whatever HEAD
7
+ happened to be there.
8
+
9
+ Here a recipe file declares its dependencies inline, in PEP 723 form:
10
+
11
+ # /// script
12
+ # requires-python = ">=3.11"
13
+ # dependencies = ["mkrun>=0.1", "acme-recipes>=0.4"]
14
+ # ///
15
+
16
+ If the current interpreter already satisfies them, nothing happens -- that is
17
+ the common case in a project with a synced virtualenv, and it costs one
18
+ `importlib.metadata` lookup per dependency. Otherwise `make` re-executes itself
19
+ under `uv run`, which resolves into a cached environment. Pin it with
20
+ `make --sync` (`uv lock --script`).
21
+
22
+ The satisfaction check errs toward re-executing: a specifier it cannot parse
23
+ counts as unsatisfied. Being needlessly slow is recoverable; running a recipe
24
+ against the wrong version of its library is not.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+
37
+ from .context import debug, note
38
+ from .errors import MakeError
39
+
40
+ __all__ = ["ScriptMetadata", "needs_bootstrap", "read_metadata", "reexec"]
41
+
42
+ BOOTSTRAP_FLAG = "_MAKE_BOOTSTRAPPED"
43
+
44
+ # The regex given in PEP 723 itself.
45
+ _BLOCK = re.compile(r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$")
46
+
47
+ _REQUIREMENT = re.compile(
48
+ r"^(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)"
49
+ r"(?P<extras>\[[^\]]*\])?"
50
+ r"\s*(?P<spec>.*)$"
51
+ )
52
+
53
+ _CLAUSE = re.compile(r"(?P<op>==|!=|>=|<=|~=|>|<)\s*(?P<version>[0-9A-Za-z.*+!-]+)")
54
+
55
+
56
+ @dataclass
57
+ class ScriptMetadata:
58
+ """The `# /// script` block of a recipe file."""
59
+
60
+ dependencies: list[str] = field(default_factory=list)
61
+ requires_python: str | None = None
62
+ raw: dict = field(default_factory=dict)
63
+
64
+ @property
65
+ def empty(self) -> bool:
66
+ return not self.dependencies and not self.requires_python
67
+
68
+
69
+ def read_metadata(path: Path) -> ScriptMetadata:
70
+ """Parse the inline metadata block, if any."""
71
+ try:
72
+ text = path.read_text(encoding="utf-8")
73
+ except OSError:
74
+ return ScriptMetadata()
75
+
76
+ for match in _BLOCK.finditer(text):
77
+ if match.group("type") != "script":
78
+ continue
79
+ content = "".join(
80
+ line[2:] if line.startswith("# ") else line[1:]
81
+ for line in match.group("content").splitlines(keepends=True)
82
+ )
83
+ import tomllib
84
+
85
+ try:
86
+ data = tomllib.loads(content)
87
+ except Exception as exc:
88
+ raise MakeError(f"{path}: the inline script metadata is not valid TOML -- {exc}") from exc
89
+ return ScriptMetadata(
90
+ dependencies=list(data.get("dependencies", [])),
91
+ requires_python=data.get("requires-python"),
92
+ raw=data,
93
+ )
94
+ return ScriptMetadata()
95
+
96
+
97
+ # --------------------------------------------------------------------------
98
+ # Is the current interpreter good enough?
99
+ # --------------------------------------------------------------------------
100
+
101
+
102
+ def _version_key(version: str) -> tuple:
103
+ parts: list[object] = []
104
+ for chunk in re.split(r"[._-]", version):
105
+ if chunk.isdigit():
106
+ parts.append((0, int(chunk)))
107
+ elif chunk:
108
+ parts.append((1, chunk))
109
+ return tuple(parts)
110
+
111
+
112
+ def _satisfies(installed: str, op: str, wanted: str) -> bool:
113
+ if op == "~=" or "*" in wanted:
114
+ prefix = wanted.rstrip("*").rstrip(".")
115
+ if op == "~=":
116
+ head = ".".join(wanted.split(".")[:-1])
117
+ return installed.startswith(head) and _version_key(installed) >= _version_key(wanted)
118
+ return installed.startswith(prefix)
119
+ left, right = _version_key(installed), _version_key(wanted)
120
+ return {
121
+ "==": left == right,
122
+ "!=": left != right,
123
+ ">=": left >= right,
124
+ "<=": left <= right,
125
+ ">": left > right,
126
+ "<": left < right,
127
+ }[op]
128
+
129
+
130
+ def _requirement_met(requirement: str) -> bool:
131
+ text = requirement.split("#", 1)[0].strip()
132
+ if not text:
133
+ return True
134
+ if ";" in text:
135
+ return False # environment markers: let uv decide
136
+ if any(text.startswith(scheme) for scheme in ("http://", "https://", "git+", "file://")):
137
+ return False
138
+ if "@" in text:
139
+ return False # direct reference (git+ssh://..., local path)
140
+
141
+ match = _REQUIREMENT.match(text)
142
+ if match is None:
143
+ return False
144
+ name = match.group("name")
145
+ if match.group("extras"):
146
+ return False # we cannot tell whether the extra's deps are present
147
+
148
+ import importlib.metadata as md
149
+
150
+ try:
151
+ installed = md.version(name)
152
+ except md.PackageNotFoundError:
153
+ return False
154
+
155
+ spec = match.group("spec").strip()
156
+ if not spec:
157
+ return True
158
+ for clause in spec.split(","):
159
+ clause = clause.strip()
160
+ if not clause:
161
+ continue
162
+ parsed = _CLAUSE.match(clause)
163
+ if parsed is None:
164
+ return False
165
+ if not _satisfies(installed, parsed.group("op"), parsed.group("version")):
166
+ return False
167
+ return True
168
+
169
+
170
+ def needs_bootstrap(metadata: ScriptMetadata) -> bool:
171
+ """True when this interpreter cannot run the recipe file as declared."""
172
+ if os.environ.get(BOOTSTRAP_FLAG):
173
+ return False
174
+ if not metadata.dependencies:
175
+ return False
176
+ for requirement in metadata.dependencies:
177
+ if not _requirement_met(requirement):
178
+ debug(f"bootstrap: {requirement!r} not satisfied by the current interpreter")
179
+ return True
180
+ return False
181
+
182
+
183
+ # --------------------------------------------------------------------------
184
+ # Re-exec through uv
185
+ # --------------------------------------------------------------------------
186
+
187
+
188
+ #: The distribution name. `make` and `mk` are taken on PyPI by unrelated
189
+ #: projects, so the package that provides `import make` is called this.
190
+ DISTRIBUTION = "mkrun"
191
+
192
+
193
+ def _requirement_name(requirement: str) -> str:
194
+ """The bare package name of a requirement, however it is written."""
195
+ text = requirement.split(";", 1)[0].strip()
196
+ text = text.split("@", 1)[0].strip() # "pkg @ git+ssh://..." -> "pkg"
197
+ match = _REQUIREMENT.match(text)
198
+ return (match.group("name") if match else text).replace("_", "-").lower()
199
+
200
+
201
+ def _declares_self(metadata: ScriptMetadata) -> bool:
202
+ """Does the recipe file already say where to get the tool from?"""
203
+ return any(_requirement_name(r) == DISTRIBUTION for r in metadata.dependencies)
204
+
205
+
206
+ def _self_requirement(metadata: ScriptMetadata) -> list[str]:
207
+ """How the child environment should obtain `make` itself.
208
+
209
+ Nothing, when the recipe file already declares it. Adding our own on top
210
+ would hand uv two different sources for one package -- a hard error the
211
+ moment the file pins a git URL or a specific version, which is exactly what
212
+ a consumer of a private recipe package does.
213
+ """
214
+ if _declares_self(metadata):
215
+ return []
216
+
217
+ override = os.environ.get("MAKE_SELF_REQUIREMENT")
218
+ if override:
219
+ return ["--with", override]
220
+
221
+ package_root = Path(__file__).resolve().parent
222
+ project_root = package_root.parent.parent
223
+ if (project_root / "pyproject.toml").is_file() and (project_root / "src" / "make").is_dir():
224
+ return ["--with-editable", str(project_root)] # running from a checkout
225
+
226
+ from . import __version__
227
+
228
+ return ["--with", f"{DISTRIBUTION}=={__version__}"]
229
+
230
+
231
+ def lock_path(path: Path) -> Path:
232
+ """Where `uv lock --script` puts the lockfile for a recipe file."""
233
+ return path.with_name(path.name + ".lock")
234
+
235
+
236
+ def locked_interpreter(path: Path, uv: str) -> str | None:
237
+ """The interpreter of the environment `uv` materialised from the lockfile.
238
+
239
+ Without this, `make --sync` would write a lockfile that nothing reads, and
240
+ the pinning it promises would be decorative -- which is worse than no
241
+ lockfile at all. `uv sync --script` builds the environment from the lock,
242
+ and `uv python find --script` reports where it went.
243
+
244
+ Returns None whenever anything is off (no lock, sync failed, the environment
245
+ somehow lacks `make`), so the caller falls back to resolving from the
246
+ declared ranges. A slower correct path beats a fast wrong one.
247
+ """
248
+ if not lock_path(path).is_file():
249
+ return None
250
+ synced = subprocess.run(
251
+ [uv, "sync", "--script", str(path), "--quiet"], capture_output=True, text=True, check=False
252
+ )
253
+ if synced.returncode != 0:
254
+ debug(f"bootstrap: uv sync --script failed, ignoring the lockfile\n{synced.stderr.strip()}")
255
+ return None
256
+ found = subprocess.run(
257
+ [uv, "python", "find", "--script", str(path)], capture_output=True, text=True, check=False
258
+ )
259
+ interpreter = found.stdout.strip()
260
+ if found.returncode != 0 or not interpreter:
261
+ return None
262
+ usable = subprocess.run([interpreter, "-c", "import make"], capture_output=True, check=False)
263
+ if usable.returncode != 0:
264
+ debug("bootstrap: the locked environment has no `make` in it, ignoring the lockfile")
265
+ return None
266
+ return interpreter
267
+
268
+
269
+ def reexec(metadata: ScriptMetadata, argv: list[str], recipe_file: Path | None = None) -> int:
270
+ """Run this command again in an environment that has the declared deps."""
271
+ uv = shutil.which("uv")
272
+ if uv is None:
273
+ raise MakeError(
274
+ "this recipe file declares dependencies, which needs `uv` on PATH",
275
+ hint="install it from https://docs.astral.sh/uv/ , or install the dependencies "
276
+ "into the current environment yourself and re-run",
277
+ )
278
+
279
+ environment = dict(os.environ)
280
+ environment[BOOTSTRAP_FLAG] = "1"
281
+
282
+ # Pinned wins: if there is a lockfile, run exactly what it says.
283
+ if recipe_file is not None:
284
+ interpreter = locked_interpreter(recipe_file, uv)
285
+ if interpreter is not None:
286
+ debug(f"bootstrap: using the locked environment at {interpreter}")
287
+ return subprocess.run([interpreter, "-m", "make", *argv], env=environment, check=False).returncode
288
+
289
+ command = [uv, "run", "--quiet"]
290
+ if metadata.requires_python:
291
+ command += ["--python", metadata.requires_python]
292
+ command += _self_requirement(metadata)
293
+ for requirement in metadata.dependencies:
294
+ command += ["--with", requirement]
295
+ command += ["python", "-m", "make", *argv]
296
+
297
+ # Only announce the wait the first time this dependency set is seen. uv
298
+ # caches the environment, so later runs cost about 50ms and an unexplained
299
+ # "resolving..." on every single invocation is just noise.
300
+ marker = _bootstrap_marker(metadata)
301
+ if not marker.exists():
302
+ note("resolving recipe dependencies with uv (cached after the first run)")
303
+ debug("bootstrap: " + " ".join(command))
304
+
305
+ completed = subprocess.run(command, env=environment, check=False)
306
+ if completed.returncode == 0:
307
+ try:
308
+ marker.parent.mkdir(parents=True, exist_ok=True)
309
+ marker.touch()
310
+ except OSError: # pragma: no cover - a read-only cache is not fatal
311
+ pass
312
+ return completed.returncode
313
+
314
+
315
+ def _bootstrap_marker(metadata: ScriptMetadata) -> Path:
316
+ """A file recording that this exact dependency set has resolved before."""
317
+ import hashlib
318
+
319
+ key = hashlib.sha256(
320
+ "\n".join([metadata.requires_python or "", *sorted(metadata.dependencies)]).encode()
321
+ ).hexdigest()[:16]
322
+ cache = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
323
+ return Path(cache) / "make" / "bootstrap" / key
324
+
325
+
326
+ def sync(path: Path, metadata: ScriptMetadata, *, upgrade: bool = False) -> int:
327
+ """Pin the declared dependencies -- `uv lock --script`, or `uv sync` in a project.
328
+
329
+ Without `upgrade`, an existing lockfile is respected: a repo stays on the
330
+ version it was pinned to even after the shared package moves. That is the
331
+ whole difference from `.just-shared/`, where `git pull --ff-only || true`
332
+ silently dragged every repo to whatever HEAD happened to be. Moving a pin is
333
+ something you ask for, and it shows up as a diff.
334
+ """
335
+ uv = shutil.which("uv")
336
+ if uv is None:
337
+ raise MakeError("`uv` is not on PATH", hint="https://docs.astral.sh/uv/")
338
+
339
+ if metadata.dependencies:
340
+ note(f"upgrading the pins in {path.name}" if upgrade else f"locking {path.name}")
341
+ command = [uv, "lock", "--script", str(path)]
342
+ if upgrade:
343
+ command.append("--upgrade")
344
+ return subprocess.run(command, check=False).returncode
345
+
346
+ if (path.parent / "pyproject.toml").is_file():
347
+ note("syncing the project environment")
348
+ command = [uv, "sync"]
349
+ if upgrade:
350
+ command.append("--upgrade")
351
+ return subprocess.run(command, cwd=str(path.parent), check=False).returncode
352
+
353
+ raise MakeError(
354
+ f"{path.name} declares no dependencies and there is no pyproject.toml beside it",
355
+ hint='add an inline metadata block:\n # /// script\n # dependencies = ["make"]\n # ///',
356
+ )
357
+
358
+
359
+ def python_executable() -> str: # pragma: no cover - trivial
360
+ return sys.executable