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/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""pyfr-cli: bring a project generated from PyFr up to a newer template version.
|
|
2
|
+
|
|
3
|
+
`pyfr update` re-renders the template at the target version with the answers
|
|
4
|
+
the project recorded, commits the result on the `template` branch, and merges
|
|
5
|
+
that branch in (M8 design, section 4). `pyfr update-check` says whether a
|
|
6
|
+
newer version exists.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("pyfr-cli")
|
|
15
|
+
except PackageNotFoundError: # a checkout that was never installed
|
|
16
|
+
__version__ = "0.0.0"
|
pyfr_cli/__main__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The `pyfr` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pyfr_cli import __version__, update
|
|
12
|
+
from pyfr_cli.errors import UpdateError
|
|
13
|
+
|
|
14
|
+
TEMPLATE_HELP = (
|
|
15
|
+
"the template repository (default: _template in .pyfr-answers.yml; one "
|
|
16
|
+
"run only, the recorded value is kept) -- its hooks and migration scripts "
|
|
17
|
+
"run with your permissions, so it must be a repository you trust"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="pyfr",
|
|
24
|
+
description="Keep a project generated from PyFr up to date with the template.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
28
|
+
)
|
|
29
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
|
|
31
|
+
run = commands.add_parser(
|
|
32
|
+
"update", help="pull in a newer template version through a git merge"
|
|
33
|
+
)
|
|
34
|
+
run.add_argument(
|
|
35
|
+
"--to", metavar="VERSION", help="the version to update to (default: the newest)"
|
|
36
|
+
)
|
|
37
|
+
run.add_argument(
|
|
38
|
+
"--no-push",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="do not push the template branch to origin (the next run pushes "
|
|
41
|
+
"it, even one that finds nothing else to do)",
|
|
42
|
+
)
|
|
43
|
+
run.add_argument("--template", metavar="URL", help=TEMPLATE_HELP)
|
|
44
|
+
|
|
45
|
+
check = commands.add_parser(
|
|
46
|
+
"update-check", help="exit 1 when a newer template version exists"
|
|
47
|
+
)
|
|
48
|
+
check.add_argument(
|
|
49
|
+
"--json", action="store_true", help="print a JSON object instead of a sentence"
|
|
50
|
+
)
|
|
51
|
+
check.add_argument("--template", metavar="URL", help=TEMPLATE_HELP)
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
56
|
+
args = build_parser().parse_args(argv)
|
|
57
|
+
project = Path.cwd()
|
|
58
|
+
try:
|
|
59
|
+
if args.command == "update":
|
|
60
|
+
options = update.Options(
|
|
61
|
+
to=args.to, push=not args.no_push, template=args.template
|
|
62
|
+
)
|
|
63
|
+
return update.update(project, options, sys.stdout)
|
|
64
|
+
options = update.Options(template=args.template)
|
|
65
|
+
return update.check(project, options, sys.stdout, as_json=args.json)
|
|
66
|
+
except UpdateError as exc:
|
|
67
|
+
if os.environ.get("PYFR_DEBUG"):
|
|
68
|
+
raise
|
|
69
|
+
print(f"error: {exc.cause}", file=sys.stderr)
|
|
70
|
+
print(f" fix: {exc.fix}", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
if os.environ.get("PYFR_DEBUG"):
|
|
74
|
+
raise
|
|
75
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
76
|
+
print(
|
|
77
|
+
" fix: check the paths and permissions the message names, then run again",
|
|
78
|
+
file=sys.stderr,
|
|
79
|
+
)
|
|
80
|
+
return 2
|
|
81
|
+
except KeyboardInterrupt:
|
|
82
|
+
return 130
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
sys.exit(main())
|
pyfr_cli/answers.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
""".pyfr-answers.yml: what the generator recorded, what an update rewrites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from pyfr_cli.errors import UpdateError
|
|
13
|
+
from pyfr_cli.versions import Version
|
|
14
|
+
|
|
15
|
+
FILE = ".pyfr-answers.yml"
|
|
16
|
+
GUIDE = "docs/guides/update-from-template.md"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Answers:
|
|
21
|
+
path: Path
|
|
22
|
+
template: str
|
|
23
|
+
version: Version
|
|
24
|
+
# Every key, every value as a string: cookiecutter's extra_context
|
|
25
|
+
# takes strings, and the generator wrote the file from strings.
|
|
26
|
+
values: dict[str, str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load(project: Path) -> Answers:
|
|
30
|
+
path = project / FILE
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
raise UpdateError(
|
|
33
|
+
f"{FILE} not found in {project}",
|
|
34
|
+
"run from the project root; a project generated before PyFr "
|
|
35
|
+
f"v0.7.0 has no answers file -- {GUIDE} shows how to write one",
|
|
36
|
+
)
|
|
37
|
+
try:
|
|
38
|
+
data = yaml.safe_load(path.read_text())
|
|
39
|
+
except yaml.YAMLError as exc:
|
|
40
|
+
raise UpdateError(
|
|
41
|
+
f"{FILE} is not valid YAML: {exc}", f"fix the file; see {GUIDE}"
|
|
42
|
+
) from exc
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
raise UpdateError(f"{FILE} is not a mapping", f"fix the file; see {GUIDE}")
|
|
45
|
+
values = {str(key): str(value) for key, value in data.items()}
|
|
46
|
+
for key in ("_template", "_template_version"):
|
|
47
|
+
if key not in values:
|
|
48
|
+
raise UpdateError(f"{FILE} has no {key}", f"add it; see {GUIDE}")
|
|
49
|
+
try:
|
|
50
|
+
version = Version.parse(values["_template_version"])
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
raise UpdateError(
|
|
53
|
+
f"{FILE}: _template_version {exc}",
|
|
54
|
+
f"set it to the version the project was generated from; see {GUIDE}",
|
|
55
|
+
) from exc
|
|
56
|
+
return Answers(path, values["_template"], version, values)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def context(
|
|
60
|
+
answers: Answers, prompts: Iterable[str]
|
|
61
|
+
) -> tuple[dict[str, str], list[str]]:
|
|
62
|
+
"""cookiecutter's extra_context for a render at a template declaring
|
|
63
|
+
`prompts`, and the names of the prompts that will take their default.
|
|
64
|
+
|
|
65
|
+
A prompt the target added since the project was generated has no
|
|
66
|
+
recorded value; a recorded answer the target no longer declares is
|
|
67
|
+
left out (spec section 4.4).
|
|
68
|
+
"""
|
|
69
|
+
wanted = [prompt for prompt in prompts if not prompt.startswith("_")]
|
|
70
|
+
recorded = {p: answers.values[p] for p in wanted if p in answers.values}
|
|
71
|
+
defaulted = [p for p in wanted if p not in answers.values]
|
|
72
|
+
return recorded, defaulted
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# The `_template:` line of an answers file, wherever it is in the file.
|
|
76
|
+
TEMPLATE_LINE = re.compile(r"^_template:[^\n]*$", re.MULTILINE)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def install(rendered_project: Path, project: Path, template: str) -> None:
|
|
80
|
+
"""Step 10: the render's answers file -- the recorded answers, the new
|
|
81
|
+
prompts' defaults, the target version -- becomes the project's.
|
|
82
|
+
|
|
83
|
+
Except for `_template`: the render carries the URL the template body
|
|
84
|
+
hard-codes, upstream's, and `template` -- the URL the project
|
|
85
|
+
recorded -- wins over it, so a fork stays pointed at itself. Only
|
|
86
|
+
that line is rewritten; every other byte of the render's file is
|
|
87
|
+
kept. (--template is a one-off override and is never recorded.)
|
|
88
|
+
"""
|
|
89
|
+
text = (rendered_project / FILE).read_text()
|
|
90
|
+
if _value_in(text, "_template") != template:
|
|
91
|
+
# yaml decides the quoting, for a URL that needs any.
|
|
92
|
+
line = yaml.safe_dump({"_template": template}).rstrip("\n")
|
|
93
|
+
text, found = TEMPLATE_LINE.subn(lambda _match: line, text, count=1)
|
|
94
|
+
if not found:
|
|
95
|
+
text += line + "\n"
|
|
96
|
+
(project / FILE).write_text(text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def version_in(text: str) -> Version | None:
|
|
100
|
+
"""The _template_version an answers file's text records, if any."""
|
|
101
|
+
value = _value_in(text, "_template_version")
|
|
102
|
+
if value is None:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
return Version.parse(value)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _value_in(text: str, key: str) -> str | None:
|
|
111
|
+
"""The value of `key` in an answers file's text, as a string, if any."""
|
|
112
|
+
try:
|
|
113
|
+
data = yaml.safe_load(text)
|
|
114
|
+
except yaml.YAMLError:
|
|
115
|
+
return None
|
|
116
|
+
if not isinstance(data, dict) or key not in data:
|
|
117
|
+
return None
|
|
118
|
+
return str(data[key])
|
pyfr_cli/changelog.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The template's CHANGELOG.md: Commitizen's `## vX.Y.Z (date)` sections.
|
|
2
|
+
|
|
3
|
+
The sections between the recorded version and the target become the merge
|
|
4
|
+
commit's body, and from there the weekly pull request's (spec section 4.9).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from pyfr_cli.versions import Version
|
|
12
|
+
|
|
13
|
+
HEADING = re.compile(r"^## v?(?P<version>\d+\.\d+\.\d+)(?P<rest>.*)$")
|
|
14
|
+
# An scp-like ssh clone URL: `[user@]host:path`, no scheme in front. The
|
|
15
|
+
# negative lookahead keeps a real URL (`https://host:port/path`) from
|
|
16
|
+
# matching -- that also has a `:` before the first `/`. The host needs at
|
|
17
|
+
# least two characters so a Windows drive path (`C:\...`, `C:/...`) is not
|
|
18
|
+
# mistaken for one -- no real ssh host is a single letter.
|
|
19
|
+
SCP_LIKE_URL = re.compile(
|
|
20
|
+
r"^(?![A-Za-z][A-Za-z0-9+.-]*://)(?:[^@/]+@)?(?P<host>[^:/]{2,}):(?P<path>.+)$"
|
|
21
|
+
)
|
|
22
|
+
# An ssh:// clone URL, any user, with an optional port that has no https
|
|
23
|
+
# equivalent -- the web UI lives on 443 whatever port ssh uses.
|
|
24
|
+
SSH_SCHEME_URL = re.compile(
|
|
25
|
+
r"^ssh://(?:[^@/]+@)?(?P<host>[^:/]+)(?::\d+)?/(?P<path>.+)$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sections(text: str) -> list[tuple[Version, str, str]]:
|
|
30
|
+
"""(version, the rest of the heading line, body) for each section, in
|
|
31
|
+
file order -- Commitizen writes the newest first."""
|
|
32
|
+
found: list[tuple[Version, str, list[str]]] = []
|
|
33
|
+
for line in text.splitlines():
|
|
34
|
+
heading = HEADING.match(line)
|
|
35
|
+
if heading is not None:
|
|
36
|
+
found.append((Version.parse(heading["version"]), heading["rest"], []))
|
|
37
|
+
elif found:
|
|
38
|
+
found[-1][2].append(line)
|
|
39
|
+
return [(version, rest, "\n".join(body).strip()) for version, rest, body in found]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _https_base(template: str) -> str:
|
|
43
|
+
"""`template` as an https URL: an scp-like or `ssh://` clone URL is
|
|
44
|
+
rewritten; anything else (https, http, a local path) is unchanged."""
|
|
45
|
+
match = SCP_LIKE_URL.match(template) or SSH_SCHEME_URL.match(template)
|
|
46
|
+
if match is None:
|
|
47
|
+
return template
|
|
48
|
+
return f"https://{match['host']}/{match['path']}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def release_url(template: str, version: Version) -> str:
|
|
52
|
+
"""The release page for `version` at `template`, as an https URL --
|
|
53
|
+
an ssh clone URL is turned into its https form first."""
|
|
54
|
+
base = _https_base(template)
|
|
55
|
+
base = base.rstrip("/").removesuffix(".git")
|
|
56
|
+
return f"{base}/releases/tag/{version}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def entries(text: str, after: Version, up_to: Version, template: str) -> str:
|
|
60
|
+
"""The sections with after < version <= up_to, newest first, each
|
|
61
|
+
heading linking to its release. Empty when there are none."""
|
|
62
|
+
parts = [
|
|
63
|
+
f"## [{version}]({release_url(template, version)}){rest}\n\n{body}\n"
|
|
64
|
+
for version, rest, body in sections(text)
|
|
65
|
+
if after < version <= up_to
|
|
66
|
+
]
|
|
67
|
+
return "\n".join(parts)
|
pyfr_cli/errors.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The one error the command reports: a cause and a fix, one line each."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UpdateError(Exception):
|
|
7
|
+
"""Stops the run. `__main__` prints `cause` and `fix` and exits 2."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, cause: str, fix: str) -> None:
|
|
10
|
+
super().__init__(cause)
|
|
11
|
+
self.cause = cause
|
|
12
|
+
self.fix = fix
|
pyfr_cli/git.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Every git call the tool makes goes through `Git.run`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pyfr_cli.errors import UpdateError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitError(UpdateError):
|
|
14
|
+
"""A git command the tool expected to succeed did not."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Git:
|
|
19
|
+
"""git, run in one directory.
|
|
20
|
+
|
|
21
|
+
Arguments are always fixed literals or paths the tool computed, never
|
|
22
|
+
input typed by a user.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
cwd: Path
|
|
26
|
+
|
|
27
|
+
def run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
["git", *args], cwd=self.cwd, capture_output=True, text=True
|
|
30
|
+
)
|
|
31
|
+
if check and result.returncode != 0:
|
|
32
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
33
|
+
raise GitError(
|
|
34
|
+
f"git {' '.join(args)} failed in {self.cwd}: {detail}",
|
|
35
|
+
"the git message above says what went wrong",
|
|
36
|
+
)
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
def out(self, *args: str) -> str:
|
|
40
|
+
return self.run(*args).stdout.strip()
|
|
41
|
+
|
|
42
|
+
def ok(self, *args: str) -> bool:
|
|
43
|
+
return self.run(*args, check=False).returncode == 0
|
|
44
|
+
|
|
45
|
+
def toplevel(self) -> Path:
|
|
46
|
+
return Path(self.out("rev-parse", "--show-toplevel")).resolve()
|
|
47
|
+
|
|
48
|
+
def git_dir(self) -> Path:
|
|
49
|
+
# Relative for the main worktree (".git"), absolute for a linked
|
|
50
|
+
# worktree; resolved against cwd either way.
|
|
51
|
+
return (self.cwd / self.out("rev-parse", "--git-dir")).resolve()
|
|
52
|
+
|
|
53
|
+
def current_branch(self) -> str | None:
|
|
54
|
+
name = self.out("rev-parse", "--abbrev-ref", "HEAD")
|
|
55
|
+
return None if name == "HEAD" else name
|
|
56
|
+
|
|
57
|
+
def has_tracked_changes(self) -> bool:
|
|
58
|
+
return bool(self.out("status", "--porcelain", "--untracked-files=no"))
|
|
59
|
+
|
|
60
|
+
def has_staged_changes(self) -> bool:
|
|
61
|
+
return not self.ok("diff", "--cached", "--quiet")
|
|
62
|
+
|
|
63
|
+
def operation_in_progress(self) -> str | None:
|
|
64
|
+
git_dir = self.git_dir()
|
|
65
|
+
if (git_dir / "MERGE_HEAD").exists():
|
|
66
|
+
return "merge"
|
|
67
|
+
if (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists():
|
|
68
|
+
return "rebase"
|
|
69
|
+
if (git_dir / "CHERRY_PICK_HEAD").exists():
|
|
70
|
+
return "cherry-pick"
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def root_commits(self) -> list[str]:
|
|
74
|
+
return self.out("rev-list", "--max-parents=0", "HEAD").split()
|
|
75
|
+
|
|
76
|
+
def has_identity(self) -> bool:
|
|
77
|
+
return self.ok("config", "user.name") and self.ok("config", "user.email")
|
|
78
|
+
|
|
79
|
+
def branch_exists(self, name: str) -> bool:
|
|
80
|
+
return self.ok("show-ref", "--verify", "--quiet", f"refs/heads/{name}")
|
|
81
|
+
|
|
82
|
+
def remote_exists(self, name: str) -> bool:
|
|
83
|
+
return self.ok("remote", "get-url", name)
|
|
84
|
+
|
|
85
|
+
def commit(self, message: str, *, allow_empty: bool = False) -> str:
|
|
86
|
+
"""Commit what is staged and return the sha.
|
|
87
|
+
|
|
88
|
+
`--no-verify`: the tool's commits are mechanical, and the template
|
|
89
|
+
worktree shares .git/hooks with the project, where the generator
|
|
90
|
+
installed pre-commit. CI runs the gates on the pull request.
|
|
91
|
+
"""
|
|
92
|
+
args = ["commit", "--quiet", "--no-verify", "--message", message]
|
|
93
|
+
if allow_empty:
|
|
94
|
+
args.append("--allow-empty")
|
|
95
|
+
self.run(*args)
|
|
96
|
+
return self.out("rev-parse", "HEAD")
|
|
97
|
+
|
|
98
|
+
def subject(self, rev: str) -> str:
|
|
99
|
+
return self.out("show", "--no-patch", "--format=%s", rev)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def require_tools(*names: str) -> None:
|
|
103
|
+
for name in names:
|
|
104
|
+
if shutil.which(name) is None:
|
|
105
|
+
raise UpdateError(
|
|
106
|
+
f"{name} is not on PATH", f"install {name}, then run again"
|
|
107
|
+
)
|
pyfr_cli/ignore.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
""".pyfr-update-ignore: the paths an update leaves exactly as the project has them.
|
|
2
|
+
|
|
3
|
+
gitignore syntax, matched with pathspec. A project generated before the
|
|
4
|
+
template shipped the file uses the built-in default below, rendered for its
|
|
5
|
+
answers (spec section 5.2 and decision M8-5).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pathspec import GitIgnoreSpec
|
|
15
|
+
|
|
16
|
+
from pyfr_cli.answers import Answers
|
|
17
|
+
|
|
18
|
+
FILE = ".pyfr-update-ignore"
|
|
19
|
+
|
|
20
|
+
HEADER = """\
|
|
21
|
+
# Paths `just update` leaves exactly as this project has them: yours from
|
|
22
|
+
# the first day, or artifacts of your code. Add paths as you diverge --
|
|
23
|
+
# one gitignore pattern per line, comments on their own lines.
|
|
24
|
+
# Everything not listed is template-owned and receives fixes by default.
|
|
25
|
+
|
|
26
|
+
# Written by the update itself.
|
|
27
|
+
/.pyfr-answers.yml
|
|
28
|
+
# This file.
|
|
29
|
+
/.pyfr-update-ignore
|
|
30
|
+
/README.md
|
|
31
|
+
/CHANGELOG.md
|
|
32
|
+
# Resolver output; run `uv lock` after an update that touched pyproject.toml.
|
|
33
|
+
/uv.lock
|
|
34
|
+
"""
|
|
35
|
+
SCHEMA = """\
|
|
36
|
+
# Your schema.
|
|
37
|
+
/migrations/
|
|
38
|
+
/schema.sql
|
|
39
|
+
"""
|
|
40
|
+
FOOTER = """\
|
|
41
|
+
# Artifacts of your code: the contract, and the baseline your release promotes.
|
|
42
|
+
/openapi.json
|
|
43
|
+
/openapi.baseline.json
|
|
44
|
+
# Your decisions.
|
|
45
|
+
/docs/adr/
|
|
46
|
+
# The example slice, then your business model.
|
|
47
|
+
/src/{package}/domain/
|
|
48
|
+
/src/{package}/services/
|
|
49
|
+
/src/{package}/api/v1/
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_text(package: str, database: str) -> str:
|
|
54
|
+
"""The built-in default, as the template body ships it for these answers.
|
|
55
|
+
|
|
56
|
+
The schema lines exist only when the project has a database: the
|
|
57
|
+
generator prunes migrations/ and schema.sql otherwise.
|
|
58
|
+
"""
|
|
59
|
+
schema = SCHEMA if database == "postgres" else ""
|
|
60
|
+
return HEADER + schema + FOOTER.format(package=package)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Ignore:
|
|
64
|
+
def __init__(self, lines: Iterable[str]) -> None:
|
|
65
|
+
self._spec = GitIgnoreSpec.from_lines(lines)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_text(cls, text: str) -> Ignore:
|
|
69
|
+
return cls(text.splitlines())
|
|
70
|
+
|
|
71
|
+
def matches(self, relative: str) -> bool:
|
|
72
|
+
"""Whether a path (POSIX, relative to the project root) is left alone."""
|
|
73
|
+
return bool(self._spec.match_file(relative))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load(project: Path, recorded: Answers) -> tuple[Ignore, bool]:
|
|
77
|
+
"""The project's ignore file, or the default rendered for its answers.
|
|
78
|
+
|
|
79
|
+
Returns the spec and whether the file existed, so the caller can say
|
|
80
|
+
which one applied.
|
|
81
|
+
"""
|
|
82
|
+
file = project / FILE
|
|
83
|
+
if file.is_file():
|
|
84
|
+
return Ignore.from_text(file.read_text()), True
|
|
85
|
+
package = recorded.values.get("package_name", "")
|
|
86
|
+
database = recorded.values.get("database", "none")
|
|
87
|
+
return Ignore.from_text(default_text(package, database)), False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def install(rendered_project: Path, project: Path) -> bool:
|
|
91
|
+
"""Give a project that has no ignore file the render's, if the render
|
|
92
|
+
has one; say whether that happened.
|
|
93
|
+
|
|
94
|
+
The built-in default ignores the file itself, so the sync never puts
|
|
95
|
+
it on the template branch and the merge can never deliver it. This
|
|
96
|
+
is how a project generated before the template shipped the file
|
|
97
|
+
receives it with the update (spec section 5.2).
|
|
98
|
+
"""
|
|
99
|
+
source, target = rendered_project / FILE, project / FILE
|
|
100
|
+
if not source.is_file() or target.exists():
|
|
101
|
+
return False
|
|
102
|
+
shutil.copyfile(source, target)
|
|
103
|
+
return True
|
pyfr_cli/migrate.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Migration scripts: updates/<version>/before.py and after.py in the template.
|
|
2
|
+
|
|
3
|
+
Some template changes cannot be expressed as a merge -- a file that moves,
|
|
4
|
+
a setting that changes shape. For each version in (recorded, target] the
|
|
5
|
+
template may ship a `before.py`, run on the project's tree before the
|
|
6
|
+
merge, and an `after.py`, run after it (spec section 6; updates/README.md
|
|
7
|
+
is the contract for the scripts' authors).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TextIO
|
|
17
|
+
|
|
18
|
+
from pyfr_cli.errors import UpdateError
|
|
19
|
+
from pyfr_cli.versions import Version
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Migration:
|
|
24
|
+
version: Version
|
|
25
|
+
before: Path | None
|
|
26
|
+
after: Path | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def discover(clone: Path, after: Version, up_to: Version) -> list[Migration]:
|
|
30
|
+
"""The migrations for versions in (after, up_to], oldest first."""
|
|
31
|
+
updates = clone / "updates"
|
|
32
|
+
if not updates.is_dir():
|
|
33
|
+
return []
|
|
34
|
+
found: list[Migration] = []
|
|
35
|
+
for entry in updates.iterdir():
|
|
36
|
+
if not entry.is_dir():
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
version = Version.parse(entry.name)
|
|
40
|
+
except ValueError:
|
|
41
|
+
continue
|
|
42
|
+
# Exactly vX.Y.Z: the v is required, and no zero padding.
|
|
43
|
+
if entry.name != str(version) or not after < version <= up_to:
|
|
44
|
+
continue
|
|
45
|
+
before, after_script = entry / "before.py", entry / "after.py"
|
|
46
|
+
found.append(
|
|
47
|
+
Migration(
|
|
48
|
+
version,
|
|
49
|
+
before if before.is_file() else None,
|
|
50
|
+
after_script if after_script.is_file() else None,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return sorted(found, key=lambda migration: migration.version)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_script(
|
|
57
|
+
script: Path,
|
|
58
|
+
project: Path,
|
|
59
|
+
from_version: Version,
|
|
60
|
+
to_version: Version,
|
|
61
|
+
out: TextIO,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""One script, in the project root, with the update's versions in the
|
|
64
|
+
environment. `--no-project`: scripts are standard-library only, and a
|
|
65
|
+
project sync here would be slow and would fail in a fresh checkout."""
|
|
66
|
+
label = f"{script.parent.name}/{script.name}"
|
|
67
|
+
out.write(f"migrations: {label}\n")
|
|
68
|
+
env = {
|
|
69
|
+
**os.environ,
|
|
70
|
+
"PYFR_UPDATE_FROM": str(from_version),
|
|
71
|
+
"PYFR_UPDATE_TO": str(to_version),
|
|
72
|
+
}
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["uv", "run", "--no-project", "python", str(script)],
|
|
75
|
+
cwd=project,
|
|
76
|
+
env=env,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
)
|
|
80
|
+
if result.stdout:
|
|
81
|
+
out.write(result.stdout)
|
|
82
|
+
if result.returncode != 0:
|
|
83
|
+
raise UpdateError(
|
|
84
|
+
f"{label} failed (exit {result.returncode}): {result.stderr.strip()}",
|
|
85
|
+
"fix what it reports; `git reset --hard HEAD` reverts what it "
|
|
86
|
+
"changed, then run pyfr update again",
|
|
87
|
+
)
|
|
88
|
+
# A warning the script printed is worth seeing when it succeeds too.
|
|
89
|
+
if result.stderr:
|
|
90
|
+
out.write(result.stderr)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_before(
|
|
94
|
+
migrations: list[Migration],
|
|
95
|
+
project: Path,
|
|
96
|
+
from_version: Version,
|
|
97
|
+
to_version: Version,
|
|
98
|
+
out: TextIO,
|
|
99
|
+
) -> None:
|
|
100
|
+
for migration in migrations:
|
|
101
|
+
if migration.before is not None:
|
|
102
|
+
run_script(migration.before, project, from_version, to_version, out)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_after(
|
|
106
|
+
migrations: list[Migration],
|
|
107
|
+
project: Path,
|
|
108
|
+
from_version: Version,
|
|
109
|
+
to_version: Version,
|
|
110
|
+
out: TextIO,
|
|
111
|
+
) -> None:
|
|
112
|
+
for migration in migrations:
|
|
113
|
+
if migration.after is not None:
|
|
114
|
+
run_script(migration.after, project, from_version, to_version, out)
|