uv-launch 0.2.0__tar.gz → 0.2.2__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.
- {uv_launch-0.2.0 → uv_launch-0.2.2}/PKG-INFO +17 -3
- {uv_launch-0.2.0 → uv_launch-0.2.2}/README.md +16 -2
- {uv_launch-0.2.0 → uv_launch-0.2.2}/pyproject.toml +1 -1
- {uv_launch-0.2.0 → uv_launch-0.2.2}/pyproject.toml.orig +1 -1
- {uv_launch-0.2.0 → uv_launch-0.2.2}/src/uv_launch/__init__.py +37 -4
- {uv_launch-0.2.0 → uv_launch-0.2.2}/src/uv_launch/dependencies.py +10 -1
- uv_launch-0.2.2/src/uv_launch/source.py +231 -0
- {uv_launch-0.2.0 → uv_launch-0.2.2}/src/uv_launch/__main__.py +0 -0
- {uv_launch-0.2.0 → uv_launch-0.2.2}/src/uv_launch/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: uv-launch
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Immediate Python command startup with atomic background updates through uv
|
|
5
5
|
Requires-Dist: packaging>=24
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -11,7 +11,7 @@ Description-Content-Type: text/markdown
|
|
|
11
11
|
|
|
12
12
|
Install Python commands that start immediately and update in the background. The foreground shell shim executes an already installed environment. It does not invoke uv or wait for network access.
|
|
13
13
|
|
|
14
|
-
Requires macOS or Linux, Python 3.11+, and [uv](https://docs.astral.sh/uv/).
|
|
14
|
+
Requires macOS or Linux, Python 3.11+, and [uv](https://docs.astral.sh/uv/). Use `uvx uv-launch` from [PyPI](https://pypi.org/project/uv-launch/) to install commands without maintaining a bootstrap environment. A stable `uv tool install uv-launch` also works. Each managed application includes its own updater, so later launches do not depend on the bootstrap interpreter. Then install commands:
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
17
|
uv-launch install ruff ruff
|
|
@@ -28,7 +28,21 @@ Each invocation captures the current immutable generation and starts a detached
|
|
|
28
28
|
|
|
29
29
|
The launched command retains its arguments, working directory, environment and exit status. Updates never mutate its environment. An offline or failed update leaves the installed version usable. `status` reports the current generation and the last completed update result; background diagnostics are in `<UV_LAUNCH_HOME>/<command>/update.log`. Checks use the existing uv/Git authentication configuration and do not change it.
|
|
30
30
|
|
|
31
|
-
An initial installation requires dependency access. Automatic updates have a 600-second timeout per subprocess by default; configure it with `install --timeout`. Validation checks installation and entrypoint integrity, not application-specific behavior. Keep old generations while processes may still use them; this release deliberately does not automatically delete them.
|
|
31
|
+
An initial installation requires dependency access. Automatic updates have a 600-second timeout per subprocess by default; configure it with `install --timeout`. Validation checks installation and entrypoint integrity, not application-specific behavior. Keep old generations while processes may still use them; this release deliberately does not automatically delete them. The updater is resolved with the application and runs from the captured generation. A successful update can therefore deliver a newer updater as well as newer application code. Existing installations created before 0.2.1 need one reinstall to adopt this behavior.
|
|
32
|
+
|
|
33
|
+
## Source toolfuncs
|
|
34
|
+
|
|
35
|
+
Install a standalone toolfunc together with its adjacent repository files:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uvx uv-launch install-source wiki https://github.com/owner/wiki scripts/wiki --prepare prepare-runtime
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`SOURCE` must be a repository-relative PEP 723 toolfunc whose filename matches the command. `--revision` defaults to `main`; `--with`, `--python`, `--bin-dir` and `--timeout` work as for package installations. Put `--prepare` last: its remaining arguments invoke a candidate's toolfunc to prepare and validate application-specific integrations before activation.
|
|
42
|
+
|
|
43
|
+
Each check fetches one exact Git revision and resolves the script's declared dependencies together with toolfuncs and uv-launch. It validates the prepared tool and optional preparation command, then selects the complete generation atomically. Source-only changes reuse the same immutable dependency environment. Running commands keep their captured source and interpreter; failed builds and offline checks keep the last successful installation.
|
|
44
|
+
|
|
45
|
+
The foreground calls `toolfuncs run-prepared`, which loads the selected source in its already prepared environment. It does not resolve or install another environment. `managed_target` resolves a source installation to its executable source for static discovery. The source repository is delivery state; applications should keep mutable user work elsewhere.
|
|
32
46
|
|
|
33
47
|
## Service startup and companion commands
|
|
34
48
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Install Python commands that start immediately and update in the background. The foreground shell shim executes an already installed environment. It does not invoke uv or wait for network access.
|
|
4
4
|
|
|
5
|
-
Requires macOS or Linux, Python 3.11+, and [uv](https://docs.astral.sh/uv/).
|
|
5
|
+
Requires macOS or Linux, Python 3.11+, and [uv](https://docs.astral.sh/uv/). Use `uvx uv-launch` from [PyPI](https://pypi.org/project/uv-launch/) to install commands without maintaining a bootstrap environment. A stable `uv tool install uv-launch` also works. Each managed application includes its own updater, so later launches do not depend on the bootstrap interpreter. Then install commands:
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
uv-launch install ruff ruff
|
|
@@ -19,7 +19,21 @@ Each invocation captures the current immutable generation and starts a detached
|
|
|
19
19
|
|
|
20
20
|
The launched command retains its arguments, working directory, environment and exit status. Updates never mutate its environment. An offline or failed update leaves the installed version usable. `status` reports the current generation and the last completed update result; background diagnostics are in `<UV_LAUNCH_HOME>/<command>/update.log`. Checks use the existing uv/Git authentication configuration and do not change it.
|
|
21
21
|
|
|
22
|
-
An initial installation requires dependency access. Automatic updates have a 600-second timeout per subprocess by default; configure it with `install --timeout`. Validation checks installation and entrypoint integrity, not application-specific behavior. Keep old generations while processes may still use them; this release deliberately does not automatically delete them.
|
|
22
|
+
An initial installation requires dependency access. Automatic updates have a 600-second timeout per subprocess by default; configure it with `install --timeout`. Validation checks installation and entrypoint integrity, not application-specific behavior. Keep old generations while processes may still use them; this release deliberately does not automatically delete them. The updater is resolved with the application and runs from the captured generation. A successful update can therefore deliver a newer updater as well as newer application code. Existing installations created before 0.2.1 need one reinstall to adopt this behavior.
|
|
23
|
+
|
|
24
|
+
## Source toolfuncs
|
|
25
|
+
|
|
26
|
+
Install a standalone toolfunc together with its adjacent repository files:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
uvx uv-launch install-source wiki https://github.com/owner/wiki scripts/wiki --prepare prepare-runtime
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`SOURCE` must be a repository-relative PEP 723 toolfunc whose filename matches the command. `--revision` defaults to `main`; `--with`, `--python`, `--bin-dir` and `--timeout` work as for package installations. Put `--prepare` last: its remaining arguments invoke a candidate's toolfunc to prepare and validate application-specific integrations before activation.
|
|
33
|
+
|
|
34
|
+
Each check fetches one exact Git revision and resolves the script's declared dependencies together with toolfuncs and uv-launch. It validates the prepared tool and optional preparation command, then selects the complete generation atomically. Source-only changes reuse the same immutable dependency environment. Running commands keep their captured source and interpreter; failed builds and offline checks keep the last successful installation.
|
|
35
|
+
|
|
36
|
+
The foreground calls `toolfuncs run-prepared`, which loads the selected source in its already prepared environment. It does not resolve or install another environment. `managed_target` resolves a source installation to its executable source for static discovery. The source repository is delivery state; applications should keep mutable user work elsewhere.
|
|
23
37
|
|
|
24
38
|
## Service startup and companion commands
|
|
25
39
|
|
|
@@ -53,9 +53,7 @@ def _run_checked(arguments: list[str], **kwargs) -> None:
|
|
|
53
53
|
|
|
54
54
|
def state_root() -> Path:
|
|
55
55
|
"""Return durable installation state, independently of any current project."""
|
|
56
|
-
return (
|
|
57
|
-
Path(os.environ.get("UV_LAUNCH_HOME", "~/.local/share/uv-launch")).expanduser().absolute()
|
|
58
|
-
)
|
|
56
|
+
return Path(os.environ.get("UV_LAUNCH_HOME", "~/.local/share/uv-launch")).expanduser().resolve()
|
|
59
57
|
|
|
60
58
|
|
|
61
59
|
def tool_directory(name: str) -> Path:
|
|
@@ -102,6 +100,10 @@ def run_uv(config: dict, arguments: list[str], *, cwd: Path) -> None:
|
|
|
102
100
|
|
|
103
101
|
def refresh(root: Path, config: dict) -> dict:
|
|
104
102
|
"""Prepare an immutable candidate; publish only after dependency and entrypoint checks."""
|
|
103
|
+
if config.get("kind") == "source":
|
|
104
|
+
from .source import refresh_source
|
|
105
|
+
|
|
106
|
+
return refresh_source(root, config)
|
|
105
107
|
generations = root / "generations"
|
|
106
108
|
generations.mkdir(exist_ok=True)
|
|
107
109
|
candidate = generations / uuid.uuid4().hex
|
|
@@ -209,10 +211,14 @@ def refresh(root: Path, config: dict) -> dict:
|
|
|
209
211
|
|
|
210
212
|
|
|
211
213
|
def _launcher_content(root: Path, config: dict, manager_python: str) -> str:
|
|
212
|
-
python = shlex.quote(manager_python)
|
|
213
214
|
home = shlex.quote(str(root.parent))
|
|
214
215
|
name = shlex.quote(config["name"])
|
|
215
216
|
directory = shlex.quote(str(root))
|
|
217
|
+
python = (
|
|
218
|
+
f'{directory}/"$generation"/venv/bin/python'
|
|
219
|
+
if config.get("managed_updater")
|
|
220
|
+
else shlex.quote(manager_python)
|
|
221
|
+
)
|
|
216
222
|
return (
|
|
217
223
|
"#!/bin/sh\n"
|
|
218
224
|
f"# Managed by uv-launch: {config['name']}\n"
|
|
@@ -254,7 +260,10 @@ def install(args: argparse.Namespace) -> dict:
|
|
|
254
260
|
"uv": uv,
|
|
255
261
|
"timeout": args.timeout,
|
|
256
262
|
"bin_dir": str(Path(args.bin_dir).expanduser().absolute()),
|
|
263
|
+
"managed_updater": True,
|
|
257
264
|
}
|
|
265
|
+
if args.name != "uv-launch":
|
|
266
|
+
config["requirements"].append("uv-launch>=0.2.1,<0.3")
|
|
258
267
|
for requirement in config["requirements"]:
|
|
259
268
|
if not requirement or "\n" in requirement or "\r" in requirement:
|
|
260
269
|
raise ValueError("each requirement must be a single nonempty line")
|
|
@@ -344,6 +353,10 @@ def execute_installation(
|
|
|
344
353
|
raise ValueError("installation identity does not match its generation")
|
|
345
354
|
config = json.loads((generation / "config.json").read_text())
|
|
346
355
|
selected = command or config["entrypoint"]
|
|
356
|
+
if config.get("kind") == "source" and command is None:
|
|
357
|
+
executable = generation / "command"
|
|
358
|
+
os.execv(str(executable), [str(executable), *arguments])
|
|
359
|
+
raise AssertionError("exec returned")
|
|
347
360
|
tool_directory(selected)
|
|
348
361
|
executable = generation / "venv/bin" / selected
|
|
349
362
|
if not executable.is_file() or not os.access(executable, os.X_OK):
|
|
@@ -367,6 +380,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
367
380
|
installing.add_argument(
|
|
368
381
|
"--timeout", type=float, default=600, help="seconds per update operation"
|
|
369
382
|
)
|
|
383
|
+
source_installing = commands.add_parser(
|
|
384
|
+
"install-source", help="install a toolfunc and adjacent files from a Git repository"
|
|
385
|
+
)
|
|
386
|
+
source_installing.add_argument("name", help="toolfunc executable name")
|
|
387
|
+
source_installing.add_argument("repository", help="Git remote URL or local repository")
|
|
388
|
+
source_installing.add_argument("source", help="repository-relative toolfunc path")
|
|
389
|
+
source_installing.add_argument("--revision", default="main", help="branch, tag, or commit")
|
|
390
|
+
source_installing.add_argument("--with", dest="extra", action="append", default=[])
|
|
391
|
+
source_installing.add_argument("--python", default="3.13")
|
|
392
|
+
source_installing.add_argument("--bin-dir", default="~/.local/bin")
|
|
393
|
+
source_installing.add_argument("--timeout", type=float, default=600)
|
|
394
|
+
source_installing.add_argument(
|
|
395
|
+
"--prepare",
|
|
396
|
+
nargs=argparse.REMAINDER,
|
|
397
|
+
help="toolfunc arguments to prepare and validate a candidate before activation",
|
|
398
|
+
)
|
|
370
399
|
updating = commands.add_parser("update", help="check and atomically activate an update")
|
|
371
400
|
updating.add_argument("name")
|
|
372
401
|
updating.add_argument("--background", action="store_true", help=argparse.SUPPRESS)
|
|
@@ -395,6 +424,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
395
424
|
if args.timeout <= 0:
|
|
396
425
|
raise ValueError("timeout must be positive")
|
|
397
426
|
result = install(args)
|
|
427
|
+
elif args.command == "install-source":
|
|
428
|
+
from .source import install_source
|
|
429
|
+
|
|
430
|
+
result = install_source(args)
|
|
398
431
|
elif args.command == "update":
|
|
399
432
|
result = update(args.name, background=args.background)
|
|
400
433
|
elif args.command == "select":
|
|
@@ -331,7 +331,16 @@ def managed_target(source: Path) -> Path | None:
|
|
|
331
331
|
config = json.loads((generation / "config.json").read_text())
|
|
332
332
|
if source.read_text() != _launcher_content(root, config, manager_python):
|
|
333
333
|
raise ValueError("managed launcher content does not match its installation")
|
|
334
|
+
if config.get("kind") == "source":
|
|
335
|
+
from .source import source_command
|
|
336
|
+
|
|
337
|
+
if (generation / "command").read_text() != source_command(generation, config):
|
|
338
|
+
raise ValueError("managed source command does not match its installation")
|
|
339
|
+
target = (generation / "source" / config["source"]).resolve(strict=True)
|
|
340
|
+
if not target.is_relative_to(generation / "source"):
|
|
341
|
+
raise ValueError("managed source must belong to its generation")
|
|
342
|
+
return target
|
|
334
343
|
target = (generation / "command").resolve(strict=True)
|
|
335
|
-
if not target.is_relative_to(generation / "venv"):
|
|
344
|
+
if not target.is_relative_to((generation / "venv").resolve()):
|
|
336
345
|
raise ValueError("managed command must belong to its generation")
|
|
337
346
|
return target
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Immutable Git source applications with prepared toolfunc environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
import tomllib
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import _run_checked, atomic_write, installation_lock, run_uv, tool_directory, write_launcher
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _git(config: dict, *arguments: str, cwd: Path) -> str:
|
|
22
|
+
return subprocess.check_output(
|
|
23
|
+
["git", *arguments],
|
|
24
|
+
cwd=cwd,
|
|
25
|
+
text=True,
|
|
26
|
+
stdin=subprocess.DEVNULL,
|
|
27
|
+
stderr=subprocess.PIPE,
|
|
28
|
+
timeout=config["timeout"],
|
|
29
|
+
).strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _script_metadata(source: Path) -> dict:
|
|
33
|
+
blocks = re.findall(r"(?m)^# /// script\s*\n((?:#(?: .*|)\n)+)# ///\s*$", source.read_text())
|
|
34
|
+
if len(blocks) != 1:
|
|
35
|
+
raise ValueError(f"{source}: expected one PEP 723 script block")
|
|
36
|
+
metadata = tomllib.loads("\n".join(line[2:] for line in blocks[0].splitlines()))
|
|
37
|
+
if not isinstance(metadata.get("dependencies"), list):
|
|
38
|
+
raise ValueError(f"{source}: script dependencies must be a list")
|
|
39
|
+
return metadata
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def source_command(generation: Path, config: dict) -> str:
|
|
43
|
+
"""Render the exact executable boundary for a prepared source generation."""
|
|
44
|
+
return (
|
|
45
|
+
"#!/bin/sh\n"
|
|
46
|
+
f"export UV_LAUNCH_GENERATION={shlex.quote(str(generation))}\n"
|
|
47
|
+
f"exec {shlex.quote(str(generation / 'venv/bin/python'))} -I -m toolfuncs "
|
|
48
|
+
f'run-prepared {shlex.quote(str(generation / "source" / config["source"]))} "$@"\n'
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def refresh_source(root: Path, config: dict) -> dict:
|
|
53
|
+
"""Fetch and validate a complete candidate without changing any selected generation."""
|
|
54
|
+
cache = root / "repository.git"
|
|
55
|
+
if not cache.exists():
|
|
56
|
+
_git(config, "init", "--bare", str(cache), cwd=root)
|
|
57
|
+
_git(config, "fetch", "--no-tags", config["repository"], config["revision"], cwd=cache)
|
|
58
|
+
revision = _git(config, "rev-parse", "FETCH_HEAD^{commit}", cwd=cache)
|
|
59
|
+
generations = root / "generations"
|
|
60
|
+
generations.mkdir(exist_ok=True)
|
|
61
|
+
candidate = generations / uuid.uuid4().hex
|
|
62
|
+
candidate.mkdir(mode=0o700)
|
|
63
|
+
published = False
|
|
64
|
+
try:
|
|
65
|
+
source_root = candidate / "source"
|
|
66
|
+
_git(
|
|
67
|
+
config,
|
|
68
|
+
"clone",
|
|
69
|
+
"--local",
|
|
70
|
+
"--no-hardlinks",
|
|
71
|
+
"--no-checkout",
|
|
72
|
+
str(cache),
|
|
73
|
+
str(source_root),
|
|
74
|
+
cwd=root,
|
|
75
|
+
)
|
|
76
|
+
_git(config, "checkout", "-B", "main", revision, cwd=source_root)
|
|
77
|
+
_git(config, "remote", "set-url", "origin", config["repository"], cwd=source_root)
|
|
78
|
+
source = (source_root / config["source"]).resolve(strict=True)
|
|
79
|
+
if not source.is_relative_to(source_root):
|
|
80
|
+
raise ValueError("toolfunc source must stay inside its repository")
|
|
81
|
+
metadata = _script_metadata(source)
|
|
82
|
+
requirements = candidate / "requirements.in"
|
|
83
|
+
requirements.write_text(
|
|
84
|
+
"\n".join(
|
|
85
|
+
[
|
|
86
|
+
"toolfuncs>=0.12.1,<0.13",
|
|
87
|
+
"uv-launch>=0.2.1,<0.3",
|
|
88
|
+
*metadata["dependencies"],
|
|
89
|
+
*config["extra"],
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
+ "\n"
|
|
93
|
+
)
|
|
94
|
+
lock = candidate / "requirements.txt"
|
|
95
|
+
run_uv(
|
|
96
|
+
config,
|
|
97
|
+
[
|
|
98
|
+
"pip",
|
|
99
|
+
"compile",
|
|
100
|
+
str(requirements),
|
|
101
|
+
"--output-file",
|
|
102
|
+
str(lock),
|
|
103
|
+
"--no-header",
|
|
104
|
+
"--no-annotate",
|
|
105
|
+
"--refresh",
|
|
106
|
+
"--python",
|
|
107
|
+
config["python"],
|
|
108
|
+
],
|
|
109
|
+
cwd=root,
|
|
110
|
+
)
|
|
111
|
+
identity = hashlib.sha256(
|
|
112
|
+
(
|
|
113
|
+
json.dumps(config, sort_keys=True) + "\n" + revision + "\n" + lock.read_text()
|
|
114
|
+
).encode()
|
|
115
|
+
).hexdigest()
|
|
116
|
+
if (root / "current").exists():
|
|
117
|
+
previous = json.loads((root / "current/installation.json").read_text())
|
|
118
|
+
if previous["identity"] == identity:
|
|
119
|
+
return {"status": "unchanged", **previous}
|
|
120
|
+
|
|
121
|
+
# Source-only updates share an immutable environment. Never relocate venvs.
|
|
122
|
+
environment_id = hashlib.sha256(
|
|
123
|
+
(config["python"] + "\n" + lock.read_text()).encode()
|
|
124
|
+
).hexdigest()
|
|
125
|
+
environment_root = root / "environments" / environment_id
|
|
126
|
+
environment = environment_root / "venv"
|
|
127
|
+
if not (environment_root / "ready").exists():
|
|
128
|
+
if environment_root.exists():
|
|
129
|
+
shutil.rmtree(environment_root)
|
|
130
|
+
environment_root.mkdir(parents=True)
|
|
131
|
+
run_uv(config, ["venv", "--python", config["python"], str(environment)], cwd=root)
|
|
132
|
+
run_uv(
|
|
133
|
+
config,
|
|
134
|
+
[
|
|
135
|
+
"pip",
|
|
136
|
+
"sync",
|
|
137
|
+
"--python",
|
|
138
|
+
str(environment / "bin/python"),
|
|
139
|
+
"--link-mode",
|
|
140
|
+
"copy",
|
|
141
|
+
"--strict",
|
|
142
|
+
str(lock),
|
|
143
|
+
],
|
|
144
|
+
cwd=root,
|
|
145
|
+
)
|
|
146
|
+
(environment_root / "ready").touch()
|
|
147
|
+
(candidate / "venv").symlink_to(environment)
|
|
148
|
+
python = str(environment / "bin/python")
|
|
149
|
+
_run_checked(
|
|
150
|
+
[
|
|
151
|
+
python,
|
|
152
|
+
"-I",
|
|
153
|
+
"-m",
|
|
154
|
+
"toolfuncs",
|
|
155
|
+
"doctor",
|
|
156
|
+
"--prepared",
|
|
157
|
+
str(source),
|
|
158
|
+
],
|
|
159
|
+
cwd=source_root,
|
|
160
|
+
stdin=subprocess.DEVNULL,
|
|
161
|
+
stdout=subprocess.DEVNULL,
|
|
162
|
+
timeout=config["timeout"],
|
|
163
|
+
)
|
|
164
|
+
atomic_write(candidate / "command", source_command(candidate, config), 0o755)
|
|
165
|
+
if config["prepare"]:
|
|
166
|
+
_run_checked(
|
|
167
|
+
[str(candidate / "command"), *config["prepare"]],
|
|
168
|
+
cwd=source_root,
|
|
169
|
+
stdin=subprocess.DEVNULL,
|
|
170
|
+
stdout=subprocess.DEVNULL,
|
|
171
|
+
timeout=config["timeout"],
|
|
172
|
+
)
|
|
173
|
+
record = {
|
|
174
|
+
"identity": identity,
|
|
175
|
+
"generation": str(candidate),
|
|
176
|
+
"revision": revision,
|
|
177
|
+
"created_at": time.time(),
|
|
178
|
+
}
|
|
179
|
+
atomic_write(candidate / "installation.json", json.dumps(record, sort_keys=True))
|
|
180
|
+
atomic_write(candidate / "config.json", json.dumps(config, indent=2) + "\n")
|
|
181
|
+
link = root / f".current-{uuid.uuid4().hex}"
|
|
182
|
+
try:
|
|
183
|
+
link.symlink_to(candidate.relative_to(root), target_is_directory=True)
|
|
184
|
+
os.replace(link, root / "current")
|
|
185
|
+
finally:
|
|
186
|
+
link.unlink(missing_ok=True)
|
|
187
|
+
published = True
|
|
188
|
+
return {"status": "updated", **record}
|
|
189
|
+
finally:
|
|
190
|
+
if not published:
|
|
191
|
+
shutil.rmtree(candidate)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def install_source(args: argparse.Namespace) -> dict:
|
|
195
|
+
"""Install a named Git source toolfunc with one automatically updated runtime."""
|
|
196
|
+
root = tool_directory(args.name)
|
|
197
|
+
source = Path(args.source)
|
|
198
|
+
if source.is_absolute() or ".." in source.parts or source.name != args.name:
|
|
199
|
+
raise ValueError("source must be a repository-relative path with the command's name")
|
|
200
|
+
if args.timeout <= 0:
|
|
201
|
+
raise ValueError("timeout must be positive")
|
|
202
|
+
uv = shutil.which("uv")
|
|
203
|
+
if uv is None:
|
|
204
|
+
raise ValueError("uv must be installed and available on PATH")
|
|
205
|
+
repository = args.repository
|
|
206
|
+
if Path(repository).exists():
|
|
207
|
+
repository = str(Path(repository).resolve())
|
|
208
|
+
config = {
|
|
209
|
+
"kind": "source",
|
|
210
|
+
"name": args.name,
|
|
211
|
+
"entrypoint": args.name,
|
|
212
|
+
"repository": repository,
|
|
213
|
+
"revision": args.revision,
|
|
214
|
+
"source": str(source),
|
|
215
|
+
"extra": args.extra,
|
|
216
|
+
"python": args.python,
|
|
217
|
+
"uv": uv,
|
|
218
|
+
"timeout": args.timeout,
|
|
219
|
+
"bin_dir": str(Path(args.bin_dir).expanduser().absolute()),
|
|
220
|
+
"prepare": args.prepare or [],
|
|
221
|
+
"managed_updater": True,
|
|
222
|
+
}
|
|
223
|
+
destination = Path(config["bin_dir"]) / config["name"]
|
|
224
|
+
if destination.exists() and not destination.read_text().startswith(
|
|
225
|
+
f"#!/bin/sh\n# Managed by uv-launch: {config['name']}\n"
|
|
226
|
+
):
|
|
227
|
+
raise ValueError(f"refusing to replace an unmanaged command: {destination}")
|
|
228
|
+
with installation_lock(root):
|
|
229
|
+
result = refresh_source(root, config)
|
|
230
|
+
destination = write_launcher(root, config)
|
|
231
|
+
return {**result, "command": str(destination)}
|
|
File without changes
|
|
File without changes
|