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