pyselfupdate 0.5.3__tar.gz → 0.5.5__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.3
3
+ Version: 0.5.5
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
 
@@ -221,12 +221,26 @@ later `uv tool upgrade` stays held to them.
221
221
  | Carries none | Installs unlocked and sets `lock_missing`. `lock_warning` names the tag, and `run_update` and `update_and_reexec` print it |
222
222
  | Will not clone, uv will not export its lock, or git or uv is not on `PATH` | Raises `LockUnreadableError` and installs nothing |
223
223
 
224
- An install from an index reads no lock, because a wheel carries none.
224
+ An install from an index reads no lock, because a wheel carries none. It
225
+ takes the release as a floor, `mytool>=2.0.0`, rather than a pin. uv records
226
+ the specifier in the receipt, and `uv tool upgrade` refuses to move an exact
227
+ pin.
225
228
 
226
229
  An update reinstalls the tool's own extras, as in `mytool[fast]`, with their
227
230
  dependencies held to the lock. Every `--with` and `--with-editable`
228
231
  requirement is passed back to uv as well.
229
232
 
233
+ `uv tool install --force` reads nothing back from the receipt it replaces. So
234
+ an update passes back the receipt's `--python` and the flags naming where its
235
+ packages come from: `--index`, `--default-index`, `--index-url`,
236
+ `--extra-index-url`, `--find-links`, `--no-index`, `--index-strategy` and
237
+ `--keyring-provider`. Without them, a tool installed with
238
+ `--index https://pypi.example.com/simple/` resolves its update against PyPI
239
+ alone and is not found. An `explicit` index is left out, because only a config
240
+ file can declare one and the reinstall reads that file again. Dropping any
241
+ other recorded option changes how the tool resolves or builds, never whether it
242
+ is found.
243
+
230
244
  ## What may be updated is read from uv's receipt, not guessed from the version
231
245
 
232
246
  uv writes `uv-receipt.toml` into the tool's directory at install time. It
@@ -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
 
@@ -198,12 +198,26 @@ later `uv tool upgrade` stays held to them.
198
198
  | Carries none | Installs unlocked and sets `lock_missing`. `lock_warning` names the tag, and `run_update` and `update_and_reexec` print it |
199
199
  | Will not clone, uv will not export its lock, or git or uv is not on `PATH` | Raises `LockUnreadableError` and installs nothing |
200
200
 
201
- An install from an index reads no lock, because a wheel carries none.
201
+ An install from an index reads no lock, because a wheel carries none. It
202
+ takes the release as a floor, `mytool>=2.0.0`, rather than a pin. uv records
203
+ the specifier in the receipt, and `uv tool upgrade` refuses to move an exact
204
+ pin.
202
205
 
203
206
  An update reinstalls the tool's own extras, as in `mytool[fast]`, with their
204
207
  dependencies held to the lock. Every `--with` and `--with-editable`
205
208
  requirement is passed back to uv as well.
206
209
 
210
+ `uv tool install --force` reads nothing back from the receipt it replaces. So
211
+ an update passes back the receipt's `--python` and the flags naming where its
212
+ packages come from: `--index`, `--default-index`, `--index-url`,
213
+ `--extra-index-url`, `--find-links`, `--no-index`, `--index-strategy` and
214
+ `--keyring-provider`. Without them, a tool installed with
215
+ `--index https://pypi.example.com/simple/` resolves its update against PyPI
216
+ alone and is not found. An `explicit` index is left out, because only a config
217
+ file can declare one and the reinstall reads that file again. Dropping any
218
+ other recorded option changes how the tool resolves or builds, never whether it
219
+ is found.
220
+
207
221
  ## What may be updated is read from uv's receipt, not guessed from the version
208
222
 
209
223
  uv writes `uv-receipt.toml` into the tool's directory at install time. It
@@ -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"
@@ -77,6 +77,11 @@ class Installation:
77
77
  # editable back.
78
78
  with_requirements: tuple[str, ...] = ()
79
79
 
80
+ # uv flags reproducing the receipt's Python and package sources, from
81
+ # `_source_flags`. `uv tool install --force` reads neither back from the
82
+ # receipt it replaces, so an update passes them itself.
83
+ source_flags: tuple[str, ...] = ()
84
+
80
85
  # Names of the receipt requirements `_requirement_line` returns None for.
81
86
  # `read_installation` records them rather than raising: the notify gate
82
87
  # treats a failed read as a local install and prints no notice. `update`
@@ -90,6 +95,7 @@ def tool_dir() -> Path:
90
95
  `UV_TOOL_DIR` first. Otherwise uv's data directory: `%APPDATA%\\uv` on
91
96
  Windows, where uv ignores `XDG_DATA_HOME`, and `$XDG_DATA_HOME/uv` or
92
97
  `~/.local/share/uv` everywhere else.
98
+
93
99
  Resolved from the environment rather than by running `uv tool dir`, which
94
100
  costs a subprocess on a path that runs before every command.
95
101
  """
@@ -122,7 +128,8 @@ def read_installation(tool: str) -> Installation:
122
128
  except (OSError, tomllib.TOMLDecodeError) as error:
123
129
  raise NotInstalledError(f'cannot read {receipt}: {error}') from error
124
130
 
125
- requirements = (payload.get('tool') or {}).get('requirements') or []
131
+ recorded = payload.get('tool') or {}
132
+ requirements = recorded.get('requirements') or []
126
133
  own = next((requirement for requirement in requirements if requirement.get('name') == tool), None)
127
134
  if own is None:
128
135
  raise NotInstalledError(f'{receipt} lists no requirement named {tool}')
@@ -140,7 +147,7 @@ def read_installation(tool: str) -> Installation:
140
147
 
141
148
  def installed_as(kind: InstallKind, url: str = '', revision: str = '') -> Installation:
142
149
  extras = tuple(own.get('extras') or ())
143
- return Installation(tool, kind, url, revision, extras, tuple(rebuilt), tuple(unrebuildable))
150
+ return Installation(tool, kind, url, revision, extras, tuple(rebuilt), _source_flags(recorded), tuple(unrebuildable))
144
151
 
145
152
  # A local checkout, however uv spelled it. Reinstalling one would
146
153
  # discard the working copy the user is developing against.
@@ -156,6 +163,43 @@ def read_installation(tool: str) -> Installation:
156
163
  return installed_as(InstallKind.INDEX)
157
164
 
158
165
 
166
+ def _source_flags(recorded: dict) -> tuple[str, ...]:
167
+ """The uv flags that reinstall onto the receipt's Python from the receipt's package sources.
168
+
169
+ Without them a reinstall resolves against PyPI alone and moves to uv's
170
+ default Python. uv records a setting from a flag, the environment or a
171
+ config file alike, and cannot say which. An `explicit` or `flat` index only
172
+ a config file can declare, and that file is read again by the reinstall, so
173
+ it is left out. Dropping any other recorded option changes how the tool
174
+ resolves or builds, never whether its packages can be found.
175
+ """
176
+ options = recorded.get('options') or {}
177
+ pairs: list[tuple[str, str]] = []
178
+ if recorded.get('python'):
179
+ pairs.append(('--python', str(recorded['python'])))
180
+ for index in options.get('index') or ():
181
+ if index.get('explicit') or index.get('format', 'simple') != 'simple' or not index.get('url'):
182
+ continue
183
+ if index.get('default'):
184
+ pairs.append(('--default-index', str(index['url'])))
185
+ else:
186
+ name = index.get('name')
187
+ pairs.append(('--index', f'{name}={index["url"]}' if name else str(index['url'])))
188
+ if options.get('index-url'):
189
+ pairs.append(('--index-url', str(options['index-url'])))
190
+ pairs += [('--extra-index-url', str(url)) for url in options.get('extra-index-url') or ()]
191
+ pairs += [('--find-links', str(location)) for location in options.get('find-links') or ()]
192
+ pairs += [(f'--{key}', str(options[key])) for key in ('index-strategy', 'keyring-provider') if options.get(key)]
193
+ if options.get('no-index'):
194
+ pairs.append(('--no-index', ''))
195
+
196
+ # A source declared in a config file is recorded again beside the flag that
197
+ # passes it back. Passing each source once holds it at two copies, rather
198
+ # than adding one to the receipt per update.
199
+ distinct = dict.fromkeys(pairs)
200
+ return tuple(part for flag, value in distinct for part in (flag, value) if part)
201
+
202
+
159
203
  def _split_git(git: str) -> tuple[str, dict[str, str], str]:
160
204
  """A receipt's git source as its URL, its query and the commit after `#`.
161
205
 
@@ -221,11 +265,15 @@ def current_version(package: str) -> str:
221
265
 
222
266
 
223
267
  def requirement_for(installation: Installation, ref: str) -> str:
224
- """The requirement string that installs `ref` of an already-installed tool, with the extras it has."""
268
+ """The requirement string that installs `ref` of an already-installed tool, with the extras it has.
269
+
270
+ An index install takes `ref` as a floor. uv records the specifier in the
271
+ receipt, and `uv tool upgrade` refuses to move an exact pin.
272
+ """
225
273
  named = f'{installation.tool}[{",".join(installation.extras)}]' if installation.extras else installation.tool
226
274
  if installation.kind is InstallKind.GIT:
227
275
  return f'{named} @ git+{installation.url}@{ref}'
228
- return f'{named}=={ref.removeprefix("v")}'
276
+ return f'{named}>={ref.removeprefix("v")}'
229
277
 
230
278
 
231
279
  @dataclass(frozen=True)
@@ -355,7 +403,14 @@ def _pins_from_export(exported: str, extras: dict[str, tuple[str, ...]]) -> Pins
355
403
  return Pins(tuple(constraints), tuple(overrides))
356
404
 
357
405
 
358
- def run_install(requirement: str, *, quiet: bool = True, pins: Pins | None = None, with_requirements: Sequence[str] = ()) -> None:
406
+ def run_install(
407
+ requirement: str,
408
+ *,
409
+ quiet: bool = True,
410
+ pins: Pins | None = None,
411
+ with_requirements: Sequence[str] = (),
412
+ source_flags: Sequence[str] = (),
413
+ ) -> None:
359
414
  """Install a requirement over any existing tool of that name, held to `pins` when given.
360
415
 
361
416
  `with_requirements` are requirements-file lines installed beside the tool,
@@ -374,7 +429,7 @@ def run_install(requirement: str, *, quiet: bool = True, pins: Pins | None = Non
374
429
  listed = Path(scratch) / 'with.txt'
375
430
  listed.write_text(''.join(f'{line}\n' for line in with_requirements), encoding='utf-8')
376
431
  beside = ['--with-requirements', str(listed)]
377
- command = [executable, 'tool', 'install', '--force', *held, *beside, requirement]
432
+ command = [executable, 'tool', 'install', '--force', *source_flags, *held, *beside, requirement]
378
433
  if quiet:
379
434
  command.insert(1, '--quiet')
380
435
  completed = _run(command)
@@ -388,11 +443,11 @@ def _run(command: list[str], cwd: Path | None = None) -> subprocess.CompletedPro
388
443
 
389
444
 
390
445
  def _reason(completed: subprocess.CompletedProcess[str]) -> str:
391
- """Everything a failing command printed, rather than one line of it.
446
+ """Everything a failing command printed.
392
447
 
393
- uv puts a lock's schema error on its first line, git puts a missing ref on
394
- its last, and uv wraps a resolution failure so its last line is half a
395
- sentence.
448
+ No single line carries the reason: uv puts a lock's schema error on its
449
+ first line, git puts a missing ref on its last, and uv wraps a resolution
450
+ failure so its last line is half a sentence.
396
451
  """
397
452
  return (completed.stderr or completed.stdout or '').strip() or f'exit status {completed.returncode}'
398
453
 
@@ -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:
@@ -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
@@ -131,7 +130,13 @@ def install_release(config: Config, result: Result, installation: Installation,
131
130
 
132
131
  ref = result.release.install_ref()
133
132
  pins = read_lock(installation.url, ref, extras=installation.extras) if installation.kind is InstallKind.GIT else None
134
- run_install(requirement_for(installation, ref), quiet=quiet, pins=pins, with_requirements=installation.with_requirements)
133
+ run_install(
134
+ requirement_for(installation, ref),
135
+ quiet=quiet,
136
+ pins=pins,
137
+ with_requirements=installation.with_requirements,
138
+ source_flags=installation.source_flags,
139
+ )
135
140
 
136
141
  return Result(
137
142
  current=result.current,
File without changes
File without changes