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 ADDED
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .env
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.