pyselfupdate 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyselfupdate
3
+ Version: 0.1.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: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
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/blob/main/CHANGELOG.md
22
+ Provides-Extra: typer
23
+ Description-Content-Type: text/markdown
24
+
25
+ # pyselfupdate
26
+
27
+ Self-update and update notification for Python CLIs installed with `uv tool`.
28
+
29
+ Two things, used independently: tell the user once a day that a newer release
30
+ exists, and install it when they ask. No runtime dependencies.
31
+
32
+ ```python
33
+ from pyselfupdate import Config, notify, update
34
+
35
+ config = Config(tool='mytool', owner='you')
36
+
37
+ notify(config) # once a day, one line if behind. Never raises.
38
+ update(config) # install the latest release. Raises on failure.
39
+ ```
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ uv add pyselfupdate
45
+
46
+ # with the ready-made typer command
47
+ uv add "pyselfupdate[typer]"
48
+ ```
49
+
50
+ Requires Python 3.11+.
51
+
52
+ ## Why
53
+
54
+ A CLI distributed with `uv tool install` has no way to tell its user a newer
55
+ version exists, so it silently drifts. The usual fix drags an HTTP client, a
56
+ TOML parser and a version library into a tool that had none of them.
57
+
58
+ This package has zero runtime dependencies — `urllib` for the network,
59
+ `tomllib` for uv's receipt, and its own semver implementation — and CI enforces
60
+ that by importing every module into a virtual environment containing nothing
61
+ else.
62
+
63
+ ## notify
64
+
65
+ Put it in your CLI's root callback and ignore the result:
66
+
67
+ ```python
68
+ import typer
69
+ from pyselfupdate import Config, notify
70
+
71
+ app = typer.Typer()
72
+ CONFIG = Config(tool='mytool', owner='you')
73
+
74
+
75
+ @app.callback()
76
+ def main() -> None:
77
+ notify(CONFIG)
78
+ ```
79
+
80
+ Once per 24 hours, if a newer release exists, one line goes to stderr **after**
81
+ your command's own output:
82
+
83
+ ```text
84
+ mytool v1.4.0 available (running v1.3.2) — run `mytool update`
85
+ ```
86
+
87
+ It never raises, never installs anything, and never prints an error. A failed
88
+ check is recorded in the state file and swallowed, because an update notice
89
+ must not be able to break the command the user actually typed.
90
+
91
+ Nothing is printed when any of these hold:
92
+
93
+ | Condition | Why |
94
+ | --- | --- |
95
+ | `NO_AUTO_UPDATE` or `MYTOOL_NO_AUTO_UPDATE` is set | Opted out |
96
+ | `CI`, `BUILD_NUMBER`, `RUN_ID`, `GITHUB_ACTIONS`, `CODESPACES` | Not a human |
97
+ | 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 |
99
+ | Checked within the interval | One request per day, not per invocation |
100
+
101
+ Presence-only, any value: `NO_AUTO_UPDATE=0` disables it, the same way
102
+ [`NO_COLOR`](https://no-color.org) works. Set the interval separately with
103
+ `AUTO_UPDATE_INTERVAL=6h` or `MYTOOL_AUTO_UPDATE_INTERVAL=30m`.
104
+
105
+ ## update
106
+
107
+ ```python
108
+ from pyselfupdate import Config, check, update
109
+
110
+ result = check(config) # no filesystem, no install
111
+ if result.update_available:
112
+ print(result.current, '->', result.latest)
113
+
114
+ result = update(config) # installs, raises on failure
115
+ ```
116
+
117
+ Or take the whole command:
118
+
119
+ ```python
120
+ from pyselfupdate.typercmd import add_update_command
121
+
122
+ add_update_command(app, CONFIG) # gives you `mytool update [--check]`
123
+ ```
124
+
125
+ `update` runs `uv tool install --force`, which **rebuilds the virtual
126
+ environment the running interpreter lives in**. Unlike replacing a Unix binary
127
+ — where the process holds an inode and is untouched — this pulls modules out
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.
131
+
132
+ ## What will not be updated
133
+
134
+ Read from uv's own receipt, written at install time, rather than guessed at
135
+ runtime:
136
+
137
+ | Receipt | Result |
138
+ | --- | --- |
139
+ | `git = "...git?rev=v1.2.3"` | Updatable |
140
+ | `name = "mytool"` (from an index) | Updatable |
141
+ | `git = "...git"` with no `rev` | Refused — tracks a branch, so its version says nothing about how far behind it is |
142
+ | `directory` / `path` / `editable` | Refused — reinstalling would discard a working copy |
143
+
144
+ A tool that cannot be identified at all is treated as local and left alone.
145
+
146
+ ## Configuration
147
+
148
+ ```python
149
+ Config(
150
+ tool='mytool', # required: uv tool name, state dir, env prefix
151
+ owner='you', # GitHub owner
152
+ repo='mytool', # defaults to tool
153
+ package='mytool', # distribution name, defaults to tool
154
+ version='1.2.3', # defaults to the installed distribution's metadata
155
+ token='', # defaults to $GITHUB_TOKEN, then $GH_TOKEN
156
+ tag_prefix='', # e.g. 'cli/' for tags like cli/v1.2.3
157
+ allow_prerelease=False,
158
+ source=None, # a custom Source; anything with latest_release()
159
+ )
160
+ ```
161
+
162
+ Without a token, GitHub allows 60 API requests per hour per IP and rejects
163
+ private repositories outright. One check per day per tool is far inside that; a
164
+ shared egress address is not.
165
+
166
+ ## State
167
+
168
+ `${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate.json`, written atomically:
169
+
170
+ ```json
171
+ {
172
+ "schema": 1,
173
+ "tool": "mytool",
174
+ "checked_at": "2026-07-26T15:07:15Z",
175
+ "checked_at_epoch": 1785078435,
176
+ "current_version": "v1.3.2",
177
+ "latest_version": "v1.4.0",
178
+ "last_error": "",
179
+ "skip": ""
180
+ }
181
+ ```
182
+
183
+ State, not config and not cache: it persists across runs, it is not authored by
184
+ the user, and deleting it changes behaviour rather than merely costing a
185
+ recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
186
+ is where `gh` puts the same thing.
187
+
188
+ The timestamp is written **before** the network call. `gh` stamps only on
189
+ success, so a rate-limited or offline user re-hits the API on every invocation
190
+ until the window resets; an interval exists to bound the request rate, and only
191
+ this ordering actually does that.
192
+
193
+ ## Siblings
194
+
195
+ The same design in two other languages, sharing the state schema and the
196
+ environment-variable contract:
197
+
198
+ - [goselfupdate](https://github.com/datapointchris/goselfupdate) — replaces a Go binary
199
+ - [bashselfupdate](https://github.com/datapointchris/bashselfupdate) — moves a git checkout to its newest tag
200
+
201
+ ## Licence
202
+
203
+ MIT
@@ -0,0 +1,179 @@
1
+ # pyselfupdate
2
+
3
+ Self-update and update notification for Python CLIs installed with `uv tool`.
4
+
5
+ Two things, used independently: tell the user once a day that a newer release
6
+ exists, and install it when they ask. No runtime dependencies.
7
+
8
+ ```python
9
+ from pyselfupdate import Config, notify, update
10
+
11
+ config = Config(tool='mytool', owner='you')
12
+
13
+ notify(config) # once a day, one line if behind. Never raises.
14
+ update(config) # install the latest release. Raises on failure.
15
+ ```
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ uv add pyselfupdate
21
+
22
+ # with the ready-made typer command
23
+ uv add "pyselfupdate[typer]"
24
+ ```
25
+
26
+ Requires Python 3.11+.
27
+
28
+ ## Why
29
+
30
+ A CLI distributed with `uv tool install` has no way to tell its user a newer
31
+ version exists, so it silently drifts. The usual fix drags an HTTP client, a
32
+ TOML parser and a version library into a tool that had none of them.
33
+
34
+ This package has zero runtime dependencies — `urllib` for the network,
35
+ `tomllib` for uv's receipt, and its own semver implementation — and CI enforces
36
+ that by importing every module into a virtual environment containing nothing
37
+ else.
38
+
39
+ ## notify
40
+
41
+ Put it in your CLI's root callback and ignore the result:
42
+
43
+ ```python
44
+ import typer
45
+ from pyselfupdate import Config, notify
46
+
47
+ app = typer.Typer()
48
+ CONFIG = Config(tool='mytool', owner='you')
49
+
50
+
51
+ @app.callback()
52
+ def main() -> None:
53
+ notify(CONFIG)
54
+ ```
55
+
56
+ Once per 24 hours, if a newer release exists, one line goes to stderr **after**
57
+ your command's own output:
58
+
59
+ ```text
60
+ mytool v1.4.0 available (running v1.3.2) — run `mytool update`
61
+ ```
62
+
63
+ It never raises, never installs anything, and never prints an error. A failed
64
+ check is recorded in the state file and swallowed, because an update notice
65
+ must not be able to break the command the user actually typed.
66
+
67
+ Nothing is printed when any of these hold:
68
+
69
+ | Condition | Why |
70
+ | --- | --- |
71
+ | `NO_AUTO_UPDATE` or `MYTOOL_NO_AUTO_UPDATE` is set | Opted out |
72
+ | `CI`, `BUILD_NUMBER`, `RUN_ID`, `GITHUB_ACTIONS`, `CODESPACES` | Not a human |
73
+ | 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 |
75
+ | Checked within the interval | One request per day, not per invocation |
76
+
77
+ Presence-only, any value: `NO_AUTO_UPDATE=0` disables it, the same way
78
+ [`NO_COLOR`](https://no-color.org) works. Set the interval separately with
79
+ `AUTO_UPDATE_INTERVAL=6h` or `MYTOOL_AUTO_UPDATE_INTERVAL=30m`.
80
+
81
+ ## update
82
+
83
+ ```python
84
+ from pyselfupdate import Config, check, update
85
+
86
+ result = check(config) # no filesystem, no install
87
+ if result.update_available:
88
+ print(result.current, '->', result.latest)
89
+
90
+ result = update(config) # installs, raises on failure
91
+ ```
92
+
93
+ Or take the whole command:
94
+
95
+ ```python
96
+ from pyselfupdate.typercmd import add_update_command
97
+
98
+ add_update_command(app, CONFIG) # gives you `mytool update [--check]`
99
+ ```
100
+
101
+ `update` runs `uv tool install --force`, which **rebuilds the virtual
102
+ environment the running interpreter lives in**. Unlike replacing a Unix binary
103
+ — where the process holds an inode and is untouched — this pulls modules out
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.
107
+
108
+ ## What will not be updated
109
+
110
+ Read from uv's own receipt, written at install time, rather than guessed at
111
+ runtime:
112
+
113
+ | Receipt | Result |
114
+ | --- | --- |
115
+ | `git = "...git?rev=v1.2.3"` | Updatable |
116
+ | `name = "mytool"` (from an index) | Updatable |
117
+ | `git = "...git"` with no `rev` | Refused — tracks a branch, so its version says nothing about how far behind it is |
118
+ | `directory` / `path` / `editable` | Refused — reinstalling would discard a working copy |
119
+
120
+ A tool that cannot be identified at all is treated as local and left alone.
121
+
122
+ ## Configuration
123
+
124
+ ```python
125
+ Config(
126
+ tool='mytool', # required: uv tool name, state dir, env prefix
127
+ owner='you', # GitHub owner
128
+ repo='mytool', # defaults to tool
129
+ package='mytool', # distribution name, defaults to tool
130
+ version='1.2.3', # defaults to the installed distribution's metadata
131
+ token='', # defaults to $GITHUB_TOKEN, then $GH_TOKEN
132
+ tag_prefix='', # e.g. 'cli/' for tags like cli/v1.2.3
133
+ allow_prerelease=False,
134
+ source=None, # a custom Source; anything with latest_release()
135
+ )
136
+ ```
137
+
138
+ Without a token, GitHub allows 60 API requests per hour per IP and rejects
139
+ private repositories outright. One check per day per tool is far inside that; a
140
+ shared egress address is not.
141
+
142
+ ## State
143
+
144
+ `${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate.json`, written atomically:
145
+
146
+ ```json
147
+ {
148
+ "schema": 1,
149
+ "tool": "mytool",
150
+ "checked_at": "2026-07-26T15:07:15Z",
151
+ "checked_at_epoch": 1785078435,
152
+ "current_version": "v1.3.2",
153
+ "latest_version": "v1.4.0",
154
+ "last_error": "",
155
+ "skip": ""
156
+ }
157
+ ```
158
+
159
+ State, not config and not cache: it persists across runs, it is not authored by
160
+ the user, and deleting it changes behaviour rather than merely costing a
161
+ recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
162
+ is where `gh` puts the same thing.
163
+
164
+ The timestamp is written **before** the network call. `gh` stamps only on
165
+ success, so a rate-limited or offline user re-hits the API on every invocation
166
+ until the window resets; an interval exists to bound the request rate, and only
167
+ this ordering actually does that.
168
+
169
+ ## Siblings
170
+
171
+ The same design in two other languages, sharing the state schema and the
172
+ environment-variable contract:
173
+
174
+ - [goselfupdate](https://github.com/datapointchris/goselfupdate) — replaces a Go binary
175
+ - [bashselfupdate](https://github.com/datapointchris/bashselfupdate) — moves a git checkout to its newest tag
176
+
177
+ ## Licence
178
+
179
+ MIT
@@ -0,0 +1,144 @@
1
+ [project]
2
+ name = "pyselfupdate"
3
+ version = "0.1.0"
4
+ description = "Self-update and update notification for Python CLIs installed with uv tool"
5
+ authors = [{ name = "Chris Birch", email = "datapointchris@gmail.com" }]
6
+ license = { text = "MIT" }
7
+ readme = "README.md"
8
+ keywords = ["uv", "cli", "self-update", "update-notifier", "release"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Topic :: Software Development :: Libraries",
16
+ "Topic :: System :: Software Distribution",
17
+ "Typing :: Typed",
18
+ ]
19
+
20
+ # The floor is deliberately below the 3.13 the author's own tools use. Raising
21
+ # it excludes callers and needs a reason beyond convenience. 3.11 is what
22
+ # `tomllib` requires, and reading uv's receipt without a TOML dependency is what
23
+ # keeps this package dependency-free.
24
+ requires-python = ">=3.11"
25
+
26
+ # Empty, and enforced empty by CI. This is the package's main differentiator:
27
+ # adding an update notice to a CLI should not drag an HTTP client, a TOML parser
28
+ # and a version library into its dependency tree.
29
+ dependencies = []
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/blob/main/CHANGELOG.md"
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.7.0",
46
+ "typer>=0.12.0",
47
+ ]
48
+
49
+
50
+ # ---------- Tool Configurations ---------- #
51
+
52
+
53
+ [tool.bandit]
54
+ exclude_dirs = [".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "tests"]
55
+ skips = ["B311", "B404", "B603"]
56
+
57
+
58
+ [tool.codespell]
59
+ skip = '*.lock'
60
+ check-filenames = true
61
+
62
+
63
+ [tool.mypy]
64
+ pretty = true
65
+ ignore_missing_imports = true
66
+ check_untyped_defs = false
67
+ warn_return_any = false
68
+
69
+
70
+ [tool.pytest.ini_options]
71
+ addopts = "-vv"
72
+ minversion = "8.0"
73
+ testpaths = ["tests"]
74
+
75
+
76
+ [tool.refurb]
77
+ enable_all = true
78
+
79
+
80
+ [tool.ruff]
81
+ line-length = 140
82
+ exclude = [
83
+ ".git",
84
+ "__pycache__",
85
+ ".mypy_cache",
86
+ ".ruff_cache",
87
+ ".vscode",
88
+ ".venv",
89
+ "build",
90
+ "dist",
91
+ ]
92
+
93
+ [tool.ruff.format]
94
+ quote-style = "single"
95
+ indent-style = "space"
96
+ skip-magic-trailing-comma = false
97
+ line-ending = "auto"
98
+ docstring-code-format = true
99
+
100
+ [tool.ruff.lint]
101
+ 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",
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
+
124
+ [tool.semantic_release]
125
+ version_toml = ["pyproject.toml:project.version"]
126
+ branch = "main"
127
+ commit_message = "build(release): {version}"
128
+ build_command = """
129
+ pip install uv
130
+ uv lock --upgrade-package pyselfupdate
131
+ git add uv.lock
132
+ """
133
+
134
+
135
+ # ---------- Build System ---------- #
136
+
137
+
138
+ [build-system]
139
+ requires = ["uv_build>=0.11.32,<0.12.0"]
140
+ build-backend = "uv_build"
141
+
142
+ [tool.uv.build-backend]
143
+ module-name = "pyselfupdate"
144
+ module-root = "src"
@@ -0,0 +1,72 @@
1
+ """Self-update and update notification for tools installed with `uv tool`.
2
+
3
+ Two layers, used independently:
4
+
5
+ from pyselfupdate import Config, notify, update
6
+
7
+ config = Config(tool='syncer', owner='datapointchris')
8
+
9
+ notify(config) # once a day, print one line if behind. Never raises.
10
+ update(config) # install the latest release. Raises on failure.
11
+
12
+ `notify` belongs in a CLI's root callback and its result should be ignored.
13
+ `update` belongs behind an explicit `<tool> update` command, which is the only
14
+ place update failures are ever reported.
15
+
16
+ The package has no third-party dependencies. The optional typer integration
17
+ lives in `pyselfupdate.typercmd` and is installed with the `typer` extra.
18
+ """
19
+
20
+ from pyselfupdate.config import Config
21
+ from pyselfupdate.errors import InstallFailedError
22
+ from pyselfupdate.errors import InvalidConfigError
23
+ from pyselfupdate.errors import LocalInstallError
24
+ from pyselfupdate.errors import NoReleaseError
25
+ from pyselfupdate.errors import NotInstalledError
26
+ from pyselfupdate.errors import SelfUpdateError
27
+ from pyselfupdate.errors import SourceError
28
+ from pyselfupdate.github import GitHubSource
29
+ from pyselfupdate.install import Installation
30
+ from pyselfupdate.install import InstallKind
31
+ from pyselfupdate.install import read_installation
32
+ from pyselfupdate.notifier import Outcome
33
+ from pyselfupdate.notifier import Skip
34
+ from pyselfupdate.notifier import enabled
35
+ from pyselfupdate.notifier import notify
36
+ from pyselfupdate.source import Release
37
+ from pyselfupdate.source import Source
38
+ from pyselfupdate.state import State
39
+ from pyselfupdate.state import read as read_state
40
+ from pyselfupdate.updater import Result
41
+ from pyselfupdate.updater import changelog
42
+ from pyselfupdate.updater import check
43
+ from pyselfupdate.updater import update
44
+ from pyselfupdate.updater import update_and_reexec
45
+
46
+ __all__ = [
47
+ 'Config',
48
+ 'GitHubSource',
49
+ 'InstallFailedError',
50
+ 'InstallKind',
51
+ 'Installation',
52
+ 'InvalidConfigError',
53
+ 'LocalInstallError',
54
+ 'NoReleaseError',
55
+ 'NotInstalledError',
56
+ 'Outcome',
57
+ 'Release',
58
+ 'Result',
59
+ 'SelfUpdateError',
60
+ 'Skip',
61
+ 'Source',
62
+ 'SourceError',
63
+ 'State',
64
+ 'changelog',
65
+ 'check',
66
+ 'enabled',
67
+ 'notify',
68
+ 'read_installation',
69
+ 'read_state',
70
+ 'update',
71
+ 'update_and_reexec',
72
+ ]
@@ -0,0 +1,100 @@
1
+ """What to update, and how to reach it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from dataclasses import field
7
+
8
+ from pyselfupdate.errors import InvalidConfigError
9
+ from pyselfupdate.github import GitHubSource
10
+ from pyselfupdate.install import current_version
11
+ from pyselfupdate.source import Source
12
+
13
+ DEFAULT_TIMEOUT = 10.0
14
+
15
+
16
+ @dataclass
17
+ class Config:
18
+ """Describes one updatable tool.
19
+
20
+ `tool` is the only required field. Everything else either has a working
21
+ default or is derived from it, so the common case is `Config(tool='syncer',
22
+ owner='datapointchris')`.
23
+ """
24
+
25
+ # The uv tool name. Names the receipt directory, the entry point, and the
26
+ # state file, and is what appears in messages.
27
+ tool: str
28
+
29
+ owner: str = ''
30
+ repo: str = ''
31
+
32
+ # The distribution name to read the running version from, when it differs
33
+ # from the tool name. Defaults to `tool`.
34
+ package: str = ''
35
+
36
+ # The running version. Defaults to the installed distribution's metadata,
37
+ # which is correct for anything installed as a uv tool.
38
+ version: str = ''
39
+
40
+ token: str = ''
41
+ timeout: float = DEFAULT_TIMEOUT
42
+ allow_prerelease: bool = False
43
+
44
+ # Selects one release stream in a repository publishing several, as in
45
+ # "cli/" for tags of the form cli/v1.2.3. Configures the default GitHub
46
+ # source and is unused when `source` is supplied.
47
+ tag_prefix: str = ''
48
+
49
+ # Locates releases. Defaults to a GitHubSource built from the fields above.
50
+ source: Source | None = None
51
+
52
+ metadata: dict[str, str] = field(default_factory=dict)
53
+
54
+ def require_source(self) -> Source:
55
+ """The resolved source.
56
+
57
+ `resolved()` always populates `source`, but the field stays optional so
58
+ that constructing a Config does not require one. This is the accessor
59
+ that expresses "past this point it is set", rather than each caller
60
+ asserting it.
61
+ """
62
+ if self.source is None:
63
+ raise InvalidConfigError('config was not resolved before use')
64
+ return self.source
65
+
66
+ def resolved(self) -> Config:
67
+ """A copy with every default filled in, so callers can assume they are set."""
68
+ if not self.tool:
69
+ raise InvalidConfigError('tool is required')
70
+
71
+ source = self.source
72
+ owner = self.owner
73
+ repo = self.repo or self.tool
74
+
75
+ if source is None:
76
+ if not owner:
77
+ raise InvalidConfigError('owner is required without a custom source')
78
+ source = GitHubSource(
79
+ owner=owner,
80
+ repo=repo,
81
+ token=self.token,
82
+ timeout=self.timeout,
83
+ allow_prerelease=self.allow_prerelease,
84
+ tag_prefix=self.tag_prefix,
85
+ )
86
+
87
+ package = self.package or self.tool
88
+ return Config(
89
+ tool=self.tool,
90
+ owner=owner,
91
+ repo=repo,
92
+ package=package,
93
+ version=self.version or current_version(package),
94
+ token=self.token,
95
+ timeout=self.timeout,
96
+ allow_prerelease=self.allow_prerelease,
97
+ tag_prefix=self.tag_prefix,
98
+ source=source,
99
+ metadata=self.metadata.copy(),
100
+ )