Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate hook to GitHub #1

Merged
merged 4 commits into from
Oct 18, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.292
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]

- repo: https://github.com/psf/black
rev: 23.9.1
hooks:
- id: black
exclude: 'tests/data/.*'

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: check-toml
- id: check-yaml
- id: check-merge-conflict
- id: end-of-file-fixer
exclude: 'tests/data/.*'
- id: mixed-line-ending
- id: check-added-large-files

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.5.1
hooks:
- id: mypy
args: ['--ignore-missing', '--python-version', '3.8']
DanShort12 marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- id: check-ui-files
name: check-ui-files
description: check that generated PySide ui files are up-to-date
entry: pyuic-pre-commit
language: python
files: .*\.ui$
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,49 @@
# pyuic-pre-commit
A pre-commit hook to run PyQt/PySide's uic tool and ensure generated Python files are up-to-date with their corresponding .ui files.

A [pre-commit](https://pre-commit.com/) hook to run PyQt/PySide's `pyuic` tool
and ensure generated Python files are up-to-date with their `.ui` files.

## Usage

Add the following to your `.pre-commit-config.yaml`:

```yaml
- repo: https://github.com/ukaea/pyuic-pre-commit.git
rev: v0.1.0 # change to your desired version
hooks:
- id: check-ui-files
args: ['--exe-name', 'pyside6-uic'] # optional
```
You **must** have a `pyuic` tool available and on your path.
The default `pyuic` tool is `pyside6-uic`,
but this can be set using the `--exe-name` argument shown above.
For example, if you're using PyQt5:

```yaml
args: ['--exe-name', 'pyuic5']
```

## Assumptions

This hook assumes that each `.ui` and generated Python file pair are
located in the same directory and have a consistent naming pattern.
The generated Python file must have the same name as the `.ui` file
with a `ui_` prefix.

For example, the following is OK:

```text
module/widget.ui -> module/ui_widget.py
```

But these examples are not:

```text
module/ui/widget.ui -> module/ui_widget.py
module/widget.ui -> module/widget.py
```

> *Note:
> If you have requirements that are not supported by these assumptions,
> please let the hook authors know and they can try to help.*
566 changes: 566 additions & 0 deletions poetry.lock

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
[tool.poetry]
name = "pyuicprecommit"
version = "0.1.0"
description = "Pre-commit hook to validate Qt UI files have been generated using pyuic"
authors = ["hsaunders1904"]
readme = "README.md"

[tool.poetry.scripts]
pyuic-pre-commit = 'pyuicprecommit:main_with_exit'

[tool.poetry.dependencies]
python = "^3.10,<3.12"

[tool.poetry.group.dev.dependencies]
pre-commit = "^3.4.0"
black = "^23.9.1"
ruff = "^0.0.292"
mypy = "^1.5.1"

[tool.poetry.group.test.dependencies]
pre-commit = "^3.4.0"
pytest = "^7.4.2"
gitpython = "^3.1.37"
pyside6 = "6.5.3"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.black]
extend-exclude = "tests/data/*"

[tool.ruff]
line-length = 89
select = [
"C90", # mccabe
"B", # flake8-bugbear
"D", # pydocstyle
"E", # pycodestyle-error
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"PTH", # flake8-use-pathlib
"S", # flake8-bandit
"UP", # pyupgrade
"W", # pydocstyle-warning
]

[tool.ruff.per-file-ignores]
"tests/data/*" = ["ALL"]
"tests/**/test_*.py" = [
# Allow asserts in tests
"S101",
# Allow undocumented code in tests
"D",
]

[tool.ruff.pydocstyle]
convention = "numpy"
82 changes: 82 additions & 0 deletions pyuicprecommit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""The entry point for the pyuic-pre-commit pre-commit hook."""

from __future__ import annotations

import argparse
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path


@dataclass
class Args:
"""Command line options for the hook."""

files: list[Path]
exe_name: str


def main_with_exit():
"""Run the pre-commit checks and exit on the returned error code."""
sys.exit(main(sys.argv[1:]))


def main(argv: list[str]) -> int:
"""Run the pre-commit checks."""
args = parse_args(argv)
if not (uic_exe := find_uic_exe(args.exe_name)):
print("cannot find uic executable, ensure it's on your path", file=sys.stderr)
return 1

exit_code = 0
for ui_file in args.files:
target_file = Path(ui_file.parent / f"ui_{ui_file.stem}.py")
if not target_file.is_file():
print(f"no Python file found for ui file '{ui_file}'", file=sys.stderr)
exit_code = 1
continue
out = run_uic(uic_exe, ui_file)
if out != target_file.read_text():
print(
f"Python file '{target_file}' out of data with '{ui_file}'",
DanShort12 marked this conversation as resolved.
Show resolved Hide resolved
file=sys.stderr,
)
exit_code = 1
return exit_code


def parse_args(argv: list[str]) -> Args:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"files",
nargs="+",
type=Path,
help="the files to apply the check to",
)
parser.add_argument(
"--exe-name",
default="pyside6-uic",
type=str,
help="the name of the uic executable to use",
)
return Args(**vars(parser.parse_args(argv)))


def find_uic_exe(cli_exe: str) -> Path | None:
"""Find the uic executable on the system path."""
if exe := shutil.which(cli_exe):
return Path(exe)
return None


def run_uic(uic_exe: Path, ui_file: Path) -> str:
"""Run uic on the given ui file, returning the output."""
cmd = [str(uic_exe), str(ui_file)]
return subprocess.check_output(cmd).decode() # noqa: S603


if __name__ == "__main__":
main_with_exit()
34 changes: 34 additions & 0 deletions tests/data/ui_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-

################################################################################
## Form generated from reading UI file 'window.ui'
##
## Created by: Qt User Interface Compiler version 6.5.3
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################

from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QSizePolicy, QWidget)

class Ui_Form(object):
def setupUi(self, Form):
if not Form.objectName():
Form.setObjectName(u"Form")
Form.resize(400, 300)

self.retranslateUi(Form)

QMetaObject.connectSlotsByName(Form)
# setupUi

def retranslateUi(self, Form):
Form.setWindowTitle(QCoreApplication.translate("Form", u"Form", None))
# retranslateUi

19 changes: 19 additions & 0 deletions tests/data/window.ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
</widget>
<resources/>
<connections/>
</ui>
73 changes: 73 additions & 0 deletions tests/test_check_ui_files_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import os
import shutil
import tempfile
from contextlib import contextmanager
from pathlib import Path

import git
import pytest
from pre_commit.main import main as pre_commit_main

DATA_DIR = Path(__file__).parent / "data"
ROOT_DIR = Path(__file__).parent.parent
HOOK_NAME = "check-ui-files"


@contextmanager
def working_directory(working_dir: Path):
current_dir = Path.cwd()
try:
os.chdir(working_dir)
yield
finally:
os.chdir(current_dir)


@pytest.fixture
def temp_repo():
with tempfile.TemporaryDirectory() as tmp_dir:
yield git.Repo.init(tmp_dir)


def test_install_succeeds(temp_repo: git.Repo):
repo_dir = Path(temp_repo.git_dir).parent

with working_directory(repo_dir):
result = pre_commit_main(["try-repo", str(ROOT_DIR), HOOK_NAME])

assert result == 0


def test_hook_errors_given_missing_py_file(temp_repo: git.Repo):
repo_dir = Path(temp_repo.git_dir).parent
shutil.copyfile(DATA_DIR / "window.ui", repo_dir / "window.ui")
temp_repo.git.add(str(repo_dir / "window.ui"))

with working_directory(repo_dir):
result = pre_commit_main(["try-repo", str(ROOT_DIR), HOOK_NAME])

assert result == 1


def test_hook_errors_given_py_file_out_of_date(temp_repo: git.Repo):
repo_dir = Path(temp_repo.git_dir).parent
shutil.copyfile(DATA_DIR / "window.ui", repo_dir / "window.ui")
(repo_dir / "ui_window.py").write_text("Not right content")
temp_repo.git.add(str(repo_dir / "window.ui"))

with working_directory(repo_dir):
result = pre_commit_main(["try-repo", str(ROOT_DIR), HOOK_NAME])

assert result == 1


def test_hook_successful_given_py_file_present(temp_repo: git.Repo):
repo_dir = Path(temp_repo.git_dir).parent
shutil.copyfile(DATA_DIR / "window.ui", repo_dir / "window.ui")
shutil.copyfile(DATA_DIR / "ui_window.py", repo_dir / "ui_window.py")
temp_repo.git.add(str(repo_dir / "window.ui"))

with working_directory(repo_dir):
result = pre_commit_main(["try-repo", str(ROOT_DIR), HOOK_NAME])

assert result == 0