From e7e1f861837b357c26850b815f3a1b8e98d5ab1a Mon Sep 17 00:00:00 2001 From: un-pogaz <46523284+un-pogaz@users.noreply.github.com> Date: Sat, 20 Sep 2025 11:11:41 +0200 Subject: [PATCH 1/4] setup.py git_hooks Install/uninstall git hooks --- setup/commands.py | 5 ++ setup/git_hooks.py | 125 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 setup/git_hooks.py diff --git a/setup/commands.py b/setup/commands.py index fa03ad7a9e..faba5b437a 100644 --- a/setup/commands.py +++ b/setup/commands.py @@ -15,6 +15,7 @@ __all__ = [ 'export_packages', 'extdev', 'get_translations', + 'git_hooks', 'git_version', 'gui', 'hyphenation', @@ -102,6 +103,10 @@ from setup.liberation import LiberationFonts liberation_fonts = LiberationFonts() +from setup.git_hooks import GitHooks + +git_hooks = GitHooks() + from setup.git_version import GitVersion git_version = GitVersion() diff --git a/setup/git_hooks.py b/setup/git_hooks.py new file mode 100644 index 0000000000..750474e573 --- /dev/null +++ b/setup/git_hooks.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2025, un_pogaz + +import os +from collections import namedtuple + +from setup import Command + +base = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +base_hooks = os.path.join(base, '.git', 'hooks') +src_script = os.path.splitext(os.path.realpath(__file__))[0] + + +HOOK_TEMPLATE = '''\ +#!/bin/sh +#!/usr/bin/env bash +# File generated by calibre "setup.py git_hooks" + +HOOK_DIR=$(cd "$(dirname "$0")" && pwd) +BASE_DIR=$(dirname "$(dirname "$HOOK_DIR")") +SCRIPT=$BASE_DIR/{file} + +exec python "$SCRIPT" {args} +''' + +Hook = namedtuple('Hook', ['name', 'file', 'args_count', 'default']) + +HOOKS = {h.name:h for h in ( + Hook('post-checkout', 'git_post_checkout_hook.py', 3, True), + Hook('post-rewrite', 'git_post_rewrite_hook.py', 1, True), + # disable by default, because except Kovid, nobody can run this hook + Hook('commit-msg', 'git_commit_msg_hook.py', 1, False), +)} + +DEFAULT = ','.join(sorted(h.name for h in HOOKS.values() if h.default)) +AVAILABLES = ', '.join(sorted(h for h in HOOKS)) + + +class GitHooks(Command): + description = 'Install/uninstall git hooks' + + def add_options(self, parser): + parser.add_option('-n', '--name', default=DEFAULT, + help='Name(s) of the hook to install, separated by commas. ' + f'Default: "{DEFAULT}". Hooks available: {AVAILABLES}') + parser.add_option('-u', '--uninstall', default=False, action='store_true', + help='Uninstall the selected hooks') + parser.add_option('-f', '--force', default=False, action='store_true', + help='Force the operations on the hooks') + + def run(self, opts): + self.force = opts.force + self.names = [] + + invalides = [] + for candidate in sorted(c.strip().lower() for c in opts.name.split(',')): + if not candidate: + continue + if candidate not in HOOKS: + invalides.append(candidate) + else: + self.names.append(candidate) + + if invalides: + self.info('Info: The following hook names are not recognized:', ', '.join(invalides)) + if not self.names: + self.info('No supported hook names recognized.') + return + + if opts.uninstall: + self.uninstall() + else: + self.install() + + def _parse_template(self, hook_name): + hook = HOOKS[hook_name] + path = self.j(base_hooks, hook.name) + sh_file = f'setup/{hook.file}' + sh_args = ' '.join(f'"${i}"' for i in range(1, hook.args_count+1)) + script = HOOK_TEMPLATE.format(file=sh_file, args=sh_args) + return path, script + + def install(self): + self.info('Installing the hooks:', ', '.join(self.names)) + for candidate in self.names: + path, script = self._parse_template(candidate) + + if self.e(path): + with open(path, 'rb') as f: + previous = f.read().decode('utf-8') + msg = f'{candidate}: a non-calibre hook is installed.' + if previous == script: + self.info(f'{candidate}: installed.') + continue + elif self.force: + self.info(msg, 'Force installation.') + else: + self.info(msg, 'Skip installation.') + continue + + self.info(f'{candidate}: installed.') + with open(path, 'wb') as f: + f.write(script.encode('utf-8')) + + def uninstall(self): + self.info('Uninstalling the hooks:', ', '.join(self.names)) + for candidate in self.names: + path, script = self._parse_template(candidate) + + if not self.e(path): + self.info(f'{candidate}: no hook to unistall.') + continue + + with open(path, 'rb') as f: + previous = f.read().decode('utf-8') + msg = f'{candidate}: a non-calibre hook is installed.' + if previous == script: + self.info(f'{candidate}: unistalled.') + elif self.force: + self.info(msg, 'Force unistallation.') + else: + self.info(msg, 'Skip unistallation.') + continue + + os.remove(path) From 5fed12a0caf8d72375847e48dd8b1736b2ec0e54 Mon Sep 17 00:00:00 2001 From: un-pogaz <46523284+un-pogaz@users.noreply.github.com> Date: Sat, 20 Sep 2025 11:11:41 +0200 Subject: [PATCH 2/4] windows want its executable as first parameter --- setup/git_post_checkout_hook.py | 2 +- setup/git_post_rewrite_hook.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup/git_post_checkout_hook.py b/setup/git_post_checkout_hook.py index 3d244a0af2..393c7b25b2 100755 --- a/setup/git_post_checkout_hook.py +++ b/setup/git_post_checkout_hook.py @@ -21,7 +21,7 @@ if flags == '1': # A branch checkout prev_branch, cur_branch = list(map(get_branch_name, (prev_rev, current_rev))) rebase_in_progress = os.path.exists('.git/rebase-apply') or os.path.exists('.git/rebase-merge') - subprocess.check_call('./setup.py gui --summary'.split()) + subprocess.check_call([sys.executable, './setup.py', 'gui', '--summary']) # Remove .pyc files as some of them might have been orphaned for dirpath, dirnames, filenames in os.walk('.'): diff --git a/setup/git_post_rewrite_hook.py b/setup/git_post_rewrite_hook.py index bc5c450487..becfbf8c13 100755 --- a/setup/git_post_rewrite_hook.py +++ b/setup/git_post_rewrite_hook.py @@ -13,4 +13,4 @@ os.chdir(base) action = [x.decode('utf-8') if isinstance(x, bytes) else x for x in sys.argv[1:]][0] if action == 'rebase': - subprocess.check_call(['./setup.py', 'gui', '--summary']) + subprocess.check_call([sys.executable, './setup.py', 'gui', '--summary']) From a589785ad2fc6737f377a89a848e7bd95cf5246f Mon Sep 17 00:00:00 2001 From: un-pogaz <46523284+un-pogaz@users.noreply.github.com> Date: Sat, 20 Sep 2025 11:11:41 +0200 Subject: [PATCH 3/4] support passing a file as argument for check and quiet ruff --- setup/check.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/setup/check.py b/setup/check.py index 55330fa542..dc93bd5954 100644 --- a/setup/check.py +++ b/setup/check.py @@ -47,6 +47,10 @@ class Check(Command): def add_options(self, parser): parser.add_option('--fix', '--auto-fix', default=False, action='store_true', help='Try to automatically fix some of the smallest errors instead of opening an editor for bad files.') + parser.add_option('-f', '--file', dest='files', type='string', action='append', + help='Specific file to be check. Can be repeat to check severals.') + parser.add_option('--no-editor', default=False, action='store_true', + help="Don't open the editor when a bad file is found.") def get_files(self): yield from checkable_python_files(self.SRC) @@ -83,9 +87,9 @@ class Check(Command): ext = os.path.splitext(f)[1] if ext in {'.py', '.recipe'}: if self.auto_fix: - p = subprocess.Popen(['ruff', 'check', '--fix', f]) + p = subprocess.Popen(['ruff', 'check', '-q', '--fix', f]) else: - p = subprocess.Popen(['ruff', 'check', f]) + p = subprocess.Popen(['ruff', 'check', '-q', f]) return p.wait() != 0 if ext == '.pyj': p = subprocess.Popen(['rapydscript', 'lint', f]) @@ -99,6 +103,8 @@ class Check(Command): self.wn_path = os.path.expanduser('~/work/srv/main/static') self.has_changelog_check = os.path.exists(self.wn_path) self.auto_fix = opts.fix + self.files = opts.files + self.no_editor = opts.no_editor self.run_check_files() def run_check_files(self): @@ -109,18 +115,24 @@ class Check(Command): except OSError as err: if err.errno != errno.ENOENT: raise - dirty_files = tuple(f for f in self.get_files() if not self.is_cache_valid(f, cache)) + if self.files: + dirty_files = tuple(self.files) + else: + dirty_files = tuple(f for f in self.get_files() if not self.is_cache_valid(f, cache)) try: for i, f in enumerate(dirty_files): self.info('\tChecking', f) if self.file_has_errors(f): self.info(f'{len(dirty_files) - i - 1} files left to check') + e = SystemExit(1) + if self.no_editor: + raise e try: edit_file(f) except FileNotFoundError: - pass # continue if the configured editor fail to be open + raise e # raise immediately to skip second check if self.file_has_errors(f): - raise SystemExit(1) + raise e cache[f] = self.file_hash(f) finally: self.save_cache(cache) From 2458e20dbaba047f4259a599973f596364370d36 Mon Sep 17 00:00:00 2001 From: un-pogaz <46523284+un-pogaz@users.noreply.github.com> Date: Sat, 20 Sep 2025 11:11:41 +0200 Subject: [PATCH 4/4] add pre-commit git hook --- setup/git_hooks.py | 1 + setup/git_pre_commit_hook.py | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 setup/git_pre_commit_hook.py diff --git a/setup/git_hooks.py b/setup/git_hooks.py index 750474e573..e4da9d6d66 100644 --- a/setup/git_hooks.py +++ b/setup/git_hooks.py @@ -28,6 +28,7 @@ Hook = namedtuple('Hook', ['name', 'file', 'args_count', 'default']) HOOKS = {h.name:h for h in ( Hook('post-checkout', 'git_post_checkout_hook.py', 3, True), Hook('post-rewrite', 'git_post_rewrite_hook.py', 1, True), + Hook('pre-commit', 'git_pre_commit_hook.py', 0, True), # disable by default, because except Kovid, nobody can run this hook Hook('commit-msg', 'git_commit_msg_hook.py', 1, False), )} diff --git a/setup/git_pre_commit_hook.py b/setup/git_pre_commit_hook.py new file mode 100644 index 0000000000..6971020948 --- /dev/null +++ b/setup/git_pre_commit_hook.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +import os +import subprocess +import sys + +base = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +os.chdir(base) +setup_py = os.path.realpath('./setup.py') + + +def testfile(file): + def t(f, start, end, exclude_end=None): + return f.startswith(start) and f.endswith(end) and not f.endswith(exclude_end) if exclude_end else True + if t(file, ('src/odf', 'src/calibre'), '.py', exclude_end='_ui.py'): + return True + if t(file, 'recipes', '.recipe'): + return True + if t(file, 'src/pyj', '.pyj'): + return True + return False + + +output = subprocess.check_output(( + 'git', 'diff', '--staged', '--name-only', '--no-ext-diff', '-z', + # Everything except for D + '--diff-filter=ACMRTUXB', +)).decode('utf-8') + +output = output.strip('\0') +if not output: + output = [] +else: + output = output.split('\0') + +filenames = tuple(filter(testfile, output)) +if not filenames: + sys.exit(0) + +check_args = [sys.executable, './setup.py', 'check', '--no-editor'] +# let's hope that too many arguments do not hold any surprises +for f in filenames: + check_args.append('-f') + check_args.append(f) + +returncode = subprocess.call(check_args) +sys.exit(returncode)