auto-self-update 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auto-self-update
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Single-file, zero-dependency self-update on launch for Python CLIs.
|
|
5
|
+
Author-email: Julynx <julicabria@outlook.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Julynx/self-update
|
|
8
|
+
Project-URL: Repository, https://github.com/Julynx/self-update
|
|
9
|
+
Project-URL: Issues, https://github.com/Julynx/self-update/issues
|
|
10
|
+
Keywords: self-update,updater,cli,pip,pipx,uv
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# self-update
|
|
29
|
+
|
|
30
|
+
Single-file, zero-dependency self-update on launch for Python CLIs.
|
|
31
|
+
Works on Windows, Linux, and macOS with pip, pipx, and uv
|
|
32
|
+
installations.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Install it, then add two lines at the very top of your entry point,
|
|
37
|
+
**before** any setup, try/finally, or UI:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install auto-self-update
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import selfupdate
|
|
45
|
+
|
|
46
|
+
def main():
|
|
47
|
+
if selfupdate.check("my-package"):
|
|
48
|
+
return # the updater is running; it relaunches this command
|
|
49
|
+
# ... your normal startup ...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`"my-package"` is your app's PyPI/distribution name. That is all.
|
|
53
|
+
Alternatively, vendor `selfupdate.py` into your project — it has no
|
|
54
|
+
dependencies beyond the standard library.
|
|
55
|
+
|
|
56
|
+
## What it does
|
|
57
|
+
|
|
58
|
+
1. On launch, compares the installed version against the newest stable
|
|
59
|
+
release on PyPI (4 s hard timeout; any failure launches normally).
|
|
60
|
+
2. A newer release spawns a detached updater and your process exits.
|
|
61
|
+
Exiting first is deliberate: Windows locks a running process's
|
|
62
|
+
executables, and this flow works identically everywhere.
|
|
63
|
+
3. The updater waits for your process to disappear, runs the
|
|
64
|
+
installer-native upgrade command (`uv tool upgrade`, `pipx upgrade`,
|
|
65
|
+
or the environment's own pip), verifies the installed version
|
|
66
|
+
actually advanced, and re-executes the original command with its
|
|
67
|
+
arguments.
|
|
68
|
+
4. Any failure prints the manual command and leaves the old install
|
|
69
|
+
untouched — the updater never relaunches a failed upgrade.
|
|
70
|
+
|
|
71
|
+
## Skipped automatically
|
|
72
|
+
|
|
73
|
+
- **Repository checkouts and editable installs** (found by walking up
|
|
74
|
+
from the caller's file to a `pyproject.toml`; the walk stops at
|
|
75
|
+
`site-packages`).
|
|
76
|
+
- **Ephemeral `uvx` / `pipx run` caches** — a one-line note is printed
|
|
77
|
+
instead; those refresh on their own schedule.
|
|
78
|
+
- **Kill switches**: pass `enabled=False` (wire your own config toggle
|
|
79
|
+
to it), or set `NO_AUTO_UPDATE` / `<PACKAGE>_NO_AUTO_UPDATE`
|
|
80
|
+
(uppercased, non-alphanumerics become underscores) in the environment.
|
|
81
|
+
|
|
82
|
+
## Optional parameters
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
selfupdate.check(
|
|
86
|
+
"my-package",
|
|
87
|
+
enabled=True, # your own config toggle goes here
|
|
88
|
+
index_url=None, # JSON index template with {package}; PyPI default
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Caveats
|
|
93
|
+
|
|
94
|
+
- Only dotted-numeric releases (`2.0.8`) are update targets;
|
|
95
|
+
pre-releases are never chased.
|
|
96
|
+
- The upgrade command inherits the terminal, so its progress output is
|
|
97
|
+
visible; the updater is detached from Ctrl+C delivery so an
|
|
98
|
+
interruption cannot break a half-written install.
|
|
99
|
+
- Two instances updating simultaneously serialize on the installer's
|
|
100
|
+
own lock; the second finds nothing to do.
|
|
101
|
+
- Keep your package's `__init__.py` light — good practice anyway; the
|
|
102
|
+
updater runs this module by file path and never imports your package.
|
|
103
|
+
|
|
104
|
+
## Tests
|
|
105
|
+
|
|
106
|
+
`python test_selfupdate.py` — 39 offline tests, no network, no spawn.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
selfupdate.py,sha256=i3h31N7wB4I_pvMNtxz-IeB-Lx8E_tvbhIOoNDwL008,15142
|
|
2
|
+
auto_self_update-0.9.0.dist-info/licenses/LICENSE,sha256=TPOsSTKrCT2kvvUzstUISemTH0OuT6LAzEi-tmq8myc,1063
|
|
3
|
+
auto_self_update-0.9.0.dist-info/METADATA,sha256=qxZWX4Z4iq-yxxuFteSxcqBZpCyje0KvY_2dCkJofTc,4060
|
|
4
|
+
auto_self_update-0.9.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
auto_self_update-0.9.0.dist-info/top_level.txt,sha256=fZbvj1F_30F7wwCayWHHoqgOf8b8aPHX3dXq97FoPns,11
|
|
6
|
+
auto_self_update-0.9.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julynx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
selfupdate
|
selfupdate.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Drop-in launch-time self-update for pip, pipx, and uv-installed CLIs.
|
|
2
|
+
|
|
3
|
+
Vendor this single file into any project (it has no dependencies beyond
|
|
4
|
+
the standard library) and add two lines at the very top of the entry
|
|
5
|
+
point, before any setup, try/finally, or UI:
|
|
6
|
+
|
|
7
|
+
from . import selfupdate
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
if selfupdate.check("my-package"):
|
|
11
|
+
return # the updater is running; it relaunches this command
|
|
12
|
+
|
|
13
|
+
How it works: `check` compares the installed version against the newest
|
|
14
|
+
stable release on PyPI (hard timeout, fails open). A newer release
|
|
15
|
+
spawns a detached updater process and the caller must exit immediately:
|
|
16
|
+
Windows locks a running process's executables, so exiting before
|
|
17
|
+
upgrading is the one flow that works identically on every platform. The
|
|
18
|
+
updater waits for the caller to disappear, runs the installer-native
|
|
19
|
+
upgrade command (uv receipt -> `uv tool upgrade`, pipx metadata ->
|
|
20
|
+
`pipx upgrade`, else the environment's own pip, else `uv pip`), verifies
|
|
21
|
+
the installed version actually advanced, and re-executes the original
|
|
22
|
+
command with its arguments. Any failure prints the manual command and
|
|
23
|
+
leaves the old installation untouched.
|
|
24
|
+
|
|
25
|
+
Skipped automatically: repository checkouts and editable installs
|
|
26
|
+
(detected by walking up from the caller's file), ephemeral `uvx` /
|
|
27
|
+
`pipx run` caches (a one-line note is printed instead), and any failure.
|
|
28
|
+
Kill switches: `enabled=False`, or the environment variables
|
|
29
|
+
`NO_AUTO_UPDATE` and `<PACKAGE>_NO_AUTO_UPDATE` (uppercased,
|
|
30
|
+
non-alphanumerics replaced by underscores).
|
|
31
|
+
|
|
32
|
+
Keep the host package's `__init__` light: the updater runs this module
|
|
33
|
+
by file path precisely so it never imports the host package, and the
|
|
34
|
+
launch-time check imports nothing beyond the standard library.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import importlib.util
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import shutil
|
|
43
|
+
import subprocess
|
|
44
|
+
import sys
|
|
45
|
+
import time
|
|
46
|
+
import urllib.request
|
|
47
|
+
from importlib import metadata
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger("selfupdate")
|
|
51
|
+
|
|
52
|
+
PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json"
|
|
53
|
+
CHECK_TIMEOUT_S = 4.0
|
|
54
|
+
UPGRADE_TIMEOUT_S = 180
|
|
55
|
+
PARENT_WAIT_TIMEOUT_S = 30.0
|
|
56
|
+
EXECUTABLE_SUFFIXES = (".exe", ".bat", ".cmd")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check(
|
|
60
|
+
package: str,
|
|
61
|
+
*,
|
|
62
|
+
enabled: bool = True,
|
|
63
|
+
index_url: str | None = None,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""Update from PyPI when a newer stable release exists.
|
|
66
|
+
|
|
67
|
+
`package` is the PyPI/distribution name. `enabled` lets the host
|
|
68
|
+
project wire its own configuration toggle. `index_url` overrides the
|
|
69
|
+
JSON index URL template (must contain `{package}`); PyPI is the
|
|
70
|
+
default.
|
|
71
|
+
|
|
72
|
+
Returns True only when an updater was spawned: the caller must then
|
|
73
|
+
exit immediately, before touching its normal work. Every other
|
|
74
|
+
outcome (gated, up to date, check failed, no installer) returns
|
|
75
|
+
False and the launch proceeds. This function never raises.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
caller_file = sys._getframe(1).f_globals.get("__file__")
|
|
79
|
+
skip = _skip_reason(package, enabled, caller_file)
|
|
80
|
+
if skip:
|
|
81
|
+
logger.debug("Self-update skipped: %s", skip)
|
|
82
|
+
return False
|
|
83
|
+
installed = _installed_version(package)
|
|
84
|
+
if not installed:
|
|
85
|
+
return False
|
|
86
|
+
latest = _latest_version(package, index_url)
|
|
87
|
+
if not latest or not _is_newer(latest, installed):
|
|
88
|
+
return False
|
|
89
|
+
runner = _ephemeral_note(Path(sys.prefix))
|
|
90
|
+
if runner:
|
|
91
|
+
print(
|
|
92
|
+
f"\n + {package} {latest} is available (this {runner} run uses"
|
|
93
|
+
" a cached copy that refreshes on its own schedule).\n",
|
|
94
|
+
flush=True,
|
|
95
|
+
)
|
|
96
|
+
return False
|
|
97
|
+
command = _upgrade_command(package)
|
|
98
|
+
if command is None:
|
|
99
|
+
print(
|
|
100
|
+
f"\n ! {package} {latest} is available, but no supported"
|
|
101
|
+
f" installer was found.\n Update manually with:"
|
|
102
|
+
f" pip install --upgrade {package}\n",
|
|
103
|
+
flush=True,
|
|
104
|
+
)
|
|
105
|
+
return False
|
|
106
|
+
payload = {
|
|
107
|
+
"pid": os.getpid(),
|
|
108
|
+
"package": package,
|
|
109
|
+
"command": command,
|
|
110
|
+
"argv": relaunch_argv(Path(sys.argv[0]).stem),
|
|
111
|
+
"previous": installed,
|
|
112
|
+
}
|
|
113
|
+
if not _spawn_updater(payload):
|
|
114
|
+
print(
|
|
115
|
+
f"\n ! Could not start the updater. Update manually with:"
|
|
116
|
+
f" {' '.join(command)}\n",
|
|
117
|
+
flush=True,
|
|
118
|
+
)
|
|
119
|
+
return False
|
|
120
|
+
print(
|
|
121
|
+
f"\n + Updating {package} {installed} -> {latest};"
|
|
122
|
+
" it will relaunch automatically.\n",
|
|
123
|
+
flush=True,
|
|
124
|
+
)
|
|
125
|
+
return True
|
|
126
|
+
except Exception:
|
|
127
|
+
logger.exception(
|
|
128
|
+
"Self-update check failed; continuing with the installed version"
|
|
129
|
+
)
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _installed_version(package: str) -> str | None:
|
|
134
|
+
"""The installed distribution version, or None outside a real install."""
|
|
135
|
+
try:
|
|
136
|
+
return metadata.version(package)
|
|
137
|
+
except metadata.PackageNotFoundError:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _version_key(version: str) -> tuple:
|
|
142
|
+
"""Dotted-numeric sort key; a bare release outranks its prereleases."""
|
|
143
|
+
key = []
|
|
144
|
+
for piece in version.strip().split("."):
|
|
145
|
+
match = re.match(r"^(\d+)(.*)$", piece)
|
|
146
|
+
if match:
|
|
147
|
+
number, suffix = match.group(1), match.group(2)
|
|
148
|
+
key.append((1, int(number), suffix == "", suffix))
|
|
149
|
+
else:
|
|
150
|
+
key.append((0, 0, False, piece))
|
|
151
|
+
return tuple(key)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_stable_release(version: str) -> bool:
|
|
155
|
+
"""Only dotted-numeric releases are auto-update targets."""
|
|
156
|
+
return bool(re.fullmatch(r"\d+(\.\d+)*", version.strip()))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_newer(candidate: str, installed: str) -> bool:
|
|
160
|
+
"""Whether the candidate strictly outranks the installed version."""
|
|
161
|
+
return _version_key(candidate) > _version_key(installed)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _latest_version(package: str, index_url: str | None) -> str | None:
|
|
165
|
+
"""The newest stable version on the index, or None on any failure.
|
|
166
|
+
|
|
167
|
+
The check runs on the launch path, so it fails open and fast: a slow
|
|
168
|
+
or unreachable index must never hold the user's launch hostage.
|
|
169
|
+
"""
|
|
170
|
+
url = (index_url or PYPI_JSON_URL).format(package=package)
|
|
171
|
+
try:
|
|
172
|
+
with urllib.request.urlopen(url, timeout=CHECK_TIMEOUT_S) as response:
|
|
173
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
174
|
+
version = data.get("info", {}).get("version")
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
logger.debug("Update check failed: %s", exc)
|
|
177
|
+
return None
|
|
178
|
+
if not isinstance(version, str) or not _is_stable_release(version):
|
|
179
|
+
return None
|
|
180
|
+
return version
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _kill_switch_env(package: str) -> str:
|
|
184
|
+
"""The per-package opt-out variable, e.g. MY_TOOL_NO_AUTO_UPDATE."""
|
|
185
|
+
return re.sub(r"[^A-Z0-9]", "_", package.upper()) + "_NO_AUTO_UPDATE"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _checkout_root(caller_file: str | None) -> Path | None:
|
|
189
|
+
"""The repository root when the caller runs from a checkout.
|
|
190
|
+
|
|
191
|
+
Walking up from the calling module finds a pyproject.toml within a
|
|
192
|
+
few hops in a source checkout (src or flat layout), while an
|
|
193
|
+
installed package's walk hits its site-packages boundary first.
|
|
194
|
+
Editable installs keep their code in the checkout, so they count as
|
|
195
|
+
checkouts too.
|
|
196
|
+
"""
|
|
197
|
+
if not caller_file:
|
|
198
|
+
return None
|
|
199
|
+
path = Path(caller_file).resolve().parent
|
|
200
|
+
for _ in range(4):
|
|
201
|
+
if path.name in ("site-packages", "dist-packages"):
|
|
202
|
+
return None
|
|
203
|
+
if (path / "pyproject.toml").is_file():
|
|
204
|
+
return path
|
|
205
|
+
if path.parent == path:
|
|
206
|
+
return None
|
|
207
|
+
path = path.parent
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _skip_reason(package: str, enabled: bool, caller_file: str | None) -> str | None:
|
|
212
|
+
"""Why the update check must not run, or None when it may."""
|
|
213
|
+
if os.environ.get("NO_AUTO_UPDATE") or os.environ.get(_kill_switch_env(package)):
|
|
214
|
+
return "disabled by environment"
|
|
215
|
+
if not enabled:
|
|
216
|
+
return "disabled by the caller"
|
|
217
|
+
checkout = _checkout_root(caller_file)
|
|
218
|
+
if checkout:
|
|
219
|
+
return f"running from a checkout ({checkout})"
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _ephemeral_note(prefix: Path) -> str | None:
|
|
224
|
+
"""The ephemeral-runner name for a cache-resident prefix, else None."""
|
|
225
|
+
parts = [part.lower() for part in prefix.parts]
|
|
226
|
+
if "uv" in parts and any(part.startswith("environments") for part in parts):
|
|
227
|
+
return "uvx"
|
|
228
|
+
if "pipx" in parts and "cache" in parts:
|
|
229
|
+
return "pipx run"
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _pip_available() -> bool:
|
|
234
|
+
"""Whether this environment can run pip (uv tool venvs ship without it)."""
|
|
235
|
+
return importlib.util.find_spec("pip") is not None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _upgrade_command(package: str) -> list[str] | None:
|
|
239
|
+
"""The installer-native upgrade command for this environment.
|
|
240
|
+
|
|
241
|
+
pipx and uv tool installs are detected by the receipt each manager
|
|
242
|
+
writes into the environment; their own upgrade commands replay the
|
|
243
|
+
recorded install options. A plain environment falls back to its pip,
|
|
244
|
+
or to uv pip when it has no pip of its own.
|
|
245
|
+
"""
|
|
246
|
+
prefix = Path(sys.prefix)
|
|
247
|
+
if (prefix / "uv-receipt.toml").is_file():
|
|
248
|
+
uv = shutil.which("uv")
|
|
249
|
+
return [uv, "tool", "upgrade", package] if uv else None
|
|
250
|
+
if (prefix / "pipx_metadata.json").is_file():
|
|
251
|
+
pipx = shutil.which("pipx")
|
|
252
|
+
if pipx:
|
|
253
|
+
return [pipx, "upgrade", package]
|
|
254
|
+
if _pip_available():
|
|
255
|
+
return [sys.executable, "-m", "pip", "install", "--upgrade", package]
|
|
256
|
+
uv = shutil.which("uv")
|
|
257
|
+
if uv:
|
|
258
|
+
return [
|
|
259
|
+
uv,
|
|
260
|
+
"pip",
|
|
261
|
+
"install",
|
|
262
|
+
"--python",
|
|
263
|
+
sys.executable,
|
|
264
|
+
"--upgrade",
|
|
265
|
+
package,
|
|
266
|
+
]
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def relaunch_argv(entry_point: str, argv: list[str] | None = None) -> list[str]:
|
|
271
|
+
"""Build the argv that re-executes the given entry point.
|
|
272
|
+
|
|
273
|
+
Preference order: the current entry point (an entry-point executable
|
|
274
|
+
is re-executed directly, a script through the current interpreter),
|
|
275
|
+
the globally installed shim (any PATH entry outside the active
|
|
276
|
+
environment), `uv run` for checkouts without a resolvable entry
|
|
277
|
+
point, and finally the current interpreter with the original
|
|
278
|
+
arguments.
|
|
279
|
+
"""
|
|
280
|
+
argv = list(sys.argv if argv is None else argv)
|
|
281
|
+
argv0 = Path(argv[0])
|
|
282
|
+
candidate = argv0 if argv0.is_file() else argv0.with_suffix(".exe")
|
|
283
|
+
if candidate.is_file():
|
|
284
|
+
if candidate.suffix.lower() in EXECUTABLE_SUFFIXES:
|
|
285
|
+
return [str(candidate), *argv[1:]]
|
|
286
|
+
return [sys.executable, str(candidate), *argv[1:]]
|
|
287
|
+
venv_root = Path(sys.prefix).resolve()
|
|
288
|
+
found = shutil.which(entry_point)
|
|
289
|
+
if found and not Path(found).resolve().is_relative_to(venv_root):
|
|
290
|
+
return [found, *argv[1:]]
|
|
291
|
+
uv = shutil.which("uv")
|
|
292
|
+
if uv:
|
|
293
|
+
return [uv, "run", entry_point, *argv[1:]]
|
|
294
|
+
return [sys.executable, str(argv0), *argv[1:]]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _updater_path() -> list[str]:
|
|
298
|
+
"""How to execute this module standalone: file path, or -m fallback.
|
|
299
|
+
|
|
300
|
+
Running by file path keeps the updater free of the host package's
|
|
301
|
+
import-time cost and failure surface; the -m form covers zipimports
|
|
302
|
+
and frozen apps, where __file__ does not exist on disk.
|
|
303
|
+
"""
|
|
304
|
+
module_file = globals().get("__file__")
|
|
305
|
+
if module_file and Path(module_file).is_file():
|
|
306
|
+
return [str(Path(module_file).resolve())]
|
|
307
|
+
spec_name = getattr(globals().get("__spec__"), "name", None) or __name__
|
|
308
|
+
return ["-m", spec_name]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _spawn_updater(payload: dict) -> bool:
|
|
312
|
+
"""Start the detached updater process; False when it cannot start.
|
|
313
|
+
|
|
314
|
+
The updater is removed from the terminal's interrupt delivery (its
|
|
315
|
+
own session on POSIX, its own process group on Windows): a Ctrl+C
|
|
316
|
+
meant for the exiting launcher must never reach it mid-upgrade,
|
|
317
|
+
where an interrupted install can leave the environment broken.
|
|
318
|
+
"""
|
|
319
|
+
kwargs: dict = {}
|
|
320
|
+
if sys.platform == "win32":
|
|
321
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
322
|
+
else:
|
|
323
|
+
kwargs["start_new_session"] = True
|
|
324
|
+
try:
|
|
325
|
+
subprocess.Popen(
|
|
326
|
+
[sys.executable, *_updater_path(), json.dumps(payload)], **kwargs
|
|
327
|
+
)
|
|
328
|
+
return True
|
|
329
|
+
except OSError as exc:
|
|
330
|
+
logger.warning("Could not start the updater: %s", exc)
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _pid_alive(pid: int) -> bool:
|
|
335
|
+
"""Whether a process with this PID exists (best-effort, cross-platform)."""
|
|
336
|
+
if sys.platform == "win32":
|
|
337
|
+
import ctypes
|
|
338
|
+
|
|
339
|
+
handle = ctypes.windll.kernel32.OpenProcess(0x00100000, False, pid)
|
|
340
|
+
if not handle:
|
|
341
|
+
return False
|
|
342
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
343
|
+
return True
|
|
344
|
+
try:
|
|
345
|
+
os.kill(pid, 0)
|
|
346
|
+
except ProcessLookupError:
|
|
347
|
+
return False
|
|
348
|
+
except PermissionError:
|
|
349
|
+
return True
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _wait_for_parent(pid: int, timeout_s: float = PARENT_WAIT_TIMEOUT_S) -> None:
|
|
354
|
+
"""Block until the launching process exits.
|
|
355
|
+
|
|
356
|
+
The upgrade must never touch files a running instance still holds
|
|
357
|
+
open: Windows refuses to replace a live process's executables and
|
|
358
|
+
native modules. A timed-out wait proceeds anyway: the upgrade
|
|
359
|
+
command then fails on its own terms and the updater reports it.
|
|
360
|
+
"""
|
|
361
|
+
deadline = time.monotonic() + timeout_s
|
|
362
|
+
while _pid_alive(pid) and time.monotonic() < deadline:
|
|
363
|
+
time.sleep(0.1)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _relaunch(argv: list[str]) -> None:
|
|
367
|
+
"""Hand the terminal to a fresh process running the updated version."""
|
|
368
|
+
if sys.platform == "win32":
|
|
369
|
+
sys.exit(subprocess.call(argv))
|
|
370
|
+
os.execv(argv[0], argv)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _updater_main(argv: list[str]) -> int:
|
|
374
|
+
"""Updater entry point: wait for the parent, upgrade, verify, relaunch."""
|
|
375
|
+
try:
|
|
376
|
+
payload = json.loads(argv[1])
|
|
377
|
+
pid = int(payload["pid"])
|
|
378
|
+
package = payload["package"]
|
|
379
|
+
command = payload["command"]
|
|
380
|
+
previous = payload["previous"]
|
|
381
|
+
relaunch = payload["argv"]
|
|
382
|
+
except (IndexError, ValueError, KeyError, TypeError):
|
|
383
|
+
print(" ! Updater called without a valid payload.", flush=True)
|
|
384
|
+
return 2
|
|
385
|
+
_wait_for_parent(pid)
|
|
386
|
+
print(f" + Running: {' '.join(command)}", flush=True)
|
|
387
|
+
try:
|
|
388
|
+
completed = subprocess.run(command, check=False, timeout=UPGRADE_TIMEOUT_S)
|
|
389
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
390
|
+
print(f" ! The update could not run: {exc}", flush=True)
|
|
391
|
+
print(f" Update manually with: pip install --upgrade {package}", flush=True)
|
|
392
|
+
return 1
|
|
393
|
+
if completed.returncode != 0:
|
|
394
|
+
print(
|
|
395
|
+
f" ! The update command failed (exit {completed.returncode}).", flush=True
|
|
396
|
+
)
|
|
397
|
+
print(f" Update manually with: pip install --upgrade {package}", flush=True)
|
|
398
|
+
return 1
|
|
399
|
+
installed = _installed_version(package)
|
|
400
|
+
if not installed or not _is_newer(installed, previous):
|
|
401
|
+
print(
|
|
402
|
+
f" ! The update did not land (still"
|
|
403
|
+
f" {installed or 'unknown'}); not relaunching.",
|
|
404
|
+
flush=True,
|
|
405
|
+
)
|
|
406
|
+
return 1
|
|
407
|
+
print(f" + Updated to {installed}; relaunching.\n", flush=True)
|
|
408
|
+
_relaunch(relaunch)
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
sys.exit(_updater_main(sys.argv))
|