From 006cc8a784f98b82965f8e4fcc79cbb985df016a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 06:45:37 +0000 Subject: [PATCH 1/4] Add QGIS 4.x (Qt6) support while keeping QGIS 3.22+ compatibility QGIS 4.0 is Qt6/PyQt6-based, and the qgis.PyQt compatibility layer no longer allows unscoped Qt enum access or removed Qt5 APIs. Update the plugin so a single codebase runs on QGIS 3.22 through 4.x: - Use fully scoped Qt enums everywhere (e.g. Qt.ItemDataRole.UserRole, Qt.PenStyle.NoPen, QPalette.ColorRole.Base), which work identically on PyQt5 and PyQt6. QGIS API enums are unchanged, as the QGIS 4 bindings still support unscoped access for them. - Replace Qt5-only APIs: QFontMetrics.width() -> horizontalAdvance(), dialog exec_() -> exec(), and import QSvgWidget from QtSvgWidgets on Qt6 builds (with fallback for QGIS 4 releases that don't wrap it). - Replace the one direct PyQt5 import with qgis.PyQt. - metadata.txt: add qgisMaximumVersion=4.99 so the plugin is marked as QGIS 4 ready, and bump version to 1.1.0. - Make the heatmap FSL conversion test accept Qt6's slightly different gradient colour interpolation rounding. - Test harness fixes for newer QGIS/Python: create the QgsApplication before test discovery (widgets created at import time crash on Qt6 without an application), pass an explicit top level directory to unittest discovery, and add a run_tests_and_exit() entry point that reports failures through the exit code without running interpreter teardown (exiting a headless QgsApplication can crash on Qt6). - CI: test against QGIS 3.22, 3.34, 3.40 LTR, 3.44 LTR and 4.0 docker images, running the suite headless with QT_QPA_PLATFORM=offscreen (the qgis_testrunner.sh harness no longer executes scripts on the newer images). Verified: full test suite passes in qgis/qgis:release-3_22, ltr (3.44) and 4.0 (4.0.3, Qt6) docker images; flake8/pycodestyle/pylint clean. https://claude.ai/code/session_01LCTq6nNRJEWyBGQ1sjipHw --- .github/workflows/test_plugin.yaml | 19 ++-- CHANGELOG.md | 5 + felt/core/api_client.py | 2 +- felt/core/auth.py | 5 +- felt/core/fsl_converter.py | 43 ++++---- felt/core/logger.py | 8 +- felt/core/map.py | 2 +- felt/core/map_uploader.py | 27 +++-- felt/core/multi_step_feedback.py | 3 +- felt/core/recent_projects_model.py | 34 +++--- felt/core/thumbnail_manager.py | 8 +- felt/core/workspaces_model.py | 18 +-- felt/gui/authorization_manager.py | 6 +- felt/gui/authorize_dialog.py | 3 +- felt/gui/colored_progress_bar.py | 14 ++- felt/gui/create_map_dialog.py | 97 +++++++++------- felt/gui/felt_dialog_header.py | 16 ++- felt/gui/gui_utils.py | 2 +- felt/gui/recent_maps_list_view.py | 43 ++++---- felt/metadata.txt | 3 +- felt/test/qgis_interface.py | 2 +- felt/test/test_api_client.py | 24 ++-- felt/test/test_fsl_conversion.py | 170 ++++++++++++----------------- felt/test_suite.py | 41 ++++++- requirements/testing.txt | 1 + 25 files changed, 322 insertions(+), 274 deletions(-) diff --git a/.github/workflows/test_plugin.yaml b/.github/workflows/test_plugin.yaml index 9756923..7e21679 100644 --- a/.github/workflows/test_plugin.yaml +++ b/.github/workflows/test_plugin.yaml @@ -11,8 +11,6 @@ on: env: # plugin name/directory where the code for the plugin is stored PLUGIN_NAME: felt - # python notation to test running inside plugin - TESTS_RUN_FUNCTION: felt.test_suite.test_package # Docker settings DOCKER_IMAGE: qgis/qgis @@ -24,26 +22,25 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - docker_tags: [release-3_22, release-3_28, release-3_34, release-3_36] + # oldest supported version, recent LTRs, and latest QGIS 4 (Qt6) + docker_tags: [release-3_22, release-3_34, "3.40", "3.44", "4.0"] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Docker pull and create qgis-testing-environment run: | docker pull "$DOCKER_IMAGE":${{ matrix.docker_tags }} - docker run -d --name qgis-testing-environment -v "$GITHUB_WORKSPACE":/tests_directory -e DISPLAY=:99 "$DOCKER_IMAGE":${{ matrix.docker_tags }} + docker run -d --name qgis-testing-environment -v "$GITHUB_WORKSPACE":/tests_directory -e QT_QPA_PLATFORM=offscreen "$DOCKER_IMAGE":${{ matrix.docker_tags }} - - name: Docker set up QGIS + - name: Docker install test requirements run: | - docker exec qgis-testing-environment sh -c "qgis_setup.sh $PLUGIN_NAME" - docker exec qgis-testing-environment sh -c "rm -f /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" - docker exec qgis-testing-environment sh -c "ln -s /tests_directory/$PLUGIN_NAME /root/.local/share/QGIS/QGIS3/profiles/default/python/plugins/$PLUGIN_NAME" - docker exec qgis-testing-environment sh -c "pip3 install -r /tests_directory/requirements/testing.txt" + docker exec qgis-testing-environment sh -c "pip3 install --break-system-packages -r /tests_directory/requirements/testing.txt || pip3 install -r /tests_directory/requirements/testing.txt" - name: Docker run plugin tests run: | - docker exec qgis-testing-environment sh -c "qgis_testrunner.sh $TESTS_RUN_FUNCTION" + docker exec qgis-testing-environment sh -c "cd /tests_directory && python3 -c \"from felt.test_suite import run_tests_and_exit; run_tests_and_exit()\"" diff --git a/CHANGELOG.md b/CHANGELOG.md index 231bb7b..6975224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +## [1.1.0] - 2026-06-12 + +- Add support for QGIS 4.x (Qt6-based) releases, while remaining + compatible with QGIS 3.22 and later + ## [1.0.0] - 2023-06-21 - Initial release diff --git a/felt/core/api_client.py b/felt/core/api_client.py index 5418fb8..79a6642 100644 --- a/felt/core/api_client.py +++ b/felt/core/api_client.py @@ -517,7 +517,7 @@ def create_layer_groups(self, json.dumps(group_post_data).encode() ) - if reply.error() == QNetworkReply.ContentAccessDenied: + if reply.error() == QNetworkReply.NetworkError.ContentAccessDenied: raise PaidPlanRequiredError("Upload requires a paid plan") return [ diff --git a/felt/core/auth.py b/felt/core/auth.py index f2d5f8b..aa368f6 100644 --- a/felt/core/auth.py +++ b/felt/core/auth.py @@ -93,8 +93,9 @@ def do_GET(self): token_body = urllib.parse.urlencode(body).encode() network_request = QNetworkRequest(QUrl(TOKEN_URL)) - network_request.setHeader(QNetworkRequest.ContentTypeHeader, - 'application/x-www-form-urlencoded') + network_request.setHeader( + QNetworkRequest.KnownHeaders.ContentTypeHeader, + 'application/x-www-form-urlencoded') result_code = request.post(network_request, data=token_body, diff --git a/felt/core/fsl_converter.py b/felt/core/fsl_converter.py index cfec26b..ca790d4 100644 --- a/felt/core/fsl_converter.py +++ b/felt/core/fsl_converter.py @@ -1004,9 +1004,9 @@ def convert_cap_style(style: Qt.PenCapStyle) -> str: Convert a Qt cap style to FSL """ return { - Qt.RoundCap: 'round', - Qt.SquareCap: 'square', - Qt.FlatCap: 'butt', + Qt.PenCapStyle.RoundCap: 'round', + Qt.PenCapStyle.SquareCap: 'square', + Qt.PenCapStyle.FlatCap: 'butt', }[style] @staticmethod @@ -1015,10 +1015,10 @@ def convert_join_style(style: Qt.PenJoinStyle) -> str: Convert a Qt join style to FSL """ return { - Qt.RoundJoin: 'round', - Qt.BevelJoin: 'bevel', - Qt.MiterJoin: 'miter', - Qt.SvgMiterJoin: 'miter', + Qt.PenJoinStyle.RoundJoin: 'round', + Qt.PenJoinStyle.BevelJoin: 'bevel', + Qt.PenJoinStyle.MiterJoin: 'miter', + Qt.PenJoinStyle.SvgMiterJoin: 'miter', }[style] @staticmethod @@ -1027,12 +1027,12 @@ def convert_pen_style(style: Qt.PenStyle) -> List[float]: Converts a Qt pen style to an array of dash/space lengths """ return { - Qt.NoPen: [], - Qt.SolidLine: [], - Qt.DashLine: [2.5, 2], - Qt.DotLine: [0.5, 1.3], - Qt.DashDotLine: [0.5, 1.3, 2.5, 1.3], - Qt.DashDotDotLine: [0.5, 1.3, 0.5, 1.3, 2.5, 1.3] + Qt.PenStyle.NoPen: [], + Qt.PenStyle.SolidLine: [], + Qt.PenStyle.DashLine: [2.5, 2], + Qt.PenStyle.DotLine: [0.5, 1.3], + Qt.PenStyle.DashDotLine: [0.5, 1.3, 2.5, 1.3], + Qt.PenStyle.DashDotDotLine: [0.5, 1.3, 0.5, 1.3, 2.5, 1.3] }[style] @staticmethod @@ -1043,7 +1043,7 @@ def simple_line_to_fsl( """ Converts a QGIS simple line symbol layer to FSL """ - if (layer.penStyle() == Qt.NoPen or + if (layer.penStyle() == Qt.PenStyle.NoPen or not layer.color().isValid() or layer.color().alphaF() == 0): return [] @@ -1070,7 +1070,7 @@ def simple_line_to_fsl( part, layer.customDashPatternUnit(), context, round_size=False) for part in layer.customDashVector()] - elif layer.penStyle() != Qt.SolidLine: + elif layer.penStyle() != Qt.PenStyle.SolidLine: res['dashArray'] = FslConverter.convert_pen_style(layer.penStyle()) # not supported: @@ -1264,10 +1264,10 @@ def simple_fill_to_fsl( """ Converts a QGIS simple fill symbol layer to FSL """ - has_invisible_fill = (layer.brushStyle() == Qt.NoBrush or + has_invisible_fill = (layer.brushStyle() == Qt.BrushStyle.NoBrush or not layer.color().isValid() or layer.color().alphaF() == 0) - has_invisible_stroke = (layer.strokeStyle() == Qt.NoPen or + has_invisible_stroke = (layer.strokeStyle() == Qt.PenStyle.NoPen or not layer.strokeColor().isValid() or layer.strokeColor().alphaF() == 0) if has_invisible_fill and has_invisible_stroke: @@ -1291,7 +1291,7 @@ def simple_fill_to_fsl( res['lineJoin'] = FslConverter.convert_join_style( layer.penJoinStyle()) - if layer.strokeStyle() != Qt.SolidLine: + if layer.strokeStyle() != Qt.PenStyle.SolidLine: res['dashArray'] = FslConverter.convert_pen_style( layer.strokeStyle()) else: @@ -1301,7 +1301,8 @@ def simple_fill_to_fsl( # - fill offset # - fill style - if layer.brushStyle() not in (Qt.SolidPattern, Qt.NoBrush): + if layer.brushStyle() not in (Qt.BrushStyle.SolidPattern, + Qt.BrushStyle.NoBrush): context.push_warning( 'Fill patterns are not supported, converting to a solid fill', LogLevel.Warning, @@ -1323,7 +1324,7 @@ def simple_marker_to_fsl( """ has_fill = layer.color().isValid() and layer.color().alphaF() > 0 has_stroke = (layer.strokeColor().alphaF() > 0 and - layer.strokeStyle() != Qt.NoPen) + layer.strokeStyle() != Qt.PenStyle.NoPen) if not has_fill and not has_stroke: return [] @@ -1378,7 +1379,7 @@ def ellipse_marker_to_fsl( """ has_fill = layer.color().isValid() and layer.color().alphaF() > 0 has_stroke = (layer.strokeColor().alphaF() > 0 and - layer.strokeStyle() != Qt.NoPen) + layer.strokeStyle() != Qt.PenStyle.NoPen) if not has_fill and not has_stroke: return [] diff --git a/felt/core/logger.py b/felt/core/logger.py index 7daec9f..f752a6e 100644 --- a/felt/core/logger.py +++ b/felt/core/logger.py @@ -128,7 +128,7 @@ def log_message(self, message: str): QMetaObject.invokeMethod( self, "_submit_usage", - Qt.QueuedConnection, + Qt.ConnectionType.QueuedConnection, Q_ARG(str, message), Q_ARG(str, UsageType.Info.to_string())) @@ -137,7 +137,7 @@ def log_message_json(self, message: Dict): QMetaObject.invokeMethod( self, "_submit_usage", - Qt.QueuedConnection, + Qt.ConnectionType.QueuedConnection, Q_ARG(str, message_str), Q_ARG(str, UsageType.Info.to_string())) @@ -152,7 +152,7 @@ def log_error(self, error: str): QMetaObject.invokeMethod( self, "_submit_usage", - Qt.QueuedConnection, + Qt.ConnectionType.QueuedConnection, Q_ARG(str, message), Q_ARG(str, UsageType.Error.to_string())) @@ -167,7 +167,7 @@ def log_error_json(self, error: Dict): QMetaObject.invokeMethod( self, "_submit_usage", - Qt.QueuedConnection, + Qt.ConnectionType.QueuedConnection, Q_ARG(str, json.dumps(error)), Q_ARG(str, UsageType.Error.to_string())) diff --git a/felt/core/map.py b/felt/core/map.py index cdf4053..8d017c6 100644 --- a/felt/core/map.py +++ b/felt/core/map.py @@ -47,7 +47,7 @@ def from_json(jsons: Union[str, Dict]) -> 'Map': last_visited_string = res.get('attributes', {}).get('visited_at') if last_visited_string: last_visited = QDateTime.fromString( - last_visited_string, Qt.ISODate + last_visited_string, Qt.DateFormat.ISODate ) else: last_visited = None diff --git a/felt/core/map_uploader.py b/felt/core/map_uploader.py index 88c23eb..2326822 100644 --- a/felt/core/map_uploader.py +++ b/felt/core/map_uploader.py @@ -507,8 +507,9 @@ def run(self): feedback=self.feedback ) - if reply.error() != QNetworkReply.NoError: - if reply.error() == QNetworkReply.ContentAccessDenied: + if reply.error() != QNetworkReply.NetworkError.NoError: + if (reply.error() == + QNetworkReply.NetworkError.ContentAccessDenied): self.paid_plan_error = True self.error_string = reply.errorString() Logger.instance().log_error_json( @@ -647,7 +648,8 @@ def run(self): ) if reply.attribute( - QNetworkRequest.HttpStatusCodeAttribute) == 429: + QNetworkRequest.Attribute.HttpStatusCodeAttribute + ) == 429: rate_limit_counter += 1 if rate_limit_counter > 3: self.error_string = \ @@ -669,8 +671,9 @@ def run(self): QThread.sleep(5) continue - if reply.error() != QNetworkReply.NoError: - if reply.error() == QNetworkReply.ContentAccessDenied: + if reply.error() != QNetworkReply.NetworkError.NoError: + if (reply.error() == + QNetworkReply.NetworkError.ContentAccessDenied): self.paid_plan_error = True self.error_string = reply.errorString() Logger.instance().log_error_json( @@ -734,7 +737,9 @@ def _upload_progress(sent, total): form_content, feedback=self.feedback) - if blocking_request.reply().error() != QNetworkReply.NoError: + if (blocking_request.reply().error() != + + QNetworkReply.NetworkError.NoError): self.error_string = blocking_request.reply().errorString() Logger.instance().log_error_json( { @@ -784,8 +789,9 @@ def _upload_progress(sent, total): ordering_key=details.ordering_key, ) - if reply and reply.error() != QNetworkReply.NoError: - if reply.error() == QNetworkReply.ContentAccessDenied: + if reply and reply.error() != QNetworkReply.NetworkError.NoError: + if (reply.error() == + QNetworkReply.NetworkError.ContentAccessDenied): self.paid_plan_error = True self.error_string = reply.errorString() Logger.instance().log_error_json( @@ -823,8 +829,9 @@ def _upload_progress(sent, total): ordering_key=details.ordering_key, ) - if reply and reply.error() != QNetworkReply.NoError: - if reply.error() == QNetworkReply.ContentAccessDenied: + if reply and reply.error() != QNetworkReply.NetworkError.NoError: + if (reply.error() == + QNetworkReply.NetworkError.ContentAccessDenied): self.paid_plan_error = True self.error_string = reply.errorString() Logger.instance().log_error_json( diff --git a/felt/core/multi_step_feedback.py b/felt/core/multi_step_feedback.py index 9131d38..7b972b4 100644 --- a/felt/core/multi_step_feedback.py +++ b/felt/core/multi_step_feedback.py @@ -26,7 +26,8 @@ def __init__(self, steps: int, feedback: QgsFeedback): self.current_step = 0 self._feedback = feedback - self._feedback.canceled.connect(self.cancel, Qt.DirectConnection) + self._feedback.canceled.connect( + self.cancel, Qt.ConnectionType.DirectConnection) self.progressChanged.connect(self._update_overall_progress) def step_finished(self): diff --git a/felt/core/recent_projects_model.py b/felt/core/recent_projects_model.py index f56f6b3..ac1fc68 100644 --- a/felt/core/recent_projects_model.py +++ b/felt/core/recent_projects_model.py @@ -33,13 +33,13 @@ class RecentMapsModel(QAbstractItemModel): Qt model for recent maps """ - TitleRole = Qt.UserRole + 1 - UrlRole = Qt.UserRole + 2 - ThumbnailRole = Qt.UserRole + 3 - IdRole = Qt.UserRole + 4 - MapRole = Qt.UserRole + 5 - SubTitleRole = Qt.UserRole + 6 - IsNewMapRole = Qt.UserRole + 7 + TitleRole = Qt.ItemDataRole.UserRole + 1 + UrlRole = Qt.ItemDataRole.UserRole + 2 + ThumbnailRole = Qt.ItemDataRole.UserRole + 3 + IdRole = Qt.ItemDataRole.UserRole + 4 + MapRole = Qt.ItemDataRole.UserRole + 5 + SubTitleRole = Qt.ItemDataRole.UserRole + 6 + IsNewMapRole = Qt.ItemDataRole.UserRole + 7 LIMIT = 100 @@ -131,11 +131,11 @@ def _reply_finished(self, reply: QNetworkReply): self._current_reply = None - if reply.error() == QNetworkReply.ContentNotFoundError: + if reply.error() == QNetworkReply.NetworkError.ContentNotFoundError: self._next_page = None return - if reply.error() != QNetworkReply.NoError: + if reply.error() != QNetworkReply.NetworkError.NoError: return result = json.loads(reply.readAll().data().decode()) @@ -250,14 +250,16 @@ def pretty_format_date(self, date: QDateTime) -> str: # pylint:disable=too-many-return-statements,too-many-branches def data(self, index, - role=Qt.DisplayRole): + role=Qt.ItemDataRole.DisplayRole): if index.row() == 0 and not index.parent().isValid(): # special "New map" item - if role in (self.TitleRole, Qt.DisplayRole, Qt.ToolTipRole): + if role in (self.TitleRole, + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.ToolTipRole): return self._new_map_title if role == self.SubTitleRole: return self.tr('New map') - if role in (self.ThumbnailRole, Qt.DecorationRole): + if role in (self.ThumbnailRole, Qt.ItemDataRole.DecorationRole): # pylint: disable=import-outside-toplevel from ..gui import GuiUtils # pylint: enable=import-outside-toplevel @@ -271,7 +273,9 @@ def data(self, if _map: if role == self.MapRole: return _map - if role in (self.TitleRole, Qt.DisplayRole, Qt.ToolTipRole): + if role in (self.TitleRole, + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.ToolTipRole): return _map.title if role == self.SubTitleRole and _map.last_visited: date_string = self.pretty_format_date(_map.last_visited) @@ -280,7 +284,7 @@ def data(self, return _map.url if role == self.IdRole: return _map.id - if role in (self.ThumbnailRole, Qt.DecorationRole): + if role in (self.ThumbnailRole, Qt.ItemDataRole.DecorationRole): return self._thumbnail_manager.thumbnail( _map.thumbnail_url) if role == self.IsNewMapRole: @@ -295,7 +299,7 @@ def flags(self, index): if not index.isValid(): return f - return f | Qt.ItemIsEnabled | Qt.ItemIsSelectable + return f | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable def canFetchMore(self, index: QModelIndex): if self._no_results_found: diff --git a/felt/core/thumbnail_manager.py b/felt/core/thumbnail_manager.py index 1df6c71..3126b7e 100644 --- a/felt/core/thumbnail_manager.py +++ b/felt/core/thumbnail_manager.py @@ -47,10 +47,10 @@ def download_thumbnail(self, url: str) -> Optional[QImage]: request = QNetworkRequest(QUrl(url)) request.setAttribute( - QNetworkRequest.CacheLoadControlAttribute, - QNetworkRequest.PreferCache) + QNetworkRequest.Attribute.CacheLoadControlAttribute, + QNetworkRequest.CacheLoadControl.PreferCache) request.setAttribute( - QNetworkRequest.CacheSaveControlAttribute, + QNetworkRequest.Attribute.CacheSaveControlAttribute, True ) reply = QgsNetworkAccessManager.instance().get(request) @@ -66,7 +66,7 @@ def _thumbnail_downloaded(self, reply): Triggered when a thumbnail download is complete """ self.queued_replies.remove(reply) - if reply.error() == QNetworkReply.NoError: + if reply.error() == QNetworkReply.NetworkError.NoError: url = reply.url().toString() img = QImage() img.loadFromData(reply.readAll()) diff --git a/felt/core/workspaces_model.py b/felt/core/workspaces_model.py index 29353d2..73bde62 100644 --- a/felt/core/workspaces_model.py +++ b/felt/core/workspaces_model.py @@ -30,9 +30,9 @@ class WorkspacesModel(QAbstractItemModel): Qt model for workspaces """ - NameRole = Qt.UserRole + 1 - UrlRole = Qt.UserRole + 2 - IdRole = Qt.UserRole + 4 + NameRole = Qt.ItemDataRole.UserRole + 1 + UrlRole = Qt.ItemDataRole.UserRole + 2 + IdRole = Qt.ItemDataRole.UserRole + 4 no_workspaces_found = pyqtSignal() workspaces_loaded = pyqtSignal() @@ -61,11 +61,11 @@ def _reply_finished(self, reply: QNetworkReply): self._current_reply = None - if reply.error() == QNetworkReply.ContentNotFoundError: + if reply.error() == QNetworkReply.NetworkError.ContentNotFoundError: self._next_page = None return - if reply.error() != QNetworkReply.NoError: + if reply.error() != QNetworkReply.NetworkError.NoError: return result = json.loads(reply.readAll().data().decode()) @@ -113,11 +113,13 @@ def columnCount(self, parent=QModelIndex()): # pylint:disable=too-many-return-statements,too-many-branches def data(self, index, - role=Qt.DisplayRole): + role=Qt.ItemDataRole.DisplayRole): _workspace = self.index2workspace(index) if _workspace: - if role in (self.NameRole, Qt.DisplayRole, Qt.ToolTipRole): + if role in (self.NameRole, + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.ToolTipRole): return _workspace.name if role == self.UrlRole: return _workspace.url @@ -133,7 +135,7 @@ def flags(self, index): if not index.isValid(): return f - return f | Qt.ItemIsEnabled | Qt.ItemIsSelectable + return f | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable # pylint: enable=missing-docstring,unused-argument def index2workspace(self, index: QModelIndex) -> Optional[Workspace]: diff --git a/felt/gui/authorization_manager.py b/felt/gui/authorization_manager.py index 651399a..1a7c4ff 100644 --- a/felt/gui/authorization_manager.py +++ b/felt/gui/authorization_manager.py @@ -231,7 +231,7 @@ def show_authorization_dialog(self): process """ dlg = AuthorizeDialog() - if dlg.exec_(): + if dlg.exec(): self.start_authorization_workflow() else: self.queued_callbacks = [] @@ -332,13 +332,13 @@ def _set_user_details(self, reply: QNetworkReply): return if self._user_reply.attribute( - QNetworkRequest.HttpStatusCodeAttribute) == 401: + QNetworkRequest.Attribute.HttpStatusCodeAttribute) == 401: self._user_reply = None self.deauthorize() self.attempt_authorize() return - if self._user_reply.error() != QNetworkReply.NoError: + if self._user_reply.error() != QNetworkReply.NetworkError.NoError: self._user_reply = None return diff --git a/felt/gui/authorize_dialog.py b/felt/gui/authorize_dialog.py index e11a976..70d2fd3 100644 --- a/felt/gui/authorize_dialog.py +++ b/felt/gui/authorize_dialog.py @@ -67,7 +67,8 @@ def __init__(self, parent: Optional[QWidget] = None): ) self.footer_label.setMinimumWidth( - QFontMetrics(self.footer_label.font()).width('x') * 40 + QFontMetrics(self.footer_label.font()).horizontalAdvance('x') * + 40 ) def _sign_up(self): diff --git a/felt/gui/colored_progress_bar.py b/felt/gui/colored_progress_bar.py index 5ff01cb..23f4064 100644 --- a/felt/gui/colored_progress_bar.py +++ b/felt/gui/colored_progress_bar.py @@ -29,18 +29,20 @@ def paintEvent(self, event): option = QStyleOptionProgressBar() self.initStyleOption(option) - option.textAlignment = Qt.AlignHCenter - option.palette.setColor(QPalette.Highlight, QColor("#3d521e")) + option.textAlignment = Qt.AlignmentFlag.AlignHCenter + option.palette.setColor(QPalette.ColorRole.Highlight, + QColor("#3d521e")) if self.value() > 45: - option.palette.setColor(QPalette.HighlightedText, + option.palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) else: - option.palette.setColor(QPalette.Text, + option.palette.setColor(QPalette.ColorRole.Text, QColor(0, 0, 0)) - option.palette.setColor(QPalette.HighlightedText, + option.palette.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0)) painter = QPainter(self) - self.style().drawControl(QStyle.CE_ProgressBar, option, painter, self) + self.style().drawControl(QStyle.ControlElement.CE_ProgressBar, + option, painter, self) # pylint: enable=missing-function-docstring,unused-argument diff --git a/felt/gui/create_map_dialog.py b/felt/gui/create_map_dialog.py index 7bedd0c..dbfcd9b 100644 --- a/felt/gui/create_map_dialog.py +++ b/felt/gui/create_map_dialog.py @@ -77,13 +77,15 @@ def __init__(self, # pylint: disable=too-many-statements self.setStyleSheet(FELT_STYLESHEET) self.page.setStyleSheet(FELT_STYLESHEET) self.page_2.setStyleSheet(FELT_STYLESHEET) - self.button_box.button(QDialogButtonBox.Ok).setStyleSheet( - FELT_STYLESHEET) - self.button_box.button(QDialogButtonBox.Cancel).setStyleSheet( - FELT_STYLESHEET) + self.button_box.button( + QDialogButtonBox.StandardButton.Ok + ).setStyleSheet(FELT_STYLESHEET) + self.button_box.button( + QDialogButtonBox.StandardButton.Cancel + ).setStyleSheet(FELT_STYLESHEET) self.progress_label.setTextInteractionFlags( - Qt.TextBrowserInteraction + Qt.TextInteractionFlag.TextBrowserInteraction ) self.progress_label.setOpenExternalLinks(True) @@ -119,23 +121,24 @@ def __init__(self, # pylint: disable=too-many-statements self.setWindowTitle(self.tr('Add to Felt')) self.footer_label.setMinimumWidth( - QFontMetrics(self.footer_label.font()).width('x') * 40 + QFontMetrics(self.footer_label.font()).horizontalAdvance('x') * + 40 ) self.stacked_widget.setCurrentIndex(0) self.layers = layers - self.button_box.button(QDialogButtonBox.Ok).setText( + self.button_box.button(QDialogButtonBox.StandardButton.Ok).setText( self.tr('Add to Felt') ) - self.button_box.button(QDialogButtonBox.Ok).clicked.connect( - self._start - ) - self.button_box.button(QDialogButtonBox.Cancel).clicked.connect( - self._cancel - ) - self.button_box.button(QDialogButtonBox.Cancel).setText( + self.button_box.button( + QDialogButtonBox.StandardButton.Ok + ).clicked.connect(self._start) + self.button_box.button( + QDialogButtonBox.StandardButton.Cancel + ).clicked.connect(self._cancel) + self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText( self.tr('Close') ) @@ -146,11 +149,17 @@ def __init__(self, # pylint: disable=too-many-statements self.setting_menu = QMenu(self) palette = self.setting_menu.palette() - palette.setColor(QPalette.Active, QPalette.Base, QColor(255, 255, 255)) - palette.setColor(QPalette.Active, QPalette.Text, QColor(0, 0, 0)) - palette.setColor(QPalette.Active, QPalette.Highlight, + palette.setColor(QPalette.ColorGroup.Active, + QPalette.ColorRole.Base, + QColor(255, 255, 255)) + palette.setColor(QPalette.ColorGroup.Active, + QPalette.ColorRole.Text, + QColor(0, 0, 0)) + palette.setColor(QPalette.ColorGroup.Active, + QPalette.ColorRole.Highlight, QColor('#3d521e')) - palette.setColor(QPalette.Active, QPalette.HighlightedText, + palette.setColor(QPalette.ColorGroup.Active, + QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) self.setting_menu.setPalette(palette) @@ -186,12 +195,15 @@ def upload_raster_as_styled_toggled(): self.logout_action.triggered.connect(self._logout) palette = self.setting_button.palette() - palette.setColor(QPalette.Active, QPalette.Button, QColor('#ececec')) + palette.setColor(QPalette.ColorGroup.Active, + QPalette.ColorRole.Button, + QColor('#ececec')) self.setting_button.setPalette(palette) self.setting_button.setMenu(self.setting_menu) self.setting_button.setIcon(GuiUtils.get_icon('setting_icon.svg')) - self.setting_button.setPopupMode(QToolButton.InstantPopup) + self.setting_button.setPopupMode( + QToolButton.ToolButtonPopupMode.InstantPopup) self.setting_button.setStyleSheet( """QToolButton::menu-indicator { image: none } QToolButton { @@ -201,7 +213,8 @@ def upload_raster_as_styled_toggled(): """ ) self.setting_button.setFixedHeight( - self.button_box.button(QDialogButtonBox.Cancel).height() + self.button_box.button( + QDialogButtonBox.StandardButton.Cancel).height() ) self.setting_button.setFixedWidth( int(self.setting_button.size().height() * 1.8) @@ -213,7 +226,8 @@ def upload_raster_as_styled_toggled(): # setting the setting button to a fixed height doesn't always # guarantee that the height exactly matches the Close/Add buttons. # So let's play it safe and force them to match always: - for b in (QDialogButtonBox.Cancel, QDialogButtonBox.Ok): + for b in (QDialogButtonBox.StandardButton.Cancel, + QDialogButtonBox.StandardButton.Ok): self.button_box.button(b).setFixedHeight( self.setting_button.size().height() ) @@ -312,7 +326,8 @@ def _fatal_error(self, error: str): """ self.stacked_widget.setCurrentIndex(2) self.error_label.setText(error) - self.button_box.button(QDialogButtonBox.Ok).deleteLater() + self.button_box.button( + QDialogButtonBox.StandardButton.Ok).deleteLater() def _no_workspace(self): """ @@ -431,7 +446,7 @@ def _validate(self): """ Validates the dialog """ - self.button_box.button(QDialogButtonBox.Ok).setEnabled( + self.button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled( self._is_valid() ) @@ -453,13 +468,14 @@ def _start(self): ) self.started = True - self.button_box.button(QDialogButtonBox.Cancel).setText( + self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText( self.tr('Cancel') ) - self.button_box.button(QDialogButtonBox.Ok).setText( + self.button_box.button(QDialogButtonBox.StandardButton.Ok).setText( self.tr('Uploading') ) - self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) + self.button_box.button( + QDialogButtonBox.StandardButton.Ok).setEnabled(False) target_map = self.maps_widget.selected_map() self.map_uploader_task.associated_map = target_map @@ -480,9 +496,9 @@ def _start(self): self.map_uploader_task.taskTerminated.connect(self._upload_terminated) self.map_uploader_task.progressChanged.connect(self.set_progress) - self.button_box.button(QDialogButtonBox.Ok).clicked.disconnect( - self._start - ) + self.button_box.button( + QDialogButtonBox.StandardButton.Ok + ).clicked.disconnect(self._start) QgsApplication.taskManager().addTask(self.map_uploader_task) @@ -504,16 +520,17 @@ def _upload_finished(self): self.map_uploader_task = None self.started = False - self.button_box.button(QDialogButtonBox.Cancel).setText( + self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText( self.tr('Close') ) - self.button_box.button(QDialogButtonBox.Ok).setText( + self.button_box.button(QDialogButtonBox.StandardButton.Ok).setText( self.tr('Open Map') ) - self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) - self.button_box.button(QDialogButtonBox.Ok).clicked.connect( - self._view_map - ) + self.button_box.button( + QDialogButtonBox.StandardButton.Ok).setEnabled(True) + self.button_box.button( + QDialogButtonBox.StandardButton.Ok + ).clicked.connect(self._view_map) def _upload_terminated(self): """ @@ -525,7 +542,7 @@ def _upload_terminated(self): self.tr('Upload canceled — {}').format( self._map_title) ) - self.button_box.button(QDialogButtonBox.Ok).setText( + self.button_box.button(QDialogButtonBox.StandardButton.Ok).setText( self.tr('Canceled') ) self.progress_label.hide() @@ -566,14 +583,14 @@ def _upload_terminated(self): ) self.map_uploader_task = None - self.button_box.button(QDialogButtonBox.Cancel).setText( + self.button_box.button(QDialogButtonBox.StandardButton.Cancel).setText( self.tr('Close') ) - self.button_box.button(QDialogButtonBox.Ok).setEnabled( + self.button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled( False ) - self.button_box.button(QDialogButtonBox.Ok).hide() + self.button_box.button(QDialogButtonBox.StandardButton.Ok).hide() def _view_map(self): """ diff --git a/felt/gui/felt_dialog_header.py b/felt/gui/felt_dialog_header.py index a657812..9463a8f 100644 --- a/felt/gui/felt_dialog_header.py +++ b/felt/gui/felt_dialog_header.py @@ -12,7 +12,15 @@ QPainter, QImage ) -from qgis.PyQt.QtSvg import QSvgWidget +try: + # Qt 5 and QGIS >= 3.99 builds wrap QSvgWidget in qgis.PyQt + from qgis.PyQt.QtSvg import QSvgWidget +except ImportError: + try: + from qgis.PyQt.QtSvgWidgets import QSvgWidget + except ImportError: + # early QGIS 4 releases don't wrap QtSvgWidgets at all + from PyQt6.QtSvgWidgets import QSvgWidget from qgis.PyQt.QtWidgets import ( QWidget, QVBoxLayout, @@ -39,8 +47,8 @@ def __init__(self, parent: Optional[QWidget] = None): self._cached_image: Optional[QImage] = None self.setSizePolicy( - QSizePolicy.Minimum, - QSizePolicy.Fixed + QSizePolicy.Policy.Minimum, + QSizePolicy.Policy.Fixed ) svg_logo_widget = QSvgWidget() @@ -78,7 +86,7 @@ def sizeHint(self): def paintEvent(self, event): # pylint: disable=unused-argument painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing, True) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) # image has 437 x 107 aspect ratio if not self._cached_image or \ diff --git a/felt/gui/gui_utils.py b/felt/gui/gui_utils.py index cdc0ed7..6c42086 100644 --- a/felt/gui/gui_utils.py +++ b/felt/gui/gui_utils.py @@ -149,7 +149,7 @@ def get_svg_as_image(icon: str, width: int, height: int, renderer = QSvgRenderer(path) image = QImage(int(width * device_pixel_ratio), int(height * device_pixel_ratio), - QImage.Format_ARGB32) + QImage.Format.Format_ARGB32) image.setDevicePixelRatio(device_pixel_ratio) if not background_color: image.fill(Qt.transparent) diff --git a/felt/gui/recent_maps_list_view.py b/felt/gui/recent_maps_list_view.py index b5b258b..72b2a8f 100644 --- a/felt/gui/recent_maps_list_view.py +++ b/felt/gui/recent_maps_list_view.py @@ -72,17 +72,17 @@ def process_thumbnail(self, device_pixel_ratio) scaled = thumbnail.scaled( QSize(uncropped_thumbnail_width, int(height * device_pixel_ratio)), - Qt.KeepAspectRatio, - Qt.SmoothTransformation, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, ) im_out = QImage(int(target_size.width() * device_pixel_ratio), int(target_size.height() * device_pixel_ratio), - QImage.Format_ARGB32) + QImage.Format.Format_ARGB32) im_out.fill(Qt.transparent) painter = QPainter(im_out) - painter.setRenderHint(QPainter.Antialiasing, True) - painter.setPen(Qt.NoPen) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QBrush(QColor(0, 0, 0))) painter.drawRoundedRect( QRectF(1 * device_pixel_ratio, @@ -106,7 +106,7 @@ def process_thumbnail(self, pen.setWidthF(self.BORDER_WIDTH_PIXELS * device_pixel_ratio) pen.setCosmetic(True) painter.setPen(pen) - painter.setBrush(Qt.NoBrush) + painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawRoundedRect( QRectF(device_pixel_ratio, device_pixel_ratio, @@ -145,16 +145,18 @@ def paint( device_pixel_ratio = 1.0 if option.widget is None else \ option.widget.devicePixelRatioF() - option.palette.setColor(QPalette.Highlight, self.SELECTED_ROW_COLOR) + option.palette.setColor(QPalette.ColorRole.Highlight, + self.SELECTED_ROW_COLOR) # draw background for item (i.e. selection background) style.drawPrimitive( - QStyle.PE_PanelItemViewItem, option, painter, option.widget) + QStyle.PrimitiveElement.PE_PanelItemViewItem, option, painter, + option.widget) painter.save() - painter.setRenderHint(QPainter.Antialiasing, True) - painter.setRenderHint(QPainter.TextAntialiasing, True) - painter.setRenderHint(QPainter.SmoothPixmapTransform, True) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True) inner_rect = QRectF(option.rect) inner_rect.adjust( @@ -205,7 +207,7 @@ def paint( line_heights = [1.0 * line_scale, 2.0 * line_scale] - painter.setBrush(Qt.NoBrush) + painter.setBrush(Qt.BrushStyle.NoBrush) painter.setPen(QPen(self.HEADING_COLOR)) painter.drawText( QPointF( @@ -247,14 +249,15 @@ def __init__(self, parent: Optional[QWidget] = None): self.setItemDelegate(delegate) p = self.palette() - p.setColor(QPalette.Base, QColor(255, 255, 255)) + p.setColor(QPalette.ColorRole.Base, QColor(255, 255, 255)) self.setPalette(p) fm = QFontMetrics(self.font()) self.setMinimumHeight(fm.height() * 12) - self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._model.first_results_found.connect(self._on_first_results_found) self._model.no_results_found.connect(self._on_no_results_found) @@ -269,7 +272,7 @@ def set_filter_string(self, filter_string: str): # option should get selected again self.selectionModel().select( self._model.index(0, 0), - QItemSelectionModel.ClearAndSelect) + QItemSelectionModel.SelectionFlag.ClearAndSelect) def set_workspace_id(self, workspace_id: Optional[str]): """ @@ -280,7 +283,7 @@ def set_workspace_id(self, workspace_id: Optional[str]): # option should get selected again self.selectionModel().select( self._model.index(0, 0), - QItemSelectionModel.ClearAndSelect) + QItemSelectionModel.SelectionFlag.ClearAndSelect) def set_new_map_title(self, title: str): """ @@ -298,7 +301,7 @@ def _on_first_results_found(self): if self._model.filter_string(): self.selectionModel().select( self._model.index(1, 0), - QItemSelectionModel.ClearAndSelect) + QItemSelectionModel.SelectionFlag.ClearAndSelect) def _on_no_results_found(self): """ @@ -309,7 +312,7 @@ def _on_no_results_found(self): # option should get selected again self.selectionModel().select( self._model.index(0, 0), - QItemSelectionModel.ClearAndSelect) + QItemSelectionModel.SelectionFlag.ClearAndSelect) class RecentMapsWidget(QWidget): @@ -372,7 +375,7 @@ def _update_filter_stylesheet(): self._view.selectionModel().select( self._view.model().index(0, 0), - QItemSelectionModel.ClearAndSelect) + QItemSelectionModel.SelectionFlag.ClearAndSelect) def filter_line_edit(self) -> QgsFilterLineEdit: """ diff --git a/felt/metadata.txt b/felt/metadata.txt index 20e60b8..3b1e24a 100644 --- a/felt/metadata.txt +++ b/felt/metadata.txt @@ -10,8 +10,9 @@ [general] name=Add to Felt qgisMinimumVersion=3.22 +qgisMaximumVersion=4.99 description=Create a collaborative Felt (felt.com) map from QGIS -version=1.0.0 +version=1.1.0 author=Felt email=support@felt.com diff --git a/felt/test/qgis_interface.py b/felt/test/qgis_interface.py index 7d2735b..30292cd 100644 --- a/felt/test/qgis_interface.py +++ b/felt/test/qgis_interface.py @@ -25,7 +25,7 @@ import logging from typing import List -from PyQt5.QtCore import QObject, pyqtSlot, pyqtSignal, QSize +from qgis.PyQt.QtCore import QObject, pyqtSlot, pyqtSignal, QSize from qgis.PyQt.QtWidgets import QDockWidget from qgis.core import QgsProject, QgsMapLayer from qgis.gui import (QgsMapCanvas, diff --git a/felt/test/test_api_client.py b/felt/test/test_api_client.py index fbaafb6..978f7aa 100644 --- a/felt/test/test_api_client.py +++ b/felt/test/test_api_client.py @@ -117,8 +117,9 @@ def test_user(self): spy.wait() - self.assertEqual(reply.error(), - QNetworkReply.AuthenticationRequiredError) + self.assertEqual( + reply.error(), + QNetworkReply.NetworkError.AuthenticationRequiredError) # an authenticated client reply = CLIENT.user() @@ -130,7 +131,7 @@ def test_user(self): spy.wait() self.assertEqual(reply.error(), - QNetworkReply.NoError) + QNetworkReply.NetworkError.NoError) user = User.from_json(reply.readAll().data().decode()) self.assertEqual(user.name, 'Nyall Dawson') @@ -152,8 +153,9 @@ def test_create_map(self): spy.wait() - self.assertEqual(reply.error(), - QNetworkReply.AuthenticationRequiredError) + self.assertEqual( + reply.error(), + QNetworkReply.NetworkError.AuthenticationRequiredError) # an authenticated client reply = CLIENT.create_map( @@ -166,7 +168,7 @@ def test_create_map(self): spy.wait() self.assertEqual(reply.error(), - QNetworkReply.NoError) + QNetworkReply.NetworkError.NoError) created_map = Map.from_json(reply.readAll().data().decode()) self.assertEqual(created_map.type, ObjectType.Map) @@ -184,7 +186,7 @@ def test_create_layer(self): spy.wait() self.assertEqual(reply.error(), - QNetworkReply.NoError) + QNetworkReply.NetworkError.NoError) created_map = Map.from_json(reply.readAll().data().decode()) @@ -200,7 +202,7 @@ def test_create_layer(self): spy.wait() self.assertEqual(reply.error(), - QNetworkReply.NoError) + QNetworkReply.NetworkError.NoError) json_params = reply.readAll().data().decode() params = S3UploadParameters.from_json(json.loads(json_params)) @@ -228,7 +230,7 @@ def test_create_layer(self): spy = QSignalSpy(reply.finished) spy.wait() - self.assertEqual(reply.error(), QNetworkReply.NoError) + self.assertEqual(reply.error(), QNetworkReply.NetworkError.NoError) reply = CLIENT.finalize_layer_upload( created_map.id, @@ -239,7 +241,7 @@ def test_create_layer(self): spy.wait() self.assertEqual(reply.error(), - QNetworkReply.NoError) + QNetworkReply.NetworkError.NoError) json_params = reply.readAll().data().decode() print(json_params) @@ -257,7 +259,7 @@ def test_usage(self): # reply should be empty response self.assertEqual(reply.error(), - QNetworkReply.NoError) + QNetworkReply.NetworkError.NoError) self.assertFalse(reply.readAll()) diff --git a/felt/test/test_fsl_conversion.py b/felt/test/test_fsl_conversion.py index 3379de7..8129a9f 100644 --- a/felt/test/test_fsl_conversion.py +++ b/felt/test/test_fsl_conversion.py @@ -5,7 +5,10 @@ import unittest from pathlib import Path -from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtCore import ( + Qt, + QT_VERSION +) from qgis.PyQt.QtGui import ( QColor, QFont @@ -158,13 +161,13 @@ def test_simple_line_to_fsl(self): line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) # no pen - line.setPenStyle(Qt.NoPen) + line.setPenStyle(Qt.PenStyle.NoPen) self.assertFalse( FslConverter.simple_line_to_fsl(line, conversion_context) ) # transparent color - line.setPenStyle(Qt.SolidLine) + line.setPenStyle(Qt.PenStyle.SolidLine) line.setColor(QColor(0, 255, 0, 0)) self.assertFalse( FslConverter.simple_line_to_fsl(line, conversion_context) @@ -197,7 +200,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenCapStyle(Qt.FlatCap) + line.setPenCapStyle(Qt.PenCapStyle.FlatCap) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context), [{ @@ -210,7 +213,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenCapStyle(Qt.RoundCap) + line.setPenCapStyle(Qt.PenCapStyle.RoundCap) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context), [{ @@ -223,7 +226,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenJoinStyle(Qt.RoundJoin) + line.setPenJoinStyle(Qt.PenJoinStyle.RoundJoin) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context), [{ @@ -236,7 +239,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenJoinStyle(Qt.MiterJoin) + line.setPenJoinStyle(Qt.PenJoinStyle.MiterJoin) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context), [{ @@ -249,7 +252,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenJoinStyle(Qt.MiterJoin) + line.setPenJoinStyle(Qt.PenJoinStyle.MiterJoin) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context, symbol_opacity=0.5), @@ -264,7 +267,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenStyle(Qt.DashLine) + line.setPenStyle(Qt.PenStyle.DashLine) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context), [{ @@ -278,7 +281,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenStyle(Qt.DotLine) + line.setPenStyle(Qt.PenStyle.DotLine) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context), [{ @@ -292,7 +295,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenStyle(Qt.DashDotLine) + line.setPenStyle(Qt.PenStyle.DashDotLine) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context), [{ @@ -306,7 +309,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenStyle(Qt.DashDotDotLine) + line.setPenStyle(Qt.PenStyle.DashDotDotLine) self.assertEqual( FslConverter.simple_line_to_fsl(line, conversion_context), [{ @@ -320,7 +323,7 @@ def test_simple_line_to_fsl(self): }] ) - line.setPenStyle(Qt.SolidLine) + line.setPenStyle(Qt.PenStyle.SolidLine) line.setUseCustomDashPattern(True) line.setCustomDashPatternUnit(QgsUnitTypes.RenderPixels) line.setCustomDashVector([0.5, 1, 1.5, 2]) @@ -369,16 +372,16 @@ def test_simple_fill_to_fsl(self): fill = QgsSimpleFillSymbolLayer(color=QColor(255, 0, 0)) - fill.setStrokeStyle(Qt.NoPen) + fill.setStrokeStyle(Qt.PenStyle.NoPen) # no brush - fill.setBrushStyle(Qt.NoBrush) + fill.setBrushStyle(Qt.BrushStyle.NoBrush) self.assertFalse( FslConverter.simple_fill_to_fsl(fill, conversion_context) ) # transparent color - fill.setBrushStyle(Qt.SolidPattern) + fill.setBrushStyle(Qt.BrushStyle.SolidPattern) fill.setColor(QColor(0, 255, 0, 0)) self.assertFalse( FslConverter.simple_fill_to_fsl(fill, conversion_context) @@ -387,7 +390,7 @@ def test_simple_fill_to_fsl(self): fill.setColor(QColor(0, 255, 0)) # transparent color with stroke - fill.setStrokeStyle(Qt.DashLine) + fill.setStrokeStyle(Qt.PenStyle.DashLine) fill.setStrokeWidth(3) fill.setStrokeColor(QColor(255, 0, 0)) self.assertEqual( @@ -400,7 +403,7 @@ def test_simple_fill_to_fsl(self): 'isHoverable': False, 'strokeWidth': 11}] ) - fill.setStrokeStyle(Qt.SolidLine) + fill.setStrokeStyle(Qt.PenStyle.SolidLine) fill.setStrokeColor(QColor(35, 35, 35)) fill.setColor(QColor(0, 255, 0)) @@ -426,7 +429,7 @@ def test_simple_fill_to_fsl(self): 'strokeWidth': 3.0}] ) - fill.setPenJoinStyle(Qt.RoundJoin) + fill.setPenJoinStyle(Qt.PenJoinStyle.RoundJoin) self.assertEqual( FslConverter.simple_fill_to_fsl(fill, conversion_context), [{'color': 'rgb(0, 255, 0)', @@ -437,7 +440,7 @@ def test_simple_fill_to_fsl(self): 'strokeWidth': 3.0}] ) - fill.setPenJoinStyle(Qt.MiterJoin) + fill.setPenJoinStyle(Qt.PenJoinStyle.MiterJoin) self.assertEqual( FslConverter.simple_fill_to_fsl(fill, conversion_context), [{'color': 'rgb(0, 255, 0)', @@ -448,7 +451,7 @@ def test_simple_fill_to_fsl(self): 'strokeWidth': 3.0}] ) - fill.setPenJoinStyle(Qt.MiterJoin) + fill.setPenJoinStyle(Qt.PenJoinStyle.MiterJoin) self.assertEqual( FslConverter.simple_fill_to_fsl(fill, conversion_context, symbol_opacity=0.5), @@ -461,7 +464,7 @@ def test_simple_fill_to_fsl(self): 'strokeWidth': 3.0}] ) - fill.setStrokeStyle(Qt.DashLine) + fill.setStrokeStyle(Qt.PenStyle.DashLine) self.assertEqual( FslConverter.simple_fill_to_fsl(fill, conversion_context), [{'color': 'rgb(0, 255, 0)', @@ -486,7 +489,7 @@ def test_simple_fill_to_fsl(self): ) # outline, no fill - fill.setBrushStyle(Qt.NoBrush) + fill.setBrushStyle(Qt.BrushStyle.NoBrush) self.assertEqual( FslConverter.simple_fill_to_fsl(fill, conversion_context, symbol_opacity=0.5), @@ -499,7 +502,7 @@ def test_simple_fill_to_fsl(self): 'isHoverable': False, 'strokeWidth': 3.0}] ) - fill.setBrushStyle(Qt.SolidPattern) + fill.setBrushStyle(Qt.BrushStyle.SolidPattern) fill.setFillColor(QColor(255, 255, 0, 0)) self.assertEqual( FslConverter.simple_fill_to_fsl(fill, conversion_context, @@ -625,7 +628,7 @@ def test_line_pattern_fill_to_fsl(self): # invisible line line = QgsLineSymbol() simple_line = QgsSimpleLineSymbolLayer() - simple_line.setPenStyle(Qt.NoPen) + simple_line.setPenStyle(Qt.PenStyle.NoPen) line.changeSymbolLayer(0, simple_line.clone()) fill.setSubSymbol(line.clone()) self.assertFalse( @@ -712,7 +715,7 @@ def test_simple_marker_to_fsl(self): ) marker.setStrokeColor(QColor(255, 0, 255)) - marker.setStrokeStyle(Qt.NoPen) + marker.setStrokeStyle(Qt.PenStyle.NoPen) self.assertFalse( FslConverter.simple_marker_to_fsl(marker, conversion_context) ) @@ -744,7 +747,7 @@ def test_simple_marker_to_fsl(self): ) # with stroke - marker.setStrokeStyle(Qt.SolidLine) + marker.setStrokeStyle(Qt.PenStyle.SolidLine) marker.setStrokeColor(QColor(255, 100, 0)) marker.setStrokeWidth(2) marker.setStrokeWidthUnit(QgsUnitTypes.RenderPoints) @@ -786,7 +789,7 @@ def test_ellipse_marker_to_fsl(self): ) marker.setStrokeColor(QColor(255, 0, 255)) - marker.setStrokeStyle(Qt.NoPen) + marker.setStrokeStyle(Qt.PenStyle.NoPen) self.assertFalse( FslConverter.ellipse_marker_to_fsl(marker, conversion_context) ) @@ -818,7 +821,7 @@ def test_ellipse_marker_to_fsl(self): ) # with stroke - marker.setStrokeStyle(Qt.SolidLine) + marker.setStrokeStyle(Qt.PenStyle.SolidLine) marker.setStrokeColor(QColor(255, 100, 0)) marker.setStrokeWidth(2) marker.setStrokeWidthUnit(QgsUnitTypes.RenderPoints) @@ -1002,8 +1005,8 @@ def test_filled_marker(self): fill = QgsSimpleFillSymbolLayer(color=QColor(255, 0, 0)) # no brush, no stroke - fill.setBrushStyle(Qt.NoBrush) - fill.setStrokeStyle(Qt.NoPen) + fill.setBrushStyle(Qt.BrushStyle.NoBrush) + fill.setStrokeStyle(Qt.PenStyle.NoPen) fill_symbol.changeSymbolLayer(0, fill.clone()) marker = QgsFilledMarkerSymbolLayer() marker.setSubSymbol(fill_symbol.clone()) @@ -1012,7 +1015,7 @@ def test_filled_marker(self): ) # transparent color - fill.setBrushStyle(Qt.SolidPattern) + fill.setBrushStyle(Qt.BrushStyle.SolidPattern) fill.setColor(QColor(0, 255, 0, 0)) fill_symbol.changeSymbolLayer(0, fill.clone()) marker.setSubSymbol(fill_symbol.clone()) @@ -1033,7 +1036,7 @@ def test_filled_marker(self): 'strokeColor': 'rgba(0, 0, 0, 0)'}] ) - fill.setStrokeStyle(Qt.SolidLine) + fill.setStrokeStyle(Qt.PenStyle.SolidLine) fill_symbol.changeSymbolLayer(0, fill.clone()) marker.setSubSymbol(fill_symbol.clone()) self.assertEqual( @@ -1104,7 +1107,7 @@ def test_point_pattern_fill_to_fsl(self): ) marker.setStrokeColor(QColor(255, 0, 255)) - marker.setStrokeStyle(Qt.NoPen) + marker.setStrokeStyle(Qt.PenStyle.NoPen) marker_symbol.changeSymbolLayer(0, marker.clone()) fill.setSubSymbol(marker_symbol.clone()) self.assertFalse( @@ -1154,7 +1157,7 @@ def test_centroid_fill_to_fsl(self): ) marker.setStrokeColor(QColor(255, 0, 255)) - marker.setStrokeStyle(Qt.NoPen) + marker.setStrokeStyle(Qt.PenStyle.NoPen) marker_symbol.changeSymbolLayer(0, marker.clone()) fill.setSubSymbol(marker_symbol.clone()) self.assertFalse( @@ -1206,7 +1209,7 @@ def test_random_marker_fill_to_fsl(self): ) marker.setStrokeColor(QColor(255, 0, 255)) - marker.setStrokeStyle(Qt.NoPen) + marker.setStrokeStyle(Qt.PenStyle.NoPen) marker_symbol.changeSymbolLayer(0, marker.clone()) fill.setSubSymbol(marker_symbol.clone()) self.assertFalse( @@ -1257,7 +1260,7 @@ def test_marker_line_to_fsl(self): ) marker.setStrokeColor(QColor(255, 0, 255)) - marker.setStrokeStyle(Qt.NoPen) + marker.setStrokeStyle(Qt.PenStyle.NoPen) marker_symbol.changeSymbolLayer(0, marker.clone()) line.setSubSymbol(marker_symbol.clone()) self.assertFalse( @@ -1322,7 +1325,7 @@ def test_hashed_line_to_fsl(self): ) hatch.setColor(QColor(255, 0, 255)) - hatch.setPenStyle(Qt.NoPen) + hatch.setPenStyle(Qt.PenStyle.NoPen) hatch_symbol.changeSymbolLayer(0, hatch.clone()) line.setSubSymbol(hatch_symbol.clone()) self.assertFalse( @@ -1331,7 +1334,7 @@ def test_hashed_line_to_fsl(self): # with hatch hatch.setColor(QColor(120, 130, 140)) - hatch.setPenStyle(Qt.SolidLine) + hatch.setPenStyle(Qt.PenStyle.SolidLine) hatch_symbol.changeSymbolLayer(0, hatch.clone()) line.setSubSymbol(hatch_symbol.clone()) @@ -1379,7 +1382,7 @@ def test_arrow_to_fsl(self): fill = QgsSimpleFillSymbolLayer() # invisible fill fill.setColor(QColor(255, 0, 0, 0)) - fill.setStrokeStyle(Qt.NoPen) + fill.setStrokeStyle(Qt.PenStyle.NoPen) fill_symbol = QgsFillSymbol() fill_symbol.changeSymbolLayer(0, fill.clone()) @@ -1860,7 +1863,7 @@ def test_categorized_no_stroke(self): conversion_context = ConversionContext() fill = QgsSimpleFillSymbolLayer(color=QColor(255, 0, 0)) - fill.setStrokeStyle(Qt.NoPen) + fill.setStrokeStyle(Qt.PenStyle.NoPen) fill_symbol = QgsFillSymbol() fill_symbol.changeSymbolLayer(0, fill.clone()) @@ -1898,13 +1901,13 @@ def test_categorized_dash_array_for_one(self): conversion_context = ConversionContext() line = QgsSimpleLineSymbolLayer(color=QColor(255, 0, 0)) - line.setPenStyle(Qt.DashLine) + line.setPenStyle(Qt.PenStyle.DashLine) line_symbol = QgsLineSymbol() line_symbol.changeSymbolLayer(0, line.clone()) line_symbol2 = QgsLineSymbol() line.setColor(QColor(255, 0, 255)) - line.setPenStyle(Qt.SolidLine) + line.setPenStyle(Qt.PenStyle.SolidLine) line_symbol2.changeSymbolLayer(0, line.clone()) categories = [ @@ -2078,40 +2081,30 @@ def test_heatmap_renderer(self): """ conversion_context = ConversionContext() + # Qt 6 rounds interpolated gradient colors slightly differently + # to Qt 5, so build the expected ramp colors to match + if QT_VERSION >= 0x060000: + HEATMAP_RAMP_COLORS = [ + '#ffffff', '#f7f7f7', '#eeeeee', '#e6e6e6', '#dddddd', + '#d5d5d5', '#cccccc', '#c4c4c4', '#bbbbbb', '#b3b3b3', + '#aaaaaa', '#a2a2a2', '#999999', '#919191', '#888888', + '#808080', '#777777', '#6f6f6f', '#666666', '#5e5e5e', + '#555555', '#4d4d4d', '#444444', '#3c3c3c', '#333333', + '#2b2b2b', '#222222', '#1a1a1a', '#111111', '#090909'] + else: + HEATMAP_RAMP_COLORS = [ + '#ffffff', '#f7f7f7', '#eeeeee', '#e6e6e6', '#dddddd', + '#d5d5d5', '#cccccc', '#c3c3c3', '#bbbbbb', '#b3b3b3', + '#aaaaaa', '#a2a2a2', '#999999', '#919191', '#888888', + '#808080', '#777777', '#6f6f6f', '#666666', '#5e5e5e', + '#555555', '#4d4d4d', '#444444', '#3b3b3b', '#333333', + '#2a2a2a', '#222222', '#191919', '#111111', '#080808'] + renderer = QgsHeatmapRenderer() self.assertEqual( FslConverter.vector_renderer_to_fsl(renderer, conversion_context), {'legend': {'displayName': {'0': 'Low', '1': 'High'}}, - 'style': {'color': ['#ffffff', - '#f7f7f7', - '#eeeeee', - '#e6e6e6', - '#dddddd', - '#d5d5d5', - '#cccccc', - '#c3c3c3', - '#bbbbbb', - '#b3b3b3', - '#aaaaaa', - '#a2a2a2', - '#999999', - '#919191', - '#888888', - '#808080', - '#777777', - '#6f6f6f', - '#666666', - '#5e5e5e', - '#555555', - '#4d4d4d', - '#444444', - '#3b3b3b', - '#333333', - '#2a2a2a', - '#222222', - '#191919', - '#111111', - '#080808'], + 'style': {'color': HEATMAP_RAMP_COLORS, 'intensity': 1, 'opacity': 1, 'size': 38}, @@ -2122,36 +2115,7 @@ def test_heatmap_renderer(self): FslConverter.vector_renderer_to_fsl(renderer, conversion_context, layer_opacity=0.5), {'legend': {'displayName': {'0': 'Low', '1': 'High'}}, - 'style': {'color': ['#ffffff', - '#f7f7f7', - '#eeeeee', - '#e6e6e6', - '#dddddd', - '#d5d5d5', - '#cccccc', - '#c3c3c3', - '#bbbbbb', - '#b3b3b3', - '#aaaaaa', - '#a2a2a2', - '#999999', - '#919191', - '#888888', - '#808080', - '#777777', - '#6f6f6f', - '#666666', - '#5e5e5e', - '#555555', - '#4d4d4d', - '#444444', - '#3b3b3b', - '#333333', - '#2a2a2a', - '#222222', - '#191919', - '#111111', - '#080808'], + 'style': {'color': HEATMAP_RAMP_COLORS, 'intensity': 1, 'opacity': 0.5, 'size': 38}, diff --git a/felt/test_suite.py b/felt/test_suite.py index cc7828e..0c91bd5 100644 --- a/felt/test_suite.py +++ b/felt/test_suite.py @@ -42,7 +42,8 @@ def _run_tests(test_suite, package_name, with_coverage=False): ) cov.start() - unittest.TextTestRunner(verbosity=3, stream=sys.stdout).run(test_suite) + result = unittest.TextTestRunner( + verbosity=3, stream=sys.stdout).run(test_suite) if with_coverage: cov.stop() @@ -57,20 +58,35 @@ def _run_tests(test_suite, package_name, with_coverage=False): with open(report.name, 'r', encoding='utf8') as fin: print(fin.read()) + return result.wasSuccessful() + def test_package(package='felt'): """Test package. This function is called by travis without arguments. + Returns True if all tests passed. + :param package: The package to test. :type package: str """ + # ensure a QgsApplication exists BEFORE any plugin modules are + # imported by test discovery: on Qt6 builds, widgets created at + # import time crash if no application instance exists + from felt.test.utilities import get_qgis_app + get_qgis_app() test_loader = unittest.defaultTestLoader + # specify the top level directory explicitly: newer Python versions + # no longer reliably resolve it from a package start directory, which + # breaks the relative imports used by the test modules + package_dir = os.path.dirname(os.path.abspath(__file__)) try: - test_suite = test_loader.discover(package) + test_suite = test_loader.discover( + os.path.join(package_dir, 'test'), + top_level_dir=os.path.dirname(package_dir)) except ImportError: test_suite = unittest.TestSuite() - _run_tests(test_suite, package) + return _run_tests(test_suite, package) def test_environment(): @@ -78,8 +94,23 @@ def test_environment(): package = os.environ.get('TESTING_PACKAGE', 'felt') test_loader = unittest.defaultTestLoader test_suite = test_loader.discover(package) - _run_tests(test_suite, package) + return _run_tests(test_suite, package) + + +def run_tests_and_exit(): + """ + Runs the test suite and exits the process with code 0 on success or + 1 on test failures. + + Exits without running interpreter teardown, as exiting a + QgsApplication from a headless test run can crash on cleanup + (especially on Qt6 builds), which would mask the test result. + """ + successful = test_package() + sys.stdout.flush() + sys.stderr.flush() + os._exit(0 if successful else 1) # pylint: disable=protected-access if __name__ == '__main__': - test_package() + run_tests_and_exit() diff --git a/requirements/testing.txt b/requirements/testing.txt index e0356c3..ab93982 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,4 +1,5 @@ # For tests execution: +coverage deepdiff mock flake8 From 30053ce3a6237935ef0d2dcb33068b592713668d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 06:55:34 +0000 Subject: [PATCH 2/4] Fix CI: keep new QGIS docker images running, update lint workflow actions The newer qgis/qgis images (3.40/3.44/4.0) exit immediately when run detached without a TTY, so pass -t to docker run. Also update the lint workflow off actions/setup-python@v1 with Python 3.9, which is no longer available on ubuntu-latest runners. https://claude.ai/code/session_01LCTq6nNRJEWyBGQ1sjipHw --- .github/workflows/lint.yaml | 8 ++++---- .github/workflows/test_plugin.yaml | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index bd87ddf..93a3d1b 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -16,12 +16,12 @@ jobs: steps: - name: Install Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' - name: Check out source repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install packages run: | @@ -29,7 +29,7 @@ jobs: pip install pylint pycodestyle - name: flake8 Lint - uses: py-actions/flake8@v1 + uses: py-actions/flake8@v2 - name: Pycodestyle run: make pycodestyle diff --git a/.github/workflows/test_plugin.yaml b/.github/workflows/test_plugin.yaml index 7e21679..8b746dd 100644 --- a/.github/workflows/test_plugin.yaml +++ b/.github/workflows/test_plugin.yaml @@ -35,7 +35,8 @@ jobs: - name: Docker pull and create qgis-testing-environment run: | docker pull "$DOCKER_IMAGE":${{ matrix.docker_tags }} - docker run -d --name qgis-testing-environment -v "$GITHUB_WORKSPACE":/tests_directory -e QT_QPA_PLATFORM=offscreen "$DOCKER_IMAGE":${{ matrix.docker_tags }} + # -t keeps the newer images' shell entrypoint alive + docker run -d -t --name qgis-testing-environment -v "$GITHUB_WORKSPACE":/tests_directory -e QT_QPA_PLATFORM=offscreen "$DOCKER_IMAGE":${{ matrix.docker_tags }} - name: Docker install test requirements run: | From 84fea261b556f817ba1d1eb2d79fa152dc83cdeb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 15:47:31 +0000 Subject: [PATCH 3/4] Fix Qt.transparent crash when painting dialogs on Qt6 Qt.GlobalColor members are lowercase so the enum scoping sweep missed them, and unscoped Qt.transparent raises AttributeError on PyQt6. The failure only occurs in paint paths (dialog header and recent map thumbnails), which unit tests don't reach, and repeated paint errors make the Add to Felt dialog unusable on QGIS 4. Verified by rendering the dialog and thumbnail compositing in both QGIS 4.0 (Qt 6.9.2) and QGIS 3.44 (Qt 5.15.17) containers. https://claude.ai/code/session_01LCTq6nNRJEWyBGQ1sjipHw --- felt/gui/gui_utils.py | 2 +- felt/gui/recent_maps_list_view.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/felt/gui/gui_utils.py b/felt/gui/gui_utils.py index 6c42086..51edd56 100644 --- a/felt/gui/gui_utils.py +++ b/felt/gui/gui_utils.py @@ -152,7 +152,7 @@ def get_svg_as_image(icon: str, width: int, height: int, QImage.Format.Format_ARGB32) image.setDevicePixelRatio(device_pixel_ratio) if not background_color: - image.fill(Qt.transparent) + image.fill(Qt.GlobalColor.transparent) else: image.fill(background_color) diff --git a/felt/gui/recent_maps_list_view.py b/felt/gui/recent_maps_list_view.py index 72b2a8f..58ac48d 100644 --- a/felt/gui/recent_maps_list_view.py +++ b/felt/gui/recent_maps_list_view.py @@ -79,7 +79,7 @@ def process_thumbnail(self, im_out = QImage(int(target_size.width() * device_pixel_ratio), int(target_size.height() * device_pixel_ratio), QImage.Format.Format_ARGB32) - im_out.fill(Qt.transparent) + im_out.fill(Qt.GlobalColor.transparent) painter = QPainter(im_out) painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) painter.setPen(Qt.PenStyle.NoPen) From f62f2c1c80849e892d9ad554a87ce5e4d16643ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 16:23:36 +0000 Subject: [PATCH 4/4] Fix upload failure on Qt6: QByteArray no longer accepts str appends The multipart upload body was built with QByteArray.append(str), which PyQt6 rejects with TypeError, so every map upload failed on QGIS 4. Build the body as Python bytes instead and wrap it in a QByteArray at the end, which behaves identically on PyQt5. Add a unit test covering create_upload_file_request so this path is exercised by CI without network access. https://claude.ai/code/session_01LCTq6nNRJEWyBGQ1sjipHw --- felt/core/api_client.py | 50 +++++++++++++++++++----------------- felt/test/test_api_client.py | 35 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/felt/core/api_client.py b/felt/core/api_client.py index 79a6642..8375488 100644 --- a/felt/core/api_client.py +++ b/felt/core/api_client.py @@ -342,35 +342,37 @@ def create_upload_file_request(self, b"Content-Type", b"multipart/form-data; boundary=QGISFormBoundary2XCkqVRLJ5XMxfw5") - form_content = QByteArray() + # build the form content as bytes: PyQt6 does not permit appending + # strings to QByteArray + form_content = b'' for name, value in parameters.to_form_fields().items(): - form_content.append("--QGISFormBoundary2XCkqVRLJ5XMxfw5\r\n") - form_content.append("Content-Disposition: form-data; ") - form_content.append(f"name=\"{name}\"") - form_content.append("\r\n") - form_content.append("\r\n") - form_content.append(value) - form_content.append("\r\n") - - form_content.append("--QGISFormBoundary2XCkqVRLJ5XMxfw5\r\n") - form_content.append("Content-Disposition: ") - form_content.append( - f"form-data; name=\"file\"; filename=\"{filename}\"\r\n") - form_content.append( - "Content-Type: application/octet-stream\r\n") - form_content.append("\r\n") - - form_content.append(content) - - form_content.append("\r\n") - form_content.append("--QGISFormBoundary2XCkqVRLJ5XMxfw5--\r\n") - - content_length = form_content.length() + form_content += b"--QGISFormBoundary2XCkqVRLJ5XMxfw5\r\n" + form_content += b"Content-Disposition: form-data; " + form_content += f"name=\"{name}\"".encode() + form_content += b"\r\n" + form_content += b"\r\n" + form_content += str(value).encode() + form_content += b"\r\n" + + form_content += b"--QGISFormBoundary2XCkqVRLJ5XMxfw5\r\n" + form_content += b"Content-Disposition: " + form_content += \ + f"form-data; name=\"file\"; filename=\"{filename}\"\r\n".encode() + form_content += b"Content-Type: application/octet-stream\r\n" + form_content += b"\r\n" + + form_content += content + + form_content += b"\r\n" + form_content += b"--QGISFormBoundary2XCkqVRLJ5XMxfw5--\r\n" + + form_data = QByteArray(form_content) + content_length = form_data.length() network_request.setRawHeader( b"Content-Length", str(content_length).encode() ) - return network_request, form_content + return network_request, form_data def upload_file(self, filename: str, diff --git a/felt/test/test_api_client.py b/felt/test/test_api_client.py index 978f7aa..398b9b1 100644 --- a/felt/test/test_api_client.py +++ b/felt/test/test_api_client.py @@ -246,6 +246,41 @@ def test_create_layer(self): json_params = reply.readAll().data().decode() print(json_params) + def test_create_upload_file_request(self): + """ + Test building file upload requests (no network access required) + """ + params = S3UploadParameters.from_json( + {'url': 'https://test-bucket.s3.amazonaws.com/', + 'layer_id': 'layer_1', + 'data': {'type': 'presigned_upload'}, + 'presigned_attributes': { + 'key': 'some_key', + 'policy': 'some_policy' + }}) + + request, form_content = CLIENT.create_upload_file_request( + 'test.gpkg', b'GPKG\x00\x01binary', params + ) + + self.assertEqual(request.url(), + QUrl('https://test-bucket.s3.amazonaws.com/')) + self.assertEqual(request.rawHeader(b'Host'), + b'test-bucket.s3.amazonaws.com') + + body = bytes(form_content) + self.assertIn(b'Content-Disposition: form-data; name="key"', body) + self.assertIn(b'some_key\r\n', body) + self.assertIn(b'Content-Disposition: form-data; name="policy"', body) + self.assertIn(b'some_policy\r\n', body) + self.assertIn( + b'form-data; name="file"; filename="test.gpkg"', body) + self.assertIn(b'GPKG\x00\x01binary', body) + self.assertTrue( + body.endswith(b'--QGISFormBoundary2XCkqVRLJ5XMxfw5--\r\n')) + self.assertEqual(request.rawHeader(b'Content-Length'), + str(len(body)).encode()) + @unittest.skipIf(not CLIENT.token, 'Not authorized') def test_usage(self): """