pyselfupdate 0.5.4__tar.gz → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyselfupdate
3
- Version: 0.5.4
3
+ Version: 0.6.0
4
4
  Summary: Self-update and update notification for Python CLIs installed with uv tool
5
5
  Project-URL: Repository, https://github.com/datapointchris/pyselfupdate
6
6
  Project-URL: Issues, https://github.com/datapointchris/pyselfupdate/issues
@@ -143,8 +143,8 @@ fail in ways that are hard to read.
143
143
 
144
144
  So make `update` the last thing the process does, then call `exit_now`. Or call
145
145
  `update_and_reexec`, which replaces the process with the release it installed,
146
- running the same command line. Its caller never sees the `Result`, so it writes
147
- `lock_warning` to stderr itself before the re-exec.
146
+ running the same command line. `update_and_reexec` writes `lock_warning` to
147
+ stderr itself before the re-exec, because its caller never sees the `Result`.
148
148
 
149
149
  `check` reports what `update` would install, and installs nothing:
150
150
 
@@ -203,6 +203,8 @@ caller matches a type rather than message text:
203
203
  | `NotInstalledError` | uv's receipt is missing or unreadable, or holds a `--with` requirement an update would drop |
204
204
  | `NoReleaseError` | The source publishes no release, or its newest tag is not a semantic version |
205
205
  | `SourceError` | The source could not be reached, or answered with something unusable |
206
+ | `SelfUpdateSourceFailure` | A `SourceError` from a transport or protocol failure, as opposed to a missing release |
207
+ | `RateLimitedError` | A `SelfUpdateSourceFailure` for a spent rate limit. `authenticated` says which ceiling, and `resets_at` when it lifts, or None |
206
208
  | `LockUnreadableError` | The release's `uv.lock` could not be read, so nothing was installed |
207
209
  | `InstallFailedError` | `uv tool install` ran and failed, or uv is not on `PATH` |
208
210
 
@@ -120,8 +120,8 @@ fail in ways that are hard to read.
120
120
 
121
121
  So make `update` the last thing the process does, then call `exit_now`. Or call
122
122
  `update_and_reexec`, which replaces the process with the release it installed,
123
- running the same command line. Its caller never sees the `Result`, so it writes
124
- `lock_warning` to stderr itself before the re-exec.
123
+ running the same command line. `update_and_reexec` writes `lock_warning` to
124
+ stderr itself before the re-exec, because its caller never sees the `Result`.
125
125
 
126
126
  `check` reports what `update` would install, and installs nothing:
127
127
 
@@ -180,6 +180,8 @@ caller matches a type rather than message text:
180
180
  | `NotInstalledError` | uv's receipt is missing or unreadable, or holds a `--with` requirement an update would drop |
181
181
  | `NoReleaseError` | The source publishes no release, or its newest tag is not a semantic version |
182
182
  | `SourceError` | The source could not be reached, or answered with something unusable |
183
+ | `SelfUpdateSourceFailure` | A `SourceError` from a transport or protocol failure, as opposed to a missing release |
184
+ | `RateLimitedError` | A `SelfUpdateSourceFailure` for a spent rate limit. `authenticated` says which ceiling, and `resets_at` when it lifts, or None |
183
185
  | `LockUnreadableError` | The release's `uv.lock` could not be read, so nothing was installed |
184
186
  | `InstallFailedError` | `uv tool install` ran and failed, or uv is not on `PATH` |
185
187
 
@@ -42,7 +42,7 @@ dev = [
42
42
  "mypy>=1.10.0",
43
43
  "pre-commit>=4.3.0",
44
44
  "pytest>=8.0.0",
45
- "ruff==0.12.5",
45
+ "ruff==0.17.1",
46
46
  "typer>=0.12.0",
47
47
  ]
48
48
 
@@ -95,6 +95,7 @@ exclude = [
95
95
  "build",
96
96
  "dist",
97
97
  ]
98
+ extend-exclude = ["*.md"]
98
99
 
99
100
  [tool.ruff.format]
100
101
  quote-style = "single"
@@ -158,12 +159,28 @@ major_on_zero = false
158
159
  typeCheckingMode = "standard"
159
160
  analyzeUnannotatedFunctions = true # or pylance stops analyzing in vscode
160
161
 
162
+ [tool.hatch.version]
163
+ source = "uv-dynamic-versioning"
164
+
165
+ [tool.uv-dynamic-versioning]
166
+ style = "semver"
167
+
168
+ # A new commit or tag changes the version, so it has to invalidate uv's cache.
169
+ [tool.uv]
170
+ cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
171
+
172
+ # The published sdist holds the package and its metadata, and none of the
173
+ # repo's tooling or tests. hatchling's default takes the whole tree.
174
+ [tool.hatch.build.targets.sdist]
175
+ only-include = ["src"]
176
+
161
177
  [tool.forge]
162
178
  # Keys the shared toolchain standard owns, written by its generator.
163
179
  # Dropping one from the template removes it here on the next sync; a key absent
164
180
  # from this list belongs to the project and is never touched. Do not hand-edit.
165
181
  managed = [
166
182
  ["ruff", "line-length"],
183
+ ["ruff", "extend-exclude"],
167
184
  ["ruff", "format", "quote-style"],
168
185
  ["ruff", "format", "indent-style"],
169
186
  ["ruff", "format", "skip-magic-trailing-comma"],
@@ -197,24 +214,6 @@ managed = [
197
214
  # and extra that names them, so a local run and CI cannot disagree.
198
215
  pinned = ["ruff"]
199
216
 
200
- # The release tag is the version. python-semantic-release finds no file to
201
- # stamp, so it tags the merge commit itself and writes no release commit.
202
- # uv_build cannot do this: its metadata has no dynamic version field.
203
- [tool.hatch.version]
204
- source = "uv-dynamic-versioning"
205
-
206
- [tool.uv-dynamic-versioning]
207
- style = "semver"
208
-
209
- # A new commit or tag changes the version, so it has to invalidate uv's cache.
210
- [tool.uv]
211
- cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
212
-
213
- # The published sdist holds the package and its metadata, and none of the
214
- # repo's tooling or tests, as uv_build's did.
215
- [tool.hatch.build.targets.sdist]
216
- only-include = ["src"]
217
-
218
217
  [build-system]
219
218
  requires = ["hatchling", "uv-dynamic-versioning"]
220
219
  build-backend = "hatchling.build"
@@ -24,7 +24,9 @@ from pyselfupdate.errors import LocalInstallError
24
24
  from pyselfupdate.errors import LockUnreadableError
25
25
  from pyselfupdate.errors import NoReleaseError
26
26
  from pyselfupdate.errors import NotInstalledError
27
+ from pyselfupdate.errors import RateLimitedError
27
28
  from pyselfupdate.errors import SelfUpdateError
29
+ from pyselfupdate.errors import SelfUpdateSourceFailure
28
30
  from pyselfupdate.errors import SourceError
29
31
  from pyselfupdate.github import GitHubSource
30
32
  from pyselfupdate.install import Installation
@@ -59,9 +61,11 @@ __all__ = [
59
61
  'NoReleaseError',
60
62
  'NotInstalledError',
61
63
  'Outcome',
64
+ 'RateLimitedError',
62
65
  'Release',
63
66
  'Result',
64
67
  'SelfUpdateError',
68
+ 'SelfUpdateSourceFailure',
65
69
  'Skip',
66
70
  'Source',
67
71
  'SourceError',
@@ -1,10 +1,15 @@
1
1
  """Exceptions raised by pyselfupdate.
2
2
 
3
3
  Every failure mode has its own class so callers match on a type rather than on
4
- message text. The hierarchy is one level deep under `SelfUpdateError`, which is
5
- what a caller catches when it does not care why an update did not happen.
4
+ message text. Every class derives from `SelfUpdateError`, which is what a caller
5
+ catches when it does not care why an update did not happen. A class that narrows
6
+ another derives from it, so catching `SourceError` still catches a rate limit.
6
7
  """
7
8
 
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+
8
13
 
9
14
  class SelfUpdateError(Exception):
10
15
  """Base class for every error this package raises."""
@@ -41,6 +46,31 @@ class SourceError(SelfUpdateError):
41
46
  """The release source could not be reached or returned something unusable."""
42
47
 
43
48
 
49
+ class SelfUpdateSourceFailure(SourceError):
50
+ """A transport or protocol failure, as opposed to a missing release.
51
+
52
+ Distinct from NoReleaseError so a caller can tell "GitHub is unreachable"
53
+ apart from "this repository has published nothing", which are the same HTTP
54
+ status for a private repository and want different messages.
55
+ """
56
+
57
+
58
+ class RateLimitedError(SelfUpdateSourceFailure):
59
+ """The source's rate limit for this caller is spent.
60
+
61
+ `authenticated` says which ceiling was hit. Without a credential the limit
62
+ is shared by every host behind one address, and supplying a token lifts it.
63
+ With one, nothing is left to configure, and the caller can only wait.
64
+ `resets_at` is when the ceiling lifts, in UTC, or None when the response
65
+ did not say.
66
+ """
67
+
68
+ def __init__(self, message: str, *, authenticated: bool, resets_at: dt.datetime | None = None) -> None:
69
+ super().__init__(message)
70
+ self.authenticated = authenticated
71
+ self.resets_at = resets_at
72
+
73
+
44
74
  class InstallFailedError(SelfUpdateError):
45
75
  """The install command ran and failed."""
46
76
 
@@ -27,8 +27,11 @@ from dataclasses import field
27
27
  from typing import IO
28
28
 
29
29
  from pyselfupdate.errors import NoReleaseError
30
+ from pyselfupdate.errors import RateLimitedError
30
31
  from pyselfupdate.errors import SelfUpdateError
32
+ from pyselfupdate.errors import SelfUpdateSourceFailure
31
33
  from pyselfupdate.errors import SourceError
34
+ from pyselfupdate.install import run_command
32
35
  from pyselfupdate.source import Release
33
36
 
34
37
  API = 'https://api.github.com'
@@ -131,7 +134,7 @@ def token_from_command() -> str:
131
134
  if not binary:
132
135
  return ''
133
136
  try:
134
- result = subprocess.run([binary, *argv[1:]], capture_output=True, text=True, check=False, timeout=COMMAND_TIMEOUT_SECONDS) # noqa: S603
137
+ result = run_command([binary, *argv[1:]], timeout=COMMAND_TIMEOUT_SECONDS)
135
138
  except (OSError, subprocess.SubprocessError):
136
139
  return ''
137
140
  return result.stdout.strip() if result.returncode == 0 else ''
@@ -306,15 +309,6 @@ class GitHubSource:
306
309
  raise SelfUpdateSourceFailure(f'{API}{path} returned invalid JSON') from error
307
310
 
308
311
 
309
- class SelfUpdateSourceFailure(SourceError):
310
- """A transport or protocol failure, as opposed to a missing release.
311
-
312
- Distinct from NoReleaseError so a caller can tell "GitHub is unreachable"
313
- apart from "this repository has published nothing", which are the same HTTP
314
- status for a private repository and want different messages.
315
- """
316
-
317
-
318
312
  def _http_error(owner: str, repo: str, error: urllib.error.HTTPError, *, authenticated: bool) -> SelfUpdateError:
319
313
  if error.code == 404:
320
314
  # A private repository reached without a token is indistinguishable
@@ -324,12 +318,14 @@ def _http_error(owner: str, repo: str, error: urllib.error.HTTPError, *, authent
324
318
  if error.code in (401, 403):
325
319
  remaining = error.headers.get('x-ratelimit-remaining') if error.headers else None
326
320
  if remaining == '0':
327
- return SelfUpdateSourceFailure(_rate_limit_message(error, authenticated=authenticated))
321
+ resets_at = _reset_moment(error)
322
+ message = _rate_limit_message(authenticated=authenticated, resets_at=resets_at)
323
+ return RateLimitedError(message, authenticated=authenticated, resets_at=resets_at)
328
324
  return SelfUpdateSourceFailure(f'GitHub refused the request for {owner}/{repo} ({error.code})')
329
325
  return SelfUpdateSourceFailure(f'GitHub returned {error.code} for {owner}/{repo}')
330
326
 
331
327
 
332
- def _rate_limit_message(error: urllib.error.HTTPError, *, authenticated: bool) -> str:
328
+ def _rate_limit_message(*, authenticated: bool, resets_at: dt.datetime | None) -> str:
333
329
  """Which ceiling was hit, and what to do about that one.
334
330
 
335
331
  The two are different problems with different fixes, and one sentence for
@@ -341,7 +337,10 @@ def _rate_limit_message(error: urllib.error.HTTPError, *, authenticated: bool) -
341
337
  The authenticated case should be rare, so it says when the ceiling lifts
342
338
  rather than what to change -- there is nothing to change.
343
339
  """
344
- resets = _reset_clock(error)
340
+ # A wall-clock time rather than "in 43 minutes", because the message is read
341
+ # out of a state file long after the request that produced it and a duration
342
+ # would be counted from the wrong instant.
343
+ resets = f'; resets at {resets_at:%H:%M} UTC' if resets_at else ''
345
344
  if authenticated:
346
345
  return f"GitHub's authenticated rate limit (5,000/hour) is exhausted{resets}"
347
346
  return (
@@ -350,19 +349,13 @@ def _rate_limit_message(error: urllib.error.HTTPError, *, authenticated: bool) -
350
349
  )
351
350
 
352
351
 
353
- def _reset_clock(error: urllib.error.HTTPError) -> str:
354
- """`; resets at 14:22 UTC`, or nothing when the header is absent or unusable.
355
-
356
- A wall-clock time rather than "in 43 minutes", because the message is read
357
- out of a state file long after the request that produced it and a duration
358
- would be counted from the wrong instant.
359
- """
352
+ def _reset_moment(error: urllib.error.HTTPError) -> dt.datetime | None:
353
+ """When the spent limit lifts, in UTC, or None when the header is absent or unusable."""
360
354
  header = error.headers.get('x-ratelimit-reset') if error.headers else None
361
355
  try:
362
- moment = dt.datetime.fromtimestamp(int(header or ''), tz=dt.UTC)
356
+ return dt.datetime.fromtimestamp(int(header or ''), tz=dt.UTC)
363
357
  except (TypeError, ValueError, OSError, OverflowError):
364
- return ''
365
- return f'; resets at {moment:%H:%M} UTC'
358
+ return None
366
359
 
367
360
 
368
361
  def _quote(ref: str) -> str:
@@ -47,6 +47,14 @@ on one line: uv writes every hash as a continuation line, and
47
47
  `_pins_from_export` reads line by line.
48
48
  """
49
49
 
50
+ LOCK_READ_TIMEOUT_SECONDS = 120.0
51
+ """The bound on each of the clone and the export that read a release's lock.
52
+
53
+ Both run before anything is installed, so a stalled remote or a credential
54
+ prompt nobody answers fails the update with `LockUnreadableError` rather than
55
+ holding the command someone typed.
56
+ """
57
+
50
58
 
51
59
  class InstallKind(Enum):
52
60
  """How a uv tool was installed, which decides whether it may be updated."""
@@ -64,10 +72,9 @@ class Installation:
64
72
  kind: InstallKind
65
73
  url: str = ''
66
74
 
67
- # Whatever followed the `@` at install time: a tag, a branch or a commit,
68
- # which uv records alike as `rev=`. Empty when the install follows the
69
- # default branch. `updater.installed_from_release` reads it as a release
70
- # tag or refuses it.
75
+ # Whatever followed the `@` at install time, read from the receipt by
76
+ # `_split_git`. `updater.installed_from_release` reads it as a release tag
77
+ # or refuses it.
71
78
  revision: str = ''
72
79
 
73
80
  extras: tuple[str, ...] = ()
@@ -95,6 +102,7 @@ def tool_dir() -> Path:
95
102
  `UV_TOOL_DIR` first. Otherwise uv's data directory: `%APPDATA%\\uv` on
96
103
  Windows, where uv ignores `XDG_DATA_HOME`, and `$XDG_DATA_HOME/uv` or
97
104
  `~/.local/share/uv` everywhere else.
105
+
98
106
  Resolved from the environment rather than by running `uv tool dir`, which
99
107
  costs a subprocess on a path that runs before every command.
100
108
  """
@@ -120,7 +128,11 @@ def read_installation(tool: str) -> Installation:
120
128
  """
121
129
  receipt = tool_dir() / tool / RECEIPT_NAME
122
130
  if not receipt.is_file():
123
- raise NotInstalledError(f'{tool} is not installed as a uv tool ({receipt} does not exist)')
131
+ raise NotInstalledError(
132
+ f'{tool} is not installed as a uv tool ({receipt} does not exist). '
133
+ f"Install a release with `uv tool install '{tool} @ git+<repository url>@<release tag>'`, "
134
+ f'or `uv tool install {tool}` from a package index'
135
+ )
124
136
 
125
137
  try:
126
138
  payload = tomllib.loads(receipt.read_text(encoding='utf-8'))
@@ -317,25 +329,37 @@ def read_lock(url: str, ref: str, *, extras: Sequence[str] = ()) -> Pins | None:
317
329
  a dependent requests of a git dependency, which `_override_extras` restores.
318
330
 
319
331
  Raises `LockUnreadableError` when git or uv is not on PATH, the ref will not
320
- clone, or uv will not export its lock. None is not a failure:
332
+ clone, uv will not export its lock, or either outlasts
333
+ `LOCK_READ_TIMEOUT_SECONDS`. None is not a failure:
321
334
  `install_release` installs that tag unlocked and sets `lock_missing`.
322
335
  """
323
336
  git = shutil.which('git')
324
337
  uv = shutil.which('uv')
325
338
  if not git or not uv:
326
339
  missing = 'git' if not git else 'uv'
327
- raise LockUnreadableError(f'{missing} is not on PATH, so the uv.lock at {ref} cannot be read')
340
+ raise LockUnreadableError(
341
+ f'{missing} is not on PATH, so the uv.lock at {ref} cannot be read. '
342
+ f'Install {missing}, or add the directory holding it to PATH, then run the update again'
343
+ )
328
344
 
329
345
  # A clone that cannot be deleted stays in the system temp, and the update goes ahead.
330
346
  with tempfile.TemporaryDirectory(prefix='pyselfupdate-lock-', ignore_cleanup_errors=True) as scratch:
331
347
  checkout = Path(scratch) / 'checkout'
332
- cloned = _run([git, 'clone', '--quiet', '--depth', '1', '--branch', ref, url, str(checkout)])
348
+ clone = [git, 'clone', '--quiet', '--depth', '1', '--branch', ref, url, str(checkout)]
349
+ try:
350
+ cloned = run_command(clone, timeout=LOCK_READ_TIMEOUT_SECONDS)
351
+ except subprocess.TimeoutExpired as error:
352
+ raise LockUnreadableError(f'cloning {url} at {ref} to read its uv.lock took longer than {error.timeout:g}s') from error
333
353
  if cloned.returncode != 0:
334
354
  raise LockUnreadableError(f'could not clone {url} at {ref} to read its uv.lock: {_reason(cloned)}')
335
355
  lock = checkout / LOCK_NAME
336
356
  if not lock.is_file():
337
357
  return None
338
- exported = _run([uv, *EXPORT, *chain.from_iterable(('--extra', extra) for extra in extras)], cwd=checkout)
358
+ export = [uv, *EXPORT, *chain.from_iterable(('--extra', extra) for extra in extras)]
359
+ try:
360
+ exported = run_command(export, cwd=checkout, timeout=LOCK_READ_TIMEOUT_SECONDS)
361
+ except subprocess.TimeoutExpired as error:
362
+ raise LockUnreadableError(f'uv export of the uv.lock at {ref} took longer than {error.timeout:g}s') from error
339
363
  if exported.returncode != 0:
340
364
  raise LockUnreadableError(f'uv export would not read the uv.lock at {ref}: {_reason(exported)}')
341
365
  try:
@@ -416,10 +440,16 @@ def run_install(
416
440
  the way `--with` and `--with-editable` install them. `--force` is what
417
441
  allows an entry point that already exists to be replaced; without it uv
418
442
  refuses rather than overwriting.
443
+
444
+ The install runs unbounded. Killed part way, it leaves the tool's
445
+ environment half rewritten, which is worse than a slow install.
419
446
  """
420
447
  executable = shutil.which('uv')
421
448
  if not executable:
422
- raise InstallFailedError('uv is not on PATH, so the tool cannot reinstall itself')
449
+ raise InstallFailedError(
450
+ 'uv is not on PATH, so the tool cannot reinstall itself. '
451
+ 'Add the directory holding uv to PATH, or install uv from https://docs.astral.sh/uv/, then run the update again'
452
+ )
423
453
 
424
454
  with tempfile.TemporaryDirectory(prefix='pyselfupdate-pins-', ignore_cleanup_errors=True) as scratch:
425
455
  held = pins.arguments(Path(scratch)) if pins else []
@@ -431,22 +461,28 @@ def run_install(
431
461
  command = [executable, 'tool', 'install', '--force', *source_flags, *held, *beside, requirement]
432
462
  if quiet:
433
463
  command.insert(1, '--quiet')
434
- completed = _run(command)
464
+ completed = run_command(command)
435
465
 
436
466
  if completed.returncode != 0:
437
467
  raise InstallFailedError(f'uv tool install failed: {_reason(completed)}')
438
468
 
439
469
 
440
- def _run(command: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
441
- return subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False)
470
+ def run_command(command: list[str], *, cwd: Path | None = None, timeout: float | None = None) -> subprocess.CompletedProcess[str]:
471
+ """Run a program to completion and capture what it printed.
472
+
473
+ Every process this package starts goes through here. `timeout` bounds the call in seconds. Past it the program is killed and
474
+ `subprocess.TimeoutExpired` is raised, which each caller turns into its own
475
+ failure. None runs unbounded.
476
+ """
477
+ return subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False, timeout=timeout)
442
478
 
443
479
 
444
480
  def _reason(completed: subprocess.CompletedProcess[str]) -> str:
445
- """Everything a failing command printed, rather than one line of it.
481
+ """Everything a failing command printed.
446
482
 
447
- uv puts a lock's schema error on its first line, git puts a missing ref on
448
- its last, and uv wraps a resolution failure so its last line is half a
449
- sentence.
483
+ No single line carries the reason: uv puts a lock's schema error on its
484
+ first line, git puts a missing ref on its last, and uv wraps a resolution
485
+ failure so its last line is half a sentence.
450
486
  """
451
487
  return (completed.stderr or completed.stdout or '').strip() or f'exit status {completed.returncode}'
452
488
 
@@ -124,10 +124,6 @@ def _asking_for_help() -> bool:
124
124
  screen. Nothing a notice is for happens on a help screen, and the check
125
125
  costs a releases-API call and a state write every time.
126
126
 
127
- It lands hardest on a reader that walks a tool's help. Reading every
128
- command's help is one invocation per command plus one for the root, and
129
- each of them reaches this.
130
-
131
127
  A `--` ends the scan, because everything after it is the command's own
132
128
  argument. Skipping when it should not have costs a missed notice, which is
133
129
  the cheap direction to be wrong in.
@@ -32,10 +32,39 @@ from pyselfupdate.updater import install_release
32
32
  from pyselfupdate.updater import require_updatable
33
33
 
34
34
 
35
+ def update_help(tool: str, name: str, markup_mode: str | None) -> str:
36
+ """The update command's help: one line on what it does, then annotated examples.
37
+
38
+ Each example keeps a line of its own under each of typer's markup modes,
39
+ which lay out a help's later paragraphs differently. Markdown joins a
40
+ paragraph's lines, so the examples sit in a fenced block there. Rich markup
41
+ keeps the lines as written and would print a fence's backticks. Without
42
+ rich, click rewraps every paragraph that does not open with its `\\b`
43
+ marker.
44
+ """
45
+ command = f'{tool} {name}'
46
+ width = len(command) + len(' --no-changelog') + 2
47
+ examples = '\n'.join(
48
+ f'{call:<{width}}{purpose}'
49
+ for call, purpose in (
50
+ (command, 'install the newest and list its commits'),
51
+ (f'{command} --check', 'only report whether a newer one exists'),
52
+ (f'{command} --no-changelog', 'install it without listing its commits'),
53
+ )
54
+ )
55
+ if markup_mode == 'markdown':
56
+ block = f'```text\n{examples}\n```'
57
+ elif markup_mode == 'rich':
58
+ block = examples
59
+ else:
60
+ block = f'\b\n{examples}'
61
+ return f'Update {tool} to the latest release\n\nExamples:\n\n{block}'
62
+
63
+
35
64
  def add_update_command(app: typer.Typer, config: Config, *, name: str = 'update') -> None:
36
65
  """Register `<tool> update` on an existing typer app."""
37
66
 
38
- @app.command(name=name, help=f'Update {config.tool} to the latest release')
67
+ @app.command(name=name, help=update_help(config.tool, name, app.rich_markup_mode))
39
68
  def update_command(
40
69
  check_only: bool = typer.Option(False, '--check', help='Report whether an update is available without installing it'),
41
70
  skip_changelog: bool = typer.Option(False, '--no-changelog', help='Do not list the commits between versions'),
@@ -52,17 +81,15 @@ def run_update(config: Config, *, check_only: bool = False, skip_changelog: bool
52
81
  library report an update in the same words.
53
82
 
54
83
  `check_only` reads the receipt first, as an update does, and refuses the
55
- same installs. A branch build's version can rank below the release it is
56
- ahead of, so a check that skipped the receipt would offer that older
57
- release as an update.
58
-
59
- The step order is the load-bearing part. Everything that reaches the network
60
- or imports a module happens before the install; after it, this process only
61
- prints what it already holds and exits without unwinding. Both halves of
62
- that come from a real failure. A tool that fetched its changelog after
63
- replacing its own environment died on its HTTP client's lazy import of a
64
- transport module. The release it had just installed did not depend on that
65
- module, so the install had deleted it.
84
+ same installs. `updater.installed_from_release` says why a version alone
85
+ cannot decide.
86
+
87
+ Everything that reaches the network or imports a module happens before the
88
+ install; after it, this process only prints what it already holds and exits
89
+ without unwinding. An import after the install can name a module the new
90
+ release no longer ships, which the install has already deleted. An HTTP
91
+ client's lazy import of its transport is one, and it raises ImportError
92
+ after the update has succeeded.
66
93
  """
67
94
  tool = config.tool
68
95
  try:
@@ -60,9 +60,9 @@ class Result:
60
60
  def check(config: Config) -> Result:
61
61
  """Report whether a newer release exists, without installing anything.
62
62
 
63
- Compares versions and reads nothing else. A branch build's version can rank
64
- below the release it is ahead of, so a caller reporting the result runs
63
+ Compares versions and reads nothing else. A caller reporting the result runs
65
64
  `require_updatable` first, as `update` and `typercmd.run_update` do.
65
+ `installed_from_release` says why a version alone cannot decide.
66
66
  """
67
67
  resolved = config.resolved()
68
68
  current = _current_version(resolved)
@@ -97,9 +97,8 @@ def installed_from_release(installation: Installation, tag_prefix: str) -> bool:
97
97
 
98
98
  An index install is. A git install is when its revision is a release tag:
99
99
  `tag_prefix` followed by a semantic version, the form a release's
100
- `install_ref` takes. uv writes `?rev=` for whatever followed the `@`.
101
- `@main` and `@v1.2.3` land in the same field, so only the value tells a
102
- branch from a tag.
100
+ `install_ref` takes. `Installation.revision` holds a branch or a commit in
101
+ the same field, so only the value tells them apart.
103
102
 
104
103
  A build from a branch reports a version that ranks wrongly against a
105
104
  release. Versioned from git tags, a branch three commits past v1.2.3 builds
File without changes
File without changes