diff --git a/.gitignore b/.gitignore index 32588dd..0b53ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ docs/site /.coverage /.coverage.xml /.profile/ +/tests/resources/.process_output diff --git a/docker-compose.yml b/docker-compose.yml index 1fa1ed4..98c0cb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: qgis-server-light: image: local/opengisch/qgis-server-light-dev:latest + platform: linux/amd64 build: context: . dockerfile: ./Dockerfile diff --git a/requirements.test.txt b/requirements.test.txt index a4556f9..80414fc 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -3,3 +3,4 @@ pillow pixelmatch mypy pytest-cov +pillow diff --git a/src/qgis_server_light/exporter/cli.py b/src/qgis_server_light/exporter/cli.py index 495f754..97b2877 100644 --- a/src/qgis_server_light/exporter/cli.py +++ b/src/qgis_server_light/exporter/cli.py @@ -2,12 +2,19 @@ import os.path import click +from qgis.analysis import QgsNativeAlgorithms from qgis.core import QgsApplication -from xsdata.formats.dataclass.serializers import JsonSerializer, XmlSerializer +from xsdata.formats.dataclass.serializers import ( + DictEncoder, + JsonSerializer, + XmlSerializer, +) from xsdata.formats.dataclass.serializers.config import SerializerConfig from qgis_server_light.exporter.common import create_full_pg_service_conf from qgis_server_light.exporter.extract import Exporter +from qgis_server_light.interface.exporter.extract import Process +from qgis_server_light.worker.runner.process import algorithm_from_qgs_definition os.environ["QT_QPA_PLATFORM"] = "offscreen" QgsApplication.setPrefixPath("/usr", True) @@ -93,5 +100,28 @@ def export( raise AttributeError("Project file does not exist") +@cli.command("export-processes") +def export_processes(): + registry = qgs.processingRegistry() + qgs.setTranslation("en") + registry.addProvider(QgsNativeAlgorithms()) + process = Process( + algorithms=[ + algorithm_from_qgs_definition(registry.algorithmById(alg)) + for alg in [ + "native:buffer", + "native:centroids", + "native:concavehull", + "native:rasterlayerproperties", + "native:rescaleraster", + "native:collect", + "native:rasterize", + "native:affinetransform", + ] + ] + ) + click.echo(DictEncoder().encode(process)) + + if __name__ == "__main__": - export() + cli() diff --git a/src/qgis_server_light/exporter/extract.py b/src/qgis_server_light/exporter/extract.py index 8ce1719..0996152 100644 --- a/src/qgis_server_light/exporter/extract.py +++ b/src/qgis_server_light/exporter/extract.py @@ -1,5 +1,6 @@ import logging import re +import sys import unicodedata import zlib from base64 import urlsafe_b64encode @@ -11,6 +12,7 @@ from PyQt5.QtCore import QMetaType from PyQt5.QtXml import QDomDocument from qgis.core import ( + Qgis, QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsDataSourceUri, @@ -24,6 +26,36 @@ QgsMapLayer, QgsMeshLayer, QgsPointCloudLayer, + QgsProcessingAlgorithm, + QgsProcessingOutputBoolean, + QgsProcessingOutputDefinition, + QgsProcessingOutputFile, + QgsProcessingOutputHtml, + QgsProcessingOutputMapLayer, + QgsProcessingOutputNumber, + QgsProcessingOutputRasterLayer, + QgsProcessingOutputString, + QgsProcessingOutputVectorLayer, + QgsProcessingParameterBand, + QgsProcessingParameterBoolean, + QgsProcessingParameterCrs, + QgsProcessingParameterDefinition, + QgsProcessingParameterEnum, + QgsProcessingParameterExpression, + QgsProcessingParameterExtent, + QgsProcessingParameterFeatureSink, + QgsProcessingParameterFeatureSource, + QgsProcessingParameterField, + QgsProcessingParameterFile, + QgsProcessingParameterLayout, + QgsProcessingParameterMapTheme, + QgsProcessingParameterMultipleLayers, + QgsProcessingParameterNumber, + QgsProcessingParameterRasterDestination, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterString, + QgsProcessingParameterVectorDestination, + QgsProcessingParameterVectorLayer, QgsProject, QgsProviderRegistry, QgsRasterLayer, @@ -36,6 +68,7 @@ from qgis_server_light.interface.common import BBox, Style from qgis_server_light.interface.exporter.extract import ( + Algorithm, Config, Crs, Custom, @@ -46,7 +79,27 @@ Group, MetaData, OgrSource, + Output, + Parameter, PostgresSource, + ProcessingParameterType, + ProcessingParameterTypeAnyLayer, + ProcessingParameterTypeBand, + ProcessingParameterTypeBoolean, + ProcessingParameterTypeCrs, + ProcessingParameterTypeEnum, + ProcessingParameterTypeExpression, + ProcessingParameterTypeExtent, + ProcessingParameterTypeField, + ProcessingParameterTypeFile, + ProcessingParameterTypeFloat, + ProcessingParameterTypeInt, + ProcessingParameterTypeLayout, + ProcessingParameterTypeMapLayer, + ProcessingParameterTypeMapTheme, + ProcessingParameterTypeRasterLayer, + ProcessingParameterTypeString, + ProcessingParameterTypeVectorLayer, Project, Raster, Service, @@ -934,7 +987,10 @@ def collect(acc, pair): acc.append((our_scope_name, list_as_text)) else: acc.append( - (our_scope_name, project.readEntry(qgis_scope_name, key)[0]) + ( + our_scope_name, + project.readEntry(qgis_scope_name, key)[0], + ) ) return acc @@ -958,3 +1014,136 @@ def create_style_list(qgs_layer: QgsMapLayer) -> List[Style]: ) ) return style_list + + +def parameter_type_from_qgs_definition( + param: QgsProcessingParameterDefinition, +) -> ProcessingParameterType: + if isinstance(param, QgsProcessingParameterFeatureSource): + return ProcessingParameterTypeVectorLayer() + if isinstance(param, QgsProcessingParameterVectorLayer): + return ProcessingParameterTypeVectorLayer() + if isinstance(param, QgsProcessingParameterRasterLayer): + return ProcessingParameterTypeRasterLayer() + if isinstance(param, QgsProcessingParameterFile): + return ProcessingParameterTypeFile() + if isinstance(param, QgsProcessingParameterFeatureSink): + return ProcessingParameterTypeVectorLayer() + if isinstance(param, QgsProcessingParameterVectorDestination): + return ProcessingParameterTypeVectorLayer() + if isinstance(param, QgsProcessingParameterRasterDestination): + return ProcessingParameterTypeRasterLayer() + if isinstance(param, QgsProcessingParameterMultipleLayers): + minimum = param.minimumNumberInputs() + match param.layerType(): + case Qgis.ProcessingSourceType.MapLayer: + layer_type = ProcessingParameterTypeMapLayer() + case Qgis.ProcessingSourceType.Raster: + layer_type = ProcessingParameterTypeRasterLayer() + case ( + Qgis.ProcessingSourceType.Vector + | Qgis.ProcessingSourceType.VectorAnyGeometry + | Qgis.ProcessingSourceType.VectorPoint + | Qgis.ProcessingSourceType.VectorLine + | Qgis.ProcessingSourceType.VectorPolygon + ): + layer_type = ProcessingParameterTypeVectorLayer() + case unsupported: + raise ValueError(f"unsupported ProcessingSourceType: {unsupported}") + return ProcessingParameterTypeAnyLayer( + layer_type=layer_type, + minimum=minimum, + ) + if isinstance(param, QgsProcessingParameterBand): + return ProcessingParameterTypeBand(allow_multiple=param.allowMultiple()) + if isinstance(param, QgsProcessingParameterNumber): + if (maximum := param.maximum()) >= sys.float_info.max: + maximum = None + if (minimum := param.minimum()) <= sys.float_info.min: + minimum = None + match param.dataType(): + case Qgis.ProcessingNumberParameterType.Double: + return ProcessingParameterTypeFloat(minimum=minimum, maximum=maximum) + case Qgis.ProcessingNumberParameterType.Integer: + minimum = None if minimum is None else int(minimum) + maximum = None if maximum is None else int(maximum) + return ProcessingParameterTypeInt(minimum=minimum, maximum=maximum) + if isinstance(param, QgsProcessingParameterString): + return ProcessingParameterTypeString() + if isinstance(param, QgsProcessingParameterExpression): + return ProcessingParameterTypeExpression() + if isinstance(param, QgsProcessingParameterCrs): + return ProcessingParameterTypeCrs() + if isinstance(param, QgsProcessingParameterLayout): + return ProcessingParameterTypeLayout() + if isinstance(param, QgsProcessingParameterField): + return ProcessingParameterTypeField(allow_multiple=param.allowMultiple()) + if isinstance(param, QgsProcessingParameterEnum): + return ProcessingParameterTypeEnum( + options=param.options(), allow_multiple=param.allowMultiple() + ) + if isinstance(param, QgsProcessingParameterBoolean): + return ProcessingParameterTypeBoolean() + if isinstance(param, QgsProcessingParameterExtent): + return ProcessingParameterTypeExtent() + if isinstance(param, QgsProcessingParameterMapTheme): + return ProcessingParameterTypeMapTheme() + else: + logging.error(f"invalid parameter: {param.name()}, {param.type()}, {param}") + raise ValueError(f"parameter: {param}") + + +def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Parameter: + return Parameter( + name=param.name(), + type=parameter_type_from_qgs_definition(param), + description=param.description(), + optional=bool(param.flags() & Qgis.ProcessingParameterFlag.Optional), + default=param.defaultValue(), + is_destination=param.isDestination(), + ) + + +def output_type_from_qgs_definition(output: QgsProcessingOutputDefinition) -> Output: + if isinstance(output, QgsProcessingOutputMapLayer): + return ProcessingParameterTypeMapLayer() + elif isinstance(output, QgsProcessingOutputRasterLayer): + return ProcessingParameterTypeRasterLayer() + elif isinstance(output, QgsProcessingOutputVectorLayer): + return ProcessingParameterTypeVectorLayer() + elif isinstance(output, QgsProcessingOutputFile): + return ProcessingParameterTypeFile() + elif isinstance(output, QgsProcessingOutputHtml): + return ProcessingParameterTypeString() + elif isinstance(output, QgsProcessingOutputNumber): + return ProcessingParameterTypeFloat() + elif isinstance(output, QgsProcessingOutputString): + return ProcessingParameterTypeString() + elif isinstance(output, QgsProcessingOutputBoolean): + return ProcessingParameterTypeBoolean() + else: + logging.error(f"invalid output: {output.name()}, {output.type()}, {output}") + raise ValueError(f"output: {output}") + + +def output_from_qgs_definition(output: QgsProcessingOutputDefinition) -> Output: + return Output( + name=output.name(), + description=output.description(), + type=output_type_from_qgs_definition(output), + ) + + +def algorithm_from_qgs_definition(alg: QgsProcessingAlgorithm) -> Algorithm: + algorithm = Algorithm( + id=alg.id(), + name=alg.name(), + display_name=alg.displayName(), + short_help_string=alg.shortHelpString(), + short_description=alg.shortDescription(), + ) + for param in alg.parameterDefinitions(): + algorithm.parameters.append(parameter_from_qgs_definition(param)) + for output in alg.outputDefinitions(): + algorithm.outputs.append(output_from_qgs_definition(output)) + return algorithm diff --git a/src/qgis_server_light/interface/dispatcher/redis_asio.py b/src/qgis_server_light/interface/dispatcher/redis_asio.py index 76f2489..2619e05 100644 --- a/src/qgis_server_light/interface/dispatcher/redis_asio.py +++ b/src/qgis_server_light/interface/dispatcher/redis_asio.py @@ -29,6 +29,10 @@ QslJobInfoLegend, QslJobParameterLegend, ) +from qgis_server_light.interface.job.process.input import ( + QslJobInfoExecuteProcess, + QslJobParameterExecuteProcess, +) from qgis_server_light.interface.job.render.input import ( QslJobInfoRender, QslJobParameterRender, @@ -83,6 +87,7 @@ async def post( | QslJobParameterFeatureInfo | QslJobParameterLegend | QslJobParameterFeature + | QslJobParameterExecuteProcess ), to: float = 10.0, ) -> tuple[JobResult, str]: @@ -113,6 +118,10 @@ async def post( job_info = QslJobInfoFeature( id=job_id, type=QslJobInfoFeature.__name__, job=job_parameter ) + elif isinstance(job_parameter, QslJobParameterExecuteProcess): + job_info = QslJobInfoExecuteProcess( + id=job_id, type=QslJobInfoExecuteProcess.__name__, job=job_parameter + ) else: return ( JobResult( diff --git a/src/qgis_server_light/interface/exporter/extract.py b/src/qgis_server_light/interface/exporter/extract.py index ea11eaf..58369e2 100644 --- a/src/qgis_server_light/interface/exporter/extract.py +++ b/src/qgis_server_light/interface/exporter/extract.py @@ -7,7 +7,7 @@ from abc import ABC from dataclasses import dataclass, field, fields from datetime import UTC, datetime -from typing import List, Optional +from typing import List, Optional, TypeAlias from qgis_server_light.interface.common import BaseInterface, BBox, Style @@ -638,3 +638,177 @@ class Config(BaseInterface): meta_data: MetaData = field(metadata={"type": "Element"}) tree: Tree = field(metadata={"type": "Element"}) datasets: Datasets = field(metadata={"type": "Element"}) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeString: + name: str = field(metadata={"type": "Element"}, default="str") + length: int | None = field(metadata={"type": "Element"}, default=None) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeBoolean: + name: str = field(metadata={"type": "Element"}, default="bool") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeFloat: + name: str = field(metadata={"type": "Element"}, default="float") + minimum: float | None = field(metadata={"type": "Element"}, default=None) + maximum: float | None = field(metadata={"type": "Element"}, default=None) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeInt: + name: str = field(metadata={"type": "Element"}, default="int") + minimum: int | None = field(metadata={"type": "Element"}, default=None) + maximum: int | None = field(metadata={"type": "Element"}, default=None) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeExtent: + name: str = field(metadata={"type": "Element"}, default="extent") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeCrs: + name: str = field(metadata={"type": "Element"}, default="crs") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeBand: + name: str = field(metadata={"type": "Element"}, default="band") + allow_multiple: bool = field(metadata={"type": "Element"}, default=False) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeField: + name: str = field(metadata={"type": "Element"}, default="field") + allow_multiple: bool = field(metadata={"type": "Element"}, default=False) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeLayout: + name: str = field(metadata={"type": "Element"}, default="layout") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeMapTheme: + name: str = field(metadata={"type": "Element"}, default="map_theme") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeExpression: + name: str = field(metadata={"type": "Element"}, default="expression") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeEnum: + name: str = field(metadata={"type": "Element"}, default="enum") + options: list[str] = field(metadata={"type": "Element"}) + allow_multiple: bool = field(metadata={"type": "Element"}, default=False) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeVectorLayer: + name: str = field(metadata={"type": "Element"}, default="vector_layer") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeRasterLayer: + name: str = field(metadata={"type": "Element"}, default="raster_layer") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeFile: + name: str = field(metadata={"type": "Element"}, default="file") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeMapLayer: + name: str = field(metadata={"type": "Element"}, default="map_layer") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeAnyLayer: + name: str = field(metadata={"type": "Element"}, default="multiple_layers") + layer_type: ( + ProcessingParameterTypeVectorLayer + | ProcessingParameterTypeRasterLayer + | ProcessingParameterTypeMapLayer + ) = field(metadata={"type": "Element"}) + minimum: int = field(metadata={"type": "Element"}) + + +ProcessingParameterType: TypeAlias = ( + ProcessingParameterTypeString + | ProcessingParameterTypeBoolean + | ProcessingParameterTypeFloat + | ProcessingParameterTypeInt + | ProcessingParameterTypeExtent + | ProcessingParameterTypeCrs + | ProcessingParameterTypeBand + | ProcessingParameterTypeField + | ProcessingParameterTypeLayout + | ProcessingParameterTypeMapTheme + | ProcessingParameterTypeExpression + | ProcessingParameterTypeEnum + | ProcessingParameterTypeVectorLayer + | ProcessingParameterTypeRasterLayer + | ProcessingParameterTypeFile + | ProcessingParameterTypeAnyLayer +) + + +@dataclass +class Parameter(BaseInterface): + name: str = field(metadata={"type": "Element"}) + type: ProcessingParameterType = field(metadata={"type": "Element"}) + optional: bool = field(metadata={"type": "Element"}) + default: str | int | float | bool = field(metadata={"type": "Element"}) + description: str = field(metadata={"type": "Element"}) + is_destination: bool = field(metadata={"type": "Element"}) + + @property + def shortened_fields(self) -> set: + return {"description"} + + +@dataclass +class Output(BaseInterface): + name: str = field(metadata={"type": "Element"}) + type: ProcessingParameterType = field(metadata={"type": "Element"}) + description: str = field(metadata={"type": "Element"}) + + @property + def shortened_fields(self) -> set: + return {"description"} + + +@dataclass +class Algorithm: + id: str = field(metadata={"type": "Element"}) + name: str = field(metadata={"type": "Element"}) + display_name: str = field(metadata={"type": "Element"}) + short_help_string: str = field(metadata={"type": "Element"}) + short_description: str = field(metadata={"type": "Element"}) + parameters: list[Parameter] = field( + default_factory=list, metadata={"type": "Element"} + ) + outputs: list[Output] = field(default_factory=list, metadata={"type": "Element"}) + + +@dataclass +class Process: + # uniqueness is not assured here! + algorithms: list[Algorithm] = field( + default_factory=list, metadata={"type": "Element"} + ) + + def algorithm_by_id(self, algorithm_id: str): + for algorithm in self.algorithms: + if algorithm.id == algorithm_id: + return algorithm + raise LookupError( + f"Algorithm with {algorithm_id} was not found in {self.algorithms}" + ) diff --git a/src/qgis_server_light/interface/job/process/input.py b/src/qgis_server_light/interface/job/process/input.py index e69de29..f89cb49 100644 --- a/src/qgis_server_light/interface/job/process/input.py +++ b/src/qgis_server_light/interface/job/process/input.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from qgis_server_light.interface.job.common.input import ( + QslJobInfoParameter, + QslJobLayer, + QslJobParameter, +) + + +@dataclass +class ParameterInput: + name: str = field(metadata={"type": "Element"}) + value: QslJobLayer | str | int | float | bool = field(metadata={"type": "Element"}) + + +@dataclass(kw_only=True) +class QslJobParameterExecuteProcess(QslJobParameter): + """A runner to execute a process""" + + process_id: str = field(metadata={"type": "Element"}) + parameters: list[ParameterInput] = field(metadata={"type": "Element"}) + + +@dataclass +class QslJobInfoExecuteProcess(QslJobInfoParameter): + job: QslJobParameterExecuteProcess = field( + metadata={"type": "Element", "required": True} + ) diff --git a/src/qgis_server_light/interface/job/process/process_list.py b/src/qgis_server_light/interface/job/process/process_list.py new file mode 100644 index 0000000..e122cc8 --- /dev/null +++ b/src/qgis_server_light/interface/job/process/process_list.py @@ -0,0 +1,588 @@ +available = { + "algorithms": [ + { + "id": "native:buffer", + "name": "buffer", + "display_name": "Buffer", + "short_help_string": "This algorithm computes a buffer area for all the features in an input layer, using a fixed or dynamic distance.\n\nThe segments parameter controls the number of line segments to use to approximate a quarter circle when creating rounded offsets.\n\nThe end cap style parameter controls how line endings are handled in the buffer.\n\nThe join style parameter specifies whether round, miter or beveled joins should be used when offsetting corners in a line.\n\nThe miter limit parameter is only applicable for miter join styles, and controls the maximum distance from the offset curve to use when creating a mitered join.", + "short_description": "", + "parameters": [ + { + "name": "INPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Input layer", + "is_destination": False, + }, + { + "name": "DISTANCE", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 10, + "description": "Distance", + "is_destination": False, + }, + { + "name": "SEGMENTS", + "type": {"name": "int", "minimum": 1, "maximum": None}, + "optional": False, + "default": 5, + "description": "Segments", + "is_destination": False, + }, + { + "name": "END_CAP_STYLE", + "type": { + "name": "enum", + "options": ["Round", "Flat", "Square"], + "allow_multiple": False, + }, + "optional": False, + "default": 0, + "description": "End cap style", + "is_destination": False, + }, + { + "name": "JOIN_STYLE", + "type": { + "name": "enum", + "options": ["Round", "Miter", "Bevel"], + "allow_multiple": False, + }, + "optional": False, + "default": 0, + "description": "Join style", + "is_destination": False, + }, + { + "name": "MITER_LIMIT", + "type": {"name": "float", "minimum": 1.0, "maximum": None}, + "optional": False, + "default": 2, + "description": "Miter limit", + "is_destination": False, + }, + { + "name": "DISSOLVE", + "type": {"name": "bool"}, + "optional": False, + "default": False, + "description": "Dissolve result", + "is_destination": False, + }, + { + "name": "SEPARATE_DISJOINT", + "type": {"name": "bool"}, + "optional": False, + "default": False, + "description": "Keep disjoint results separate", + "is_destination": False, + }, + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Buffered", + "is_destination": True, + }, + ], + "outputs": [ + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "description": "Buffered", + } + ], + }, + { + "id": "native:centroids", + "name": "centroids", + "display_name": "Centroids", + "short_help_string": "This algorithm creates a new point layer, with points representing the centroid of the geometries in an input layer.\n\nThe attributes associated to each point in the output layer are the same ones associated to the original features.", + "short_description": "", + "parameters": [ + { + "name": "INPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Input layer", + "is_destination": False, + }, + { + "name": "ALL_PARTS", + "type": {"name": "bool"}, + "optional": False, + "default": False, + "description": "Create centroid for each part", + "is_destination": False, + }, + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Centroids", + "is_destination": True, + }, + ], + "outputs": [ + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "description": "Centroids", + } + ], + }, + { + "id": "native:concavehull", + "name": "concavehull", + "display_name": "Concave hull", + "short_help_string": "This algorithm computes the concave hull of the features from an input layer.", + "short_description": "", + "parameters": [ + { + "name": "INPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Input layer", + "is_destination": False, + }, + { + "name": "ALPHA", + "type": {"name": "float", "minimum": None, "maximum": 1.0}, + "optional": False, + "default": 0.3, + "description": "Threshold (0-1, where 1 is equivalent with Convex Hull)", + "is_destination": False, + }, + { + "name": "HOLES", + "type": {"name": "bool"}, + "optional": False, + "default": True, + "description": "Allow holes", + "is_destination": False, + }, + { + "name": "NO_MULTIGEOMETRY", + "type": {"name": "bool"}, + "optional": False, + "default": False, + "description": "Split multipart geometry into singleparts", + "is_destination": False, + }, + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Concave hull", + "is_destination": True, + }, + ], + "outputs": [ + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "description": "Concave hull", + } + ], + }, + { + "id": "native:rasterlayerproperties", + "name": "rasterlayerproperties", + "display_name": "Raster layer properties", + "short_help_string": "This algorithm returns basic properties of the given raster layer, including the extent, size in pixels and dimensions of pixels (in map units).\n\nIf an optional band number is specified then the NoData value for the selected band will also be returned.", + "short_description": "", + "parameters": [ + { + "name": "INPUT", + "type": {"name": "raster_layer"}, + "optional": False, + "default": None, + "description": "Input layer", + "is_destination": False, + }, + { + "name": "BAND", + "type": {"name": "band", "allow_multiple": False}, + "optional": True, + "default": None, + "description": "Band number", + "is_destination": False, + }, + ], + "outputs": [ + { + "name": "X_MIN", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Minimum x-coordinate", + }, + { + "name": "X_MAX", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Maximum x-coordinate", + }, + { + "name": "Y_MIN", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Minimum y-coordinate", + }, + { + "name": "Y_MAX", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Maximum y-coordinate", + }, + { + "name": "EXTENT", + "type": {"name": "str", "length": None}, + "description": "Extent", + }, + { + "name": "PIXEL_WIDTH", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Pixel size (width) in map units", + }, + { + "name": "PIXEL_HEIGHT", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Pixel size (height) in map units", + }, + { + "name": "CRS_AUTHID", + "type": {"name": "str", "length": None}, + "description": "CRS authority identifier", + }, + { + "name": "WIDTH_IN_PIXELS", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Width in pixels", + }, + { + "name": "HEIGHT_IN_PIXELS", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Height in pixels", + }, + { + "name": "HAS_NODATA_VALUE", + "type": {"name": "bool"}, + "description": "Band has a NoData value set", + }, + { + "name": "NODATA_VALUE", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Band NoData value", + }, + { + "name": "BAND_COUNT", + "type": {"name": "float", "minimum": None, "maximum": None}, + "description": "Number of bands in raster", + }, + ], + }, + { + "id": "native:rescaleraster", + "name": "rescaleraster", + "display_name": "Rescale raster", + "short_help_string": "Rescales raster layer to a new value range, while preserving the shape (distribution) of the raster's histogram (pixel values). Input values are mapped using a linear interpolation from the source raster's minimum and maximum pixel values to the destination minimum and maximum pixel range.\n\nBy default the algorithm preserves the original NoData value, but there is an option to override it.", + "short_description": "", + "parameters": [ + { + "name": "INPUT", + "type": {"name": "raster_layer"}, + "optional": False, + "default": None, + "description": "Input raster", + "is_destination": False, + }, + { + "name": "BAND", + "type": {"name": "band", "allow_multiple": False}, + "optional": False, + "default": 1, + "description": "Band number", + "is_destination": False, + }, + { + "name": "MINIMUM", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 0, + "description": "New minimum value", + "is_destination": False, + }, + { + "name": "MAXIMUM", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 255, + "description": "New maximum value", + "is_destination": False, + }, + { + "name": "NODATA", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": True, + "default": None, + "description": "New NoData value", + "is_destination": False, + }, + { + "name": "CREATE_OPTIONS", + "type": {"name": "str", "length": None}, + "optional": True, + "default": None, + "description": "Creation options", + "is_destination": False, + }, + { + "name": "OUTPUT", + "type": {"name": "raster_layer"}, + "optional": False, + "default": None, + "description": "Rescaled", + "is_destination": True, + }, + ], + "outputs": [ + { + "name": "OUTPUT", + "type": {"name": "raster_layer"}, + "description": "Rescaled", + } + ], + }, + { + "id": "native:collect", + "name": "collect", + "display_name": "Collect geometries", + "short_help_string": "This algorithm takes a vector layer and collects its geometries into new multipart geometries. One or more attributes can be specified to collect only geometries belonging to the same class (having the same value for the specified attributes), alternatively all geometries can be collected.\n\nAll output geometries will be converted to multi geometries, even those with just a single part. This algorithm does not dissolve overlapping geometries - they will be collected together without modifying the shape of each geometry part.\n\nSee the 'Promote to multipart' or 'Aggregate' algorithms for alternative options.", + "short_description": "", + "parameters": [ + { + "name": "INPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Input layer", + "is_destination": False, + }, + { + "name": "FIELD", + "type": {"name": "field", "allow_multiple": True}, + "optional": True, + "default": None, + "description": "Unique ID fields", + "is_destination": False, + }, + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Collected", + "is_destination": True, + }, + ], + "outputs": [ + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "description": "Collected", + } + ], + }, + { + "id": "native:rasterize", + "name": "rasterize", + "display_name": "Convert map to raster", + "short_help_string": "This algorithm rasterizes map canvas content.\n\nA map theme can be selected to render a predetermined set of layers with a defined style for each layer. Alternatively, a set of layers can be selected if no map theme is set. If neither map theme nor layer is set, all the visible layers in the set extent will be rendered.\n\nThe minimum extent entered will internally be extended to a multiple of the tile size.", + "short_description": "Renders the map canvas to a raster file.", + "parameters": [ + { + "name": "EXTENT", + "type": {"name": "extent"}, + "optional": False, + "default": None, + "description": "Minimum extent to render", + "is_destination": False, + }, + { + "name": "EXTENT_BUFFER", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": True, + "default": 0, + "description": "Buffer around tiles in map units", + "is_destination": False, + }, + { + "name": "TILE_SIZE", + "type": {"name": "int", "minimum": 64, "maximum": None}, + "optional": False, + "default": 1024, + "description": "Tile size", + "is_destination": False, + }, + { + "name": "MAP_UNITS_PER_PIXEL", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": True, + "default": 100, + "description": "Map units per pixel", + "is_destination": False, + }, + { + "name": "MAKE_BACKGROUND_TRANSPARENT", + "type": {"name": "bool"}, + "optional": False, + "default": False, + "description": "Make background transparent", + "is_destination": False, + }, + { + "name": "MAP_THEME", + "type": {"name": "map_theme"}, + "optional": True, + "default": None, + "description": "Map theme to render", + "is_destination": False, + }, + { + "name": "LAYERS", + "type": { + "name": "multiple_layers", + "layer_type": {"name": "map_layer"}, + "minimum": 0, + }, + "optional": True, + "default": None, + "description": "Layers to render", + "is_destination": False, + }, + { + "name": "OUTPUT", + "type": {"name": "raster_layer"}, + "optional": False, + "default": None, + "description": "Output layer", + "is_destination": True, + }, + ], + "outputs": [ + { + "name": "OUTPUT", + "type": {"name": "raster_layer"}, + "description": "Output layer", + } + ], + }, + { + "id": "native:affinetransform", + "name": "affinetransform", + "display_name": "Affine transform", + "short_help_string": "Applies an affine transformation to the geometries from a layer. Affine transformations can include translation, scaling and rotation. The operations are performed in a scale, rotation, translation order.\n\nZ and M values present in the geometry can also be translated and scaled independently.", + "short_description": "Applies an affine transformation to geometries.", + "parameters": [ + { + "name": "INPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Input layer", + "is_destination": False, + }, + { + "name": "DELTA_X", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 0.0, + "description": "Translation (x-axis)", + "is_destination": False, + }, + { + "name": "DELTA_Y", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 0.0, + "description": "Translation (y-axis)", + "is_destination": False, + }, + { + "name": "DELTA_Z", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 0.0, + "description": "Translation (z-axis)", + "is_destination": False, + }, + { + "name": "DELTA_M", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 0.0, + "description": "Translation (m values)", + "is_destination": False, + }, + { + "name": "SCALE_X", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 1.0, + "description": "Scale factor (x-axis)", + "is_destination": False, + }, + { + "name": "SCALE_Y", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 1.0, + "description": "Scale factor (y-axis)", + "is_destination": False, + }, + { + "name": "SCALE_Z", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 1.0, + "description": "Scale factor (z-axis)", + "is_destination": False, + }, + { + "name": "SCALE_M", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 1.0, + "description": "Scale factor (m values)", + "is_destination": False, + }, + { + "name": "ROTATION_Z", + "type": {"name": "float", "minimum": None, "maximum": None}, + "optional": False, + "default": 0.0, + "description": "Rotation around z-axis (degrees counter-clockwise)", + "is_destination": False, + }, + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "optional": False, + "default": None, + "description": "Transformed", + "is_destination": True, + }, + ], + "outputs": [ + { + "name": "OUTPUT", + "type": {"name": "vector_layer"}, + "description": "Transformed", + } + ], + }, + ] +} diff --git a/src/qgis_server_light/worker/redis.py b/src/qgis_server_light/worker/redis.py index 2512692..e0158ec 100644 --- a/src/qgis_server_light/worker/redis.py +++ b/src/qgis_server_light/worker/redis.py @@ -232,6 +232,7 @@ def main() -> None: [ "qgis_server_light.worker.runner.render.RenderRunner", "qgis_server_light.worker.runner.feature.GetFeatureRunner", + "qgis_server_light.worker.runner.process.ProcessRunner", # Not fully functional yet # "qgis_server_light.worker.runner.feature_info.GetFeatureInfoRunner", ], diff --git a/src/qgis_server_light/worker/runner/process.py b/src/qgis_server_light/worker/runner/process.py index ecfa171..f15910f 100644 --- a/src/qgis_server_light/worker/runner/process.py +++ b/src/qgis_server_light/worker/runner/process.py @@ -1,30 +1,75 @@ -import logging from typing import Optional -from qgis.analysis import QgsNativeAlgorithms, QgsPdalAlgorithms -from qgis.core import Qgis, QgsApplication, QgsProviderRegistry -from qgis.processing import ProcessingAlgFactory +from qgis.analysis import QgsNativeAlgorithms +from qgis.core import ( + Qgis, + QgsApplication, + QgsProcessingContext, + QgsProcessingFeedback, +) -from qgis_server_light.interface.job.common.input import QslJobInfoParameter +from qgis_server_light.exporter.extract import algorithm_from_qgs_definition +from qgis_server_light.interface.exporter.extract import ( + Process, +) +from qgis_server_light.interface.job.common.input import QslJobLayer +from qgis_server_light.interface.job.common.output import JobResult +from qgis_server_light.interface.job.process.input import ( + QslJobInfoExecuteProcess, + QslJobParameterExecuteProcess, +) from qgis_server_light.worker.runner.common import JobContext, MapRunner class ProcessRunner(MapRunner): + job_info_class = QslJobInfoExecuteProcess + def __init__( self, qgis: QgsApplication, context: JobContext, - job_info: QslJobInfoParameter, + job_info: QslJobInfoExecuteProcess, layer_cache: Optional[dict], ): super().__init__(qgis, context, job_info, layer_cache) + self.registry = self.qgis.processingRegistry() + self.registry.addProvider(QgsNativeAlgorithms()) + + @classmethod + def info(cls, qgis: Qgis) -> Process: + registry = qgis.processingRegistry() + algorithms = registry.algorithms() + process = Process() + for alg in algorithms: + algorithm = algorithm_from_qgs_definition(alg) + process.algorithms.append(algorithm) + return process + + def run(self): + job: QslJobParameterExecuteProcess = self.job_info.job + algorithm = self.registry.algorithmById(job.process_id) + if algorithm is None: + return JobResult( + id=self.job_info.id, + data={"result": {}, "ok": False, "log": "Algorithm not found"}, + content_type="application/json", + ) + + parameters = {} + for param in job.parameters: + if isinstance(param.value, QslJobLayer): + parameters[param.name] = self._handle_layer_cache(param.value) + elif isinstance(param.value, (str, int, float, bool)): + parameters[param.name] = param.value + else: + raise ValueError(f"Unexpected value: {param.value}") - ProcessingAlgFactory() - providers = QgsProviderRegistry.instance().pluginList().split("\n") - logging.info("Found Providers:") - for provider in providers: - logging.info(f" - {provider}") + context = QgsProcessingContext() + feedback = QgsProcessingFeedback() + result, ok = algorithm.run(parameters, context, feedback) - def load_providers(self, qgis: Qgis): - qgis.processingRegistry().addProvider(QgsNativeAlgorithms()) - qgis.processingRegistry().addProvider(QgsPdalAlgorithms()) + return JobResult( + id=self.job_info.id, + data={"result": result, "ok": ok, "log": feedback.textLog()}, + content_type="application/json", + ) diff --git a/tests/integration/worker/runner/test_process.py b/tests/integration/worker/runner/test_process.py new file mode 100644 index 0000000..9257e22 --- /dev/null +++ b/tests/integration/worker/runner/test_process.py @@ -0,0 +1,114 @@ +import json +import uuid +from pathlib import Path + +import pytest + +from qgis_server_light.interface.exporter.extract import OgrSource +from qgis_server_light.interface.job.common.input import QslJobLayer +from qgis_server_light.interface.job.common.output import JobResult +from qgis_server_light.interface.job.process.input import ( + ParameterInput, + QslJobInfoExecuteProcess, + QslJobParameterExecuteProcess, +) +from qgis_server_light.worker.runner.common import JobContext +from qgis_server_light.worker.runner.process import ProcessRunner + + +class TestProcessRunnerIntegration: + @pytest.mark.parametrize( + "job_layer", + [ + QslJobLayer( + id=str(uuid.uuid4()), + name="test-local-gpkg", + source=json.dumps( + OgrSource( + path="placenames.gpkg", layer_name="placenames" + ).to_qgis_decoded_uri + ), + remote=False, + folder_name="data", + driver="ogr", + ), + ], + ) + def test_execute_buffer(self, qgis_app, data_path, job_layer): + job_id = str(uuid.uuid4()) + output_layer_path = f"{data_path}/.process_output/{job_id}/Buffered.geojson" + Path(output_layer_path).parent.mkdir(parents=True) + job_info = QslJobInfoExecuteProcess( + id=job_id, + type=QslJobInfoExecuteProcess.__name__, + job=QslJobParameterExecuteProcess( + process_id="native:buffer", + parameters=[ + ParameterInput("INPUT", job_layer), + ParameterInput("DISTANCE", 1.0), + ParameterInput("OUTPUT", str(output_layer_path)), + ParameterInput("END_CAP_STYLE", "Round"), + ParameterInput("JOIN_STYLE", "Round"), + ParameterInput("MITER_LIMIT", 2.0), + ParameterInput("DISSOLVE", False), + ParameterInput("SEPARATE_DISJOINT", False), + ], + ), + ) + + runner = ProcessRunner( + qgis_app, + JobContext(base_path=data_path), + job_info, + {}, + ) + result = runner.run() + assert isinstance(result, JobResult) + assert result.id == job_id + assert result.data["ok"] + assert result.data["result"]["OUTPUT"] == output_layer_path + + @pytest.mark.parametrize( + "job_layer", + [ + QslJobLayer( + id=str(uuid.uuid4()), + name="test-local-gpkg", + source=json.dumps( + OgrSource( + path="placenames.gpkg", layer_name="placenames" + ).to_qgis_decoded_uri + ), + remote=False, + folder_name="data", + driver="ogr", + ), + ], + ) + def test_execute_centroids(self, qgis_app, data_path, job_layer): + job_id = str(uuid.uuid4()) + output_layer_path = f"{data_path}/.process_output/{job_id}/Centroids.geojson" + Path(output_layer_path).parent.mkdir(parents=True) + job_info = QslJobInfoExecuteProcess( + id=job_id, + type=QslJobInfoExecuteProcess.__name__, + job=QslJobParameterExecuteProcess( + process_id="native:centroids", + parameters=[ + ParameterInput("INPUT", job_layer), + ParameterInput("OUTPUT", output_layer_path), + ], + ), + ) + + runner = ProcessRunner( + qgis_app, + JobContext(base_path=data_path), + job_info, + {}, + ) + result = runner.run() + assert isinstance(result, JobResult) + assert result.id == job_id + assert result.data["ok"] + assert result.data["result"]["OUTPUT"] == output_layer_path diff --git a/tests/unit/interface/job/process/test_algorithm.py b/tests/unit/interface/job/process/test_algorithm.py new file mode 100644 index 0000000..7315052 --- /dev/null +++ b/tests/unit/interface/job/process/test_algorithm.py @@ -0,0 +1,150 @@ +from qgis.analysis import QgsNativeAlgorithms + +from qgis_server_light.interface.exporter.extract import ( + Algorithm, + Output, + Parameter, + ProcessingParameterTypeBoolean, + ProcessingParameterTypeEnum, + ProcessingParameterTypeFloat, + ProcessingParameterTypeInt, + ProcessingParameterTypeVectorLayer, +) +from qgis_server_light.worker.runner.process import algorithm_from_qgs_definition + + +def test_some_algorithms(qgis_app): + registry = qgis_app.processingRegistry() + registry.addProvider(QgsNativeAlgorithms()) + for alg_name in [ + "native:buffer", + "native:centroids", + "native:concavehull", + "native:rasterlayerproperties", + "native:rescaleraster", + "native:collect", + "native:rasterize", + "native:affinetransform", + ]: + algorithm = registry.algorithmById(alg_name) + assert algorithm is not None, alg_name + algorithm_from_qgs_definition(algorithm) + + +def test_algorithm_from_qgs_definition_native_buffer(qgis_app): + registry = qgis_app.processingRegistry() + registry.addProvider(QgsNativeAlgorithms()) + buffer = registry.algorithmById("native:buffer") + assert buffer is not None + + mapped = algorithm_from_qgs_definition(buffer) + assert mapped == Algorithm( + id="native:buffer", + name="buffer", + display_name="Buffer", + help_string="", + parameters=[ + Parameter( + name="INPUT", + description="Input layer", + type=ProcessingParameterTypeVectorLayer(), + optional=False, + default=None, + is_destination=False, + ), + Parameter( + name="DISTANCE", + description="Distance", + type=ProcessingParameterTypeFloat(), + optional=False, + default=10, + is_destination=False, + ), + Parameter( + name="SEGMENTS", + description="Segments", + type=ProcessingParameterTypeInt(minimum=1), + optional=False, + default=5, + is_destination=False, + ), + Parameter( + name="END_CAP_STYLE", + description="End cap style", + type=ProcessingParameterTypeEnum( + options=[ + "Round", + "Flat", + "Square", + ] + ), + optional=False, + default=0, + is_destination=False, + ), + Parameter( + name="JOIN_STYLE", + description="Join style", + type=ProcessingParameterTypeEnum( + options=[ + "Round", + "Miter", + "Bevel", + ] + ), + optional=False, + default=0, + is_destination=False, + ), + Parameter( + name="MITER_LIMIT", + description="Miter limit", + type=ProcessingParameterTypeFloat(minimum=1.0), + optional=False, + default=2, + is_destination=False, + ), + Parameter( + name="DISSOLVE", + description="Dissolve result", + type=ProcessingParameterTypeBoolean(), + optional=False, + default=False, + is_destination=False, + ), + Parameter( + name="SEPARATE_DISJOINT", + description="Keep disjoint results separate", + type=ProcessingParameterTypeBoolean(), + optional=False, + default=False, + is_destination=False, + ), + Parameter( + name="OUTPUT", + type=ProcessingParameterTypeVectorLayer(), + optional=False, + description="Buffered", + default=None, + is_destination=True, + ), + ], + outputs=[ + Output( + name="OUTPUT", + description="Buffered", + type=ProcessingParameterTypeVectorLayer(), + ) + ], + ) + + assert isinstance(mapped, Algorithm) + assert mapped.id == buffer.id() + assert mapped.name == buffer.name() + assert mapped.display_name == buffer.displayName() + + expected_parameters = [param for param in buffer.parameterDefinitions()] + + assert len(mapped.parameters) == len(expected_parameters) + assert all(isinstance(param, Parameter) for param in mapped.parameters) + assert len(mapped.outputs) == len(buffer.outputDefinitions())