ferrygl 0.1.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.
- ferrygl/__init__.py +3 -0
- ferrygl/__main__.py +3 -0
- ferrygl/_http.py +81 -0
- ferrygl/cli.py +92 -0
- ferrygl/config.py +271 -0
- ferrygl/github.py +272 -0
- ferrygl/linear.py +272 -0
- ferrygl/model.py +123 -0
- ferrygl/plan.py +412 -0
- ferrygl/sync.py +306 -0
- ferrygl-0.1.0.dist-info/METADATA +318 -0
- ferrygl-0.1.0.dist-info/RECORD +15 -0
- ferrygl-0.1.0.dist-info/WHEEL +4 -0
- ferrygl-0.1.0.dist-info/entry_points.txt +2 -0
- ferrygl-0.1.0.dist-info/licenses/LICENSE +21 -0
ferrygl/__init__.py
ADDED
ferrygl/__main__.py
ADDED
ferrygl/_http.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""JSON over HTTP with retries, shared by the GitHub and Linear clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HttpError(Exception):
|
|
15
|
+
"""A request failed, or returned an error status without a GraphQL ``errors`` body."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def post_json(
|
|
23
|
+
client: httpx.Client,
|
|
24
|
+
url: str,
|
|
25
|
+
payload: dict[str, Any],
|
|
26
|
+
headers: dict[str, str],
|
|
27
|
+
*,
|
|
28
|
+
attempts: int = 3,
|
|
29
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
30
|
+
idempotent: bool = True,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""POST ``payload`` and return the decoded JSON object.
|
|
33
|
+
|
|
34
|
+
Rate-limited responses are retried, honoring ``Retry-After``. When ``idempotent``,
|
|
35
|
+
network errors and temporarily unavailable responses are retried too; otherwise they
|
|
36
|
+
are raised, because the server may have acted on the request already. An error status
|
|
37
|
+
whose body carries GraphQL ``errors`` is returned rather than raised, so the caller can
|
|
38
|
+
report the server's own message.
|
|
39
|
+
"""
|
|
40
|
+
for attempt in range(1, attempts + 1):
|
|
41
|
+
try:
|
|
42
|
+
response = client.post(url, json=payload, headers=headers)
|
|
43
|
+
except httpx.TransportError as exc:
|
|
44
|
+
if attempt == attempts or not idempotent:
|
|
45
|
+
raise HttpError(f"could not reach {url}: {exc}") from exc
|
|
46
|
+
sleep(float(2**attempt))
|
|
47
|
+
continue
|
|
48
|
+
if attempt < attempts and _should_retry(response, idempotent):
|
|
49
|
+
sleep(_retry_after(response, default=float(2**attempt)))
|
|
50
|
+
continue
|
|
51
|
+
return _decode(url, response)
|
|
52
|
+
raise ValueError("attempts must be at least 1")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _should_retry(response: httpx.Response, idempotent: bool) -> bool:
|
|
56
|
+
if response.status_code == 429:
|
|
57
|
+
return True
|
|
58
|
+
# GitHub reports secondary rate limits as 403 with Retry-After.
|
|
59
|
+
if response.status_code == 403 and "retry-after" in response.headers:
|
|
60
|
+
return True
|
|
61
|
+
return idempotent and response.status_code in RETRY_STATUSES
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _retry_after(response: httpx.Response, default: float) -> float:
|
|
65
|
+
try:
|
|
66
|
+
return min(float(response.headers["retry-after"]), 60.0)
|
|
67
|
+
except (KeyError, ValueError):
|
|
68
|
+
return default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _decode(url: str, response: httpx.Response) -> dict[str, Any]:
|
|
72
|
+
try:
|
|
73
|
+
body = response.json()
|
|
74
|
+
except ValueError:
|
|
75
|
+
body = None
|
|
76
|
+
if isinstance(body, dict) and (response.status_code < 400 or "errors" in body):
|
|
77
|
+
return body
|
|
78
|
+
snippet = response.text[:200].strip()
|
|
79
|
+
raise HttpError(
|
|
80
|
+
f"{url} returned HTTP {response.status_code}: {snippet}", status=response.status_code
|
|
81
|
+
)
|
ferrygl/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The ``ferrygl`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ferrygl import __version__
|
|
13
|
+
from ferrygl.config import Config, ConfigError, default_path, load
|
|
14
|
+
from ferrygl.github import GitHubClient, resolve_token
|
|
15
|
+
from ferrygl.linear import LinearClient
|
|
16
|
+
from ferrygl.sync import EXIT_SETUP, GitHubFactory, run_check, run_sync
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
20
|
+
args = _parser().parse_args(argv)
|
|
21
|
+
try:
|
|
22
|
+
config = load(args.config or default_path())
|
|
23
|
+
api_key = linear_api_key(config)
|
|
24
|
+
except ConfigError as exc:
|
|
25
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
26
|
+
return EXIT_SETUP
|
|
27
|
+
|
|
28
|
+
linear = LinearClient(api_key)
|
|
29
|
+
github_for = _github_factory()
|
|
30
|
+
try:
|
|
31
|
+
if args.command == "check":
|
|
32
|
+
return run_check(config, linear=linear, github_for=github_for, out=sys.stdout)
|
|
33
|
+
return run_sync(
|
|
34
|
+
config,
|
|
35
|
+
apply=args.apply,
|
|
36
|
+
linear=linear,
|
|
37
|
+
github_for=github_for,
|
|
38
|
+
out=sys.stdout,
|
|
39
|
+
err=sys.stderr,
|
|
40
|
+
now=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
41
|
+
)
|
|
42
|
+
except KeyboardInterrupt:
|
|
43
|
+
return 130
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def linear_api_key(config: Config, env: Mapping[str, str] = os.environ) -> str:
|
|
47
|
+
"""``LINEAR_API_KEY``, else the contents of ``[linear] api_key_file``."""
|
|
48
|
+
if key := env.get("LINEAR_API_KEY", "").strip():
|
|
49
|
+
return key
|
|
50
|
+
path = config.linear_api_key_file
|
|
51
|
+
if path is None:
|
|
52
|
+
raise ConfigError("no Linear API key: set LINEAR_API_KEY or [linear] api_key_file")
|
|
53
|
+
try:
|
|
54
|
+
key = path.read_text(encoding="utf-8").strip()
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
raise ConfigError(f"{path}: {exc.strerror}") from None
|
|
57
|
+
if not key:
|
|
58
|
+
raise ConfigError(f"{path}: the Linear API key file is empty")
|
|
59
|
+
return key
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _github_factory() -> GitHubFactory:
|
|
63
|
+
clients: dict[str | None, GitHubClient] = {}
|
|
64
|
+
|
|
65
|
+
def github_for(user: str | None) -> GitHubClient:
|
|
66
|
+
if user not in clients:
|
|
67
|
+
clients[user] = GitHubClient(resolve_token(user))
|
|
68
|
+
return clients[user]
|
|
69
|
+
|
|
70
|
+
return github_for
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parser() -> argparse.ArgumentParser:
|
|
74
|
+
parser = argparse.ArgumentParser(
|
|
75
|
+
prog="ferrygl",
|
|
76
|
+
description="Copy your GitHub issues and pull requests into your own Linear workspace.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument("--version", action="version", version=f"ferrygl {__version__}")
|
|
79
|
+
commands = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
80
|
+
config_help = f"configuration file (default: {default_path()})"
|
|
81
|
+
|
|
82
|
+
check = commands.add_parser(
|
|
83
|
+
"check", help="check credentials, repositories and Linear settings; writes nothing"
|
|
84
|
+
)
|
|
85
|
+
check.add_argument("--config", type=Path, help=config_help)
|
|
86
|
+
|
|
87
|
+
sync = commands.add_parser(
|
|
88
|
+
"sync", help="show the changes a sync would make; --apply makes them in Linear"
|
|
89
|
+
)
|
|
90
|
+
sync.add_argument("--config", type=Path, help=config_help)
|
|
91
|
+
sync.add_argument("--apply", action="store_true", help="make the changes in Linear")
|
|
92
|
+
return parser
|
ferrygl/config.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Read and validate ``ferrygl.toml``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import string
|
|
8
|
+
import tomllib
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
CATEGORIES = ("todo", "in_progress", "in_review", "done", "canceled")
|
|
15
|
+
TITLE_FIELDS = frozenset({"title", "number", "repo"})
|
|
16
|
+
_REPO = re.compile(r"^[A-Za-z0-9_.-]+/([A-Za-z0-9_.-]+|\*)$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigError(Exception):
|
|
20
|
+
"""The configuration file is missing or invalid."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Route:
|
|
25
|
+
"""Repositories whose items go to one Linear team and, optionally, one project."""
|
|
26
|
+
|
|
27
|
+
repos: tuple[str, ...] # "owner/name", or "owner/*" for every repository of an owner
|
|
28
|
+
team: str # Linear team key or name
|
|
29
|
+
project: str | None = None
|
|
30
|
+
github_user: str | None = None # read GitHub as this GitHub CLI account
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def name(self) -> str:
|
|
34
|
+
target = self.team if self.project is None else f"{self.team} / {self.project}"
|
|
35
|
+
return f"{', '.join(self.repos)} -> {target}"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def single_repo(self) -> str | None:
|
|
39
|
+
"""The repository, when the route names exactly one and no wildcard."""
|
|
40
|
+
if len(self.repos) == 1 and not self.repos[0].endswith("/*"):
|
|
41
|
+
return self.repos[0]
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
SYNC_FLAGS = (
|
|
46
|
+
"issues_assigned",
|
|
47
|
+
"issues_created",
|
|
48
|
+
"prs_created",
|
|
49
|
+
"prs_assigned",
|
|
50
|
+
"prs_reviewed",
|
|
51
|
+
"review_requests",
|
|
52
|
+
"prs_with_commits",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class SyncOptions:
|
|
58
|
+
"""Which of your roles on GitHub bring an item into Linear. All are on by default."""
|
|
59
|
+
|
|
60
|
+
issues_assigned: bool = True
|
|
61
|
+
issues_created: bool = True
|
|
62
|
+
prs_created: bool = True
|
|
63
|
+
prs_assigned: bool = True # someone else's pull request assigned to you
|
|
64
|
+
prs_reviewed: bool = True
|
|
65
|
+
review_requests: bool = True
|
|
66
|
+
prs_with_commits: bool = True # someone else's pull request with a commit you wrote
|
|
67
|
+
since: date | None = None
|
|
68
|
+
label: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Titles:
|
|
73
|
+
issue: str = "{title} #{number}"
|
|
74
|
+
pr: str = "{title} - #{number}"
|
|
75
|
+
involved: str = "[PR] {title} - #{number}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class StatusNames:
|
|
80
|
+
"""Your Linear team's workflow state names for each status ferryGL sets."""
|
|
81
|
+
|
|
82
|
+
todo: str = "Todo"
|
|
83
|
+
in_progress: str = "In Progress"
|
|
84
|
+
in_review: str = "In Review"
|
|
85
|
+
done: str = "Done"
|
|
86
|
+
canceled: str = "Canceled"
|
|
87
|
+
|
|
88
|
+
def name(self, category: str) -> str:
|
|
89
|
+
return getattr(self, category)
|
|
90
|
+
|
|
91
|
+
def all(self) -> tuple[str, ...]:
|
|
92
|
+
return tuple(self.name(category) for category in CATEGORIES)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Config:
|
|
97
|
+
routes: tuple[Route, ...]
|
|
98
|
+
sync: SyncOptions = field(default_factory=SyncOptions)
|
|
99
|
+
titles: Titles = field(default_factory=Titles)
|
|
100
|
+
status: StatusNames = field(default_factory=StatusNames)
|
|
101
|
+
copy_description: bool = False
|
|
102
|
+
linear_api_key_file: Path | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def default_path() -> Path:
|
|
106
|
+
"""``$FERRYGL_CONFIG``, else ``ferrygl/ferrygl.toml`` in the user's config directory."""
|
|
107
|
+
if explicit := os.environ.get("FERRYGL_CONFIG"):
|
|
108
|
+
return Path(explicit).expanduser()
|
|
109
|
+
base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
|
|
110
|
+
return Path(base).expanduser() / "ferrygl" / "ferrygl.toml"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load(path: Path) -> Config:
|
|
114
|
+
try:
|
|
115
|
+
text = path.read_text(encoding="utf-8")
|
|
116
|
+
except FileNotFoundError:
|
|
117
|
+
raise ConfigError(f"{path}: file not found") from None
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
raise ConfigError(f"{path}: {exc.strerror}") from None
|
|
120
|
+
try:
|
|
121
|
+
data = tomllib.loads(text)
|
|
122
|
+
except tomllib.TOMLDecodeError as exc:
|
|
123
|
+
raise ConfigError(f"{path}: {exc}") from None
|
|
124
|
+
return parse(data, source=str(path))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse(data: dict[str, Any], source: str = "config") -> Config:
|
|
128
|
+
_check_keys(data, {"route", "sync", "titles", "copy", "status", "linear"}, source)
|
|
129
|
+
raw_routes = data.get("route")
|
|
130
|
+
if not raw_routes:
|
|
131
|
+
raise ConfigError(f"{source}: add at least one [[route]] table")
|
|
132
|
+
if not isinstance(raw_routes, list):
|
|
133
|
+
raise ConfigError(f"{source}: write routes as [[route]] tables")
|
|
134
|
+
routes = tuple(_route(raw, f"{source}: route {n}") for n, raw in enumerate(raw_routes, 1))
|
|
135
|
+
_check_overlap(routes, source)
|
|
136
|
+
|
|
137
|
+
copy = _table(data, "copy", source)
|
|
138
|
+
_check_keys(copy, {"description"}, f"{source}: [copy]")
|
|
139
|
+
linear = _table(data, "linear", source)
|
|
140
|
+
_check_keys(linear, {"api_key_file"}, f"{source}: [linear]")
|
|
141
|
+
key_file = _optional_str(linear, "api_key_file", f"{source}: [linear]")
|
|
142
|
+
return Config(
|
|
143
|
+
routes=routes,
|
|
144
|
+
sync=_sync(_table(data, "sync", source), f"{source}: [sync]"),
|
|
145
|
+
titles=_titles(_table(data, "titles", source), f"{source}: [titles]"),
|
|
146
|
+
status=_status(_table(data, "status", source), f"{source}: [status]"),
|
|
147
|
+
copy_description=_bool(copy, "description", False, f"{source}: [copy]"),
|
|
148
|
+
linear_api_key_file=Path(key_file).expanduser() if key_file else None,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _route(raw: Any, where: str) -> Route:
|
|
153
|
+
if not isinstance(raw, dict):
|
|
154
|
+
raise ConfigError(f"{where}: must be a table")
|
|
155
|
+
_check_keys(raw, {"repos", "team", "project", "github_user"}, where)
|
|
156
|
+
repos = raw.get("repos")
|
|
157
|
+
if not isinstance(repos, list) or not repos or not all(isinstance(r, str) for r in repos):
|
|
158
|
+
raise ConfigError(f'{where}: `repos` must be a non-empty list such as ["owner/name"]')
|
|
159
|
+
for repo in repos:
|
|
160
|
+
if not _REPO.match(repo):
|
|
161
|
+
raise ConfigError(f'{where}: "{repo}" is not "owner/name" or "owner/*"')
|
|
162
|
+
team = raw.get("team")
|
|
163
|
+
if not isinstance(team, str) or not team.strip():
|
|
164
|
+
raise ConfigError(f"{where}: `team` must name a Linear team, by key or name")
|
|
165
|
+
return Route(
|
|
166
|
+
repos=tuple(repos),
|
|
167
|
+
team=team.strip(),
|
|
168
|
+
project=_optional_str(raw, "project", where),
|
|
169
|
+
github_user=_optional_str(raw, "github_user", where),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_overlap(routes: tuple[Route, ...], source: str) -> None:
|
|
174
|
+
"""Each repository must belong to exactly one route, or its items would be planned twice."""
|
|
175
|
+
entries = [(n, repo) for n, route in enumerate(routes, 1) for repo in route.repos]
|
|
176
|
+
for i, (n, a) in enumerate(entries):
|
|
177
|
+
for m, b in entries[i + 1 :]:
|
|
178
|
+
if _covers(a, b) or _covers(b, a):
|
|
179
|
+
raise ConfigError(
|
|
180
|
+
f'{source}: "{a}" (route {n}) and "{b}" (route {m}) overlap; '
|
|
181
|
+
"send each repository to exactly one route"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _covers(pattern: str, repo: str) -> bool:
|
|
186
|
+
owner, name = pattern.lower().split("/", 1)
|
|
187
|
+
return pattern.lower() == repo.lower() or (name == "*" and repo.lower().startswith(f"{owner}/"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _sync(raw: dict[str, Any], where: str) -> SyncOptions:
|
|
191
|
+
flags = SYNC_FLAGS
|
|
192
|
+
_check_keys(raw, {*flags, "since", "label"}, where)
|
|
193
|
+
since = raw.get("since")
|
|
194
|
+
if isinstance(since, datetime):
|
|
195
|
+
since = since.date()
|
|
196
|
+
elif isinstance(since, str):
|
|
197
|
+
try:
|
|
198
|
+
since = date.fromisoformat(since)
|
|
199
|
+
except ValueError:
|
|
200
|
+
raise ConfigError(f"{where}: `since` must be a date such as 2026-09-01") from None
|
|
201
|
+
elif since is not None and not isinstance(since, date):
|
|
202
|
+
raise ConfigError(f"{where}: `since` must be a date such as 2026-09-01")
|
|
203
|
+
return SyncOptions(
|
|
204
|
+
**{flag: _bool(raw, flag, True, where) for flag in flags},
|
|
205
|
+
since=since,
|
|
206
|
+
label=_optional_str(raw, "label", where),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _titles(raw: dict[str, Any], where: str) -> Titles:
|
|
211
|
+
_check_keys(raw, {"issue", "pr", "involved"}, where)
|
|
212
|
+
defaults = Titles()
|
|
213
|
+
values = {}
|
|
214
|
+
for key in ("issue", "pr", "involved"):
|
|
215
|
+
value = raw.get(key, getattr(defaults, key))
|
|
216
|
+
if not isinstance(value, str) or not value.strip():
|
|
217
|
+
raise ConfigError(f"{where}: `{key}` must be a non-empty string")
|
|
218
|
+
try:
|
|
219
|
+
fields = {f for _, f, _, _ in string.Formatter().parse(value) if f is not None}
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
raise ConfigError(f"{where}: `{key}`: {exc}") from None
|
|
222
|
+
unknown = sorted(fields - TITLE_FIELDS)
|
|
223
|
+
if unknown:
|
|
224
|
+
shown = ", ".join("{" + f + "}" for f in unknown)
|
|
225
|
+
raise ConfigError(
|
|
226
|
+
f"{where}: `{key}` uses {shown}; use {{title}}, {{number}} or {{repo}}"
|
|
227
|
+
)
|
|
228
|
+
values[key] = value
|
|
229
|
+
return Titles(**values)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _status(raw: dict[str, Any], where: str) -> StatusNames:
|
|
233
|
+
_check_keys(raw, set(CATEGORIES), where)
|
|
234
|
+
defaults = StatusNames()
|
|
235
|
+
values = {}
|
|
236
|
+
for category in CATEGORIES:
|
|
237
|
+
value = raw.get(category, defaults.name(category))
|
|
238
|
+
if not isinstance(value, str) or not value.strip():
|
|
239
|
+
raise ConfigError(f"{where}: `{category}` must name a Linear workflow state")
|
|
240
|
+
values[category] = value.strip()
|
|
241
|
+
return StatusNames(**values)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _table(data: dict[str, Any], key: str, source: str) -> dict[str, Any]:
|
|
245
|
+
value = data.get(key, {})
|
|
246
|
+
if not isinstance(value, dict):
|
|
247
|
+
raise ConfigError(f"{source}: `{key}` must be a [{key}] table")
|
|
248
|
+
return value
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _check_keys(table: dict[str, Any], allowed: set[str], where: str) -> None:
|
|
252
|
+
unknown = sorted(set(table) - allowed)
|
|
253
|
+
if unknown:
|
|
254
|
+
names = ", ".join(f"`{k}`" for k in unknown)
|
|
255
|
+
raise ConfigError(f"{where}: unknown setting {names}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _optional_str(table: dict[str, Any], key: str, where: str) -> str | None:
|
|
259
|
+
value = table.get(key)
|
|
260
|
+
if value is None:
|
|
261
|
+
return None
|
|
262
|
+
if not isinstance(value, str) or not value.strip():
|
|
263
|
+
raise ConfigError(f"{where}: `{key}` must be a non-empty string")
|
|
264
|
+
return value.strip()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _bool(table: dict[str, Any], key: str, default: bool, where: str) -> bool:
|
|
268
|
+
value = table.get(key, default)
|
|
269
|
+
if not isinstance(value, bool):
|
|
270
|
+
raise ConfigError(f"{where}: `{key}` must be true or false")
|
|
271
|
+
return value
|