pyselfupdate 0.2.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyselfupdate
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Self-update and update notification for Python CLIs installed with uv tool
5
5
  Keywords: uv,cli,self-update,update-notifier,release
6
6
  Author: Chris Birch
@@ -126,8 +126,23 @@ add_update_command(app, CONFIG) # gives you `mytool update [--check]`
126
126
  environment the running interpreter lives in**. Unlike replacing a Unix binary
127
127
  — where the process holds an inode and is untouched — this pulls modules out
128
128
  from under a live process, so anything imported afterwards may fail in ways
129
- that are hard to read. Make it the last thing your process does, or use
130
- `update_and_reexec` to replace the process with the new version immediately.
129
+ that are hard to read. Make it the last thing your process does, then call
130
+ `exit_now` — or use `update_and_reexec` to replace the process with the new
131
+ version immediately.
132
+
133
+ That cuts both ways: anything you want to *print* after the install has to be
134
+ fetched before it. `run_update` resolves its changelog first for exactly this
135
+ reason, and a caller that needs its own steps in between composes the three
136
+ pieces `update` is made of rather than working around it:
137
+
138
+ ```python
139
+ installation = require_updatable(config) # refuses a checkout, costs nothing
140
+ result = check(config) # network, environment still intact
141
+ notes = changelog(config, result.current, result.latest)
142
+ install_release(config, result, installation)
143
+ print(notes)
144
+ exit_now()
145
+ ```
131
146
 
132
147
  ## What will not be updated
133
148
 
@@ -102,8 +102,23 @@ add_update_command(app, CONFIG) # gives you `mytool update [--check]`
102
102
  environment the running interpreter lives in**. Unlike replacing a Unix binary
103
103
  — where the process holds an inode and is untouched — this pulls modules out
104
104
  from under a live process, so anything imported afterwards may fail in ways
105
- that are hard to read. Make it the last thing your process does, or use
106
- `update_and_reexec` to replace the process with the new version immediately.
105
+ that are hard to read. Make it the last thing your process does, then call
106
+ `exit_now` — or use `update_and_reexec` to replace the process with the new
107
+ version immediately.
108
+
109
+ That cuts both ways: anything you want to *print* after the install has to be
110
+ fetched before it. `run_update` resolves its changelog first for exactly this
111
+ reason, and a caller that needs its own steps in between composes the three
112
+ pieces `update` is made of rather than working around it:
113
+
114
+ ```python
115
+ installation = require_updatable(config) # refuses a checkout, costs nothing
116
+ result = check(config) # network, environment still intact
117
+ notes = changelog(config, result.current, result.latest)
118
+ install_release(config, result, installation)
119
+ print(notes)
120
+ exit_now()
121
+ ```
107
122
 
108
123
  ## What will not be updated
109
124
 
@@ -0,0 +1,239 @@
1
+ [project]
2
+ name = "pyselfupdate"
3
+ version = "0.2.2"
4
+ description = "Self-update and update notification for Python CLIs installed with uv tool"
5
+ readme = "README.md"
6
+ keywords = [
7
+ "uv",
8
+ "cli",
9
+ "self-update",
10
+ "update-notifier",
11
+ "release",
12
+ ]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Topic :: System :: Software Distribution",
21
+ "Typing :: Typed",
22
+ ]
23
+ requires-python = ">=3.11"
24
+ dependencies = []
25
+
26
+ [[project.authors]]
27
+ name = "Chris Birch"
28
+ email = "datapointchris@gmail.com"
29
+
30
+ [project.license]
31
+ text = "MIT"
32
+
33
+ [project.optional-dependencies]
34
+ typer = ["typer>=0.12.0"]
35
+
36
+ [project.urls]
37
+ Repository = "https://github.com/datapointchris/pyselfupdate"
38
+ Issues = "https://github.com/datapointchris/pyselfupdate/issues"
39
+ Changelog = "https://github.com/datapointchris/pyselfupdate/blob/main/CHANGELOG.md"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "bandit>=1.7.8",
44
+ "mypy>=1.10.0",
45
+ "pre-commit>=4.3.0",
46
+ "pytest>=8.0.0",
47
+ "ruff>=0.7.0",
48
+ "typer>=0.12.0",
49
+ ]
50
+
51
+ [tool.bandit]
52
+ exclude_dirs = [
53
+ ".git",
54
+ ".mypy_cache",
55
+ ".pytest_cache",
56
+ ".ruff_cache",
57
+ ".venv",
58
+ "tests",
59
+ ]
60
+ skips = [
61
+ "B311",
62
+ "B404",
63
+ "B603",
64
+ ]
65
+
66
+ [tool.codespell]
67
+ skip = "*.lock"
68
+ check-filenames = true
69
+
70
+ [tool.mypy]
71
+ pretty = true
72
+ ignore_missing_imports = true
73
+ check_untyped_defs = false
74
+ warn_return_any = false
75
+
76
+ [tool.pytest.ini_options]
77
+ addopts = "-vv"
78
+ minversion = "8.0"
79
+ testpaths = ["tests"]
80
+
81
+ [tool.refurb]
82
+ enable_all = true
83
+
84
+ [tool.ruff]
85
+ line-length = 140
86
+ exclude = [
87
+ ".git",
88
+ "__pycache__",
89
+ ".mypy_cache",
90
+ ".ruff_cache",
91
+ ".vscode",
92
+ ".venv",
93
+ "build",
94
+ "dist",
95
+ ]
96
+
97
+ [tool.ruff.format]
98
+ quote-style = "single"
99
+ indent-style = "space"
100
+ skip-magic-trailing-comma = false
101
+ line-ending = "auto"
102
+ docstring-code-format = true
103
+
104
+ [tool.ruff.lint]
105
+ select = [
106
+ "E",
107
+ "F",
108
+ "UP",
109
+ "B",
110
+ "SIM",
111
+ "I",
112
+ ]
113
+ ignore = ["SIM108"]
114
+
115
+ [tool.ruff.lint.isort]
116
+ force-single-line = true
117
+
118
+ [tool.ruff.lint.per-file-ignores]
119
+ "__init__.py" = ["F401"]
120
+
121
+ [tool.semantic_release]
122
+ version_toml = ["pyproject.toml:project.version"]
123
+ branch = "main"
124
+ commit_message = "build(release): {version}"
125
+ build_command = """
126
+ pip install uv
127
+ uv lock --upgrade-package pyselfupdate
128
+ git add uv.lock
129
+ """
130
+
131
+ [tool.pyright]
132
+ typeCheckingMode = "standard"
133
+ analyzeUnannotatedFunctions = true
134
+
135
+ [tool.forge]
136
+ managed = [
137
+ [
138
+ "ruff",
139
+ "line-length",
140
+ ],
141
+ [
142
+ "ruff",
143
+ "format",
144
+ "quote-style",
145
+ ],
146
+ [
147
+ "ruff",
148
+ "format",
149
+ "indent-style",
150
+ ],
151
+ [
152
+ "ruff",
153
+ "format",
154
+ "skip-magic-trailing-comma",
155
+ ],
156
+ [
157
+ "ruff",
158
+ "format",
159
+ "line-ending",
160
+ ],
161
+ [
162
+ "ruff",
163
+ "format",
164
+ "docstring-code-format",
165
+ ],
166
+ [
167
+ "ruff",
168
+ "lint",
169
+ "select",
170
+ ],
171
+ [
172
+ "ruff",
173
+ "lint",
174
+ "ignore",
175
+ ],
176
+ [
177
+ "ruff",
178
+ "lint",
179
+ "isort",
180
+ "force-single-line",
181
+ ],
182
+ [
183
+ "ruff",
184
+ "lint",
185
+ "per-file-ignores",
186
+ "__init__.py",
187
+ ],
188
+ [
189
+ "mypy",
190
+ "pretty",
191
+ ],
192
+ [
193
+ "mypy",
194
+ "ignore_missing_imports",
195
+ ],
196
+ [
197
+ "mypy",
198
+ "check_untyped_defs",
199
+ ],
200
+ [
201
+ "mypy",
202
+ "warn_return_any",
203
+ ],
204
+ [
205
+ "pyright",
206
+ "typeCheckingMode",
207
+ ],
208
+ [
209
+ "pyright",
210
+ "analyzeUnannotatedFunctions",
211
+ ],
212
+ [
213
+ "codespell",
214
+ "check-filenames",
215
+ ],
216
+ [
217
+ "pytest",
218
+ "ini_options",
219
+ "addopts",
220
+ ],
221
+ [
222
+ "pytest",
223
+ "ini_options",
224
+ "minversion",
225
+ ],
226
+ [
227
+ "pytest",
228
+ "ini_options",
229
+ "testpaths",
230
+ ],
231
+ ]
232
+
233
+ [tool.uv.build-backend]
234
+ module-name = "pyselfupdate"
235
+ module-root = "src"
236
+
237
+ [build-system]
238
+ requires = ["uv_build>=0.11.32,<0.12.0"]
239
+ build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyselfupdate"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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 = { text = "MIT" }
@@ -66,6 +66,9 @@ ignore_missing_imports = true
66
66
  check_untyped_defs = false
67
67
  warn_return_any = false
68
68
 
69
+ # `standard` replaces the per-rule opt-outs repos used to accumulate; basedpyright
70
+ # defaults to `recommended`, which turns every one of them back on. Named
71
+ # `pyright` so both basedpyright and Pylance read it.
69
72
 
70
73
  [tool.pytest.ini_options]
71
74
  addopts = "-vv"
@@ -99,20 +102,16 @@ docstring-code-format = true
99
102
 
100
103
  [tool.ruff.lint]
101
104
  select = [
102
- # pycodestyle
103
- "E",
104
- # Pyflakes
105
- "F",
106
- # pyupgrade
107
- "UP",
108
- # flake8-bugbear
109
- "B",
110
- # flake8-simplify
111
- "SIM",
112
- # isort
113
- "I",
105
+ "E", # pycodestyle errors
106
+ "F", # pyflakes
107
+ "UP", # pyupgrade
108
+ "B", # flake8-bugbear
109
+ "SIM", # flake8-simplify
110
+ "I", # isort
111
+ ]
112
+ ignore = [
113
+ "SIM108", # ternary operator — often less readable
114
114
  ]
115
- ignore = ["SIM108"]
116
115
 
117
116
  [tool.ruff.lint.isort]
118
117
  force-single-line = true
@@ -120,7 +119,6 @@ force-single-line = true
120
119
  [tool.ruff.lint.per-file-ignores]
121
120
  "__init__.py" = ["F401"]
122
121
 
123
-
124
122
  [tool.semantic_release]
125
123
  version_toml = ["pyproject.toml:project.version"]
126
124
  branch = "main"
@@ -135,6 +133,37 @@ build_command = """
135
133
  # ---------- Build System ---------- #
136
134
 
137
135
 
136
+ [tool.pyright]
137
+ typeCheckingMode = "standard"
138
+ analyzeUnannotatedFunctions = true # or pylance stops analyzing in vscode
139
+
140
+ [tool.forge]
141
+ # Keys the standard owns, written by `forge dies run maintenance/sync-pyproject.sh`.
142
+ # Dropping one from the template removes it here on the next sync; a key absent
143
+ # from this list belongs to the project and is never touched. Do not hand-edit.
144
+ managed = [
145
+ ["ruff", "line-length"],
146
+ ["ruff", "format", "quote-style"],
147
+ ["ruff", "format", "indent-style"],
148
+ ["ruff", "format", "skip-magic-trailing-comma"],
149
+ ["ruff", "format", "line-ending"],
150
+ ["ruff", "format", "docstring-code-format"],
151
+ ["ruff", "lint", "select"],
152
+ ["ruff", "lint", "ignore"],
153
+ ["ruff", "lint", "isort", "force-single-line"],
154
+ ["ruff", "lint", "per-file-ignores", "__init__.py"],
155
+ ["mypy", "pretty"],
156
+ ["mypy", "ignore_missing_imports"],
157
+ ["mypy", "check_untyped_defs"],
158
+ ["mypy", "warn_return_any"],
159
+ ["pyright", "typeCheckingMode"],
160
+ ["pyright", "analyzeUnannotatedFunctions"],
161
+ ["codespell", "check-filenames"],
162
+ ["pytest", "ini_options", "addopts"],
163
+ ["pytest", "ini_options", "minversion"],
164
+ ["pytest", "ini_options", "testpaths"],
165
+ ]
166
+
138
167
  [build-system]
139
168
  requires = ["uv_build>=0.11.32,<0.12.0"]
140
169
  build-backend = "uv_build"
@@ -28,6 +28,7 @@ from pyselfupdate.errors import SourceError
28
28
  from pyselfupdate.github import GitHubSource
29
29
  from pyselfupdate.install import Installation
30
30
  from pyselfupdate.install import InstallKind
31
+ from pyselfupdate.install import exit_now
31
32
  from pyselfupdate.install import read_installation
32
33
  from pyselfupdate.notifier import Outcome
33
34
  from pyselfupdate.notifier import Skip
@@ -40,6 +41,8 @@ from pyselfupdate.state import read as read_state
40
41
  from pyselfupdate.updater import Result
41
42
  from pyselfupdate.updater import changelog
42
43
  from pyselfupdate.updater import check
44
+ from pyselfupdate.updater import install_release
45
+ from pyselfupdate.updater import require_updatable
43
46
  from pyselfupdate.updater import update
44
47
  from pyselfupdate.updater import update_and_reexec
45
48
 
@@ -64,9 +67,12 @@ __all__ = [
64
67
  'changelog',
65
68
  'check',
66
69
  'enabled',
70
+ 'exit_now',
71
+ 'install_release',
67
72
  'notify',
68
73
  'read_installation',
69
74
  'read_state',
75
+ 'require_updatable',
70
76
  'update',
71
77
  'update_and_reexec',
72
78
  ]
@@ -156,6 +156,25 @@ def run_install(requirement: str, *, quiet: bool = True) -> None:
156
156
  raise InstallFailedError(f'uv tool install failed: {message}')
157
157
 
158
158
 
159
+ def exit_now(code: int = 0) -> None:
160
+ """End this process immediately, after flushing what it has written.
161
+
162
+ The sibling of `reexec` for when there is nothing left to run. Both exist
163
+ for the same reason: the environment has been rewritten, so no further
164
+ import can be trusted. `sys.exit` raises SystemExit, which unwinds through
165
+ whatever CLI framework called us and then through interpreter shutdown --
166
+ and both are free to import a module they had not needed yet, from a
167
+ directory that is no longer the one this process started in. `os._exit`
168
+ skips both, which is why the flushes are done here rather than left to the
169
+ shutdown that no longer happens.
170
+
171
+ Never returns.
172
+ """
173
+ sys.stdout.flush()
174
+ sys.stderr.flush()
175
+ os._exit(code)
176
+
177
+
159
178
  def reexec() -> None:
160
179
  """Replace this process with the newly installed one.
161
180
 
@@ -3,7 +3,7 @@
3
3
  This layer never installs. `<tool> update` installs, and `<tool> update` is
4
4
  where errors are printed; a failure here is recorded in the state file and
5
5
  swallowed. That single rule is what keeps a dev checkout from printing an
6
- upgrade failure on every invocation.
6
+ update failure on every invocation.
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -96,7 +96,7 @@ def notify(
96
96
  def enabled(config: Config, *, interactive: bool | None = None) -> tuple[bool, Skip | None]:
97
97
  """Whether a check would run, without touching the network or the clock.
98
98
 
99
- Backs `<tool> update --why` and any fleet dashboard. The interval is
99
+ Backs a fleet dashboard and a `<tool> update --why`. The interval is
100
100
  deliberately not consulted: this answers "is this tool opted in", not "is it
101
101
  due".
102
102
  """
@@ -21,15 +21,16 @@ rather than at the first call.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
- import sys
25
-
26
24
  import typer
27
25
 
28
26
  from pyselfupdate.config import Config
29
27
  from pyselfupdate.errors import SelfUpdateError
28
+ from pyselfupdate.install import Installation
29
+ from pyselfupdate.install import exit_now
30
30
  from pyselfupdate.updater import changelog
31
31
  from pyselfupdate.updater import check
32
- from pyselfupdate.updater import update
32
+ from pyselfupdate.updater import install_release
33
+ from pyselfupdate.updater import require_updatable
33
34
 
34
35
 
35
36
  def add_update_command(app: typer.Typer, config: Config, *, name: str = 'update') -> None:
@@ -50,37 +51,51 @@ def run_update(config: Config, *, check_only: bool = False, skip_changelog: bool
50
51
  the command under a group, does not have to reimplement the reporting. The
51
52
  output format is identical to goselfupdate's, because a fleet that reports
52
53
  the same thing three different ways is a fleet you have to read carefully.
54
+
55
+ The step order is the load-bearing part. Everything that reaches the network
56
+ or imports a module happens before the install; after it, this process only
57
+ prints what it already holds and exits without unwinding. Both halves of
58
+ that are lessons from a real failure: syncer 4.0.0 fetched its changelog
59
+ after replacing its own environment, and died on httpx's lazy import of
60
+ httpcore -- a module the release it had just installed no longer depended
61
+ on, and so had just deleted.
53
62
  """
54
63
  tool = config.tool
64
+ installation: Installation | None = None
55
65
  try:
56
- result = check(config) if check_only else update(config)
66
+ if not check_only:
67
+ installation = require_updatable(config)
68
+ result = check(config)
57
69
  except SelfUpdateError as error:
58
- typer.echo(f'✗ {tool} upgrade failed: {error}', err=True)
70
+ typer.echo(f'✗ {tool} update failed: {error}', err=True)
59
71
  raise typer.Exit(1) from error
60
72
 
61
73
  if not result.update_available:
62
74
  typer.echo(f'✓ {tool} already at latest: {result.latest}')
63
75
  return
64
76
 
65
- if result.applied:
66
- typer.echo(f'✓ {tool} upgraded: {result.current} → {result.latest}')
67
- else:
68
- typer.echo(f'✓ {tool} update available: {result.current} → {result.latest}')
77
+ subjects = [] if skip_changelog else changelog(config, result.current, result.latest)
69
78
 
70
- if skip_changelog:
79
+ if installation is None:
80
+ typer.echo(f'✓ {tool} update available: {result.current} → {result.latest}')
81
+ _echo_changes(subjects)
71
82
  return
72
83
 
73
- subjects = changelog(config, result.current, result.latest)
84
+ try:
85
+ install_release(config, result, installation)
86
+ except SelfUpdateError as error:
87
+ typer.echo(f'✗ {tool} update failed: {error}', err=True)
88
+ raise typer.Exit(1) from error
89
+
90
+ typer.echo(f'✓ {tool} updated: {result.current} → {result.latest}')
91
+ _echo_changes(subjects)
92
+ exit_now()
93
+
94
+
95
+ def _echo_changes(subjects: list[str]) -> None:
74
96
  if not subjects:
75
97
  return
76
-
77
98
  typer.echo('')
78
99
  typer.echo('Changes:')
79
100
  for subject in subjects:
80
101
  typer.echo(f' • {subject}')
81
-
82
- # The environment this interpreter is running in has just been replaced.
83
- # Nothing more may be imported, so the process ends here rather than
84
- # returning into a CLI that might touch a lazily-loaded module.
85
- if result.applied:
86
- sys.stdout.flush()
@@ -55,18 +55,27 @@ def check(config: Config) -> Result:
55
55
  return Result(current=current, latest=latest, release=release)
56
56
 
57
57
 
58
- def update(config: Config, *, quiet: bool = True) -> Result:
59
- """Install the latest release over the running one.
58
+ def require_updatable(config: Config) -> Installation:
59
+ """The installation an update would rewrite, refusing one that must not be.
60
60
 
61
- A no-op returning `applied=False` when already current. Note that on
62
- success the running interpreter's environment has been rewritten underneath
63
- it -- see `pyselfupdate.install.reexec`.
61
+ Public because it is the only part of an update that costs nothing and can
62
+ still refuse outright. A caller that orders the steps itself -- as
63
+ `typercmd.run_update` does, to fetch a changelog while the environment is
64
+ still intact -- keeps the refusal ahead of the network by starting here.
64
65
  """
65
- resolved = config.resolved()
66
- installation = read_installation(resolved.tool)
66
+ installation = read_installation(config.resolved().tool)
67
67
  _require_updatable(installation)
68
+ return installation
68
69
 
69
- result = check(resolved)
70
+
71
+ def install_release(config: Config, result: Result, installation: Installation, *, quiet: bool = True) -> Result:
72
+ """Install the release `result` names, over the running one.
73
+
74
+ A no-op returning the result unchanged when there is nothing newer. On
75
+ success this interpreter's environment has been rewritten underneath it, so
76
+ the caller may not import anything afterwards -- see
77
+ `pyselfupdate.install.reexec` and `pyselfupdate.install.exit_now`.
78
+ """
70
79
  if not result.update_available or result.release is None:
71
80
  return result
72
81
 
@@ -81,6 +90,20 @@ def update(config: Config, *, quiet: bool = True) -> Result:
81
90
  )
82
91
 
83
92
 
93
+ def update(config: Config, *, quiet: bool = True) -> Result:
94
+ """Check for a newer release and install it, in one call.
95
+
96
+ A no-op returning `applied=False` when already current. The composition of
97
+ `require_updatable`, `check` and `install_release`, in the order that keeps
98
+ a refusal cheap; a caller needing to do work between the check and the
99
+ install calls those three itself.
100
+ """
101
+ resolved = config.resolved()
102
+ installation = require_updatable(resolved)
103
+ result = check(resolved)
104
+ return install_release(resolved, result, installation, quiet=quiet)
105
+
106
+
84
107
  def update_and_reexec(config: Config, *, quiet: bool = True) -> Result:
85
108
  """`update`, then replace this process when anything was installed.
86
109