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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: uv-launch
3
- Version: 0.2.0
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/). 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
+ 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. Reinstalling this launcher itself is explicit, using `uv tool upgrade uv-launch`.
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/). 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:
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. Reinstalling this launcher itself is explicit, using `uv tool upgrade uv-launch`.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uv-launch"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Immediate Python command startup with atomic background updates through uv"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uv-launch"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Immediate Python command startup with atomic background updates through uv"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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)}