pubgate 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.
pubgate/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .core import PubGate
2
+ from .errors import PubGateError
3
+
4
+ __all__ = ["PubGate", "PubGateError"]
pubgate/__main__.py ADDED
@@ -0,0 +1,97 @@
1
+ import argparse
2
+ import enum
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from ._log import setup_logging
8
+ from .config import load_config
9
+ from .errors import PubGateError
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Command(enum.Enum):
15
+ ABSORB = "absorb"
16
+ STAGE = "stage"
17
+ PUBLISH = "publish"
18
+ STATUS = "status"
19
+
20
+
21
+ def _add_common_flags(subparser: argparse.ArgumentParser) -> None:
22
+ subparser.add_argument(
23
+ "--dry-run",
24
+ action="store_true",
25
+ help="Preview planned actions (fetches remotes but skips branch creation, commits, and pushes)",
26
+ )
27
+ subparser.add_argument(
28
+ "--force", action="store_true", help="Overwrite existing PR branch (use when previous PR was not merged)"
29
+ )
30
+ subparser.add_argument(
31
+ "--no-pr", action="store_true", help="Skip automatic PR creation (show manual steps instead)"
32
+ )
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(prog="pubgate", description="Sync internal repo <-> public repo")
37
+ parser.add_argument("--repo-dir", default=".", help="Path to the internal repo (default: .)")
38
+
39
+ sub = parser.add_subparsers(dest="command")
40
+ for cmd, help_text in (
41
+ (Command.ABSORB.value, "Bring public repo changes into internal main via PR"),
42
+ (Command.STAGE.value, "Generate stage candidate and open internal PR into pubgate/public-approved"),
43
+ (Command.PUBLISH.value, "Push reviewed pubgate/public-approved content to the public repo and open PR"),
44
+ ):
45
+ sp = sub.add_parser(cmd, help=help_text)
46
+ _add_common_flags(sp)
47
+
48
+ sub.add_parser(Command.STATUS.value, help="Show sync status of absorb, stage, and publish")
49
+
50
+ return parser
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> None:
54
+ parser = build_parser()
55
+ args = parser.parse_args(argv)
56
+
57
+ setup_logging()
58
+
59
+ if not args.command:
60
+ parser.print_help()
61
+ sys.exit(1)
62
+
63
+ try:
64
+ cfg = load_config(args.repo_dir)
65
+ except PubGateError as exc:
66
+ logger.error("Configuration error: %s", exc)
67
+ sys.exit(1)
68
+
69
+ cmd = Command(args.command)
70
+
71
+ from .core import PubGate
72
+ from .git import GitRepo
73
+
74
+ try:
75
+ git = GitRepo(Path(args.repo_dir))
76
+ git.verify_repo()
77
+ git.ensure_remote(cfg.public_remote, cfg.public_url)
78
+ pg = PubGate(cfg, git)
79
+
80
+ if cmd == Command.STATUS:
81
+ pg.status()
82
+ else:
83
+ flags = dict(dry_run=args.dry_run, force=args.force, no_pr=args.no_pr)
84
+ match cmd:
85
+ case Command.ABSORB:
86
+ pg.absorb(**flags)
87
+ case Command.STAGE:
88
+ pg.stage(**flags)
89
+ case Command.PUBLISH:
90
+ pg.publish(**flags)
91
+ except PubGateError as exc:
92
+ logger.error("Command failed: %s", exc)
93
+ sys.exit(1)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
pubgate/_log.py ADDED
@@ -0,0 +1,42 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+
5
+ import colorlog
6
+
7
+ _ENV_VAR = "PUBGATE_LOG_LEVEL"
8
+
9
+
10
+ def setup_logging() -> None:
11
+ env_level = os.environ.get(_ENV_VAR, "INFO").upper()
12
+ level = getattr(logging, env_level, None)
13
+ if level is None:
14
+ level = logging.INFO
15
+
16
+ handler = logging.StreamHandler(sys.stderr)
17
+ if hasattr(sys.stderr, "isatty") and sys.stderr.isatty():
18
+ handler.setFormatter(
19
+ colorlog.ColoredFormatter(
20
+ "%(log_color)s[%(levelname)-7s]:%(reset)s %(message)s",
21
+ log_colors={
22
+ "DEBUG": "cyan",
23
+ "INFO": "green",
24
+ "WARNING": "yellow",
25
+ "ERROR": "red",
26
+ "CRITICAL": "bold_red",
27
+ },
28
+ )
29
+ )
30
+ else:
31
+ handler.setFormatter(logging.Formatter("[%(levelname)-7s]: %(message)s"))
32
+
33
+ root = logging.getLogger()
34
+ root.handlers.clear()
35
+ root.addHandler(handler)
36
+ root.setLevel(logging.WARNING)
37
+
38
+ # App logger gets the user-requested level.
39
+ logging.getLogger("pubgate").setLevel(level)
40
+
41
+ # Route warnings.warn() through logging.
42
+ logging.captureWarnings(True)
pubgate/absorb.py ADDED
@@ -0,0 +1,302 @@
1
+ import logging
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ from .config import Config
6
+ from .errors import GitError, PubGateError
7
+ from .filtering import scrub_internal_blocks
8
+ from .git import GitRepo, is_lfs_pointer
9
+ from .models import format_commit
10
+ from .state import AbsorbStatus, StateRef
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Public API
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ class AbsorbResult:
21
+ __slots__ = ("status", "public_head", "last_absorbed")
22
+
23
+ def __init__(self, status: AbsorbStatus, public_head: str, last_absorbed: str | None) -> None:
24
+ self.status = status
25
+ self.public_head = public_head
26
+ self.last_absorbed = last_absorbed
27
+
28
+
29
+ def check_absorb(cfg: Config, git: GitRepo) -> AbsorbResult:
30
+ if not git.remote_branch_exists(cfg.public_remote, cfg.public_main_branch):
31
+ raise PubGateError(
32
+ f"Error: public repo has no '{cfg.public_main_branch}' branch. "
33
+ f"The public repo must have at least one commit before running absorb."
34
+ )
35
+ public_head = git.rev_parse(cfg.public_main_ref)
36
+
37
+ absorb_ref = StateRef.read(git, cfg.internal_main_branch, cfg.absorb_state_file)
38
+ last_absorbed = absorb_ref.sha if absorb_ref else None
39
+
40
+ if last_absorbed is None:
41
+ logger.debug("Inbound status: NEEDS_BOOTSTRAP")
42
+ return AbsorbResult(AbsorbStatus.NEEDS_BOOTSTRAP, public_head, None)
43
+
44
+ if last_absorbed == public_head:
45
+ logger.debug("Inbound status: UP_TO_DATE")
46
+ return AbsorbResult(AbsorbStatus.UP_TO_DATE, public_head, last_absorbed)
47
+
48
+ logger.debug("Inbound status: NEEDS_ABSORB")
49
+ return AbsorbResult(AbsorbStatus.NEEDS_ABSORB, public_head, last_absorbed)
50
+
51
+
52
+ def resolve_and_apply(cfg: Config, git: GitRepo, base_sha: str, public_head: str) -> list[str]:
53
+ public_ref = f"{cfg.public_remote}/{cfg.public_main_branch}"
54
+ excluded = cfg.state_files
55
+ staged_sha: str | None = None
56
+ try:
57
+ stage_ref = StateRef.read(git, public_ref, cfg.stage_state_file)
58
+ if stage_ref is not None:
59
+ staged_sha = stage_ref.sha
60
+ except PubGateError as exc:
61
+ logger.warning("Could not read stage state from %s: %s", public_ref, exc)
62
+ return _apply_absorb_changes(git, base_sha, public_head, public_ref, excluded=excluded, staged_sha=staged_sha)
63
+
64
+
65
+ def absorb_commit_message(
66
+ git: GitRepo,
67
+ last_absorbed: str,
68
+ public_head: str,
69
+ conflicted: list[str] | None = None,
70
+ needs_review: list[str] | None = None,
71
+ ) -> str:
72
+ subject = f"pubgate: absorb public changes {last_absorbed[:7]}..{public_head[:7]}"
73
+ commits = git.log_oneline(last_absorbed, public_head)
74
+ lines = [subject]
75
+ if commits:
76
+ lines.append("")
77
+ lines.append(f"Included commits ({last_absorbed[:7]}..{public_head[:7]}):")
78
+ lines.extend(f" {i}. {format_commit(c)}" for i, c in enumerate(commits, 1))
79
+ if conflicted:
80
+ lines.append("")
81
+ lines.append("CONFLICTS (resolve before merging):")
82
+ for path in conflicted:
83
+ lines.append(f" {path}")
84
+ if needs_review:
85
+ lines.append("")
86
+ lines.append("Review manually:")
87
+ for item in needs_review:
88
+ lines.append(f" {item}")
89
+ return "\n".join(lines)
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Implementation details (private)
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _read_text_at_ref(git: GitRepo, ref: str, path: str) -> str | None:
98
+ data = git.read_file_at_ref_bytes(ref, path)
99
+ if data is None:
100
+ return None
101
+ return data.decode("utf-8")
102
+
103
+
104
+ def _log_review_diff(git: GitRepo, public_ref: str, path: str, local_path: Path) -> None:
105
+ """Log debug info comparing local vs public version of a file needing review."""
106
+ try:
107
+ public_bytes = git.read_file_at_ref_bytes(public_ref, path)
108
+ local_bytes = local_path.read_bytes() if local_path.exists() else None
109
+ pub_size = len(public_bytes) if public_bytes is not None else 0
110
+ loc_size = len(local_bytes) if local_bytes is not None else 0
111
+ same = public_bytes == local_bytes
112
+ logger.debug(" public: %d bytes, local: %d bytes, identical: %s", pub_size, loc_size, same)
113
+ if same:
114
+ return
115
+ # For text files, show a unified diff
116
+ if public_bytes is not None and local_bytes is not None:
117
+ try:
118
+ pub_text = public_bytes.decode("utf-8")
119
+ loc_text = local_bytes.decode("utf-8")
120
+ except UnicodeDecodeError:
121
+ return
122
+ import difflib
123
+
124
+ diff = difflib.unified_diff(
125
+ pub_text.splitlines(keepends=True),
126
+ loc_text.splitlines(keepends=True),
127
+ fromfile=f"public ({public_ref})",
128
+ tofile="local (working tree)",
129
+ )
130
+ diff_text = "".join(diff)
131
+ if diff_text:
132
+ for line in diff_text.splitlines():
133
+ logger.debug(" %s", line)
134
+ except Exception:
135
+ pass # best-effort debug logging
136
+
137
+
138
+ def _apply_absorb_changes(
139
+ git: GitRepo,
140
+ base_sha: str,
141
+ public_head: str,
142
+ public_ref: str,
143
+ *,
144
+ excluded: frozenset[str] = frozenset(),
145
+ staged_sha: str | None = None,
146
+ ) -> list[str]:
147
+ changes = git.diff_tree(base_sha, public_head)
148
+ changes = [c for c in changes if c.path not in excluded and (c.old_path is None or c.old_path not in excluded)]
149
+ actions: list[str] = []
150
+
151
+ for change in changes:
152
+ logger.debug("Processing change: %s %s", change.status, change.path)
153
+ if change.is_add:
154
+ local_path = git.repo_dir / change.path
155
+ if local_path.exists():
156
+ kind = git.classify_at_ref(public_ref, change.path)
157
+ if kind != "text":
158
+ label = "LFS file" if kind == "lfs" else "binary"
159
+ public_bytes = git.read_file_at_ref_bytes(public_ref, change.path)
160
+ local_bytes = local_path.read_bytes()
161
+ if public_bytes == local_bytes:
162
+ actions.append(f" {label} (identical): {change.path}")
163
+ else:
164
+ actions.append(
165
+ f" {label} added on public (kept local version, review manually): {change.path}"
166
+ )
167
+ _log_review_diff(git, public_ref, change.path, local_path)
168
+ else:
169
+ theirs_content = _read_text_at_ref(git, public_ref, change.path)
170
+ if theirs_content is None:
171
+ actions.append(f" added on public (kept local version, review manually): {change.path}")
172
+ _log_review_diff(git, public_ref, change.path, local_path)
173
+ continue
174
+ # Try to find the published base: the scrubbed version of
175
+ # the file at the internal commit that was staged.
176
+ published_base: str | None = None
177
+ if staged_sha is not None:
178
+ staged_content = _read_text_at_ref(git, staged_sha, change.path)
179
+ if staged_content is not None:
180
+ published_base = scrub_internal_blocks(staged_content, path=change.path)
181
+ if published_base is not None:
182
+ # Three-way merge using the published version as base
183
+ with tempfile.TemporaryDirectory() as tmpdir:
184
+ base_tmp = Path(tmpdir) / "base"
185
+ theirs_tmp = Path(tmpdir) / "theirs"
186
+ base_tmp.write_text(published_base, encoding="utf-8", newline="")
187
+ theirs_tmp.write_text(theirs_content, encoding="utf-8", newline="")
188
+ # Read ours from git objects instead of working copy
189
+ # to avoid CRLF mismatch on Windows (see _merge_file).
190
+ ours_content = _read_text_at_ref(git, "HEAD", change.path)
191
+ if ours_content is not None:
192
+ local_path.write_text(ours_content, encoding="utf-8", newline="")
193
+ clean = git.merge_file(local_path, base_tmp, theirs_tmp)
194
+ git.stage(change.path)
195
+ if clean:
196
+ actions.append(f" merge (clean): {change.path}")
197
+ else:
198
+ actions.append(f" merge (CONFLICTS - resolve manually): {change.path}")
199
+ else:
200
+ # No staged version; file wasn't published through pubgate
201
+ actions.append(f" added on public (kept local, review manually): {change.path}")
202
+ _log_review_diff(git, public_ref, change.path, local_path)
203
+ else:
204
+ is_binary = git.copy_file_from_ref(public_ref, change.path)
205
+ if is_binary:
206
+ with open(git.repo_dir / change.path, "rb") as f:
207
+ head = f.read(1024)
208
+ tag = " (LFS)" if is_lfs_pointer(head) else " (binary)"
209
+ else:
210
+ tag = ""
211
+ actions.append(f" add{tag}: {change.path}")
212
+
213
+ elif change.is_modify:
214
+ _merge_file(git, base_sha, public_ref, change.path, actions, staged_sha=staged_sha)
215
+
216
+ elif change.is_delete:
217
+ if (git.repo_dir / change.path).exists():
218
+ actions.append(f" deleted on public (kept locally, review manually): {change.path}")
219
+ logger.debug(" local file exists at %s", git.repo_dir / change.path)
220
+
221
+ elif change.is_rename:
222
+ old_path = change.old_path or ""
223
+ git.copy_file_from_ref(public_ref, change.path)
224
+ msg = f" rename on public: {old_path} → {change.path} (kept old, review manually)"
225
+ actions.append(msg)
226
+
227
+ return actions
228
+
229
+
230
+ def _merge_file(
231
+ git: GitRepo,
232
+ base_sha: str,
233
+ public_ref: str,
234
+ path: str,
235
+ actions: list[str],
236
+ *,
237
+ staged_sha: str | None = None,
238
+ ) -> None:
239
+ if git.is_binary_at_ref(public_ref, path) or git.is_binary_at_ref(base_sha, path):
240
+ theirs_bytes = git.read_file_at_ref_bytes(public_ref, path)
241
+ if theirs_bytes is None:
242
+ raise GitError(
243
+ ["show", f"{public_ref}:{path}"],
244
+ 1,
245
+ f"diff_tree reported M for {path} but binary content is unreadable "
246
+ f"at {public_ref}. Repository may have corrupt objects.",
247
+ )
248
+ git.write_file_and_stage_bytes(path, theirs_bytes)
249
+ label = "LFS file" if is_lfs_pointer(theirs_bytes) else "binary"
250
+ actions.append(f" {label} changed on public (replaced locally, review manually): {path}")
251
+ _log_review_diff(git, public_ref, path, git.repo_dir / path)
252
+ return
253
+
254
+ # Use the scrubbed staged content as merge base when available.
255
+ # This mirrors the is_add path: after a publish cycle, the correct base
256
+ # is the scrubbed version of the internal file that was staged, not the
257
+ # old public content (which would cause false conflicts on internal blocks).
258
+ base_content: str | None = None
259
+ if staged_sha is not None:
260
+ staged_content = _read_text_at_ref(git, staged_sha, path)
261
+ if staged_content is not None:
262
+ base_content = scrub_internal_blocks(staged_content, path=path)
263
+ if base_content is None:
264
+ base_content = _read_text_at_ref(git, base_sha, path)
265
+ theirs_content = _read_text_at_ref(git, public_ref, path)
266
+
267
+ if base_content is None or theirs_content is None:
268
+ missing_ref = base_sha if base_content is None else public_ref
269
+ raise GitError(
270
+ ["show", f"{missing_ref}:{path}"],
271
+ 1,
272
+ f"diff_tree reported M for {path} but content is unreadable "
273
+ f"at {missing_ref}. Repository may have corrupt objects.",
274
+ )
275
+
276
+ ours_path = git.repo_dir / path
277
+ if not ours_path.exists():
278
+ if theirs_content is not None:
279
+ git.write_file_and_stage(path, theirs_content)
280
+ actions.append(f" add (was modified on public, missing locally): {path}")
281
+ return
282
+
283
+ with tempfile.TemporaryDirectory() as tmpdir:
284
+ base_tmp = Path(tmpdir) / "base"
285
+ theirs_tmp = Path(tmpdir) / "theirs"
286
+ base_tmp.write_text(base_content, encoding="utf-8", newline="")
287
+ theirs_tmp.write_text(theirs_content, encoding="utf-8", newline="")
288
+
289
+ # Read ours from git objects instead of the working copy.
290
+ # On Windows, checkout may denormalize LF→CRLF, causing
291
+ # git merge-file to see spurious differences vs the LF
292
+ # base/theirs content read from git objects.
293
+ ours_content = _read_text_at_ref(git, "HEAD", path)
294
+ if ours_content is not None:
295
+ ours_path.write_text(ours_content, encoding="utf-8", newline="")
296
+
297
+ clean = git.merge_file(ours_path, base_tmp, theirs_tmp)
298
+ git.stage(path)
299
+ if clean:
300
+ actions.append(f" merge (clean): {path}")
301
+ else:
302
+ actions.append(f" merge (CONFLICTS - resolve manually): {path}")
pubgate/config.py ADDED
@@ -0,0 +1,157 @@
1
+ import logging
2
+ import re
3
+ import sys
4
+ from dataclasses import dataclass, field, fields
5
+ from pathlib import Path
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else:
10
+ import tomli as tomllib # type: ignore[import-untyped]
11
+
12
+ from .errors import PubGateError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Constants
18
+ # ---------------------------------------------------------------------------
19
+
20
+ CONFIG_FILE = "pubgate.toml"
21
+
22
+ DEFAULT_IGNORE_PATTERNS: list[str] = [
23
+ ".internal/*",
24
+ "internal/*",
25
+ "*-internal.*",
26
+ "*.internal.*",
27
+ "*_internal.*",
28
+ "*-private.*",
29
+ "*.private.*",
30
+ "*_private.*",
31
+ "*.secret",
32
+ "*.secrets",
33
+ ]
34
+
35
+ # Allowed: alphanumeric, underscore, forward slash, dot, colon, hyphen
36
+ _VALID_BRANCH_RE = re.compile(r"^[a-zA-Z0-9_/.:-]+$")
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Field helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _config_field(kind: str, *, scope: str = "", **kwargs):
44
+ metadata: dict[str, str] = {"kind": kind}
45
+ if scope:
46
+ metadata["scope"] = scope
47
+ return field(metadata=metadata, **kwargs)
48
+
49
+
50
+ def _validate_branch_name(name: str, field_name: str) -> None:
51
+ if not _VALID_BRANCH_RE.match(name):
52
+ raise PubGateError(f"'{field_name}' contains invalid characters: '{name}'")
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Field metadata utilities
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def _fields_by_kind(*kinds: str) -> frozenset[str]:
61
+ return frozenset(f.name for f in fields(Config) if f.metadata.get("kind") in kinds)
62
+
63
+
64
+ def _branch_scope_groups() -> dict[str, frozenset[str]]:
65
+ groups: dict[str, set[str]] = {}
66
+ for f in fields(Config):
67
+ if f.metadata.get("kind") == "branch":
68
+ groups.setdefault(f.metadata.get("scope", ""), set()).add(f.name)
69
+ return {k: frozenset(v) for k, v in groups.items()}
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Config dataclass
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ @dataclass
78
+ class Config:
79
+ # Internal repo
80
+ internal_main_branch: str = _config_field("branch", scope="internal", default="main")
81
+ internal_approved_branch: str = _config_field("branch", scope="internal", default="pubgate/public-approved")
82
+ internal_absorb_branch: str = _config_field("branch", scope="internal", default="pubgate/absorb")
83
+ internal_stage_branch: str = _config_field("branch", scope="internal", default="pubgate/stage")
84
+
85
+ # Public repo
86
+ public_url: str | None = _config_field("str", default=None)
87
+ public_remote: str = _config_field("str", default="public-remote")
88
+ public_main_branch: str = _config_field("branch", scope="public", default="main")
89
+ public_publish_branch: str = _config_field("branch", scope="public", default="pubgate/publish")
90
+
91
+ # State tracking
92
+ absorb_state_file: str = _config_field("state", default=".pubgate-absorbed")
93
+ stage_state_file: str = _config_field("state", default=".pubgate-staged")
94
+
95
+ # Filtering
96
+ ignore: list[str] = _config_field("list", default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS))
97
+
98
+ def __post_init__(self) -> None:
99
+ for key in _fields_by_kind("branch"):
100
+ _validate_branch_name(getattr(self, key), key)
101
+ for keys in _branch_scope_groups().values():
102
+ self._check_no_duplicates(keys, "branch name")
103
+ self._check_no_duplicates(_fields_by_kind("state"), "filename")
104
+
105
+ def _check_no_duplicates(self, keys: frozenset[str], label: str) -> None:
106
+ seen: dict[str, str] = {}
107
+ for key in sorted(keys):
108
+ val = getattr(self, key)
109
+ if val in seen:
110
+ raise PubGateError(f"'{key}' and '{seen[val]}' share the same {label} '{val}'")
111
+ seen[val] = key
112
+
113
+ @property
114
+ def public_main_ref(self) -> str:
115
+ return f"{self.public_remote}/{self.public_main_branch}"
116
+
117
+ @property
118
+ def state_files(self) -> frozenset[str]:
119
+ return frozenset({self.absorb_state_file, self.stage_state_file})
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # TOML loader
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def load_config(repo_dir: str | Path = ".") -> Config:
128
+ repo_path = Path(repo_dir)
129
+ config_path = repo_path / CONFIG_FILE
130
+
131
+ if not config_path.is_file():
132
+ raise PubGateError(
133
+ f'No {CONFIG_FILE} found at {config_path.resolve()}. Create one with at least: public_url = "..."'
134
+ )
135
+
136
+ with config_path.open("rb") as f:
137
+ data = tomllib.load(f)
138
+ logger.debug("Loaded config from %s", config_path)
139
+
140
+ str_keys = _fields_by_kind("str", "branch", "state")
141
+ list_keys = _fields_by_kind("list")
142
+
143
+ unknown = set(data) - (str_keys | list_keys)
144
+ if unknown:
145
+ raise PubGateError(f"Unknown keys in {CONFIG_FILE}: {', '.join(sorted(unknown))}")
146
+
147
+ kwargs: dict[str, object] = {}
148
+ for key, val in data.items():
149
+ if key in str_keys:
150
+ if not isinstance(val, str):
151
+ raise PubGateError(f"{CONFIG_FILE}: '{key}' must be a string")
152
+ elif key in list_keys:
153
+ if not isinstance(val, list) or not all(isinstance(v, str) for v in val):
154
+ raise PubGateError(f"{CONFIG_FILE}: '{key}' must be a list of strings")
155
+ kwargs[key] = val
156
+
157
+ return Config(**kwargs) # type: ignore[arg-type]