uv-launch 0.2.2__tar.gz → 0.2.4__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.4/PKG-INFO +139 -0
- uv_launch-0.2.4/README.md +129 -0
- {uv_launch-0.2.2 → uv_launch-0.2.4}/pyproject.toml +5 -2
- {uv_launch-0.2.2 → uv_launch-0.2.4}/pyproject.toml.orig +2 -2
- {uv_launch-0.2.2 → uv_launch-0.2.4}/src/uv_launch/__init__.py +362 -57
- {uv_launch-0.2.2 → uv_launch-0.2.4}/src/uv_launch/dependencies.py +23 -2
- uv_launch-0.2.2/PKG-INFO +0 -103
- uv_launch-0.2.2/README.md +0 -94
- {uv_launch-0.2.2 → uv_launch-0.2.4}/src/uv_launch/__main__.py +0 -0
- {uv_launch-0.2.2 → uv_launch-0.2.4}/src/uv_launch/py.typed +0 -0
- {uv_launch-0.2.2 → uv_launch-0.2.4}/src/uv_launch/source.py +0 -0
uv_launch-0.2.4/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: uv-launch
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: Immediate Python command startup with atomic background updates through uv
|
|
5
|
+
Requires-Dist: packaging>=24
|
|
6
|
+
Requires-Dist: platformdirs>=4.8
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Project-URL: Repository, https://github.com/nimashoghi/uv-launch
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# uv-launch
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
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:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
uv-launch install ruff ruff
|
|
19
|
+
uv-launch install my-command 'package-name @ git+https://github.com/owner/project.git@main' \
|
|
20
|
+
--entrypoint actual-console-script --with another-package --python 3.13
|
|
21
|
+
my-command --help
|
|
22
|
+
uv-launch update my-command
|
|
23
|
+
uv-launch status my-command
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Directory defaults come from [platformdirs](https://platformdirs.readthedocs.io/en/latest/api.html): `user_bin_path()` for installed commands and `user_data_path("uv-launch", appauthor=False)` for durable installations. On macOS the data directory is `~/Library/Application Support/uv-launch`; on Linux it follows `XDG_DATA_HOME`, normally `~/.local/share/uv-launch`. Both platforms normally install commands in `~/.local/bin`. `--bin-dir` and a nonempty `UV_LAUNCH_HOME` override those defaults. Use a separate directory for evaluation. The installer refuses to overwrite commands it does not own.
|
|
27
|
+
|
|
28
|
+
To adopt the platform directory after upgrading from 0.2.2 or earlier, repeat the original `install` or `install-source` command with its existing requirements and options, without a `UV_LAUNCH_HOME` override. This builds and validates a new installation before replacing its launcher. Keep the old directory while any process uses it; do not move its virtual environments, whose executable paths are absolute. Existing launchers retain their recorded directory until reinstalled. To operate on an old installation directly, set `UV_LAUNCH_HOME` to its recorded root.
|
|
29
|
+
|
|
30
|
+
Each invocation without an explicit generation pin 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), runs any configured validation command, then atomically switches the generation used by future launches. Virtual environments are never moved after creation, because their scripts contain absolute paths.
|
|
31
|
+
|
|
32
|
+
The launched command retains its arguments, working directory, environment and exit status, with a configured generation variable set to the selected ID. 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.
|
|
33
|
+
|
|
34
|
+
An initial installation requires dependency access. Automatic updates have a 600-second timeout per subprocess by default; configure it with `install --timeout`. Without `--validate`, validation checks installation and entrypoint integrity only. 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.
|
|
35
|
+
|
|
36
|
+
## Package candidate validation
|
|
37
|
+
|
|
38
|
+
An application can check its packaged resources, hooks, companion commands, and startup before a new generation becomes active:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
uv-launch install my-command 'package-name @ git+https://github.com/owner/project.git@main' \
|
|
42
|
+
--entrypoint actual-console-script --validation-timeout 30 \
|
|
43
|
+
--validate package-check --smoke-test
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Put `--validate` last. It takes the name of an executable in the candidate's `venv/bin` followed by fixed arguments, passed directly without a shell. The executable need not have a public shim. The command and timeout are saved in the installation and apply to initial installation, reinstall, foreground updates, and background updates. `--validation-timeout` defaults to 60 seconds and must be finite and positive. An unchanged generation does not run validation again.
|
|
47
|
+
|
|
48
|
+
When validation is configured, entrypoint import checks and the validation command run with a fresh temporary working directory and clean environment. `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CACHE_HOME`, `XDG_RUNTIME_DIR`, `XDG_CONFIG_DIRS`, `XDG_DATA_DIRS`, `TMPDIR`, and `UV_LAUNCH_HOME` point to private temporary directories, removed after the check. Application-specific environment variables and credentials are not inherited. `PATH` starts with the candidate's `venv/bin` and retains the calling process's PATH for external tools. `VIRTUAL_ENV` names the candidate environment, `UV_LAUNCH_GENERATION` gives its absolute generation directory, and a configured `--generation-env` variable contains its opaque generation ID. Dependency resolution still uses the user's normal uv/Git authentication. This isolates conventional configuration and state paths; it is not an operating-system sandbox against code that deliberately accesses other paths.
|
|
49
|
+
|
|
50
|
+
A zero exit status permits the existing atomic activation. A nonzero exit, missing validation executable, or timeout rejects and removes the candidate while retaining the active generation and its shims. Timeout cleanup terminates the command's process group, allowing up to five seconds for the command to exit before forced termination. `uv-launch status my-command` exposes `last_validation`, including the attempted generation, command, `returncode` (null if it could not start or timed out), `timed_out`, `checked_at`, `duration_seconds`, and the final 8192 bytes of combined stdout/stderr decoded as UTF-8. The record is also stored in `<UV_LAUNCH_HOME>/<command>/last-validation.json`. A failed first validation is inspectable with `status`, with `active` and `config` set to null. Update failures also appear in `last_update`, and background failures in `update.log`.
|
|
51
|
+
|
|
52
|
+
Repeat the original install command with the new options to enable validation for an existing installation. New package installations require uv-launch `>=0.2.4,<0.3` in their environments so the bundled updater understands these settings. Installations using validation, companions, or a generation pin use configuration schema 2, which stores `package_requirements` and omits the legacy `requirements` key. A 0.2.3 updater, including one that started before a reinstall, therefore fails before resolution instead of publishing a candidate without enforcing the new settings. The current updater still reads ordinary legacy configurations; refreshing an earlier unversioned configuration with these features rebuilds it in the protected format before activation.
|
|
53
|
+
|
|
54
|
+
## Source toolfuncs
|
|
55
|
+
|
|
56
|
+
Install a standalone toolfunc together with its adjacent repository files:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
uvx uv-launch install-source wiki https://github.com/owner/wiki scripts/wiki --prepare prepare-runtime
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`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.
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
## Service startup and companion commands
|
|
69
|
+
|
|
70
|
+
Package installations can expose selected companion console scripts alongside the main command:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
uv-launch install my-command package-name --entrypoint actual-console-script \
|
|
74
|
+
--companion package-helper --companion package-admin \
|
|
75
|
+
--generation-env MY_APP_GENERATION \
|
|
76
|
+
--validate package-check --smoke-test
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Each repeatable `--companion` names a candidate executable and creates a shim of that name in `--bin-dir`. Main and companion entrypoints are checked before activation; unrelated commands are never overwritten. All shims select from the same installation, so they switch together on an update. `--companion` and `--generation-env` are package-mode options.
|
|
80
|
+
|
|
81
|
+
Reinstalling without a previously selected companion retires it only after the new candidate succeeds. If no generation variable was configured, its owned shim is removed. Otherwise the shim remains available for pinned sessions: unpinned calls report that it is retired, while an explicit pin still runs the executable from that retained generation. A retired shim continues to use the variable configured before retirement, even if the reinstall changes or omits `--generation-env`. Retirement records persist across updates and reinstalls; selecting the companion again restores normal dispatch. User-owned replacements are preserved, and rejected reinstalls preserve both the active generation and its shims. Retired shims and old generations can be removed manually once no sessions use them.
|
|
82
|
+
|
|
83
|
+
With `--generation-env MY_APP_GENERATION`, every active shim exports its captured generation ID in that variable. Child shells and companion commands inherit the pin, even if shell startup files put the global shim directory first on PATH. A nonempty pin must name an existing generation of this installation; invalid or unavailable pins fail instead of switching to the active generation. An unset or empty pin selects the current generation and starts the usual background update check. Explicitly pinned launches do not start an updater from the old runtime. Use a distinct application-specific variable name, such as `MY_APP_GENERATION`. Standard configuration and launcher variables, shell-managed names such as `PPID`, `IFS`, `UID`, `EUID`, and `RANDOM`, and the `BASH*` and `COMP_*` namespaces are rejected. Old generations are retained, so an update cannot retarget a running session. `managed_target` also honors the pin when inspecting main, companion, and retired shims; inspecting an unpinned retired shim reports retirement.
|
|
84
|
+
|
|
85
|
+
A service can require the latest successfully resolved dependencies at startup:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
uv-launch run --refresh my-command -- serve --port 8080
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Unlike the ordinary background-updating shim, `run --refresh` waits for a successful update and fails without executing stale code when that update fails. It then replaces itself with the selected command. Arguments, environment, working directory and exit status are preserved. SIGTERM during refresh terminates and reaps the resolver/build process group; after execution the application receives signals directly. Old generations remain available for workers that outlive their launcher.
|
|
92
|
+
|
|
93
|
+
Run a companion executable from the same installation with `uv-launch run --command python my-command -- -c 'print("probe")'`. Options to `run` precede the installation name. For several operations that must use exactly the same generation, capture once and pass the returned opaque `generation` value:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
uv-launch select --refresh my-command
|
|
97
|
+
uv-launch run --generation GENERATION --command python my-command -- -c 'print("probe")'
|
|
98
|
+
uv-launch run --generation GENERATION my-command -- serve
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The Python equivalents are `select_installation(name, refresh_dependencies=True)` and `execute_installation(installation, arguments, command=None)` from `uv_launch`. `execute_installation` uses `exec`, so successful execution does not return. `run` and `execute_installation` export a configured generation variable for the generation they explicitly select; `select` and `run` retain their explicit selection semantics rather than consulting the shim pin. The opaque `generation` returned by `select` can also be assigned to the configured variable when invoking a shim. Consumers should use these interfaces instead of reading installation symlinks or reconstructing generation paths.
|
|
102
|
+
|
|
103
|
+
## Script runtimes and Python imports
|
|
104
|
+
|
|
105
|
+
`uv_launch.dependencies` provides shared preparation for tools such as toolfuncs:
|
|
106
|
+
|
|
107
|
+
- `prepare_environment(requirements, python=..., background=True)` returns a ready immutable virtual environment. Warm calls reuse a generation without foreground resolution; first use and changed requirements wait for preparation. It does not snapshot the caller's script.
|
|
108
|
+
- `resolve_dependencies(requirements, python=..., constraints=(), background=True)` returns an immutable pinned requirements file. Resolutions are cached by requirements, interpreter/platform and constraints.
|
|
109
|
+
- `install_dependencies(requirements)` adds missing packages to the **current interpreter's environment**. It reuses satisfying installed dependencies, constrains resolution to preserve existing distributions, checks the selected graph before installation, and raises `DependencyConflictError` if preservation is impossible. It never synchronizes the whole environment or replaces existing distributions. Background resolution refresh does not install into the active interpreter or reload modules.
|
|
110
|
+
- `managed_target(path)` statically resolves a generated uv-launch command to its installed executable. It validates the shim against its recorded installation without executing shell code, importing the application, or requesting an update. This lets discovery tools inspect package metadata behind managed commands.
|
|
111
|
+
|
|
112
|
+
The shared resolver accepts floating Git branches and records exact commit IDs in each cached resolution. No separate script registration or checked-in lockfile is required. A process already using an installed package retains that package; a new cached resolution is not a hot reload. Use a compatible environment when requirements conflict. Each installed source distribution is identified by its recorded origin or package version via `distribution_requirement(distribution)`.
|
|
113
|
+
|
|
114
|
+
## Codex-router example
|
|
115
|
+
|
|
116
|
+
All five packages are resolved in one operation, so a moving Git ref produces one consistent suite revision:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
uv-launch install codexr \
|
|
120
|
+
'codex-router @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router' \
|
|
121
|
+
--with 'codex-router-sdk @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router-sdk' \
|
|
122
|
+
--with 'codex-monitor @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-monitor' \
|
|
123
|
+
--with 'codex-recovery @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-recovery' \
|
|
124
|
+
--with 'codex-accounts @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-accounts' \
|
|
125
|
+
--bin-dir ./codexr-evaluation/bin
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
uv sync
|
|
134
|
+
uv run pytest -q
|
|
135
|
+
uv run ruff check .
|
|
136
|
+
uv run ruff format --check .
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Integration tests create local Git packages and exercise installation, immutable revisions, background publication, isolated candidate validation and its failure diagnostics, companion generation pins and retirement, failed updates, concurrent checks, offline launches, and preservation of unrelated commands. A frozen copy of the 0.2.3 updater exercises updates delayed across a reinstall. Shell tests cover dash and bash when installed, including unset, empty, valid, invalid, and unavailable pins and reserved generation-variable names.
|
|
@@ -0,0 +1,129 @@
|
|
|
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/). 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
|
+
|
|
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
|
+
Directory defaults come from [platformdirs](https://platformdirs.readthedocs.io/en/latest/api.html): `user_bin_path()` for installed commands and `user_data_path("uv-launch", appauthor=False)` for durable installations. On macOS the data directory is `~/Library/Application Support/uv-launch`; on Linux it follows `XDG_DATA_HOME`, normally `~/.local/share/uv-launch`. Both platforms normally install commands in `~/.local/bin`. `--bin-dir` and a nonempty `UV_LAUNCH_HOME` override those defaults. Use a separate directory for evaluation. The installer refuses to overwrite commands it does not own.
|
|
17
|
+
|
|
18
|
+
To adopt the platform directory after upgrading from 0.2.2 or earlier, repeat the original `install` or `install-source` command with its existing requirements and options, without a `UV_LAUNCH_HOME` override. This builds and validates a new installation before replacing its launcher. Keep the old directory while any process uses it; do not move its virtual environments, whose executable paths are absolute. Existing launchers retain their recorded directory until reinstalled. To operate on an old installation directly, set `UV_LAUNCH_HOME` to its recorded root.
|
|
19
|
+
|
|
20
|
+
Each invocation without an explicit generation pin 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), runs any configured validation command, then atomically switches the generation used by future launches. Virtual environments are never moved after creation, because their scripts contain absolute paths.
|
|
21
|
+
|
|
22
|
+
The launched command retains its arguments, working directory, environment and exit status, with a configured generation variable set to the selected ID. 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.
|
|
23
|
+
|
|
24
|
+
An initial installation requires dependency access. Automatic updates have a 600-second timeout per subprocess by default; configure it with `install --timeout`. Without `--validate`, validation checks installation and entrypoint integrity only. 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.
|
|
25
|
+
|
|
26
|
+
## Package candidate validation
|
|
27
|
+
|
|
28
|
+
An application can check its packaged resources, hooks, companion commands, and startup before a new generation becomes active:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
uv-launch install my-command 'package-name @ git+https://github.com/owner/project.git@main' \
|
|
32
|
+
--entrypoint actual-console-script --validation-timeout 30 \
|
|
33
|
+
--validate package-check --smoke-test
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Put `--validate` last. It takes the name of an executable in the candidate's `venv/bin` followed by fixed arguments, passed directly without a shell. The executable need not have a public shim. The command and timeout are saved in the installation and apply to initial installation, reinstall, foreground updates, and background updates. `--validation-timeout` defaults to 60 seconds and must be finite and positive. An unchanged generation does not run validation again.
|
|
37
|
+
|
|
38
|
+
When validation is configured, entrypoint import checks and the validation command run with a fresh temporary working directory and clean environment. `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CACHE_HOME`, `XDG_RUNTIME_DIR`, `XDG_CONFIG_DIRS`, `XDG_DATA_DIRS`, `TMPDIR`, and `UV_LAUNCH_HOME` point to private temporary directories, removed after the check. Application-specific environment variables and credentials are not inherited. `PATH` starts with the candidate's `venv/bin` and retains the calling process's PATH for external tools. `VIRTUAL_ENV` names the candidate environment, `UV_LAUNCH_GENERATION` gives its absolute generation directory, and a configured `--generation-env` variable contains its opaque generation ID. Dependency resolution still uses the user's normal uv/Git authentication. This isolates conventional configuration and state paths; it is not an operating-system sandbox against code that deliberately accesses other paths.
|
|
39
|
+
|
|
40
|
+
A zero exit status permits the existing atomic activation. A nonzero exit, missing validation executable, or timeout rejects and removes the candidate while retaining the active generation and its shims. Timeout cleanup terminates the command's process group, allowing up to five seconds for the command to exit before forced termination. `uv-launch status my-command` exposes `last_validation`, including the attempted generation, command, `returncode` (null if it could not start or timed out), `timed_out`, `checked_at`, `duration_seconds`, and the final 8192 bytes of combined stdout/stderr decoded as UTF-8. The record is also stored in `<UV_LAUNCH_HOME>/<command>/last-validation.json`. A failed first validation is inspectable with `status`, with `active` and `config` set to null. Update failures also appear in `last_update`, and background failures in `update.log`.
|
|
41
|
+
|
|
42
|
+
Repeat the original install command with the new options to enable validation for an existing installation. New package installations require uv-launch `>=0.2.4,<0.3` in their environments so the bundled updater understands these settings. Installations using validation, companions, or a generation pin use configuration schema 2, which stores `package_requirements` and omits the legacy `requirements` key. A 0.2.3 updater, including one that started before a reinstall, therefore fails before resolution instead of publishing a candidate without enforcing the new settings. The current updater still reads ordinary legacy configurations; refreshing an earlier unversioned configuration with these features rebuilds it in the protected format before activation.
|
|
43
|
+
|
|
44
|
+
## Source toolfuncs
|
|
45
|
+
|
|
46
|
+
Install a standalone toolfunc together with its adjacent repository files:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
uvx uv-launch install-source wiki https://github.com/owner/wiki scripts/wiki --prepare prepare-runtime
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`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.
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
## Service startup and companion commands
|
|
59
|
+
|
|
60
|
+
Package installations can expose selected companion console scripts alongside the main command:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
uv-launch install my-command package-name --entrypoint actual-console-script \
|
|
64
|
+
--companion package-helper --companion package-admin \
|
|
65
|
+
--generation-env MY_APP_GENERATION \
|
|
66
|
+
--validate package-check --smoke-test
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Each repeatable `--companion` names a candidate executable and creates a shim of that name in `--bin-dir`. Main and companion entrypoints are checked before activation; unrelated commands are never overwritten. All shims select from the same installation, so they switch together on an update. `--companion` and `--generation-env` are package-mode options.
|
|
70
|
+
|
|
71
|
+
Reinstalling without a previously selected companion retires it only after the new candidate succeeds. If no generation variable was configured, its owned shim is removed. Otherwise the shim remains available for pinned sessions: unpinned calls report that it is retired, while an explicit pin still runs the executable from that retained generation. A retired shim continues to use the variable configured before retirement, even if the reinstall changes or omits `--generation-env`. Retirement records persist across updates and reinstalls; selecting the companion again restores normal dispatch. User-owned replacements are preserved, and rejected reinstalls preserve both the active generation and its shims. Retired shims and old generations can be removed manually once no sessions use them.
|
|
72
|
+
|
|
73
|
+
With `--generation-env MY_APP_GENERATION`, every active shim exports its captured generation ID in that variable. Child shells and companion commands inherit the pin, even if shell startup files put the global shim directory first on PATH. A nonempty pin must name an existing generation of this installation; invalid or unavailable pins fail instead of switching to the active generation. An unset or empty pin selects the current generation and starts the usual background update check. Explicitly pinned launches do not start an updater from the old runtime. Use a distinct application-specific variable name, such as `MY_APP_GENERATION`. Standard configuration and launcher variables, shell-managed names such as `PPID`, `IFS`, `UID`, `EUID`, and `RANDOM`, and the `BASH*` and `COMP_*` namespaces are rejected. Old generations are retained, so an update cannot retarget a running session. `managed_target` also honors the pin when inspecting main, companion, and retired shims; inspecting an unpinned retired shim reports retirement.
|
|
74
|
+
|
|
75
|
+
A service can require the latest successfully resolved dependencies at startup:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
uv-launch run --refresh my-command -- serve --port 8080
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Unlike the ordinary background-updating shim, `run --refresh` waits for a successful update and fails without executing stale code when that update fails. It then replaces itself with the selected command. Arguments, environment, working directory and exit status are preserved. SIGTERM during refresh terminates and reaps the resolver/build process group; after execution the application receives signals directly. Old generations remain available for workers that outlive their launcher.
|
|
82
|
+
|
|
83
|
+
Run a companion executable from the same installation with `uv-launch run --command python my-command -- -c 'print("probe")'`. Options to `run` precede the installation name. For several operations that must use exactly the same generation, capture once and pass the returned opaque `generation` value:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
uv-launch select --refresh my-command
|
|
87
|
+
uv-launch run --generation GENERATION --command python my-command -- -c 'print("probe")'
|
|
88
|
+
uv-launch run --generation GENERATION my-command -- serve
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The Python equivalents are `select_installation(name, refresh_dependencies=True)` and `execute_installation(installation, arguments, command=None)` from `uv_launch`. `execute_installation` uses `exec`, so successful execution does not return. `run` and `execute_installation` export a configured generation variable for the generation they explicitly select; `select` and `run` retain their explicit selection semantics rather than consulting the shim pin. The opaque `generation` returned by `select` can also be assigned to the configured variable when invoking a shim. Consumers should use these interfaces instead of reading installation symlinks or reconstructing generation paths.
|
|
92
|
+
|
|
93
|
+
## Script runtimes and Python imports
|
|
94
|
+
|
|
95
|
+
`uv_launch.dependencies` provides shared preparation for tools such as toolfuncs:
|
|
96
|
+
|
|
97
|
+
- `prepare_environment(requirements, python=..., background=True)` returns a ready immutable virtual environment. Warm calls reuse a generation without foreground resolution; first use and changed requirements wait for preparation. It does not snapshot the caller's script.
|
|
98
|
+
- `resolve_dependencies(requirements, python=..., constraints=(), background=True)` returns an immutable pinned requirements file. Resolutions are cached by requirements, interpreter/platform and constraints.
|
|
99
|
+
- `install_dependencies(requirements)` adds missing packages to the **current interpreter's environment**. It reuses satisfying installed dependencies, constrains resolution to preserve existing distributions, checks the selected graph before installation, and raises `DependencyConflictError` if preservation is impossible. It never synchronizes the whole environment or replaces existing distributions. Background resolution refresh does not install into the active interpreter or reload modules.
|
|
100
|
+
- `managed_target(path)` statically resolves a generated uv-launch command to its installed executable. It validates the shim against its recorded installation without executing shell code, importing the application, or requesting an update. This lets discovery tools inspect package metadata behind managed commands.
|
|
101
|
+
|
|
102
|
+
The shared resolver accepts floating Git branches and records exact commit IDs in each cached resolution. No separate script registration or checked-in lockfile is required. A process already using an installed package retains that package; a new cached resolution is not a hot reload. Use a compatible environment when requirements conflict. Each installed source distribution is identified by its recorded origin or package version via `distribution_requirement(distribution)`.
|
|
103
|
+
|
|
104
|
+
## Codex-router example
|
|
105
|
+
|
|
106
|
+
All five packages are resolved in one operation, so a moving Git ref produces one consistent suite revision:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
uv-launch install codexr \
|
|
110
|
+
'codex-router @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router' \
|
|
111
|
+
--with 'codex-router-sdk @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router-sdk' \
|
|
112
|
+
--with 'codex-monitor @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-monitor' \
|
|
113
|
+
--with 'codex-recovery @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-recovery' \
|
|
114
|
+
--with 'codex-accounts @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-accounts' \
|
|
115
|
+
--bin-dir ./codexr-evaluation/bin
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
uv sync
|
|
124
|
+
uv run pytest -q
|
|
125
|
+
uv run ruff check .
|
|
126
|
+
uv run ruff format --check .
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Integration tests create local Git packages and exercise installation, immutable revisions, background publication, isolated candidate validation and its failure diagnostics, companion generation pins and retirement, failed updates, concurrent checks, offline launches, and preservation of unrelated commands. A frozen copy of the 0.2.3 updater exercises updates delayed across a reinstall. Shell tests cover dash and bash when installed, including unset, empty, valid, invalid, and unavailable pins and reserved generation-variable names.
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "uv-launch"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.4"
|
|
4
4
|
description = "Immediate Python command startup with atomic background updates through uv"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
|
-
dependencies = [
|
|
7
|
+
dependencies = [
|
|
8
|
+
"packaging>=24",
|
|
9
|
+
"platformdirs>=4.8",
|
|
10
|
+
]
|
|
8
11
|
|
|
9
12
|
[project.urls]
|
|
10
13
|
Repository = "https://github.com/nimashoghi/uv-launch"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "uv-launch"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.4"
|
|
4
4
|
description = "Immediate Python command startup with atomic background updates through uv"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
|
-
dependencies = ["packaging>=24"]
|
|
7
|
+
dependencies = ["packaging>=24", "platformdirs>=4.8"]
|
|
8
8
|
|
|
9
9
|
[project.urls]
|
|
10
10
|
Repository = "https://github.com/nimashoghi/uv-launch"
|
|
@@ -7,6 +7,7 @@ import fcntl
|
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
|
+
import math
|
|
10
11
|
import os
|
|
11
12
|
import re
|
|
12
13
|
import shlex
|
|
@@ -17,11 +18,74 @@ import sys
|
|
|
17
18
|
import tempfile
|
|
18
19
|
import time
|
|
19
20
|
import uuid
|
|
20
|
-
from contextlib import contextmanager
|
|
21
|
+
from contextlib import contextmanager, suppress
|
|
21
22
|
from dataclasses import asdict, dataclass
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
|
|
25
|
+
from platformdirs import user_bin_path, user_data_path
|
|
26
|
+
|
|
24
27
|
LOGGER = logging.getLogger(__name__)
|
|
28
|
+
_ISOLATED_DIRECTORIES = (
|
|
29
|
+
"HOME",
|
|
30
|
+
"XDG_CONFIG_HOME",
|
|
31
|
+
"XDG_DATA_HOME",
|
|
32
|
+
"XDG_STATE_HOME",
|
|
33
|
+
"XDG_CACHE_HOME",
|
|
34
|
+
"XDG_RUNTIME_DIR",
|
|
35
|
+
"XDG_CONFIG_DIRS",
|
|
36
|
+
"XDG_DATA_DIRS",
|
|
37
|
+
"TMPDIR",
|
|
38
|
+
"UV_LAUNCH_HOME",
|
|
39
|
+
)
|
|
40
|
+
# Shell Variables in the Bash manual; these may be initialized, rewritten, or
|
|
41
|
+
# readonly even when absent from the caller's environment. Reserve BASH*/COMP_*
|
|
42
|
+
# below as namespaces so additions there cannot become generation variables.
|
|
43
|
+
_SHELL_VARIABLES = frozenset(
|
|
44
|
+
[
|
|
45
|
+
"_",
|
|
46
|
+
"CDPATH",
|
|
47
|
+
"ENV",
|
|
48
|
+
"IFS",
|
|
49
|
+
"PPID",
|
|
50
|
+
"UID",
|
|
51
|
+
"EUID",
|
|
52
|
+
"PWD",
|
|
53
|
+
"OLDPWD",
|
|
54
|
+
"SHLVL",
|
|
55
|
+
"SHELL",
|
|
56
|
+
"SHELLOPTS",
|
|
57
|
+
"OPTARG",
|
|
58
|
+
"OPTERR",
|
|
59
|
+
"OPTIND",
|
|
60
|
+
"PS0",
|
|
61
|
+
"PS1",
|
|
62
|
+
"PS2",
|
|
63
|
+
"PS3",
|
|
64
|
+
"PS4",
|
|
65
|
+
"PROMPT_COMMAND",
|
|
66
|
+
"RANDOM",
|
|
67
|
+
"SRANDOM",
|
|
68
|
+
"SECONDS",
|
|
69
|
+
"EPOCHREALTIME",
|
|
70
|
+
"EPOCHSECONDS",
|
|
71
|
+
"LINENO",
|
|
72
|
+
"HISTCMD",
|
|
73
|
+
"GROUPS",
|
|
74
|
+
"DIRSTACK",
|
|
75
|
+
"PIPESTATUS",
|
|
76
|
+
"FUNCNAME",
|
|
77
|
+
"COPROC",
|
|
78
|
+
"COMPREPLY",
|
|
79
|
+
"MAPFILE",
|
|
80
|
+
"HOSTNAME",
|
|
81
|
+
"HOSTTYPE",
|
|
82
|
+
"MACHTYPE",
|
|
83
|
+
"OSTYPE",
|
|
84
|
+
"TERM",
|
|
85
|
+
"POSIXLY_CORRECT",
|
|
86
|
+
"GLOBIGNORE",
|
|
87
|
+
]
|
|
88
|
+
)
|
|
25
89
|
|
|
26
90
|
|
|
27
91
|
class _Terminated(BaseException):
|
|
@@ -42,18 +106,22 @@ def _run_checked(arguments: list[str], **kwargs) -> None:
|
|
|
42
106
|
raise subprocess.CalledProcessError(returncode, arguments)
|
|
43
107
|
except BaseException:
|
|
44
108
|
if process.poll() is None:
|
|
45
|
-
|
|
46
|
-
|
|
109
|
+
with suppress(ProcessLookupError):
|
|
110
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
111
|
+
with suppress(subprocess.TimeoutExpired):
|
|
47
112
|
process.wait(timeout=5)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
113
|
+
# Children may survive their parent or ignore SIGTERM.
|
|
114
|
+
with suppress(ProcessLookupError):
|
|
115
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
116
|
+
process.wait()
|
|
51
117
|
raise
|
|
52
118
|
|
|
53
119
|
|
|
54
120
|
def state_root() -> Path:
|
|
55
121
|
"""Return durable installation state, independently of any current project."""
|
|
56
|
-
|
|
122
|
+
if override := os.environ.get("UV_LAUNCH_HOME"):
|
|
123
|
+
return Path(override).expanduser().resolve()
|
|
124
|
+
return user_data_path("uv-launch", appauthor=False).resolve()
|
|
57
125
|
|
|
58
126
|
|
|
59
127
|
def tool_directory(name: str) -> Path:
|
|
@@ -98,12 +166,90 @@ def run_uv(config: dict, arguments: list[str], *, cwd: Path) -> None:
|
|
|
98
166
|
)
|
|
99
167
|
|
|
100
168
|
|
|
169
|
+
@contextmanager
|
|
170
|
+
def _candidate_state(candidate: Path, config: dict):
|
|
171
|
+
if not config.get("validate"):
|
|
172
|
+
yield None, None
|
|
173
|
+
return
|
|
174
|
+
with tempfile.TemporaryDirectory(prefix="uv-launch-validation-") as temporary:
|
|
175
|
+
directory = Path(temporary)
|
|
176
|
+
# Start clean: inherited application-specific config paths and credentials
|
|
177
|
+
# must not bypass HOME/XDG isolation. This is not a filesystem sandbox.
|
|
178
|
+
environment = {
|
|
179
|
+
"PATH": str(candidate / "venv/bin") + os.pathsep + os.environ.get("PATH", os.defpath),
|
|
180
|
+
"VIRTUAL_ENV": str(candidate / "venv"),
|
|
181
|
+
"UV_LAUNCH_GENERATION": str(candidate),
|
|
182
|
+
}
|
|
183
|
+
for variable in _ISOLATED_DIRECTORIES:
|
|
184
|
+
path = directory / variable.lower()
|
|
185
|
+
path.mkdir(mode=0o700)
|
|
186
|
+
environment[variable] = str(path)
|
|
187
|
+
if variable := config.get("generation_env"):
|
|
188
|
+
environment[variable] = candidate.name
|
|
189
|
+
yield directory, environment
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validate_candidate(root: Path, candidate: Path, config: dict, *, cwd: Path, env: dict) -> None:
|
|
193
|
+
arguments = [str(candidate / "venv/bin" / config["validate"][0]), *config["validate"][1:]]
|
|
194
|
+
started = time.monotonic()
|
|
195
|
+
diagnostic = {
|
|
196
|
+
"command": config["validate"],
|
|
197
|
+
"generation": str(candidate),
|
|
198
|
+
"checked_at": time.time(),
|
|
199
|
+
"timeout": config["validation_timeout"],
|
|
200
|
+
"returncode": None,
|
|
201
|
+
"timed_out": False,
|
|
202
|
+
}
|
|
203
|
+
with tempfile.TemporaryFile() as output:
|
|
204
|
+
try:
|
|
205
|
+
_run_checked(
|
|
206
|
+
arguments,
|
|
207
|
+
cwd=cwd,
|
|
208
|
+
env=env,
|
|
209
|
+
stdin=subprocess.DEVNULL,
|
|
210
|
+
stdout=output,
|
|
211
|
+
stderr=subprocess.STDOUT,
|
|
212
|
+
timeout=config["validation_timeout"],
|
|
213
|
+
)
|
|
214
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
215
|
+
diagnostic.update(status="failed", error=str(error))
|
|
216
|
+
if isinstance(error, subprocess.CalledProcessError):
|
|
217
|
+
diagnostic["returncode"] = error.returncode
|
|
218
|
+
diagnostic["timed_out"] = isinstance(error, subprocess.TimeoutExpired)
|
|
219
|
+
else:
|
|
220
|
+
diagnostic.update(status="passed", returncode=0)
|
|
221
|
+
diagnostic["duration_seconds"] = time.monotonic() - started
|
|
222
|
+
output.seek(0, os.SEEK_END)
|
|
223
|
+
output.seek(max(0, output.tell() - 8192))
|
|
224
|
+
diagnostic["output_tail"] = output.read().decode("utf-8", errors="replace")
|
|
225
|
+
atomic_write(root / "last-validation.json", json.dumps(diagnostic))
|
|
226
|
+
if diagnostic["status"] == "failed":
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"candidate validation failed after {diagnostic['duration_seconds']:.3f}s: "
|
|
229
|
+
f"{diagnostic['error']}\n{diagnostic['output_tail']}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
101
233
|
def refresh(root: Path, config: dict) -> dict:
|
|
102
234
|
"""Prepare an immutable candidate; publish only after dependency and entrypoint checks."""
|
|
103
235
|
if config.get("kind") == "source":
|
|
104
236
|
from .source import refresh_source
|
|
105
237
|
|
|
106
238
|
return refresh_source(root, config)
|
|
239
|
+
if config.get("schema_version", 1) not in (1, 2):
|
|
240
|
+
raise ValueError("unsupported installation configuration version")
|
|
241
|
+
if "requirements" in config and any(
|
|
242
|
+
config.get(key)
|
|
243
|
+
for key in ("validate", "companions", "generation_env", "retired_companions")
|
|
244
|
+
):
|
|
245
|
+
config = dict(config)
|
|
246
|
+
# 0.2.3 requires this legacy key before resolving or publishing. Removing
|
|
247
|
+
# it makes even an already-running old updater fail closed after reinstall.
|
|
248
|
+
config["package_requirements"] = config.pop("requirements")
|
|
249
|
+
config["schema_version"] = 2
|
|
250
|
+
package_requirements = config[
|
|
251
|
+
"package_requirements" if config.get("schema_version") == 2 else "requirements"
|
|
252
|
+
]
|
|
107
253
|
generations = root / "generations"
|
|
108
254
|
generations.mkdir(exist_ok=True)
|
|
109
255
|
candidate = generations / uuid.uuid4().hex
|
|
@@ -111,7 +257,7 @@ def refresh(root: Path, config: dict) -> dict:
|
|
|
111
257
|
published = False
|
|
112
258
|
try:
|
|
113
259
|
requirements = candidate / "requirements.in"
|
|
114
|
-
requirements.write_text("\n".join(
|
|
260
|
+
requirements.write_text("\n".join(package_requirements) + "\n")
|
|
115
261
|
lock = candidate / "requirements.txt"
|
|
116
262
|
constraints = candidate / "constraints.txt"
|
|
117
263
|
constraints.write_text("\n".join(config.get("constraints", [])) + "\n")
|
|
@@ -168,30 +314,40 @@ def refresh(root: Path, config: dict) -> dict:
|
|
|
168
314
|
raise ValueError(f"installed environment does not provide {config['entrypoint']!r}")
|
|
169
315
|
# Import Python entrypoints to catch missing callables before publication.
|
|
170
316
|
# Wheel-provided executables need an owning distribution instead.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
"
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
317
|
+
with _candidate_state(candidate, config) as (validation_cwd, validation_env):
|
|
318
|
+
for entrypoint in dict.fromkeys([config["entrypoint"], *config.get("companions", [])]):
|
|
319
|
+
if not entrypoint:
|
|
320
|
+
continue
|
|
321
|
+
companion = environment / "bin" / entrypoint
|
|
322
|
+
if not companion.is_file() or not os.access(companion, os.X_OK):
|
|
323
|
+
raise ValueError(f"installed environment does not provide {entrypoint!r}")
|
|
324
|
+
_run_checked(
|
|
325
|
+
[
|
|
326
|
+
str(environment / "bin/python"),
|
|
327
|
+
"-I",
|
|
328
|
+
"-c",
|
|
329
|
+
"import importlib.metadata as m, pathlib, sys\n"
|
|
330
|
+
"entries=list(m.entry_points(group='console_scripts', name=sys.argv[1]))\n"
|
|
331
|
+
"assert len(entries)<=1, 'ambiguous console entrypoint'\n"
|
|
332
|
+
"if entries:\n"
|
|
333
|
+
" assert callable(entries[0].load()), 'entrypoint must be callable'\n"
|
|
334
|
+
"else:\n"
|
|
335
|
+
" executable=pathlib.Path(sys.argv[2]).resolve()\n"
|
|
336
|
+
" owners=[d for d in m.distributions() if any(\n"
|
|
337
|
+
" pathlib.Path(d.locate_file(f)).resolve()==executable\n"
|
|
338
|
+
" for f in d.files or ())]\n"
|
|
339
|
+
" assert len(owners)==1, 'expected one executable owner'\n",
|
|
340
|
+
entrypoint,
|
|
341
|
+
str(companion),
|
|
342
|
+
],
|
|
343
|
+
cwd=validation_cwd,
|
|
344
|
+
env=validation_env,
|
|
345
|
+
stdin=subprocess.DEVNULL,
|
|
346
|
+
stdout=subprocess.DEVNULL,
|
|
347
|
+
timeout=config["timeout"],
|
|
348
|
+
)
|
|
349
|
+
if config.get("validate"):
|
|
350
|
+
_validate_candidate(root, candidate, config, cwd=validation_cwd, env=validation_env)
|
|
195
351
|
metadata = {"identity": identity, "generation": str(candidate), "created_at": time.time()}
|
|
196
352
|
atomic_write(candidate / "installation.json", json.dumps(metadata, sort_keys=True))
|
|
197
353
|
atomic_write(candidate / "config.json", json.dumps(config, indent=2) + "\n")
|
|
@@ -210,7 +366,12 @@ def refresh(root: Path, config: dict) -> dict:
|
|
|
210
366
|
shutil.rmtree(candidate)
|
|
211
367
|
|
|
212
368
|
|
|
213
|
-
def _launcher_content(
|
|
369
|
+
def _launcher_content(
|
|
370
|
+
root: Path, config: dict, manager_python: str, *, command: str | None = None
|
|
371
|
+
) -> str:
|
|
372
|
+
retired = command in config.get("retired_companions", {})
|
|
373
|
+
if retired:
|
|
374
|
+
config = {**config, "generation_env": config["retired_companions"][command]}
|
|
214
375
|
home = shlex.quote(str(root.parent))
|
|
215
376
|
name = shlex.quote(config["name"])
|
|
216
377
|
directory = shlex.quote(str(root))
|
|
@@ -219,28 +380,81 @@ def _launcher_content(root: Path, config: dict, manager_python: str) -> str:
|
|
|
219
380
|
if config.get("managed_updater")
|
|
220
381
|
else shlex.quote(manager_python)
|
|
221
382
|
)
|
|
383
|
+
selection = f"generation=$(readlink {directory}/current) || exit 1\n"
|
|
384
|
+
update = (
|
|
385
|
+
f"UV_LAUNCH_HOME={home} nohup {python} -I -m uv_launch update {name} "
|
|
386
|
+
"--background </dev/null >/dev/null 2>&1 &\n"
|
|
387
|
+
)
|
|
388
|
+
export = ""
|
|
389
|
+
if variable := config.get("generation_env"):
|
|
390
|
+
selection = (
|
|
391
|
+
f'if [ -n "${{{variable}-}}" ]; then\n'
|
|
392
|
+
f' generation="${variable}"\n'
|
|
393
|
+
' case "$generation" in *[!a-f0-9]*) '
|
|
394
|
+
"echo 'uv-launch: invalid generation pin' >&2; exit 1;; esac\n"
|
|
395
|
+
' [ "${#generation}" -eq 32 ] || { '
|
|
396
|
+
"echo 'uv-launch: invalid generation pin' >&2; exit 1; }\n"
|
|
397
|
+
' generation="generations/$generation"\n'
|
|
398
|
+
f' [ -f {directory}/"$generation"/installation.json ] || {{ '
|
|
399
|
+
"echo 'uv-launch: pinned generation is unavailable' >&2; exit 1; }\n"
|
|
400
|
+
f"else\n {selection}fi\n"
|
|
401
|
+
)
|
|
402
|
+
# A pinned old updater may not understand settings added by a reinstall.
|
|
403
|
+
update = f'if [ -z "${{{variable}-}}" ]; then\n {update}fi\n'
|
|
404
|
+
export = f'export {variable}="${{generation##*/}}"\n'
|
|
405
|
+
if retired:
|
|
406
|
+
error = shlex.quote(
|
|
407
|
+
f"uv-launch: companion {command!r} is retired; "
|
|
408
|
+
f"an explicit {variable} pin is required"
|
|
409
|
+
)
|
|
410
|
+
selection = (
|
|
411
|
+
f'if [ -z "${{{variable}-}}" ]; then echo {error} >&2; exit 1; fi\n' + selection
|
|
412
|
+
)
|
|
413
|
+
target = "command" if command is None else f"venv/bin/{command}"
|
|
414
|
+
check = ""
|
|
415
|
+
if retired:
|
|
416
|
+
error = shlex.quote(f"uv-launch: pinned generation does not provide {command!r}")
|
|
417
|
+
check = (
|
|
418
|
+
f'[ -x {directory}/"$generation"/{shlex.quote(target)} ] || '
|
|
419
|
+
f"{{ echo {error} >&2; exit 1; }}\n"
|
|
420
|
+
)
|
|
222
421
|
return (
|
|
223
422
|
"#!/bin/sh\n"
|
|
224
423
|
f"# Managed by uv-launch: {config['name']}\n"
|
|
225
424
|
f"# uv-launch-root: {json.dumps(str(root))}\n"
|
|
226
425
|
f"# uv-launch-python: {json.dumps(manager_python)}\n"
|
|
227
426
|
# Read one immutable generation before the updater can switch current.
|
|
228
|
-
f"
|
|
229
|
-
f"
|
|
230
|
-
"
|
|
231
|
-
f
|
|
427
|
+
f"{selection}"
|
|
428
|
+
f"{update}"
|
|
429
|
+
f"{export}"
|
|
430
|
+
f"{check}"
|
|
431
|
+
f'exec {directory}/"$generation"/{shlex.quote(target)} "$@"\n'
|
|
232
432
|
)
|
|
233
433
|
|
|
234
434
|
|
|
235
|
-
def
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
435
|
+
def _owned_launcher(path: Path, name: str) -> bool:
|
|
436
|
+
marker = f"#!/bin/sh\n# Managed by uv-launch: {name}\n".encode()
|
|
437
|
+
try:
|
|
438
|
+
with path.open("rb") as stream:
|
|
439
|
+
return stream.read(len(marker)) == marker
|
|
440
|
+
except OSError:
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _launcher_destination(config: dict, command: str | None = None) -> Path:
|
|
445
|
+
destination = Path(config["bin_dir"]) / (command or config["name"])
|
|
446
|
+
if (destination.exists() or destination.is_symlink()) and not _owned_launcher(
|
|
447
|
+
destination, config["name"]
|
|
242
448
|
):
|
|
243
449
|
raise ValueError(f"refusing to replace an unmanaged command: {destination}")
|
|
450
|
+
return destination
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def write_launcher(root: Path, config: dict, *, command: str | None = None) -> Path:
|
|
454
|
+
"""Install a shell shim with no foreground Python or package resolution overhead."""
|
|
455
|
+
destination = _launcher_destination(config, command)
|
|
456
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
457
|
+
content = _launcher_content(root, config, sys.executable, command=command)
|
|
244
458
|
atomic_write(destination, content, 0o755)
|
|
245
459
|
return destination
|
|
246
460
|
|
|
@@ -249,6 +463,28 @@ def install(args: argparse.Namespace) -> dict:
|
|
|
249
463
|
"""Install a named command; existing unrelated launchers are never replaced."""
|
|
250
464
|
root = tool_directory(args.name)
|
|
251
465
|
tool_directory(args.entrypoint or args.name)
|
|
466
|
+
for companion in args.companion:
|
|
467
|
+
tool_directory(companion)
|
|
468
|
+
if companion == args.name:
|
|
469
|
+
raise ValueError("a companion command must differ from the launcher name")
|
|
470
|
+
if args.validate is not None:
|
|
471
|
+
if not args.validate:
|
|
472
|
+
raise ValueError("--validate requires a candidate command")
|
|
473
|
+
tool_directory(args.validate[0])
|
|
474
|
+
if args.generation_env is not None and (
|
|
475
|
+
not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", args.generation_env)
|
|
476
|
+
or args.generation_env in _SHELL_VARIABLES
|
|
477
|
+
or args.generation_env.startswith(("BASH", "COMP_"))
|
|
478
|
+
or args.generation_env
|
|
479
|
+
in {
|
|
480
|
+
"generation",
|
|
481
|
+
"PATH",
|
|
482
|
+
"VIRTUAL_ENV",
|
|
483
|
+
"UV_LAUNCH_GENERATION",
|
|
484
|
+
*_ISOLATED_DIRECTORIES,
|
|
485
|
+
}
|
|
486
|
+
):
|
|
487
|
+
raise ValueError("--generation-env must be a non-reserved environment variable name")
|
|
252
488
|
uv = shutil.which("uv")
|
|
253
489
|
if uv is None:
|
|
254
490
|
raise ValueError("uv must be installed and available on PATH")
|
|
@@ -261,20 +497,54 @@ def install(args: argparse.Namespace) -> dict:
|
|
|
261
497
|
"timeout": args.timeout,
|
|
262
498
|
"bin_dir": str(Path(args.bin_dir).expanduser().absolute()),
|
|
263
499
|
"managed_updater": True,
|
|
500
|
+
"validate": args.validate or [],
|
|
501
|
+
"validation_timeout": args.validation_timeout,
|
|
502
|
+
"companions": list(dict.fromkeys(args.companion)),
|
|
503
|
+
"generation_env": args.generation_env,
|
|
264
504
|
}
|
|
265
|
-
if args.name != "uv-launch":
|
|
266
|
-
config["requirements"].append("uv-launch>=0.2.
|
|
505
|
+
if args.name != "uv-launch" or args.validate or args.companion or args.generation_env:
|
|
506
|
+
config["requirements"].append("uv-launch>=0.2.4,<0.3")
|
|
267
507
|
for requirement in config["requirements"]:
|
|
268
508
|
if not requirement or "\n" in requirement or "\r" in requirement:
|
|
269
509
|
raise ValueError("each requirement must be a single nonempty line")
|
|
270
|
-
destination = Path(config["bin_dir"]) / config["name"]
|
|
271
|
-
if destination.exists() and not destination.read_text().startswith(
|
|
272
|
-
f"#!/bin/sh\n# Managed by uv-launch: {config['name']}\n"
|
|
273
|
-
):
|
|
274
|
-
raise ValueError(f"refusing to replace an unmanaged command: {destination}")
|
|
275
510
|
with installation_lock(root):
|
|
511
|
+
previous = (
|
|
512
|
+
json.loads((root / "current/config.json").read_text())
|
|
513
|
+
if (root / "current").exists()
|
|
514
|
+
else {}
|
|
515
|
+
)
|
|
516
|
+
removed = set(previous.get("companions", [])) - set(config["companions"])
|
|
517
|
+
retired = dict(previous.get("retired_companions", {}))
|
|
518
|
+
for companion in removed:
|
|
519
|
+
if variable := previous.get("generation_env"):
|
|
520
|
+
retired[companion] = variable
|
|
521
|
+
for companion in config["companions"]:
|
|
522
|
+
retired.pop(companion, None)
|
|
523
|
+
if retired:
|
|
524
|
+
config["retired_companions"] = retired
|
|
525
|
+
for companion in [None, *config["companions"]]:
|
|
526
|
+
_launcher_destination(config, companion)
|
|
276
527
|
result = refresh(root, config)
|
|
277
528
|
destination = write_launcher(root, config)
|
|
529
|
+
for companion in config["companions"]:
|
|
530
|
+
write_launcher(root, config, command=companion)
|
|
531
|
+
for companion in retired:
|
|
532
|
+
path = Path(config["bin_dir"]) / companion
|
|
533
|
+
# Retiring a shim must leave user-owned replacements alone.
|
|
534
|
+
if not (path.exists() or path.is_symlink()) or _owned_launcher(path, config["name"]):
|
|
535
|
+
write_launcher(root, config, command=companion)
|
|
536
|
+
for companion in removed:
|
|
537
|
+
path = Path(previous["bin_dir"]) / companion
|
|
538
|
+
if _owned_launcher(path, config["name"]):
|
|
539
|
+
if companion in retired:
|
|
540
|
+
# Also retire the original shim when --bin-dir changed.
|
|
541
|
+
atomic_write(
|
|
542
|
+
path,
|
|
543
|
+
_launcher_content(root, config, sys.executable, command=companion),
|
|
544
|
+
0o755,
|
|
545
|
+
)
|
|
546
|
+
else:
|
|
547
|
+
path.unlink()
|
|
278
548
|
return {**result, "command": str(destination)}
|
|
279
549
|
|
|
280
550
|
|
|
@@ -352,6 +622,8 @@ def execute_installation(
|
|
|
352
622
|
if metadata["identity"] != installation.identity:
|
|
353
623
|
raise ValueError("installation identity does not match its generation")
|
|
354
624
|
config = json.loads((generation / "config.json").read_text())
|
|
625
|
+
if variable := config.get("generation_env"):
|
|
626
|
+
os.environ[variable] = installation.generation
|
|
355
627
|
selected = command or config["entrypoint"]
|
|
356
628
|
if config.get("kind") == "source" and command is None:
|
|
357
629
|
executable = generation / "command"
|
|
@@ -376,10 +648,31 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
376
648
|
"--entrypoint", help="installed console script, defaults to command name"
|
|
377
649
|
)
|
|
378
650
|
installing.add_argument("--python", default="3.13", help="uv Python interpreter selector")
|
|
379
|
-
installing.add_argument("--bin-dir", default=
|
|
651
|
+
installing.add_argument("--bin-dir", default=user_bin_path())
|
|
380
652
|
installing.add_argument(
|
|
381
653
|
"--timeout", type=float, default=600, help="seconds per update operation"
|
|
382
654
|
)
|
|
655
|
+
installing.add_argument(
|
|
656
|
+
"--companion",
|
|
657
|
+
action="append",
|
|
658
|
+
default=[],
|
|
659
|
+
help="also expose this candidate command on PATH (repeatable)",
|
|
660
|
+
)
|
|
661
|
+
installing.add_argument(
|
|
662
|
+
"--generation-env",
|
|
663
|
+
help="environment variable used to export and pin the generation in every shim",
|
|
664
|
+
)
|
|
665
|
+
installing.add_argument(
|
|
666
|
+
"--validation-timeout",
|
|
667
|
+
type=float,
|
|
668
|
+
default=60,
|
|
669
|
+
help="seconds allowed for candidate validation (default: 60)",
|
|
670
|
+
)
|
|
671
|
+
installing.add_argument(
|
|
672
|
+
"--validate",
|
|
673
|
+
nargs=argparse.REMAINDER,
|
|
674
|
+
help="candidate command and fixed arguments to validate before activation; put last",
|
|
675
|
+
)
|
|
383
676
|
source_installing = commands.add_parser(
|
|
384
677
|
"install-source", help="install a toolfunc and adjacent files from a Git repository"
|
|
385
678
|
)
|
|
@@ -389,7 +682,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
389
682
|
source_installing.add_argument("--revision", default="main", help="branch, tag, or commit")
|
|
390
683
|
source_installing.add_argument("--with", dest="extra", action="append", default=[])
|
|
391
684
|
source_installing.add_argument("--python", default="3.13")
|
|
392
|
-
source_installing.add_argument("--bin-dir", default=
|
|
685
|
+
source_installing.add_argument("--bin-dir", default=user_bin_path())
|
|
393
686
|
source_installing.add_argument("--timeout", type=float, default=600)
|
|
394
687
|
source_installing.add_argument(
|
|
395
688
|
"--prepare",
|
|
@@ -421,8 +714,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
421
714
|
previous_sigterm = signal.signal(signal.SIGTERM, _terminate)
|
|
422
715
|
try:
|
|
423
716
|
if args.command == "install":
|
|
424
|
-
if args.timeout <= 0:
|
|
425
|
-
raise ValueError("timeout must be positive")
|
|
717
|
+
if not math.isfinite(args.timeout) or args.timeout <= 0:
|
|
718
|
+
raise ValueError("timeout must be finite and positive")
|
|
719
|
+
if not math.isfinite(args.validation_timeout) or args.validation_timeout <= 0:
|
|
720
|
+
raise ValueError("validation timeout must be finite and positive")
|
|
426
721
|
result = install(args)
|
|
427
722
|
elif args.command == "install-source":
|
|
428
723
|
from .source import install_source
|
|
@@ -459,12 +754,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
459
754
|
return 0
|
|
460
755
|
else:
|
|
461
756
|
root = tool_directory(args.name)
|
|
757
|
+
current = (root / "current").resolve()
|
|
758
|
+
if not current.exists() and not (root / "last-validation.json").exists():
|
|
759
|
+
raise ValueError(f"no installation found for {args.name!r}")
|
|
462
760
|
result = {
|
|
463
|
-
"config": json.loads((
|
|
464
|
-
|
|
761
|
+
"config": json.loads((current / "config.json").read_text())
|
|
762
|
+
if current.exists()
|
|
763
|
+
else None,
|
|
764
|
+
"active": json.loads((current / "installation.json").read_text())
|
|
765
|
+
if current.exists()
|
|
766
|
+
else None,
|
|
465
767
|
"last_update": json.loads((root / "last-update.json").read_text())
|
|
466
768
|
if (root / "last-update.json").exists()
|
|
467
769
|
else None,
|
|
770
|
+
"last_validation": json.loads((root / "last-validation.json").read_text())
|
|
771
|
+
if (root / "last-validation.json").exists()
|
|
772
|
+
else None,
|
|
468
773
|
}
|
|
469
774
|
if not getattr(args, "background", False):
|
|
470
775
|
sys.stdout.write(json.dumps(result, sort_keys=True) + "\n")
|
|
@@ -329,8 +329,27 @@ def managed_target(source: Path) -> Path | None:
|
|
|
329
329
|
raise ValueError("managed launcher root must be absolute")
|
|
330
330
|
generation = (root / "current").resolve(strict=True)
|
|
331
331
|
config = json.loads((generation / "config.json").read_text())
|
|
332
|
-
|
|
332
|
+
content = source.read_text()
|
|
333
|
+
retired = config.get("retired_companions", {})
|
|
334
|
+
commands = [None, *config.get("companions", []), *retired]
|
|
335
|
+
matches = [
|
|
336
|
+
command
|
|
337
|
+
for command in commands
|
|
338
|
+
if content == _launcher_content(root, config, manager_python, command=command)
|
|
339
|
+
]
|
|
340
|
+
if len(matches) != 1:
|
|
333
341
|
raise ValueError("managed launcher content does not match its installation")
|
|
342
|
+
command = matches[0]
|
|
343
|
+
variable = retired[command] if command in retired else config.get("generation_env")
|
|
344
|
+
pin = os.environ.get(variable) if variable else None
|
|
345
|
+
if command in retired and not pin:
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f"companion {command!r} is retired; an explicit {variable} pin is required"
|
|
348
|
+
)
|
|
349
|
+
if pin:
|
|
350
|
+
if not re.fullmatch(r"[a-f0-9]{32}", pin):
|
|
351
|
+
raise ValueError("invalid generation pin")
|
|
352
|
+
generation = (root / "generations" / pin).resolve(strict=True)
|
|
334
353
|
if config.get("kind") == "source":
|
|
335
354
|
from .source import source_command
|
|
336
355
|
|
|
@@ -340,7 +359,9 @@ def managed_target(source: Path) -> Path | None:
|
|
|
340
359
|
if not target.is_relative_to(generation / "source"):
|
|
341
360
|
raise ValueError("managed source must belong to its generation")
|
|
342
361
|
return target
|
|
343
|
-
target = (
|
|
362
|
+
target = (
|
|
363
|
+
generation / "command" if command is None else generation / "venv/bin" / command
|
|
364
|
+
).resolve(strict=True)
|
|
344
365
|
if not target.is_relative_to((generation / "venv").resolve()):
|
|
345
366
|
raise ValueError("managed command must belong to its generation")
|
|
346
367
|
return target
|
uv_launch-0.2.2/PKG-INFO
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: uv-launch
|
|
3
|
-
Version: 0.2.2
|
|
4
|
-
Summary: Immediate Python command startup with atomic background updates through uv
|
|
5
|
-
Requires-Dist: packaging>=24
|
|
6
|
-
Requires-Python: >=3.11
|
|
7
|
-
Project-URL: Repository, https://github.com/nimashoghi/uv-launch
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
|
|
10
|
-
# uv-launch
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
```sh
|
|
17
|
-
uv-launch install ruff ruff
|
|
18
|
-
uv-launch install my-command 'package-name @ git+https://github.com/owner/project.git@main' \
|
|
19
|
-
--entrypoint actual-console-script --with another-package --python 3.13
|
|
20
|
-
my-command --help
|
|
21
|
-
uv-launch update my-command
|
|
22
|
-
uv-launch status my-command
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
`--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`.
|
|
26
|
-
|
|
27
|
-
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.
|
|
28
|
-
|
|
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
|
-
|
|
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.
|
|
46
|
-
|
|
47
|
-
## Service startup and companion commands
|
|
48
|
-
|
|
49
|
-
A service can require the latest successfully resolved dependencies at startup:
|
|
50
|
-
|
|
51
|
-
```sh
|
|
52
|
-
uv-launch run --refresh my-command -- serve --port 8080
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
Unlike the ordinary background-updating shim, `run --refresh` waits for a successful update and fails without executing stale code when that update fails. It then replaces itself with the selected command. Arguments, environment, working directory and exit status are preserved. SIGTERM during refresh terminates and reaps the resolver/build process group; after execution the application receives signals directly. Old generations remain available for workers that outlive their launcher.
|
|
56
|
-
|
|
57
|
-
Run a companion executable from the same installation with `uv-launch run --command python my-command -- -c 'print("probe")'`. Options to `run` precede the installation name. For several operations that must use exactly the same generation, capture once and pass the returned opaque `generation` value:
|
|
58
|
-
|
|
59
|
-
```sh
|
|
60
|
-
uv-launch select --refresh my-command
|
|
61
|
-
uv-launch run --generation GENERATION --command python my-command -- -c 'print("probe")'
|
|
62
|
-
uv-launch run --generation GENERATION my-command -- serve
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
The Python equivalents are `select_installation(name, refresh_dependencies=True)` and `execute_installation(installation, arguments, command=None)` from `uv_launch`. `execute_installation` uses `exec`, so successful execution does not return. Consumers should use these interfaces instead of reading installation symlinks or reconstructing generation paths.
|
|
66
|
-
|
|
67
|
-
## Script runtimes and Python imports
|
|
68
|
-
|
|
69
|
-
`uv_launch.dependencies` provides shared preparation for tools such as toolfuncs:
|
|
70
|
-
|
|
71
|
-
- `prepare_environment(requirements, python=..., background=True)` returns a ready immutable virtual environment. Warm calls reuse a generation without foreground resolution; first use and changed requirements wait for preparation. It does not snapshot the caller's script.
|
|
72
|
-
- `resolve_dependencies(requirements, python=..., constraints=(), background=True)` returns an immutable pinned requirements file. Resolutions are cached by requirements, interpreter/platform and constraints.
|
|
73
|
-
- `install_dependencies(requirements)` adds missing packages to the **current interpreter's environment**. It reuses satisfying installed dependencies, constrains resolution to preserve existing distributions, checks the selected graph before installation, and raises `DependencyConflictError` if preservation is impossible. It never synchronizes the whole environment or replaces existing distributions. Background resolution refresh does not install into the active interpreter or reload modules.
|
|
74
|
-
- `managed_target(path)` statically resolves a generated uv-launch command to its installed executable. It validates the shim against its recorded installation without executing shell code, importing the application, or requesting an update. This lets discovery tools inspect package metadata behind managed commands.
|
|
75
|
-
|
|
76
|
-
The shared resolver accepts floating Git branches and records exact commit IDs in each cached resolution. No separate script registration or checked-in lockfile is required. A process already using an installed package retains that package; a new cached resolution is not a hot reload. Use a compatible environment when requirements conflict. Each installed source distribution is identified by its recorded origin or package version via `distribution_requirement(distribution)`.
|
|
77
|
-
|
|
78
|
-
## Codex-router example
|
|
79
|
-
|
|
80
|
-
All five packages are resolved in one operation, so a moving Git ref produces one consistent suite revision:
|
|
81
|
-
|
|
82
|
-
```sh
|
|
83
|
-
uv-launch install codexr \
|
|
84
|
-
'codex-router @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router' \
|
|
85
|
-
--with 'codex-router-sdk @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router-sdk' \
|
|
86
|
-
--with 'codex-monitor @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-monitor' \
|
|
87
|
-
--with 'codex-recovery @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-recovery' \
|
|
88
|
-
--with 'codex-accounts @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-accounts' \
|
|
89
|
-
--bin-dir ~/.local/share/codexr-evaluation/bin
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
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.
|
|
93
|
-
|
|
94
|
-
## Development
|
|
95
|
-
|
|
96
|
-
```sh
|
|
97
|
-
uv sync
|
|
98
|
-
uv run pytest -q
|
|
99
|
-
uv run ruff check .
|
|
100
|
-
uv run ruff format --check .
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Integration tests create local Git packages and exercise installation, immutable revisions, background publication, failed updates, concurrent checks, offline launches, and preservation of unrelated commands.
|
uv_launch-0.2.2/README.md
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
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/). 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
|
-
|
|
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. 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.
|
|
37
|
-
|
|
38
|
-
## Service startup and companion commands
|
|
39
|
-
|
|
40
|
-
A service can require the latest successfully resolved dependencies at startup:
|
|
41
|
-
|
|
42
|
-
```sh
|
|
43
|
-
uv-launch run --refresh my-command -- serve --port 8080
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Unlike the ordinary background-updating shim, `run --refresh` waits for a successful update and fails without executing stale code when that update fails. It then replaces itself with the selected command. Arguments, environment, working directory and exit status are preserved. SIGTERM during refresh terminates and reaps the resolver/build process group; after execution the application receives signals directly. Old generations remain available for workers that outlive their launcher.
|
|
47
|
-
|
|
48
|
-
Run a companion executable from the same installation with `uv-launch run --command python my-command -- -c 'print("probe")'`. Options to `run` precede the installation name. For several operations that must use exactly the same generation, capture once and pass the returned opaque `generation` value:
|
|
49
|
-
|
|
50
|
-
```sh
|
|
51
|
-
uv-launch select --refresh my-command
|
|
52
|
-
uv-launch run --generation GENERATION --command python my-command -- -c 'print("probe")'
|
|
53
|
-
uv-launch run --generation GENERATION my-command -- serve
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
The Python equivalents are `select_installation(name, refresh_dependencies=True)` and `execute_installation(installation, arguments, command=None)` from `uv_launch`. `execute_installation` uses `exec`, so successful execution does not return. Consumers should use these interfaces instead of reading installation symlinks or reconstructing generation paths.
|
|
57
|
-
|
|
58
|
-
## Script runtimes and Python imports
|
|
59
|
-
|
|
60
|
-
`uv_launch.dependencies` provides shared preparation for tools such as toolfuncs:
|
|
61
|
-
|
|
62
|
-
- `prepare_environment(requirements, python=..., background=True)` returns a ready immutable virtual environment. Warm calls reuse a generation without foreground resolution; first use and changed requirements wait for preparation. It does not snapshot the caller's script.
|
|
63
|
-
- `resolve_dependencies(requirements, python=..., constraints=(), background=True)` returns an immutable pinned requirements file. Resolutions are cached by requirements, interpreter/platform and constraints.
|
|
64
|
-
- `install_dependencies(requirements)` adds missing packages to the **current interpreter's environment**. It reuses satisfying installed dependencies, constrains resolution to preserve existing distributions, checks the selected graph before installation, and raises `DependencyConflictError` if preservation is impossible. It never synchronizes the whole environment or replaces existing distributions. Background resolution refresh does not install into the active interpreter or reload modules.
|
|
65
|
-
- `managed_target(path)` statically resolves a generated uv-launch command to its installed executable. It validates the shim against its recorded installation without executing shell code, importing the application, or requesting an update. This lets discovery tools inspect package metadata behind managed commands.
|
|
66
|
-
|
|
67
|
-
The shared resolver accepts floating Git branches and records exact commit IDs in each cached resolution. No separate script registration or checked-in lockfile is required. A process already using an installed package retains that package; a new cached resolution is not a hot reload. Use a compatible environment when requirements conflict. Each installed source distribution is identified by its recorded origin or package version via `distribution_requirement(distribution)`.
|
|
68
|
-
|
|
69
|
-
## Codex-router example
|
|
70
|
-
|
|
71
|
-
All five packages are resolved in one operation, so a moving Git ref produces one consistent suite revision:
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
uv-launch install codexr \
|
|
75
|
-
'codex-router @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router' \
|
|
76
|
-
--with 'codex-router-sdk @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-router-sdk' \
|
|
77
|
-
--with 'codex-monitor @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-monitor' \
|
|
78
|
-
--with 'codex-recovery @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-recovery' \
|
|
79
|
-
--with 'codex-accounts @ git+https://github.com/nimashoghi/codex-router.git@main#subdirectory=packages/codex-accounts' \
|
|
80
|
-
--bin-dir ~/.local/share/codexr-evaluation/bin
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
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.
|
|
84
|
-
|
|
85
|
-
## Development
|
|
86
|
-
|
|
87
|
-
```sh
|
|
88
|
-
uv sync
|
|
89
|
-
uv run pytest -q
|
|
90
|
-
uv run ruff check .
|
|
91
|
-
uv run ruff format --check .
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Integration tests create local Git packages and exercise installation, immutable revisions, background publication, failed updates, concurrent checks, offline launches, and preservation of unrelated commands.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|