pyfr-cli 0.11.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.
- pyfr_cli/__init__.py +16 -0
- pyfr_cli/__main__.py +86 -0
- pyfr_cli/answers.py +118 -0
- pyfr_cli/changelog.py +67 -0
- pyfr_cli/errors.py +12 -0
- pyfr_cli/git.py +107 -0
- pyfr_cli/ignore.py +103 -0
- pyfr_cli/migrate.py +114 -0
- pyfr_cli/render.py +119 -0
- pyfr_cli/state.py +70 -0
- pyfr_cli/update.py +443 -0
- pyfr_cli/vendor.py +372 -0
- pyfr_cli/versions.py +97 -0
- pyfr_cli-0.11.0.dist-info/METADATA +573 -0
- pyfr_cli-0.11.0.dist-info/RECORD +18 -0
- pyfr_cli-0.11.0.dist-info/WHEEL +4 -0
- pyfr_cli-0.11.0.dist-info/entry_points.txt +2 -0
- pyfr_cli-0.11.0.dist-info/licenses/LICENSE +373 -0
pyfr_cli/vendor.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""The `template` branch: pristine rendered output and nothing else.
|
|
2
|
+
|
|
3
|
+
Every update's merge base lives here (spec section 4.3). The branch is
|
|
4
|
+
kept on the remote and pushed before every merge, and every commit the
|
|
5
|
+
tool makes on it carries the Pyfr-Template-Version trailer -- a
|
|
6
|
+
`Key: value` line at the end of the message -- so the tool can tell its
|
|
7
|
+
own commits from anyone else's.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pyfr_cli import answers
|
|
19
|
+
from pyfr_cli.errors import UpdateError
|
|
20
|
+
from pyfr_cli.git import Git
|
|
21
|
+
from pyfr_cli.ignore import Ignore
|
|
22
|
+
from pyfr_cli.versions import Version
|
|
23
|
+
|
|
24
|
+
BRANCH = "template"
|
|
25
|
+
REMOTE = "origin"
|
|
26
|
+
TRAILER = "Pyfr-Template-Version"
|
|
27
|
+
GUIDE = answers.GUIDE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Branch:
|
|
32
|
+
version: Version # what the branch's tip renders
|
|
33
|
+
base: str # the first commit on it that the tool did not make
|
|
34
|
+
created: bool
|
|
35
|
+
notes: list[str] = field(default_factory=list) # lines for the user
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def trailer_version(git: Git, rev: str) -> Version | None:
|
|
39
|
+
text = git.out(
|
|
40
|
+
"show", "--no-patch", f"--format=%(trailers:key={TRAILER},valueonly)", rev
|
|
41
|
+
)
|
|
42
|
+
if not text:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return Version.parse(text.splitlines()[0])
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise UpdateError(
|
|
48
|
+
f"{BRANCH} commit {rev[:12]} has a broken {TRAILER} trailer: {exc}",
|
|
49
|
+
f"see {GUIDE} for re-pointing the branch",
|
|
50
|
+
) from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def recorded_in(git: Git, rev: str) -> Version | None:
|
|
54
|
+
"""The _template_version of the answers file at `rev`, if it has one."""
|
|
55
|
+
result = git.run("show", f"{rev}:{answers.FILE}", check=False)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
return None
|
|
58
|
+
return answers.version_in(result.stdout)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def remote_tip(git: Git, *, offline: bool = False) -> str | None:
|
|
62
|
+
"""The sha of the branch as the remote has it right now, or None when
|
|
63
|
+
the remote has no such branch.
|
|
64
|
+
|
|
65
|
+
`git ls-remote --exit-code` exits 2 when the branch is absent and 128
|
|
66
|
+
when the remote cannot be reached; the two must not be confused, or an
|
|
67
|
+
offline run would rebuild the branch from the root although the remote
|
|
68
|
+
has the real one. Unreachable is an error -- unless `offline`, the
|
|
69
|
+
--no-push run's promise to work from the local branch: then it counts
|
|
70
|
+
as absent.
|
|
71
|
+
"""
|
|
72
|
+
if not git.remote_exists(REMOTE):
|
|
73
|
+
return None
|
|
74
|
+
# The full ref name, not the bare branch name: a pattern matches the
|
|
75
|
+
# tail of a ref, so `template` alone also matches `feature/template`,
|
|
76
|
+
# which sorts first and would be taken for the branch.
|
|
77
|
+
# `--` before REMOTE: it is always the literal "origin" here, but the
|
|
78
|
+
# same shape as clone_template and remote_versions keeps the three
|
|
79
|
+
# calls that take a repository argument uniform.
|
|
80
|
+
result = git.run(
|
|
81
|
+
"ls-remote", "--exit-code", "--heads", "--", REMOTE, f"refs/heads/{BRANCH}",
|
|
82
|
+
check=False,
|
|
83
|
+
) # fmt: skip
|
|
84
|
+
if result.returncode == 0:
|
|
85
|
+
return result.stdout.split()[0]
|
|
86
|
+
if result.returncode == 2 or offline:
|
|
87
|
+
return None
|
|
88
|
+
raise UpdateError(
|
|
89
|
+
f"could not reach {REMOTE} to look for the {BRANCH} branch: "
|
|
90
|
+
f"{result.stderr.strip()}",
|
|
91
|
+
"check the network, or pass --no-push to work from the local branch",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ensure(
|
|
96
|
+
git: Git, recorded: Version, target: Version, *, offline: bool = False
|
|
97
|
+
) -> Branch:
|
|
98
|
+
"""Find, fetch or create the branch, then check the guard.
|
|
99
|
+
|
|
100
|
+
In order: the remote's branch wins when it exists; a local one is used
|
|
101
|
+
(and pushed later) when it does not; otherwise the single root commit
|
|
102
|
+
starts it (spec section 4.3). `offline` is passed on to `remote_tip`.
|
|
103
|
+
"""
|
|
104
|
+
notes: list[str] = []
|
|
105
|
+
created = False
|
|
106
|
+
# A run that died may have left a worktree entry behind -- and, when
|
|
107
|
+
# it died before its clean-up, the worktree itself.
|
|
108
|
+
git.run("worktree", "prune")
|
|
109
|
+
for path in remove_stale_worktrees(git):
|
|
110
|
+
notes.append(f"worktree: removed {path} (left by a killed run)")
|
|
111
|
+
if remote_tip(git, offline=offline) is not None:
|
|
112
|
+
git.run("fetch", "--quiet", REMOTE, BRANCH)
|
|
113
|
+
if not git.branch_exists(BRANCH):
|
|
114
|
+
git.run("branch", BRANCH, "FETCH_HEAD")
|
|
115
|
+
elif git.ok("merge-base", "--is-ancestor", BRANCH, "FETCH_HEAD"):
|
|
116
|
+
git.run("branch", "--force", BRANCH, "FETCH_HEAD")
|
|
117
|
+
elif not git.ok("merge-base", "--is-ancestor", "FETCH_HEAD", BRANCH):
|
|
118
|
+
local = git.out("rev-parse", BRANCH)[:12]
|
|
119
|
+
remote = git.out("rev-parse", "FETCH_HEAD")[:12]
|
|
120
|
+
raise UpdateError(
|
|
121
|
+
f"the local {BRANCH} branch ({local}) and {REMOTE}/{BRANCH} "
|
|
122
|
+
f"({remote}) have diverged",
|
|
123
|
+
f"git branch --force {BRANCH} {REMOTE}/{BRANCH} keeps the "
|
|
124
|
+
f"remote's, which every other machine uses; see {GUIDE}",
|
|
125
|
+
)
|
|
126
|
+
# Otherwise the local branch is ahead -- a --no-push run -- and the
|
|
127
|
+
# push at the end of this run carries it.
|
|
128
|
+
elif git.branch_exists(BRANCH):
|
|
129
|
+
notes.append(f"{BRANCH}: exists locally but not on {REMOTE}; it will be pushed")
|
|
130
|
+
else:
|
|
131
|
+
roots = git.root_commits()
|
|
132
|
+
if len(roots) != 1:
|
|
133
|
+
raise UpdateError(
|
|
134
|
+
f"the repository has {len(roots)} root commits, so the {BRANCH} "
|
|
135
|
+
"branch cannot be created from the one that is template output",
|
|
136
|
+
f"create it by hand: git branch {BRANCH} <that commit>; see {GUIDE}",
|
|
137
|
+
)
|
|
138
|
+
git.run("branch", BRANCH, roots[0])
|
|
139
|
+
created = True
|
|
140
|
+
notes.append(
|
|
141
|
+
f"{BRANCH}: created from root commit {roots[0][:12]} "
|
|
142
|
+
f'"{git.subject(roots[0])}"'
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
version, base = describe(git)
|
|
146
|
+
_check_range(version, recorded, target)
|
|
147
|
+
except UpdateError:
|
|
148
|
+
# A branch this run created and then refused would make the next
|
|
149
|
+
# run say "exists locally but not on origin" and then fail the
|
|
150
|
+
# same way; deleting it leaves the repository as it was found.
|
|
151
|
+
if created:
|
|
152
|
+
git.run("branch", "-D", BRANCH, check=False)
|
|
153
|
+
raise
|
|
154
|
+
return Branch(version, base, created, notes)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _check_range(version: Version, recorded: Version, target: Version) -> None:
|
|
158
|
+
"""The branch may sit anywhere from the recorded version to the target,
|
|
159
|
+
inclusive. Between the two it is a pending update -- the weekly
|
|
160
|
+
workflow synced and pushed it, and the project has not merged it yet
|
|
161
|
+
-- and the sync continues from it. Below the recorded version, the
|
|
162
|
+
branch was recreated (origin/template deleted, then rebuilt from the
|
|
163
|
+
root) and the answers file knows better; `commit_for` would fail
|
|
164
|
+
later anyway, this says why now."""
|
|
165
|
+
if version > target:
|
|
166
|
+
# The target is --to, or the newest release when none was given.
|
|
167
|
+
raise UpdateError(
|
|
168
|
+
f"the {BRANCH} branch is at {version}, ahead of the target {target}",
|
|
169
|
+
f"pass --to {version} or newer, or wait for a newer template release",
|
|
170
|
+
)
|
|
171
|
+
if version < recorded:
|
|
172
|
+
raise UpdateError(
|
|
173
|
+
f"{BRANCH} is at {version} but {answers.FILE} records {recorded}, "
|
|
174
|
+
"which is newer",
|
|
175
|
+
f"see {GUIDE} for re-pointing the branch",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def remove_stale_worktrees(git: Git) -> list[Path]:
|
|
180
|
+
"""Remove the tool's own leftover worktrees of the branch; return their
|
|
181
|
+
paths. Any other worktree of the branch stops the run.
|
|
182
|
+
|
|
183
|
+
A run killed before its clean-up (kill -9, a lost connection) leaves
|
|
184
|
+
its temporary worktree on disk, and `git worktree prune` keeps an
|
|
185
|
+
entry whose directory still exists. `worktree add` and `branch
|
|
186
|
+
--force` would then fail with git's message about the branch being
|
|
187
|
+
used elsewhere. Only a worktree in the tool's own layout is removed:
|
|
188
|
+
one a user made on purpose may hold uncommitted files, and `remove
|
|
189
|
+
--force` would discard them without a word. The main worktree and
|
|
190
|
+
the one the tool runs in are never touched (`remove --force` would
|
|
191
|
+
remove the current one too).
|
|
192
|
+
"""
|
|
193
|
+
here = git.toplevel()
|
|
194
|
+
removed: list[Path] = []
|
|
195
|
+
entries = git.out("worktree", "list", "--porcelain").split("\n\n")
|
|
196
|
+
for entry in entries[1:]: # the first entry is the main worktree
|
|
197
|
+
lines = entry.splitlines()
|
|
198
|
+
if f"branch refs/heads/{BRANCH}" not in lines:
|
|
199
|
+
continue
|
|
200
|
+
path = Path(lines[0].removeprefix("worktree "))
|
|
201
|
+
if path.resolve() == here:
|
|
202
|
+
continue
|
|
203
|
+
if not _is_tool_worktree(path):
|
|
204
|
+
raise UpdateError(
|
|
205
|
+
f"{BRANCH} is checked out in another worktree: {path}",
|
|
206
|
+
f"git worktree remove {path} (after saving what it holds), "
|
|
207
|
+
"then run again",
|
|
208
|
+
)
|
|
209
|
+
# A run killed mid-lock (`git worktree lock <path>`) leaves the entry
|
|
210
|
+
# locked, and `remove --force` refuses a locked worktree outright.
|
|
211
|
+
if any(line == "locked" or line.startswith("locked ") for line in lines):
|
|
212
|
+
git.run("worktree", "unlock", str(path))
|
|
213
|
+
result = git.run("worktree", "remove", "--force", str(path), check=False)
|
|
214
|
+
if result.returncode != 0:
|
|
215
|
+
raise UpdateError(
|
|
216
|
+
f"could not remove the leftover worktree {path}: "
|
|
217
|
+
f"{result.stderr.strip()}",
|
|
218
|
+
f"remove it by hand -- git worktree unlock {path}; "
|
|
219
|
+
f"git worktree remove --force {path} -- then run again",
|
|
220
|
+
)
|
|
221
|
+
removed.append(path)
|
|
222
|
+
return removed
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _is_tool_worktree(path: Path) -> bool:
|
|
226
|
+
"""Whether `path` is where update.py puts the branch's worktree: a
|
|
227
|
+
directory named `worktree` inside a `pyfr-update-*` temporary one."""
|
|
228
|
+
return path.name == "worktree" and path.parent.name.startswith("pyfr-update-")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def describe(git: Git) -> tuple[Version, str]:
|
|
232
|
+
"""The branch's version and its base commit, checking the guard.
|
|
233
|
+
|
|
234
|
+
Walking from the tip, every commit down to the first without the
|
|
235
|
+
trailer must carry it; that first commit is the base. A tip without a
|
|
236
|
+
trailer is the base itself -- the root, or a deliberate re-point --
|
|
237
|
+
unless tool commits lie below it: then someone committed on top of
|
|
238
|
+
them by hand, and the branch is refused (spec section 4.3).
|
|
239
|
+
"""
|
|
240
|
+
shas = git.out("rev-list", BRANCH).split()
|
|
241
|
+
tip_version: Version | None = None
|
|
242
|
+
base = shas[-1]
|
|
243
|
+
for sha in shas:
|
|
244
|
+
version = trailer_version(git, sha)
|
|
245
|
+
if version is None:
|
|
246
|
+
base = sha
|
|
247
|
+
break
|
|
248
|
+
if tip_version is None:
|
|
249
|
+
tip_version = version
|
|
250
|
+
if tip_version is not None:
|
|
251
|
+
return tip_version, base
|
|
252
|
+
below = git.out("log", "--format=%H", f"--grep=^{TRAILER}: ", base).split()
|
|
253
|
+
if below:
|
|
254
|
+
raise UpdateError(
|
|
255
|
+
f'{BRANCH}\'s tip {base[:12]} "{git.subject(base)}" was not made by '
|
|
256
|
+
"pyfr update",
|
|
257
|
+
f"git branch --force {BRANCH} {below[0][:12]} points it back at the "
|
|
258
|
+
f"last commit pyfr update made; see {GUIDE}",
|
|
259
|
+
)
|
|
260
|
+
version = recorded_in(git, base)
|
|
261
|
+
if version is None:
|
|
262
|
+
raise UpdateError(
|
|
263
|
+
f"{BRANCH}'s base commit {base[:12]} has no {answers.FILE}",
|
|
264
|
+
f"point {BRANCH} at a commit that has one: git branch --force "
|
|
265
|
+
f"{BRANCH} <commit>; see {GUIDE}",
|
|
266
|
+
)
|
|
267
|
+
return version, base
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def commit_for(git: Git, version: Version, base: str) -> str:
|
|
271
|
+
"""The commit on the branch that renders `version`: the tool commit
|
|
272
|
+
whose trailer names it, or the base when the base's answers file does."""
|
|
273
|
+
for sha in git.out("rev-list", BRANCH).split():
|
|
274
|
+
if sha == base:
|
|
275
|
+
break
|
|
276
|
+
if trailer_version(git, sha) == version:
|
|
277
|
+
return sha
|
|
278
|
+
if recorded_in(git, base) == version:
|
|
279
|
+
return base
|
|
280
|
+
raise UpdateError(
|
|
281
|
+
f"{BRANCH} has no commit for {version}",
|
|
282
|
+
f"the branch and {answers.FILE} disagree; see {GUIDE}",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@contextmanager
|
|
287
|
+
def worktree(git: Git, path: Path) -> Iterator[Git]:
|
|
288
|
+
"""The branch checked out in `path`; removed afterwards, whatever happens."""
|
|
289
|
+
git.run("worktree", "add", "--quiet", str(path), BRANCH)
|
|
290
|
+
try:
|
|
291
|
+
yield Git(path)
|
|
292
|
+
finally:
|
|
293
|
+
git.run("worktree", "remove", "--force", str(path), check=False)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def sync(rendered: Path, worktree: Git, ignore: Ignore) -> None:
|
|
297
|
+
"""Make the worktree's tree the render's, except for ignored paths.
|
|
298
|
+
|
|
299
|
+
Every rendered file is copied over (with its mode: scripts keep their
|
|
300
|
+
executable bit); every tracked file the render does not produce is
|
|
301
|
+
deleted; both steps skip what `ignore` matches, so the template side
|
|
302
|
+
never changes those paths and the merge never forms an opinion about
|
|
303
|
+
them (spec section 4.5). Then everything is staged.
|
|
304
|
+
"""
|
|
305
|
+
root = worktree.cwd
|
|
306
|
+
wanted = {
|
|
307
|
+
path.relative_to(rendered).as_posix(): path
|
|
308
|
+
for path in rendered.rglob("*")
|
|
309
|
+
if path.is_file()
|
|
310
|
+
}
|
|
311
|
+
for relative, source in wanted.items():
|
|
312
|
+
if ignore.matches(relative):
|
|
313
|
+
continue
|
|
314
|
+
target = root / relative
|
|
315
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
shutil.copy(source, target)
|
|
317
|
+
for relative in worktree.out("ls-files", "-z").split("\0"):
|
|
318
|
+
if not relative or relative in wanted or ignore.matches(relative):
|
|
319
|
+
continue
|
|
320
|
+
stale = root / relative
|
|
321
|
+
if stale.exists():
|
|
322
|
+
stale.unlink()
|
|
323
|
+
parent = stale.parent
|
|
324
|
+
while parent != root and parent.is_dir() and not any(parent.iterdir()):
|
|
325
|
+
parent.rmdir()
|
|
326
|
+
parent = parent.parent
|
|
327
|
+
worktree.run("add", "--all")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def commit(worktree: Git, previous: Version, target: Version) -> str:
|
|
331
|
+
"""`chore: template vA -> vB` with the trailer; allowed to be empty, so the
|
|
332
|
+
version is recorded even when nothing in the body changed for these
|
|
333
|
+
answers."""
|
|
334
|
+
message = f"chore: template {previous} -> {target}\n\n{TRAILER}: {target}\n"
|
|
335
|
+
return worktree.commit(message, allow_empty=True)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def push(git: Git) -> None:
|
|
339
|
+
if not git.remote_exists(REMOTE):
|
|
340
|
+
raise UpdateError(
|
|
341
|
+
f"there is no {REMOTE} remote to push the {BRANCH} branch to",
|
|
342
|
+
"add one (git remote add origin <url>), or pass --no-push",
|
|
343
|
+
)
|
|
344
|
+
result = git.run("push", "--quiet", REMOTE, f"{BRANCH}:{BRANCH}", check=False)
|
|
345
|
+
if result.returncode != 0:
|
|
346
|
+
raise UpdateError(
|
|
347
|
+
f"pushing {BRANCH} to {REMOTE} failed: {result.stderr.strip()}",
|
|
348
|
+
"check your access to the remote, then run pyfr update again -- "
|
|
349
|
+
"the local branch is correct and the next run pushes it",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def push_if_ahead(git: Git) -> bool:
|
|
354
|
+
"""Push the local branch when it holds commits the remote lacks -- what
|
|
355
|
+
a --no-push run leaves behind -- and say whether a push happened.
|
|
356
|
+
|
|
357
|
+
Called by the runs that would otherwise touch the branch not at all
|
|
358
|
+
(already current, or already at the target), so that the --no-push
|
|
359
|
+
promise "the next run pushes it" holds for every next run, not only
|
|
360
|
+
for one that finds a newer version. A branch that has diverged from
|
|
361
|
+
the remote's is left alone here: `ensure` reports that, with the fix,
|
|
362
|
+
on the next real update.
|
|
363
|
+
"""
|
|
364
|
+
if not git.branch_exists(BRANCH) or not git.remote_exists(REMOTE):
|
|
365
|
+
return False
|
|
366
|
+
tip = remote_tip(git)
|
|
367
|
+
if tip == git.out("rev-parse", BRANCH):
|
|
368
|
+
return False
|
|
369
|
+
if tip is not None and not git.ok("merge-base", "--is-ancestor", tip, BRANCH):
|
|
370
|
+
return False
|
|
371
|
+
push(git)
|
|
372
|
+
return True
|
pyfr_cli/versions.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Template versions: the vX.Y.Z tags, compared as numbers, printed with the v."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from pyfr_cli.errors import UpdateError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pyfr_cli.git import Git
|
|
13
|
+
|
|
14
|
+
# A release tag on the template repository. The v is required: a tag
|
|
15
|
+
# without it is not one Commitizen made for a release (spec section 4.2).
|
|
16
|
+
TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
|
17
|
+
# What people type after --to, and what .pyfr-answers.yml records
|
|
18
|
+
# (cookiecutter.json holds the version without the v): the v is optional.
|
|
19
|
+
LENIENT = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
|
20
|
+
# `git ls-remote --tags` prints "<sha>\trefs/tags/<name>"; an annotated tag
|
|
21
|
+
# adds a second line for the commit it points at, whose name ends in ^{}.
|
|
22
|
+
REF = re.compile(r"^[0-9a-f]+\trefs/tags/(?P<name>[^\s^]+)$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, order=True)
|
|
26
|
+
class Version:
|
|
27
|
+
major: int
|
|
28
|
+
minor: int
|
|
29
|
+
patch: int
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def parse(cls, text: str) -> Version:
|
|
33
|
+
match = LENIENT.match(text.strip())
|
|
34
|
+
if match is None:
|
|
35
|
+
raise ValueError(f"{text.strip()!r} is not a version like v0.12.0")
|
|
36
|
+
major, minor, patch = (int(part) for part in match.groups())
|
|
37
|
+
return cls(major, minor, patch)
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
return f"v{self.major}.{self.minor}.{self.patch}"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def bare(self) -> str:
|
|
44
|
+
"""Without the v, as cookiecutter.json and PyPI carry it."""
|
|
45
|
+
return f"{self.major}.{self.minor}.{self.patch}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_ls_remote(output: str) -> list[Version]:
|
|
49
|
+
"""Every release tag in `git ls-remote --tags` output, oldest first."""
|
|
50
|
+
found: set[Version] = set()
|
|
51
|
+
for line in output.splitlines():
|
|
52
|
+
ref = REF.match(line)
|
|
53
|
+
if ref is None:
|
|
54
|
+
continue
|
|
55
|
+
tag = TAG.match(ref["name"])
|
|
56
|
+
if tag is None:
|
|
57
|
+
continue
|
|
58
|
+
major, minor, patch = (int(part) for part in tag.groups())
|
|
59
|
+
found.add(Version(major, minor, patch))
|
|
60
|
+
return sorted(found)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def remote_versions(template: str, git: Git) -> list[Version]:
|
|
64
|
+
"""The template's release tags, oldest first; never empty."""
|
|
65
|
+
# `template` comes from .pyfr-answers.yml or --template, so it is not
|
|
66
|
+
# trusted input. `--` stops git from reading a value starting with `-`
|
|
67
|
+
# (say `--upload-pack=...`) as an option instead of the repository.
|
|
68
|
+
result = git.run("ls-remote", "--tags", "--", template, check=False)
|
|
69
|
+
if result.returncode != 0:
|
|
70
|
+
raise UpdateError(
|
|
71
|
+
f"could not list the tags of {template}: {result.stderr.strip()}",
|
|
72
|
+
"check the _template URL in .pyfr-answers.yml (or --template), "
|
|
73
|
+
"and that you are online",
|
|
74
|
+
)
|
|
75
|
+
found = parse_ls_remote(result.stdout)
|
|
76
|
+
if not found:
|
|
77
|
+
raise UpdateError(
|
|
78
|
+
f"{template} has no release tags (vX.Y.Z)",
|
|
79
|
+
"check the _template URL in .pyfr-answers.yml, or pass --template",
|
|
80
|
+
)
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_target(requested: str | None, available: list[Version]) -> Version:
|
|
85
|
+
"""The version to update to: the newest, or the one --to names."""
|
|
86
|
+
if requested is None:
|
|
87
|
+
return available[-1]
|
|
88
|
+
try:
|
|
89
|
+
wanted = Version.parse(requested)
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
raise UpdateError(str(exc), "pass --to like v0.12.0") from exc
|
|
92
|
+
if wanted not in available:
|
|
93
|
+
newest = ", ".join(str(version) for version in available[-5:])
|
|
94
|
+
raise UpdateError(
|
|
95
|
+
f"the template has no tag {wanted}", f"the newest tags are {newest}"
|
|
96
|
+
)
|
|
97
|
+
return wanted
|