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 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
@@ -0,0 +1,5 @@
1
+ """`python -m prepush_ci`: how hooks and integration tests start the CLI (coverage follows it)."""
2
+
3
+ from prepush_ci.cli import main
4
+
5
+ raise SystemExit(main())
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