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/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