peachycheck 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.
@@ -0,0 +1 @@
1
+ """peachycheck: native linters and agent hooks from one peachycheck.yml."""
@@ -0,0 +1,7 @@
1
+ """``python -m peachycheck``."""
2
+
3
+ import sys
4
+
5
+ from peachycheck.cli import main
6
+
7
+ sys.exit(main())
peachycheck/cli.py ADDED
@@ -0,0 +1,447 @@
1
+ """The command line ``peachycheck``: lint, sync and the agents' hooks.
2
+
3
+ ``FILES`` of a repository's ``peachycheck.yml`` names the tool configurations
4
+ it shares: those of ``general`` with every repository, the others with each
5
+ repository that has their marker file. ``sync`` writes them, ``sync --check``
6
+ fails on any difference; ``lint`` runs the linters and ``hook`` answers the
7
+ agents' hooks. ``init`` sets a repository up, ``upgrade`` moves it to
8
+ another version. Builds and tests are the repository's own CI's.
9
+ """
10
+
11
+ import argparse
12
+ import filecmp
13
+ import importlib.metadata
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tomllib
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import replace
23
+ from pathlib import Path
24
+ from typing import Final, cast
25
+
26
+ from peachycheck import config, hooks, linting
27
+
28
+ PACKAGE = "peachycheck"
29
+ WORKFLOW = """name: peachycheck
30
+
31
+ on:
32
+ pull_request:
33
+ push:
34
+ branches: [main]
35
+
36
+ jobs:
37
+ peachycheck:
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: drexlerd/peachycheck@{ref}
41
+ """
42
+ # The action or the reusable workflow at any ref, and the pinned version.
43
+ USES = re.compile(
44
+ r"(drexlerd/peachycheck(?:/\.github/workflows/check\.yml)?@)[^\s#]+"
45
+ )
46
+ PIN = re.compile(r"^PEACHYCHECK_VERSION:.*$", re.M)
47
+ GENERAL: Final = "general" # the FILES of every repository
48
+ MAIN_BRANCH: str = "" # empty: the remote's default branch (origin/HEAD)
49
+
50
+
51
+ def version() -> str:
52
+ """The installed version of peachycheck."""
53
+ return importlib.metadata.version(PACKAGE)
54
+
55
+
56
+ def released() -> bool:
57
+ """Installed from a built package, not from a git or folder source."""
58
+ try:
59
+ found = importlib.metadata.distribution(PACKAGE)
60
+ except importlib.metadata.PackageNotFoundError:
61
+ return False
62
+ direct = json.loads(found.read_text("direct_url.json") or "{}")
63
+ return not {"vcs_info", "dir_info"} & set(direct)
64
+
65
+
66
+ def _commit() -> str:
67
+ """The commit of peachycheck this was installed from."""
68
+ try:
69
+ found = importlib.metadata.distribution(PACKAGE)
70
+ direct = json.loads(found.read_text("direct_url.json") or "{}")
71
+ commit = str(direct.get("vcs_info", {}).get("commit_id", ""))
72
+ except importlib.metadata.PackageNotFoundError:
73
+ commit = ""
74
+ if commit:
75
+ return commit
76
+ head = subprocess.run(
77
+ ["git", "rev-parse", "HEAD"],
78
+ cwd=Path(__file__).parent,
79
+ capture_output=True,
80
+ text=True,
81
+ )
82
+ return head.stdout.strip() if head.returncode == 0 else "main"
83
+
84
+
85
+ def base_files(root: Path) -> dict[str, Path]:
86
+ """What ``.peachycheck/`` of ``root`` holds: each file, by its path there.
87
+
88
+ ``FILES`` names them under ``general`` for every repository, and under a
89
+ marker file, such as ``pyproject.toml``, for each repository that has
90
+ it; a later entry wins, and a folder brings every file below it.
91
+ """
92
+ files = cast(dict[str, dict[str, str]], config.load(root).get("FILES", {}))
93
+ wanted: dict[str, Path] = {}
94
+ for name, entries in files.items():
95
+ if name != GENERAL and not (root / name).exists():
96
+ continue
97
+ for target, source_name in entries.items():
98
+ source = Path(source_name)
99
+ below = sorted(p for p in source.rglob("*") if p.is_file())
100
+ for path in below if source.is_dir() else [source]:
101
+ inner = path.relative_to(source) if source.is_dir() else ""
102
+ wanted[(Path(target) / inner).as_posix()] = path
103
+ return wanted
104
+
105
+
106
+ def sync(root: Path, *, check: bool) -> list[str]:
107
+ """Write ``.peachycheck/`` of ``root``; with ``check``, only compare.
108
+
109
+ The folder holds the base configurations of the repository, as
110
+ ``base_files`` lists them, and nothing else.
111
+
112
+ Returns:
113
+ The files of ``.peachycheck/`` that differ (``check``) or were
114
+ written or removed.
115
+ """
116
+ folder = root / config.BASE
117
+ wanted = base_files(root)
118
+ found = folder.rglob("*") if folder.is_dir() else iter(list[Path]())
119
+ present = {p.relative_to(folder).as_posix() for p in found if p.is_file()}
120
+ present -= _ignored(folder, present) if present else set()
121
+ differing = sorted(
122
+ {n for n in present if n not in wanted}
123
+ | {
124
+ n
125
+ for n, source in wanted.items()
126
+ if n not in present
127
+ or not filecmp.cmp(source, folder / n, shallow=False)
128
+ }
129
+ )
130
+ if not check:
131
+ for name in differing:
132
+ target = folder / name
133
+ target.unlink(missing_ok=True)
134
+ if name in wanted:
135
+ target.parent.mkdir(parents=True, exist_ok=True)
136
+ shutil.copyfile(wanted[name], target)
137
+ return [f"{config.BASE}/{n}" for n in differing]
138
+
139
+
140
+ def _ignored(folder: Path, names: set[str]) -> set[str]:
141
+ """The ``names`` in ``folder`` that git ignores, such as a tool's cache."""
142
+ checked = subprocess.run(
143
+ ["git", "check-ignore", "--stdin"],
144
+ cwd=folder,
145
+ input="\n".join(sorted(names)),
146
+ capture_output=True,
147
+ text=True,
148
+ )
149
+ return set(checked.stdout.splitlines()) & names
150
+
151
+
152
+ # What a repository's own configuration needs to inherit the base; ``init``
153
+ # writes it when the repository has none.
154
+ INHERIT: Final = {
155
+ "ruff.toml": f'extend = "{config.BASE}/ruff.toml"\n',
156
+ "pyrightconfig.json": (
157
+ f'{{\n "extends": "{config.BASE}/pyrightconfig.json"\n}}\n'
158
+ ),
159
+ "eslint.config.mjs": (
160
+ f'import base from "./{config.BASE}/eslint.config.mjs";\n\n'
161
+ "export default [...base];\n"
162
+ ),
163
+ }
164
+
165
+
166
+ def inherit(root: Path) -> list[str]:
167
+ """Write the repository's own configurations that inherit the base.
168
+
169
+ Only for a base file of ``.peachycheck/`` whose tool can extend another
170
+ file, and only where the repository has no configuration yet.
171
+ """
172
+ pyproject = root / "pyproject.toml"
173
+ ruff_there = pyproject.is_file() and "[tool.ruff" in pyproject.read_text()
174
+ written: list[str] = []
175
+ for name, text in INHERIT.items():
176
+ own = root / name
177
+ if not (root / config.BASE / name).is_file() or own.is_file():
178
+ continue
179
+ if name == "ruff.toml" and ruff_there:
180
+ continue
181
+ own.write_text(text)
182
+ written.append(name)
183
+ return written
184
+
185
+
186
+ def _uv_add(root: Path, requirement: str) -> None:
187
+ subprocess.run(["uv", "add", "--dev", requirement], cwd=root, check=True)
188
+
189
+
190
+ def _dev_dependency(root: Path, wanted: str | None, *, add: bool) -> str:
191
+ """Pin ``peachycheck==wanted`` in the dev group of ``root``.
192
+
193
+ Without ``add``, only an existing pin moves. A generated pyproject.toml
194
+ (see ``GENERATED``) gets none.
195
+
196
+ Returns:
197
+ What was done, or "" when ``root`` is no Python project.
198
+ """
199
+ pyproject = root / "pyproject.toml"
200
+ if not pyproject.is_file() or "pyproject.toml" in linting.generated(root):
201
+ return ""
202
+ if wanted is None:
203
+ return "pyproject.toml: no dev pin, not installed from PyPI" * add
204
+ requirement = f"{PACKAGE}=={wanted}"
205
+ groups = tomllib.loads(pyproject.read_text()).get("dependency-groups", {})
206
+ dev = [d for d in groups.get("dev", []) if isinstance(d, str)]
207
+ if requirement in dev:
208
+ return f"pyproject.toml: {requirement} already"
209
+ if not add and not any(re.match(r"peachycheck\b", d) for d in dev):
210
+ return ""
211
+ _uv_add(root, requirement)
212
+ return f"pyproject.toml: {requirement} in the dev dependencies"
213
+
214
+
215
+ def _pin() -> str:
216
+ """The version peachycheck.yml pins: the release, else the commit."""
217
+ return version() if released() else _commit()
218
+
219
+
220
+ def _ref(pin: str) -> str:
221
+ """The ref of the action for ``pin``: a release's tag, else ``pin``."""
222
+ return f"v{pin}" if re.match(r"\d+\.\d+", pin) else pin
223
+
224
+
225
+ def init(root: Path, extends: str | None = None) -> int:
226
+ """Set ``root`` up: peachycheck.yml, the CI workflow, configs, tools, pin."""
227
+ own = root / config.NAME
228
+ if own.is_file():
229
+ print(f"{config.NAME}: exists")
230
+ else:
231
+ own.write_text(
232
+ "# peachycheck's configuration: "
233
+ "https://github.com/drexlerd/peachycheck#configuration\n"
234
+ + (f"EXTENDS: {extends}\n" if extends else "")
235
+ + f"PEACHYCHECK_VERSION: {_pin()}\n"
236
+ )
237
+ print(f"{config.NAME}: written")
238
+ workflow = root / ".github" / "workflows" / "peachycheck.yml"
239
+ if workflow.is_file():
240
+ print(".github/workflows/peachycheck.yml: exists")
241
+ else:
242
+ private = (root / ".gitmodules").is_file()
243
+ workflow.parent.mkdir(parents=True, exist_ok=True)
244
+ token = (
245
+ " with:\n token: ${{ secrets.SUBMODULES_TOKEN }}\n"
246
+ )
247
+ workflow.write_text(WORKFLOW.format(ref=_ref(_pin())) + token * private)
248
+ print(".github/workflows/peachycheck.yml: written")
249
+ _sync(root, check_only=False)
250
+ for name in inherit(root):
251
+ print(f"{name}: written, extending {config.BASE}/{name}")
252
+ install(root)
253
+ pinned = _dev_dependency(root, version() if released() else None, add=True)
254
+ if pinned:
255
+ print(pinned)
256
+ return 0
257
+
258
+
259
+ def _moved(root: Path, path: Path, pattern: re.Pattern[str], line: str) -> None:
260
+ """Replace ``pattern``'s matches in ``path`` by ``line``, a replacement."""
261
+ name = path.relative_to(root).as_posix()
262
+ text = path.read_text()
263
+ moved = pattern.sub(line, text)
264
+ if moved != text:
265
+ path.write_text(moved)
266
+ print(f"{name}: moved")
267
+ elif pattern.search(text):
268
+ print(f"{name}: already")
269
+
270
+
271
+ def upgrade(root: Path, to: str | None) -> int:
272
+ """Move the pins of peachycheck to ``to`` (or the installed version).
273
+
274
+ The pins are ``PEACHYCHECK_VERSION`` of peachycheck.yml, the refs of the
275
+ action and the reusable workflow in ``.github/workflows`` and the dev
276
+ dependency.
277
+ """
278
+ own = root / config.NAME
279
+ if not own.is_file():
280
+ print(f"no {config.NAME}; run `peachycheck init` first")
281
+ return 1
282
+ wanted = to.removeprefix("v") if to else None
283
+ if wanted is None and released():
284
+ wanted = version()
285
+ pin = wanted or _commit()
286
+ if not PIN.search(own.read_text()):
287
+ own.write_text(own.read_text() + f"PEACHYCHECK_VERSION: {pin}\n")
288
+ _moved(root, own, PIN, f"PEACHYCHECK_VERSION: {pin}")
289
+ for workflow in sorted((root / ".github" / "workflows").glob("*.y*ml")):
290
+ _moved(root, workflow, USES, f"\\g<1>{_ref(pin)}")
291
+ pinned = _dev_dependency(root, wanted, add=False)
292
+ if pinned:
293
+ print(pinned)
294
+ return _sync(root, check_only=False)
295
+
296
+
297
+ # The empty tree: diffing against it lists every tracked file.
298
+ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
299
+
300
+
301
+ def base_of(root: Path) -> str:
302
+ """The merge base of ``HEAD`` with the main branch, else ``HEAD~1``.
303
+
304
+ A repository with neither diffs against the empty tree.
305
+
306
+ The main branch is ``origin/MAIN_BRANCH``, else ``origin/HEAD``, which
307
+ many clones lack, else ``origin/main``.
308
+ """
309
+ branches = (
310
+ [f"origin/{MAIN_BRANCH}"]
311
+ if MAIN_BRANCH
312
+ else ["origin/HEAD", "origin/main"]
313
+ )
314
+ for branch in branches:
315
+ found = subprocess.run(
316
+ ["git", "merge-base", "HEAD", branch],
317
+ cwd=root,
318
+ capture_output=True,
319
+ text=True,
320
+ check=False,
321
+ )
322
+ if found.returncode == 0:
323
+ return found.stdout.strip()
324
+ previous = subprocess.run(
325
+ ["git", "rev-parse", "--verify", "--quiet", "HEAD~1"],
326
+ cwd=root,
327
+ capture_output=True,
328
+ text=True,
329
+ check=False,
330
+ )
331
+ # Without a main branch or a parent commit every file counts as changed.
332
+ return "HEAD~1" if previous.returncode == 0 else EMPTY_TREE
333
+
334
+
335
+ def lint(root: Path, options: argparse.Namespace) -> int:
336
+ """Lint the given files, those changed since ``--base``, or ``--all``.
337
+
338
+ Each file is linted with the repository it belongs to (see
339
+ ``config.owner``); a file of another repository shows its path from
340
+ ``root``.
341
+ """
342
+ if options.all:
343
+ paths = [root / p for p in linting.all_files(root)]
344
+ elif options.files:
345
+ paths = [Path(f).resolve() for f in options.files]
346
+ else:
347
+ base = options.base or base_of(root)
348
+ paths = [root / p for p in linting.changed_files(root, base)]
349
+ messages: list[str] = []
350
+ for repo, result in linting.lint_files(paths, fix=options.fix):
351
+ prefix = os.path.relpath(repo, root)
352
+ if prefix != ".":
353
+ result.findings = [
354
+ replace(f, path=f"{prefix}/{f.path}") for f in result.findings
355
+ ]
356
+ messages += linting.report(result, limit=10**9)
357
+ print("\n".join(messages) or f"{len(paths)} files, no findings")
358
+ return 1 if messages else 0
359
+
360
+
361
+ def install(root: Path | None = None) -> int:
362
+ """Download the pinned tools of ``VERSIONS``."""
363
+ versions = config.load(root).get("VERSIONS", {})
364
+ print("\n".join(linting.install(versions)))
365
+ return 0
366
+
367
+
368
+ def install_hooks(options: argparse.Namespace) -> int:
369
+ """Write or remove the user's Claude Code and Codex hooks; print the diff."""
370
+ both = not options.claude and not options.codex
371
+ remove = options.action == "uninstall"
372
+ diff = hooks.install(
373
+ Path.home(),
374
+ claude=options.claude or both,
375
+ codex=options.codex or both,
376
+ remove=remove,
377
+ )
378
+ print(diff or f"the hooks are {'removed' if remove else 'installed'}")
379
+ if (options.codex or both) and not remove:
380
+ print("Trust the Codex hooks once with /hooks.")
381
+ return 0
382
+
383
+
384
+ def parser() -> argparse.ArgumentParser:
385
+ """The command line; a consumer may add arguments before parsing."""
386
+ result = argparse.ArgumentParser(prog="peachycheck")
387
+ result.add_argument("--root", type=Path, default=Path.cwd())
388
+ result.add_argument(
389
+ "--version", action="version", version=f"%(prog)s {version()}"
390
+ )
391
+ commands = result.add_subparsers(dest="command", required=True)
392
+ commands.add_parser("install", help="download the pinned linters")
393
+ linted = commands.add_parser("lint", help="run the linters")
394
+ linted.add_argument("files", nargs="*")
395
+ linted.add_argument("--all", action="store_true", help="every file")
396
+ linted.add_argument(
397
+ "--base", help="files changed since; default: the merge base"
398
+ )
399
+ linted.add_argument("--fix", action="store_true", help="fix first")
400
+ hook = commands.add_parser("hook", help="answer a Claude or Codex hook")
401
+ hook.add_argument("event", choices=["post-edit", "stop"])
402
+ installing = commands.add_parser(
403
+ "hooks", help="install or uninstall the agents' hooks"
404
+ )
405
+ installing.add_argument("action", choices=["install", "uninstall"])
406
+ installing.add_argument("--claude", action="store_true")
407
+ installing.add_argument("--codex", action="store_true")
408
+ syncing = commands.add_parser("sync", help="write the shared tool configs")
409
+ syncing.add_argument("--check", action="store_true", help="only compare")
410
+ initializing = commands.add_parser(
411
+ "init", help="set the repository up: peachycheck.yml, CI, configs"
412
+ )
413
+ initializing.add_argument(
414
+ "--extends", help="the URL or path of the file peachycheck.yml extends"
415
+ )
416
+ upgrading = commands.add_parser(
417
+ "upgrade", help="move the pins of peachycheck to a version"
418
+ )
419
+ upgrading.add_argument("--to", help="default: the installed version")
420
+ return result
421
+
422
+
423
+ def _sync(root: Path, check_only: bool) -> int:
424
+ differing = sync(root, check=check_only)
425
+ verb = "differs; run `peachycheck sync`" if check_only else "written"
426
+ print("\n".join(f"{path}: {verb}" for path in differing) or "in sync")
427
+ return 1 if differing and check_only else 0
428
+
429
+
430
+ def main(arguments: Sequence[str] | None = None) -> int:
431
+ """Run one command of ``peachycheck``."""
432
+ options = parser().parse_args(arguments)
433
+ root: Path = options.root.resolve()
434
+ commands: dict[str, Callable[[], int]] = {
435
+ "install": lambda: install(root),
436
+ "lint": lambda: lint(root, options),
437
+ "hook": lambda: hooks.run(options.event),
438
+ "hooks": lambda: install_hooks(options),
439
+ "sync": lambda: _sync(root, options.check),
440
+ "init": lambda: init(root, options.extends),
441
+ "upgrade": lambda: upgrade(root, options.to),
442
+ }
443
+ return commands[options.command]()
444
+
445
+
446
+ if __name__ == "__main__":
447
+ sys.exit(main())
peachycheck/config.py ADDED
@@ -0,0 +1,235 @@
1
+ """``peachycheck.yml``: peachycheck's defaults and a repository's overrides.
2
+
3
+ Every configuration extends peachycheck's defaults first. A file may
4
+ ``EXTENDS`` others (URLs or paths, resolved first); later files win, mappings
5
+ merge key by key, and the keys ``CONFIG_PROPERTIES_TO_APPEND`` names append
6
+ their lists, as in MegaLinter's configuration. A repository without a
7
+ ``peachycheck.yml`` gets the defaults.
8
+
9
+ The paths in ``FILES`` resolve against the file that names them. A file
10
+ extended by its GitHub raw URL
11
+ (``https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>``) is read
12
+ from a snapshot of its repository at that ref, so its folders resolve too.
13
+ """
14
+
15
+ import hashlib
16
+ import io
17
+ import re
18
+ import subprocess
19
+ import tarfile
20
+ import urllib.request
21
+ from collections.abc import Iterable
22
+ from pathlib import Path
23
+ from typing import Any, Final, cast
24
+
25
+ import yaml
26
+
27
+ NAME: Final = "peachycheck.yml"
28
+ APPEND: Final = "CONFIG_PROPERTIES_TO_APPEND"
29
+ CACHE: Final = Path("~/.cache/peachycheck/extends")
30
+ DEFAULTS: Final = Path(__file__).parent / "defaults" / NAME
31
+ # The folder ``sync`` writes the shared base configurations into.
32
+ BASE: Final = ".peachycheck"
33
+ GITHUB_RAW: Final = re.compile(
34
+ r"https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)"
35
+ )
36
+
37
+ type Config = dict[str, Any]
38
+
39
+
40
+ def _url(source: str) -> bool:
41
+ return bool(re.match(r"https?://", source))
42
+
43
+
44
+ def _download(url: str) -> bytes:
45
+ request = urllib.request.Request(url, headers={"User-Agent": "peachycheck"})
46
+ with urllib.request.urlopen(request, timeout=60) as response:
47
+ return cast(bytes, response.read())
48
+
49
+
50
+ def _snapshot(owner: str, repo: str, ref: str) -> Path:
51
+ """The files of ``owner/repo`` at ``ref``, downloaded once.
52
+
53
+ ponytail: cached forever, so EXTENDS should pin a commit; delete the cache
54
+ to refetch a moving ref. Public repositories only.
55
+ """
56
+ folder = CACHE.expanduser() / owner / repo / ref
57
+ if not folder.is_dir():
58
+ data = _download(
59
+ f"https://codeload.github.com/{owner}/{repo}/tar.gz/{ref}"
60
+ )
61
+ partial = folder.with_name(f".{ref}.partial")
62
+ with tarfile.open(fileobj=io.BytesIO(data)) as archive:
63
+ archive.extractall(partial, filter="data")
64
+ (top,) = partial.iterdir() # the archive's one folder, repo-ref/
65
+ top.rename(folder)
66
+ partial.rmdir()
67
+ return folder
68
+
69
+
70
+ def local(source: str) -> str:
71
+ """``source`` as a local path; a GitHub raw URL in its repository's snapshot.
72
+
73
+ Other URLs stay URLs.
74
+ """
75
+ found = GITHUB_RAW.fullmatch(source)
76
+ if found:
77
+ owner, repo, ref, path = found.groups()
78
+ return str(_snapshot(owner, repo, ref) / path)
79
+ return source
80
+
81
+
82
+ def _text(source: str) -> str:
83
+ """A file's text; a URL is fetched once and cached by its address."""
84
+ if not _url(source):
85
+ return Path(source).read_text()
86
+ cached = CACHE.expanduser() / hashlib.sha256(source.encode()).hexdigest()
87
+ if not cached.is_file():
88
+ cached.parent.mkdir(parents=True, exist_ok=True)
89
+ cached.write_bytes(_download(source))
90
+ return cached.read_text()
91
+
92
+
93
+ def _located(entry: str, parent: str) -> str:
94
+ """``entry``, a path relative to the file ``parent`` that names it."""
95
+ if _url(entry) or Path(entry).is_absolute():
96
+ return entry
97
+ if _url(parent):
98
+ return parent.rsplit("/", 1)[0] + "/" + entry
99
+ return str(Path(parent).parent / entry)
100
+
101
+
102
+ def _anchored(data: Config, source: str) -> Config:
103
+ """``data`` with its relative paths resolved against ``source``."""
104
+ found = dict(data)
105
+ files = cast(dict[str, dict[str, str]], found.get("FILES") or {})
106
+ found_files = {
107
+ kind: {
108
+ target: _located(str(path), source) for target, path in by.items()
109
+ }
110
+ for kind, by in files.items()
111
+ }
112
+ return {**found, "FILES": found_files} if files else found
113
+
114
+
115
+ def merge(base: Config, extra: Config, append: Iterable[str] = ()) -> Config:
116
+ """``extra`` over ``base``: later wins, mappings merge, ``append`` lists join."""
117
+ keys = set(append) | set(base.get(APPEND, ())) | set(extra.get(APPEND, ()))
118
+ merged = dict(base)
119
+ for key, value in extra.items():
120
+ old = merged.get(key)
121
+ if key in keys and isinstance(old, list) and isinstance(value, list):
122
+ merged[key] = [*cast(list[Any], old), *cast(list[Any], value)]
123
+ elif isinstance(old, dict) and isinstance(value, dict):
124
+ merged[key] = merge(
125
+ cast(Config, old), cast(Config, value), keys - {APPEND}
126
+ )
127
+ else:
128
+ merged[key] = value
129
+ return merged
130
+
131
+
132
+ def resolve(source: str, seen: frozenset[str] = frozenset()) -> Config:
133
+ """The configuration of ``source`` with everything it extends."""
134
+ source = local(source)
135
+ data = _anchored(cast(Config, yaml.safe_load(_text(source)) or {}), source)
136
+ extends = data.pop("EXTENDS", [])
137
+ result: Config = {}
138
+ for entry in [extends] if isinstance(extends, str) else extends:
139
+ located = local(_located(str(entry), source))
140
+ if located not in seen | {source}: # a cycle is read once
141
+ result = merge(result, resolve(located, seen | {source}))
142
+ return merge(result, data)
143
+
144
+
145
+ def load(root: Path | None = None) -> Config:
146
+ """The configuration of ``root``: the defaults and its ``peachycheck.yml``."""
147
+ defaults = resolve(str(DEFAULTS))
148
+ own = root / NAME if root else None
149
+ if own is None or not own.is_file():
150
+ return defaults
151
+ return merge(defaults, resolve(str(own)))
152
+
153
+
154
+ def rule_file(root: Path, name: str) -> Path | None:
155
+ """The configuration file ``name`` of a linter in the repository ``root``.
156
+
157
+ The repository's own (which inherits the base natively), else the base
158
+ in ``.peachycheck/``, else none: the tool's own defaults.
159
+ """
160
+ candidates = (root / name, root / BASE / name)
161
+ return next((c for c in candidates if c.is_file()), None)
162
+
163
+
164
+ def _toplevel(folder: Path) -> Path | None:
165
+ found = subprocess.run(
166
+ ["git", "rev-parse", "--show-toplevel"],
167
+ cwd=folder,
168
+ capture_output=True,
169
+ text=True,
170
+ )
171
+ return Path(found.stdout.strip()) if found.returncode == 0 else None
172
+
173
+
174
+ def owner(path: Path) -> Path | None:
175
+ """The repository a file belongs to, or None when peachycheck skips it.
176
+
177
+ It is the nearest folder above the file with a peachycheck.yml, up to the
178
+ file's git toplevel.
179
+ """
180
+ folder = path if path.is_dir() else path.parent
181
+ while not folder.is_dir():
182
+ folder = folder.parent # a deleted file's folder may be gone too
183
+ top = _toplevel(folder)
184
+ if top is None:
185
+ return None
186
+ for candidate in [folder, *folder.parents]:
187
+ if (candidate / NAME).is_file():
188
+ return candidate
189
+ if candidate == top:
190
+ return None
191
+ return None
192
+
193
+
194
+ def grouped(paths: Iterable[Path]) -> dict[Path, list[str]]:
195
+ """Absolute ``paths`` by their repository, relative to it; others dropped."""
196
+ found: dict[Path, list[str]] = {}
197
+ for path in paths:
198
+ root = owner(path.resolve())
199
+ if root is not None:
200
+ relative = path.resolve().relative_to(root.resolve()).as_posix()
201
+ found.setdefault(root, []).append(relative)
202
+ return found
203
+
204
+
205
+ def linters(config: Config) -> dict[str, Config]:
206
+ """The linters that run, with the repository's overrides applied."""
207
+ enabled = config.get("ENABLE_LINTERS")
208
+ disabled = set(config.get("DISABLE_LINTERS", ()))
209
+ found: dict[str, Config] = {}
210
+ for name, spec in cast(
211
+ dict[str, Config], config.get("LINTERS", {})
212
+ ).items():
213
+ if not spec.get("command") or name in disabled:
214
+ continue
215
+ if enabled is not None and name not in enabled:
216
+ continue
217
+ arguments = config.get(f"{name}_ARGUMENTS", [])
218
+ setting = f"{name}_{spec.get('requires_key', 'REQUIRES')}"
219
+ found[name] = {
220
+ **spec,
221
+ "requires": config.get(setting, spec.get("requires")),
222
+ "arguments": [arguments]
223
+ if isinstance(arguments, str)
224
+ else arguments,
225
+ "include": config.get(f"{name}_FILTER_REGEX_INCLUDE", ""),
226
+ "exclude": [
227
+ r
228
+ for r in (
229
+ config.get("FILTER_REGEX_EXCLUDE", ""),
230
+ config.get(f"{name}_FILTER_REGEX_EXCLUDE", ""),
231
+ )
232
+ if r
233
+ ],
234
+ }
235
+ return found