mkrun 0.1.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.
Potentially problematic release.
This version of mkrun might be problematic. Click here for more details.
- mkrun-0.1.0/.gitignore +9 -0
- mkrun-0.1.0/LICENSE +21 -0
- mkrun-0.1.0/PKG-INFO +334 -0
- mkrun-0.1.0/README.md +310 -0
- mkrun-0.1.0/pyproject.toml +80 -0
- mkrun-0.1.0/src/make/__init__.py +73 -0
- mkrun-0.1.0/src/make/__main__.py +10 -0
- mkrun-0.1.0/src/make/bootstrap.py +360 -0
- mkrun-0.1.0/src/make/cli.py +481 -0
- mkrun-0.1.0/src/make/completions.py +79 -0
- mkrun-0.1.0/src/make/config.py +275 -0
- mkrun-0.1.0/src/make/context.py +205 -0
- mkrun-0.1.0/src/make/discovery.py +92 -0
- mkrun-0.1.0/src/make/env.py +217 -0
- mkrun-0.1.0/src/make/errors.py +64 -0
- mkrun-0.1.0/src/make/fs.py +122 -0
- mkrun-0.1.0/src/make/params.py +439 -0
- mkrun-0.1.0/src/make/recipes.py +401 -0
- mkrun-0.1.0/src/make/runner.py +250 -0
- mkrun-0.1.0/src/make/sh.py +275 -0
- mkrun-0.1.0/src/make/testing.py +196 -0
- mkrun-0.1.0/tests/conftest.py +42 -0
- mkrun-0.1.0/tests/helpers.py +10 -0
- mkrun-0.1.0/tests/test_cli.py +347 -0
- mkrun-0.1.0/tests/test_config.py +110 -0
- mkrun-0.1.0/tests/test_env.py +112 -0
- mkrun-0.1.0/tests/test_fs.py +76 -0
- mkrun-0.1.0/tests/test_overrides.py +111 -0
- mkrun-0.1.0/tests/test_params.py +143 -0
- mkrun-0.1.0/tests/test_recipes.py +153 -0
- mkrun-0.1.0/tests/test_runner.py +211 -0
- mkrun-0.1.0/tests/test_sh.py +102 -0
- mkrun-0.1.0/tests/test_startup.py +69 -0
mkrun-0.1.0/.gitignore
ADDED
mkrun-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 optersoft
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mkrun-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mkrun
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A command runner whose recipes are Python -- shareable as versioned packages.
|
|
5
|
+
Project-URL: Homepage, https://github.com/optersoft/make
|
|
6
|
+
Project-URL: Source, https://github.com/optersoft/make
|
|
7
|
+
Author-email: optersoft <david@optersoft.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: command runner,just,make,recipes,task runner,uv
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# make
|
|
26
|
+
|
|
27
|
+
A command runner whose recipes are Python.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# Makefile.py
|
|
31
|
+
from make import recipe, sh
|
|
32
|
+
|
|
33
|
+
@recipe(group="app", requires=["cargo"])
|
|
34
|
+
def test(*, fast: bool = False) -> None:
|
|
35
|
+
"""Run the test suite."""
|
|
36
|
+
sh("cargo", "test", *(["--lib"] if fast else []))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
$ make app.test --fast
|
|
41
|
+
$ cargo test --lib
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The command line is derived from the function signature, so there is no second
|
|
45
|
+
schema to keep in sync. Recipes are ordinary functions — importable,
|
|
46
|
+
unit-testable, and **distributable as versioned packages** rather than a
|
|
47
|
+
directory someone `git clone`d.
|
|
48
|
+
|
|
49
|
+
```console
|
|
50
|
+
$ uv tool install mkrun # installs the `make` and `mk` commands
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Three names, deliberately different.** The PyPI distribution is `mkrun`,
|
|
54
|
+
because both `make` and `mk` are taken by unrelated projects — and a recipe file
|
|
55
|
+
declaring `dependencies = ["make"]` would silently install a jinja2 templating
|
|
56
|
+
tool. The import name is `make` and the commands are `make` and `mk`, which are
|
|
57
|
+
independent of the distribution name (the same way `pip install pillow` gives
|
|
58
|
+
you `import PIL`).
|
|
59
|
+
|
|
60
|
+
`make` shadows GNU make on `PATH`. That is deliberate; `mk` is the identical
|
|
61
|
+
alias for repos that also use a real `Makefile`.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Why
|
|
66
|
+
|
|
67
|
+
`just` is a good dispatcher wrapped around a language that recipes outgrow. Once
|
|
68
|
+
a recipe body has a loop, an `if`, or three variables that must agree, you are
|
|
69
|
+
writing shell inside string interpolation with no types, no tests, and no way to
|
|
70
|
+
share it except copying a file.
|
|
71
|
+
|
|
72
|
+
| | `just` | `make` |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| Recipe body | bash, with `{{ }}` spliced in **as text** | Python; values are values |
|
|
75
|
+
| Arguments | positional strings | typed, from the signature — `int`, `Path`, `Literal`, `list[str]` |
|
|
76
|
+
| Required input | omit the default so it becomes a *parse error* | declared, with an error naming the field and where to set it |
|
|
77
|
+
| Namespacing | one flat namespace, `web-`/`box-` prefixes by convention | modules: `web.start`, `box.ls` |
|
|
78
|
+
| Overriding a shared recipe | impossible — duplicates are fatal | `@recipe(override="web.start")`, and `abstract=True` upstream |
|
|
79
|
+
| Sharing | `git clone --depth 1` into a gitignored directory | a PyPI (or git) dependency, resolved and locked by uv |
|
|
80
|
+
| Pinning | none — every checkout is on some HEAD | `make --sync` → a lockfile |
|
|
81
|
+
| Testing | `just --fmt --check` (it parses) | `pytest`, with a command recorder |
|
|
82
|
+
| Dry run | text expansion | every command actually suppressed |
|
|
83
|
+
|
|
84
|
+
Nothing here is theoretical. Every row is something that cost real bugs in a
|
|
85
|
+
fleet of five repositories sharing 1100 lines of `just` — a teardown that reaped
|
|
86
|
+
the wrong thing, a worktree that silently started with no environment at all, an
|
|
87
|
+
export that was a no-op for two repos out of five. All of them are properties of
|
|
88
|
+
small functions, and all of them are tests now.
|
|
89
|
+
|
|
90
|
+
`docs/why.md` names them, one by one, with the commits. Migrating, including the
|
|
91
|
+
full translation table: `docs/from-just.md`.
|
|
92
|
+
|
|
93
|
+
## Recipes
|
|
94
|
+
|
|
95
|
+
### Arguments come from the signature
|
|
96
|
+
|
|
97
|
+
Parameters **before `*`** are positional; parameters **after `*`** are options.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from pathlib import Path
|
|
101
|
+
from typing import Literal
|
|
102
|
+
from make import recipe, sh
|
|
103
|
+
|
|
104
|
+
@recipe
|
|
105
|
+
def publish(bundle: Path, *, track: Literal["alpha", "prod"] = "alpha",
|
|
106
|
+
locale: list[str] = [], dry: bool = False) -> None:
|
|
107
|
+
"""Upload a bundle to the store."""
|
|
108
|
+
sh("fastlane", "supply", "--aab", bundle, "--track", track,
|
|
109
|
+
*(["--validate_only"] if dry else []))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```console
|
|
113
|
+
$ make publish ./app.aab --track prod --locale es-ES --locale en-US
|
|
114
|
+
$ make publish --help
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Signature | Command line |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `path: Path` | required positional |
|
|
120
|
+
| `dest: str = "."` | optional positional |
|
|
121
|
+
| `*, port: int = 8001` | `--port 8001` |
|
|
122
|
+
| `*, force: bool = False` | `--force` |
|
|
123
|
+
| `*, color: bool = True` | `--no-color` |
|
|
124
|
+
| `*, track: Literal["a","b"]` | `--track {a,b}` |
|
|
125
|
+
| `*, locale: list[str] = []` | `--locale` (repeatable) |
|
|
126
|
+
| `*args: str` | trailing arguments |
|
|
127
|
+
|
|
128
|
+
Short flags, help text and environment fallbacks attach without leaving the
|
|
129
|
+
signature:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from typing import Annotated
|
|
133
|
+
from make import arg
|
|
134
|
+
|
|
135
|
+
def serve(*, port: Annotated[int, arg("-p", help="dev port", env="DEV_PORT")] = 8001): ...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Options on `@recipe`
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
@recipe(
|
|
142
|
+
group="play", # namespace -> play.publish
|
|
143
|
+
needs=[build, sign], # run first, once per invocation
|
|
144
|
+
requires=["fastlane"], # must be on PATH; checked before anything runs
|
|
145
|
+
dangerous=True, # demand --yes or an interactive confirmation
|
|
146
|
+
inputs=["src/**/*.rs"], # skip when outputs are newer than inputs
|
|
147
|
+
outputs=["dist/app"],
|
|
148
|
+
aliases=["ship"],
|
|
149
|
+
abstract=False, # declared but unimplemented; a consumer must override
|
|
150
|
+
override=False, # True, or the full name of the recipe being replaced
|
|
151
|
+
keep_cwd=False, # run where the user stood, not at the recipe-file root
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Running commands
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
sh("git", "commit", "-m", message) # argv list -- no quoting hazard, ever
|
|
159
|
+
sh.out("git", "rev-parse", "HEAD") # captured stdout, stripped
|
|
160
|
+
sh.lines("git", "ls-files")
|
|
161
|
+
sh.ok("command", "-v", "fastlane") # bool, never raises
|
|
162
|
+
sh.pipe("du -sk target | cut -f1") # explicit shell, because it is the hazard
|
|
163
|
+
sh.bash(script) # multi-line bash, set -euo pipefail
|
|
164
|
+
sh.background("tailwindcss", "--watch", log="tmp/css.log")
|
|
165
|
+
sh.replace_process("dx", "serve") # exec, replacing this process
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
There is no interpolation step, so a value containing a space, a quote or a `$`
|
|
169
|
+
is data and cannot become syntax. Every one of these honours `--dry-run`; give
|
|
170
|
+
`sh.out(..., dry="...")` or `sh.ok(..., dry=False)` a stand-in when the value
|
|
171
|
+
steers later logic.
|
|
172
|
+
|
|
173
|
+
### Changing files
|
|
174
|
+
|
|
175
|
+
A dry run that suppresses every command but still writes files looks safe and
|
|
176
|
+
is not. Use `fs` wherever a recipe changes something:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from make import fs
|
|
180
|
+
|
|
181
|
+
fs.write(path, text) fs.copy(source, destination)
|
|
182
|
+
fs.mkdir(path) fs.replace(source, destination) # atomic move
|
|
183
|
+
fs.remove(path) fs.rmtree(path)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Reading is untouched — `read_text`, `glob` and `stat` stay as they are, because
|
|
187
|
+
suppressing reads would make the dry run diverge from the real one.
|
|
188
|
+
|
|
189
|
+
### Configuration for shared packages
|
|
190
|
+
|
|
191
|
+
A package declares what it needs from the consumer, typed:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from dataclasses import dataclass, field
|
|
195
|
+
from make import config
|
|
196
|
+
|
|
197
|
+
@config.section("web")
|
|
198
|
+
@dataclass
|
|
199
|
+
class Web:
|
|
200
|
+
bin: str # required
|
|
201
|
+
port: int = 8001
|
|
202
|
+
watch: list[str] = field(default_factory=list)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Consumers set it from the recipe file, a config file, or the environment — last
|
|
206
|
+
wins:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
Web.configure(bin="acme-web", port=8005, watch=["server", "web"])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```toml
|
|
213
|
+
# make.toml (or [tool.make.web] in pyproject.toml)
|
|
214
|
+
[web]
|
|
215
|
+
port = 8005
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```console
|
|
219
|
+
$ MAKE_WEB_PORT=8105 make web.start
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
A missing required value fails with the field, its type, and all three places it
|
|
223
|
+
could be set.
|
|
224
|
+
|
|
225
|
+
### Layered secrets
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from make import env
|
|
229
|
+
env.layered() # ~/.make/secrets.env -> ~/.make/<repo>.env -> ./.env
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Later layers win, but a variable exported by the caller still beats all of them.
|
|
233
|
+
`<repo>` resolves through `git rev-parse --git-common-dir`, so it is the *main*
|
|
234
|
+
checkout's name even from inside a linked worktree — the failure mode where a
|
|
235
|
+
worktree silently starts with no application environment at all. `~/.just/` is
|
|
236
|
+
read too, so an existing setup keeps working.
|
|
237
|
+
|
|
238
|
+
## Sharing recipes
|
|
239
|
+
|
|
240
|
+
This is the point. Declare dependencies inline (PEP 723):
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
# Makefile.py
|
|
244
|
+
# /// script
|
|
245
|
+
# requires-python = ">=3.11"
|
|
246
|
+
# dependencies = ["mkrun>=0.1", "acme-recipes>=0.4"]
|
|
247
|
+
# ///
|
|
248
|
+
from make import recipe, sh
|
|
249
|
+
from acme_recipes import deploy, docker # importing registers deploy.* and docker.*
|
|
250
|
+
|
|
251
|
+
deploy.Deploy.configure(host="app.example.com", unit="acme-web")
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
```console
|
|
255
|
+
$ make --sync # pin -> Makefile.py.lock, committed
|
|
256
|
+
$ make --sync --upgrade # move the pins, deliberately, as a reviewable diff
|
|
257
|
+
$ make web.start
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Without `--upgrade`, an existing lock is respected: a repo stays on the version
|
|
261
|
+
it was pinned to even after the shared package moves. That is the whole
|
|
262
|
+
difference from a `git pull --ff-only || true` that drags every checkout to
|
|
263
|
+
whatever `HEAD` happens to be.
|
|
264
|
+
|
|
265
|
+
If the current interpreter already satisfies the dependencies, nothing happens.
|
|
266
|
+
Otherwise `make` re-executes itself under `uv run`, into a cached environment.
|
|
267
|
+
In a project that already has a `pyproject.toml` and a virtualenv, put the
|
|
268
|
+
dependencies there instead and `make --sync` runs `uv sync`.
|
|
269
|
+
|
|
270
|
+
Publishing a recipe package is publishing a wheel. Nothing about it is special:
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
# make_recipes_yourthing/__init__.py
|
|
274
|
+
from make import group, sh
|
|
275
|
+
|
|
276
|
+
docker = group("docker")
|
|
277
|
+
|
|
278
|
+
@docker
|
|
279
|
+
def build(*, tag: str = "latest") -> None:
|
|
280
|
+
"""Build the image."""
|
|
281
|
+
sh("docker", "build", "-t", f"app:{tag}", ".")
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Testing recipes
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
from make.testing import record
|
|
288
|
+
from acme_recipes import web
|
|
289
|
+
|
|
290
|
+
def test_start_reaps_a_stale_lock_holder():
|
|
291
|
+
with record(responses={"lsof -t": "4711"}) as rec:
|
|
292
|
+
web.start(port=8105)
|
|
293
|
+
assert rec.saw("kill", "-9", "4711")
|
|
294
|
+
assert rec.matched(r"dx serve .*--port 8105")
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`record()` captures every command instead of running it, answers `sh.out()` with
|
|
298
|
+
canned text, and reports declared tools as present. Recipes called from Python
|
|
299
|
+
are plain functions — `needs=`, the confirmation gate and staleness belong to the
|
|
300
|
+
runner, not the function.
|
|
301
|
+
|
|
302
|
+
## Command line
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
make [options] <recipe> [arguments] [<recipe> [arguments] ...]
|
|
306
|
+
|
|
307
|
+
-l, --list list recipes (the default with no recipe)
|
|
308
|
+
-h, --help [RECIPE] help, or full help for one recipe
|
|
309
|
+
-n, --dry-run print commands instead of running them
|
|
310
|
+
-y, --yes pre-answer confirmations for dangerous recipes
|
|
311
|
+
-f, --force ignore inputs=/outputs= staleness
|
|
312
|
+
-j, --jobs N run independent prerequisites in parallel
|
|
313
|
+
-q, --quiet only errors
|
|
314
|
+
-v, --verbose more detail (repeatable)
|
|
315
|
+
-C, --cwd DIR change directory before finding the recipe file
|
|
316
|
+
-F, --file PATH use this recipe file
|
|
317
|
+
-e, --env KEY=VALUE set a variable for every command
|
|
318
|
+
--json machine-readable --list
|
|
319
|
+
--doctor check every declared tool
|
|
320
|
+
--sync resolve and pin dependencies
|
|
321
|
+
--completions SH bash | zsh | fish
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Recipe files, searched from the current directory upward: `Makefile.py`,
|
|
325
|
+
`makefile.py`, `mk.py`, `.make/main.py`. Not `make.py` — that name can shadow
|
|
326
|
+
`import make`.
|
|
327
|
+
|
|
328
|
+
## Status
|
|
329
|
+
|
|
330
|
+
Alpha. The recipe-authoring API — `@recipe`, `sh`, `fs`, `config`, `env` — is
|
|
331
|
+
what a private fleet of seven recipe groups is already built on, and is not
|
|
332
|
+
expected to change shape. The internals may.
|
|
333
|
+
|
|
334
|
+
MIT.
|
mkrun-0.1.0/README.md
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# make
|
|
2
|
+
|
|
3
|
+
A command runner whose recipes are Python.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
# Makefile.py
|
|
7
|
+
from make import recipe, sh
|
|
8
|
+
|
|
9
|
+
@recipe(group="app", requires=["cargo"])
|
|
10
|
+
def test(*, fast: bool = False) -> None:
|
|
11
|
+
"""Run the test suite."""
|
|
12
|
+
sh("cargo", "test", *(["--lib"] if fast else []))
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
$ make app.test --fast
|
|
17
|
+
$ cargo test --lib
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The command line is derived from the function signature, so there is no second
|
|
21
|
+
schema to keep in sync. Recipes are ordinary functions — importable,
|
|
22
|
+
unit-testable, and **distributable as versioned packages** rather than a
|
|
23
|
+
directory someone `git clone`d.
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ uv tool install mkrun # installs the `make` and `mk` commands
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Three names, deliberately different.** The PyPI distribution is `mkrun`,
|
|
30
|
+
because both `make` and `mk` are taken by unrelated projects — and a recipe file
|
|
31
|
+
declaring `dependencies = ["make"]` would silently install a jinja2 templating
|
|
32
|
+
tool. The import name is `make` and the commands are `make` and `mk`, which are
|
|
33
|
+
independent of the distribution name (the same way `pip install pillow` gives
|
|
34
|
+
you `import PIL`).
|
|
35
|
+
|
|
36
|
+
`make` shadows GNU make on `PATH`. That is deliberate; `mk` is the identical
|
|
37
|
+
alias for repos that also use a real `Makefile`.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Why
|
|
42
|
+
|
|
43
|
+
`just` is a good dispatcher wrapped around a language that recipes outgrow. Once
|
|
44
|
+
a recipe body has a loop, an `if`, or three variables that must agree, you are
|
|
45
|
+
writing shell inside string interpolation with no types, no tests, and no way to
|
|
46
|
+
share it except copying a file.
|
|
47
|
+
|
|
48
|
+
| | `just` | `make` |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| Recipe body | bash, with `{{ }}` spliced in **as text** | Python; values are values |
|
|
51
|
+
| Arguments | positional strings | typed, from the signature — `int`, `Path`, `Literal`, `list[str]` |
|
|
52
|
+
| Required input | omit the default so it becomes a *parse error* | declared, with an error naming the field and where to set it |
|
|
53
|
+
| Namespacing | one flat namespace, `web-`/`box-` prefixes by convention | modules: `web.start`, `box.ls` |
|
|
54
|
+
| Overriding a shared recipe | impossible — duplicates are fatal | `@recipe(override="web.start")`, and `abstract=True` upstream |
|
|
55
|
+
| Sharing | `git clone --depth 1` into a gitignored directory | a PyPI (or git) dependency, resolved and locked by uv |
|
|
56
|
+
| Pinning | none — every checkout is on some HEAD | `make --sync` → a lockfile |
|
|
57
|
+
| Testing | `just --fmt --check` (it parses) | `pytest`, with a command recorder |
|
|
58
|
+
| Dry run | text expansion | every command actually suppressed |
|
|
59
|
+
|
|
60
|
+
Nothing here is theoretical. Every row is something that cost real bugs in a
|
|
61
|
+
fleet of five repositories sharing 1100 lines of `just` — a teardown that reaped
|
|
62
|
+
the wrong thing, a worktree that silently started with no environment at all, an
|
|
63
|
+
export that was a no-op for two repos out of five. All of them are properties of
|
|
64
|
+
small functions, and all of them are tests now.
|
|
65
|
+
|
|
66
|
+
`docs/why.md` names them, one by one, with the commits. Migrating, including the
|
|
67
|
+
full translation table: `docs/from-just.md`.
|
|
68
|
+
|
|
69
|
+
## Recipes
|
|
70
|
+
|
|
71
|
+
### Arguments come from the signature
|
|
72
|
+
|
|
73
|
+
Parameters **before `*`** are positional; parameters **after `*`** are options.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
from typing import Literal
|
|
78
|
+
from make import recipe, sh
|
|
79
|
+
|
|
80
|
+
@recipe
|
|
81
|
+
def publish(bundle: Path, *, track: Literal["alpha", "prod"] = "alpha",
|
|
82
|
+
locale: list[str] = [], dry: bool = False) -> None:
|
|
83
|
+
"""Upload a bundle to the store."""
|
|
84
|
+
sh("fastlane", "supply", "--aab", bundle, "--track", track,
|
|
85
|
+
*(["--validate_only"] if dry else []))
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```console
|
|
89
|
+
$ make publish ./app.aab --track prod --locale es-ES --locale en-US
|
|
90
|
+
$ make publish --help
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Signature | Command line |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `path: Path` | required positional |
|
|
96
|
+
| `dest: str = "."` | optional positional |
|
|
97
|
+
| `*, port: int = 8001` | `--port 8001` |
|
|
98
|
+
| `*, force: bool = False` | `--force` |
|
|
99
|
+
| `*, color: bool = True` | `--no-color` |
|
|
100
|
+
| `*, track: Literal["a","b"]` | `--track {a,b}` |
|
|
101
|
+
| `*, locale: list[str] = []` | `--locale` (repeatable) |
|
|
102
|
+
| `*args: str` | trailing arguments |
|
|
103
|
+
|
|
104
|
+
Short flags, help text and environment fallbacks attach without leaving the
|
|
105
|
+
signature:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from typing import Annotated
|
|
109
|
+
from make import arg
|
|
110
|
+
|
|
111
|
+
def serve(*, port: Annotated[int, arg("-p", help="dev port", env="DEV_PORT")] = 8001): ...
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Options on `@recipe`
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
@recipe(
|
|
118
|
+
group="play", # namespace -> play.publish
|
|
119
|
+
needs=[build, sign], # run first, once per invocation
|
|
120
|
+
requires=["fastlane"], # must be on PATH; checked before anything runs
|
|
121
|
+
dangerous=True, # demand --yes or an interactive confirmation
|
|
122
|
+
inputs=["src/**/*.rs"], # skip when outputs are newer than inputs
|
|
123
|
+
outputs=["dist/app"],
|
|
124
|
+
aliases=["ship"],
|
|
125
|
+
abstract=False, # declared but unimplemented; a consumer must override
|
|
126
|
+
override=False, # True, or the full name of the recipe being replaced
|
|
127
|
+
keep_cwd=False, # run where the user stood, not at the recipe-file root
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Running commands
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
sh("git", "commit", "-m", message) # argv list -- no quoting hazard, ever
|
|
135
|
+
sh.out("git", "rev-parse", "HEAD") # captured stdout, stripped
|
|
136
|
+
sh.lines("git", "ls-files")
|
|
137
|
+
sh.ok("command", "-v", "fastlane") # bool, never raises
|
|
138
|
+
sh.pipe("du -sk target | cut -f1") # explicit shell, because it is the hazard
|
|
139
|
+
sh.bash(script) # multi-line bash, set -euo pipefail
|
|
140
|
+
sh.background("tailwindcss", "--watch", log="tmp/css.log")
|
|
141
|
+
sh.replace_process("dx", "serve") # exec, replacing this process
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
There is no interpolation step, so a value containing a space, a quote or a `$`
|
|
145
|
+
is data and cannot become syntax. Every one of these honours `--dry-run`; give
|
|
146
|
+
`sh.out(..., dry="...")` or `sh.ok(..., dry=False)` a stand-in when the value
|
|
147
|
+
steers later logic.
|
|
148
|
+
|
|
149
|
+
### Changing files
|
|
150
|
+
|
|
151
|
+
A dry run that suppresses every command but still writes files looks safe and
|
|
152
|
+
is not. Use `fs` wherever a recipe changes something:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from make import fs
|
|
156
|
+
|
|
157
|
+
fs.write(path, text) fs.copy(source, destination)
|
|
158
|
+
fs.mkdir(path) fs.replace(source, destination) # atomic move
|
|
159
|
+
fs.remove(path) fs.rmtree(path)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Reading is untouched — `read_text`, `glob` and `stat` stay as they are, because
|
|
163
|
+
suppressing reads would make the dry run diverge from the real one.
|
|
164
|
+
|
|
165
|
+
### Configuration for shared packages
|
|
166
|
+
|
|
167
|
+
A package declares what it needs from the consumer, typed:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from dataclasses import dataclass, field
|
|
171
|
+
from make import config
|
|
172
|
+
|
|
173
|
+
@config.section("web")
|
|
174
|
+
@dataclass
|
|
175
|
+
class Web:
|
|
176
|
+
bin: str # required
|
|
177
|
+
port: int = 8001
|
|
178
|
+
watch: list[str] = field(default_factory=list)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Consumers set it from the recipe file, a config file, or the environment — last
|
|
182
|
+
wins:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
Web.configure(bin="acme-web", port=8005, watch=["server", "web"])
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```toml
|
|
189
|
+
# make.toml (or [tool.make.web] in pyproject.toml)
|
|
190
|
+
[web]
|
|
191
|
+
port = 8005
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```console
|
|
195
|
+
$ MAKE_WEB_PORT=8105 make web.start
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
A missing required value fails with the field, its type, and all three places it
|
|
199
|
+
could be set.
|
|
200
|
+
|
|
201
|
+
### Layered secrets
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from make import env
|
|
205
|
+
env.layered() # ~/.make/secrets.env -> ~/.make/<repo>.env -> ./.env
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Later layers win, but a variable exported by the caller still beats all of them.
|
|
209
|
+
`<repo>` resolves through `git rev-parse --git-common-dir`, so it is the *main*
|
|
210
|
+
checkout's name even from inside a linked worktree — the failure mode where a
|
|
211
|
+
worktree silently starts with no application environment at all. `~/.just/` is
|
|
212
|
+
read too, so an existing setup keeps working.
|
|
213
|
+
|
|
214
|
+
## Sharing recipes
|
|
215
|
+
|
|
216
|
+
This is the point. Declare dependencies inline (PEP 723):
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
# Makefile.py
|
|
220
|
+
# /// script
|
|
221
|
+
# requires-python = ">=3.11"
|
|
222
|
+
# dependencies = ["mkrun>=0.1", "acme-recipes>=0.4"]
|
|
223
|
+
# ///
|
|
224
|
+
from make import recipe, sh
|
|
225
|
+
from acme_recipes import deploy, docker # importing registers deploy.* and docker.*
|
|
226
|
+
|
|
227
|
+
deploy.Deploy.configure(host="app.example.com", unit="acme-web")
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```console
|
|
231
|
+
$ make --sync # pin -> Makefile.py.lock, committed
|
|
232
|
+
$ make --sync --upgrade # move the pins, deliberately, as a reviewable diff
|
|
233
|
+
$ make web.start
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Without `--upgrade`, an existing lock is respected: a repo stays on the version
|
|
237
|
+
it was pinned to even after the shared package moves. That is the whole
|
|
238
|
+
difference from a `git pull --ff-only || true` that drags every checkout to
|
|
239
|
+
whatever `HEAD` happens to be.
|
|
240
|
+
|
|
241
|
+
If the current interpreter already satisfies the dependencies, nothing happens.
|
|
242
|
+
Otherwise `make` re-executes itself under `uv run`, into a cached environment.
|
|
243
|
+
In a project that already has a `pyproject.toml` and a virtualenv, put the
|
|
244
|
+
dependencies there instead and `make --sync` runs `uv sync`.
|
|
245
|
+
|
|
246
|
+
Publishing a recipe package is publishing a wheel. Nothing about it is special:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
# make_recipes_yourthing/__init__.py
|
|
250
|
+
from make import group, sh
|
|
251
|
+
|
|
252
|
+
docker = group("docker")
|
|
253
|
+
|
|
254
|
+
@docker
|
|
255
|
+
def build(*, tag: str = "latest") -> None:
|
|
256
|
+
"""Build the image."""
|
|
257
|
+
sh("docker", "build", "-t", f"app:{tag}", ".")
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Testing recipes
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from make.testing import record
|
|
264
|
+
from acme_recipes import web
|
|
265
|
+
|
|
266
|
+
def test_start_reaps_a_stale_lock_holder():
|
|
267
|
+
with record(responses={"lsof -t": "4711"}) as rec:
|
|
268
|
+
web.start(port=8105)
|
|
269
|
+
assert rec.saw("kill", "-9", "4711")
|
|
270
|
+
assert rec.matched(r"dx serve .*--port 8105")
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`record()` captures every command instead of running it, answers `sh.out()` with
|
|
274
|
+
canned text, and reports declared tools as present. Recipes called from Python
|
|
275
|
+
are plain functions — `needs=`, the confirmation gate and staleness belong to the
|
|
276
|
+
runner, not the function.
|
|
277
|
+
|
|
278
|
+
## Command line
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
make [options] <recipe> [arguments] [<recipe> [arguments] ...]
|
|
282
|
+
|
|
283
|
+
-l, --list list recipes (the default with no recipe)
|
|
284
|
+
-h, --help [RECIPE] help, or full help for one recipe
|
|
285
|
+
-n, --dry-run print commands instead of running them
|
|
286
|
+
-y, --yes pre-answer confirmations for dangerous recipes
|
|
287
|
+
-f, --force ignore inputs=/outputs= staleness
|
|
288
|
+
-j, --jobs N run independent prerequisites in parallel
|
|
289
|
+
-q, --quiet only errors
|
|
290
|
+
-v, --verbose more detail (repeatable)
|
|
291
|
+
-C, --cwd DIR change directory before finding the recipe file
|
|
292
|
+
-F, --file PATH use this recipe file
|
|
293
|
+
-e, --env KEY=VALUE set a variable for every command
|
|
294
|
+
--json machine-readable --list
|
|
295
|
+
--doctor check every declared tool
|
|
296
|
+
--sync resolve and pin dependencies
|
|
297
|
+
--completions SH bash | zsh | fish
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Recipe files, searched from the current directory upward: `Makefile.py`,
|
|
301
|
+
`makefile.py`, `mk.py`, `.make/main.py`. Not `make.py` — that name can shadow
|
|
302
|
+
`import make`.
|
|
303
|
+
|
|
304
|
+
## Status
|
|
305
|
+
|
|
306
|
+
Alpha. The recipe-authoring API — `@recipe`, `sh`, `fs`, `config`, `env` — is
|
|
307
|
+
what a private fleet of seven recipe groups is already built on, and is not
|
|
308
|
+
expected to change shape. The internals may.
|
|
309
|
+
|
|
310
|
+
MIT.
|