vommit 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of vommit might be problematic. Click here for more details.
- vommit/__about__.py +5 -0
- vommit/__init__.py +18 -0
- vommit/auth.py +268 -0
- vommit/bump.py +285 -0
- vommit/changelog.py +150 -0
- vommit/cli.py +13 -0
- vommit/commits.py +88 -0
- vommit/config.py +486 -0
- vommit/errors.py +7 -0
- vommit/git.py +468 -0
- vommit/helpers.py +56 -0
- vommit/interactive.py +437 -0
- vommit/licenses.py +29 -0
- vommit/migrate.py +1247 -0
- vommit/release.py +467 -0
- vommit/scaffold.py +560 -0
- vommit/shell.py +118 -0
- vommit/tasks.py +926 -0
- vommit/undo.py +204 -0
- vommit/versioning.py +240 -0
- vommit-0.1.0.dist-info/METADATA +278 -0
- vommit-0.1.0.dist-info/RECORD +24 -0
- vommit-0.1.0.dist-info/WHEEL +4 -0
- vommit-0.1.0.dist-info/entry_points.txt +6 -0
vommit/__about__.py
ADDED
vommit/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .bump import BumpRequest, BumpResult, run_bump
|
|
2
|
+
from .changelog import Changelog
|
|
3
|
+
from .config import Config
|
|
4
|
+
from .errors import VommitError
|
|
5
|
+
from .git import GitRepo
|
|
6
|
+
from .helpers import canonical_version, throw
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BumpRequest",
|
|
10
|
+
"BumpResult",
|
|
11
|
+
"Changelog",
|
|
12
|
+
"Config",
|
|
13
|
+
"GitRepo",
|
|
14
|
+
"VommitError",
|
|
15
|
+
"canonical_version",
|
|
16
|
+
"run_bump",
|
|
17
|
+
"throw",
|
|
18
|
+
]
|
vommit/auth.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The PyPI token: where it comes from, and whether it looks usable.
|
|
3
|
+
|
|
4
|
+
vommit resolves the token itself rather than leaving it to the publish command,
|
|
5
|
+
so that any publish tool works the same way. The token therefore passes through
|
|
6
|
+
this process, which is why it only ever travels as a return value or an `env`
|
|
7
|
+
mapping: never onto a `CommandResult`, never into a message.
|
|
8
|
+
|
|
9
|
+
Three places are tried, in this order: the environment (so CI needs nothing set
|
|
10
|
+
up), the keyring, and finally the person at the keyboard.
|
|
11
|
+
|
|
12
|
+
Both things this module talks to are held rather than imported at the point of
|
|
13
|
+
use: `TokenStore` takes its keyring the way `GitRepo` takes its runner, and the
|
|
14
|
+
index probe takes the function that posts. A test hands over a stand-in; nothing
|
|
15
|
+
has to reach into this module and swap its globals out.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import dataclasses as dc
|
|
19
|
+
import os
|
|
20
|
+
import typing as t
|
|
21
|
+
|
|
22
|
+
import keyring
|
|
23
|
+
import keyring.errors
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from .errors import VommitError
|
|
27
|
+
|
|
28
|
+
SERVICE = "vommit"
|
|
29
|
+
USERNAME = "pypi"
|
|
30
|
+
|
|
31
|
+
TOKEN_VAR = "UV_PUBLISH_TOKEN"
|
|
32
|
+
TOKEN_PREFIX = "pypi-"
|
|
33
|
+
|
|
34
|
+
# where a token was found, for saying so out loud
|
|
35
|
+
ENVIRONMENT = f"{TOKEN_VAR} environment variable"
|
|
36
|
+
KEYRING = "keyring"
|
|
37
|
+
PROMPT = "prompt"
|
|
38
|
+
|
|
39
|
+
UPLOAD_URL = "https://upload.pypi.org/legacy/"
|
|
40
|
+
# enough of an upload for the index to authenticate it before finding it wanting
|
|
41
|
+
UPLOAD_FORM = {":action": "file_upload", "protocol_version": "1"}
|
|
42
|
+
# A valid token gets through authentication and then is refused for the
|
|
43
|
+
# deliberately incomplete upload form. Any other response is inconclusive.
|
|
44
|
+
ACCEPTED = 400
|
|
45
|
+
|
|
46
|
+
Authenticate = t.Callable[[], str]
|
|
47
|
+
Notify = t.Callable[[str], None]
|
|
48
|
+
|
|
49
|
+
_NO_BACKEND = (
|
|
50
|
+
"Over SSH or on a headless machine, install `vommit[ssh]` for a backend "
|
|
51
|
+
"that works through ssh-agent. Otherwise set `pypi.use_keyring = false` to "
|
|
52
|
+
f"be asked for the token instead, or set {TOKEN_VAR} in the environment."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Keyring(t.Protocol):
|
|
57
|
+
"""
|
|
58
|
+
The three calls this module makes into a keyring.
|
|
59
|
+
|
|
60
|
+
The `keyring` module itself satisfies this, and so does a dictionary with
|
|
61
|
+
three methods around it, which is what the tests use.
|
|
62
|
+
|
|
63
|
+
Positional-only, because the real module spells the first parameter
|
|
64
|
+
`service_name` and a stand-in has no reason to copy that.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def get_password(
|
|
68
|
+
self, service: str, username: str, /
|
|
69
|
+
) -> str | None: ... # pragma: no cover
|
|
70
|
+
|
|
71
|
+
def set_password(
|
|
72
|
+
self, service: str, username: str, password: str, /
|
|
73
|
+
) -> None: ... # pragma: no cover
|
|
74
|
+
|
|
75
|
+
def delete_password(
|
|
76
|
+
self, service: str, username: str, /
|
|
77
|
+
) -> None: ... # pragma: no cover
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Response(t.Protocol):
|
|
81
|
+
"""
|
|
82
|
+
As much of an HTTP response as the probe reads.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
status_code: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: Posts the probe upload. `requests.post` is the one that really does.
|
|
89
|
+
Poster = t.Callable[..., Response]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def environment_token() -> str | None:
|
|
93
|
+
"""
|
|
94
|
+
A token the environment already provides; the path CI takes.
|
|
95
|
+
"""
|
|
96
|
+
return os.environ.get(TOKEN_VAR) or None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dc.dataclass(frozen=True)
|
|
100
|
+
class TokenStore:
|
|
101
|
+
"""
|
|
102
|
+
The PyPI token as this machine keeps it.
|
|
103
|
+
|
|
104
|
+
Every keyring failure becomes a `VommitError` naming a way out, because the
|
|
105
|
+
common one is not a bug but a headless machine with no backend installed.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
backend: Keyring = keyring
|
|
109
|
+
service: str = SERVICE
|
|
110
|
+
username: str = USERNAME
|
|
111
|
+
|
|
112
|
+
def stored(self) -> str | None:
|
|
113
|
+
"""
|
|
114
|
+
The token in the keyring, or None when nothing is stored there yet.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
return self.backend.get_password(self.service, self.username) or None
|
|
118
|
+
except keyring.errors.KeyringError as error:
|
|
119
|
+
raise VommitError(
|
|
120
|
+
f"Could not read the keyring: {error}\n{_NO_BACKEND}"
|
|
121
|
+
) from error
|
|
122
|
+
|
|
123
|
+
def store(self, token: str) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Write (or overwrite) the token, so rotating one is a matter of storing it.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
self.backend.set_password(self.service, self.username, token.strip())
|
|
129
|
+
except keyring.errors.KeyringError as error:
|
|
130
|
+
raise VommitError(
|
|
131
|
+
f"Could not write to the keyring: {error}\n{_NO_BACKEND}"
|
|
132
|
+
) from error
|
|
133
|
+
|
|
134
|
+
def forget(self) -> bool:
|
|
135
|
+
"""
|
|
136
|
+
Remove the stored token; False when there was nothing to remove.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
self.backend.delete_password(self.service, self.username)
|
|
140
|
+
except keyring.errors.PasswordDeleteError:
|
|
141
|
+
return False
|
|
142
|
+
except keyring.errors.KeyringError as error:
|
|
143
|
+
raise VommitError(
|
|
144
|
+
f"Could not clear the keyring: {error}\n{_NO_BACKEND}"
|
|
145
|
+
) from error
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
def available(self) -> tuple[str, str] | None:
|
|
149
|
+
"""
|
|
150
|
+
The token a release would pick up, and where it came from.
|
|
151
|
+
"""
|
|
152
|
+
if token := environment_token():
|
|
153
|
+
return ENVIRONMENT, token
|
|
154
|
+
if token := self.stored():
|
|
155
|
+
return KEYRING, token
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def require(self) -> str:
|
|
159
|
+
"""
|
|
160
|
+
A token from the environment or the keyring, or a refusal naming the fix.
|
|
161
|
+
|
|
162
|
+
The half of the resolution that can run unattended; entrypoints that can
|
|
163
|
+
prompt wrap this with one that asks.
|
|
164
|
+
"""
|
|
165
|
+
if found := self.available():
|
|
166
|
+
return found[1]
|
|
167
|
+
raise VommitError(
|
|
168
|
+
"No PyPI token found; run `vommit authenticate` to store one, "
|
|
169
|
+
f"or set {TOKEN_VAR} in the environment."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def require_token() -> str:
|
|
174
|
+
"""
|
|
175
|
+
The default `Authenticate`: this machine's keyring, asking nothing.
|
|
176
|
+
"""
|
|
177
|
+
return TokenStore().require()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def mask(token: str) -> str:
|
|
181
|
+
"""
|
|
182
|
+
Enough of a token to recognise it by, never enough to use it.
|
|
183
|
+
|
|
184
|
+
A short value is not a real token, so showing nine characters of it would
|
|
185
|
+
give away most of whatever it is; those get the tail only.
|
|
186
|
+
"""
|
|
187
|
+
tail = token[-4:]
|
|
188
|
+
return f"{token[:9]}...{tail}" if len(token) >= 20 else f"...{tail}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_format(token: str) -> str:
|
|
192
|
+
"""
|
|
193
|
+
The token, stripped, if it looks like one at all.
|
|
194
|
+
|
|
195
|
+
Catches the realistic mistake, which is a half-copied clipboard rather than
|
|
196
|
+
a revoked token: only the index can tell you about the latter.
|
|
197
|
+
"""
|
|
198
|
+
token = token.strip()
|
|
199
|
+
if not token:
|
|
200
|
+
raise VommitError("An empty PyPI token cannot be stored.")
|
|
201
|
+
if not token.startswith(TOKEN_PREFIX):
|
|
202
|
+
raise VommitError(
|
|
203
|
+
f"A PyPI token starts with {TOKEN_PREFIX!r}, and this one does not. "
|
|
204
|
+
"Check what you pasted, or pass --no-verify if you publish to an "
|
|
205
|
+
"index that issues a different kind of token."
|
|
206
|
+
)
|
|
207
|
+
return token
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def probe(
|
|
211
|
+
token: str,
|
|
212
|
+
url: str = UPLOAD_URL,
|
|
213
|
+
timeout: float = 10.0,
|
|
214
|
+
post: Poster = requests.post,
|
|
215
|
+
) -> int | None:
|
|
216
|
+
"""
|
|
217
|
+
What the index answers to these credentials, or None if it was unreachable.
|
|
218
|
+
|
|
219
|
+
The upload announces itself and then says nothing else, which is deliberate:
|
|
220
|
+
a token that works gets as far as being told the request is incomplete, one
|
|
221
|
+
that does not is turned away at the door with a 403.
|
|
222
|
+
|
|
223
|
+
The body has to be there. A POST to `/legacy/` with an empty body is
|
|
224
|
+
answered 405 before the credentials are looked at, which made an earlier
|
|
225
|
+
version of this check accept everything.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
response = post(
|
|
229
|
+
url,
|
|
230
|
+
auth=("__token__", token),
|
|
231
|
+
data=UPLOAD_FORM,
|
|
232
|
+
timeout=timeout,
|
|
233
|
+
)
|
|
234
|
+
except requests.RequestException:
|
|
235
|
+
return None
|
|
236
|
+
return response.status_code
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def verify_token(
|
|
240
|
+
token: str,
|
|
241
|
+
url: str = UPLOAD_URL,
|
|
242
|
+
timeout: float = 10.0,
|
|
243
|
+
notify: Notify = lambda _: None,
|
|
244
|
+
post: Poster = requests.post,
|
|
245
|
+
) -> str:
|
|
246
|
+
"""
|
|
247
|
+
The token, checked as far as it can be checked before a real upload.
|
|
248
|
+
|
|
249
|
+
An index that cannot be reached is not held against the token: refusing to
|
|
250
|
+
store one because the network is down would be its own kind of wrong.
|
|
251
|
+
"""
|
|
252
|
+
token = check_format(token)
|
|
253
|
+
status = probe(token, url, timeout, post)
|
|
254
|
+
if status is None:
|
|
255
|
+
notify("Could not reach PyPI to check the token; taking it as given.")
|
|
256
|
+
elif status != ACCEPTED:
|
|
257
|
+
raise VommitError(
|
|
258
|
+
f"PyPI could not verify this token ({status}). Create a new one at "
|
|
259
|
+
"https://pypi.org/manage/account/token/ and try again."
|
|
260
|
+
)
|
|
261
|
+
return token
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def publish_env(token: str | None) -> dict[str, str]:
|
|
265
|
+
"""
|
|
266
|
+
The environment a publish command needs, empty when there is no token.
|
|
267
|
+
"""
|
|
268
|
+
return {TOKEN_VAR: token} if token else {}
|
vommit/bump.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import dataclasses as dc
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import typing as t
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .changelog import Changelog, ChangelogUpdate
|
|
7
|
+
from .commits import VersionBump, highest_version_bump
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .errors import VommitError
|
|
10
|
+
from .git import GitRepo, resolve_author
|
|
11
|
+
from .helpers import relative_path
|
|
12
|
+
from .shell import Runner
|
|
13
|
+
from .versioning import UvProject, is_prerelease, plan_bump
|
|
14
|
+
|
|
15
|
+
Notify = t.Callable[[str], None]
|
|
16
|
+
Confirm = t.Callable[["BumpResult"], bool]
|
|
17
|
+
|
|
18
|
+
PYPROJECT = "pyproject.toml"
|
|
19
|
+
LOCKFILE = "uv.lock"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def always(_: t.Any) -> bool:
|
|
23
|
+
"""
|
|
24
|
+
The default answer for callers that have nobody to ask.
|
|
25
|
+
"""
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def reject_flags(flags: list[str], because: str) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Refuse flags that contradict the mode the user asked for.
|
|
32
|
+
"""
|
|
33
|
+
if flags:
|
|
34
|
+
raise VommitError(f"{because}; drop {' and '.join(flags)}.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dc.dataclass(frozen=True)
|
|
38
|
+
class BumpRequest:
|
|
39
|
+
level: VersionBump | None = None
|
|
40
|
+
version: str | None = None
|
|
41
|
+
prerelease: bool = False
|
|
42
|
+
noop: bool = False
|
|
43
|
+
allow_dirty: bool = False
|
|
44
|
+
undo: bool = False
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if self.undo:
|
|
48
|
+
self._reject_alongside_undo()
|
|
49
|
+
return
|
|
50
|
+
if not self.version:
|
|
51
|
+
return
|
|
52
|
+
if self.level:
|
|
53
|
+
raise VommitError(
|
|
54
|
+
f"--version {self.version} sets an exact version; drop --{self.level}."
|
|
55
|
+
)
|
|
56
|
+
if self.prerelease:
|
|
57
|
+
raise VommitError(
|
|
58
|
+
f"--version {self.version} sets an exact version; "
|
|
59
|
+
"--prerelease would be ignored."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def targeting_flags(self, include_dirty: bool = True) -> list[str]:
|
|
63
|
+
"""
|
|
64
|
+
The flags that aim this request at a new version.
|
|
65
|
+
|
|
66
|
+
Anything that takes the version as given instead of choosing one
|
|
67
|
+
(`--undo`, `--no-bump`) has the same set to reject, so the set is
|
|
68
|
+
written once here rather than once per caller.
|
|
69
|
+
"""
|
|
70
|
+
return [
|
|
71
|
+
f"--{name}"
|
|
72
|
+
for name, given in (
|
|
73
|
+
("major", self.level == "major"),
|
|
74
|
+
("minor", self.level == "minor"),
|
|
75
|
+
("patch", self.level == "patch"),
|
|
76
|
+
("prerelease", self.prerelease),
|
|
77
|
+
("version", bool(self.version)),
|
|
78
|
+
("allow-dirty", include_dirty and self.allow_dirty),
|
|
79
|
+
)
|
|
80
|
+
if given
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def _reject_alongside_undo(self) -> None:
|
|
84
|
+
"""
|
|
85
|
+
--undo takes back the last release; it cannot also aim at a new one.
|
|
86
|
+
"""
|
|
87
|
+
reject_flags(self.targeting_flags(), "--undo goes back to the previous release")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dc.dataclass(frozen=True)
|
|
91
|
+
class BumpResult:
|
|
92
|
+
previous: str | None
|
|
93
|
+
version: str
|
|
94
|
+
level: VersionBump | None
|
|
95
|
+
entry: str | None
|
|
96
|
+
changelog_path: Path | None
|
|
97
|
+
commit_message: str | None
|
|
98
|
+
tag: str | None
|
|
99
|
+
noop: bool
|
|
100
|
+
prerelease: bool = False
|
|
101
|
+
# declined at the confirmation step; nothing was written, same as a noop
|
|
102
|
+
cancelled: bool = False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def select_level(
|
|
106
|
+
major: bool = False,
|
|
107
|
+
minor: bool = False,
|
|
108
|
+
patch: bool = False,
|
|
109
|
+
) -> VersionBump | None:
|
|
110
|
+
"""
|
|
111
|
+
Turn the mutually exclusive CLI flags into one level (None = derive it).
|
|
112
|
+
"""
|
|
113
|
+
chosen = [
|
|
114
|
+
level
|
|
115
|
+
for level, picked in (("major", major), ("minor", minor), ("patch", patch))
|
|
116
|
+
if picked
|
|
117
|
+
]
|
|
118
|
+
if len(chosen) > 1:
|
|
119
|
+
raise VommitError(
|
|
120
|
+
f"Pick one bump level, not {' and '.join(f'--{level}' for level in chosen)}."
|
|
121
|
+
)
|
|
122
|
+
return t.cast(VersionBump | None, chosen[0] if chosen else None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run_bump(
|
|
126
|
+
config: Config,
|
|
127
|
+
runner: Runner,
|
|
128
|
+
root: Path,
|
|
129
|
+
request: BumpRequest,
|
|
130
|
+
notify: Notify = lambda _: None,
|
|
131
|
+
today: dt.date | None = None,
|
|
132
|
+
confirm: Confirm = always,
|
|
133
|
+
) -> BumpResult | None:
|
|
134
|
+
"""
|
|
135
|
+
Bump the version, update the changelog, commit and tag.
|
|
136
|
+
|
|
137
|
+
Returns None when the commits since the last release do not warrant a bump,
|
|
138
|
+
and a `cancelled` result when `confirm` says no. Nothing is written until
|
|
139
|
+
every step that can fail has been checked, so a missing placeholder or a
|
|
140
|
+
wrong branch leaves the project untouched.
|
|
141
|
+
"""
|
|
142
|
+
git = config.active_git
|
|
143
|
+
changelog_settings = config.active_changelog
|
|
144
|
+
|
|
145
|
+
repo = GitRepo(runner=runner, root=root)
|
|
146
|
+
project = UvProject(runner=runner, root=root)
|
|
147
|
+
|
|
148
|
+
if git:
|
|
149
|
+
branch = repo.ensure_branch(git, notify)
|
|
150
|
+
repo.ensure_up_to_date(git, branch, notify)
|
|
151
|
+
|
|
152
|
+
# reading history is how the bump level is found at all, so it happens even
|
|
153
|
+
# when git integration (branch checks, committing, tagging) is switched off.
|
|
154
|
+
# only a changelog that lists prereleases has a reason to stop at one: it
|
|
155
|
+
# has already reported those commits, so repeating them would duplicate.
|
|
156
|
+
aggregating = not (changelog_settings and changelog_settings.include_prereleases)
|
|
157
|
+
tag_glob = config.git.tag_glob if config.git else None
|
|
158
|
+
stable_tag = repo.last_stable_tag(tag_glob, config.tag_version)
|
|
159
|
+
baseline = config.tag_version(stable_tag)
|
|
160
|
+
|
|
161
|
+
# aggregating means the window that feeds the changelog also decides the
|
|
162
|
+
# level, so a prerelease series that has gone quiet can still be released.
|
|
163
|
+
since = stable_tag if aggregating else repo.last_tag(tag_glob)
|
|
164
|
+
commit_messages = repo.commit_messages_since(since)
|
|
165
|
+
|
|
166
|
+
level = request.level or highest_version_bump(
|
|
167
|
+
config.resolve_version_bump_from_commit(message) for message in commit_messages
|
|
168
|
+
)
|
|
169
|
+
if not request.version and level is None:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
previous = project.current_version()
|
|
173
|
+
plan = (
|
|
174
|
+
[request.version]
|
|
175
|
+
if request.version
|
|
176
|
+
else plan_bump(
|
|
177
|
+
current=previous,
|
|
178
|
+
level=t.cast(VersionBump, level),
|
|
179
|
+
baseline=baseline,
|
|
180
|
+
prerelease_token=config.prerelease_token if request.prerelease else None,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
next_version = project.preview(plan)
|
|
184
|
+
prerelease = is_prerelease(next_version)
|
|
185
|
+
|
|
186
|
+
changelog = (
|
|
187
|
+
Changelog(settings=changelog_settings, root=root)
|
|
188
|
+
if changelog_settings
|
|
189
|
+
else None
|
|
190
|
+
)
|
|
191
|
+
update: ChangelogUpdate | None = None
|
|
192
|
+
if changelog_settings and prerelease and not changelog_settings.include_prereleases:
|
|
193
|
+
notify(
|
|
194
|
+
f"{next_version} is a prerelease; its changes stay unlisted until "
|
|
195
|
+
"the next release."
|
|
196
|
+
)
|
|
197
|
+
elif changelog:
|
|
198
|
+
update = changelog.plan(
|
|
199
|
+
next_version, config.commit_entries(commit_messages), today
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
touched = [PYPROJECT]
|
|
203
|
+
if update:
|
|
204
|
+
touched.append(relative_path(update.path, root))
|
|
205
|
+
|
|
206
|
+
tag = git.format_tag(next_version) if git else None
|
|
207
|
+
commit_message = git.format_commit(next_version) if git else None
|
|
208
|
+
# guarded by the commit, like the tag check below: `commit_format = ""` is a
|
|
209
|
+
# supported bump that stages without committing, and refusing it over an
|
|
210
|
+
# author that is never passed to git would block it for no gain.
|
|
211
|
+
author = resolve_author(git.commit_author) if git and commit_message else None
|
|
212
|
+
|
|
213
|
+
if git:
|
|
214
|
+
if not request.allow_dirty:
|
|
215
|
+
_refuse_dirty(repo, touched)
|
|
216
|
+
if tag:
|
|
217
|
+
# checked here rather than at tagging time: finding out afterwards
|
|
218
|
+
# would leave a release commit that never gets a tag.
|
|
219
|
+
repo.ensure_tag_available(tag)
|
|
220
|
+
|
|
221
|
+
result = BumpResult(
|
|
222
|
+
previous=previous,
|
|
223
|
+
version=next_version,
|
|
224
|
+
level=level,
|
|
225
|
+
entry=update.entry if update else None,
|
|
226
|
+
changelog_path=update.path if update else None,
|
|
227
|
+
commit_message=commit_message,
|
|
228
|
+
tag=tag,
|
|
229
|
+
noop=request.noop,
|
|
230
|
+
prerelease=prerelease,
|
|
231
|
+
)
|
|
232
|
+
if request.noop:
|
|
233
|
+
return result
|
|
234
|
+
if not confirm(result):
|
|
235
|
+
return dc.replace(result, noop=True, cancelled=True)
|
|
236
|
+
|
|
237
|
+
lockfile_ignored = bool(git and repo.ignores(LOCKFILE))
|
|
238
|
+
applied = project.apply(plan, frozen=lockfile_ignored)
|
|
239
|
+
if applied != next_version:
|
|
240
|
+
raise VommitError(
|
|
241
|
+
f"`uv version` produced {applied}, but {next_version} was planned; "
|
|
242
|
+
"the project changed underneath us."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if update:
|
|
246
|
+
update.apply()
|
|
247
|
+
|
|
248
|
+
if git:
|
|
249
|
+
if project.lockfile.exists() and not lockfile_ignored:
|
|
250
|
+
touched.append(LOCKFILE)
|
|
251
|
+
try:
|
|
252
|
+
repo.add(touched)
|
|
253
|
+
except VommitError as error:
|
|
254
|
+
raise VommitError(
|
|
255
|
+
f"{error}\nNothing has been pushed, so `vommit bump --undo` can still "
|
|
256
|
+
f"take {next_version} back."
|
|
257
|
+
) from error
|
|
258
|
+
if commit_message:
|
|
259
|
+
_commit(repo, commit_message, next_version, author=author)
|
|
260
|
+
if tag:
|
|
261
|
+
repo.tag(tag)
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _commit(
|
|
267
|
+
repo: GitRepo, message: str, version: str, author: str | None = None
|
|
268
|
+
) -> None:
|
|
269
|
+
try:
|
|
270
|
+
repo.commit(message, author=author)
|
|
271
|
+
except VommitError as error:
|
|
272
|
+
# the version is already written by now; say where that leaves things
|
|
273
|
+
raise VommitError(
|
|
274
|
+
f"{error}\nThe changes for {version} are staged but not committed, "
|
|
275
|
+
"and no tag was created."
|
|
276
|
+
) from error
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _refuse_dirty(repo: GitRepo, paths: list[str]) -> None:
|
|
280
|
+
dirty = repo.dirty_paths(paths)
|
|
281
|
+
if dirty:
|
|
282
|
+
raise VommitError(
|
|
283
|
+
f"Uncommitted changes in {', '.join(dirty)}; "
|
|
284
|
+
"commit or stash them first, or pass --allow-dirty."
|
|
285
|
+
)
|