pyselfupdate 0.5.3__tar.gz → 0.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyselfupdate
3
- Version: 0.5.3
3
+ Version: 0.5.4
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
@@ -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
@@ -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
@@ -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`
@@ -122,7 +127,8 @@ def read_installation(tool: str) -> Installation:
122
127
  except (OSError, tomllib.TOMLDecodeError) as error:
123
128
  raise NotInstalledError(f'cannot read {receipt}: {error}') from error
124
129
 
125
- requirements = (payload.get('tool') or {}).get('requirements') or []
130
+ recorded = payload.get('tool') or {}
131
+ requirements = recorded.get('requirements') or []
126
132
  own = next((requirement for requirement in requirements if requirement.get('name') == tool), None)
127
133
  if own is None:
128
134
  raise NotInstalledError(f'{receipt} lists no requirement named {tool}')
@@ -140,7 +146,7 @@ def read_installation(tool: str) -> Installation:
140
146
 
141
147
  def installed_as(kind: InstallKind, url: str = '', revision: str = '') -> Installation:
142
148
  extras = tuple(own.get('extras') or ())
143
- return Installation(tool, kind, url, revision, extras, tuple(rebuilt), tuple(unrebuildable))
149
+ return Installation(tool, kind, url, revision, extras, tuple(rebuilt), _source_flags(recorded), tuple(unrebuildable))
144
150
 
145
151
  # A local checkout, however uv spelled it. Reinstalling one would
146
152
  # discard the working copy the user is developing against.
@@ -156,6 +162,43 @@ def read_installation(tool: str) -> Installation:
156
162
  return installed_as(InstallKind.INDEX)
157
163
 
158
164
 
165
+ def _source_flags(recorded: dict) -> tuple[str, ...]:
166
+ """The uv flags that reinstall onto the receipt's Python from the receipt's package sources.
167
+
168
+ Without them a reinstall resolves against PyPI alone and moves to uv's
169
+ default Python. uv records a setting from a flag, the environment or a
170
+ config file alike, and cannot say which. An `explicit` or `flat` index only
171
+ a config file can declare, and that file is read again by the reinstall, so
172
+ it is left out. Dropping any other recorded option changes how the tool
173
+ resolves or builds, never whether its packages can be found.
174
+ """
175
+ options = recorded.get('options') or {}
176
+ pairs: list[tuple[str, str]] = []
177
+ if recorded.get('python'):
178
+ pairs.append(('--python', str(recorded['python'])))
179
+ for index in options.get('index') or ():
180
+ if index.get('explicit') or index.get('format', 'simple') != 'simple' or not index.get('url'):
181
+ continue
182
+ if index.get('default'):
183
+ pairs.append(('--default-index', str(index['url'])))
184
+ else:
185
+ name = index.get('name')
186
+ pairs.append(('--index', f'{name}={index["url"]}' if name else str(index['url'])))
187
+ if options.get('index-url'):
188
+ pairs.append(('--index-url', str(options['index-url'])))
189
+ pairs += [('--extra-index-url', str(url)) for url in options.get('extra-index-url') or ()]
190
+ pairs += [('--find-links', str(location)) for location in options.get('find-links') or ()]
191
+ pairs += [(f'--{key}', str(options[key])) for key in ('index-strategy', 'keyring-provider') if options.get(key)]
192
+ if options.get('no-index'):
193
+ pairs.append(('--no-index', ''))
194
+
195
+ # A source declared in a config file is recorded again beside the flag that
196
+ # passes it back. Passing each source once holds it at two copies, rather
197
+ # than adding one to the receipt per update.
198
+ distinct = dict.fromkeys(pairs)
199
+ return tuple(part for flag, value in distinct for part in (flag, value) if part)
200
+
201
+
159
202
  def _split_git(git: str) -> tuple[str, dict[str, str], str]:
160
203
  """A receipt's git source as its URL, its query and the commit after `#`.
161
204
 
@@ -221,11 +264,15 @@ def current_version(package: str) -> str:
221
264
 
222
265
 
223
266
  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."""
267
+ """The requirement string that installs `ref` of an already-installed tool, with the extras it has.
268
+
269
+ An index install takes `ref` as a floor. uv records the specifier in the
270
+ receipt, and `uv tool upgrade` refuses to move an exact pin.
271
+ """
225
272
  named = f'{installation.tool}[{",".join(installation.extras)}]' if installation.extras else installation.tool
226
273
  if installation.kind is InstallKind.GIT:
227
274
  return f'{named} @ git+{installation.url}@{ref}'
228
- return f'{named}=={ref.removeprefix("v")}'
275
+ return f'{named}>={ref.removeprefix("v")}'
229
276
 
230
277
 
231
278
  @dataclass(frozen=True)
@@ -355,7 +402,14 @@ def _pins_from_export(exported: str, extras: dict[str, tuple[str, ...]]) -> Pins
355
402
  return Pins(tuple(constraints), tuple(overrides))
356
403
 
357
404
 
358
- def run_install(requirement: str, *, quiet: bool = True, pins: Pins | None = None, with_requirements: Sequence[str] = ()) -> None:
405
+ def run_install(
406
+ requirement: str,
407
+ *,
408
+ quiet: bool = True,
409
+ pins: Pins | None = None,
410
+ with_requirements: Sequence[str] = (),
411
+ source_flags: Sequence[str] = (),
412
+ ) -> None:
359
413
  """Install a requirement over any existing tool of that name, held to `pins` when given.
360
414
 
361
415
  `with_requirements` are requirements-file lines installed beside the tool,
@@ -374,7 +428,7 @@ def run_install(requirement: str, *, quiet: bool = True, pins: Pins | None = Non
374
428
  listed = Path(scratch) / 'with.txt'
375
429
  listed.write_text(''.join(f'{line}\n' for line in with_requirements), encoding='utf-8')
376
430
  beside = ['--with-requirements', str(listed)]
377
- command = [executable, 'tool', 'install', '--force', *held, *beside, requirement]
431
+ command = [executable, 'tool', 'install', '--force', *source_flags, *held, *beside, requirement]
378
432
  if quiet:
379
433
  command.insert(1, '--quiet')
380
434
  completed = _run(command)
@@ -131,7 +131,13 @@ def install_release(config: Config, result: Result, installation: Installation,
131
131
 
132
132
  ref = result.release.install_ref()
133
133
  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)
134
+ run_install(
135
+ requirement_for(installation, ref),
136
+ quiet=quiet,
137
+ pins=pins,
138
+ with_requirements=installation.with_requirements,
139
+ source_flags=installation.source_flags,
140
+ )
135
141
 
136
142
  return Result(
137
143
  current=result.current,
File without changes
File without changes