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.
- peachycheck/__init__.py +1 -0
- peachycheck/__main__.py +7 -0
- peachycheck/cli.py +447 -0
- peachycheck/config.py +235 -0
- peachycheck/defaults/peachycheck.yml +170 -0
- peachycheck/hooks.py +357 -0
- peachycheck/linting.py +753 -0
- peachycheck/py.typed +0 -0
- peachycheck-0.1.0.dist-info/METADATA +355 -0
- peachycheck-0.1.0.dist-info/RECORD +13 -0
- peachycheck-0.1.0.dist-info/WHEEL +4 -0
- peachycheck-0.1.0.dist-info/entry_points.txt +2 -0
- peachycheck-0.1.0.dist-info/licenses/LICENSE +235 -0
peachycheck/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""peachycheck: native linters and agent hooks from one peachycheck.yml."""
|
peachycheck/__main__.py
ADDED
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
|