pkgdx 0.5.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.
- pkgdx/__init__.py +17 -0
- pkgdx/__main__.py +207 -0
- pkgdx/_core.py +649 -0
- pkgdx/_types.py +111 -0
- pkgdx/exceptions.py +37 -0
- pkgdx/logging/__init__.py +15 -0
- pkgdx/logging/_logging.py +49 -0
- pkgdx/logging/config.toml +48 -0
- pkgdx/py.typed +0 -0
- pkgdx/standards/__init__.py +182 -0
- pkgdx/standards/_cli.py +380 -0
- pkgdx/standards/_hooks.py +53 -0
- pkgdx/standards/hooks.toml +50 -0
- pkgdx/standards/mypy.ini +44 -0
- pkgdx/standards/pymarkdown.toml +9 -0
- pkgdx/standards/ruff.toml +57 -0
- pkgdx-0.5.0.dist-info/METADATA +277 -0
- pkgdx-0.5.0.dist-info/RECORD +22 -0
- pkgdx-0.5.0.dist-info/WHEEL +4 -0
- pkgdx-0.5.0.dist-info/entry_points.txt +7 -0
- pkgdx-0.5.0.dist-info/licenses/COPYRIGHT +15 -0
- pkgdx-0.5.0.dist-info/licenses/LICENSE +201 -0
pkgdx/_core.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""Core logic and file-system operations for `pkgdx init` and `config`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import dataclasses
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from enum import IntEnum, StrEnum
|
|
13
|
+
from itertools import chain, pairwise
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import tomlkit
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from tomlkit import TOMLDocument
|
|
20
|
+
from tomlkit.exceptions import TOMLKitError
|
|
21
|
+
from tomlkit.items import AoT, Table
|
|
22
|
+
|
|
23
|
+
from pkgdx import exceptions, standards
|
|
24
|
+
from pkgdx._types import HookBuiltin, HookLocal, HookRemote
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__package__)
|
|
27
|
+
|
|
28
|
+
_PREK_FIX_HINT = "Fix prek.toml, or regenerate it with `pkgdx init --reset`"
|
|
29
|
+
_WOULD_UPDATE = re.compile(
|
|
30
|
+
r"would update rev `(?P<old>[^`]+)` -> `(?P<new>[^`]+)`"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExitCode(IntEnum):
|
|
35
|
+
"""Exit codes for the CLI commands.
|
|
36
|
+
|
|
37
|
+
NOTE: Members can be passed to `sys.exit` as `int` type checks pass
|
|
38
|
+
due to `IntEnum` inheritance.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
EX_OK = 0
|
|
42
|
+
EX_FAILURE = 1
|
|
43
|
+
EX_SYNTAX = 2
|
|
44
|
+
EX_USAGE = 64
|
|
45
|
+
EX_INTERRUPT = 130
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Outcome(StrEnum):
|
|
49
|
+
"""The state of a finished ledger step (`pkgdx init` or `config`)."""
|
|
50
|
+
|
|
51
|
+
OK = "ok"
|
|
52
|
+
WARN = "warn"
|
|
53
|
+
FAIL = "fail"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Change(StrEnum):
|
|
57
|
+
"""The change a state-changing ledger step made on disk."""
|
|
58
|
+
|
|
59
|
+
CREATED = "created"
|
|
60
|
+
UPDATED = "updated"
|
|
61
|
+
UNCHANGED = "unchanged"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
65
|
+
class StepResult:
|
|
66
|
+
"""The result of one ledger step, for the CLI to render.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
outcome: Whether the step succeeded, warned or failed.
|
|
70
|
+
detail: The step summary, shown when `change` is None.
|
|
71
|
+
message: The cause, shown under a WARN or FAIL row when non-empty.
|
|
72
|
+
hint: The suggested fix, shown under a WARN or FAIL row.
|
|
73
|
+
change: What a state-changing step did, else None.
|
|
74
|
+
subject: What was changed, plus any qualifier that adds information.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
outcome: Outcome
|
|
78
|
+
detail: str = ""
|
|
79
|
+
message: str = ""
|
|
80
|
+
hint: str = ""
|
|
81
|
+
change: Change | None = None
|
|
82
|
+
subject: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
86
|
+
class CLIContext:
|
|
87
|
+
"""Centralized state for the CLI commands.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
args: The parsed command-line arguments from argparse.
|
|
91
|
+
console: The Rich console instance used for terminal output.
|
|
92
|
+
is_debug: Flag indicating if the CLI is running in debug mode.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
args: argparse.Namespace
|
|
96
|
+
console: Console
|
|
97
|
+
is_debug: bool = False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CLIGFormatter(argparse.RawDescriptionHelpFormatter):
|
|
101
|
+
"""Custom formatter for the CLI help output.
|
|
102
|
+
|
|
103
|
+
NOTE: Based on CLI guidance from https://clig.dev/.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def add_usage(
|
|
107
|
+
self,
|
|
108
|
+
usage: str | None,
|
|
109
|
+
actions: Iterable[argparse.Action],
|
|
110
|
+
groups: Iterable[argparse._MutuallyExclusiveGroup],
|
|
111
|
+
prefix: str | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Add a custom usage message to the CLI help output.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
usage: The usage string to display.
|
|
117
|
+
actions: The list of actions for the parser.
|
|
118
|
+
groups: The list of mutually exclusive groups for the parser.
|
|
119
|
+
prefix: The prefix for the usage message.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
super().add_usage(usage, actions, groups, prefix=prefix or "")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_git_toplevel() -> Path:
|
|
126
|
+
"""Get the root path of the consuming Git repository.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
The Git top-level directory path.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
exceptions.GitTopLevelError: If the working directory is not inside
|
|
133
|
+
a Git repository.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
138
|
+
check=True,
|
|
139
|
+
text=True,
|
|
140
|
+
capture_output=True,
|
|
141
|
+
)
|
|
142
|
+
except subprocess.CalledProcessError as e:
|
|
143
|
+
logger.debug("git rev-parse failed: %s", e.stderr)
|
|
144
|
+
msg = "Failed `git rev-parse --show-toplevel`"
|
|
145
|
+
raise exceptions.GitTopLevelError(msg) from e
|
|
146
|
+
else:
|
|
147
|
+
return Path(result.stdout.strip())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_project_root() -> Path:
|
|
151
|
+
"""Get the root path of the consuming repo.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The root path of the consuming repo.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
exceptions.ProjectRootNotFoundError: If no `pyproject.toml` is found
|
|
158
|
+
in the working directory, its parents or the venv parent.
|
|
159
|
+
"""
|
|
160
|
+
cwd = Path.cwd().resolve()
|
|
161
|
+
for directory in chain([cwd], cwd.parents):
|
|
162
|
+
if (directory / "pyproject.toml").exists():
|
|
163
|
+
return directory
|
|
164
|
+
|
|
165
|
+
venv_parent = Path(sys.prefix).parent
|
|
166
|
+
if (venv_parent / "pyproject.toml").exists():
|
|
167
|
+
return venv_parent
|
|
168
|
+
|
|
169
|
+
msg = f"No pyproject.toml in {cwd}, its parents or {venv_parent}"
|
|
170
|
+
raise exceptions.ProjectRootNotFoundError(msg)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _last_line(text: str) -> str:
|
|
174
|
+
"""Get the last non-blank line of captured subprocess output.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
text: The captured output.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The last non-blank line, stripped, or an empty string.
|
|
181
|
+
"""
|
|
182
|
+
lines = text.strip().splitlines()
|
|
183
|
+
return lines[-1].strip() if lines else ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def install_prek_hooks(root: Path, git_toplevel: Path | None) -> StepResult:
|
|
187
|
+
"""Install the pre-commit Git shims if not installed.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
root: The root path of the consuming repo.
|
|
191
|
+
git_toplevel: The Git top-level directory, or None to install at
|
|
192
|
+
the project root.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
An OK result (created or unchanged), or WARN if `prek install`
|
|
196
|
+
fails.
|
|
197
|
+
"""
|
|
198
|
+
target = git_toplevel or root
|
|
199
|
+
subject = "Git shims" if git_toplevel else "Git shims (at project root)"
|
|
200
|
+
if (target / ".git" / "hooks" / "pre-commit").exists():
|
|
201
|
+
logger.debug("Git shims already installed at `.git/hooks/pre-commit`")
|
|
202
|
+
return StepResult(Outcome.OK, change=Change.UNCHANGED, subject=subject)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
proc = subprocess.run(
|
|
206
|
+
[sys.executable, "-m", "prek", "install"],
|
|
207
|
+
cwd=target,
|
|
208
|
+
capture_output=True,
|
|
209
|
+
encoding="utf-8",
|
|
210
|
+
errors="replace",
|
|
211
|
+
check=False,
|
|
212
|
+
)
|
|
213
|
+
except FileNotFoundError as e:
|
|
214
|
+
reason = str(e)
|
|
215
|
+
else:
|
|
216
|
+
logger.debug(
|
|
217
|
+
"prek install exited %d: %s", proc.returncode, proc.stderr
|
|
218
|
+
)
|
|
219
|
+
if proc.returncode == 0:
|
|
220
|
+
return StepResult(
|
|
221
|
+
Outcome.OK, change=Change.CREATED, subject=subject
|
|
222
|
+
)
|
|
223
|
+
reason = (
|
|
224
|
+
_last_line(proc.stderr)
|
|
225
|
+
or f"prek exited with code {proc.returncode}"
|
|
226
|
+
)
|
|
227
|
+
return StepResult(
|
|
228
|
+
Outcome.WARN,
|
|
229
|
+
"Git shims not installed",
|
|
230
|
+
reason,
|
|
231
|
+
"Fix the error, then run `prek install`",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def update_prek_hooks(root: Path) -> StepResult:
|
|
236
|
+
"""Check for remote pre-commit hook revision updates.
|
|
237
|
+
|
|
238
|
+
NOTE: Runs `prek update --check`, which compares each remote repo `rev`
|
|
239
|
+
with its latest tag (e.g. the remote `pkgdx` repo) without updating.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
root: The root path of the consuming repo.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
An OK result if revisions are up to date, else a WARN result naming
|
|
246
|
+
the available updates or why the check failed.
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
proc = subprocess.run(
|
|
250
|
+
[sys.executable, "-m", "prek", "update", "--check"],
|
|
251
|
+
cwd=root,
|
|
252
|
+
capture_output=True,
|
|
253
|
+
encoding="utf-8",
|
|
254
|
+
errors="replace",
|
|
255
|
+
check=False,
|
|
256
|
+
)
|
|
257
|
+
except FileNotFoundError as e:
|
|
258
|
+
reason = str(e)
|
|
259
|
+
else:
|
|
260
|
+
logger.debug(
|
|
261
|
+
"prek update --check exited %d:\n%s%s",
|
|
262
|
+
proc.returncode,
|
|
263
|
+
proc.stdout,
|
|
264
|
+
proc.stderr,
|
|
265
|
+
)
|
|
266
|
+
if proc.returncode == 0:
|
|
267
|
+
return StepResult(Outcome.OK, "hook revisions up to date")
|
|
268
|
+
|
|
269
|
+
# STDOUT lists a repo URL, then `would update rev `a` -> `b`` lines
|
|
270
|
+
repo, updates = "", []
|
|
271
|
+
for line in proc.stdout.splitlines():
|
|
272
|
+
if match := _WOULD_UPDATE.search(line):
|
|
273
|
+
update = f"{repo} {match['old']} -> {match['new']}"
|
|
274
|
+
updates.append(update.lstrip())
|
|
275
|
+
elif line.strip() and "`" not in line:
|
|
276
|
+
name = line.strip().rstrip("/").rsplit("/", 1)[-1]
|
|
277
|
+
repo = name.removesuffix(".git")
|
|
278
|
+
|
|
279
|
+
# A failed fetch prints the repo URL, then an `update failed:` line
|
|
280
|
+
unfetched = [
|
|
281
|
+
url.strip()
|
|
282
|
+
for url, line in pairwise(proc.stderr.splitlines())
|
|
283
|
+
if "update failed:" in line
|
|
284
|
+
]
|
|
285
|
+
failed = f"fetch failed: {', '.join(unfetched)}" if unfetched else ""
|
|
286
|
+
|
|
287
|
+
if proc.returncode == 1 and (updates or not proc.stderr.strip()):
|
|
288
|
+
available = ", ".join(updates) or "hook revision updates"
|
|
289
|
+
return StepResult(
|
|
290
|
+
Outcome.WARN,
|
|
291
|
+
f"{available} available",
|
|
292
|
+
failed,
|
|
293
|
+
"Update remote hook revisions - `prek update`",
|
|
294
|
+
)
|
|
295
|
+
reason = (
|
|
296
|
+
failed
|
|
297
|
+
or _last_line(proc.stderr)
|
|
298
|
+
or f"prek exited with code {proc.returncode}"
|
|
299
|
+
)
|
|
300
|
+
return StepResult(
|
|
301
|
+
Outcome.WARN,
|
|
302
|
+
"could not check hook revisions",
|
|
303
|
+
reason,
|
|
304
|
+
"Run `prek update --check` to see the full output",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _create_repo_table(
|
|
309
|
+
doc: TOMLDocument, name: str, revision: str | None = None
|
|
310
|
+
) -> Table:
|
|
311
|
+
"""Create a new repository table in the pre-commit configuration.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
doc: The TOMLDocument to parse.
|
|
315
|
+
name: The name of the repository.
|
|
316
|
+
revision: The revision of the repository.
|
|
317
|
+
"""
|
|
318
|
+
new_repo = tomlkit.table()
|
|
319
|
+
new_repo["repo"] = name
|
|
320
|
+
if revision:
|
|
321
|
+
new_repo["rev"] = revision
|
|
322
|
+
|
|
323
|
+
new_repo.add(tomlkit.nl())
|
|
324
|
+
|
|
325
|
+
new_repo["hooks"] = tomlkit.aot()
|
|
326
|
+
doc["repos"].append(new_repo)
|
|
327
|
+
|
|
328
|
+
logger.debug("Created `[repo]` - %s | %s", name, revision)
|
|
329
|
+
return new_repo
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _get_repo_table(
|
|
333
|
+
doc: TOMLDocument, name: str, revision: str | None = None
|
|
334
|
+
) -> tuple[Table, bool]:
|
|
335
|
+
"""Get an existing or create a new repository table.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
doc: The TOMLDocument to parse.
|
|
339
|
+
name: The name of the repository.
|
|
340
|
+
revision: The revision of the repository.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
A tuple containing the table and a boolean indicating any changes.
|
|
344
|
+
"""
|
|
345
|
+
for repo in doc["repos"]:
|
|
346
|
+
if repo.get("repo") == name:
|
|
347
|
+
if "hooks" not in repo:
|
|
348
|
+
repo["hooks"] = tomlkit.aot()
|
|
349
|
+
return repo, True
|
|
350
|
+
return repo, False
|
|
351
|
+
return _create_repo_table(doc, name, revision), True
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _inject_missing_hooks(
|
|
355
|
+
table: dict[str, Any],
|
|
356
|
+
expected_hooks: list[HookBuiltin | HookLocal | HookRemote],
|
|
357
|
+
) -> int:
|
|
358
|
+
"""Inject missing hooks into the repository table.
|
|
359
|
+
|
|
360
|
+
NOTE: An inline `hooks = [...]` array cannot hold tables, so it is
|
|
361
|
+
converted to `[[repos.hooks]]` tables, keeping its hooks, before any
|
|
362
|
+
missing hook is added.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
table: The repository table.
|
|
366
|
+
expected_hooks: The expected hooks to be present in the table.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
The number of hooks added to the table.
|
|
370
|
+
"""
|
|
371
|
+
existing_id_set = {hook.get("id") for hook in table.get("hooks", [])}
|
|
372
|
+
missing = [h for h in expected_hooks if h["id"] not in existing_id_set]
|
|
373
|
+
|
|
374
|
+
if missing and not isinstance(table["hooks"], AoT):
|
|
375
|
+
hooks = tomlkit.aot()
|
|
376
|
+
for hook in table.pop("hooks"):
|
|
377
|
+
hooks.append(hook.unwrap())
|
|
378
|
+
table["hooks"] = hooks
|
|
379
|
+
|
|
380
|
+
for expected in missing:
|
|
381
|
+
logger.debug("Detected missing hook `%s`", expected["id"])
|
|
382
|
+
hook_table = tomlkit.table()
|
|
383
|
+
for key, value in expected.items():
|
|
384
|
+
if isinstance(value, list):
|
|
385
|
+
array = tomlkit.array()
|
|
386
|
+
for item in value:
|
|
387
|
+
array.append(item)
|
|
388
|
+
hook_table[key] = array
|
|
389
|
+
else:
|
|
390
|
+
hook_table[key] = value
|
|
391
|
+
|
|
392
|
+
hook_table.add(tomlkit.nl())
|
|
393
|
+
table["hooks"].append(hook_table)
|
|
394
|
+
return len(missing)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _merge_template(
|
|
398
|
+
doc: TOMLDocument, template: TOMLDocument
|
|
399
|
+
) -> tuple[bool, int]:
|
|
400
|
+
"""Merge the template repos & hooks missing from a consumer document.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
doc: The parsed consumer `prek.toml` document, modified in place.
|
|
404
|
+
template: The parsed template (`hooks.toml`) document.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
A tuple containing a boolean indicating any changes and the number
|
|
408
|
+
of hooks added.
|
|
409
|
+
"""
|
|
410
|
+
changed, added = False, 0
|
|
411
|
+
if "repos" not in doc or not doc.get("repos", []):
|
|
412
|
+
logger.debug("Existing `prek.toml` missing or has empty `[repos]`")
|
|
413
|
+
doc["repos"] = tomlkit.aot()
|
|
414
|
+
changed = True
|
|
415
|
+
|
|
416
|
+
for table in template.get("repos", []):
|
|
417
|
+
name, rev = table.get("repo"), table.get("rev")
|
|
418
|
+
table_consumer, tchanged = _get_repo_table(doc, name, rev)
|
|
419
|
+
added += _inject_missing_hooks(table_consumer, table.get("hooks", []))
|
|
420
|
+
changed = changed or tchanged
|
|
421
|
+
|
|
422
|
+
return changed or added > 0, added
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _validate_prek_repos(doc: TOMLDocument) -> None:
|
|
426
|
+
"""Validate the consumer `prek.toml` `[[repos]]` shape.
|
|
427
|
+
|
|
428
|
+
NOTE: A missing or empty `repos` key stays valid - the template
|
|
429
|
+
injection path in `setup_prek_config` creates it.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
doc: The parsed consumer `prek.toml` document.
|
|
433
|
+
|
|
434
|
+
Raises:
|
|
435
|
+
exceptions.PrekConfigError: On a malformed repo/hook entry.
|
|
436
|
+
"""
|
|
437
|
+
repos = doc.get("repos")
|
|
438
|
+
if repos is None:
|
|
439
|
+
return
|
|
440
|
+
if not isinstance(repos, list):
|
|
441
|
+
msg = "`repos` must be an array of tables"
|
|
442
|
+
raise exceptions.PrekConfigError(msg)
|
|
443
|
+
|
|
444
|
+
for index, repo in enumerate(repos):
|
|
445
|
+
is_table = isinstance(repo, dict)
|
|
446
|
+
if not is_table or not isinstance(repo.get("repo"), str):
|
|
447
|
+
msg = f"`repos[{index}]` missing string `repo` key"
|
|
448
|
+
raise exceptions.PrekConfigError(msg)
|
|
449
|
+
|
|
450
|
+
hooks = repo.get("hooks", [])
|
|
451
|
+
if not isinstance(hooks, list):
|
|
452
|
+
msg = f"`repos[{index}].hooks` must be an array of tables"
|
|
453
|
+
raise exceptions.PrekConfigError(msg)
|
|
454
|
+
|
|
455
|
+
for hook_index, hook in enumerate(hooks):
|
|
456
|
+
is_table = isinstance(hook, dict)
|
|
457
|
+
if not is_table or not isinstance(hook.get("id"), str):
|
|
458
|
+
msg = (
|
|
459
|
+
f"`repos[{index}].hooks[{hook_index}]`"
|
|
460
|
+
" missing string `id` key"
|
|
461
|
+
)
|
|
462
|
+
raise exceptions.PrekConfigError(msg)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def config_dir_failure(error: exceptions.ConfigDirError) -> StepResult:
|
|
466
|
+
"""Build the FAIL result for an invalid `PKGDX_CONFIG_DIR`.
|
|
467
|
+
|
|
468
|
+
NOTE: Shared by `pkgdx init` and `pkgdx config`, so both report the same
|
|
469
|
+
cause and hint.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
error: The error raised by `standards.user_config_dir`.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
A FAIL result naming the directory, with a hint to fix it.
|
|
476
|
+
"""
|
|
477
|
+
return StepResult(
|
|
478
|
+
Outcome.FAIL,
|
|
479
|
+
"user config directory not found",
|
|
480
|
+
str(error),
|
|
481
|
+
f"Create it, or unset `{standards.CONFIG_DIR_ENV}`",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def setup_prek_config(root: Path, reset: bool = False) -> StepResult:
|
|
486
|
+
"""Configure pre-commit hooks in the consuming repo.
|
|
487
|
+
|
|
488
|
+
NOTE: Expected errors (an invalid `PKGDX_CONFIG_DIR`, `prek.toml` or
|
|
489
|
+
`hooks.toml`) are returned as FAIL results, not raised.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
root: The root path of the consuming repo.
|
|
493
|
+
reset: Clean installation flag. Defaults to False.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
An OK result (created, updated or unchanged `prek.toml`), or FAIL.
|
|
497
|
+
"""
|
|
498
|
+
config_existing = root / "prek.toml"
|
|
499
|
+
try:
|
|
500
|
+
config_template = standards.resolve_config(standards.PREK_CONFIG)
|
|
501
|
+
except exceptions.ConfigDirError as e:
|
|
502
|
+
return config_dir_failure(e)
|
|
503
|
+
template_text = config_template.read_text()
|
|
504
|
+
|
|
505
|
+
if reset or not config_existing.exists():
|
|
506
|
+
existed = config_existing.exists()
|
|
507
|
+
if existed and config_existing.read_text() == template_text:
|
|
508
|
+
logger.debug("Existing `prek.toml` matches the template")
|
|
509
|
+
change = Change.UNCHANGED
|
|
510
|
+
else:
|
|
511
|
+
logger.debug("Creating `prek.toml` from template")
|
|
512
|
+
config_existing.write_text(template_text)
|
|
513
|
+
change = Change.UPDATED if existed else Change.CREATED
|
|
514
|
+
return StepResult(Outcome.OK, change=change, subject="prek.toml")
|
|
515
|
+
|
|
516
|
+
# Consuming project `prek.toml`
|
|
517
|
+
try:
|
|
518
|
+
doc_consumer = tomlkit.parse(config_existing.read_text())
|
|
519
|
+
_validate_prek_repos(doc_consumer)
|
|
520
|
+
except TOMLKitError as e:
|
|
521
|
+
return StepResult(
|
|
522
|
+
Outcome.FAIL,
|
|
523
|
+
"prek.toml could not be parsed",
|
|
524
|
+
str(e),
|
|
525
|
+
_PREK_FIX_HINT,
|
|
526
|
+
)
|
|
527
|
+
except exceptions.PrekConfigError as e:
|
|
528
|
+
return StepResult(
|
|
529
|
+
Outcome.FAIL, "prek.toml is invalid", str(e), _PREK_FIX_HINT
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# `pkgdx` template `prek.toml`
|
|
533
|
+
try:
|
|
534
|
+
doc_pkgdx = tomlkit.parse(template_text)
|
|
535
|
+
except TOMLKitError as e:
|
|
536
|
+
return StepResult(
|
|
537
|
+
Outcome.FAIL,
|
|
538
|
+
f"{config_template.name} could not be parsed",
|
|
539
|
+
str(e),
|
|
540
|
+
f"Fix {config_template}, or remove it to use the bundled template",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
changed, added = _merge_template(doc_consumer, doc_pkgdx)
|
|
544
|
+
if not changed:
|
|
545
|
+
logger.debug("Existing `prek.toml` is correct & unchanged")
|
|
546
|
+
return StepResult(
|
|
547
|
+
Outcome.OK, change=Change.UNCHANGED, subject="prek.toml"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
config_text = tomlkit.dumps(doc_consumer)
|
|
551
|
+
# Normalise spacing between tables
|
|
552
|
+
config_text = re.sub(r"\n+\[\[repos", "\n\n[[repos", config_text)
|
|
553
|
+
|
|
554
|
+
config_existing.write_text(config_text)
|
|
555
|
+
logger.debug("Added missing pre-commit hooks to `prek.toml`")
|
|
556
|
+
hooks = f"{added} hook{'s' * (added != 1)}"
|
|
557
|
+
subject = f"prek.toml ({hooks} added)" if added else "prek.toml"
|
|
558
|
+
return StepResult(Outcome.OK, change=Change.UPDATED, subject=subject)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def create_secrets_baseline(root: Path) -> StepResult:
|
|
562
|
+
"""Run detect-secrets to create a `.secrets.baseline` file.
|
|
563
|
+
|
|
564
|
+
NOTE: The scan output is written only on success, so a failed or
|
|
565
|
+
interrupted scan leaves no partial baseline.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
root: The root path of the consuming repo.
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
An OK result (created or unchanged), or FAIL if the scan fails.
|
|
572
|
+
"""
|
|
573
|
+
secrets_baseline = root / ".secrets.baseline"
|
|
574
|
+
if secrets_baseline.exists():
|
|
575
|
+
logger.debug("Using existing `.secrets.baseline`")
|
|
576
|
+
return StepResult(
|
|
577
|
+
Outcome.OK, change=Change.UNCHANGED, subject=".secrets.baseline"
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
logger.debug("Creating `.secrets.baseline` at %s", root)
|
|
581
|
+
try:
|
|
582
|
+
proc = subprocess.run(
|
|
583
|
+
[
|
|
584
|
+
sys.executable,
|
|
585
|
+
"-m",
|
|
586
|
+
"detect_secrets",
|
|
587
|
+
"scan",
|
|
588
|
+
"--exclude-files",
|
|
589
|
+
r"(.*\.lock)",
|
|
590
|
+
],
|
|
591
|
+
cwd=root,
|
|
592
|
+
env=os.environ | {"PYTHONUTF8": "1"},
|
|
593
|
+
capture_output=True,
|
|
594
|
+
check=True,
|
|
595
|
+
)
|
|
596
|
+
except subprocess.CalledProcessError as e:
|
|
597
|
+
stderr = e.stderr.decode(errors="replace")
|
|
598
|
+
logger.debug("detect-secrets scan failed: %s", stderr)
|
|
599
|
+
reason = _last_line(stderr) or str(e)
|
|
600
|
+
except FileNotFoundError as e:
|
|
601
|
+
reason = str(e)
|
|
602
|
+
else:
|
|
603
|
+
secrets_baseline.write_bytes(proc.stdout)
|
|
604
|
+
return StepResult(
|
|
605
|
+
Outcome.OK, change=Change.CREATED, subject=".secrets.baseline"
|
|
606
|
+
)
|
|
607
|
+
return StepResult(
|
|
608
|
+
Outcome.FAIL,
|
|
609
|
+
".secrets.baseline not created",
|
|
610
|
+
reason,
|
|
611
|
+
"Run `detect-secrets scan` to see the full error",
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def export_config(bundled: Path, user_dir: Path) -> StepResult:
|
|
616
|
+
"""Copy a bundled config file into the user config directory.
|
|
617
|
+
|
|
618
|
+
NOTE: An existing file is never overwritten. A failed copy (an `OSError`)
|
|
619
|
+
is returned as a FAIL result, not raised.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
bundled: A bundled config path (for example `RUFF_CONFIG`).
|
|
623
|
+
user_dir: The user config directory, created if missing.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
An OK result (created or unchanged), or FAIL if the copy fails.
|
|
627
|
+
"""
|
|
628
|
+
target = user_dir / bundled.name
|
|
629
|
+
try:
|
|
630
|
+
# `is_file`, as `standards.resolve_config` ignores a directory
|
|
631
|
+
if target.is_file():
|
|
632
|
+
logger.debug("Keeping existing %s", target)
|
|
633
|
+
return StepResult(
|
|
634
|
+
Outcome.OK, change=Change.UNCHANGED, subject=str(target)
|
|
635
|
+
)
|
|
636
|
+
user_dir.mkdir(parents=True, exist_ok=True)
|
|
637
|
+
shutil.copyfile(bundled, target)
|
|
638
|
+
except OSError as e:
|
|
639
|
+
reason = str(e)
|
|
640
|
+
if e.strerror and e.filename:
|
|
641
|
+
# `str(e)` shows the path as a repr, doubling Windows backslashes
|
|
642
|
+
reason = f"{e.strerror}: {e.filename}"
|
|
643
|
+
return StepResult(
|
|
644
|
+
Outcome.FAIL,
|
|
645
|
+
"could not be copied",
|
|
646
|
+
reason,
|
|
647
|
+
"Fix the cause, then re-run `pkgdx config --export`",
|
|
648
|
+
)
|
|
649
|
+
return StepResult(Outcome.OK, change=Change.CREATED, subject=str(target))
|