auto-self-update 0.9.0__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.
@@ -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,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,83 @@
1
+ # self-update
2
+
3
+ Single-file, zero-dependency self-update on launch for Python CLIs.
4
+ Works on Windows, Linux, and macOS with pip, pipx, and uv
5
+ installations.
6
+
7
+ ## Usage
8
+
9
+ Install it, then add two lines at the very top of your entry point,
10
+ **before** any setup, try/finally, or UI:
11
+
12
+ ```
13
+ pip install auto-self-update
14
+ ```
15
+
16
+ ```python
17
+ import selfupdate
18
+
19
+ def main():
20
+ if selfupdate.check("my-package"):
21
+ return # the updater is running; it relaunches this command
22
+ # ... your normal startup ...
23
+ ```
24
+
25
+ `"my-package"` is your app's PyPI/distribution name. That is all.
26
+ Alternatively, vendor `selfupdate.py` into your project — it has no
27
+ dependencies beyond the standard library.
28
+
29
+ ## What it does
30
+
31
+ 1. On launch, compares the installed version against the newest stable
32
+ release on PyPI (4 s hard timeout; any failure launches normally).
33
+ 2. A newer release spawns a detached updater and your process exits.
34
+ Exiting first is deliberate: Windows locks a running process's
35
+ executables, and this flow works identically everywhere.
36
+ 3. The updater waits for your process to disappear, runs the
37
+ installer-native upgrade command (`uv tool upgrade`, `pipx upgrade`,
38
+ or the environment's own pip), verifies the installed version
39
+ actually advanced, and re-executes the original command with its
40
+ arguments.
41
+ 4. Any failure prints the manual command and leaves the old install
42
+ untouched — the updater never relaunches a failed upgrade.
43
+
44
+ ## Skipped automatically
45
+
46
+ - **Repository checkouts and editable installs** (found by walking up
47
+ from the caller's file to a `pyproject.toml`; the walk stops at
48
+ `site-packages`).
49
+ - **Ephemeral `uvx` / `pipx run` caches** — a one-line note is printed
50
+ instead; those refresh on their own schedule.
51
+ - **Kill switches**: pass `enabled=False` (wire your own config toggle
52
+ to it), or set `NO_AUTO_UPDATE` / `<PACKAGE>_NO_AUTO_UPDATE`
53
+ (uppercased, non-alphanumerics become underscores) in the environment.
54
+
55
+ ## Optional parameters
56
+
57
+ ```python
58
+ selfupdate.check(
59
+ "my-package",
60
+ enabled=True, # your own config toggle goes here
61
+ index_url=None, # JSON index template with {package}; PyPI default
62
+ )
63
+ ```
64
+
65
+ ## Caveats
66
+
67
+ - Only dotted-numeric releases (`2.0.8`) are update targets;
68
+ pre-releases are never chased.
69
+ - The upgrade command inherits the terminal, so its progress output is
70
+ visible; the updater is detached from Ctrl+C delivery so an
71
+ interruption cannot break a half-written install.
72
+ - Two instances updating simultaneously serialize on the installer's
73
+ own lock; the second finds nothing to do.
74
+ - Keep your package's `__init__.py` light — good practice anyway; the
75
+ updater runs this module by file path and never imports your package.
76
+
77
+ ## Tests
78
+
79
+ `python test_selfupdate.py` — 39 offline tests, no network, no spawn.
80
+
81
+ ## License
82
+
83
+ MIT — see [LICENSE](LICENSE).
@@ -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,8 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ selfupdate.py
5
+ auto_self_update.egg-info/PKG-INFO
6
+ auto_self_update.egg-info/SOURCES.txt
7
+ auto_self_update.egg-info/dependency_links.txt
8
+ auto_self_update.egg-info/top_level.txt
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "auto-self-update"
7
+ version = "0.9.0"
8
+ description = "Single-file, zero-dependency self-update on launch for Python CLIs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Julynx", email = "julicabria@outlook.com" }
15
+ ]
16
+ keywords = ["self-update", "updater", "cli", "pip", "pipx", "uv"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Console",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Software Development",
29
+ "Topic :: Utilities",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/Julynx/self-update"
34
+ Repository = "https://github.com/Julynx/self-update"
35
+ Issues = "https://github.com/Julynx/self-update/issues"
36
+
37
+ [tool.setuptools]
38
+ py-modules = ["selfupdate"]
@@ -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))
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+