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

1#!/usr/bin/env python3 

2 

3# Standard libraries 

4from pathlib import Path 

5from re import Match, search 

6from shutil import copyfile, move 

7from typing import Dict, List, Optional 

8 

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 

17 

18# PreCommit class 

19class PreCommit: 

20 

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' 

33 

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' 

41 

42 # Variables 

43 BINARY: str = '' 

44 PACKAGES_MISE: str = '' 

45 PACKAGES_PIPX: List[str] = [] 

46 TITLE: str = '' 

47 

48 # Internals 

49 __flags_configuration: List[str] = [] 

50 

51 # Configurations 

52 @staticmethod 

53 def configure() -> None: 

54 

55 # Prepare settings 

56 settings: Settings = Settings(name=Bundle.NAME) 

57 

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 

64 

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 

71 

72 # Autoupdate 

73 @staticmethod 

74 def autoupdate(repo: str = '') -> bool: 

75 

76 # Clean pre-commit 

77 return Commands.run(PreCommit.BINARY, [ 

78 'autoupdate', 

79 ] + PreCommit.__flags_configuration + ([ 

80 '--repo', 

81 repo, 

82 ] if repo else [])) 

83 

84 # Clean 

85 @staticmethod 

86 def clean() -> bool: 

87 

88 # Clean pre-commit 

89 return Commands.run(PreCommit.BINARY, [ 

90 'clean', 

91 ]) 

92 

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: 

100 

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] = [] 

114 

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 ) 

126 

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 ]) 

134 

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 ] 

155 

156 # Cleanup fake pre-commit configuration 

157 if configuration_moved or configuration_tmp: 

158 Path(PreCommit.CONFIGURATION_FILE).unlink() 

159 

160 # Restore pre-commit configurations 

161 if configuration_moved: 

162 move(f'{PreCommit.CONFIGURATION_FILE}.tmp', PreCommit.CONFIGURATION_FILE) 

163 

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 ]) 

172 

173 # Show unused hooks 

174 print(f'detected unused hooks in configuration: {", ".join(hooks_unused) or "-"}') 

175 

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 

182 

183 # Disable pre-commit unused hooks 

184 if hooks_unused: 

185 

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() 

198 

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 

222 

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 

229 

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) 

237 

238 # Result 

239 return True 

240 

241 # Configure useless excludes 

242 @staticmethod 

243 def configure_useless_excludes( 

244 default: bool, 

245 configuration_file: str = CONFIGURATION_FILE, 

246 ) -> bool: 

247 

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] = [] 

258 

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 ) 

270 

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 ]) 

278 

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 ] 

301 

302 # Cleanup fake pre-commit configuration 

303 if configuration_moved or configuration_tmp: 

304 Path(PreCommit.CONFIGURATION_FILE).unlink() 

305 

306 # Restore pre-commit configurations 

307 if configuration_moved: 

308 move(f'{PreCommit.CONFIGURATION_FILE}.tmp', PreCommit.CONFIGURATION_FILE) 

309 

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 ]) 

318 

319 # Show unused hooks 

320 print(f'detected unused hooks in configuration: {", ".join(hooks_unused) or "-"}') 

321 

322 # Disable pre-commit unused hooks 

323 if hooks_unused: 

324 

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() 

336 

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 

361 

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) 

369 

370 # Result 

371 return True 

372 

373 # Dependencies 

374 @staticmethod 

375 def dependencies() -> bool: 

376 

377 # Variables 

378 result: bool = True 

379 

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 

387 

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(' ') 

396 

397 # Install packages (pipx) 

398 for package in PreCommit.PACKAGES_PIPX: 

399 

400 # Check package installation 

401 if not Commands.pip([ 

402 'install', 

403 package, 

404 ]): 

405 result = False 

406 

407 # Isolate package installation 

408 if package != PreCommit.PACKAGES_PIPX[-1]: 

409 print(' ') 

410 

411 # Result 

412 return result 

413 

414 # Exists 

415 @staticmethod 

416 def exists() -> bool: 

417 

418 # Check if binary exists 

419 return Commands.exists(PreCommit.BINARY) 

420 

421 # Revision 

422 @staticmethod 

423 def revision(configuration_file: str = CONFIGURATION_FILE) -> str: 

424 

425 # Variables 

426 repository_found: bool = False 

427 

428 # Ignore missing configuration 

429 if not Path(configuration_file).exists(): 

430 return '' 

431 

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) 

445 

446 # Fallback 

447 return '' 

448 

449 # Install 

450 @staticmethod 

451 def install() -> bool: 

452 

453 # Clean pre-commit 

454 return Commands.run(PreCommit.BINARY, [ 

455 'install', 

456 ] + PreCommit.__flags_configuration + [ 

457 '--allow-missing-config', 

458 ]) 

459 

460 # Run 

461 @staticmethod 

462 def run(all_files: bool, stage: str = '') -> bool: 

463 

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 [])) 

473 

474 # Set configuration 

475 @staticmethod 

476 def set_configuration(file: str) -> None: 

477 

478 # Set configuration flags 

479 if file: 

480 PreCommit.__flags_configuration = [ 

481 '--config', 

482 file, 

483 ] 

484 else: 

485 PreCommit.__flags_configuration = [] 

486 

487 # Stages 

488 @staticmethod 

489 def stages() -> List[str]: 

490 

491 # Variables 

492 hooks_stages: List[str] = [] 

493 

494 # Validate pre-commit 

495 if not PreCommit.exists(): 

496 return hooks_stages # pragma: no cover 

497 

498 # Get pre-commit run help 

499 lines: List[str] = Commands.output(PreCommit.BINARY, [ 

500 'run', 

501 '--help', 

502 ]).splitlines() 

503 

504 # Parse hooks stages 

505 for index, line in enumerate(lines): 

506 

507 # Detect stage option ('--hook-stage' for pre-commit, '--stage' for prek) 

508 if not line.lstrip().startswith(('--hook-stage', '--stage')): 

509 continue 

510 

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()}' 

517 

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 

531 

532 # Result 

533 return hooks_stages 

534 

535 # Uninstall 

536 @staticmethod 

537 def uninstall() -> bool: 

538 

539 # Clean pre-commit 

540 return Commands.run(PreCommit.BINARY, [ 

541 'uninstall', 

542 ] + PreCommit.__flags_configuration)