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/_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))