uv-launch 0.1.1__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.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: uv-launch
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Immediate Python command startup with atomic background updates through uv
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Project-URL: Repository, https://github.com/nimashoghi/uv-launch
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# uv-launch
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
Requires macOS or Linux, Python 3.11+, and [uv](https://docs.astral.sh/uv/). Install this tool into a stable environment with `uv tool install uv-launch` from [PyPI](https://pypi.org/project/uv-launch/). `uvx uv-launch --help` also runs the CLI without a tool installation; use the stable tool installation for long-lived managed commands because their background updater uses its Python environment. Then install commands:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
uv-launch install ruff ruff
|
|
17
|
+
uv-launch install my-command 'package-name @ git+https://github.com/owner/project.git@main' \
|
|
18
|
+
--entrypoint actual-console-script --with another-package --python 3.13
|
|
19
|
+
my-command --help
|
|
20
|
+
uv-launch update my-command
|
|
21
|
+
uv-launch status my-command
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`--bin-dir` defaults to `~/.local/bin`. Use a separate directory for evaluation. The installer refuses to overwrite commands it does not own. `UV_LAUNCH_HOME` selects the installation-state directory; its default is `~/.local/share/uv-launch`.
|
|
25
|
+
|
|
26
|
+
Each invocation captures the current immutable generation and starts a detached update check. Concurrent checks for the same tool coalesce under a process lock. A check resolves all requested packages together, pinning Git dependencies to concrete revisions. If the resolved installation is unchanged, it keeps the existing environment. Otherwise it creates a new environment at its final path, checks dependencies and loads the Python entrypoint (or verifies ownership of a wheel-provided executable), then atomically switches the generation used by future launches. Virtual environments are never moved after creation, because their scripts contain absolute paths.
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
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. Reinstalling this launcher itself is explicit, using `uv tool upgrade uv-launch`.
|
|
31
|
+
|
|
32
|
+
## Codex-router example
|
|
33
|
+
|
|
34
|
+
All five packages are resolved in one operation, so a moving Git ref produces one consistent suite revision:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uv-launch install codexr \
|
|
38
|
+
'codex-router @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router' \
|
|
39
|
+
--with 'codex-router-sdk @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router-sdk' \
|
|
40
|
+
--with 'codex-monitor @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-monitor' \
|
|
41
|
+
--with 'codex-recovery @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-recovery' \
|
|
42
|
+
--with 'codex-accounts @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-accounts' \
|
|
43
|
+
--bin-dir ~/.local/share/codexr-evaluation/bin
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Use that isolated command before choosing to replace an existing launcher. Updating the local codexr suite and preparing that exact suite on an SSH host remain separate responsibilities; uv-launch contains no Codex or SSH runtime logic.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
uv sync
|
|
52
|
+
uv run pytest -q
|
|
53
|
+
uv run ruff check .
|
|
54
|
+
uv run ruff format --check .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Integration tests create local Git packages and exercise installation, immutable revisions, background publication, failed updates, concurrent checks, offline launches, and preservation of unrelated commands.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# uv-launch
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
Requires macOS or Linux, Python 3.11+, and [uv](https://docs.astral.sh/uv/). Install this tool into a stable environment with `uv tool install uv-launch` from [PyPI](https://pypi.org/project/uv-launch/). `uvx uv-launch --help` also runs the CLI without a tool installation; use the stable tool installation for long-lived managed commands because their background updater uses its Python environment. Then install commands:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uv-launch install ruff ruff
|
|
9
|
+
uv-launch install my-command 'package-name @ git+https://github.com/owner/project.git@main' \
|
|
10
|
+
--entrypoint actual-console-script --with another-package --python 3.13
|
|
11
|
+
my-command --help
|
|
12
|
+
uv-launch update my-command
|
|
13
|
+
uv-launch status my-command
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`--bin-dir` defaults to `~/.local/bin`. Use a separate directory for evaluation. The installer refuses to overwrite commands it does not own. `UV_LAUNCH_HOME` selects the installation-state directory; its default is `~/.local/share/uv-launch`.
|
|
17
|
+
|
|
18
|
+
Each invocation captures the current immutable generation and starts a detached update check. Concurrent checks for the same tool coalesce under a process lock. A check resolves all requested packages together, pinning Git dependencies to concrete revisions. If the resolved installation is unchanged, it keeps the existing environment. Otherwise it creates a new environment at its final path, checks dependencies and loads the Python entrypoint (or verifies ownership of a wheel-provided executable), then atomically switches the generation used by future launches. Virtual environments are never moved after creation, because their scripts contain absolute paths.
|
|
19
|
+
|
|
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
|
+
|
|
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. Reinstalling this launcher itself is explicit, using `uv tool upgrade uv-launch`.
|
|
23
|
+
|
|
24
|
+
## Codex-router example
|
|
25
|
+
|
|
26
|
+
All five packages are resolved in one operation, so a moving Git ref produces one consistent suite revision:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
uv-launch install codexr \
|
|
30
|
+
'codex-router @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router' \
|
|
31
|
+
--with 'codex-router-sdk @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router-sdk' \
|
|
32
|
+
--with 'codex-monitor @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-monitor' \
|
|
33
|
+
--with 'codex-recovery @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-recovery' \
|
|
34
|
+
--with 'codex-accounts @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-accounts' \
|
|
35
|
+
--bin-dir ~/.local/share/codexr-evaluation/bin
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Use that isolated command before choosing to replace an existing launcher. Updating the local codexr suite and preparing that exact suite on an SSH host remain separate responsibilities; uv-launch contains no Codex or SSH runtime logic.
|
|
39
|
+
|
|
40
|
+
## Development
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
uv sync
|
|
44
|
+
uv run pytest -q
|
|
45
|
+
uv run ruff check .
|
|
46
|
+
uv run ruff format --check .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Integration tests create local Git packages and exercise installation, immutable revisions, background publication, failed updates, concurrent checks, offline launches, and preservation of unrelated commands.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "uv-launch"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Immediate Python command startup with atomic background updates through uv"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.urls]
|
|
10
|
+
Repository = "https://github.com/nimashoghi/uv-launch"
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
uv-launch = "uv_launch:main"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8,<10",
|
|
22
|
+
"ruff>=0.12",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 100
|
|
27
|
+
target-version = "py311"
|
|
28
|
+
|
|
29
|
+
[tool.ruff.lint]
|
|
30
|
+
select = [
|
|
31
|
+
"E",
|
|
32
|
+
"F",
|
|
33
|
+
"I",
|
|
34
|
+
"UP",
|
|
35
|
+
"B",
|
|
36
|
+
"SIM",
|
|
37
|
+
"RUF",
|
|
38
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "uv-launch"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Immediate Python command startup with atomic background updates through uv"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.urls]
|
|
10
|
+
Repository = "https://github.com/nimashoghi/uv-launch"
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
uv-launch = "uv_launch:main"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = ["pytest>=8,<10", "ruff>=0.12"]
|
|
21
|
+
|
|
22
|
+
[tool.ruff]
|
|
23
|
+
line-length = 100
|
|
24
|
+
target-version = "py311"
|
|
25
|
+
|
|
26
|
+
[tool.ruff.lint]
|
|
27
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Launch installed Python commands immediately and update future launches with uv."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def state_root() -> Path:
|
|
26
|
+
"""Return durable installation state, independently of any current project."""
|
|
27
|
+
return (
|
|
28
|
+
Path(os.environ.get("UV_LAUNCH_HOME", "~/.local/share/uv-launch")).expanduser().absolute()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def tool_directory(name: str) -> Path:
|
|
33
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name):
|
|
34
|
+
raise ValueError("command names must contain only letters, digits, '.', '_' and '-'")
|
|
35
|
+
return state_root() / name
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def atomic_write(path: Path, content: str, mode: int = 0o600) -> None:
|
|
39
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
|
|
40
|
+
try:
|
|
41
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
42
|
+
stream.write(content)
|
|
43
|
+
os.chmod(temporary, mode)
|
|
44
|
+
os.replace(temporary, path)
|
|
45
|
+
finally:
|
|
46
|
+
Path(temporary).unlink(missing_ok=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def installation_lock(root: Path, *, background: bool = False):
|
|
51
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
52
|
+
with (root / "update.lock").open("a+") as lock:
|
|
53
|
+
try:
|
|
54
|
+
fcntl.flock(lock, fcntl.LOCK_EX | (fcntl.LOCK_NB if background else 0))
|
|
55
|
+
except BlockingIOError:
|
|
56
|
+
yield False
|
|
57
|
+
else:
|
|
58
|
+
try:
|
|
59
|
+
yield True
|
|
60
|
+
finally:
|
|
61
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_uv(config: dict, arguments: list[str], *, cwd: Path) -> None:
|
|
65
|
+
subprocess.run(
|
|
66
|
+
[config["uv"], "--no-config", "--no-progress", *arguments],
|
|
67
|
+
cwd=cwd,
|
|
68
|
+
stdin=subprocess.DEVNULL,
|
|
69
|
+
stdout=subprocess.DEVNULL,
|
|
70
|
+
check=True,
|
|
71
|
+
timeout=config["timeout"],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def refresh(root: Path, config: dict) -> dict:
|
|
76
|
+
"""Prepare an immutable candidate; publish only after dependency and entrypoint checks."""
|
|
77
|
+
generations = root / "generations"
|
|
78
|
+
generations.mkdir(exist_ok=True)
|
|
79
|
+
candidate = generations / uuid.uuid4().hex
|
|
80
|
+
candidate.mkdir(mode=0o700)
|
|
81
|
+
published = False
|
|
82
|
+
try:
|
|
83
|
+
requirements = candidate / "requirements.in"
|
|
84
|
+
requirements.write_text("\n".join(config["requirements"]) + "\n")
|
|
85
|
+
lock = candidate / "requirements.txt"
|
|
86
|
+
# One resolution pins shared Git sources once for all requested packages.
|
|
87
|
+
# The resulting immutable revisions are reused when installing the candidate.
|
|
88
|
+
run_uv(
|
|
89
|
+
config,
|
|
90
|
+
[
|
|
91
|
+
"pip",
|
|
92
|
+
"compile",
|
|
93
|
+
str(requirements),
|
|
94
|
+
"--output-file",
|
|
95
|
+
str(lock),
|
|
96
|
+
"--no-header",
|
|
97
|
+
"--no-annotate",
|
|
98
|
+
"--refresh",
|
|
99
|
+
"--python",
|
|
100
|
+
config["python"],
|
|
101
|
+
],
|
|
102
|
+
cwd=root,
|
|
103
|
+
)
|
|
104
|
+
identity = hashlib.sha256(
|
|
105
|
+
(json.dumps(config, sort_keys=True) + "\n" + lock.read_text()).encode()
|
|
106
|
+
).hexdigest()
|
|
107
|
+
current = root / "current"
|
|
108
|
+
if current.exists():
|
|
109
|
+
previous = json.loads((current / "installation.json").read_text())
|
|
110
|
+
if previous["identity"] == identity:
|
|
111
|
+
return {"status": "unchanged", **previous}
|
|
112
|
+
# Never move a virtualenv: entrypoint shebangs contain its absolute path.
|
|
113
|
+
environment = candidate / "venv"
|
|
114
|
+
run_uv(config, ["venv", "--python", config["python"], str(environment)], cwd=root)
|
|
115
|
+
run_uv(
|
|
116
|
+
config,
|
|
117
|
+
[
|
|
118
|
+
"pip",
|
|
119
|
+
"sync",
|
|
120
|
+
"--python",
|
|
121
|
+
str(environment / "bin/python"),
|
|
122
|
+
"--link-mode",
|
|
123
|
+
"copy",
|
|
124
|
+
"--strict",
|
|
125
|
+
str(lock),
|
|
126
|
+
],
|
|
127
|
+
cwd=root,
|
|
128
|
+
)
|
|
129
|
+
executable = environment / "bin" / config["entrypoint"]
|
|
130
|
+
if not executable.is_file() or not os.access(executable, os.X_OK):
|
|
131
|
+
raise ValueError(f"installed environment does not provide {config['entrypoint']!r}")
|
|
132
|
+
# Import Python entrypoints to catch missing callables before publication.
|
|
133
|
+
# Wheel-provided executables need an owning distribution instead.
|
|
134
|
+
subprocess.run(
|
|
135
|
+
[
|
|
136
|
+
str(environment / "bin/python"),
|
|
137
|
+
"-I",
|
|
138
|
+
"-c",
|
|
139
|
+
"import importlib.metadata as m, pathlib, sys\n"
|
|
140
|
+
"entries=list(m.entry_points(group='console_scripts', name=sys.argv[1]))\n"
|
|
141
|
+
"assert len(entries)<=1, 'ambiguous console entrypoint'\n"
|
|
142
|
+
"if entries:\n"
|
|
143
|
+
" assert callable(entries[0].load()), 'entrypoint must be callable'\n"
|
|
144
|
+
"else:\n"
|
|
145
|
+
" executable=pathlib.Path(sys.argv[2]).resolve()\n"
|
|
146
|
+
" owners=[d for d in m.distributions() if any(\n"
|
|
147
|
+
" pathlib.Path(d.locate_file(f)).resolve()==executable\n"
|
|
148
|
+
" for f in d.files or ())]\n"
|
|
149
|
+
" assert len(owners)==1, 'expected one distribution owning the executable'\n",
|
|
150
|
+
config["entrypoint"],
|
|
151
|
+
str(executable),
|
|
152
|
+
],
|
|
153
|
+
check=True,
|
|
154
|
+
stdin=subprocess.DEVNULL,
|
|
155
|
+
stdout=subprocess.DEVNULL,
|
|
156
|
+
timeout=config["timeout"],
|
|
157
|
+
)
|
|
158
|
+
metadata = {"identity": identity, "generation": str(candidate), "created_at": time.time()}
|
|
159
|
+
atomic_write(candidate / "installation.json", json.dumps(metadata, sort_keys=True))
|
|
160
|
+
atomic_write(candidate / "config.json", json.dumps(config, indent=2) + "\n")
|
|
161
|
+
(candidate / "command").symlink_to(executable.relative_to(candidate))
|
|
162
|
+
link = root / f".current-{uuid.uuid4().hex}"
|
|
163
|
+
try:
|
|
164
|
+
link.symlink_to(candidate.relative_to(root), target_is_directory=True)
|
|
165
|
+
os.replace(link, current)
|
|
166
|
+
finally:
|
|
167
|
+
link.unlink(missing_ok=True)
|
|
168
|
+
published = True
|
|
169
|
+
return {"status": "updated", **metadata}
|
|
170
|
+
finally:
|
|
171
|
+
if not published:
|
|
172
|
+
shutil.rmtree(candidate)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def write_launcher(root: Path, config: dict) -> Path:
|
|
176
|
+
"""Install a shell shim with no foreground Python or package resolution overhead."""
|
|
177
|
+
destination = Path(config["bin_dir"]) / config["name"]
|
|
178
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
python = shlex.quote(sys.executable)
|
|
180
|
+
home = shlex.quote(str(root.parent))
|
|
181
|
+
name = shlex.quote(config["name"])
|
|
182
|
+
directory = shlex.quote(str(root))
|
|
183
|
+
content = (
|
|
184
|
+
"#!/bin/sh\n"
|
|
185
|
+
f"# Managed by uv-launch: {config['name']}\n"
|
|
186
|
+
# Read one immutable generation before the updater can switch current.
|
|
187
|
+
f"generation=$(readlink {directory}/current) || exit 1\n"
|
|
188
|
+
f"UV_LAUNCH_HOME={home} nohup {python} -I -m uv_launch update {name} "
|
|
189
|
+
"--background </dev/null >/dev/null 2>&1 &\n"
|
|
190
|
+
f'exec {directory}/"$generation"/command "$@"\n'
|
|
191
|
+
)
|
|
192
|
+
if destination.exists() and not destination.read_text().startswith(
|
|
193
|
+
f"#!/bin/sh\n# Managed by uv-launch: {config['name']}\n"
|
|
194
|
+
):
|
|
195
|
+
raise ValueError(f"refusing to replace an unmanaged command: {destination}")
|
|
196
|
+
atomic_write(destination, content, 0o755)
|
|
197
|
+
return destination
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def install(args: argparse.Namespace) -> dict:
|
|
201
|
+
"""Install a named command; existing unrelated launchers are never replaced."""
|
|
202
|
+
root = tool_directory(args.name)
|
|
203
|
+
tool_directory(args.entrypoint or args.name)
|
|
204
|
+
uv = shutil.which("uv")
|
|
205
|
+
if uv is None:
|
|
206
|
+
raise ValueError("uv must be installed and available on PATH")
|
|
207
|
+
config = {
|
|
208
|
+
"name": args.name,
|
|
209
|
+
"entrypoint": args.entrypoint or args.name,
|
|
210
|
+
"requirements": [args.requirement, *args.extra],
|
|
211
|
+
"python": args.python,
|
|
212
|
+
"uv": uv,
|
|
213
|
+
"timeout": args.timeout,
|
|
214
|
+
"bin_dir": str(Path(args.bin_dir).expanduser().absolute()),
|
|
215
|
+
}
|
|
216
|
+
for requirement in config["requirements"]:
|
|
217
|
+
if not requirement or "\n" in requirement or "\r" in requirement:
|
|
218
|
+
raise ValueError("each requirement must be a single nonempty line")
|
|
219
|
+
destination = Path(config["bin_dir"]) / config["name"]
|
|
220
|
+
if destination.exists() and not destination.read_text().startswith(
|
|
221
|
+
f"#!/bin/sh\n# Managed by uv-launch: {config['name']}\n"
|
|
222
|
+
):
|
|
223
|
+
raise ValueError(f"refusing to replace an unmanaged command: {destination}")
|
|
224
|
+
with installation_lock(root):
|
|
225
|
+
result = refresh(root, config)
|
|
226
|
+
destination = write_launcher(root, config)
|
|
227
|
+
return {**result, "command": str(destination)}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def update(name: str, *, background: bool = False) -> dict:
|
|
231
|
+
"""Update a tool without changing any running generation; coalesce concurrent checks."""
|
|
232
|
+
root = tool_directory(name)
|
|
233
|
+
with installation_lock(root, background=background) as acquired:
|
|
234
|
+
if not acquired:
|
|
235
|
+
return {"status": "already-checking"}
|
|
236
|
+
config = json.loads((root / "current/config.json").read_text())
|
|
237
|
+
try:
|
|
238
|
+
if background:
|
|
239
|
+
# Only the lock owner opens this log, so coalesced checks cannot
|
|
240
|
+
# truncate another update's diagnostics.
|
|
241
|
+
with (root / "update.log").open("w") as log:
|
|
242
|
+
os.dup2(log.fileno(), sys.stderr.fileno())
|
|
243
|
+
result = refresh(root, config)
|
|
244
|
+
else:
|
|
245
|
+
result = refresh(root, config)
|
|
246
|
+
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
|
247
|
+
atomic_write(
|
|
248
|
+
root / "last-update.json",
|
|
249
|
+
json.dumps(
|
|
250
|
+
{
|
|
251
|
+
"status": "failed",
|
|
252
|
+
"error": str(error),
|
|
253
|
+
"checked_at": time.time(),
|
|
254
|
+
}
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
raise
|
|
258
|
+
else:
|
|
259
|
+
atomic_write(
|
|
260
|
+
root / "last-update.json", json.dumps({**result, "checked_at": time.time()})
|
|
261
|
+
)
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main(argv: list[str] | None = None) -> int:
|
|
266
|
+
"""Command-line interface for installing, updating and inspecting named tools."""
|
|
267
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
268
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
269
|
+
installing = commands.add_parser("install", help="install an immediately runnable command")
|
|
270
|
+
installing.add_argument("name", help="name of the generated command")
|
|
271
|
+
installing.add_argument("requirement", help="Python package requirement or Git URL")
|
|
272
|
+
installing.add_argument("--with", dest="extra", action="append", default=[])
|
|
273
|
+
installing.add_argument(
|
|
274
|
+
"--entrypoint", help="installed console script, defaults to command name"
|
|
275
|
+
)
|
|
276
|
+
installing.add_argument("--python", default="3.13", help="uv Python interpreter selector")
|
|
277
|
+
installing.add_argument("--bin-dir", default="~/.local/bin")
|
|
278
|
+
installing.add_argument(
|
|
279
|
+
"--timeout", type=float, default=600, help="seconds per update operation"
|
|
280
|
+
)
|
|
281
|
+
updating = commands.add_parser("update", help="check and atomically activate an update")
|
|
282
|
+
updating.add_argument("name")
|
|
283
|
+
updating.add_argument("--background", action="store_true", help=argparse.SUPPRESS)
|
|
284
|
+
inspecting = commands.add_parser("status", help="show the active installation and last check")
|
|
285
|
+
inspecting.add_argument("name")
|
|
286
|
+
args = parser.parse_args(argv)
|
|
287
|
+
logging.basicConfig(level=logging.INFO, format="uv-launch: %(message)s")
|
|
288
|
+
try:
|
|
289
|
+
if args.command == "install":
|
|
290
|
+
if args.timeout <= 0:
|
|
291
|
+
raise ValueError("timeout must be positive")
|
|
292
|
+
result = install(args)
|
|
293
|
+
elif args.command == "update":
|
|
294
|
+
result = update(args.name, background=args.background)
|
|
295
|
+
else:
|
|
296
|
+
root = tool_directory(args.name)
|
|
297
|
+
result = {
|
|
298
|
+
"config": json.loads((root / "current/config.json").read_text()),
|
|
299
|
+
"active": json.loads((root / "current/installation.json").read_text()),
|
|
300
|
+
"last_update": json.loads((root / "last-update.json").read_text())
|
|
301
|
+
if (root / "last-update.json").exists()
|
|
302
|
+
else None,
|
|
303
|
+
}
|
|
304
|
+
if not getattr(args, "background", False):
|
|
305
|
+
sys.stdout.write(json.dumps(result, sort_keys=True) + "\n")
|
|
306
|
+
return 0
|
|
307
|
+
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
|
308
|
+
LOGGER.error("%s", error)
|
|
309
|
+
return 1
|