Coverage for src/features/precommit.py: 68.70%
246 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-08 13:49 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-08 13:49 +0000
1#!/usr/bin/env python3
3# Standard libraries
4from pathlib import Path
5from re import Match, search
6from shutil import copyfile, move
7from typing import Dict, List, Optional
9# Components
10from ..features.git import Git
11from ..package.bundle import Bundle
12from ..package.settings import Settings
13from ..prints.colors import Colors
14from ..system.commands import Commands
15from .mise import Mise
16from .prek import Prek
18# PreCommit class
19class PreCommit:
21 # Constants
22 CONFIGURATION_EOL: str = '\n'
23 CONFIGURATION_EXTENSION: str = '.yaml'
24 CONFIGURATION_FILE: str = '.pre-commit-config.yaml'
25 CONFIGURATION_TEMPLATE: str = '.pre-commit-config.template.yaml'
26 ENGINE: str = 'pre-commit'
27 HOOK_CHECK_HOOKS_APPLY: str = 'check-hooks-apply'
28 HOOK_CHECK_USELESS_EXCLUDES: str = 'check-useless-excludes'
29 HOOKS_APPLY_ERROR: str = ' does not apply to this repository'
30 HOOKS_EXCLUDE_DELIMITER: str = ' for '
31 HOOKS_EXCLUDE_ERROR: str = ' does not match any files'
32 REV_TEMPLATE: str = 'PACKAGE_REVISION'
34 # Defaults
35 DEFAULT_BINARY: str = 'pre-commit'
36 DEFAULT_PACKAGES_MISE: str = 'pre-commit'
37 DEFAULT_PACKAGES_PIPX: List[str] = [
38 'pre-commit',
39 ]
40 DEFAULT_TITLE: str = 'pre-commit'
42 # Variables
43 BINARY: str = ''
44 PACKAGES_MISE: str = ''
45 PACKAGES_PIPX: List[str] = []
46 TITLE: str = ''
48 # Internals
49 __flags_configuration: List[str] = []
51 # Configurations
52 @staticmethod
53 def configure() -> None:
55 # Prepare settings
56 settings: Settings = Settings(name=Bundle.NAME)
58 # Configure pre-commit engine
59 if settings.get('engines', 'pre_commit') == PreCommit.ENGINE:
60 PreCommit.BINARY = PreCommit.DEFAULT_BINARY
61 PreCommit.PACKAGES_MISE = PreCommit.DEFAULT_PACKAGES_MISE
62 PreCommit.PACKAGES_PIPX = PreCommit.DEFAULT_PACKAGES_PIPX
63 PreCommit.TITLE = PreCommit.DEFAULT_TITLE
65 # Configure Prek engine
66 else:
67 PreCommit.BINARY = Prek.DEFAULT_BINARY
68 PreCommit.PACKAGES_MISE = Prek.DEFAULT_PACKAGES_MISE
69 PreCommit.PACKAGES_PIPX = Prek.DEFAULT_PACKAGES_PIPX
70 PreCommit.TITLE = Prek.DEFAULT_TITLE
72 # Autoupdate
73 @staticmethod
74 def autoupdate(repo: str = '') -> bool:
76 # Clean pre-commit
77 return Commands.run(PreCommit.BINARY, [
78 'autoupdate',
79 ] + PreCommit.__flags_configuration + ([
80 '--repo',
81 repo,
82 ] if repo else []))
84 # Clean
85 @staticmethod
86 def clean() -> bool:
88 # Clean pre-commit
89 return Commands.run(PreCommit.BINARY, [
90 'clean',
91 ])
93 # Configure unused hooks, pylint: disable=too-many-branches,too-many-locals,too-many-statements
94 @staticmethod
95 def configure_unused_hooks(
96 default: bool,
97 configuration_file: str = CONFIGURATION_FILE,
98 hooks_disable: Optional[List[str]] = None,
99 ) -> bool:
101 # Variables
102 configuration_moved: bool = False
103 configuration_tmp: bool = False
104 data_lines: List[str]
105 hook_id: str = '- id: '
106 hook_offset: str
107 hook_section: bool
108 hooks_element: str = 'hooks:'
109 hooks_empty_flag: Dict[int, bool]
110 hooks_ending: str
111 hooks_index: int
112 hooks_line: str
113 hooks_unused: List[str] = []
115 # Fake pre-commit configuration
116 if default and configuration_file != PreCommit.CONFIGURATION_FILE:
117 if Path(PreCommit.CONFIGURATION_FILE).exists():
118 move(PreCommit.CONFIGURATION_FILE, f'{PreCommit.CONFIGURATION_FILE}.tmp')
119 configuration_moved = True
120 else:
121 configuration_tmp = True
122 copyfile(
123 configuration_file,
124 PreCommit.CONFIGURATION_FILE,
125 )
127 # Prestage pre-commit configuration
128 if not default or configuration_moved or configuration_tmp:
129 if Git.is_repository():
130 Commands.run(Git.BINARY, [
131 'add',
132 PreCommit.CONFIGURATION_FILE,
133 ])
135 # Configure pre-commit unused hooks
136 print(' ')
137 print(f'{Colors.GREEN} ===['
138 f'{Colors.YELLOW} Configure sources: '
139 f'{Colors.YELLOW_LIGHT}{PreCommit.HOOK_CHECK_HOOKS_APPLY}'
140 f'{Colors.GREEN} ]==='
141 f'{Colors.RESET}')
142 print(' ')
143 for line in Commands.output(PreCommit.BINARY, [
144 'run',
145 ] + PreCommit.__flags_configuration + [
146 '--all-files',
147 PreCommit.HOOK_CHECK_HOOKS_APPLY,
148 ]).splitlines():
149 if PreCommit.HOOKS_APPLY_ERROR not in line:
150 continue
151 hooks_unused += [
152 line[0:line.find(PreCommit.HOOKS_APPLY_ERROR)] \
153 .lstrip(' \t')
154 ]
156 # Cleanup fake pre-commit configuration
157 if configuration_moved or configuration_tmp:
158 Path(PreCommit.CONFIGURATION_FILE).unlink()
160 # Restore pre-commit configurations
161 if configuration_moved:
162 move(f'{PreCommit.CONFIGURATION_FILE}.tmp', PreCommit.CONFIGURATION_FILE)
164 # Reset pre-commit configuration
165 if not default or configuration_moved or configuration_tmp:
166 if Git.is_repository():
167 Commands.run(Git.BINARY, [
168 'reset',
169 'HEAD',
170 PreCommit.CONFIGURATION_FILE,
171 ])
173 # Show unused hooks
174 print(f'detected unused hooks in configuration: {", ".join(hooks_unused) or "-"}')
176 # Disable pre-commit specific hooks
177 if hooks_disable:
178 print(
179 f'disabling specific hooks in configuration: {", ".join(hooks_disable) or "-"}'
180 )
181 hooks_unused += hooks_disable
183 # Disable pre-commit unused hooks
184 if hooks_unused:
186 # Parse pre-commit hooks
187 data_lines = []
188 hook_section = False
189 hooks_empty_flag = {}
190 hooks_index = -1
191 with open(
192 configuration_file,
193 encoding='utf8',
194 mode='r',
195 ) as f:
196 for line in f.readlines():
197 line_stripped = line.strip()
199 # Parse lines
200 if not line_stripped:
201 data_lines.append(line)
202 hook_section = False
203 elif line_stripped.startswith(hooks_element):
204 hooks_index = len(data_lines)
205 hooks_empty_flag[hooks_index] = True
206 data_lines.append(line)
207 elif any(
208 line_stripped == f'{hook_id}{hook}' for hook in hooks_unused):
209 data_lines.append(line.replace(hook_id, f'# {hook_id}'))
210 hook_offset = line[0:line.find(hook_id)]
211 hook_section = True
212 elif line_stripped.startswith(hook_id):
213 data_lines.append(line)
214 if hooks_index != -1:
215 hooks_empty_flag[hooks_index] = False
216 hook_section = False
217 elif hook_section:
218 data_lines.append(f'{hook_offset}# {line.lstrip()}')
219 else:
220 data_lines.append(line)
221 hooks_index = -1
223 # Fix empty hooks
224 for index, empty in hooks_empty_flag.items():
225 if empty:
226 hooks_line = data_lines[index]
227 hooks_ending = hooks_line[len(hooks_line.rstrip('\r\n')):]
228 data_lines[index] = hooks_line.rstrip('\r\n') + ' []' + hooks_ending
230 # Write pre-commit hooks
231 with open(
232 configuration_file,
233 encoding='utf8',
234 mode='w',
235 ) as f:
236 f.writelines(data_lines)
238 # Result
239 return True
241 # Configure useless excludes
242 @staticmethod
243 def configure_useless_excludes(
244 default: bool,
245 configuration_file: str = CONFIGURATION_FILE,
246 ) -> bool:
248 # Variables
249 configuration_moved: bool = False
250 configuration_tmp: bool = False
251 data_lines: List[str]
252 hook_exclude: str = 'exclude: '
253 hook_id: str = '- id: '
254 hook_offset: str
255 hook_property: bool
256 hook_section: bool
257 hooks_unused: List[str] = []
259 # Fake pre-commit configuration
260 if default and configuration_file != PreCommit.CONFIGURATION_FILE:
261 if Path(PreCommit.CONFIGURATION_FILE).exists():
262 move(PreCommit.CONFIGURATION_FILE, f'{PreCommit.CONFIGURATION_FILE}.tmp')
263 configuration_moved = True
264 else:
265 configuration_tmp = True
266 copyfile(
267 configuration_file,
268 PreCommit.CONFIGURATION_FILE,
269 )
271 # Prestage pre-commit configuration
272 if not default or configuration_moved or configuration_tmp:
273 if Git.is_repository():
274 Commands.run(Git.BINARY, [
275 'add',
276 PreCommit.CONFIGURATION_FILE,
277 ])
279 # Configure pre-commit unused hooks
280 print(' ')
281 print(f'{Colors.GREEN} ===['
282 f'{Colors.YELLOW} Configure sources: '
283 f'{Colors.YELLOW_LIGHT}{PreCommit.HOOK_CHECK_USELESS_EXCLUDES}'
284 f'{Colors.GREEN} ]==='
285 f'{Colors.RESET}')
286 print(' ')
287 for line in Commands.output(PreCommit.BINARY, [
288 'run',
289 ] + PreCommit.__flags_configuration + [
290 '--all-files',
291 PreCommit.HOOK_CHECK_USELESS_EXCLUDES,
292 ]).splitlines():
293 if PreCommit.HOOKS_EXCLUDE_ERROR not in line:
294 continue
295 hooks_unused += [
296 line[line.find(PreCommit.HOOKS_EXCLUDE_DELIMITER) \
297 + len(PreCommit.HOOKS_EXCLUDE_DELIMITER) \
298 :line.find(PreCommit.HOOKS_EXCLUDE_ERROR)] \
299 .strip('`')
300 ]
302 # Cleanup fake pre-commit configuration
303 if configuration_moved or configuration_tmp:
304 Path(PreCommit.CONFIGURATION_FILE).unlink()
306 # Restore pre-commit configurations
307 if configuration_moved:
308 move(f'{PreCommit.CONFIGURATION_FILE}.tmp', PreCommit.CONFIGURATION_FILE)
310 # Reset pre-commit configuration
311 if not default or configuration_moved or configuration_tmp:
312 if Git.is_repository():
313 Commands.run(Git.BINARY, [
314 'reset',
315 'HEAD',
316 PreCommit.CONFIGURATION_FILE,
317 ])
319 # Show unused hooks
320 print(f'detected unused hooks in configuration: {", ".join(hooks_unused) or "-"}')
322 # Disable pre-commit unused hooks
323 if hooks_unused:
325 # Parse pre-commit hooks
326 data_lines = []
327 hook_property = False
328 hook_section = False
329 with open(
330 configuration_file,
331 encoding='utf8',
332 mode='r',
333 ) as f:
334 for line in f.readlines():
335 line_stripped = line.strip()
337 # Parse lines
338 if not line_stripped:
339 data_lines.append(line)
340 hook_property = False
341 hook_section = False
342 elif any(
343 line_stripped == f'{hook_id}{hook}' for hook in hooks_unused):
344 data_lines.append(line)
345 hook_offset = line[0:line.find(hook_id)]
346 hook_property = False
347 hook_section = True
348 elif line_stripped.startswith(hook_id):
349 data_lines.append(line)
350 hook_property = False
351 hook_section = False
352 elif hook_section and line_stripped.startswith(hook_exclude):
353 data_lines.append(line.replace(hook_exclude, f'# {hook_exclude}'))
354 hook_property = True
355 elif hook_property and line.startswith(hook_offset + ' '):
356 data_lines.append(
357 f'{hook_offset} # {line[len(hook_offset) + 2:]}')
358 else:
359 data_lines.append(line)
360 hook_property = False
362 # Write pre-commit hooks
363 with open(
364 configuration_file,
365 encoding='utf8',
366 mode='w',
367 ) as f:
368 f.writelines(data_lines)
370 # Result
371 return True
373 # Dependencies
374 @staticmethod
375 def dependencies() -> bool:
377 # Variables
378 result: bool = True
380 # Install packages (mise)
381 result_mise: Optional[bool] = Mise.dependencies(
382 PreCommit.BINARY,
383 PreCommit.PACKAGES_MISE,
384 )
385 if result_mise is not None: # pragma: no cover
386 return result_mise
388 # Uninstall existing packages (pipx), pylint: disable=duplicate-code
389 if Commands.exists(PreCommit.BINARY):
390 for package in PreCommit.PACKAGES_PIPX:
391 Commands.pip([
392 'uninstall',
393 package,
394 ])
395 print(' ')
397 # Install packages (pipx)
398 for package in PreCommit.PACKAGES_PIPX:
400 # Check package installation
401 if not Commands.pip([
402 'install',
403 package,
404 ]):
405 result = False
407 # Isolate package installation
408 if package != PreCommit.PACKAGES_PIPX[-1]:
409 print(' ')
411 # Result
412 return result
414 # Exists
415 @staticmethod
416 def exists() -> bool:
418 # Check if binary exists
419 return Commands.exists(PreCommit.BINARY)
421 # Revision
422 @staticmethod
423 def revision(configuration_file: str = CONFIGURATION_FILE) -> str:
425 # Variables
426 repository_found: bool = False
428 # Ignore missing configuration
429 if not Path(configuration_file).exists():
430 return ''
432 # Parse configuration for the bundle repository revision
433 with open(configuration_file, encoding='utf8', mode='r') as f:
434 for line in f:
435 repo_match: Optional[Match[str]] = search(r'^\s*-\s*repo:\s*(\S+)', line)
436 if repo_match:
437 repository_found = repo_match.group(1).rstrip('/').removesuffix(
438 '.git') == Bundle.REPOSITORY
439 continue
440 if repository_found:
441 rev_match: Optional[Match[str]] = search(
442 r'^\s*rev:\s*[\'"]?([^\'"\s#]+)', line)
443 if rev_match:
444 return rev_match.group(1)
446 # Fallback
447 return ''
449 # Install
450 @staticmethod
451 def install() -> bool:
453 # Clean pre-commit
454 return Commands.run(PreCommit.BINARY, [
455 'install',
456 ] + PreCommit.__flags_configuration + [
457 '--allow-missing-config',
458 ])
460 # Run
461 @staticmethod
462 def run(all_files: bool, stage: str = '') -> bool:
464 # Run commitizen hooks
465 return Commands.run(PreCommit.BINARY, [
466 'run',
467 ] + PreCommit.__flags_configuration + ([
468 '--all-files',
469 ] if all_files else []) + ([
470 '--hook-stage',
471 stage,
472 ] if stage else []))
474 # Set configuration
475 @staticmethod
476 def set_configuration(file: str) -> None:
478 # Set configuration flags
479 if file:
480 PreCommit.__flags_configuration = [
481 '--config',
482 file,
483 ]
484 else:
485 PreCommit.__flags_configuration = []
487 # Stages
488 @staticmethod
489 def stages() -> List[str]:
491 # Variables
492 hooks_stages: List[str] = []
494 # Validate pre-commit
495 if not PreCommit.exists():
496 return hooks_stages # pragma: no cover
498 # Get pre-commit run help
499 lines: List[str] = Commands.output(PreCommit.BINARY, [
500 'run',
501 '--help',
502 ]).splitlines()
504 # Parse hooks stages
505 for index, line in enumerate(lines):
507 # Detect stage option ('--hook-stage' for pre-commit, '--stage' for prek)
508 if not line.lstrip().startswith(('--hook-stage', '--stage')):
509 continue
511 # Acquire option description lines
512 description: str = line
513 for next_line in lines[index + 1:]:
514 if not next_line.strip() or next_line.lstrip().startswith('-'):
515 break
516 description += f' {next_line.strip()}'
518 # Parse pre-commit stages: '--hook-stage {stage1,stage2}'
519 # Parse prek stages: '--stage <STAGE> [possible values: stage1, stage2]'
520 values: Optional[Match[str]] = search(
521 r'\{([^}]*)\}|\[possible values: ([^\]]*)\]',
522 description,
523 )
524 if values:
525 hooks_stages = [
526 stage.strip()
527 for stage in (values.group(1) or values.group(2)).split(',')
528 if stage.strip()
529 ]
530 break
532 # Result
533 return hooks_stages
535 # Uninstall
536 @staticmethod
537 def uninstall() -> bool:
539 # Clean pre-commit
540 return Commands.run(PreCommit.BINARY, [
541 'uninstall',
542 ] + PreCommit.__flags_configuration)