pyselfupdate 0.5.0__tar.gz → 0.5.2__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,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .coverage
12
+ coverage.xml
13
+ *.egg-info/
@@ -1,3 +1,26 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyselfupdate
3
+ Version: 0.5.2
4
+ Summary: Self-update and update notification for Python CLIs installed with uv tool
5
+ Project-URL: Repository, https://github.com/datapointchris/pyselfupdate
6
+ Project-URL: Issues, https://github.com/datapointchris/pyselfupdate/issues
7
+ Project-URL: Changelog, https://github.com/datapointchris/pyselfupdate/releases
8
+ Author-email: Chris Birch <datapointchris@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,release,self-update,update-notifier,uv
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: System :: Software Distribution
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Provides-Extra: typer
21
+ Requires-Dist: typer>=0.12.0; extra == 'typer'
22
+ Description-Content-Type: text/markdown
23
+
1
24
  # pyselfupdate
2
25
 
3
26
  Self-update and update notification for Python CLIs installed with `uv tool`.
@@ -71,7 +94,7 @@ Nothing is printed when any of these hold:
71
94
  | `NO_AUTO_UPDATE` or `MYTOOL_NO_AUTO_UPDATE` is set | Opted out |
72
95
  | `CI`, `BUILD_NUMBER`, `RUN_ID`, `GITHUB_ACTIONS`, `CODESPACES` | Not a human |
73
96
  | stdout or stderr is not a terminal | `mytool list > out 2>&1` must stay clean |
74
- | Installed from a local path, an editable checkout, or a branch | Nothing to compare against |
97
+ | Installed from a local path, an editable checkout, a branch or a commit | Nothing to compare against |
75
98
  | Checked within the interval | One request per day, not per invocation |
76
99
 
77
100
  Presence-only, any value: `NO_AUTO_UPDATE=0` disables it, the same way
@@ -90,6 +113,13 @@ if result.update_available:
90
113
  result = update(config) # installs, raises on failure
91
114
  ```
92
115
 
116
+ `check` compares versions and reads nothing else. A branch build's version can
117
+ rank below the release it is ahead of. Three commits past `v1.2.3`, a tool
118
+ versioned from git tags builds as `1.2.3-post.3`, which semver ranks below
119
+ `v1.2.3`. Call `require_updatable` before reporting what `check` found. It
120
+ reads uv's receipt and refuses every install `update` refuses, as
121
+ [What will not be updated](#what-will-not-be-updated) lists.
122
+
93
123
  Or take the whole command:
94
124
 
95
125
  ```python
@@ -98,6 +128,9 @@ from pyselfupdate.typercmd import add_update_command
98
128
  add_update_command(app, CONFIG) # gives you `mytool update [--check]`
99
129
  ```
100
130
 
131
+ `mytool update --check` reads the receipt first, as `update` does. On an
132
+ install `update` refuses, it prints the same refusal and exits 1.
133
+
101
134
  `update` runs `uv tool install --force`, which **rebuilds the virtual
102
135
  environment the running interpreter lives in**. Unlike replacing a Unix binary
103
136
  — where the process holds an inode and is untouched — this pulls modules out
@@ -153,12 +186,21 @@ runtime:
153
186
  | --- | --- |
154
187
  | `git = "...git?rev=v1.2.3"` | Updatable |
155
188
  | `name = "mytool"` (from an index) | Updatable |
156
- | `git = "...git"` with no `rev` | Refused — tracks a branch, so its version says nothing about how far behind it is |
189
+ | `git = "...git"` with no `rev` | Refused — tracks the default branch, so its version says nothing about how far behind it is |
190
+ | `git = "...git?rev=main"`, or a `rev` naming a commit | Refused — uv writes `rev=` for whatever followed the `@`, so a `rev` counts as a release only when it is `tag_prefix` followed by a semantic version |
157
191
  | `directory` / `path` / `editable` | Refused — reinstalling would discard a working copy |
158
192
  | A `--with` requirement no requirements-file line reproduces, such as a relative path | Refused with `NotInstalledError` — the update would drop it |
159
193
 
160
194
  A tool that cannot be identified at all is treated as local and left alone.
161
195
 
196
+ `update` and `mytool update --check` refuse every row marked Refused. The
197
+ notice stays silent on each of them except the `--with` row, because a newer
198
+ release is still worth knowing about there.
199
+
200
+ A caller with a custom `source` whose refs carry a prefix sets `tag_prefix` to
201
+ match. Without it, a tag install from that source is refused as a branch
202
+ install.
203
+
162
204
  ## Configuration
163
205
 
164
206
  ```python
@@ -1,27 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: pyselfupdate
3
- Version: 0.5.0
4
- Summary: Self-update and update notification for Python CLIs installed with uv tool
5
- Keywords: uv,cli,self-update,update-notifier,release
6
- Author: Chris Birch
7
- Author-email: Chris Birch <datapointchris@gmail.com>
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Classifier: Development Status :: 4 - Beta
11
- Classifier: Environment :: Console
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Programming Language :: Python :: 3 :: Only
14
- Classifier: Topic :: Software Development :: Libraries
15
- Classifier: Topic :: System :: Software Distribution
16
- Classifier: Typing :: Typed
17
- Requires-Dist: typer>=0.12.0 ; extra == 'typer'
18
- Requires-Python: >=3.11
19
- Project-URL: Repository, https://github.com/datapointchris/pyselfupdate
20
- Project-URL: Issues, https://github.com/datapointchris/pyselfupdate/issues
21
- Project-URL: Changelog, https://github.com/datapointchris/pyselfupdate/releases
22
- Provides-Extra: typer
23
- Description-Content-Type: text/markdown
24
-
25
1
  # pyselfupdate
26
2
 
27
3
  Self-update and update notification for Python CLIs installed with `uv tool`.
@@ -95,7 +71,7 @@ Nothing is printed when any of these hold:
95
71
  | `NO_AUTO_UPDATE` or `MYTOOL_NO_AUTO_UPDATE` is set | Opted out |
96
72
  | `CI`, `BUILD_NUMBER`, `RUN_ID`, `GITHUB_ACTIONS`, `CODESPACES` | Not a human |
97
73
  | stdout or stderr is not a terminal | `mytool list > out 2>&1` must stay clean |
98
- | Installed from a local path, an editable checkout, or a branch | Nothing to compare against |
74
+ | Installed from a local path, an editable checkout, a branch or a commit | Nothing to compare against |
99
75
  | Checked within the interval | One request per day, not per invocation |
100
76
 
101
77
  Presence-only, any value: `NO_AUTO_UPDATE=0` disables it, the same way
@@ -114,6 +90,13 @@ if result.update_available:
114
90
  result = update(config) # installs, raises on failure
115
91
  ```
116
92
 
93
+ `check` compares versions and reads nothing else. A branch build's version can
94
+ rank below the release it is ahead of. Three commits past `v1.2.3`, a tool
95
+ versioned from git tags builds as `1.2.3-post.3`, which semver ranks below
96
+ `v1.2.3`. Call `require_updatable` before reporting what `check` found. It
97
+ reads uv's receipt and refuses every install `update` refuses, as
98
+ [What will not be updated](#what-will-not-be-updated) lists.
99
+
117
100
  Or take the whole command:
118
101
 
119
102
  ```python
@@ -122,6 +105,9 @@ from pyselfupdate.typercmd import add_update_command
122
105
  add_update_command(app, CONFIG) # gives you `mytool update [--check]`
123
106
  ```
124
107
 
108
+ `mytool update --check` reads the receipt first, as `update` does. On an
109
+ install `update` refuses, it prints the same refusal and exits 1.
110
+
125
111
  `update` runs `uv tool install --force`, which **rebuilds the virtual
126
112
  environment the running interpreter lives in**. Unlike replacing a Unix binary
127
113
  — where the process holds an inode and is untouched — this pulls modules out
@@ -177,12 +163,21 @@ runtime:
177
163
  | --- | --- |
178
164
  | `git = "...git?rev=v1.2.3"` | Updatable |
179
165
  | `name = "mytool"` (from an index) | Updatable |
180
- | `git = "...git"` with no `rev` | Refused — tracks a branch, so its version says nothing about how far behind it is |
166
+ | `git = "...git"` with no `rev` | Refused — tracks the default branch, so its version says nothing about how far behind it is |
167
+ | `git = "...git?rev=main"`, or a `rev` naming a commit | Refused — uv writes `rev=` for whatever followed the `@`, so a `rev` counts as a release only when it is `tag_prefix` followed by a semantic version |
181
168
  | `directory` / `path` / `editable` | Refused — reinstalling would discard a working copy |
182
169
  | A `--with` requirement no requirements-file line reproduces, such as a relative path | Refused with `NotInstalledError` — the update would drop it |
183
170
 
184
171
  A tool that cannot be identified at all is treated as local and left alone.
185
172
 
173
+ `update` and `mytool update --check` refuse every row marked Refused. The
174
+ notice stays silent on each of them except the `--with` row, because a newer
175
+ release is still worth knowing about there.
176
+
177
+ A caller with a custom `source` whose refs carry a prefix sets `tag_prefix` to
178
+ match. Without it, a tag install from that source is refused as a branch
179
+ install.
180
+
186
181
  ## Configuration
187
182
 
188
183
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyselfupdate"
3
- version = "0.5.0"
3
+ dynamic = ["version"]
4
4
  description = "Self-update and update notification for Python CLIs installed with uv tool"
5
5
  authors = [{ name = "Chris Birch", email = "datapointchris@gmail.com" }]
6
6
  license = "MIT"
@@ -112,6 +112,15 @@ select = [
112
112
  "SIM", # flake8-simplify
113
113
  "I", # isort
114
114
  "ICN", # flake8-import-conventions
115
+ # flake8-bandit by rule, never as the family. Most of what the family
116
+ # reports is a subprocess call made on purpose, and silencing that takes an
117
+ # ignore list that grows with every project.
118
+ "S102", # exec
119
+ "S113", # an HTTP request with no timeout, which can hang forever
120
+ "S301", # pickle, which runs code from the bytes it loads
121
+ "S307", # eval
122
+ "S324", # md5 or sha1 used for security
123
+ "S501", # TLS certificate verification turned off
115
124
  ]
116
125
  ignore = [
117
126
  "SIM108", # ternary operator — often less readable
@@ -131,15 +140,15 @@ datetime = "dt"
131
140
  sqlalchemy = "sa"
132
141
  "pyspark.sql.functions" = "sf"
133
142
  "pyspark.sql.types" = "st"
143
+ dataclasses = "dc"
134
144
 
135
145
  [tool.semantic_release]
136
- version_toml = ["pyproject.toml:project.version"]
137
- commit_message = "build(release): {version}"
138
- build_command = """
139
- pip install uv
140
- uv lock --upgrade-package pyselfupdate
141
- git add uv.lock
142
- """
146
+ # A feature release on 0.x stays on 0.x. python-semantic-release v10 defaults
147
+ # this to false, which makes the next release of a 0.x project 1.0.0.
148
+ allow_zero_version = true
149
+ # 1.0 is a compatibility promise, so a breaking change on 0.x is a minor bump.
150
+ # Removing this key is how the project reaches 1.0.
151
+ major_on_zero = false
143
152
 
144
153
 
145
154
  # ---------- Build System ---------- #
@@ -165,6 +174,7 @@ managed = [
165
174
  ["ruff", "lint", "isort", "force-single-line"],
166
175
  ["ruff", "lint", "flake8-import-conventions", "banned-from"],
167
176
  ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "datetime"],
177
+ ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "dataclasses"],
168
178
  ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "sqlalchemy"],
169
179
  ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "pyspark.sql.functions"],
170
180
  ["ruff", "lint", "flake8-import-conventions", "extend-aliases", "pyspark.sql.types"],
@@ -187,10 +197,24 @@ managed = [
187
197
  # and extra that names them, so a local run and CI cannot disagree.
188
198
  pinned = ["ruff"]
189
199
 
190
- [build-system]
191
- requires = ["uv_build>=0.12.2,<0.13.0"]
192
- build-backend = "uv_build"
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 } }]
193
212
 
194
- [tool.uv.build-backend]
195
- module-name = "pyselfupdate"
196
- module-root = "src"
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
+ [build-system]
219
+ requires = ["hatchling", "uv-dynamic-versioning"]
220
+ build-backend = "hatchling.build"
@@ -51,7 +51,8 @@ class Config:
51
51
 
52
52
  # Selects one release stream in a repository publishing several, as in
53
53
  # "cli/" for tags of the form cli/v1.2.3. Configures the default GitHub
54
- # source and is unused when `source` is supplied.
54
+ # source. Whatever the source, a git install whose receipt `rev` is not
55
+ # this prefix followed by a semantic version is refused as a branch install.
55
56
  tag_prefix: str = ''
56
57
 
57
58
  # Locates releases. Defaults to a GitHubSource built from the fields above.
@@ -15,11 +15,13 @@ class InvalidConfigError(SelfUpdateError):
15
15
 
16
16
 
17
17
  class LocalInstallError(SelfUpdateError):
18
- """The tool was installed from a local path or as an editable checkout.
18
+ """The running build is not a release, so no release can be compared against it.
19
19
 
20
- Reinstalling would discard a working copy for a release that may be older,
21
- with no way to tell which is newer. This is the analog of goselfupdate's
22
- ErrDevBuild.
20
+ Raised for an install from a local path or an editable checkout, for a git
21
+ install from a branch or a commit rather than a release tag, and for a
22
+ running version that is not a semantic version. Reinstalling would replace
23
+ the build with a release that may be older, with no way to tell which is
24
+ newer. This is the analog of goselfupdate's ErrDevBuild.
23
25
  """
24
26
 
25
27
 
@@ -88,10 +88,9 @@ DEFAULT_TOKEN_COMMAND = 'gh auth token' # nosec B105
88
88
 
89
89
  Authenticating is the default because the alternative is not "no credential" but
90
90
  "sixty requests an hour, charged per IP address and shared by every host behind
91
- one egress". Measured 2026-08-21 across one household: two machines checking on a
92
- timer held that pool at zero for whole hours, and every tool on the network that
93
- asked anonymously was refused. A default that has to be opted into is a default
94
- nobody sets, and eleven of fourteen tools here had not.
91
+ one egress". Two machines checking on a timer can hold that pool at zero for
92
+ whole hours, and every tool behind the same address that asks anonymously is
93
+ then refused. A default that has to be opted into is a default nobody sets.
95
94
  """
96
95
 
97
96
 
@@ -27,6 +27,7 @@ from dataclasses import field
27
27
  from enum import Enum
28
28
  from importlib.metadata import PackageNotFoundError
29
29
  from importlib.metadata import version as installed_version
30
+ from itertools import chain
30
31
  from pathlib import Path
31
32
 
32
33
  from pyselfupdate.errors import InstallFailedError
@@ -63,9 +64,10 @@ class Installation:
63
64
  kind: InstallKind
64
65
  url: str = ''
65
66
 
66
- # The requested revision, empty when the install tracks the default branch.
67
- # An empty value on a GIT install is the interesting case: the tool was
68
- # installed from a moving target, so "up to date" has no meaning.
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.
69
71
  revision: str = ''
70
72
 
71
73
  extras: tuple[str, ...] = ()
@@ -81,9 +83,6 @@ class Installation:
81
83
  # raises NotInstalledError on them instead of dropping them.
82
84
  _unrebuildable: tuple[str, ...] = field(default=(), repr=False)
83
85
 
84
- def is_updatable(self) -> bool:
85
- return self.kind is not InstallKind.LOCAL
86
-
87
86
 
88
87
  def tool_dir() -> Path:
89
88
  """uv's tool directory.
@@ -153,8 +152,9 @@ def read_installation(tool: str) -> Installation:
153
152
  def _split_git(git: str) -> tuple[str, dict[str, str], str]:
154
153
  """A receipt's git source as its URL, its query and the commit after `#`.
155
154
 
156
- uv writes `...git?rev=v1.2.3` for a pinned install and omits the query
157
- entirely for one that follows the default branch.
155
+ uv writes `...git?rev=v1.2.3` for `@v1.2.3`, `...git?rev=main` for
156
+ `@main`, and omits the query entirely for an install that follows the
157
+ default branch.
158
158
  """
159
159
  rest, _, commit = git.partition('#')
160
160
  url, _, query = rest.partition('?')
@@ -281,7 +281,7 @@ def read_lock(url: str, ref: str, *, extras: Sequence[str] = ()) -> Pins | None:
281
281
  lock = checkout / LOCK_NAME
282
282
  if not lock.is_file():
283
283
  return None
284
- exported = _run([uv, *EXPORT, *(flag for extra in extras for flag in ('--extra', extra))], cwd=checkout)
284
+ exported = _run([uv, *EXPORT, *chain.from_iterable(('--extra', extra) for extra in extras)], cwd=checkout)
285
285
  if exported.returncode != 0:
286
286
  raise LockUnreadableError(f'uv export would not read the uv.lock at {ref}: {_reason(exported)}')
287
287
  try:
@@ -308,7 +308,9 @@ def _override_extras(lock: dict, extras: Sequence[str]) -> dict[str, tuple[str,
308
308
  for package in lock.get('package') or []:
309
309
  packages.setdefault(package.get('name', ''), []).append(package)
310
310
  project = ({'editable': '.'}, {'virtual': '.'})
311
- roots = [name for name, entries in packages.items() for package in entries if package.get('source') in project]
311
+ roots: list[str] = []
312
+ for name, entries in packages.items():
313
+ roots.extend(name for package in entries if package.get('source') in project)
312
314
 
313
315
  active: dict[str, set[str]] = {root: set(extras) for root in roots}
314
316
  pending = list(roots)
@@ -316,7 +318,8 @@ def _override_extras(lock: dict, extras: Sequence[str]) -> dict[str, tuple[str,
316
318
  name = pending.pop()
317
319
  for package in packages.get(name, ()):
318
320
  groups = package.get('optional-dependencies') or {}
319
- edges = [*(package.get('dependencies') or ()), *(edge for extra in sorted(active[name]) for edge in groups.get(extra, ()))]
321
+ optional = chain.from_iterable(groups.get(extra, ()) for extra in sorted(active[name]))
322
+ edges = [*(package.get('dependencies') or ()), *optional]
320
323
  for edge in edges:
321
324
  target, wanted = edge.get('name', ''), set(edge.get('extra') or ())
322
325
  if target not in active or not wanted <= active[target]:
@@ -22,17 +22,18 @@ from typing import TextIO
22
22
  from pyselfupdate import state as state_module
23
23
  from pyselfupdate.config import Config
24
24
  from pyselfupdate.errors import SelfUpdateError
25
- from pyselfupdate.install import InstallKind
26
25
  from pyselfupdate.install import read_installation
27
26
  from pyselfupdate.updater import check
27
+ from pyselfupdate.updater import installed_from_release
28
28
 
29
29
  DEFAULT_INTERVAL = dt.timedelta(hours=24)
30
30
 
31
- # Environment variables. Presence-only, any value including empty -- the
31
+ # The environment variables every tool honors, beside its own `<TOOL>_` forms.
32
+ # The disable switch is presence-only, any value including empty -- the
32
33
  # NO_COLOR convention -- so that NO_AUTO_UPDATE=0 cannot mean "on". The interval
33
34
  # gets its own name precisely so presence and value never share one variable.
34
- FLEET_DISABLE = 'NO_AUTO_UPDATE'
35
- FLEET_INTERVAL = 'AUTO_UPDATE_INTERVAL'
35
+ _SHARED_DISABLE_VARIABLE = 'NO_AUTO_UPDATE'
36
+ _SHARED_INTERVAL_VARIABLE = 'AUTO_UPDATE_INTERVAL'
36
37
 
37
38
  CI_VARIABLES = ('CI', 'BUILD_NUMBER', 'RUN_ID', 'GITHUB_ACTIONS', 'CODESPACES')
38
39
 
@@ -97,19 +98,20 @@ def notify(
97
98
  def enabled(config: Config, *, interactive: bool | None = None) -> tuple[bool, Skip | None]:
98
99
  """Whether a check would run, without touching the network or the clock.
99
100
 
100
- Backs a fleet dashboard and a `<tool> update --why`. The interval is
101
- deliberately not consulted: this answers "is this tool opted in", not "is it
102
- due".
101
+ For a caller that reports why a tool does or does not check, such as a
102
+ dashboard listing installed tools or a `<tool> update --why`. The interval
103
+ is deliberately not consulted: this answers "is this tool opted in", not "is
104
+ it due".
103
105
  """
104
106
  if _disabled_by_env(config.tool):
105
107
  return False, Skip.DISABLED
106
108
  if _asking_for_help():
107
109
  return False, Skip.HELP
108
- if not (_is_interactive() if interactive is None else interactive):
110
+ if not (is_interactive() if interactive is None else interactive):
109
111
  return False, Skip.NOT_A_TTY
110
112
  if _in_ci():
111
113
  return False, Skip.CI
112
- if _is_local_install(config.tool):
114
+ if _is_local_install(config):
113
115
  return False, Skip.LOCAL_INSTALL
114
116
  return True, None
115
117
 
@@ -122,9 +124,9 @@ def _asking_for_help() -> bool:
122
124
  screen. Nothing a notice is for happens on a help screen, and the check
123
125
  costs a releases-API call and a state write every time.
124
126
 
125
- It lands hardest on a reader that walks a tool's help. Measured 2026-08-22:
126
- reading doit's twenty commands meant twenty-one invocations, each one
127
- reaching this.
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.
128
130
 
129
131
  A `--` ends the scan, because everything after it is the command's own
130
132
  argument. Skipping when it should not have costs a missed notice, which is
@@ -201,14 +203,14 @@ def _emit(message: str, *, out: TextIO | None, defer: bool) -> None:
201
203
 
202
204
 
203
205
  def _disabled_by_env(tool: str) -> bool:
204
- return FLEET_DISABLE in os.environ or _tool_variable(tool, 'NO_AUTO_UPDATE') in os.environ
206
+ return _SHARED_DISABLE_VARIABLE in os.environ or _tool_variable(tool, 'NO_AUTO_UPDATE') in os.environ
205
207
 
206
208
 
207
209
  def _in_ci() -> bool:
208
210
  return any(name in os.environ for name in CI_VARIABLES)
209
211
 
210
212
 
211
- def _is_interactive(streams: Iterable[Any] | None = None) -> bool:
213
+ def is_interactive(streams: Iterable[Any] | None = None) -> bool:
212
214
  """Both streams must be terminals.
213
215
 
214
216
  Checking both matters: `tool list | jq` should still be allowed to notify on
@@ -232,35 +234,34 @@ def _is_interactive(streams: Iterable[Any] | None = None) -> bool:
232
234
  return True
233
235
 
234
236
 
235
- def _is_local_install(tool: str) -> bool:
237
+ def _is_local_install(config: Config) -> bool:
236
238
  """A local, editable, or branch-tracking install must never be nagged.
237
239
 
238
240
  Failing closed: anything that cannot be positively identified as a pinned
239
241
  release install is treated as local, because a false negative here prints a
240
- wrong notice on every command in a dev checkout.
242
+ wrong notice on every command in a dev checkout. `update` refuses the same
243
+ installs, through the same `installed_from_release`.
241
244
  """
242
245
  try:
243
- installation = read_installation(tool)
246
+ installation = read_installation(config.tool)
244
247
  except SelfUpdateError:
245
248
  return True
246
- if installation.kind is InstallKind.LOCAL:
247
- return True
248
- return installation.kind is InstallKind.GIT and not installation.revision
249
+ return not installed_from_release(installation, config.tag_prefix)
249
250
 
250
251
 
251
252
  def _interval(tool: str, override: dt.timedelta | None) -> dt.timedelta:
252
253
  if override is not None:
253
254
  return override
254
- for name in (_tool_variable(tool, 'AUTO_UPDATE_INTERVAL'), FLEET_INTERVAL):
255
+ for name in (_tool_variable(tool, 'AUTO_UPDATE_INTERVAL'), _SHARED_INTERVAL_VARIABLE):
255
256
  raw = os.environ.get(name)
256
257
  if raw:
257
- parsed = _parse_interval(raw)
258
+ parsed = parse_interval(raw)
258
259
  if parsed is not None:
259
260
  return parsed
260
261
  return DEFAULT_INTERVAL
261
262
 
262
263
 
263
- def _parse_interval(raw: str) -> dt.timedelta | None:
264
+ def parse_interval(raw: str) -> dt.timedelta | None:
264
265
  """Parse `30m`, `24h`, `7d`, or a bare number of seconds."""
265
266
  raw = raw.strip().lower()
266
267
  if not raw:
@@ -25,7 +25,6 @@ import typer
25
25
 
26
26
  from pyselfupdate.config import Config
27
27
  from pyselfupdate.errors import SelfUpdateError
28
- from pyselfupdate.install import Installation
29
28
  from pyselfupdate.install import exit_now
30
29
  from pyselfupdate.updater import changelog
31
30
  from pyselfupdate.updater import check
@@ -49,8 +48,13 @@ def run_update(config: Config, *, check_only: bool = False, skip_changelog: bool
49
48
 
50
49
  Split out from the command so a CLI that wants its own flags, or that nests
51
50
  the command under a group, does not have to reimplement the reporting. The
52
- output format is identical to goselfupdate's, because a fleet that reports
53
- the same thing three different ways is a fleet you have to read carefully.
51
+ output format is identical to goselfupdate's, so tools built on either
52
+ library report an update in the same words.
53
+
54
+ `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.
54
58
 
55
59
  The step order is the load-bearing part. Everything that reaches the network
56
60
  or imports a module happens before the install; after it, this process only
@@ -61,10 +65,8 @@ def run_update(config: Config, *, check_only: bool = False, skip_changelog: bool
61
65
  on, and so had just deleted.
62
66
  """
63
67
  tool = config.tool
64
- installation: Installation | None = None
65
68
  try:
66
- if not check_only:
67
- installation = require_updatable(config)
69
+ installation = require_updatable(config)
68
70
  result = check(config)
69
71
  except SelfUpdateError as error:
70
72
  typer.echo(f'✗ {tool} update failed: {error}', err=True)
@@ -76,7 +78,7 @@ def run_update(config: Config, *, check_only: bool = False, skip_changelog: bool
76
78
 
77
79
  subjects = [] if skip_changelog else changelog(config, result.current, result.latest)
78
80
 
79
- if installation is None:
81
+ if check_only:
80
82
  typer.echo(f'✓ {tool} update available: {result.current} → {result.latest}')
81
83
  _echo_changes(subjects)
82
84
  return
@@ -58,7 +58,12 @@ class Result:
58
58
 
59
59
 
60
60
  def check(config: Config) -> Result:
61
- """Report whether a newer release exists, without installing anything."""
61
+ """Report whether a newer release exists, without installing anything.
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
65
+ `require_updatable` first, as `update` and `typercmd.run_update` do.
66
+ """
62
67
  resolved = config.resolved()
63
68
  current = _current_version(resolved)
64
69
 
@@ -81,11 +86,34 @@ def require_updatable(config: Config) -> Installation:
81
86
  `typercmd.run_update` does, to fetch a changelog while the environment is
82
87
  still intact -- keeps the refusal ahead of the network by starting here.
83
88
  """
84
- installation = read_installation(config.resolved().tool)
85
- _require_updatable(installation)
89
+ resolved = config.resolved()
90
+ installation = read_installation(resolved.tool)
91
+ _require_updatable(installation, resolved.tag_prefix)
86
92
  return installation
87
93
 
88
94
 
95
+ def installed_from_release(installation: Installation, tag_prefix: str) -> bool:
96
+ """Whether the running build is a release, so its version can be compared with the latest one.
97
+
98
+ An index install is. A git install is when its revision is a release tag:
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.
103
+
104
+ A build from a branch reports a version that ranks wrongly against a
105
+ release. Versioned from git tags, a branch three commits past v1.2.3 builds
106
+ as `1.2.3-post.3+g<sha>`, a semver pre-release that ranks below the v1.2.3
107
+ it is ahead of.
108
+ """
109
+ if installation.kind is InstallKind.INDEX:
110
+ return True
111
+ if installation.kind is not InstallKind.GIT:
112
+ return False
113
+ revision = installation.revision
114
+ return revision.startswith(tag_prefix) and semver.is_valid(revision[len(tag_prefix) :])
115
+
116
+
89
117
  def install_release(config: Config, result: Result, installation: Installation, *, quiet: bool = True) -> Result:
90
118
  """Install the release `result` names, over the running one.
91
119
 
@@ -156,12 +184,12 @@ def changelog(config: Config, from_version: str, to_version: str) -> list[str]:
156
184
 
157
185
 
158
186
  def _current_version(config: Config) -> str:
159
- """The running version, canonicalized, rejecting one that is not a release.
187
+ """The running version, canonicalized, rejecting one that is not a semantic version.
160
188
 
161
- An install that tracks a git branch rather than a tag reports whatever
162
- version was in pyproject.toml when it was built, which says nothing about
163
- how far behind the checkout is. Comparing it against a tag would claim an
164
- update is available on every run, or none ever.
189
+ An empty version, from a distribution that is not installed, fails here, and
190
+ so does a PEP 440 form such as `1.2.3.post3.dev0`. A branch build's version
191
+ usually parses, so `installed_from_release` refuses a branch install from
192
+ the receipt instead.
165
193
  """
166
194
  canonical = semver.canonical(config.version)
167
195
  if not semver.is_valid(canonical):
@@ -169,14 +197,14 @@ def _current_version(config: Config) -> str:
169
197
  return canonical
170
198
 
171
199
 
172
- def _require_updatable(installation: Installation) -> None:
200
+ def _require_updatable(installation: Installation, tag_prefix: str) -> None:
173
201
  if installation.kind is InstallKind.LOCAL:
174
202
  raise LocalInstallError(
175
203
  f'{installation.tool} is installed from {installation.url}; update the checkout instead of reinstalling over it'
176
204
  )
177
- if installation.kind is InstallKind.GIT and not installation.revision:
205
+ if not installed_from_release(installation, tag_prefix):
178
206
  raise LocalInstallError(
179
- f'{installation.tool} is installed from a branch rather than a tag, '
207
+ f'{installation.tool} is installed from {installation.revision or "its default branch"} rather than a release tag, '
180
208
  f'so a release version cannot be compared against it; '
181
209
  f'reinstall from a tagged release to enable updates'
182
210
  )
@@ -1,300 +0,0 @@
1
- [project]
2
- name = "pyselfupdate"
3
- version = "0.5.0"
4
- description = "Self-update and update notification for Python CLIs installed with uv tool"
5
- license = "MIT"
6
- license-files = ["LICENSE"]
7
- readme = "README.md"
8
- keywords = [
9
- "uv",
10
- "cli",
11
- "self-update",
12
- "update-notifier",
13
- "release",
14
- ]
15
- classifiers = [
16
- "Development Status :: 4 - Beta",
17
- "Environment :: Console",
18
- "Intended Audience :: Developers",
19
- "Programming Language :: Python :: 3 :: Only",
20
- "Topic :: Software Development :: Libraries",
21
- "Topic :: System :: Software Distribution",
22
- "Typing :: Typed",
23
- ]
24
- requires-python = ">=3.11"
25
- dependencies = []
26
-
27
- [[project.authors]]
28
- name = "Chris Birch"
29
- email = "datapointchris@gmail.com"
30
-
31
- [project.optional-dependencies]
32
- typer = ["typer>=0.12.0"]
33
-
34
- [project.urls]
35
- Repository = "https://github.com/datapointchris/pyselfupdate"
36
- Issues = "https://github.com/datapointchris/pyselfupdate/issues"
37
- Changelog = "https://github.com/datapointchris/pyselfupdate/releases"
38
-
39
- [dependency-groups]
40
- dev = [
41
- "bandit>=1.7.8",
42
- "mypy>=1.10.0",
43
- "pre-commit>=4.3.0",
44
- "pytest>=8.0.0",
45
- "ruff==0.12.5",
46
- "typer>=0.12.0",
47
- ]
48
-
49
- [tool.bandit]
50
- exclude_dirs = [
51
- ".git",
52
- ".mypy_cache",
53
- ".pytest_cache",
54
- ".ruff_cache",
55
- ".venv",
56
- "tests",
57
- ]
58
- skips = [
59
- "B311",
60
- "B404",
61
- "B603",
62
- ]
63
-
64
- [tool.codespell]
65
- skip = "*.lock"
66
- check-filenames = true
67
- check-hidden = true
68
- builtin = "clear,rare"
69
- uri-ignore-words-list = "*"
70
-
71
- [tool.mypy]
72
- pretty = true
73
- ignore_missing_imports = true
74
- check_untyped_defs = false
75
- warn_return_any = false
76
-
77
- [tool.pytest.ini_options]
78
- minversion = "8.0"
79
- testpaths = ["tests"]
80
- verbosity_assertions = 2
81
-
82
- [tool.refurb]
83
- enable_all = true
84
-
85
- [tool.ruff]
86
- line-length = 140
87
- exclude = [
88
- ".git",
89
- "__pycache__",
90
- ".mypy_cache",
91
- ".ruff_cache",
92
- ".vscode",
93
- ".venv",
94
- "build",
95
- "dist",
96
- ]
97
-
98
- [tool.ruff.format]
99
- quote-style = "single"
100
- indent-style = "space"
101
- skip-magic-trailing-comma = false
102
- line-ending = "auto"
103
- docstring-code-format = true
104
-
105
- [tool.ruff.lint]
106
- select = [
107
- "E",
108
- "F",
109
- "UP",
110
- "B",
111
- "SIM",
112
- "I",
113
- "ICN",
114
- ]
115
- ignore = ["SIM108"]
116
-
117
- [tool.ruff.lint.isort]
118
- force-single-line = true
119
-
120
- [tool.ruff.lint.per-file-ignores]
121
- "__init__.py" = ["F401"]
122
-
123
- [tool.ruff.lint.flake8-import-conventions]
124
- banned-from = [
125
- "datetime",
126
- "pyspark.sql.functions",
127
- "pyspark.sql.types",
128
- ]
129
-
130
- [tool.ruff.lint.flake8-import-conventions.extend-aliases]
131
- datetime = "dt"
132
- sqlalchemy = "sa"
133
- "pyspark.sql.functions" = "sf"
134
- "pyspark.sql.types" = "st"
135
-
136
- [tool.semantic_release]
137
- version_toml = ["pyproject.toml:project.version"]
138
- commit_message = "build(release): {version}"
139
- build_command = """
140
- pip install uv
141
- uv lock --upgrade-package pyselfupdate
142
- git add uv.lock
143
- """
144
-
145
- [tool.pyright]
146
- typeCheckingMode = "standard"
147
- analyzeUnannotatedFunctions = true
148
-
149
- [tool.forge]
150
- managed = [
151
- [
152
- "ruff",
153
- "line-length",
154
- ],
155
- [
156
- "ruff",
157
- "format",
158
- "quote-style",
159
- ],
160
- [
161
- "ruff",
162
- "format",
163
- "indent-style",
164
- ],
165
- [
166
- "ruff",
167
- "format",
168
- "skip-magic-trailing-comma",
169
- ],
170
- [
171
- "ruff",
172
- "format",
173
- "line-ending",
174
- ],
175
- [
176
- "ruff",
177
- "format",
178
- "docstring-code-format",
179
- ],
180
- [
181
- "ruff",
182
- "lint",
183
- "select",
184
- ],
185
- [
186
- "ruff",
187
- "lint",
188
- "ignore",
189
- ],
190
- [
191
- "ruff",
192
- "lint",
193
- "isort",
194
- "force-single-line",
195
- ],
196
- [
197
- "ruff",
198
- "lint",
199
- "flake8-import-conventions",
200
- "banned-from",
201
- ],
202
- [
203
- "ruff",
204
- "lint",
205
- "flake8-import-conventions",
206
- "extend-aliases",
207
- "datetime",
208
- ],
209
- [
210
- "ruff",
211
- "lint",
212
- "flake8-import-conventions",
213
- "extend-aliases",
214
- "sqlalchemy",
215
- ],
216
- [
217
- "ruff",
218
- "lint",
219
- "flake8-import-conventions",
220
- "extend-aliases",
221
- "pyspark.sql.functions",
222
- ],
223
- [
224
- "ruff",
225
- "lint",
226
- "flake8-import-conventions",
227
- "extend-aliases",
228
- "pyspark.sql.types",
229
- ],
230
- [
231
- "ruff",
232
- "lint",
233
- "per-file-ignores",
234
- "__init__.py",
235
- ],
236
- [
237
- "mypy",
238
- "pretty",
239
- ],
240
- [
241
- "mypy",
242
- "ignore_missing_imports",
243
- ],
244
- [
245
- "mypy",
246
- "check_untyped_defs",
247
- ],
248
- [
249
- "mypy",
250
- "warn_return_any",
251
- ],
252
- [
253
- "pyright",
254
- "typeCheckingMode",
255
- ],
256
- [
257
- "pyright",
258
- "analyzeUnannotatedFunctions",
259
- ],
260
- [
261
- "codespell",
262
- "check-filenames",
263
- ],
264
- [
265
- "codespell",
266
- "check-hidden",
267
- ],
268
- [
269
- "codespell",
270
- "builtin",
271
- ],
272
- [
273
- "codespell",
274
- "uri-ignore-words-list",
275
- ],
276
- [
277
- "pytest",
278
- "ini_options",
279
- "verbosity_assertions",
280
- ],
281
- [
282
- "pytest",
283
- "ini_options",
284
- "minversion",
285
- ],
286
- [
287
- "pytest",
288
- "ini_options",
289
- "testpaths",
290
- ],
291
- ]
292
- pinned = ["ruff"]
293
-
294
- [tool.uv.build-backend]
295
- module-name = "pyselfupdate"
296
- module-root = "src"
297
-
298
- [build-system]
299
- requires = ["uv_build>=0.12.2,<0.13.0"]
300
- build-backend = "uv_build"
File without changes