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 ADDED
@@ -0,0 +1,5 @@
1
+ from .helpers import canonical_version
2
+
3
+ assert __package__, "unknown package?"
4
+
5
+ __version__ = canonical_version(__package__)
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
+ )