prepush-ci 0.0.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.
- prepush_ci/__init__.py +1 -0
- prepush_ci/__main__.py +5 -0
- prepush_ci/checking.py +52 -0
- prepush_ci/cli.py +79 -0
- prepush_ci/commit_msg_hook.py +116 -0
- prepush_ci/core/__init__.py +5 -0
- prepush_ci/core/commit_msg.py +144 -0
- prepush_ci/core/config.py +169 -0
- prepush_ci/core/config_source.py +59 -0
- prepush_ci/core/install.py +138 -0
- prepush_ci/core/pre_push.py +185 -0
- prepush_ci/core/preflight.py +64 -0
- prepush_ci/core/run_cache.py +56 -0
- prepush_ci/core/strip.py +155 -0
- prepush_ci/core/verify.py +104 -0
- prepush_ci/git.py +99 -0
- prepush_ci/install_command.py +105 -0
- prepush_ci/pass_cache.py +62 -0
- prepush_ci/pre_push_hook.py +121 -0
- prepush_ci/preflight.py +73 -0
- prepush_ci/repo_config.py +34 -0
- prepush_ci/run_command.py +39 -0
- prepush_ci/strip_command.py +168 -0
- prepush_ci/verify_command.py +82 -0
- prepush_ci-0.0.0.dist-info/METADATA +66 -0
- prepush_ci-0.0.0.dist-info/RECORD +28 -0
- prepush_ci-0.0.0.dist-info/WHEEL +4 -0
- prepush_ci-0.0.0.dist-info/entry_points.txt +3 -0
prepush_ci/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""prepush-ci: run the full CI check before push, let pull request CI skip what it proved."""
|
prepush_ci/__main__.py
ADDED
prepush_ci/checking.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The steps `run` and the pre-push hook share: what must hold before the check, and the check."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from prepush_ci.core.config import Config
|
|
8
|
+
from prepush_ci.core.pre_push import message_dirty, message_hidden
|
|
9
|
+
from prepush_ci.git import dirty, git, hidden
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Refused(Exception):
|
|
13
|
+
"""The check cannot run on this state; the message says why."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def head(cwd: Path) -> str:
|
|
17
|
+
result = git(cwd, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
|
|
18
|
+
if result.returncode != 0:
|
|
19
|
+
raise Refused("HEAD points to no commit, so there is nothing to check")
|
|
20
|
+
return result.stdout.strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def require_clean(root: Path) -> None:
|
|
24
|
+
"""Refuse a worktree whose state is not the commit: changes, or changes git hides."""
|
|
25
|
+
dirty_files = dirty(root)
|
|
26
|
+
if dirty_files:
|
|
27
|
+
raise Refused(message_dirty(dirty_files))
|
|
28
|
+
hidden_files = hidden(root)
|
|
29
|
+
if hidden_files:
|
|
30
|
+
raise Refused(message_hidden(hidden_files))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def worktree_root(cwd: Path) -> Path:
|
|
34
|
+
result = git(cwd, "rev-parse", "--show-toplevel")
|
|
35
|
+
if result.returncode != 0:
|
|
36
|
+
raise Refused(
|
|
37
|
+
"the repository has no worktree, so there is no commit to check here"
|
|
38
|
+
)
|
|
39
|
+
return Path(result.stdout.strip())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_check(config: Config, root: Path) -> int:
|
|
43
|
+
outcome = subprocess.run(
|
|
44
|
+
config.check, shell=True, cwd=root, stdin=subprocess.DEVNULL, check=False
|
|
45
|
+
)
|
|
46
|
+
if outcome.returncode != 0:
|
|
47
|
+
print(
|
|
48
|
+
f"prepush-ci: the check failed (exit {outcome.returncode}): {config.check}",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
return 1
|
|
52
|
+
return 0
|
prepush_ci/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from prepush_ci import (
|
|
10
|
+
commit_msg_hook,
|
|
11
|
+
install_command,
|
|
12
|
+
pre_push_hook,
|
|
13
|
+
run_command,
|
|
14
|
+
strip_command,
|
|
15
|
+
verify_command,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
args_in = sys.argv[1:] if argv is None else argv
|
|
21
|
+
if args_in[:1] == ["--"]:
|
|
22
|
+
args_in = args_in[
|
|
23
|
+
1:
|
|
24
|
+
] # `prepush-ci -- verify "$SHA"` must reach the route below
|
|
25
|
+
if args_in[:1] == ["verify"]:
|
|
26
|
+
# Before argparse: its usage errors exit 2 and `--help` exits 0, but a CI gate must only
|
|
27
|
+
# ever see 0 (verified) or 1.
|
|
28
|
+
return verify_command.run(args_in[1:], Path.cwd())
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="prepush-ci",
|
|
31
|
+
epilog="also: verify <sha> exit 0 when a commit carries the trailer and was not "
|
|
32
|
+
"made by a server", # not a subparser: argparse could never be allowed to answer for it
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--version", action="version", version=f"%(prog)s {version('prepush-ci')}"
|
|
36
|
+
)
|
|
37
|
+
commands = parser.add_subparsers(dest="command")
|
|
38
|
+
hook = commands.add_parser("hook", help="entry points that git hooks call")
|
|
39
|
+
hooks = hook.add_subparsers(dest="hook", required=True)
|
|
40
|
+
commands.add_parser(
|
|
41
|
+
"run",
|
|
42
|
+
help="run the preflight and the check on HEAD, as the pre-push hook would",
|
|
43
|
+
)
|
|
44
|
+
commands.add_parser("install", help="point git at hooks that run this tool")
|
|
45
|
+
commit_msg = hooks.add_parser(
|
|
46
|
+
"commit-msg", help="write the trailer when PREPUSH_CI=full"
|
|
47
|
+
)
|
|
48
|
+
commit_msg.add_argument(
|
|
49
|
+
"file", type=Path, help="the commit message file git passes"
|
|
50
|
+
)
|
|
51
|
+
pre_push = hooks.add_parser(
|
|
52
|
+
"pre-push",
|
|
53
|
+
help="refuse to push a commit with the trailer unless the check passes",
|
|
54
|
+
)
|
|
55
|
+
pre_push.add_argument(
|
|
56
|
+
"git_args", nargs="*", help="the remote name and url git passes (unused)"
|
|
57
|
+
)
|
|
58
|
+
strip = commands.add_parser(
|
|
59
|
+
"strip",
|
|
60
|
+
help="remove the trailer from the commits since <base>, so a push needs no check",
|
|
61
|
+
)
|
|
62
|
+
strip.add_argument(
|
|
63
|
+
"base",
|
|
64
|
+
nargs="*",
|
|
65
|
+
help="the commit before yours (default: the merge base with the default branch)",
|
|
66
|
+
)
|
|
67
|
+
args = parser.parse_args(args_in)
|
|
68
|
+
if args.command == "strip":
|
|
69
|
+
return strip_command.run(args.base, Path.cwd())
|
|
70
|
+
if args.command == "run":
|
|
71
|
+
return run_command.run(os.environ, Path.cwd())
|
|
72
|
+
if args.command == "install":
|
|
73
|
+
return install_command.run(Path.cwd())
|
|
74
|
+
if args.command == "hook" and args.hook == "pre-push":
|
|
75
|
+
return pre_push_hook.run(sys.stdin, os.environ, Path.cwd())
|
|
76
|
+
if args.command == "hook":
|
|
77
|
+
return commit_msg_hook.run(args.file, os.environ, Path.cwd())
|
|
78
|
+
parser.print_help()
|
|
79
|
+
return 0
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""`prepush-ci hook commit-msg <file>`: write the trailer that demands the full check."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from prepush_ci.core.commit_msg import (
|
|
9
|
+
InvalidMode,
|
|
10
|
+
Mode,
|
|
11
|
+
UnsupportedCommentChar,
|
|
12
|
+
editor_was_used,
|
|
13
|
+
is_blank_message,
|
|
14
|
+
is_template_untouched,
|
|
15
|
+
parse_mode,
|
|
16
|
+
require_known_comment_setting,
|
|
17
|
+
resolve_cleanup,
|
|
18
|
+
)
|
|
19
|
+
from prepush_ci.core.config import ConfigError
|
|
20
|
+
from prepush_ci.repo_config import load_repository_config
|
|
21
|
+
|
|
22
|
+
ENV_VAR = "PREPUSH_CI"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(message_file: Path, env: Mapping[str, str], cwd: Path) -> int:
|
|
26
|
+
"""Run the hook; return the exit code (non-zero makes git abort the commit)."""
|
|
27
|
+
try:
|
|
28
|
+
mode = parse_mode(env.get(ENV_VAR))
|
|
29
|
+
except InvalidMode as error:
|
|
30
|
+
print(f"prepush-ci: {error}", file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
if mode is Mode.OFF:
|
|
33
|
+
return 0
|
|
34
|
+
try:
|
|
35
|
+
config = load_repository_config(cwd)
|
|
36
|
+
except ConfigError as error:
|
|
37
|
+
for problem in error.problems:
|
|
38
|
+
print(f"prepush-ci: {problem}", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
try:
|
|
41
|
+
require_known_comment_setting(_comment_setting(cwd))
|
|
42
|
+
except UnsupportedCommentChar as error:
|
|
43
|
+
print(f"prepush-ci: {error}", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
text = message_file.read_text(encoding="utf-8")
|
|
46
|
+
editor_used = editor_was_used(env.get("GIT_EDITOR"), text)
|
|
47
|
+
cleanup = resolve_cleanup(_git_config(cwd, "commit.cleanup"), editor_used)
|
|
48
|
+
comment = _comment_prefix(cwd)
|
|
49
|
+
if is_blank_message(text, cleanup, comment):
|
|
50
|
+
return 0
|
|
51
|
+
template = (
|
|
52
|
+
_commit_template(cwd) if editor_used else None
|
|
53
|
+
) # git checks only after an editor
|
|
54
|
+
if template is not None and is_template_untouched(text, template, cleanup, comment):
|
|
55
|
+
return 0
|
|
56
|
+
subprocess.run(
|
|
57
|
+
[
|
|
58
|
+
"git",
|
|
59
|
+
"interpret-trailers",
|
|
60
|
+
"--in-place",
|
|
61
|
+
"--no-divider",
|
|
62
|
+
"--where",
|
|
63
|
+
"start",
|
|
64
|
+
"--if-exists",
|
|
65
|
+
"doNothing",
|
|
66
|
+
"--trailer",
|
|
67
|
+
config.trailer.line(),
|
|
68
|
+
str(message_file),
|
|
69
|
+
],
|
|
70
|
+
check=True,
|
|
71
|
+
)
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _comment_setting(cwd: Path) -> str | None:
|
|
76
|
+
"""What the repository configures as comment prefix (`commentString` is newer git)."""
|
|
77
|
+
for name in ("core.commentString", "core.commentChar"):
|
|
78
|
+
value = _git_config(cwd, name)
|
|
79
|
+
if value is not None:
|
|
80
|
+
return value
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _git_config(cwd: Path, name: str, *, as_path: bool = False) -> str | None:
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
["git", "config", "--get", *(["--type=path"] if as_path else []), name],
|
|
87
|
+
cwd=cwd,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
return result.stdout.removesuffix("\n") if result.returncode == 0 else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _comment_prefix(cwd: Path) -> str:
|
|
96
|
+
"""The comment prefix of the git that runs the hook, whichever settings it honours."""
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
["git", "stripspace", "--comment-lines"],
|
|
99
|
+
cwd=cwd,
|
|
100
|
+
input="x\n",
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
check=True,
|
|
104
|
+
)
|
|
105
|
+
return result.stdout.removesuffix(" x\n")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _commit_template(cwd: Path) -> str | None:
|
|
109
|
+
"""The text of `commit.template`, or None when unset or unreadable (git reports that itself)."""
|
|
110
|
+
path = _git_config(cwd, "commit.template", as_path=True)
|
|
111
|
+
if path is None:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
return (cwd / path).read_text(encoding="utf-8", errors="replace")
|
|
115
|
+
except OSError:
|
|
116
|
+
return None
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""The pure core: decisions without I/O (trailers, config values, ref lines, verify).
|
|
2
|
+
|
|
3
|
+
Nothing here touches the file system, processes, the environment or the network; the CLI and
|
|
4
|
+
hook modules do that and pass plain values in. tests/unit covers this package to 100% on its own.
|
|
5
|
+
"""
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Decisions of the commit-msg hook: what `PREPUSH_CI` asks for, and whether a message is empty."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Mode(Enum):
|
|
7
|
+
OFF = "off"
|
|
8
|
+
FULL = "full"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvalidMode(Exception):
|
|
12
|
+
"""`PREPUSH_CI` holds a value other than `full`, `off` or the empty string."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_mode(value: str | None) -> Mode:
|
|
16
|
+
"""Unset and empty mean `off`; values are compared exactly, so `FULL` is refused."""
|
|
17
|
+
if value is None or value == "":
|
|
18
|
+
return Mode.OFF
|
|
19
|
+
try:
|
|
20
|
+
return Mode(value)
|
|
21
|
+
except ValueError:
|
|
22
|
+
raise InvalidMode(
|
|
23
|
+
f"PREPUSH_CI={value!r} is not valid: use `full` to demand the full check "
|
|
24
|
+
"on this commit, or `off` (or leave it unset) to commit as usual"
|
|
25
|
+
) from None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
SIGN_OFF = (
|
|
29
|
+
"Signed-off-by: " # `git commit -s`; git does not count these lines as a message
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnsupportedCommentChar(Exception):
|
|
34
|
+
"""`core.commentChar` holds a value the hook cannot interpret."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_known_comment_setting(setting: str | None) -> None:
|
|
38
|
+
"""Refuse `auto` and empty: git picks the comment prefix per commit, so it cannot be known here."""
|
|
39
|
+
if setting in ("", "auto"):
|
|
40
|
+
raise UnsupportedCommentChar(
|
|
41
|
+
f"core.commentChar={setting!r} is not supported with PREPUSH_CI=full: "
|
|
42
|
+
"git picks the comment character per commit, so the hook cannot tell an "
|
|
43
|
+
"empty message from one that holds only the trailer; set core.commentChar "
|
|
44
|
+
"to a fixed character such as `#`, or commit with PREPUSH_CI=off"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Cleanup(Enum):
|
|
49
|
+
"""How git cleans the message up after the `commit-msg` hook (`commit.cleanup`)."""
|
|
50
|
+
|
|
51
|
+
STRIP = "strip"
|
|
52
|
+
WHITESPACE = "whitespace"
|
|
53
|
+
VERBATIM = "verbatim"
|
|
54
|
+
SCISSORS = "scissors"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def editor_was_used(git_editor: str | None, text: str) -> bool:
|
|
58
|
+
"""Whether git launched an editor, which hooks cannot be told directly.
|
|
59
|
+
|
|
60
|
+
Git sets `GIT_EDITOR=:` for its hooks when it launches none. A user who chose the no-op
|
|
61
|
+
editor `:` still leaves git's template in the message, which starts with an empty line.
|
|
62
|
+
"""
|
|
63
|
+
return git_editor != ":" or text.startswith("\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_cleanup(configured: str | None, editor_used: bool) -> Cleanup:
|
|
67
|
+
"""The cleanup git applies: `commit.cleanup`, else `strip` with an editor, `whitespace` without.
|
|
68
|
+
|
|
69
|
+
An explicit `git commit --cleanup=<mode>` is invisible to hooks and not accounted for.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
return Cleanup(configured)
|
|
73
|
+
except ValueError: # unset or "default"
|
|
74
|
+
return Cleanup.STRIP if editor_used else Cleanup.WHITESPACE
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_blank_message(
|
|
78
|
+
text: str, cleanup: Cleanup = Cleanup.STRIP, comment: str = "#"
|
|
79
|
+
) -> bool:
|
|
80
|
+
"""True when git will find nothing in the message after its cleanup and abort the commit.
|
|
81
|
+
|
|
82
|
+
Like git, ignore everything from the scissors line on (the diff of `git commit -v`) and
|
|
83
|
+
sign-off lines (`git commit -s` adds one to an empty message).
|
|
84
|
+
Only `strip` drops comment lines; in the other modes they are the message.
|
|
85
|
+
"""
|
|
86
|
+
scissors = f"{comment} ------------------------ >8 ------------------------"
|
|
87
|
+
kept: list[str] = []
|
|
88
|
+
for line in text.splitlines():
|
|
89
|
+
if line.startswith(scissors):
|
|
90
|
+
break
|
|
91
|
+
kept.append(line)
|
|
92
|
+
if cleanup is Cleanup.VERBATIM:
|
|
93
|
+
return not kept
|
|
94
|
+
drops_comments = cleanup is Cleanup.STRIP
|
|
95
|
+
return not any(
|
|
96
|
+
line.strip()
|
|
97
|
+
and not line.startswith(SIGN_OFF)
|
|
98
|
+
and not (drops_comments and line.startswith(comment))
|
|
99
|
+
for line in kept
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def clean_up(text: str, cleanup: Cleanup, comment: str = "#") -> str:
|
|
104
|
+
"""The message the way git's cleanup leaves it (`git stripspace`), for comparing messages.
|
|
105
|
+
|
|
106
|
+
Like git, cut at the scissors line, trim trailing whitespace, collapse runs of empty lines
|
|
107
|
+
and drop leading and trailing ones; `strip` also drops comment lines. `verbatim` keeps the text.
|
|
108
|
+
"""
|
|
109
|
+
scissors = f"{comment} ------------------------ >8 ------------------------"
|
|
110
|
+
if cleanup is Cleanup.VERBATIM:
|
|
111
|
+
return text
|
|
112
|
+
lines: list[str] = []
|
|
113
|
+
for line in text.splitlines():
|
|
114
|
+
if line.startswith(scissors):
|
|
115
|
+
break
|
|
116
|
+
if cleanup is Cleanup.STRIP and line.startswith(comment):
|
|
117
|
+
continue
|
|
118
|
+
line = line.rstrip()
|
|
119
|
+
if line or (lines and lines[-1]):
|
|
120
|
+
lines.append(line)
|
|
121
|
+
while lines and not lines[-1]:
|
|
122
|
+
lines.pop()
|
|
123
|
+
return "".join(f"{line}\n" for line in lines)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_template_untouched(
|
|
127
|
+
text: str, template: str, cleanup: Cleanup, comment: str = "#"
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""True when git will find the message unchanged from `commit.template` and abort the commit.
|
|
130
|
+
|
|
131
|
+
Git compares the cleaned message with the cleaned template, so a trailer added by the hook
|
|
132
|
+
would make an unedited template look edited. Sign-off lines after the template do not count
|
|
133
|
+
as an edit, and a verbatim message is never untouched.
|
|
134
|
+
"""
|
|
135
|
+
if cleanup is Cleanup.VERBATIM or not template:
|
|
136
|
+
return False
|
|
137
|
+
cleaned_template = clean_up(template, cleanup, comment)
|
|
138
|
+
cleaned = clean_up(text, cleanup, comment)
|
|
139
|
+
if not cleaned.startswith(cleaned_template):
|
|
140
|
+
return False
|
|
141
|
+
rest = cleaned.removeprefix(cleaned_template)
|
|
142
|
+
return all(
|
|
143
|
+
not line.strip() or line.startswith(SIGN_OFF) for line in rest.splitlines()
|
|
144
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Turn the config table into a typed `Config`, or into every problem found in it."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
DEFAULT_TRAILER_KEY = "Prepush-CI"
|
|
9
|
+
DEFAULT_TRAILER_VALUE = "full"
|
|
10
|
+
DEFAULT_BRANCH = "main"
|
|
11
|
+
REMOTE = "origin"
|
|
12
|
+
|
|
13
|
+
_TRAILER_TOKEN = re.compile(r"[A-Za-z0-9-]+")
|
|
14
|
+
_KEYS = (
|
|
15
|
+
"check",
|
|
16
|
+
"preflight",
|
|
17
|
+
"require_env",
|
|
18
|
+
"require_up_to_date",
|
|
19
|
+
"default_branch",
|
|
20
|
+
"trailer",
|
|
21
|
+
"legacy_keys",
|
|
22
|
+
"cache",
|
|
23
|
+
)
|
|
24
|
+
_TRAILER_KEYS = ("key", "value")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Trailer:
|
|
29
|
+
key: str
|
|
30
|
+
value: str
|
|
31
|
+
|
|
32
|
+
def line(self) -> str:
|
|
33
|
+
return f"{self.key}: {self.value}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Config:
|
|
38
|
+
check: str
|
|
39
|
+
preflight: str | None
|
|
40
|
+
require_env: tuple[str, ...]
|
|
41
|
+
require_up_to_date: str | None
|
|
42
|
+
default_branch: str
|
|
43
|
+
trailer: Trailer
|
|
44
|
+
legacy_keys: tuple[str, ...]
|
|
45
|
+
cache: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigError(Exception):
|
|
49
|
+
"""The config is missing or invalid; `problems` has one line per problem, each naming its key."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, problems: list[str]) -> None:
|
|
52
|
+
super().__init__("\n".join(problems))
|
|
53
|
+
self.problems = problems
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_trailer_token(text: str) -> bool:
|
|
57
|
+
return _TRAILER_TOKEN.fullmatch(text) is not None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_trailer_value(text: str) -> bool:
|
|
61
|
+
return bool(text) and text == text.strip() and "\n" not in text and "\r" not in text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_config(table: Mapping[str, Any]) -> Config:
|
|
65
|
+
problems: list[str] = []
|
|
66
|
+
|
|
67
|
+
def string(key: str, value: Any, *, default: str | None = None) -> str | None:
|
|
68
|
+
if value is None:
|
|
69
|
+
return default
|
|
70
|
+
if not isinstance(value, str):
|
|
71
|
+
problems.append(f"`{key}` must be a string")
|
|
72
|
+
return default
|
|
73
|
+
if not value:
|
|
74
|
+
problems.append(f"`{key}` must not be empty")
|
|
75
|
+
return default
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
def strings(key: str, value: Any) -> tuple[str, ...]:
|
|
79
|
+
if value is None:
|
|
80
|
+
return ()
|
|
81
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
82
|
+
problems.append(f"`{key}` must be a list of strings")
|
|
83
|
+
return ()
|
|
84
|
+
return tuple(value)
|
|
85
|
+
|
|
86
|
+
problems.extend(
|
|
87
|
+
f"unknown key `{key}` (known keys: {', '.join(_KEYS)})"
|
|
88
|
+
for key in table
|
|
89
|
+
if key not in _KEYS
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
check = string("check", table.get("check"))
|
|
93
|
+
if "check" not in table:
|
|
94
|
+
problems.append("`check` is required: the command that runs your full CI check")
|
|
95
|
+
preflight = string("preflight", table.get("preflight"))
|
|
96
|
+
default_branch = string(
|
|
97
|
+
"default_branch", table.get("default_branch"), default=DEFAULT_BRANCH
|
|
98
|
+
)
|
|
99
|
+
require_env = strings("require_env", table.get("require_env"))
|
|
100
|
+
legacy_keys = strings("legacy_keys", table.get("legacy_keys"))
|
|
101
|
+
problems.extend(
|
|
102
|
+
f"`legacy_keys` contains {key!r}, which is not a valid trailer key "
|
|
103
|
+
"(letters, digits and hyphens only)"
|
|
104
|
+
for key in legacy_keys
|
|
105
|
+
if not is_trailer_token(key)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
up_to_date = table.get("require_up_to_date")
|
|
109
|
+
if up_to_date is None or up_to_date is True:
|
|
110
|
+
require_up_to_date = f"{REMOTE}/{default_branch}"
|
|
111
|
+
elif up_to_date is False:
|
|
112
|
+
require_up_to_date = None
|
|
113
|
+
elif isinstance(up_to_date, str):
|
|
114
|
+
require_up_to_date = string("require_up_to_date", up_to_date)
|
|
115
|
+
else:
|
|
116
|
+
problems.append("`require_up_to_date` must be a string or a boolean")
|
|
117
|
+
require_up_to_date = None
|
|
118
|
+
|
|
119
|
+
trailer = _parse_trailer(table.get("trailer"), problems)
|
|
120
|
+
|
|
121
|
+
cache = table.get("cache", True)
|
|
122
|
+
if not isinstance(cache, bool):
|
|
123
|
+
problems.append("`cache` must be a boolean")
|
|
124
|
+
|
|
125
|
+
if problems or check is None or default_branch is None:
|
|
126
|
+
raise ConfigError(problems)
|
|
127
|
+
return Config(
|
|
128
|
+
check=check,
|
|
129
|
+
preflight=preflight,
|
|
130
|
+
require_env=require_env,
|
|
131
|
+
require_up_to_date=require_up_to_date,
|
|
132
|
+
default_branch=default_branch,
|
|
133
|
+
trailer=trailer,
|
|
134
|
+
legacy_keys=legacy_keys,
|
|
135
|
+
cache=bool(cache),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_trailer(table: Any, problems: list[str]) -> Trailer:
|
|
140
|
+
default = Trailer(DEFAULT_TRAILER_KEY, DEFAULT_TRAILER_VALUE)
|
|
141
|
+
if table is None:
|
|
142
|
+
return default
|
|
143
|
+
if not isinstance(table, dict):
|
|
144
|
+
problems.append("`trailer` must be a table with `key` and `value`")
|
|
145
|
+
return default
|
|
146
|
+
problems.extend(
|
|
147
|
+
f"unknown key `trailer.{key}` (known keys: {', '.join(_TRAILER_KEYS)})"
|
|
148
|
+
for key in table
|
|
149
|
+
if key not in _TRAILER_KEYS
|
|
150
|
+
)
|
|
151
|
+
key = table.get("key", default.key)
|
|
152
|
+
value = table.get("value", default.value)
|
|
153
|
+
if not isinstance(key, str):
|
|
154
|
+
problems.append("`trailer.key` must be a string")
|
|
155
|
+
elif not is_trailer_token(key):
|
|
156
|
+
problems.append(
|
|
157
|
+
f"`trailer.key` is not a valid trailer key: {key!r} "
|
|
158
|
+
"(letters, digits and hyphens only)"
|
|
159
|
+
)
|
|
160
|
+
if not isinstance(value, str):
|
|
161
|
+
problems.append("`trailer.value` must be a string")
|
|
162
|
+
elif not is_trailer_value(value):
|
|
163
|
+
problems.append(
|
|
164
|
+
f"`trailer.value` must be a non-empty single line without leading or trailing whitespace: {value!r}"
|
|
165
|
+
)
|
|
166
|
+
return Trailer(
|
|
167
|
+
key if isinstance(key, str) else default.key,
|
|
168
|
+
value if isinstance(value, str) else default.value,
|
|
169
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Pick the config source in use and parse only that one."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from prepush_ci.core.config import Config, ConfigError, parse_config
|
|
7
|
+
|
|
8
|
+
PREPUSH_CI_FILE = ".prepush-ci.toml"
|
|
9
|
+
PYPROJECT_FILE = "pyproject.toml"
|
|
10
|
+
PYPROJECT_TABLE = "[tool.prepush-ci]"
|
|
11
|
+
|
|
12
|
+
_MISSING = (
|
|
13
|
+
f"no config found: create `{PREPUSH_CI_FILE}` at the repository root "
|
|
14
|
+
f"or add a `{PYPROJECT_TABLE}` table to `{PYPROJECT_FILE}`"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigMissing(ConfigError):
|
|
19
|
+
"""There is no config at all (as opposed to an invalid one)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(prepush_ci_toml: str | None, pyproject_toml: str | None) -> Config:
|
|
23
|
+
"""Parse the config from the text of `.prepush-ci.toml`, else of `pyproject.toml`.
|
|
24
|
+
|
|
25
|
+
Each argument is the text of the file, or None when the file does not exist. The
|
|
26
|
+
dedicated file wins and the other one is not even parsed.
|
|
27
|
+
"""
|
|
28
|
+
if prepush_ci_toml is not None:
|
|
29
|
+
return _parse(_parse_toml(prepush_ci_toml, PREPUSH_CI_FILE), PREPUSH_CI_FILE)
|
|
30
|
+
if pyproject_toml is None:
|
|
31
|
+
raise ConfigMissing([_MISSING])
|
|
32
|
+
data = _parse_toml(pyproject_toml, PYPROJECT_FILE)
|
|
33
|
+
tool = data.get("tool")
|
|
34
|
+
ours = tool.get("prepush-ci") if isinstance(tool, dict) else None
|
|
35
|
+
if ours is None:
|
|
36
|
+
raise ConfigMissing([_MISSING])
|
|
37
|
+
if not isinstance(ours, dict):
|
|
38
|
+
raise ConfigError(
|
|
39
|
+
[
|
|
40
|
+
f"{PYPROJECT_FILE}: `{PYPROJECT_TABLE}` must be a table, not a single value"
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
return _parse(ours, f"{PYPROJECT_FILE} {PYPROJECT_TABLE}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_toml(text: str, name: str) -> dict[str, Any]:
|
|
47
|
+
try:
|
|
48
|
+
return tomllib.loads(text)
|
|
49
|
+
except tomllib.TOMLDecodeError as error:
|
|
50
|
+
raise ConfigError([f"{name} is not valid TOML: {error}"]) from error
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse(table: dict[str, Any], source: str) -> Config:
|
|
54
|
+
try:
|
|
55
|
+
return parse_config(table)
|
|
56
|
+
except ConfigError as error:
|
|
57
|
+
raise ConfigError(
|
|
58
|
+
[f"{source}: {problem}" for problem in error.problems]
|
|
59
|
+
) from error
|