diff --git a/.github/workflows/static-analysis-and-test.yml b/.github/workflows/static-analysis-and-test.yml index 7bd845f..b07d142 100644 --- a/.github/workflows/static-analysis-and-test.yml +++ b/.github/workflows/static-analysis-and-test.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.11" - name: Install dependencies run: | @@ -35,6 +35,9 @@ jobs: - name: Format with black run: tox -e black + - name: Check Qt Enums + run: tox -e qt-enum + test: # We want to run on external PRs, but not on our own internal PRs as they'll @@ -46,13 +49,18 @@ jobs: strategy: matrix: - os: ['ubuntu-latest', 'windows-latest'] - python: ['3.8', '3.9', '3.10', '3.11'] - # Works around the depreciation of python 3.7 for ubuntu - # https://github.com/actions/setup-python/issues/544 - include: - - os: 'ubuntu-22.04' - python: '3.7' + os: ['ubuntu-22.04', 'windows-latest'] + test_env: [ + 'py39-PyQt5', + 'py39-PySide5', + 'py310-PyQt5', + 'py310-PySide5', + 'py311-PyQt5', + 'py311-PyQt6.7', + 'py311-PyQt6.9', + 'py311-PySide6.7', + 'py311-PySide6.9', + ] runs-on: ${{ matrix.os }} @@ -60,24 +68,55 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Python + # Pulled from https://github.com/mottosso/Qt.py/blob/master/.github/actions/setup-tox/action.yml + - name: Install EGL mesa + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update -y -qq + sudo apt-get install -y -qq libegl1-mesa libegl1-mesa-dev libgl1-mesa-glx libgl1-mesa-dev + + - name: Install GUI libs + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get install -y -qq libxcb-xinerama0 + sudo apt-get install -y -qq libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xfixes0 libxcb-cursor0 + + # Note: The last python to get setup becomes the default for future python calls + - name: Setup Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Setup Python 3.10 uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python }} + python-version: '3.10' - name: Install dependencies + shell: bash run: | + python --version python -m pip install --upgrade pip python -m pip install tox - name: Run Tox + # Note: `--skip-missing-interpreters` prevents false success if python + # version is not installed when testing. run: | - tox -e begin,py + which tox + tox --skip-missing-interpreters false -e begin,${{ matrix.test_env }} - name: Upload coverage uses: actions/upload-artifact@v4 with: - name: coverage-${{ matrix.os }}-${{ matrix.python }} + name: coverage-${{ matrix.os }}-${{ matrix.test_env }} path: .coverage/* include-hidden-files: true retention-days: 1 diff --git a/hab_gui/actions/edit_custom_variables_action.py b/hab_gui/actions/edit_custom_variables_action.py index 585a2eb..5c7d95e 100644 --- a/hab_gui/actions/edit_custom_variables_action.py +++ b/hab_gui/actions/edit_custom_variables_action.py @@ -29,7 +29,7 @@ def __init__(self, settings, parent=None): def edit_custom_variables(self): dlg = CustomVariableEditor.create_dialog(self.settings, parent=self.parent()) - dlg.exec_() + utils.exec_obj(dlg) # Ensure the hab_gui respects any changes the user may have made self.settings.root_widget.refresh_cache() diff --git a/hab_gui/actions/verbosity_action.py b/hab_gui/actions/verbosity_action.py index 0581ec8..e5acb37 100644 --- a/hab_gui/actions/verbosity_action.py +++ b/hab_gui/actions/verbosity_action.py @@ -1,4 +1,4 @@ -from Qt import QtCore, QtWidgets +from Qt import QtWidgets class VerbosityAction(QtWidgets.QAction): @@ -48,10 +48,7 @@ def __init__(self, settings, parent=None): action = menu.addAction(key) action.setData(value) action.setCheckable(True) - if verbosity == value: - action.setChecked(QtCore.Qt.Checked) - else: - action.setChecked(QtCore.Qt.Unchecked) + action.setChecked(verbosity == value) self.setMenu(menu) def load_config(self): @@ -71,7 +68,4 @@ def refresh(self): verbosity = self.settings.verbosity for action in self.menu().actions(): value = action.data() - if verbosity == value: - action.setChecked(QtCore.Qt.Checked) - else: - action.setChecked(QtCore.Qt.Unchecked) + action.setChecked(verbosity == value) diff --git a/hab_gui/cli.py b/hab_gui/cli.py index 740877c..af2e1a2 100644 --- a/hab_gui/cli.py +++ b/hab_gui/cli.py @@ -170,7 +170,7 @@ def launch(settings, verbosity, uri, alias, args): if splash: splash.finish(window) - app.exec_() + utils.exec_obj(app) @gui.command() diff --git a/hab_gui/dialogs/error_message_box.py b/hab_gui/dialogs/error_message_box.py index 8a8de38..6d0bc9d 100644 --- a/hab_gui/dialogs/error_message_box.py +++ b/hab_gui/dialogs/error_message_box.py @@ -24,20 +24,20 @@ def __init__(self, etype, value, tb, parent=None): self.stack_limit = -5 self.setWindowTitle("Exception") - self.setTextFormat(QtCore.Qt.RichText) - self.setStandardButtons(QtWidgets.QMessageBox.Ok) + self.setTextFormat(QtCore.Qt.TextFormat.RichText) + self.setStandardButtons(QtWidgets.QMessageBox.StandardButton.Ok) # Using detailedText seems to disable the close button, enable it - self.setEscapeButton(QtWidgets.QMessageBox.Ok) + self.setEscapeButton(QtWidgets.QMessageBox.StandardButton.Ok) # Create a button allowing the user to copy the non-highlighted text - copy_btn = self.addButton("Copy", QtWidgets.QMessageBox.ActionRole) + copy_btn = self.addButton("Copy", QtWidgets.QMessageBox.ButtonRole.ActionRole) copy_btn.setToolTip("Copy the full traceback for error reporting.") # Disconnect the QMessageBox signals that would cause the box to close # when this button is pressed and add our own signal connection copy_btn.disconnect() copy_btn.released.connect(self.copy_traceback) - self.setDefaultButton(QtWidgets.QMessageBox.Ok) + self.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Ok) self.refresh() @QtCore.Slot() diff --git a/hab_gui/dialogs/uri_picker_dialog.py b/hab_gui/dialogs/uri_picker_dialog.py index da86057..e4fab5f 100644 --- a/hab_gui/dialogs/uri_picker_dialog.py +++ b/hab_gui/dialogs/uri_picker_dialog.py @@ -68,9 +68,11 @@ def init_gui(self): ) self.uiButtonsBOX = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Cancel, self + QtWidgets.QDialogButtonBox.StandardButton.Cancel, self + ) + self.uiButtonsBOX.addButton( + "Launch", QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole ) - self.uiButtonsBOX.addButton("Launch", QtWidgets.QDialogButtonBox.AcceptRole) self.uiButtonsBOX.accepted.connect(self.accept) self.uiButtonsBOX.rejected.connect(self.reject) @@ -151,7 +153,7 @@ def should_show(cls, settings, alias=None): # Note: Not using `keyboardModifiers` because it is not updated when # calling this from the cli module. modifiers = QtWidgets.QApplication.queryKeyboardModifiers() - if modifiers == QtCore.Qt.ShiftModifier: + if modifiers == QtCore.Qt.KeyboardModifier.ShiftModifier: return True # always_ask is checked for this alias diff --git a/hab_gui/entry_points/message_box.py b/hab_gui/entry_points/message_box.py index 9cfa37b..93baefc 100644 --- a/hab_gui/entry_points/message_box.py +++ b/hab_gui/entry_points/message_box.py @@ -1,5 +1,6 @@ import logging +from .. import utils from .logging_exception import LoggingExceptionInit logger = logging.getLogger(__name__) @@ -17,4 +18,4 @@ def excepthook(self, cls, exception, tb): from ..dialogs.error_message_box import ErrorMessageBox box = ErrorMessageBox(cls, exception, tb, parent=None) - box.exec_() + utils.exec_obj(box) diff --git a/hab_gui/utils.py b/hab_gui/utils.py index cc37abc..6c3cea4 100644 --- a/hab_gui/utils.py +++ b/hab_gui/utils.py @@ -11,7 +11,7 @@ @contextmanager -def cursor_override(cursor=QtCore.Qt.BusyCursor): +def cursor_override(cursor=QtCore.Qt.CursorShape.BusyCursor): """Change the application cursor to wait while running the context/decorator. Ensures that the cursor is restored even if an exception is raised. """ @@ -220,3 +220,14 @@ def block_signals(objs): finally: for o, b in blocked: o.blockSignals(b) + + +def exec_obj(obj, *args, **kwargs): + """Work around the removal of `exec_` from Qt6(especially PyQt6). + + This calls the `obj.exec` method if it exists, and falls back to `obj.exec_` + otherwise. + """ + if hasattr(obj, "exec"): + return obj.exec(*args, **kwargs) + return obj.exec_(*args, **kwargs) diff --git a/hab_gui/widgets/alias_button.py b/hab_gui/widgets/alias_button.py index 9028c98..47274c1 100644 --- a/hab_gui/widgets/alias_button.py +++ b/hab_gui/widgets/alias_button.py @@ -23,8 +23,9 @@ def __init__(self, cfg, alias_name, parent=None): self.alias_name = alias_name self.alias_dict = self.cfg.aliases - qsize_policy = QtWidgets.QSizePolicy - size_policy = qsize_policy(qsize_policy.Minimum, qsize_policy.Preferred) + size_policy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Preferred + ) self.setSizePolicy(size_policy) self.clicked.connect(self._button_action) self.refresh() diff --git a/hab_gui/widgets/alias_icon_button.py b/hab_gui/widgets/alias_icon_button.py index 190d1be..557e95f 100644 --- a/hab_gui/widgets/alias_icon_button.py +++ b/hab_gui/widgets/alias_icon_button.py @@ -22,7 +22,7 @@ class AliasIconButton(AliasButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + self.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon) def refresh(self): alias = self.alias_dict[self.alias_name] diff --git a/hab_gui/widgets/custom_variable_editor/custom_variable_editor.py b/hab_gui/widgets/custom_variable_editor/custom_variable_editor.py index 08b1b75..5b1ee7f 100644 --- a/hab_gui/widgets/custom_variable_editor/custom_variable_editor.py +++ b/hab_gui/widgets/custom_variable_editor/custom_variable_editor.py @@ -70,7 +70,7 @@ def editing_finished(self, top_left, bottom_right, roles): if self._is_refreshing: return - if QtCore.Qt.EditRole in roles: + if QtCore.Qt.ItemDataRole.EditRole in roles: item = self.uiVariableTREE.itemFromIndex(top_left) column = top_left.column() if column == 0: diff --git a/hab_gui/widgets/custom_variable_editor/file_tree_widget_item.py b/hab_gui/widgets/custom_variable_editor/file_tree_widget_item.py index a91f309..e5b9c1b 100644 --- a/hab_gui/widgets/custom_variable_editor/file_tree_widget_item.py +++ b/hab_gui/widgets/custom_variable_editor/file_tree_widget_item.py @@ -18,7 +18,7 @@ def __init__(self, parent, parser): # Add a child item that shows the filename. It should not be editable. self.filename_item = QtWidgets.QTreeWidgetItem(self) - self.filename_item.setFlags(QtCore.Qt.NoItemFlags) + self.filename_item.setFlags(QtCore.Qt.ItemFlag.NoItemFlags) self.refresh() diff --git a/hab_gui/widgets/custom_variable_editor/variable_tree_widget_item.py b/hab_gui/widgets/custom_variable_editor/variable_tree_widget_item.py index b78b97e..0b11403 100644 --- a/hab_gui/widgets/custom_variable_editor/variable_tree_widget_item.py +++ b/hab_gui/widgets/custom_variable_editor/variable_tree_widget_item.py @@ -8,7 +8,7 @@ def __init__(self, parent, variable_name): super().__init__(parent) self.parser = parent.parser self._variable_name = variable_name - self.setFlags(self.flags() | QtCore.Qt.ItemIsEditable) + self.setFlags(self.flags() | QtCore.Qt.ItemFlag.ItemIsEditable) self.refresh() def remove_variable(self): diff --git a/hab_gui/widgets/menu_button.py b/hab_gui/widgets/menu_button.py index 00f6fe0..8571636 100644 --- a/hab_gui/widgets/menu_button.py +++ b/hab_gui/widgets/menu_button.py @@ -29,7 +29,7 @@ def __init__(self, settings, parent=None): self.setText("Menu") self.setIcon(utils.Paths.icon("menu.svg")) - self.setPopupMode(self.InstantPopup) + self.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.refresh() @property diff --git a/hab_gui/widgets/name_picker.py b/hab_gui/widgets/name_picker.py index aacbe66..04eaed8 100644 --- a/hab_gui/widgets/name_picker.py +++ b/hab_gui/widgets/name_picker.py @@ -46,7 +46,7 @@ def names(self): ret = {} for index in range(self.name_tree.topLevelItemCount()): item = self.name_tree.topLevelItem(index) - checked = item.checkState(0) == QtCore.Qt.Checked + checked = item.checkState(0) == QtCore.Qt.CheckState.Checked ret[item.text(0)] = [item.text(1), checked] return ret @@ -61,7 +61,7 @@ def set_names(self, names, uri=None): # Build the `default_selection` set for the current URI if len(settings) > 1 and settings[1]: self.default_selection.add(name) - item.setCheckState(0, QtCore.Qt.Unchecked) + item.setCheckState(0, QtCore.Qt.CheckState.Unchecked) self.name_tree.resizeColumnToContents(0) user_selection = self.user_selection(uri) @@ -84,7 +84,7 @@ def selected(self): ret = set() for index in range(self.name_tree.topLevelItemCount()): item = self.name_tree.topLevelItem(index) - if item.checkState(0) == QtCore.Qt.Checked: + if item.checkState(0) == QtCore.Qt.CheckState.Checked: ret.add(item.text(0)) return ret @@ -95,7 +95,10 @@ def set_selected(self, selected): item = self.name_tree.topLevelItem(index) name = item.text(0) item.setCheckState( - 0, QtCore.Qt.Checked if name in selected else QtCore.Qt.Unchecked + 0, + QtCore.Qt.CheckState.Checked + if name in selected + else QtCore.Qt.CheckState.Unchecked, ) def sizeHint(self): # noqa: N802 diff --git a/hab_gui/widgets/pinned_uris_button.py b/hab_gui/widgets/pinned_uris_button.py index 5a3869a..e08975c 100644 --- a/hab_gui/widgets/pinned_uris_button.py +++ b/hab_gui/widgets/pinned_uris_button.py @@ -31,7 +31,7 @@ def __init__(self, settings, uri_widget, parent=None): self.setToolTip("Select and manage quick access to commonly used URI's.") self.setText(self._text_main) self.setIcon(utils.Paths.icon("pin-outline.svg")) - self.setPopupMode(self.InstantPopup) + self.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup) self.refresh() def add_uri(self, uri): diff --git a/hab_gui/windows/alias_launch_window.py b/hab_gui/windows/alias_launch_window.py index 1ecb84a..9c6fb6e 100644 --- a/hab_gui/windows/alias_launch_window.py +++ b/hab_gui/windows/alias_launch_window.py @@ -1,4 +1,5 @@ import logging +import math from functools import partial import hab @@ -62,7 +63,7 @@ def __init__( refresh_time = refresh_time[0] if refresh_time: self.refresh_timer.timeout.connect(partial(self.refresh_cache, False)) - refresh_time = utils.interval(refresh_time) + refresh_time = math.ceil(utils.interval(refresh_time)) logger.debug(f"Setting auto-refresh interval to {refresh_time} seconds") self.refresh_timer.start(refresh_time * 1000) @@ -86,7 +87,10 @@ def apply_layout(self): self.layout.addWidget(self.footer_widget, 2, 0, 1, -1) else: self.spacer_item = QtWidgets.QSpacerItem( - 0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding + 0, + 0, + QtWidgets.QSizePolicy.Policy.Minimum, + QtWidgets.QSizePolicy.Policy.Expanding, ) self.layout.addItem(self.spacer_item, self.layout.rowCount(), 0, 1, -1) @@ -227,4 +231,4 @@ def main(): app = QtWidgets.QApplication([]) window = AliasLaunchWindow(hab.Resolver(target="hab-gui"), verbosity=1) window.show() - app.exec_() + utils.exec_obj(app) diff --git a/pyproject.toml b/pyproject.toml index 60068ac..1ffbfb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,13 +24,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", ] requires-python = ">=3.7" -dependencies = [ - "click>=7.1.2", - "hab>=0.41.0", - "Pygments", - "Qt.py", - ] -dynamic = ["version"] +dynamic = ["dependencies", "optional-dependencies", "version"] [project.readme] file = "README.md" @@ -41,26 +35,6 @@ Homepage = "https://github.com/blurstudio/hab-gui" Source = "https://github.com/blurstudio/hab-gui" Tracker = "https://github.com/blurstudio/hab-gui/issues" -[project.optional-dependencies] -dev = [ - "black==22.12.0", - "build", - "covdefaults", - "coverage", - "flake8==5.0.4", - "flake8-bugbear==22.12.6", - "Flake8-pyproject", - "isort", - "json5", - "pep8-naming==0.13.3", - "pytest", - "tox", - "build", -] -json5 = [ - "pyjson5" -] - [project.gui-scripts] habw = "hab.cli:cli" @@ -69,6 +43,13 @@ include-package-data = true platforms = ["any"] license-files = ["LICENSE"] +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} + +[tool.setuptools.dynamic.optional-dependencies] +dev = {file = ["requirements-dev.txt"]} +json5 = {file = ["requirements-json5.txt"]} + [tool.setuptools.packages.find] exclude = ["tests"] namespaces = false diff --git a/requirements-dev.txt b/requirements-dev.txt index d7780b8..07b0529 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,12 @@ -black +black==22.12.0 build covdefaults coverage -flake8 -flake8-bugbear +flake8==5.0.4 +flake8-bugbear==22.12.6 Flake8-pyproject -pep8-naming +isort +json5 +pep8-naming==0.13.3 pytest tox diff --git a/requirements-json5.txt b/requirements-json5.txt new file mode 100644 index 0000000..5aef6f2 --- /dev/null +++ b/requirements-json5.txt @@ -0,0 +1 @@ +pyjson5 diff --git a/tests/test_echo_message.py b/tests/test_echo_message.py deleted file mode 100644 index fb56aa4..0000000 --- a/tests/test_echo_message.py +++ /dev/null @@ -1,3 +0,0 @@ -# TODO: Impliment some tests -def test_echo_default_message(): - assert 0 == 0 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..e487510 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,22 @@ +import hab_gui.utils + + +def test_exec_obj(): + class ExecBoth: + def exec_(self): + return "exec_" + + def exec(self): + return "exec" + + class Exec_: + def exec_(self): + return "exec_" + + class Exec: + def exec(self): + return "exec" + + assert hab_gui.utils.exec_obj(ExecBoth()) == "exec" + assert hab_gui.utils.exec_obj(Exec_()) == "exec_" + assert hab_gui.utils.exec_obj(Exec()) == "exec" diff --git a/tox.ini b/tox.ini index 194b100..55989d1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,41 @@ [tox] -envlist = begin,py{37,38,39,310,311},end,black,flake8 +envlist = + begin + py{39,310}-{PySide,PyQt}5 + py311-PySide{6.7,6.9} + py311-{PyQt}{5,6.7,6.9} + end + black + flake8 + qt-enum skip_missing_interpreters = True [testenv] changedir = {toxinidir} +skip_install = True package = editable -deps = - -rrequirements.txt - covdefaults - coverage - pytest commands = coverage run -m pytest {tty:--color=yes} {posargs:tests/} +[testenv:qt-enum] +# Check for any enum regressions and fail if found. +basepython = python3 +deps = + Qt.py>=1.4.7 + PySide2 +commands = + python {envsitepackagesdir}/Qt_convert_enum.py . --check + +[testenv:qt-enum-fix] +# Use this to automatically fix issues detected by the other qt-enum toxenv. +# This simply adds the --write argument to its check. +basepython = python3 +deps = + Qt.py>=1.4.7 + PySide2 +commands = + python {envsitepackagesdir}/Qt_convert_enum.py . --check --write + [testenv:begin] basepython = python3 deps = @@ -21,14 +44,33 @@ deps = commands = coverage erase -[testenv:py{37,38,39,310,311}] +[testenv:py{39,310,311}-{PySide,PyQt}{5,6.7,6.9}] depends = begin +skip_install = False +setenv = + # These are required for py37 to prevent errors caused by newer versions + py37: VIRTUALENV_PIP==24.0 + py37: VIRTUALENV_SETUPTOOLS==44.1.1 + py37: VIRTUALENV_WHEEL==0.42.0 +deps = + -rrequirements.txt + covdefaults + coverage + pytest + # Note: PyQt 6.5 and 6.6 have breaking issues on both windows and linux + PyQt5: PyQt5==5.15.* + PyQt6.7: PyQt6==6.7.* + PyQt6.9: PyQt6==6.9.* + + PySide5: PySide2==5.15.* + PySide6.7: PySide6==6.7.* + PySide6.9: PySide6==6.9.* [testenv:end] basepython = python3 depends = begin - py{37,38,39,310,311} + py{39,310,311}-{PySide,PyQt}{5,6.7,6.9} parallel_show_output = True deps = coverage