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 9756923..8b746dd 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,26 @@ 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 }} + # -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 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..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, @@ -517,7 +519,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..51edd56 100644 --- a/felt/gui/gui_utils.py +++ b/felt/gui/gui_utils.py @@ -149,10 +149,10 @@ 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) + 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 b5b258b..58ac48d 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) - im_out.fill(Qt.transparent) + QImage.Format.Format_ARGB32) + im_out.fill(Qt.GlobalColor.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..398b9b1 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,11 +241,46 @@ 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) + 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): """ @@ -257,7 +294,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