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 +73 -0
- make/__main__.py +10 -0
- make/bootstrap.py +360 -0
- make/cli.py +481 -0
- make/completions.py +79 -0
- make/config.py +275 -0
- make/context.py +205 -0
- make/discovery.py +92 -0
- make/env.py +217 -0
- make/errors.py +64 -0
- make/fs.py +122 -0
- make/params.py +439 -0
- make/recipes.py +401 -0
- make/runner.py +250 -0
- make/sh.py +275 -0
- make/testing.py +196 -0
- mkrun-0.1.0.dist-info/METADATA +334 -0
- mkrun-0.1.0.dist-info/RECORD +21 -0
- mkrun-0.1.0.dist-info/WHEEL +4 -0
- mkrun-0.1.0.dist-info/entry_points.txt +3 -0
- mkrun-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|