pkgdx 0.5.0rc1__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 +370 -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 +350 -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.0rc1.dist-info/METADATA +258 -0
- pkgdx-0.5.0rc1.dist-info/RECORD +22 -0
- pkgdx-0.5.0rc1.dist-info/WHEEL +4 -0
- pkgdx-0.5.0rc1.dist-info/entry_points.txt +7 -0
- pkgdx-0.5.0rc1.dist-info/licenses/COPYRIGHT +15 -0
- pkgdx-0.5.0rc1.dist-info/licenses/LICENSE +201 -0
pkgdx/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""__init__ for pkgdx."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
from importlib import metadata
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pkgdx import exceptions, standards
|
|
8
|
+
from pkgdx.logging import configure_pkg_logging
|
|
9
|
+
|
|
10
|
+
__all__: list[str] = ["exceptions", "standards"]
|
|
11
|
+
|
|
12
|
+
PKGDX_ROOT: Path = Path(__file__).parent
|
|
13
|
+
|
|
14
|
+
configure_pkg_logging()
|
|
15
|
+
|
|
16
|
+
with contextlib.suppress(metadata.PackageNotFoundError):
|
|
17
|
+
__version__: str = metadata.version("pkgdx")
|
pkgdx/__main__.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Main entry point for pkgdx pre-commit hooks and CLI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import textwrap
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
from pkgdx import __version__, _core, exceptions
|
|
11
|
+
from pkgdx.logging import CLI_CONSOLE, configure_cli_logging
|
|
12
|
+
from pkgdx.standards import _hooks
|
|
13
|
+
from pkgdx.standards._cli import add_subparser as add_standards_subparser
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__package__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__: list[str] = [
|
|
19
|
+
"detect_secrets",
|
|
20
|
+
"main",
|
|
21
|
+
"mypy_typing",
|
|
22
|
+
"pymarkdown_lint",
|
|
23
|
+
"ruff_format",
|
|
24
|
+
"ruff_lint",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# PRE-COMMIT ENTRYPOINTS [project.scripts]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run_hook(func: Callable[[list[str]], int]) -> NoReturn:
|
|
32
|
+
"""Run a config hook, reporting `pkgdx` errors without a traceback.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
func: The core hook logic, called with `sys.argv[1:]`.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
code = func(sys.argv[1:])
|
|
39
|
+
except exceptions.Error as err:
|
|
40
|
+
sys.stderr.write(f"pkgdx: {err}\n")
|
|
41
|
+
sys.exit(_core.ExitCode.EX_FAILURE)
|
|
42
|
+
sys.exit(code)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ruff_format() -> NoReturn:
|
|
46
|
+
"""Run ruff formatting with the configured settings.
|
|
47
|
+
|
|
48
|
+
NOTE: Relates to pre-commit hook ID `pkgdx-format`, with
|
|
49
|
+
entry point `pkgdx-format-hook`.
|
|
50
|
+
"""
|
|
51
|
+
_run_hook(_hooks.core_ruff_format)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ruff_lint() -> NoReturn:
|
|
55
|
+
"""Run ruff linting with the configured settings.
|
|
56
|
+
|
|
57
|
+
NOTE: Relates to pre-commit hook ID `pkgdx-lint`, with
|
|
58
|
+
entry point `pkgdx-lint-hook`.
|
|
59
|
+
"""
|
|
60
|
+
_run_hook(_hooks.core_ruff_lint)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def mypy_typing() -> NoReturn:
|
|
64
|
+
"""Run mypy type checking with the configured settings.
|
|
65
|
+
|
|
66
|
+
NOTE: Relates to pre-commit hook ID `pkgdx-typing`, with
|
|
67
|
+
entry point `pkgdx-typing-hook`.
|
|
68
|
+
"""
|
|
69
|
+
_run_hook(_hooks.core_mypy_typing)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_secrets() -> NoReturn:
|
|
73
|
+
"""Run detect-secrets with the provided arguments.
|
|
74
|
+
|
|
75
|
+
NOTE: Relates to pre-commit hook ID `pkgdx-secrets`, with
|
|
76
|
+
entry point `pkgdx-secrets-hook`.
|
|
77
|
+
"""
|
|
78
|
+
sys.exit(_hooks.core_detect_secrets(sys.argv[1:]))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pymarkdown_lint() -> NoReturn:
|
|
82
|
+
"""Run pymarkdown linting with the configured settings.
|
|
83
|
+
|
|
84
|
+
NOTE: Relates to pre-commit hook ID `pkgdx-markdown`, with
|
|
85
|
+
entry point `pkgdx-markdown-hook`.
|
|
86
|
+
"""
|
|
87
|
+
_run_hook(_hooks.core_pymarkdown_lint)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# MAIN CLI COMMANDS
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CLIArgumentParser(argparse.ArgumentParser):
|
|
94
|
+
"""Custom CLI ArgumentParser."""
|
|
95
|
+
|
|
96
|
+
def error(self, message: str) -> NoReturn:
|
|
97
|
+
"""Handle CLI errors by printing a formatted message and exiting.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
message: The error message to display.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
CLI_CONSOLE.print(f"[red]ERROR:[/red] [b]{message}[/b]", end="\n\n")
|
|
104
|
+
self.print_usage(sys.stderr)
|
|
105
|
+
CLI_CONSOLE.print(
|
|
106
|
+
f"\n[dim]Try [cyan]{self.prog} --help[/cyan].[/dim]",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
sys.exit(_core.ExitCode.EX_USAGE)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main() -> NoReturn:
|
|
113
|
+
"""Provide a unified `pkgdx` CLI entrypoint - init."""
|
|
114
|
+
|
|
115
|
+
# Provides a base parser for shared arguments across subcommands
|
|
116
|
+
base_parser = CLIArgumentParser(add_help=False)
|
|
117
|
+
base_parser.add_argument(
|
|
118
|
+
"-h",
|
|
119
|
+
"--help",
|
|
120
|
+
action="help",
|
|
121
|
+
help="Show this help message and exit",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
description = textwrap.dedent(
|
|
125
|
+
"""pkgdx centralises creation and implementation of coding standards
|
|
126
|
+
across projects, providing a single source of truth for toolchain
|
|
127
|
+
configuration."""
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
epilog = textwrap.fill(
|
|
131
|
+
"For more information see https://gitlab.com/andyrids/pkgdx.",
|
|
132
|
+
width=79,
|
|
133
|
+
initial_indent="",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Main parser for the CLI
|
|
137
|
+
parser = CLIArgumentParser(
|
|
138
|
+
prog=__package__,
|
|
139
|
+
description=textwrap.fill(description, width=79),
|
|
140
|
+
usage="USAGE:\n %(prog)s [options] <command>",
|
|
141
|
+
epilog=epilog,
|
|
142
|
+
add_help=False,
|
|
143
|
+
parents=[base_parser],
|
|
144
|
+
formatter_class=_core.CLIGFormatter,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
parser._optionals.title = "OPTIONS"
|
|
148
|
+
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"-d",
|
|
151
|
+
"--debug",
|
|
152
|
+
action="store_true",
|
|
153
|
+
help="Show debug logging output",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
parser.add_argument(
|
|
157
|
+
"-v",
|
|
158
|
+
"--version",
|
|
159
|
+
action="version",
|
|
160
|
+
version=f"v{__version__}",
|
|
161
|
+
help="Show program's version number and exit",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
subparsers = parser.add_subparsers(
|
|
165
|
+
title="COMMANDS",
|
|
166
|
+
# Mitigate subparser usage display issue by explicitly setting prog
|
|
167
|
+
# https://github.com/python/cpython/issues/86463
|
|
168
|
+
prog=parser.prog,
|
|
169
|
+
dest="command",
|
|
170
|
+
required=False,
|
|
171
|
+
metavar="<command>",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
add_standards_subparser(subparsers, base_parser)
|
|
175
|
+
|
|
176
|
+
args = parser.parse_args()
|
|
177
|
+
|
|
178
|
+
# Empty command (`pkgdx`) shows header and help
|
|
179
|
+
if args.command is None:
|
|
180
|
+
sys.stdout.write(
|
|
181
|
+
f"Package Developer Experience (DX) Toolkit [v{__version__}]\n\n"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Prints; USAGE, description, OPTIONS, COMMANDS & epilog
|
|
185
|
+
parser.print_help()
|
|
186
|
+
|
|
187
|
+
sys.exit(_core.ExitCode.EX_OK)
|
|
188
|
+
|
|
189
|
+
# Configure logging based on debug flag
|
|
190
|
+
is_debug = args.debug
|
|
191
|
+
configure_cli_logging(logging.DEBUG if is_debug else logging.WARNING)
|
|
192
|
+
|
|
193
|
+
ctx = _core.CLIContext(args=args, console=CLI_CONSOLE, is_debug=is_debug)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
exit_code = int(args.func(ctx))
|
|
197
|
+
except exceptions.Error as err:
|
|
198
|
+
logger.error(str(err))
|
|
199
|
+
exit_code = _core.ExitCode.EX_FAILURE
|
|
200
|
+
except Exception:
|
|
201
|
+
logger.exception("Unexpected error")
|
|
202
|
+
exit_code = _core.ExitCode.EX_SYNTAX
|
|
203
|
+
sys.exit(exit_code)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
main()
|
pkgdx/_core.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Core logic and file-system operations for the `pkgdx init` command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import dataclasses
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from enum import IntEnum
|
|
12
|
+
from itertools import chain
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import tomlkit
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from tomlkit import TOMLDocument
|
|
19
|
+
from tomlkit.items import Table
|
|
20
|
+
|
|
21
|
+
from pkgdx import exceptions, standards
|
|
22
|
+
from pkgdx._types import HookBuiltin, HookLocal, HookRemote
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__package__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExitCode(IntEnum):
|
|
28
|
+
"""Exit codes for the CLI commands.
|
|
29
|
+
|
|
30
|
+
NOTE: Members can be passed to `sys.exit` as `int` type checks pass
|
|
31
|
+
due to `IntEnum` inheritance.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
EX_OK = 0
|
|
35
|
+
EX_FAILURE = 1
|
|
36
|
+
EX_SYNTAX = 2
|
|
37
|
+
EX_USAGE = 64
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
41
|
+
class CLIContext:
|
|
42
|
+
"""Centralized state for the CLI commands.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
args: The parsed command-line arguments from argparse.
|
|
46
|
+
console: The Rich console instance used for terminal output.
|
|
47
|
+
is_debug: Flag indicating if the CLI is running in debug mode.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
args: argparse.Namespace
|
|
51
|
+
console: Console
|
|
52
|
+
is_debug: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CLIGFormatter(argparse.RawDescriptionHelpFormatter):
|
|
56
|
+
"""Custom formatter for the CLI help output.
|
|
57
|
+
|
|
58
|
+
NOTE: Based on CLI guidance from https://clig.dev/.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def add_usage(
|
|
62
|
+
self,
|
|
63
|
+
usage: str | None,
|
|
64
|
+
actions: Iterable[argparse.Action],
|
|
65
|
+
groups: Iterable[argparse._MutuallyExclusiveGroup],
|
|
66
|
+
prefix: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Add a custom usage message to the CLI help output.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
usage: The usage string to display.
|
|
72
|
+
actions: The list of actions for the parser.
|
|
73
|
+
groups: The list of mutually exclusive groups for the parser.
|
|
74
|
+
prefix: The prefix for the usage message.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
super().add_usage(usage, actions, groups, prefix=prefix or "")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_git_toplevel() -> Path:
|
|
81
|
+
"""Get the root path of the consuming Git repository.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The Git top-level directory path.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
result = subprocess.run(
|
|
88
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
89
|
+
check=True,
|
|
90
|
+
text=True,
|
|
91
|
+
capture_output=True,
|
|
92
|
+
)
|
|
93
|
+
except subprocess.CalledProcessError as e:
|
|
94
|
+
msg = "Failed `git rev-parse --show-toplevel`"
|
|
95
|
+
raise exceptions.GitTopLevelError(msg) from e
|
|
96
|
+
else:
|
|
97
|
+
return Path(result.stdout.strip())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_project_root() -> Path:
|
|
101
|
+
"""Get the root path of the consuming repo.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The root path of the consuming repo.
|
|
105
|
+
"""
|
|
106
|
+
cwd = Path.cwd().resolve()
|
|
107
|
+
for directory in chain([cwd], cwd.parents):
|
|
108
|
+
if (directory / "pyproject.toml").exists():
|
|
109
|
+
return directory
|
|
110
|
+
|
|
111
|
+
venv_parent = Path(sys.prefix).parent
|
|
112
|
+
if (venv_parent / "pyproject.toml").exists():
|
|
113
|
+
return venv_parent
|
|
114
|
+
|
|
115
|
+
msg = f"Cannot identify project root:- `{sys.prefix=}` | `{cwd=}`."
|
|
116
|
+
raise exceptions.ProjectRootNotFoundError(msg)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def install_prek_hooks(root: Path) -> None:
|
|
120
|
+
"""Install pre-commit hooks if not installed.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
root: The root path of the consuming repo.
|
|
124
|
+
"""
|
|
125
|
+
precommit_config = root / ".git" / "hooks" / "pre-commit"
|
|
126
|
+
if precommit_config.exists():
|
|
127
|
+
logger.debug("Git shims already installed at `.git/hooks/pre-commit`")
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
subprocess.run(
|
|
131
|
+
[sys.executable, "-m", "prek", "install"],
|
|
132
|
+
cwd=root,
|
|
133
|
+
capture_output=True,
|
|
134
|
+
check=True,
|
|
135
|
+
)
|
|
136
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
137
|
+
logger.exception("Failed to install Git shims")
|
|
138
|
+
else:
|
|
139
|
+
logger.debug("Git shims installed at `.git/hooks/pre-commit`")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def update_prek_hooks(root: Path) -> None:
|
|
143
|
+
"""Check for pre-commit hook updates.
|
|
144
|
+
|
|
145
|
+
NOTE: Updates the pre-commit hooks based on available tagged versions in
|
|
146
|
+
the remote `pkgdx` repo.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
root: The root path of the consuming repo.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
subprocess.run(
|
|
153
|
+
[sys.executable, "-m", "prek", "update", "--check", "-v"],
|
|
154
|
+
cwd=root,
|
|
155
|
+
capture_output=True,
|
|
156
|
+
check=True,
|
|
157
|
+
text=True,
|
|
158
|
+
)
|
|
159
|
+
except subprocess.CalledProcessError as e:
|
|
160
|
+
msg = re.sub(r"\n\s|\n", "", e.stdout or e.stderr or "Unknown error")
|
|
161
|
+
match e.returncode:
|
|
162
|
+
case 1:
|
|
163
|
+
logger.warning(msg)
|
|
164
|
+
logger.warning("Update remote hook revisions - `prek update`")
|
|
165
|
+
case 2:
|
|
166
|
+
logger.error(msg)
|
|
167
|
+
case _:
|
|
168
|
+
logger.exception(msg)
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
logger.exception("Check `prek.toml` config exists")
|
|
171
|
+
else:
|
|
172
|
+
logger.debug("Remote hook revisions are up-to-date")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _create_repo_table(
|
|
176
|
+
doc: TOMLDocument, name: str, revision: str | None = None
|
|
177
|
+
) -> Table:
|
|
178
|
+
"""Create a new repository table in the pre-commit configuration.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
doc: The TOMLDocument to parse.
|
|
182
|
+
name: The name of the repository.
|
|
183
|
+
revision: The revision of the repository.
|
|
184
|
+
"""
|
|
185
|
+
new_repo = tomlkit.table()
|
|
186
|
+
new_repo["repo"] = name
|
|
187
|
+
if revision:
|
|
188
|
+
new_repo["rev"] = revision
|
|
189
|
+
|
|
190
|
+
new_repo.add(tomlkit.nl())
|
|
191
|
+
|
|
192
|
+
new_repo["hooks"] = tomlkit.aot()
|
|
193
|
+
doc["repos"].append(new_repo)
|
|
194
|
+
|
|
195
|
+
logger.debug("Created `[repo]` - %s | %s", name, revision)
|
|
196
|
+
return new_repo
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _get_repo_table(
|
|
200
|
+
doc: TOMLDocument, name: str, revision: str | None = None
|
|
201
|
+
) -> tuple[Table, bool]:
|
|
202
|
+
"""Get an existing or create a new repository table.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
doc: The TOMLDocument to parse.
|
|
206
|
+
name: The name of the repository.
|
|
207
|
+
revision: The revision of the repository.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
A tuple containing the table and a boolean indicating any changes.
|
|
211
|
+
"""
|
|
212
|
+
for repo in doc["repos"]:
|
|
213
|
+
if repo.get("repo") == name:
|
|
214
|
+
if "hooks" not in repo:
|
|
215
|
+
repo["hooks"] = tomlkit.aot()
|
|
216
|
+
return repo, True
|
|
217
|
+
return repo, False
|
|
218
|
+
return _create_repo_table(doc, name, revision), True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _inject_missing_hooks(
|
|
222
|
+
table: dict[str, Any],
|
|
223
|
+
expected_hooks: list[HookBuiltin | HookLocal | HookRemote],
|
|
224
|
+
) -> bool:
|
|
225
|
+
"""Inject missing hooks into the repository table.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
table: The repository table.
|
|
229
|
+
expected_hooks: The expected hooks to be present in the table.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
A boolean indicating if the table was modified.
|
|
233
|
+
"""
|
|
234
|
+
changed = False
|
|
235
|
+
existing_id_set = {hook.get("id") for hook in table.get("hooks", [])}
|
|
236
|
+
|
|
237
|
+
for expected in expected_hooks:
|
|
238
|
+
if expected["id"] not in existing_id_set:
|
|
239
|
+
logger.debug("Detected missing hook `%s`", expected["id"])
|
|
240
|
+
hook_table = tomlkit.table()
|
|
241
|
+
for key, value in expected.items():
|
|
242
|
+
if isinstance(value, list):
|
|
243
|
+
array = tomlkit.array()
|
|
244
|
+
for item in value:
|
|
245
|
+
array.append(item)
|
|
246
|
+
hook_table[key] = array
|
|
247
|
+
else:
|
|
248
|
+
hook_table[key] = value
|
|
249
|
+
|
|
250
|
+
hook_table.add(tomlkit.nl())
|
|
251
|
+
table["hooks"].append(hook_table)
|
|
252
|
+
changed = True
|
|
253
|
+
return changed
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _validate_prek_repos(doc: TOMLDocument) -> None:
|
|
257
|
+
"""Validate the consumer `prek.toml` `[[repos]]` shape.
|
|
258
|
+
|
|
259
|
+
NOTE: A missing or empty `repos` key stays valid - the template
|
|
260
|
+
injection path in `setup_prek_config` creates it.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
doc: The parsed consumer `prek.toml` document.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
exceptions.PrekConfigError: On a malformed repo/hook entry.
|
|
267
|
+
"""
|
|
268
|
+
repos = doc.get("repos")
|
|
269
|
+
if repos is None:
|
|
270
|
+
return
|
|
271
|
+
if not isinstance(repos, list):
|
|
272
|
+
msg = "`repos` must be an array of tables"
|
|
273
|
+
raise exceptions.PrekConfigError(msg)
|
|
274
|
+
|
|
275
|
+
for index, repo in enumerate(repos):
|
|
276
|
+
is_table = isinstance(repo, dict)
|
|
277
|
+
if not is_table or not isinstance(repo.get("repo"), str):
|
|
278
|
+
msg = f"`repos[{index}]` missing string `repo` key"
|
|
279
|
+
raise exceptions.PrekConfigError(msg)
|
|
280
|
+
|
|
281
|
+
hooks = repo.get("hooks", [])
|
|
282
|
+
if not isinstance(hooks, list):
|
|
283
|
+
msg = f"`repos[{index}].hooks` must be an array of tables"
|
|
284
|
+
raise exceptions.PrekConfigError(msg)
|
|
285
|
+
|
|
286
|
+
for hook_index, hook in enumerate(hooks):
|
|
287
|
+
is_table = isinstance(hook, dict)
|
|
288
|
+
if not is_table or not isinstance(hook.get("id"), str):
|
|
289
|
+
msg = (
|
|
290
|
+
f"`repos[{index}].hooks[{hook_index}]`"
|
|
291
|
+
" missing string `id` key"
|
|
292
|
+
)
|
|
293
|
+
raise exceptions.PrekConfigError(msg)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def setup_prek_config(root: Path, reset: bool = False) -> None:
|
|
297
|
+
"""Configure pre-commit hooks in the consuming repo.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
root: The root path of the consuming repo.
|
|
301
|
+
reset: Clean installation flag. Defaults to False.
|
|
302
|
+
"""
|
|
303
|
+
changed = False
|
|
304
|
+
config_existing = root / "prek.toml"
|
|
305
|
+
config_template = standards.resolve_config(standards.PREK_CONFIG)
|
|
306
|
+
|
|
307
|
+
if reset or not config_existing.exists():
|
|
308
|
+
logger.debug("Creating `prek.toml` from template")
|
|
309
|
+
config_existing.write_text(config_template.read_text())
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
# Consuming project `prek.toml`
|
|
313
|
+
doc_consumer: TOMLDocument = tomlkit.parse(config_existing.read_text())
|
|
314
|
+
_validate_prek_repos(doc_consumer)
|
|
315
|
+
|
|
316
|
+
if "repos" not in doc_consumer or not doc_consumer.get("repos", []):
|
|
317
|
+
logger.debug("Existing `prek.toml` missing or has empty `[repos]`")
|
|
318
|
+
doc_consumer["repos"] = tomlkit.aot()
|
|
319
|
+
changed = True
|
|
320
|
+
|
|
321
|
+
# `pkgdx` template `prek.toml`
|
|
322
|
+
doc_pkgdx: TOMLDocument = tomlkit.parse(config_template.read_text())
|
|
323
|
+
for table in doc_pkgdx.get("repos", []):
|
|
324
|
+
name, rev = table.get("repo"), table.get("rev")
|
|
325
|
+
table_consumer, tchanged = _get_repo_table(doc_consumer, name, rev)
|
|
326
|
+
|
|
327
|
+
table_pkgdx = table.get("hooks", [])
|
|
328
|
+
hchanged = _inject_missing_hooks(table_consumer, table_pkgdx)
|
|
329
|
+
|
|
330
|
+
changed = changed or tchanged or hchanged
|
|
331
|
+
|
|
332
|
+
if changed:
|
|
333
|
+
config_text = tomlkit.dumps(doc_consumer)
|
|
334
|
+
# Normalise spacing between tables
|
|
335
|
+
config_text = re.sub(r"\n+\[\[repos", "\n\n[[repos", config_text)
|
|
336
|
+
|
|
337
|
+
config_existing.write_text(config_text)
|
|
338
|
+
logger.debug("Added missing pre-commit hooks to `prek.toml`")
|
|
339
|
+
else:
|
|
340
|
+
logger.debug("Existing `prek.toml` is correct & unchanged")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def create_secrets_baseline(root: Path) -> None:
|
|
344
|
+
"""Run detect-secrets to create a `.secrets.baseline` file."""
|
|
345
|
+
secrets_baseline = root / ".secrets.baseline"
|
|
346
|
+
if secrets_baseline.exists():
|
|
347
|
+
logger.debug("Using existing `.secrets.baseline`")
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
logger.debug("Creating `.secrets.baseline` at %s", root)
|
|
351
|
+
try:
|
|
352
|
+
with open(secrets_baseline, "w") as f:
|
|
353
|
+
subprocess.run(
|
|
354
|
+
[
|
|
355
|
+
sys.executable,
|
|
356
|
+
"-m",
|
|
357
|
+
"detect_secrets",
|
|
358
|
+
"scan",
|
|
359
|
+
"--exclude-files",
|
|
360
|
+
r"(.*\.lock)",
|
|
361
|
+
],
|
|
362
|
+
cwd=root,
|
|
363
|
+
env=os.environ | {"PYTHONUTF8": "1"},
|
|
364
|
+
stdout=f,
|
|
365
|
+
check=True,
|
|
366
|
+
)
|
|
367
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
368
|
+
secrets_baseline.unlink(missing_ok=True)
|
|
369
|
+
msg = "Error creating `.secrets.baseline`"
|
|
370
|
+
raise exceptions.Error(msg) from e
|
pkgdx/_types.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Type definitions for Pre-commit hooks and repositories in the TOML config.
|
|
2
|
+
|
|
3
|
+
This module defines `TypedDict` classes for representing remote, local and
|
|
4
|
+
builtin hooks and repositories as they appear in the Pre-commit configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal, NotRequired, TypedDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HookRemote(TypedDict):
|
|
11
|
+
"""Represents a Pre-commit remote hook entry in the TOML config.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
id: The unique identifier for the remote hook.
|
|
15
|
+
name: The name of the remote hook.
|
|
16
|
+
entry: The entry point for the remote hook.
|
|
17
|
+
args: The list of arguments for the remote hook.
|
|
18
|
+
language: The programming language of the remote hook.
|
|
19
|
+
types_or: The list of file types the hook applies to.
|
|
20
|
+
exclude: The pattern for files to exclude from the hook.
|
|
21
|
+
require_serial: Flag indicating if the hook requires serial execution.
|
|
22
|
+
pass_filenames: Flag indicating if filenames should be passed to the
|
|
23
|
+
hook.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
name: NotRequired[str]
|
|
28
|
+
entry: NotRequired[str]
|
|
29
|
+
args: NotRequired[list[str]]
|
|
30
|
+
language: NotRequired[str]
|
|
31
|
+
types_or: NotRequired[list[str]]
|
|
32
|
+
exclude: NotRequired[str]
|
|
33
|
+
require_serial: NotRequired[bool]
|
|
34
|
+
pass_filenames: NotRequired[bool]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RepoRemote(TypedDict):
|
|
38
|
+
"""Represents a Pre-commit remote repo entry in the TOML config.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
repo: The URL of the remote repository.
|
|
42
|
+
rev: The revision of the remote repository.
|
|
43
|
+
hooks: A list of remote hooks associated with the repository.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
repo: str
|
|
47
|
+
rev: NotRequired[str]
|
|
48
|
+
hooks: list[HookRemote]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HookLocal(TypedDict):
|
|
52
|
+
"""Represents a Pre-commit local hook entry in the TOML config.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
id: The unique identifier for the local hook.
|
|
56
|
+
name: The name of the local hook.
|
|
57
|
+
entry: The entry point for the local hook.
|
|
58
|
+
args: The list of arguments for the local hook.
|
|
59
|
+
language: The programming language of the local hook.
|
|
60
|
+
types_or: The list of file types the hook applies to.
|
|
61
|
+
exclude: The pattern for files to exclude from the hook.
|
|
62
|
+
require_serial: Flag indicating if the hook requires serial execution.
|
|
63
|
+
pass_filenames: Flag indicating if filenames should be passed to the
|
|
64
|
+
hook.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
id: str
|
|
68
|
+
name: str
|
|
69
|
+
entry: str
|
|
70
|
+
args: NotRequired[list[str]]
|
|
71
|
+
language: str
|
|
72
|
+
types_or: NotRequired[list[str]]
|
|
73
|
+
exclude: NotRequired[str]
|
|
74
|
+
require_serial: NotRequired[bool]
|
|
75
|
+
pass_filenames: NotRequired[bool]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RepoLocal(TypedDict):
|
|
79
|
+
"""Represents a Pre-commit local repo entry in the TOML config.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
repo: The literal string "local" indicating a local repository.
|
|
83
|
+
rev: The revision of the local repository.
|
|
84
|
+
hooks: A list of local hooks associated with the repository.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
repo: Literal["local"]
|
|
88
|
+
rev: NotRequired[str]
|
|
89
|
+
hooks: list[HookLocal]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class HookBuiltin(TypedDict):
|
|
93
|
+
"""Represents a Pre-commit builtin hook entry in the TOML config.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
id: The unique identifier for the builtin hook.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RepoBuiltin(TypedDict):
|
|
103
|
+
"""Represents a Pre-commit builtin repo entry in the TOML config.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
repo: The literal string "builtin" indicating a builtin repository.
|
|
107
|
+
hooks: A list of builtin hooks associated with the repository.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
repo: Literal["builtin"]
|
|
111
|
+
hooks: list[HookBuiltin]
|