Skip to content

Commit dfb81ed

Browse files
authored
Migrate QGIS plugin to Qt5/Qt6 dual compatibility (QGIS 4.0) (#249)
* Migrate QGIS plugin to Qt5/Qt6 dual compatibility (QGIS 4.0) Single codebase that loads on QGIS 3.28+ (PyQt5) and QGIS 4.0 (PyQt6). Plugin now appears on the QGIS 4 Ready list via qgisMaximumVersion=4.99. - Run upstream scripts/pyqt5_to_pyqt6 migrator to fully-qualify Qt enums (Qt.AlignmentFlag.AlignCenter, QMessageBox.StandardButton.Yes, etc.). Fully-qualified form works on both PyQt5 (>=5.15) and PyQt6. - Hand-qualify QGIS core enums the script cannot introspect: Qgis.Info -> Qgis.MessageLevel.Info, QgsWkbTypes.PointGeometry -> QgsWkbTypes.GeometryType.PointGeometry, etc. - metadata.txt: bump version 0.6.0 -> 0.7.0, add qgisMaximumVersion=4.99, changelog entry. (No supportsQt6 flag; that field was removed in QGIS 4.) - update_checker.py: add _require_https() guard before urlopen/urlretrieve; annotate the two call sites with # nosec B310 (defense-in-depth for hardcoded https GitHub constants). - qgis_plugin/tests/: add PyQt6 import-smoke tests (auto-discovers plugin package via metadata.txt) with a qgis.PyQt -> PyQt6 stub conftest. - .github/workflows/qgis-plugin.yml: new CI with Bandit (matches the plugins.qgis.org medium-severity gate) and PyQt6 import smoke matrix (Python 3.10-3.13 with the libEGL/libGL runtime libs PyQt6.QtGui dlopens). - .pre-commit-config.yaml: add Bandit hook scoped to qgis_plugin/. References: - https://github.com/qgis/QGIS/wiki/Plugin-migration-to-be-compatible-with-Qt5-and-Qt6 - https://plugins.qgis.org/docs/migrate-qgis4 * Address Copilot review feedback - conftest.py: gate PyQt6 imports behind pytest.importorskip so other pytest invocations (e.g. `pytest .` from the repo root) don't fail collection when PyQt6 is absent. - test_pyqt6_imports.py: explicitly insert PLUGIN_ROOT.parent on sys.path before importlib.import_module so the test does not depend on pytest's rootdir detection. Mirrors how QGIS adds plugin parent dirs to sys.path. - update_checker.py: move the two `# nosec B310` annotations from the closing-paren line onto the line containing `urlopen(` / `urlretrieve(`, where Bandit attaches the finding. Same suppression count (2) but more robust across Bandit versions. - qgis-plugin.yml: pin `pip install bandit==1.9.4` to match the pre-commit revision so local and CI runs use the same Bandit ruleset. * Rename qgis_plugin/tests -> qgis_plugin/qt6_tests to avoid pytest package collision The repo already has a top-level tests/ package (with __init__.py). Adding qgis_plugin/tests/__init__.py registered a second `tests` package with the same dotted name, so pytest's default prepend importmode collapsed both into one entry in sys.modules. The first one to be collected won, and every test in the other directory then failed with `ModuleNotFoundError: No module named 'tests.test_<x>'` -- breaking the existing ubuntu/macos/windows CI workflows that run `pytest .` from the repo root. Renaming to qt6_tests gives the smoke tests a unique top-level package name. The auto-discovery in test_pyqt6_imports.py is name-agnostic (it globs `*/metadata.txt`), so no test logic changes are needed; only the CI workflow path is updated.
1 parent 61ea3db commit dfb81ed

19 files changed

Lines changed: 396 additions & 148 deletions

.github/workflows/qgis-plugin.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: QGIS plugin
2+
3+
on:
4+
push:
5+
branches: ["**"]
6+
paths:
7+
- "qgis_plugin/**"
8+
- ".github/workflows/qgis-plugin.yml"
9+
- ".pre-commit-config.yaml"
10+
pull_request:
11+
branches: [main]
12+
paths:
13+
- "qgis_plugin/**"
14+
- ".github/workflows/qgis-plugin.yml"
15+
- ".pre-commit-config.yaml"
16+
17+
jobs:
18+
bandit:
19+
name: Bandit security scan
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v6
23+
- uses: actions/setup-python@v6
24+
with:
25+
python-version: "3.13"
26+
- name: Install Bandit
27+
run: pip install bandit==1.9.4
28+
- name: Run Bandit (matches plugins.qgis.org security gate)
29+
run: bandit -r qgis_plugin/hypercoast_qgis -ll
30+
31+
pyqt6-smoke:
32+
name: PyQt6 import smoke
33+
runs-on: ubuntu-latest
34+
strategy:
35+
fail-fast: false
36+
matrix:
37+
python-version: ["3.10", "3.11", "3.12", "3.13"]
38+
steps:
39+
- uses: actions/checkout@v6
40+
- uses: actions/setup-python@v6
41+
with:
42+
python-version: ${{ matrix.python-version }}
43+
- name: Install Qt6 runtime libs
44+
run: |
45+
sudo apt-get update
46+
sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3 libfontconfig1
47+
- name: Install PyQt6, pytest, and plugin runtime deps
48+
run: |
49+
python -m pip install --upgrade pip
50+
pip install PyQt6 PyQt6-QScintilla pytest numpy Pillow
51+
- name: Run import smoke tests
52+
run: pytest qgis_plugin/qt6_tests -v

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,10 @@ repos:
3636
rev: v6.2.0
3737
hooks:
3838
- id: reuse
39+
40+
- repo: https://github.com/PyCQA/bandit
41+
rev: 1.9.4
42+
hooks:
43+
- id: bandit
44+
args: ["-r", "qgis_plugin/hypercoast_qgis", "-ll"]
45+
pass_filenames: false

qgis_plugin/hypercoast_qgis/core/python_manager.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@
4141
}
4242

4343

44-
def _log(message, level=Qgis.Info):
44+
def _log(message, level=Qgis.MessageLevel.Info):
4545
"""Log a message to the QGIS message log.
4646
4747
Args:
4848
message: The message to log.
49-
level: The log level (Qgis.Info, Qgis.Warning, Qgis.Critical).
49+
level: The log level (Qgis.MessageLevel.Info, Qgis.MessageLevel.Warning, Qgis.MessageLevel.Critical).
5050
"""
5151
QgsMessageLog.logMessage(str(message), "HyperCoast", level=level)
5252

@@ -217,7 +217,7 @@ def download_python_standalone(progress_callback=None, cancel_check=None):
217217
)
218218
else:
219219
error_msg = f"Download failed: {error_msg}"
220-
_log(error_msg, Qgis.Critical)
220+
_log(error_msg, Qgis.MessageLevel.Critical)
221221
return False, error_msg
222222

223223
if cancel_check and cancel_check():
@@ -258,7 +258,7 @@ def download_python_standalone(progress_callback=None, cancel_check=None):
258258
if success:
259259
if progress_callback:
260260
progress_callback(100, f"Python {python_version} installed")
261-
_log("Python standalone installed successfully", Qgis.Success)
261+
_log("Python standalone installed successfully", Qgis.MessageLevel.Success)
262262
return True, f"Python {python_version} installed successfully"
263263
else:
264264
return False, f"Verification failed: {verify_msg}"
@@ -267,7 +267,7 @@ def download_python_standalone(progress_callback=None, cancel_check=None):
267267
return False, "Download cancelled"
268268
except Exception as e:
269269
error_msg = f"Installation failed: {str(e)}"
270-
_log(error_msg, Qgis.Critical)
270+
_log(error_msg, Qgis.MessageLevel.Critical)
271271

272272
if sys.platform == "win32":
273273
error_lower = str(e).lower()
@@ -339,14 +339,20 @@ def verify_standalone_python():
339339
if not version_output.startswith(
340340
f"{sys.version_info.major}.{sys.version_info.minor}"
341341
):
342-
_log(f"Python version mismatch: got {version_output}", Qgis.Warning)
342+
_log(
343+
f"Python version mismatch: got {version_output}",
344+
Qgis.MessageLevel.Warning,
345+
)
343346
return False, f"Version mismatch: {version_output}"
344347

345-
_log(f"Verified Python standalone: {version_output}", Qgis.Success)
348+
_log(
349+
f"Verified Python standalone: {version_output}",
350+
Qgis.MessageLevel.Success,
351+
)
346352
return True, f"Python {version_output} verified"
347353
else:
348354
error = result.stderr or "Unknown error"
349-
_log(f"Python verification failed: {error}", Qgis.Warning)
355+
_log(f"Python verification failed: {error}", Qgis.MessageLevel.Warning)
350356
return False, f"Verification failed: {error[:100]}"
351357

352358
except subprocess.TimeoutExpired:
@@ -366,9 +372,9 @@ def remove_standalone_python():
366372

367373
try:
368374
shutil.rmtree(STANDALONE_DIR)
369-
_log("Removed standalone Python installation", Qgis.Success)
375+
_log("Removed standalone Python installation", Qgis.MessageLevel.Success)
370376
return True, "Standalone Python removed"
371377
except Exception as e:
372378
error_msg = f"Failed to remove: {str(e)}"
373-
_log(error_msg, Qgis.Warning)
379+
_log(error_msg, Qgis.MessageLevel.Warning)
374380
return False, error_msg

qgis_plugin/hypercoast_qgis/core/uv_manager.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@
3232
UV_VERSION = "0.10.6"
3333

3434

35-
def _log(message, level=Qgis.Info):
35+
def _log(message, level=Qgis.MessageLevel.Info):
3636
"""Log a message to the QGIS message log.
3737
3838
Args:
3939
message: The message to log.
40-
level: The log level (Qgis.Info, Qgis.Warning, Qgis.Critical).
40+
level: The log level (Qgis.MessageLevel.Info, Qgis.MessageLevel.Warning, Qgis.MessageLevel.Critical).
4141
"""
4242
QgsMessageLog.logMessage(str(message), "HyperCoast", level=level)
4343

@@ -142,7 +142,7 @@ def download_uv(progress_callback=None, cancel_check=None):
142142
)
143143
else:
144144
error_msg = f"Download failed: {error_msg}"
145-
_log(error_msg, Qgis.Critical)
145+
_log(error_msg, Qgis.MessageLevel.Critical)
146146
return False, error_msg
147147

148148
if cancel_check and cancel_check():
@@ -213,7 +213,7 @@ def download_uv(progress_callback=None, cancel_check=None):
213213
if success:
214214
if progress_callback:
215215
progress_callback(100, f"uv {UV_VERSION} installed")
216-
_log("uv installed successfully", Qgis.Success)
216+
_log("uv installed successfully", Qgis.MessageLevel.Success)
217217
return True, f"uv {UV_VERSION} installed successfully"
218218
else:
219219
return False, f"Verification failed: {verify_msg}"
@@ -222,7 +222,7 @@ def download_uv(progress_callback=None, cancel_check=None):
222222
return False, "Download cancelled"
223223
except Exception as e:
224224
error_msg = f"uv installation failed: {str(e)}"
225-
_log(error_msg, Qgis.Critical)
225+
_log(error_msg, Qgis.MessageLevel.Critical)
226226
return False, error_msg
227227
finally:
228228
if os.path.exists(temp_path):
@@ -279,11 +279,11 @@ def verify_uv():
279279

280280
if result.returncode == 0:
281281
version_output = result.stdout.strip()
282-
_log(f"Verified uv: {version_output}", Qgis.Success)
282+
_log(f"Verified uv: {version_output}", Qgis.MessageLevel.Success)
283283
return True, version_output
284284
else:
285285
error = result.stderr or "Unknown error"
286-
_log(f"uv verification failed: {error}", Qgis.Warning)
286+
_log(f"uv verification failed: {error}", Qgis.MessageLevel.Warning)
287287
return False, f"Verification failed: {error[:100]}"
288288

289289
except subprocess.TimeoutExpired:
@@ -303,9 +303,9 @@ def remove_uv():
303303

304304
try:
305305
shutil.rmtree(UV_DIR)
306-
_log("Removed uv installation", Qgis.Success)
306+
_log("Removed uv installation", Qgis.MessageLevel.Success)
307307
return True, "uv removed"
308308
except Exception as e:
309309
error_msg = f"Failed to remove uv: {str(e)}"
310-
_log(error_msg, Qgis.Warning)
310+
_log(error_msg, Qgis.MessageLevel.Warning)
311311
return False, error_msg

0 commit comments

Comments
 (0)