diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f74cf180..88677918 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ on: jobs: - flake8: + linter: runs-on: ubuntu-latest steps: @@ -31,18 +31,11 @@ jobs: - name: Checkout uses: actions/checkout@v4.1.4 - - uses: actions/cache@v4.0.2 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('requirements/dev.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - name: Install Python requirements - run: pip install -r requirements/dev.txt + run: make install-tests - - name: Run flake8 - run: flake8 + - name: Run ruff + run: make lint tests: runs-on: ubuntu-latest @@ -86,7 +79,7 @@ jobs: ./run-tests.sh release: - needs: [tests, flake8] + needs: [tests, linter] runs-on: ubuntu-latest if: github.repository_owner == '3liz' && contains(github.ref, 'refs/tags/') diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2da91ef8..54a973cb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,12 +28,12 @@ qgis-server: tags: - infrav3 -flake: +linter: + image: ${REGISTRY_URL}/factory-ci-runner:qgis-ltr stage: test - before_script: - - pip3 install --user -r requirements/dev.txt script: - - flake8 + - source ~/.bashrc + - make install-tests lint FLAVOR=$QGIS_FLAVOR tags: - factory diff --git a/Makefile b/Makefile index 41ca03c8..c107db33 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ LOCAL_HOME ?= $(shell pwd) SRCDIR=$(shell realpath .) +PYTHON_PKG=lizmap_server +TESTDIR=test + tests: @mkdir -p $$(pwd)/.local $(LOCAL_HOME)/.cache @echo Do not forget to run docker pull $(QGIS_IMAGE) from time to time @@ -30,3 +33,21 @@ tests: -e QGIS_SERVER_LIZMAP_REVEAL_SETTINGS=TRUE \ -e PYTEST_ADDOPTS="$(TEST_OPTS) --assert=plain" \ $(QGIS_IMAGE) ./run-tests.sh + +.PHONY: test + +install-tests: + pip install -U --upgrade-strategy=eager -r requirements/dev.txt + +export QGIS_SERVER_LIZMAP_REVEAL_SETTINGS=TRUE +test: + cd test && pytest -v --qgis-plugins=.. + +lint: + @ruff check $(PYTHON_PKG) $(TESTDIR) + +lint-preview: + @ruff check --preview $(PYTHON_PKG) $(TESTDIR) + +lint-fix: + @ruff check --fix --preview $(PYTHON_PKG) $(TESTDIR) diff --git a/lizmap_server/core.py b/lizmap_server/core.py index f7234576..b05d3d31 100755 --- a/lizmap_server/core.py +++ b/lizmap_server/core.py @@ -333,7 +333,7 @@ def is_editing_context(handler: QgsRequestHandler) -> bool: return False -def server_feature_id_expression(feature_id, data_provider: QgsVectorDataProvider) -> str: +def server_feature_id_expression(feature_id: str, data_provider: QgsVectorDataProvider) -> str: """ Fetch the QGIS server feature ID expression according to the current QGIS version. """ if Qgis.QGIS_VERSION_INT >= 32400: from qgis.server import QgsServerFeatureId @@ -344,7 +344,7 @@ def server_feature_id_expression(feature_id, data_provider: QgsVectorDataProvide return _server_feature_id_expression(feature_id, data_provider.pkAttributeIndexes(), data_provider.fields()) -def _server_feature_id_expression(feature_id, pk_attributes: list, fields: QgsFields) -> str: +def _server_feature_id_expression(feature_id: str, pk_attributes: list, fields: QgsFields) -> str: """ Port of QgsServerFeatureId::getExpressionFromServerFid for QGIS < 3.24 The value "@@" is hardcoded in the CPP file. diff --git a/lizmap_server/expression_service.py b/lizmap_server/expression_service.py index fb28a834..c6599852 100755 --- a/lizmap_server/expression_service.py +++ b/lizmap_server/expression_service.py @@ -221,7 +221,7 @@ def evaluate(params: Dict[str, str], response: QgsServerResponse, project: QgsPr 'status': 'success', 'results': [], 'errors': [], - 'features': 0 + 'features': 0, } # without features just evaluate expression with layer context @@ -421,7 +421,7 @@ def replace_expression_text( 'status': 'success', 'results': [], 'errors': [], - 'features': 0 + 'features': 0, } # without features just replace expression string with layer context @@ -536,8 +536,8 @@ def replace_expression_text( response.write( ',\n'.join([ '{"type": "FeatureCollection"', - '"features": [' + ',\n'.join(body['results']) + ']}' - ]) + '"features": [' + ',\n'.join(body['results']) + ']}', + ]), ) else: write_json_response(body, response) diff --git a/lizmap_server/filter_by_polygon.py b/lizmap_server/filter_by_polygon.py index 05d96123..10af5cdb 100755 --- a/lizmap_server/filter_by_polygon.py +++ b/lizmap_server/filter_by_polygon.py @@ -151,7 +151,7 @@ def is_valid(self) -> bool: return True - def sql_query(self, uri: QgsDataSourceUri, sql) -> Tuple[Tuple]: + def sql_query(self, uri: QgsDataSourceUri, sql: str) -> Tuple[Tuple]: """ For a given URI, execute an SQL query and return the result. """ if self.connection is None: # noinspection PyArgumentList @@ -205,7 +205,7 @@ def subset_sql(self, groups_or_user: tuple) -> Tuple[str, str]: ewkt = "SRID={crs};{wkt}".format( crs=self.polygon.crs().postgisSrid(), - wkt=polygon.asWkt(6 if self.polygon.crs().isGeographic() else 2) + wkt=polygon.asWkt(6 if self.polygon.crs().isGeographic() else 2), ) use_st_intersect = False if self.spatial_relationship == 'contains' else True @@ -266,7 +266,7 @@ def _polygon_for_groups_with_qgis_api(self, groups_or_user: tuple) -> QgsGeometr ) )""".format( polygon_field=self.group_field, - groups_or_user=','.join(groups_or_user) + groups_or_user=','.join(groups_or_user), ) # Create request @@ -492,13 +492,13 @@ def _format_qgis_expression_relationship( :returns: The QGIS expression """ geom = "geom_from_wkt('{wkt}')".format( - wkt=polygons.asWkt(6 if filtering_crs.isGeographic() else 2) + wkt=polygons.asWkt(6 if filtering_crs.isGeographic() else 2), ) if filtering_crs != filtered_crs: geom = "transform({geom}, '{from_crs}', '{to_crs}')".format( geom=geom, from_crs=filtering_crs.authid(), - to_crs=filtered_crs.authid() + to_crs=filtered_crs.authid(), ) if use_centroid: diff --git a/lizmap_server/get_feature_info.py b/lizmap_server/get_feature_info.py index df49964f..8d81c190 100755 --- a/lizmap_server/get_feature_info.py +++ b/lizmap_server/get_feature_info.py @@ -19,6 +19,7 @@ QgsFeature, QgsFeatureRequest, QgsProject, + QgsRelationManager, ) from qgis.server import QgsServerFilter, QgsServerProjectUtils @@ -78,7 +79,8 @@ def append_maptip(cls, string: str, layer_name: str, feature_id: Union[str, int] return xml_string.strip() @classmethod - def feature_list_to_replace(cls, cfg: dict, project: QgsProject, relation_manager, xml) -> List[Result]: + def feature_list_to_replace( + cls, cfg: dict, project: QgsProject, relation_manager: QgsRelationManager, xml: str) -> List[Result]: """ Parse the XML and check for each layer according to the Lizmap CFG file. """ features = [] for layer_name, feature_id in GetFeatureInfoFilter.parse_xml(xml): @@ -191,13 +193,13 @@ def responseComplete(self): # The user has clicked in a random area on the map or no interesting LAYERS, # no features are returned. logger.info( - "No features found in the XML from QGIS Server for project {}".format(project_path) + "No features found in the XML from QGIS Server for project {}".format(project_path), ) return logger.info( "Replacing the maptip from QGIS by the drag and drop expression for {} features on {}".format( - len(features), ','.join([result.layer.name() for result in features])) + len(features), ','.join([result.layer.name() for result in features])), ) # Let's evaluate each expression popup @@ -231,7 +233,7 @@ def responseComplete(self): logger.warning( "The feature {} for layer {} is not valid, skip replacing this XML " "GetFeatureInfo, continue to the next feature".format( - result.feature_id, result.layer.id()) + result.feature_id, result.layer.id()), ) continue @@ -243,7 +245,7 @@ def responseComplete(self): logger.warning( "The GetFeatureInfo result for feature {} in layer {} is not valid, skip replacing " "this XML GetFeatureInfo, , continue to the next feature".format( - result.feature_id, result.layer.id()) + result.feature_id, result.layer.id()), ) continue diff --git a/lizmap_server/get_legend_graphic.py b/lizmap_server/get_legend_graphic.py index f870893d..750016a9 100644 --- a/lizmap_server/get_legend_graphic.py +++ b/lizmap_server/get_legend_graphic.py @@ -100,7 +100,7 @@ def responseComplete(self): 'title': layer_name, 'icon': self.warning_icon(), 'valid': False, - }] + }], } handler.clearBody() handler.appendBody(json.dumps(json_data).encode('utf8')) @@ -183,7 +183,7 @@ def responseComplete(self): @classmethod def _extract_categories( - cls, layer: QgsVectorLayer, show_feature_count: bool = False, project_path: str = "" + cls, layer: QgsVectorLayer, show_feature_count: bool = False, project_path: str = "", ) -> dict: """ Extract categories from the layer legend. """ # TODO Annotations QGIS 3.22 [str, Category] @@ -209,14 +209,14 @@ def _extract_categories( if not result: Logger.warning( f"The expression in the project '{project_path}', layer '{layer.name()}' has not " - f"been generated correctly, setting the expression to an empty string" + f"been generated correctly, setting the expression to an empty string", ) expression = '' if item.label() in categories.keys(): Logger.warning( f"The label key '{item.label()}' is not unique, expect the legend to be broken in the project " - f"'{project_path}', layer '{layer.name()}'." + f"'{project_path}', layer '{layer.name()}'.", ) categories[item.label()] = Category( diff --git a/lizmap_server/lizmap_accesscontrol.py b/lizmap_server/lizmap_accesscontrol.py index d9010a6e..bd254b2d 100755 --- a/lizmap_server/lizmap_accesscontrol.py +++ b/lizmap_server/lizmap_accesscontrol.py @@ -116,14 +116,14 @@ def layerPermissions(self, layer: QgsMapLayer) -> QgsAccessControlFilter.LayerPe # Check lizmap edition config layer_id = layer.id() - if 'editionLayers' in cfg and cfg['editionLayers']: + if cfg.get('editionLayers'): if layer_id in cfg['editionLayers'] and cfg['editionLayers'][layer_id]: edit_layer = cfg['editionLayers'][layer_id] # Check if edition is possible # By default not can_edit = False - if 'acl' in edit_layer and edit_layer['acl']: + if edit_layer.get('acl'): # acl is defined and not an empty string # authorization defined for edition group_edit = edit_layer['acl'].split(',') @@ -383,7 +383,7 @@ def _filter_by_login(cfg_layer_login_filter: dict, groups: tuple, login: str) -> # Build filter layer_filter = '{} IN ({})'.format( QgsExpression.quotedColumnRef(cfg_layer_login_filter['filterAttribute']), - ', '.join(quoted_values) + ', '.join(quoted_values), ) return layer_filter diff --git a/lizmap_server/lizmap_service.py b/lizmap_server/lizmap_service.py index 33ab1d4b..8e61f238 100755 --- a/lizmap_server/lizmap_service.py +++ b/lizmap_server/lizmap_service.py @@ -112,7 +112,7 @@ def polygon_filter( body = { 'status': 'success', 'filter': ALL_FEATURES, - 'polygons': '' + 'polygons': '', } # Check first the headers to avoid unnecessary config file reading @@ -166,7 +166,7 @@ def polygon_filter( body = { 'status': 'success', 'filter': NO_FEATURES, - 'polygons': '' + 'polygons': '', } write_json_response(body, response) return @@ -199,7 +199,7 @@ def polygon_filter( body = { 'status': 'success', 'filter': NO_FEATURES, - 'polygons': '' + 'polygons': '', } write_json_response(body, response) diff --git a/lizmap_server/logger.py b/lizmap_server/logger.py index 4a997dbc..b42d9fb8 100755 --- a/lizmap_server/logger.py +++ b/lizmap_server/logger.py @@ -38,8 +38,8 @@ def log_exception(e: BaseException): Logger.critical( "Critical exception:\n{e}\n{traceback}".format( e=e, - traceback=traceback.format_exc() - ) + traceback=traceback.format_exc(), + ), ) diff --git a/lizmap_server/server_info_handler.py b/lizmap_server/server_info_handler.py index 0aaa904d..6865e179 100755 --- a/lizmap_server/server_info_handler.py +++ b/lizmap_server/server_info_handler.py @@ -62,7 +62,7 @@ def plugins_installed(py_qgis_server: bool) -> list: return server_active_plugins -def plugin_metadata_key(py_qgis_server: bool, name: str, key: str, ) -> str: +def plugin_metadata_key(py_qgis_server: bool, name: str, key: str) -> str: """ Return the version for a given plugin. """ unknown = 'unknown' # it seems configparser is transforming all keys as lowercase... @@ -89,8 +89,8 @@ def plugin_metadata_key(py_qgis_server: bool, name: str, key: str, ) -> str: ('version', str), ('build_id', Union[int, None]), ('commit_id', Union[int, None]), - ('is_stable', bool) - ] + ('is_stable', bool), + ], ) @@ -217,7 +217,7 @@ def handleRequest(self, context): 'gdal': gdal.VersionInfo('VERSION_NUM'), 'python': sys.version, 'qt': Qt.QT_VERSION_STR, - } + }, } self.write(data, context) @@ -244,6 +244,6 @@ def parameters(self, context): "CHECK_CUSTOM_HEADERS", False, QgsServerQueryStringParameter.Type.String, - "If we check custom headers" + "If we check custom headers", ), ] diff --git a/lizmap_server/tools.py b/lizmap_server/tools.py index 30efcd4c..5a647415 100755 --- a/lizmap_server/tools.py +++ b/lizmap_server/tools.py @@ -56,7 +56,7 @@ def check_environment_variable() -> bool: 'https://docs.lizmap.com/current/en/install/pre_requirements.html#lizmap-server-plugin ' 'An environment variable must be enabled to have Lizmap Web Client ≥ 3.5 working.', "Lizmap", - Qgis.Critical + Qgis.Critical, ) return False diff --git a/lizmap_server/tooltip.py b/lizmap_server/tooltip.py index ade778a6..ffb0339f 100755 --- a/lizmap_server/tooltip.py +++ b/lizmap_server/tooltip.py @@ -82,7 +82,7 @@ def create_popup_node_item_from_form( alias = field.alias() name = field.name() fname = alias if alias else name - fname = fname.replace("'", "’") + fname = fname.replace("'", "’") # noqa RUF001 # adapt the view depending on the field type field_widget_setup = field.editorWidgetSetup() @@ -145,9 +145,9 @@ def create_popup_node_item_from_form( if node.visibilityExpression().enabled(): visibility = Tooltip._generate_eval_visibility(node.visibilityExpression().data().expression()) - l = level + lvl = level # create div container - if l == 1: + if lvl == 1: active = '' if not headers: active = 'active' @@ -159,14 +159,16 @@ def create_popup_node_item_from_form( active = '{} {}'.format(active, visibility) if visibility and not active: active = visibility - h += '\n' + SPACES + '
  • {}
  • '.format( - active, regex.sub('_', node.name()), node.name()) + h += '\n' + SPACES + h += ( + '
  • {}
  • ' + ).format(active, regex.sub('_', node.name()), node.name()) headers.append(h) - if l > 1: - a += '\n' + SPACES * l + '
    '.format(visibility) - a += '\n' + SPACES * l + '{}'.format(node.name()) - a += '\n' + SPACES * l + '
    ' + if lvl > 1: + a += '\n' + SPACES * lvl + '
    '.format(visibility) + a += '\n' + SPACES * lvl + '{}'.format(node.name()) + a += '\n' + SPACES * lvl + '
    ' # In case of root children before_tabs = [] @@ -177,7 +179,7 @@ def create_popup_node_item_from_form( for n in node.children(): h = Tooltip.create_popup_node_item_from_form(layer, n, level, headers, html, relation_manager) # If it is not root children, add html - if l > 0: + if lvl > 0: a += h continue # If it is root children, store html in the right list @@ -189,7 +191,7 @@ def create_popup_node_item_from_form( else: content_tabs.append(h) - if l == 0: + if lvl == 0: if before_tabs: a += '\n
    ' a += '\n'.join(before_tabs) @@ -205,22 +207,22 @@ def create_popup_node_item_from_form( a += '\n
    ' a += '\n'.join(after_tabs) a += '\n
    ' - elif l == 1: - a += '\n' + SPACES * l + '
    ' - elif l > 1: - a += '\n' + SPACES * l + '
    ' - a += '\n' + SPACES * l + '
    ' + elif lvl == 1: + a += '\n' + SPACES * lvl + '
    ' + elif lvl > 1: + a += '\n' + SPACES * lvl + '' + a += '\n' + SPACES * lvl + '
    ' html += a return html @staticmethod - def _generate_field_view(name: str): - return '"{}"'.format(name) + def _generate_field_view(name: str) -> str: + return f'"{name}"' @staticmethod - def _generate_eval_visibility(expression: str): - return "[% if ({}, '', 'hidden') %]".format(expression) + def _generate_eval_visibility(expression: str) -> str: + return f"[% if ({expression}, '', 'hidden') %]" @staticmethod def _generate_attribute_editor_relation(label: str, relation_id: str, referencing_layer_id: str) -> str: @@ -235,7 +237,12 @@ def _generate_attribute_editor_relation(label: str, relation_id: str, referencin return result @staticmethod - def _generate_relation_reference(name: str, parent_pk: str, layer_id: str, display_expression: str): + def _generate_relation_reference( + name: str, + parent_pk: str, + layer_id: str, + display_expression: str, + ) -> str: expression = ''' "{}" = attribute(@parent, '{}') '''.format(parent_pk, name) @@ -249,12 +256,12 @@ def _generate_relation_reference(name: str, parent_pk: str, layer_id: str, displ )'''.format( layer_id, display_expression, - expression + expression, ) return field_view @staticmethod - def _generate_field_name(name: str, fname: str, expression: str): + def _generate_field_name(name: str, fname: str, expression: str) -> str: text = ''' [% CASE WHEN "{0}" IS NOT NULL OR trim("{0}") != '' @@ -267,12 +274,12 @@ def _generate_field_name(name: str, fname: str, expression: str): END %]'''.format( name, fname, - expression + expression, ) return text @staticmethod - def _generate_value_map(widget_config: Union[list, dict], name: str): + def _generate_value_map(widget_config: Union[list, dict], name: str) -> str: def escape_value(value: str) -> str: """Change ' to ’ for the HStore function. """ return value.replace("'", "’") @@ -305,7 +312,7 @@ def escape_value(value: str) -> str: return field_view @staticmethod - def _generate_external_resource(widget_config: dict, name: str, fname: str): + def _generate_external_resource(widget_config: dict, name: str, fname: str) -> str: dview = widget_config['DocumentViewer'] if dview == QgsExternalResourceWidget.Image: @@ -354,7 +361,7 @@ def _generate_external_resource(widget_config: dict, name: str, fname: str): return field_view @staticmethod - def _generate_date(widget_config: dict, name: str): + def _generate_date(widget_config: dict, name: str) -> str: date_format = widget_config.get('display_format') if not date_format: @@ -369,12 +376,12 @@ def _generate_date(widget_config: dict, name: str): return field_view @staticmethod - def _generate_value_relation(widget_config: dict, name: str): + def _generate_value_relation(widget_config: dict, name: str) -> str: vlid = widget_config['Layer'] expression = '''"{}" = attribute(@parent, '{}')'''.format( widget_config['Key'], - name + name, ) filter_exp = widget_config.get('FilterExpression', '').strip() @@ -394,19 +401,19 @@ def _generate_value_relation(widget_config: dict, name: str): )'''.format( vlid, widget_config['Value'], - expression + expression, ) return field_view @staticmethod - def _generate_text_label(label: str, expression: str): + def _generate_text_label(label: str, expression: str) -> str: text = '''

    {0}

    {1}

    '''.format( label, - expression + expression, ) return text diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7d84133b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +# Ruff configuration +# See https://doc.astral.sh/ruff/configuration + +[tool.ruff] +line-length = 120 +target-version = "py310" +exclude = [ + ".venv", + ".local", + ".test/.local", +] + +[tool.ruff.format] +indent-style = "space" + +[tool.ruff.lint] +extend-select = ["E", "F", "ANN", "W", "T", "COM", "RUF"] +ignore = ["ANN101", "ANN102", "ANN002", "ANN003", "RUF100"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["T201", "E501"] +"test/*" = ["T201", "E501"] +"lizmap_server/tooltip.py" = ["RUF001", "RUF002"] + + +[tool.ruff.lint.isort] +lines-between-types = 1 +known-third-party = [ + "qgis", +] + +[tool.ruff.lint.flake8-annotations] +ignore-fully-untyped = true +suppress-none-returning = true +suppress-dummy-args = true diff --git a/requirements/dev.txt b/requirements/dev.txt index ee214acd..863b4337 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,15 +1,6 @@ -flake8 -flake8-absolute-import -# flake8-bugbear -flake8-builtins -flake8-isort -# flake8-multiline-containers -# flake8-mutable -# flake8-pep3101 -flake8-print -# flake8-variables-names -isort -pylint -# pycodestyle -# PyQt5 -# PyQt5-stubs +pytest +ruff +mypy +mypy-extensions +pipdeptree +xmldiff diff --git a/setup.cfg b/setup.cfg index c0e1ab07..efb2e711 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,44 +1,44 @@ -[flake8] -max-line-length = 130 -ignore = - # Closing bracket does not match visual indentation - E123, - E124, - E125, - E126, - # Line length - E501, - # Bad double quotes - Q000, - Q001, - Q003, - # Line break before binary operator - W503, - -per-file-ignores = - lizmap_server/tooltip.py:E741 - lizmap_server/lizmap_service.py:ABS101 - lizmap_server/lizmap_server.py:ABS101 - lizmap_server/expression_service.py:ABS101 - lizmap_server/lizmap_accesscontrol.py:ABS101 - -exclude = - test/conftest.py, - .venv/, - .local/, - .local/lib, - ./test/.local/lib - -[isort] -multi_line_output = 3 -include_trailing_comma = True -use_parentheses = True -ensure_newline_before_comments = True -lines_between_types = 1 -skip = - .venv, - .local/, - .cache/, +; [flake8] +; max-line-length = 130 +; ignore = +; # Closing bracket does not match visual indentation +; E123, +; E124, +; E125, +; E126, +; # Line length +; E501, +; # Bad double quotes +; Q000, +; Q001, +; Q003, +; # Line break before binary operator +; W503, +; +; per-file-ignores = +; lizmap_server/tooltip.py:E741 +; lizmap_server/lizmap_service.py:ABS101 +; lizmap_server/lizmap_server.py:ABS101 +; lizmap_server/expression_service.py:ABS101 +; lizmap_server/lizmap_accesscontrol.py:ABS101 +; +; exclude = +; test/conftest.py, +; .venv/, +; .local/, +; .local/lib, +; ./test/.local/lib +; +; [isort] +; multi_line_output = 3 +; include_trailing_comma = True +; use_parentheses = True +; ensure_newline_before_comments = True +; lines_between_types = 1 +; skip = +; .venv, +; .local/, +; .cache/, [qgis-plugin-ci] plugin_path = lizmap_server diff --git a/test/conftest.py b/test/conftest.py index deb0546a..5ce62555 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -10,28 +10,31 @@ from qgis.PyQt import Qt +from typing import Any, Dict, Generator, Optional + +from qgis.core import Qgis, QgsApplication, QgsFontUtils, QgsProject +from qgis.server import ( + QgsBufferServerRequest, + QgsBufferServerResponse, + QgsServer, + QgsServerRequest, + QgsServerInterface, +) + with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=DeprecationWarning) from osgeo import gdal -logging.basicConfig( stream=sys.stderr ) +logging.basicConfig(stream=sys.stderr) logging.disable(logging.NOTSET) LOGGER = logging.getLogger('server') LOGGER.setLevel(logging.DEBUG) -from typing import Any, Dict, Generator - -from qgis.core import Qgis, QgsApplication, QgsFontUtils, QgsProject -from qgis.server import ( - QgsBufferServerRequest, - QgsBufferServerResponse, - QgsServer, - QgsServerRequest, -) qgis_application = None + def pytest_addoption(parser): parser.addoption("--qgis-plugins", metavar="PATH", help="Plugin path", default=None) @@ -60,8 +63,8 @@ def pytest_sessionstart(session): os.environ['QT_QPA_PLATFORM'] = 'offscreen' # Define this in global environment - #os.environ['QGIS_DISABLE_MESSAGE_HOOKS'] = 1 - #os.environ['QGIS_NO_OVERRIDE_IMPORT'] = 1 + # os.environ['QGIS_DISABLE_MESSAGE_HOOKS'] = 1 + # os.environ['QGIS_NO_OVERRIDE_IMPORT'] = 1 qgis_application = QgsApplication([], False) qgis_application.initQgis() @@ -86,9 +89,10 @@ def pytest_sessionstart(session): 'wcs': "http://www.opengis.net/wcs", 'ows': "http://www.opengis.net/ows/1.1", 'gml': "http://www.opengis.net/gml", - 'xsi': "http://www.w3.org/2001/XMLSchema-instance" + 'xsi': "http://www.w3.org/2001/XMLSchema-instance", } + class OWSResponse: def __init__(self, resp: QgsBufferServerResponse) -> None: @@ -128,7 +132,7 @@ def client(request): """ class _Client: - def __init__(self) -> None: + def __init__(self): self.fontFamily = QgsFontUtils.standardTestFontFamily() QgsFontUtils.loadStandardTestFonts(['All']) @@ -138,12 +142,11 @@ def __init__(self) -> None: self.datapath = request.config.rootdir.join('data') self.server = QgsServer() - # Load plugins load_plugins(self.server.serverInterface()) - def getplugin(self, name) -> Any: - """ retourne l'instance du plugin + def getplugin(self, name: str) -> Any: # noqa ANN401 + """ Return the instance of the plugin """ return server_plugins.get(name) @@ -160,7 +163,7 @@ def get_project(self, name: str) -> QgsProject: raise ValueError("Error reading project '%s':" % projectpath.strpath) return qgsproject - def get(self, query: str, project: str = None, headers: Dict[str, str] = None) -> OWSResponse: + def get(self, query: str, project: Optional[str] = None, headers: Optional[Dict[str, str]] = None) -> OWSResponse: """ Return server response from query """ if headers is None: @@ -174,7 +177,7 @@ def get(self, query: str, project: str = None, headers: Dict[str, str] = None) - self.server.handleRequest(server_request, response, project=qgsproject) return OWSResponse(response) - def get_with_project(self, query: str, project: QgsProject, headers: Dict[str, str] = None) -> OWSResponse: + def get_with_project(self, query: str, project: QgsProject, headers: Optional[Dict[str, str]] = None) -> OWSResponse: """ Return server response from query """ if headers is None: @@ -189,7 +192,7 @@ def get_with_project(self, query: str, project: QgsProject, headers: Dict[str, s ## -## Plugins +# Plugins ## def checkQgisVersion(minver: str, maxver: str) -> bool: @@ -198,22 +201,21 @@ def to_int(ver): major, *ver = ver.split('.') major = int(major) minor = int(ver[0]) if len(ver) > 0 else 0 - rev = int(ver[1]) if len(ver) > 1 else 0 + rev = int(ver[1]) if len(ver) > 1 else 0 if minor >= 99: minor = rev = 0 major += 1 if rev > 99: rev = 99 - return int("{:d}{:02d}{:02d}".format(major,minor,rev)) - + return int("{:d}{:02d}{:02d}".format(major, minor, rev)) version = to_int(Qgis.QGIS_VERSION.split('-')[0]) - minver = to_int(minver) if minver else version - maxver = to_int(maxver) if maxver else version + minver = to_int(minver) if minver else version + maxver = to_int(maxver) if maxver else version return minver <= version <= maxver -def find_plugins(pluginpath: str) -> Generator[str,None,None]: +def find_plugins(pluginpath: str) -> Generator[str, None, None]: """ Load plugins """ for plugin in glob.glob(os.path.join(plugin_path + "/*")): @@ -235,25 +237,25 @@ def find_plugins(pluginpath: str) -> Generator[str,None,None]: minver = cp['general'].get('qgisMinimumVersion') maxver = cp['general'].get('qgisMaximumVersion') except Exception as exc: - LOGGER.critical("Error reading plugin metadata '%s': %s",metadatafile,exc) + LOGGER.critical("Error reading plugin metadata '%s': %s", metadatafile, exc) continue - if not checkQgisVersion(minver,maxver): + if not checkQgisVersion(minver, maxver): LOGGER.critical(("Unsupported version for %s:" "\n MinimumVersion: %s" "\n MaximumVersion: %s" "\n Qgis version: %s" - "\n Discarding") % (plugin,minver,maxver, + "\n Discarding") % (plugin, minver, maxver, Qgis.QGIS_VERSION.split('-')[0])) continue yield os.path.basename(plugin) -server_plugins = {} +server_plugins: Dict[str, Any] = {} -def load_plugins(serverIface: 'QgsServerInterface') -> None: +def load_plugins(serverIface: QgsServerInterface) -> None: """ Start all plugins """ if not plugin_path: @@ -270,9 +272,9 @@ def load_plugins(serverIface: 'QgsServerInterface') -> None: # Initialize the plugin server_plugins[plugin] = package.serverClassFactory(serverIface) - LOGGER.info("Loaded plugin %s",plugin) + LOGGER.info("Loaded plugin %s", plugin) except: - LOGGER.error("Error loading plugin %s",plugin) + LOGGER.error("Error loading plugin %s", plugin) raise @@ -280,14 +282,14 @@ def load_plugins(serverIface: 'QgsServerInterface') -> None: # Logger hook # -def install_logger_hook( verbose: bool=False ) -> None: +def install_logger_hook(verbose: bool = False) -> None: """ Install message log hook """ - from qgis.core import Qgis, QgsApplication, QgsMessageLog + from qgis.core import Qgis, QgsApplication # Add a hook to qgis message log def writelogmessage(message, tag, level): - arg = '{}: {}'.format( tag, message ) + arg = '{}: {}'.format(tag, message) if level == Qgis.Warning: LOGGER.warning(arg) elif level == Qgis.Critical: @@ -298,4 +300,4 @@ def writelogmessage(message, tag, level): LOGGER.info(arg) messageLog = QgsApplication.messageLog() - messageLog.messageReceived.connect( writelogmessage ) + messageLog.messageReceived.connect(writelogmessage) diff --git a/test/test_expression_service_getfeaturewithformscope.py b/test/test_expression_service_getfeaturewithformscope.py index 47897631..d2493170 100644 --- a/test/test_expression_service_getfeaturewithformscope.py +++ b/test/test_expression_service_getfeaturewithformscope.py @@ -55,9 +55,9 @@ def test_comment_space_carriage_return(client): "type": "Feature", "geometry": { "type": "Point", - "coordinates": [102.0, 0.5] + "coordinates": [102.0, 0.5], }, - "properties": {"polygon_id": 4}}) + "properties": {"polygon_id": 4}}), } rv = client.get(_build_query_string(qs), project_file) b = _check_request(rv) @@ -114,8 +114,8 @@ def test_request_get_feature_form_scope_current_value(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, - "properties": {"prop0": "Bretagne"} - } + "properties": {"prop0": "Bretagne"}, + }, )} rv = client.get(_build_query_string(qs), PROJECT_FILE) b = _check_request(rv) @@ -153,8 +153,8 @@ def test_request_get_feature_form_scope_with_geom(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, - "properties": {"prop0": "Bretagne"} - } + "properties": {"prop0": "Bretagne"}, + }, ), 'WITH_GEOMETRY': 'True'} rv = client.get(_build_query_string(qs), PROJECT_FILE) @@ -192,8 +192,8 @@ def test_request_get_feature_form_scope_with_fields(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, - "properties": {"prop0": "Bretagne"} - } + "properties": {"prop0": "Bretagne"}, + }, ), 'FIELDS': 'ISO,NAME_1'} rv = client.get(_build_query_string(qs), PROJECT_FILE) @@ -230,8 +230,8 @@ def test_request_get_feature_form_scope_with_spatial_filter(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [-3.0, 48.0]}, - "properties": {"prop0": "Bretagne"} - } + "properties": {"prop0": "Bretagne"}, + }, )} rv = client.get(_build_query_string(qs), PROJECT_FILE) b = _check_request(rv) @@ -269,8 +269,8 @@ def test_request_get_feature_without_named_parameters(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, - "properties": {"polygon_id": 4} - } + "properties": {"polygon_id": 4}, + }, )} rv = client.get(_build_query_string(qs), project_file) b = _check_request(rv) @@ -295,8 +295,8 @@ def test_request_get_feature_with_named_parameters(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, - "properties": {"polygon_id": 4} - } + "properties": {"polygon_id": 4}, + }, )} rv = client.get(_build_query_string(qs), project_file) b = _check_request(rv) @@ -319,14 +319,14 @@ def test_request_given_parent_feature(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [-3.0, 48.0]}, - "properties": {"prop1": "Rennes"} - } + "properties": {"prop1": "Rennes"}, + }, ), 'PARENT_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, - "properties": {"prop0": "Bretagne"} - } + "properties": {"prop0": "Bretagne"}, + }, )} rv = client.get(_build_query_string(qs), PROJECT_FILE) b = _check_request(rv) @@ -364,14 +364,14 @@ def test_request_current_parent_feature(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [-3.0, 48.0]}, - "properties": {"prop1": "Rennes"} - } + "properties": {"prop1": "Rennes"}, + }, ), 'PARENT_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, - "properties": {"prop0": "Bretagne"} - } + "properties": {"prop0": "Bretagne"}, + }, )} rv = client.get(_build_query_string(qs), PROJECT_FILE) b = _check_request(rv) @@ -409,14 +409,14 @@ def test_request_current_parent_geometry(client): 'FORM_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [102.0, 0.5]}, - "properties": {"prop1": "Rennes"} - } + "properties": {"prop1": "Rennes"}, + }, ), 'PARENT_FEATURE': json.dumps( { "type": "Feature", "geometry": {"type": "Point", "coordinates": [-3.0, 48.0]}, - "properties": {"prop0": "Bretagne"} - } + "properties": {"prop0": "Bretagne"}, + }, )} rv = client.get(_build_query_string(qs), PROJECT_FILE) b = _check_request(rv) diff --git a/test/test_filter_by_polygon.py b/test/test_filter_by_polygon.py index 7bb9c998..78cdc91b 100644 --- a/test/test_filter_by_polygon.py +++ b/test/test_filter_by_polygon.py @@ -1,7 +1,3 @@ -__copyright__ = 'Copyright 2021, 3Liz' -__license__ = 'GPL version 3' -__email__ = 'info@3liz.org' - """ Test filter by polygon. """ import unittest @@ -17,6 +13,9 @@ from lizmap_server.filter_by_polygon import FilterByPolygon +__copyright__ = 'Copyright 2021, 3Liz' +__license__ = 'GPL version 3' +__email__ = 'info@3liz.org' class TestFilterByPolygon(unittest.TestCase): @@ -25,16 +24,16 @@ def test_not_filtered_layer(self): json = { "config": { "polygon_layer_id": "FOO", - "group_field": "groups" + "group_field": "groups", }, "layers": [ { "layer": "BAR", "primary_key": "primary", "spatial_relationship": "intersects", - "filter_mode": "display_and_editing" - } - ] + "filter_mode": "display_and_editing", + }, + ], } points = QgsVectorLayer('Point?field=id:integer', 'points', 'memory') self.assertFalse(FilterByPolygon(json, points).is_filtered()) @@ -86,16 +85,16 @@ def test_filter_by_polygon_filter(self): json = { "config": { "polygon_layer_id": polygon.id(), - "group_field": "groups" + "group_field": "groups", }, "layers": [ { "layer": points.id(), "primary_key": "id", "spatial_relationship": "intersects", - "filter_mode": "display_and_editing" - } - ] + "filter_mode": "display_and_editing", + }, + ], } project = QgsProject.instance() @@ -150,16 +149,16 @@ def test_filter_by_polygon_filter(self): json = { "config": { "polygon_layer_id": polygon.id(), - "group_field": "groups" + "group_field": "groups", }, "layers": [ { "layer": points.id(), "primary_key": "id", "spatial_relationship": "intersects", - "filter_mode": "editing" - } - ] + "filter_mode": "editing", + }, + ], } config = FilterByPolygon(json, points, editing=False) diff --git a/test/test_server_core.py b/test/test_server_core.py index 77749dcb..16a58547 100755 --- a/test/test_server_core.py +++ b/test/test_server_core.py @@ -78,9 +78,9 @@ def test_get_lizmap_layer_login_filter(self): { 'loginFilteredLayers': { 'lines-geojson': { - 'layerId': 'lines_7ddd81b1_8307_4aa2_8b7a_a0b7983f33e3' - } - } + 'layerId': 'lines_7ddd81b1_8307_4aa2_8b7a_a0b7983f33e3', + }, + }, }, 'lines-geojson')) self.assertIsNone(get_lizmap_layer_login_filter( @@ -88,9 +88,9 @@ def test_get_lizmap_layer_login_filter(self): 'loginFilteredLayers': { 'lines-geojson': { 'layerId': 'lines_7ddd81b1_8307_4aa2_8b7a_a0b7983f33e3', - 'filterAttribute': 'name' - } - } + 'filterAttribute': 'name', + }, + }, }, 'lines-geojson')) self.assertIsNone(get_lizmap_layer_login_filter( @@ -98,9 +98,9 @@ def test_get_lizmap_layer_login_filter(self): 'loginFilteredLayers': { 'lines-geojson': { 'layerId': 'lines_7ddd81b1_8307_4aa2_8b7a_a0b7983f33e3', - 'filterPrivate': 'False' - } - } + 'filterPrivate': 'False', + }, + }, }, 'lines-geojson')) self.assertIsNone(get_lizmap_layer_login_filter( @@ -108,9 +108,9 @@ def test_get_lizmap_layer_login_filter(self): 'loginFilteredLayers': { 'lines-geojson': { 'filterAttribute': 'name', - 'filterPrivate': 'False' - } - } + 'filterPrivate': 'False', + }, + }, }, 'lines-geojson')) @@ -120,9 +120,9 @@ def test_get_lizmap_layer_login_filter(self): 'layerId': 'lines_7ddd81b1_8307_4aa2_8b7a_a0b7983f33e3', 'filterAttribute': 'name', 'filterPrivate': 'False', - 'order': 0 - } - } + 'order': 0, + }, + }, } self.assertIsNone(get_lizmap_layer_login_filter(good_dict, 'foobar')) @@ -137,7 +137,7 @@ def test_get_lizmap_layer_login_filter(self): 'layerId': 'lines_7ddd81b1_8307_4aa2_8b7a_a0b7983f33e3', 'filterAttribute': 'name', 'filterPrivate': 'False', - 'order': 0 + 'order': 0, }) def test_parse_xml_get_feature_info(self): @@ -196,7 +196,7 @@ def test_edit_xml_get_feature_info_without_maptip(self): ''' self.assertEqual( ET.tostring(ET.fromstring(expected)).decode("utf-8"), - ET.tostring(ET.fromstring(response)).decode("utf-8") + ET.tostring(ET.fromstring(response)).decode("utf-8"), ) def test_edit_xml_get_feature_info_with_maptip(self): @@ -236,7 +236,7 @@ def test_edit_xml_get_feature_info_with_maptip(self): ''' self.assertEqual( ET.tostring(ET.fromstring(expected)).decode("utf-8"), - ET.tostring(ET.fromstring(response)).decode("utf-8") + ET.tostring(ET.fromstring(response)).decode("utf-8"), ) def test_feature_id_expression(self): @@ -247,13 +247,13 @@ def test_feature_id_expression(self): self.assertEqual( "", - _server_feature_id_expression("1", [], fields) + _server_feature_id_expression("1", [], fields), ) self.assertEqual( "\"field_1\" = '1'", - _server_feature_id_expression("1", ['field_1'], fields) + _server_feature_id_expression("1", ['field_1'], fields), ) self.assertEqual( "\"field_1\" = '1' AND \"field_2\" = '2'", - _server_feature_id_expression("1@@2", ['field_1', 'field_2'], fields) + _server_feature_id_expression("1@@2", ['field_1', 'field_2'], fields), ) diff --git a/test/utils.py b/test/utils.py index ff84deef..25be92d3 100644 --- a/test/utils.py +++ b/test/utils.py @@ -17,7 +17,7 @@ def _build_query_string(params: dict) -> str: return query_string -def _check_request(result, content_type: str = 'application/json', http_code=200): +def _check_request(result, content_type: str = 'application/json', http_code: int = 200): # noqa ANN401 """ Check the output and return the content. """ assert result.status_code == http_code, f'HTTP code {result.status_code}, expected {http_code}' assert result.headers.get('Content-Type', '').lower().find(content_type) == 0, f'Headers {result.headers}'