Skip to content

Commit

Permalink
Merge pull request #1 from ukaea/github-migration
Browse files Browse the repository at this point in the history
Migrate hook to GitHub
  • Loading branch information
DanShort12 authored Oct 18, 2023
2 parents 3da8e95 + 4bec026 commit 969ec32
Show file tree
Hide file tree
Showing 9 changed files with 916 additions and 1 deletion.
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.10']
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 date with '{ui_file}'",
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

0 comments on commit 969ec32

Please sign in to comment.