From d59c4ed1ccefb43825dcc19862759800f1df9713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A8le=20Ferotin?= Date: Mon, 13 Apr 2026 10:16:15 +0200 Subject: [PATCH 01/10] Add processing job runner --- .gitignore | 1 + docker-compose.yml | 1 + requirements.test.txt | 1 + src/qgis_server_light/exporter/cli.py | 28 ++- .../interface/exporter/extract.py | 48 ++++ .../interface/job/process/input.py | 28 +++ .../worker/runner/process.py | 236 +++++++++++++++++- .../integration/worker/runner/test_process.py | 114 +++++++++ .../interface/job/process/test_algorithm.py | 130 ++++++++++ 9 files changed, 573 insertions(+), 14 deletions(-) create mode 100644 tests/integration/worker/runner/test_process.py create mode 100644 tests/unit/interface/job/process/test_algorithm.py diff --git a/.gitignore b/.gitignore index 32588dd..ed84d8c 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..1d19bd2 100644 --- a/src/qgis_server_light/exporter/cli.py +++ b/src/qgis_server_light/exporter/cli.py @@ -2,12 +2,15 @@ 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.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 +96,28 @@ def export( raise AttributeError("Project file does not exist") +@cli.command("export-processes") +def export_processes(): + serializer_config = SerializerConfig(indent=" ") + registry = qgs.processingRegistry() + 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(JsonSerializer(config=serializer_config).render(process)) + + if __name__ == "__main__": - export() + cli() diff --git a/src/qgis_server_light/interface/exporter/extract.py b/src/qgis_server_light/interface/exporter/extract.py index ea11eaf..db4e7c7 100644 --- a/src/qgis_server_light/interface/exporter/extract.py +++ b/src/qgis_server_light/interface/exporter/extract.py @@ -638,3 +638,51 @@ class Config(BaseInterface): meta_data: MetaData = field(metadata={"type": "Element"}) tree: Tree = field(metadata={"type": "Element"}) datasets: Datasets = field(metadata={"type": "Element"}) + + +@dataclass +class Parameter(BaseInterface): + name: str = field(metadata={"type": "Element"}) + type: str = field(metadata={"type": "Element"}) + schema: dict = field(metadata={"type": "Attributes"}) + optional: bool = field(metadata={"type": "Element"}) + default: str | int | float | bool = field( + metadata={"type": "Element"}, default=None + ) + description: str | None = field(default=None, metadata={"type": "Element"}) + + @property + def shortened_fields(self) -> set: + return {"description"} + + +@dataclass +class Output(BaseInterface): + name: str = field(metadata={"type": "Element"}) + type: str = field(metadata={"type": "Element"}) + schema: dict = field(metadata={"type": "Attributes"}) + description: str | None = field(default=None, 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"}) + help_string: str | None = field(default=None, 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"} + ) 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/worker/runner/process.py b/src/qgis_server_light/worker/runner/process.py index ecfa171..cdf4385 100644 --- a/src/qgis_server_light/worker/runner/process.py +++ b/src/qgis_server_light/worker/runner/process.py @@ -1,30 +1,240 @@ import logging +import sys 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, + QgsProcessingAlgorithm, + QgsProcessingContext, + QgsProcessingFeedback, + QgsProcessingOutputBoolean, + QgsProcessingOutputDefinition, + QgsProcessingOutputMapLayer, + QgsProcessingOutputNumber, + QgsProcessingOutputPointCloudLayer, + QgsProcessingOutputRasterLayer, + QgsProcessingOutputString, + QgsProcessingOutputVectorLayer, + QgsProcessingOutputVectorTileLayer, + QgsProcessingParameterBand, + QgsProcessingParameterBoolean, + QgsProcessingParameterDefinition, + QgsProcessingParameterEnum, + QgsProcessingParameterExtent, + QgsProcessingParameterFeatureSink, + QgsProcessingParameterFeatureSource, + QgsProcessingParameterField, + QgsProcessingParameterMapTheme, + QgsProcessingParameterMultipleLayers, + QgsProcessingParameterNumber, + QgsProcessingParameterRasterDestination, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterString, +) -from qgis_server_light.interface.job.common.input import QslJobInfoParameter +from qgis_server_light.interface.exporter.extract import ( + Algorithm, + Output, + Parameter, + 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 +def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Parameter: + if isinstance(param, QgsProcessingParameterFeatureSource): + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterRasterLayer): + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterFeatureSink): + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterMultipleLayers): + schema = {"type": "array", "items": {"type": "string"}} + if (min_items := param.minimumNumberInputs()) >= 1: + schema["minItems"] = min_items + elif isinstance(param, QgsProcessingParameterRasterDestination): + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterBand): + schema = {"type": "integer", "minimum": 1} + if param.allowMultiple(): + schema = {"type": "array", "minItems": 1, "items": schema} + elif isinstance(param, QgsProcessingParameterNumber): + match param.dataType(): + case Qgis.ProcessingNumberParameterType.Double: + schema = {"type": "number"} + case Qgis.ProcessingNumberParameterType.Integer: + schema = {"type": "integer"} + if (maximum := param.maximum()) < sys.float_info.max: + schema["maximum"] = maximum + if (minimum := param.minimum()) > sys.float_info.min: + schema["minimum"] = minimum + elif isinstance(param, QgsProcessingParameterString): + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterField): + schema = {"type": "string"} + if param.allowMultiple(): + schema = {"type": "array", "minItems": 1, "items": schema} + elif isinstance(param, QgsProcessingParameterEnum): + schema = {"type": "string", "enum": param.options()} + elif isinstance(param, QgsProcessingParameterBoolean): + schema = {"type": "boolean"} + elif isinstance(param, QgsProcessingParameterExtent): + schema = { + "oneOf": [ + { + "type": "array", + "items": {"type": "number"}, + "minItems": 4, + "maxItems": 4, + }, + { + "type": "array", + "items": {"type": "number"}, + "minItems": 6, + "maxItems": 6, + }, + ] + } + elif isinstance(param, QgsProcessingParameterMapTheme): + schema = {"type": "string"} + else: + print(f"parameter: {param}") + raise NotImplementedError(f"parameter: {param}") + + return Parameter( + name=param.name(), + type=param.type(), + description=param.description(), + schema=schema, + optional=bool(param.flags() & Qgis.ProcessingParameterFlag.Optional), + default=param.defaultValue(), + ) + + +def output_from_qgs_definition(output: QgsProcessingOutputDefinition) -> Output: + if isinstance( + output, + ( + QgsProcessingOutputMapLayer, + QgsProcessingOutputPointCloudLayer, + QgsProcessingOutputRasterLayer, + QgsProcessingOutputVectorLayer, + QgsProcessingOutputVectorTileLayer, + ), + ): + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputNumber): + schema = {"type": "number"} + elif isinstance(output, QgsProcessingOutputString): + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputBoolean): + schema = {"type": "boolean"} + else: + print(f"output: {output}") + raise NotImplementedError(f"output: {output}") + return Output( + name=output.name(), + type=output.type(), + description=output.description(), + schema=schema, + ) + + +def algorithm_from_qgs_definition(alg: QgsProcessingAlgorithm) -> Algorithm: + algorithm = Algorithm( + id=alg.id(), + name=alg.name(), + display_name=alg.displayName(), + help_string=alg.helpString(), + ) + 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 + + 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}") + + context = QgsProcessingContext() + feedback = QgsProcessingFeedback() + result, ok = algorithm.run(parameters, context, feedback) + for foo in result.items(): + logging.info(foo) + + # for output in algorithm.outputDefinitions(): + # if isinstance( + # output, + # (QgsProcessingOutputRasterLayer, QgsProcessingOutputVectorLayer), + # ): + # output_path = Path(self.context.base_path) / self.job_info.id + # output_path.mkdir(parents=True, exist_ok=True) + # layer_name = result[output.name()] + + # suffix = ( + # ".tif" + # if isinstance(output, QgsProcessingOutputRasterLayer) + # else ".gpkg" + # ) + # output_filename = Path(layer_name).with_suffix(suffix) + # target_path = output_path / output_filename + + # layer = context.getMapLayer(layer_name) + # logging.info(layer) - ProcessingAlgFactory() - providers = QgsProviderRegistry.instance().pluginList().split("\n") - logging.info("Found Providers:") - for provider in providers: - logging.info(f" - {provider}") + # result[output.name()] = str(target_path) - 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..99c501f --- /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.gpkg" + 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.gpkg" + 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..c598686 --- /dev/null +++ b/tests/unit/interface/job/process/test_algorithm.py @@ -0,0 +1,130 @@ +import logging + +from qgis.analysis import QgsNativeAlgorithms + +from qgis_server_light.interface.exporter.extract import Algorithm, Output, Parameter +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 in [ + "native:buffer", + "native:centroids", + "native:concavehull", + "native:rasterlayerproperties", + "native:rescaleraster", + "native:collect", + "native:rasterize", + "native:affinetransform", + ]: + logging.debug(alg) + algorithm_from_qgs_definition(registry.algorithmById(alg)) + + +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="source", + schema={"type": "string"}, + optional=False, + default=None, + ), + Parameter( + name="DISTANCE", + description="Distance", + type="distance", + schema={"type": "number"}, + optional=False, + default=10, + ), + Parameter( + name="SEGMENTS", + description="Segments", + type="number", + schema={"type": "integer", "minimum": 1.0}, + optional=False, + default=5, + ), + Parameter( + name="END_CAP_STYLE", + description="End cap style", + type="enum", + schema={"type": "string", "enum": ["Round", "Flat", "Square"]}, + optional=False, + default=0, + ), + Parameter( + name="JOIN_STYLE", + description="Join style", + type="enum", + schema={"type": "string", "enum": ["Round", "Miter", "Bevel"]}, + optional=False, + default=0, + ), + Parameter( + name="MITER_LIMIT", + description="Miter limit", + type="number", + schema={"type": "number", "minimum": 1.0}, + optional=False, + default=2, + ), + Parameter( + name="DISSOLVE", + description="Dissolve result", + type="boolean", + schema={"type": "boolean"}, + optional=False, + default=False, + ), + Parameter( + name="SEPARATE_DISJOINT", + description="Keep disjoint results separate", + type="boolean", + schema={"type": "boolean"}, + optional=False, + default=False, + ), + Parameter( + name="OUTPUT", + type="sink", + schema={"type": "string"}, + optional=False, + description="Buffered", + ), + ], + outputs=[ + Output( + name="OUTPUT", + description="Buffered", + type="outputVector", + schema={"type": "string"}, + ) + ], + ) + + 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()) From 49ea404f49ef28cb4019f4ba3c38b4ffc95432bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A8le=20Ferotin?= Date: Thu, 16 Apr 2026 15:28:37 +0200 Subject: [PATCH 02/10] Handle more parameter types and include classname in dataclass definition --- .../interface/exporter/extract.py | 10 +- .../worker/runner/process.py | 108 +++++++++++------- .../interface/job/process/test_algorithm.py | 20 +++- 3 files changed, 87 insertions(+), 51 deletions(-) diff --git a/src/qgis_server_light/interface/exporter/extract.py b/src/qgis_server_light/interface/exporter/extract.py index db4e7c7..6fe974b 100644 --- a/src/qgis_server_light/interface/exporter/extract.py +++ b/src/qgis_server_light/interface/exporter/extract.py @@ -646,10 +646,9 @@ class Parameter(BaseInterface): type: str = field(metadata={"type": "Element"}) schema: dict = field(metadata={"type": "Attributes"}) optional: bool = field(metadata={"type": "Element"}) - default: str | int | float | bool = field( - metadata={"type": "Element"}, default=None - ) - description: str | None = field(default=None, metadata={"type": "Element"}) + default: str | int | float | bool = field(metadata={"type": "Element"}) + description: str = field(metadata={"type": "Element"}) + classname: str = field(metadata={"type": "Element"}) @property def shortened_fields(self) -> set: @@ -661,7 +660,8 @@ class Output(BaseInterface): name: str = field(metadata={"type": "Element"}) type: str = field(metadata={"type": "Element"}) schema: dict = field(metadata={"type": "Attributes"}) - description: str | None = field(default=None, metadata={"type": "Element"}) + description: str = field(metadata={"type": "Element"}) + classname: str = field(metadata={"type": "Element"}) @property def shortened_fields(self) -> set: diff --git a/src/qgis_server_light/worker/runner/process.py b/src/qgis_server_light/worker/runner/process.py index cdf4385..aa65f56 100644 --- a/src/qgis_server_light/worker/runner/process.py +++ b/src/qgis_server_light/worker/runner/process.py @@ -8,9 +8,12 @@ QgsApplication, QgsProcessingAlgorithm, QgsProcessingContext, + QgsProcessingDestinationParameter, QgsProcessingFeedback, QgsProcessingOutputBoolean, QgsProcessingOutputDefinition, + QgsProcessingOutputFile, + QgsProcessingOutputHtml, QgsProcessingOutputMapLayer, QgsProcessingOutputNumber, QgsProcessingOutputPointCloudLayer, @@ -20,18 +23,22 @@ QgsProcessingOutputVectorTileLayer, QgsProcessingParameterBand, QgsProcessingParameterBoolean, + QgsProcessingParameterCrs, QgsProcessingParameterDefinition, QgsProcessingParameterEnum, + QgsProcessingParameterExpression, QgsProcessingParameterExtent, QgsProcessingParameterFeatureSink, QgsProcessingParameterFeatureSource, QgsProcessingParameterField, + QgsProcessingParameterFile, + QgsProcessingParameterLayout, QgsProcessingParameterMapTheme, QgsProcessingParameterMultipleLayers, QgsProcessingParameterNumber, - QgsProcessingParameterRasterDestination, QgsProcessingParameterRasterLayer, QgsProcessingParameterString, + QgsProcessingParameterVectorLayer, ) from qgis_server_light.interface.exporter.extract import ( @@ -51,22 +58,35 @@ def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Parameter: if isinstance(param, QgsProcessingParameterFeatureSource): + classname = "QgsProcessingParameterFeatureSource" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterVectorLayer): + classname = "QgsProcessingParameterVectorLayer" schema = {"type": "string"} elif isinstance(param, QgsProcessingParameterRasterLayer): + classname = "QgsProcessingParameterRasterLayer" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterFile): + classname = "QgsProcessingParameterFile" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingDestinationParameter): + classname = "QgsProcessingDestinationParameter" schema = {"type": "string"} elif isinstance(param, QgsProcessingParameterFeatureSink): + classname = "QgsProcessingParameterFeatureSink" schema = {"type": "string"} elif isinstance(param, QgsProcessingParameterMultipleLayers): + classname = "QgsProcessingParameterMultipleLayers" schema = {"type": "array", "items": {"type": "string"}} if (min_items := param.minimumNumberInputs()) >= 1: schema["minItems"] = min_items - elif isinstance(param, QgsProcessingParameterRasterDestination): - schema = {"type": "string"} elif isinstance(param, QgsProcessingParameterBand): + classname = "QgsProcessingParameterBand" schema = {"type": "integer", "minimum": 1} if param.allowMultiple(): schema = {"type": "array", "minItems": 1, "items": schema} elif isinstance(param, QgsProcessingParameterNumber): + classname = "QgsProcessingParameterNumber" match param.dataType(): case Qgis.ProcessingNumberParameterType.Double: schema = {"type": "number"} @@ -77,16 +97,30 @@ def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Pa if (minimum := param.minimum()) > sys.float_info.min: schema["minimum"] = minimum elif isinstance(param, QgsProcessingParameterString): + classname = "QgsProcessingParameterString" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterExpression): + classname = "QgsProcessingParameterExpression" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterCrs): + classname = "QgsProcessingParameterCrs" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterLayout): + classname = "QgsProcessingParameterLayout" schema = {"type": "string"} elif isinstance(param, QgsProcessingParameterField): + classname = "QgsProcessingParameterField" schema = {"type": "string"} if param.allowMultiple(): schema = {"type": "array", "minItems": 1, "items": schema} elif isinstance(param, QgsProcessingParameterEnum): + classname = "QgsProcessingParameterEnum" schema = {"type": "string", "enum": param.options()} elif isinstance(param, QgsProcessingParameterBoolean): + classname = "QgsProcessingParameterBoolean" schema = {"type": "boolean"} elif isinstance(param, QgsProcessingParameterExtent): + classname = "QgsProcessingParameterExtent" schema = { "oneOf": [ { @@ -104,15 +138,17 @@ def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Pa ] } elif isinstance(param, QgsProcessingParameterMapTheme): + classname = "QgsProcessingParameterMapTheme" schema = {"type": "string"} else: - print(f"parameter: {param}") - raise NotImplementedError(f"parameter: {param}") + logging.error(f"invalid parameter: {param.name()}, {param.type()}, {param}") + raise ValueError(f"parameter: {param}") return Parameter( name=param.name(), type=param.type(), description=param.description(), + classname=classname, schema=schema, optional=bool(param.flags() & Qgis.ProcessingParameterFlag.Optional), default=param.defaultValue(), @@ -120,31 +156,45 @@ def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Pa def output_from_qgs_definition(output: QgsProcessingOutputDefinition) -> Output: - if isinstance( - output, - ( - QgsProcessingOutputMapLayer, - QgsProcessingOutputPointCloudLayer, - QgsProcessingOutputRasterLayer, - QgsProcessingOutputVectorLayer, - QgsProcessingOutputVectorTileLayer, - ), - ): + if isinstance(output, QgsProcessingOutputMapLayer): + classname = "QgsProcessingOutputMapLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputPointCloudLayer): + classname = "QgsProcessingOutputPointCloudLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputRasterLayer): + classname = "QgsProcessingOutputRasterLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputVectorLayer): + classname = "QgsProcessingOutputVectorLayer" schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputVectorTileLayer): + classname = "QgsProcessingOutputVectorTileLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputFile): + classname = "QgsProcessingOutputFile" + schema = {"type": "number"} + elif isinstance(output, QgsProcessingOutputHtml): + classname = "QgsProcessingOutputHtml" + schema = {"type": "number"} elif isinstance(output, QgsProcessingOutputNumber): + classname = "QgsProcessingOutputNumber" schema = {"type": "number"} elif isinstance(output, QgsProcessingOutputString): + classname = "QgsProcessingOutputString" schema = {"type": "string"} elif isinstance(output, QgsProcessingOutputBoolean): + classname = "QgsProcessingOutputBoolean" schema = {"type": "boolean"} else: - print(f"output: {output}") - raise NotImplementedError(f"output: {output}") + logging.error(f"invalid output: {output.name()}, {output.type()}, {output}") + raise ValueError(f"output: {output}") return Output( name=output.name(), type=output.type(), description=output.description(), schema=schema, + classname=classname, ) @@ -208,30 +258,6 @@ def run(self): context = QgsProcessingContext() feedback = QgsProcessingFeedback() result, ok = algorithm.run(parameters, context, feedback) - for foo in result.items(): - logging.info(foo) - - # for output in algorithm.outputDefinitions(): - # if isinstance( - # output, - # (QgsProcessingOutputRasterLayer, QgsProcessingOutputVectorLayer), - # ): - # output_path = Path(self.context.base_path) / self.job_info.id - # output_path.mkdir(parents=True, exist_ok=True) - # layer_name = result[output.name()] - - # suffix = ( - # ".tif" - # if isinstance(output, QgsProcessingOutputRasterLayer) - # else ".gpkg" - # ) - # output_filename = Path(layer_name).with_suffix(suffix) - # target_path = output_path / output_filename - - # layer = context.getMapLayer(layer_name) - # logging.info(layer) - - # result[output.name()] = str(target_path) return JobResult( id=self.job_info.id, diff --git a/tests/unit/interface/job/process/test_algorithm.py b/tests/unit/interface/job/process/test_algorithm.py index c598686..0c2d660 100644 --- a/tests/unit/interface/job/process/test_algorithm.py +++ b/tests/unit/interface/job/process/test_algorithm.py @@ -1,5 +1,3 @@ -import logging - from qgis.analysis import QgsNativeAlgorithms from qgis_server_light.interface.exporter.extract import Algorithm, Output, Parameter @@ -9,7 +7,7 @@ def test_some_algorithms(qgis_app): registry = qgis_app.processingRegistry() registry.addProvider(QgsNativeAlgorithms()) - for alg in [ + for alg_name in [ "native:buffer", "native:centroids", "native:concavehull", @@ -19,8 +17,9 @@ def test_some_algorithms(qgis_app): "native:rasterize", "native:affinetransform", ]: - logging.debug(alg) - algorithm_from_qgs_definition(registry.algorithmById(alg)) + 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): @@ -41,6 +40,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="Input layer", type="source", schema={"type": "string"}, + classname="QgsProcessingParameterFeatureSource", optional=False, default=None, ), @@ -49,6 +49,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="Distance", type="distance", schema={"type": "number"}, + classname="QgsProcessingParameterNumber", optional=False, default=10, ), @@ -57,6 +58,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="Segments", type="number", schema={"type": "integer", "minimum": 1.0}, + classname="QgsProcessingParameterNumber", optional=False, default=5, ), @@ -65,6 +67,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="End cap style", type="enum", schema={"type": "string", "enum": ["Round", "Flat", "Square"]}, + classname="QgsProcessingParameterEnum", optional=False, default=0, ), @@ -73,6 +76,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="Join style", type="enum", schema={"type": "string", "enum": ["Round", "Miter", "Bevel"]}, + classname="QgsProcessingParameterEnum", optional=False, default=0, ), @@ -81,6 +85,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="Miter limit", type="number", schema={"type": "number", "minimum": 1.0}, + classname="QgsProcessingParameterNumber", optional=False, default=2, ), @@ -89,6 +94,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="Dissolve result", type="boolean", schema={"type": "boolean"}, + classname="QgsProcessingParameterBoolean", optional=False, default=False, ), @@ -97,6 +103,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="Keep disjoint results separate", type="boolean", schema={"type": "boolean"}, + classname="QgsProcessingParameterBoolean", optional=False, default=False, ), @@ -104,8 +111,10 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): name="OUTPUT", type="sink", schema={"type": "string"}, + classname="QgsProcessingDestinationParameter", optional=False, description="Buffered", + default=None, ), ], outputs=[ @@ -114,6 +123,7 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): description="Buffered", type="outputVector", schema={"type": "string"}, + classname="QgsProcessingOutputVectorLayer", ) ], ) From 32822ec71bb87e59163eb576b8cfd60ff910153d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A8le=20Ferotin?= Date: Thu, 16 Apr 2026 15:38:41 +0200 Subject: [PATCH 03/10] Move conversion functions to extract.py --- src/qgis_server_light/exporter/extract.py | 199 +++++++++++++++++- .../worker/runner/process.py | 193 +---------------- 2 files changed, 197 insertions(+), 195 deletions(-) diff --git a/src/qgis_server_light/exporter/extract.py b/src/qgis_server_light/exporter/extract.py index 8ce1719..745dd43 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,37 @@ QgsMapLayer, QgsMeshLayer, QgsPointCloudLayer, + QgsProcessingAlgorithm, + QgsProcessingDestinationParameter, + QgsProcessingOutputBoolean, + QgsProcessingOutputDefinition, + QgsProcessingOutputFile, + QgsProcessingOutputHtml, + QgsProcessingOutputMapLayer, + QgsProcessingOutputNumber, + QgsProcessingOutputPointCloudLayer, + QgsProcessingOutputRasterLayer, + QgsProcessingOutputString, + QgsProcessingOutputVectorLayer, + QgsProcessingOutputVectorTileLayer, + QgsProcessingParameterBand, + QgsProcessingParameterBoolean, + QgsProcessingParameterCrs, + QgsProcessingParameterDefinition, + QgsProcessingParameterEnum, + QgsProcessingParameterExpression, + QgsProcessingParameterExtent, + QgsProcessingParameterFeatureSink, + QgsProcessingParameterFeatureSource, + QgsProcessingParameterField, + QgsProcessingParameterFile, + QgsProcessingParameterLayout, + QgsProcessingParameterMapTheme, + QgsProcessingParameterMultipleLayers, + QgsProcessingParameterNumber, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterString, + QgsProcessingParameterVectorLayer, QgsProject, QgsProviderRegistry, QgsRasterLayer, @@ -36,6 +69,7 @@ from qgis_server_light.interface.common import BBox, Style from qgis_server_light.interface.exporter.extract import ( + Algorithm, Config, Crs, Custom, @@ -46,6 +80,8 @@ Group, MetaData, OgrSource, + Output, + Parameter, PostgresSource, Project, Raster, @@ -933,9 +969,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]) - ) + acc.append(( + our_scope_name, + project.readEntry(qgis_scope_name, key)[0], + )) return acc @@ -958,3 +995,159 @@ def create_style_list(qgs_layer: QgsMapLayer) -> List[Style]: ) ) return style_list + + +def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Parameter: + if isinstance(param, QgsProcessingParameterFeatureSource): + classname = "QgsProcessingParameterFeatureSource" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterVectorLayer): + classname = "QgsProcessingParameterVectorLayer" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterRasterLayer): + classname = "QgsProcessingParameterRasterLayer" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterFile): + classname = "QgsProcessingParameterFile" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingDestinationParameter): + classname = "QgsProcessingDestinationParameter" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterFeatureSink): + classname = "QgsProcessingParameterFeatureSink" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterMultipleLayers): + classname = "QgsProcessingParameterMultipleLayers" + schema = {"type": "array", "items": {"type": "string"}} + if (min_items := param.minimumNumberInputs()) >= 1: + schema["minItems"] = min_items + elif isinstance(param, QgsProcessingParameterBand): + classname = "QgsProcessingParameterBand" + schema = {"type": "integer", "minimum": 1} + if param.allowMultiple(): + schema = {"type": "array", "minItems": 1, "items": schema} + elif isinstance(param, QgsProcessingParameterNumber): + classname = "QgsProcessingParameterNumber" + match param.dataType(): + case Qgis.ProcessingNumberParameterType.Double: + schema = {"type": "number"} + case Qgis.ProcessingNumberParameterType.Integer: + schema = {"type": "integer"} + if (maximum := param.maximum()) < sys.float_info.max: + schema["maximum"] = maximum + if (minimum := param.minimum()) > sys.float_info.min: + schema["minimum"] = minimum + elif isinstance(param, QgsProcessingParameterString): + classname = "QgsProcessingParameterString" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterExpression): + classname = "QgsProcessingParameterExpression" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterCrs): + classname = "QgsProcessingParameterCrs" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterLayout): + classname = "QgsProcessingParameterLayout" + schema = {"type": "string"} + elif isinstance(param, QgsProcessingParameterField): + classname = "QgsProcessingParameterField" + schema = {"type": "string"} + if param.allowMultiple(): + schema = {"type": "array", "minItems": 1, "items": schema} + elif isinstance(param, QgsProcessingParameterEnum): + classname = "QgsProcessingParameterEnum" + schema = {"type": "string", "enum": param.options()} + elif isinstance(param, QgsProcessingParameterBoolean): + classname = "QgsProcessingParameterBoolean" + schema = {"type": "boolean"} + elif isinstance(param, QgsProcessingParameterExtent): + classname = "QgsProcessingParameterExtent" + schema = { + "oneOf": [ + { + "type": "array", + "items": {"type": "number"}, + "minItems": 4, + "maxItems": 4, + }, + { + "type": "array", + "items": {"type": "number"}, + "minItems": 6, + "maxItems": 6, + }, + ] + } + elif isinstance(param, QgsProcessingParameterMapTheme): + classname = "QgsProcessingParameterMapTheme" + schema = {"type": "string"} + else: + logging.error(f"invalid parameter: {param.name()}, {param.type()}, {param}") + raise ValueError(f"parameter: {param}") + + return Parameter( + name=param.name(), + type=param.type(), + description=param.description(), + classname=classname, + schema=schema, + optional=bool(param.flags() & Qgis.ProcessingParameterFlag.Optional), + default=param.defaultValue(), + ) + + +def output_from_qgs_definition(output: QgsProcessingOutputDefinition) -> Output: + if isinstance(output, QgsProcessingOutputMapLayer): + classname = "QgsProcessingOutputMapLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputPointCloudLayer): + classname = "QgsProcessingOutputPointCloudLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputRasterLayer): + classname = "QgsProcessingOutputRasterLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputVectorLayer): + classname = "QgsProcessingOutputVectorLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputVectorTileLayer): + classname = "QgsProcessingOutputVectorTileLayer" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputFile): + classname = "QgsProcessingOutputFile" + schema = {"type": "number"} + elif isinstance(output, QgsProcessingOutputHtml): + classname = "QgsProcessingOutputHtml" + schema = {"type": "number"} + elif isinstance(output, QgsProcessingOutputNumber): + classname = "QgsProcessingOutputNumber" + schema = {"type": "number"} + elif isinstance(output, QgsProcessingOutputString): + classname = "QgsProcessingOutputString" + schema = {"type": "string"} + elif isinstance(output, QgsProcessingOutputBoolean): + classname = "QgsProcessingOutputBoolean" + schema = {"type": "boolean"} + else: + logging.error(f"invalid output: {output.name()}, {output.type()}, {output}") + raise ValueError(f"output: {output}") + return Output( + name=output.name(), + type=output.type(), + description=output.description(), + schema=schema, + classname=classname, + ) + + +def algorithm_from_qgs_definition(alg: QgsProcessingAlgorithm) -> Algorithm: + algorithm = Algorithm( + id=alg.id(), + name=alg.name(), + display_name=alg.displayName(), + help_string=alg.helpString(), + ) + 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/worker/runner/process.py b/src/qgis_server_light/worker/runner/process.py index aa65f56..f15910f 100644 --- a/src/qgis_server_light/worker/runner/process.py +++ b/src/qgis_server_light/worker/runner/process.py @@ -1,50 +1,15 @@ -import logging -import sys from typing import Optional from qgis.analysis import QgsNativeAlgorithms from qgis.core import ( Qgis, QgsApplication, - QgsProcessingAlgorithm, QgsProcessingContext, - QgsProcessingDestinationParameter, QgsProcessingFeedback, - QgsProcessingOutputBoolean, - QgsProcessingOutputDefinition, - QgsProcessingOutputFile, - QgsProcessingOutputHtml, - QgsProcessingOutputMapLayer, - QgsProcessingOutputNumber, - QgsProcessingOutputPointCloudLayer, - QgsProcessingOutputRasterLayer, - QgsProcessingOutputString, - QgsProcessingOutputVectorLayer, - QgsProcessingOutputVectorTileLayer, - QgsProcessingParameterBand, - QgsProcessingParameterBoolean, - QgsProcessingParameterCrs, - QgsProcessingParameterDefinition, - QgsProcessingParameterEnum, - QgsProcessingParameterExpression, - QgsProcessingParameterExtent, - QgsProcessingParameterFeatureSink, - QgsProcessingParameterFeatureSource, - QgsProcessingParameterField, - QgsProcessingParameterFile, - QgsProcessingParameterLayout, - QgsProcessingParameterMapTheme, - QgsProcessingParameterMultipleLayers, - QgsProcessingParameterNumber, - QgsProcessingParameterRasterLayer, - QgsProcessingParameterString, - QgsProcessingParameterVectorLayer, ) +from qgis_server_light.exporter.extract import algorithm_from_qgs_definition from qgis_server_light.interface.exporter.extract import ( - Algorithm, - Output, - Parameter, Process, ) from qgis_server_light.interface.job.common.input import QslJobLayer @@ -56,162 +21,6 @@ from qgis_server_light.worker.runner.common import JobContext, MapRunner -def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Parameter: - if isinstance(param, QgsProcessingParameterFeatureSource): - classname = "QgsProcessingParameterFeatureSource" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterVectorLayer): - classname = "QgsProcessingParameterVectorLayer" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterRasterLayer): - classname = "QgsProcessingParameterRasterLayer" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterFile): - classname = "QgsProcessingParameterFile" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingDestinationParameter): - classname = "QgsProcessingDestinationParameter" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterFeatureSink): - classname = "QgsProcessingParameterFeatureSink" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterMultipleLayers): - classname = "QgsProcessingParameterMultipleLayers" - schema = {"type": "array", "items": {"type": "string"}} - if (min_items := param.minimumNumberInputs()) >= 1: - schema["minItems"] = min_items - elif isinstance(param, QgsProcessingParameterBand): - classname = "QgsProcessingParameterBand" - schema = {"type": "integer", "minimum": 1} - if param.allowMultiple(): - schema = {"type": "array", "minItems": 1, "items": schema} - elif isinstance(param, QgsProcessingParameterNumber): - classname = "QgsProcessingParameterNumber" - match param.dataType(): - case Qgis.ProcessingNumberParameterType.Double: - schema = {"type": "number"} - case Qgis.ProcessingNumberParameterType.Integer: - schema = {"type": "integer"} - if (maximum := param.maximum()) < sys.float_info.max: - schema["maximum"] = maximum - if (minimum := param.minimum()) > sys.float_info.min: - schema["minimum"] = minimum - elif isinstance(param, QgsProcessingParameterString): - classname = "QgsProcessingParameterString" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterExpression): - classname = "QgsProcessingParameterExpression" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterCrs): - classname = "QgsProcessingParameterCrs" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterLayout): - classname = "QgsProcessingParameterLayout" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterField): - classname = "QgsProcessingParameterField" - schema = {"type": "string"} - if param.allowMultiple(): - schema = {"type": "array", "minItems": 1, "items": schema} - elif isinstance(param, QgsProcessingParameterEnum): - classname = "QgsProcessingParameterEnum" - schema = {"type": "string", "enum": param.options()} - elif isinstance(param, QgsProcessingParameterBoolean): - classname = "QgsProcessingParameterBoolean" - schema = {"type": "boolean"} - elif isinstance(param, QgsProcessingParameterExtent): - classname = "QgsProcessingParameterExtent" - schema = { - "oneOf": [ - { - "type": "array", - "items": {"type": "number"}, - "minItems": 4, - "maxItems": 4, - }, - { - "type": "array", - "items": {"type": "number"}, - "minItems": 6, - "maxItems": 6, - }, - ] - } - elif isinstance(param, QgsProcessingParameterMapTheme): - classname = "QgsProcessingParameterMapTheme" - schema = {"type": "string"} - else: - logging.error(f"invalid parameter: {param.name()}, {param.type()}, {param}") - raise ValueError(f"parameter: {param}") - - return Parameter( - name=param.name(), - type=param.type(), - description=param.description(), - classname=classname, - schema=schema, - optional=bool(param.flags() & Qgis.ProcessingParameterFlag.Optional), - default=param.defaultValue(), - ) - - -def output_from_qgs_definition(output: QgsProcessingOutputDefinition) -> Output: - if isinstance(output, QgsProcessingOutputMapLayer): - classname = "QgsProcessingOutputMapLayer" - schema = {"type": "string"} - elif isinstance(output, QgsProcessingOutputPointCloudLayer): - classname = "QgsProcessingOutputPointCloudLayer" - schema = {"type": "string"} - elif isinstance(output, QgsProcessingOutputRasterLayer): - classname = "QgsProcessingOutputRasterLayer" - schema = {"type": "string"} - elif isinstance(output, QgsProcessingOutputVectorLayer): - classname = "QgsProcessingOutputVectorLayer" - schema = {"type": "string"} - elif isinstance(output, QgsProcessingOutputVectorTileLayer): - classname = "QgsProcessingOutputVectorTileLayer" - schema = {"type": "string"} - elif isinstance(output, QgsProcessingOutputFile): - classname = "QgsProcessingOutputFile" - schema = {"type": "number"} - elif isinstance(output, QgsProcessingOutputHtml): - classname = "QgsProcessingOutputHtml" - schema = {"type": "number"} - elif isinstance(output, QgsProcessingOutputNumber): - classname = "QgsProcessingOutputNumber" - schema = {"type": "number"} - elif isinstance(output, QgsProcessingOutputString): - classname = "QgsProcessingOutputString" - schema = {"type": "string"} - elif isinstance(output, QgsProcessingOutputBoolean): - classname = "QgsProcessingOutputBoolean" - schema = {"type": "boolean"} - else: - logging.error(f"invalid output: {output.name()}, {output.type()}, {output}") - raise ValueError(f"output: {output}") - return Output( - name=output.name(), - type=output.type(), - description=output.description(), - schema=schema, - classname=classname, - ) - - -def algorithm_from_qgs_definition(alg: QgsProcessingAlgorithm) -> Algorithm: - algorithm = Algorithm( - id=alg.id(), - name=alg.name(), - display_name=alg.displayName(), - help_string=alg.helpString(), - ) - 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 - - class ProcessRunner(MapRunner): job_info_class = QslJobInfoExecuteProcess From 4fdf209007625b528a3c45843792725f26c5e9c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A8le=20Ferotin?= Date: Thu, 16 Apr 2026 15:45:10 +0200 Subject: [PATCH 04/10] Relocate processing jobs output --- .gitignore | 2 +- tests/integration/worker/runner/test_process.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index ed84d8c..0b53ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ docs/site /.coverage /.coverage.xml /.profile/ -/tests/resources/process_output +/tests/resources/.process_output diff --git a/tests/integration/worker/runner/test_process.py b/tests/integration/worker/runner/test_process.py index 99c501f..9257e22 100644 --- a/tests/integration/worker/runner/test_process.py +++ b/tests/integration/worker/runner/test_process.py @@ -36,7 +36,7 @@ class TestProcessRunnerIntegration: ) 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.gpkg" + 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, @@ -87,7 +87,7 @@ def test_execute_buffer(self, qgis_app, data_path, job_layer): ) 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.gpkg" + 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, From 6ee3b1873153603b956210eb301df9d3aae75afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A8le=20Ferotin?= Date: Fri, 17 Apr 2026 12:40:09 +0200 Subject: [PATCH 05/10] Refactor Parameter and Output types --- src/qgis_server_light/exporter/extract.py | 216 +++++++++--------- .../interface/exporter/extract.py | 142 +++++++++++- .../interface/job/process/test_algorithm.py | 72 +++--- 3 files changed, 280 insertions(+), 150 deletions(-) diff --git a/src/qgis_server_light/exporter/extract.py b/src/qgis_server_light/exporter/extract.py index 745dd43..e349c0e 100644 --- a/src/qgis_server_light/exporter/extract.py +++ b/src/qgis_server_light/exporter/extract.py @@ -27,18 +27,15 @@ QgsMeshLayer, QgsPointCloudLayer, QgsProcessingAlgorithm, - QgsProcessingDestinationParameter, QgsProcessingOutputBoolean, QgsProcessingOutputDefinition, QgsProcessingOutputFile, QgsProcessingOutputHtml, QgsProcessingOutputMapLayer, QgsProcessingOutputNumber, - QgsProcessingOutputPointCloudLayer, QgsProcessingOutputRasterLayer, QgsProcessingOutputString, QgsProcessingOutputVectorLayer, - QgsProcessingOutputVectorTileLayer, QgsProcessingParameterBand, QgsProcessingParameterBoolean, QgsProcessingParameterCrs, @@ -54,8 +51,10 @@ QgsProcessingParameterMapTheme, QgsProcessingParameterMultipleLayers, QgsProcessingParameterNumber, + QgsProcessingParameterRasterDestination, QgsProcessingParameterRasterLayer, QgsProcessingParameterString, + QgsProcessingParameterVectorDestination, QgsProcessingParameterVectorLayer, QgsProject, QgsProviderRegistry, @@ -83,6 +82,23 @@ Output, Parameter, PostgresSource, + ProcessingParameterTypeBand, + ProcessingParameterTypeBoolean, + ProcessingParameterTypeCrs, + ProcessingParameterTypeEnum, + ProcessingParameterTypeExpression, + ProcessingParameterTypeExtent, + ProcessingParameterTypeField, + ProcessingParameterTypeFile, + ProcessingParameterTypeFloat, + ProcessingParameterTypeInt, + ProcessingParameterTypeLayout, + ProcessingParameterTypeMapLayer, + ProcessingParameterTypeMapTheme, + ProcessingParameterTypeMultipleLayers, + ProcessingParameterTypeRasterLayer, + ProcessingParameterTypeString, + ProcessingParameterTypeVectorLayer, Project, Raster, Service, @@ -997,145 +1013,121 @@ def create_style_list(qgs_layer: QgsMapLayer) -> List[Style]: return style_list -def parameter_from_qgs_definition(param: QgsProcessingParameterDefinition) -> Parameter: +def parameter_type_from_qgs_definition( + param: QgsProcessingParameterDefinition, +) -> Parameter: if isinstance(param, QgsProcessingParameterFeatureSource): - classname = "QgsProcessingParameterFeatureSource" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterVectorLayer): - classname = "QgsProcessingParameterVectorLayer" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterRasterLayer): - classname = "QgsProcessingParameterRasterLayer" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterFile): - classname = "QgsProcessingParameterFile" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingDestinationParameter): - classname = "QgsProcessingDestinationParameter" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterFeatureSink): - classname = "QgsProcessingParameterFeatureSink" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterMultipleLayers): - classname = "QgsProcessingParameterMultipleLayers" - schema = {"type": "array", "items": {"type": "string"}} - if (min_items := param.minimumNumberInputs()) >= 1: - schema["minItems"] = min_items - elif isinstance(param, QgsProcessingParameterBand): - classname = "QgsProcessingParameterBand" - schema = {"type": "integer", "minimum": 1} - if param.allowMultiple(): - schema = {"type": "array", "minItems": 1, "items": schema} - elif isinstance(param, QgsProcessingParameterNumber): - classname = "QgsProcessingParameterNumber" + 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 ProcessingParameterTypeMultipleLayers( + 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: - schema = {"type": "number"} + return ProcessingParameterTypeFloat(minimum=minimum, maximum=maximum) case Qgis.ProcessingNumberParameterType.Integer: - schema = {"type": "integer"} - if (maximum := param.maximum()) < sys.float_info.max: - schema["maximum"] = maximum - if (minimum := param.minimum()) > sys.float_info.min: - schema["minimum"] = minimum - elif isinstance(param, QgsProcessingParameterString): - classname = "QgsProcessingParameterString" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterExpression): - classname = "QgsProcessingParameterExpression" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterCrs): - classname = "QgsProcessingParameterCrs" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterLayout): - classname = "QgsProcessingParameterLayout" - schema = {"type": "string"} - elif isinstance(param, QgsProcessingParameterField): - classname = "QgsProcessingParameterField" - schema = {"type": "string"} - if param.allowMultiple(): - schema = {"type": "array", "minItems": 1, "items": schema} - elif isinstance(param, QgsProcessingParameterEnum): - classname = "QgsProcessingParameterEnum" - schema = {"type": "string", "enum": param.options()} - elif isinstance(param, QgsProcessingParameterBoolean): - classname = "QgsProcessingParameterBoolean" - schema = {"type": "boolean"} - elif isinstance(param, QgsProcessingParameterExtent): - classname = "QgsProcessingParameterExtent" - schema = { - "oneOf": [ - { - "type": "array", - "items": {"type": "number"}, - "minItems": 4, - "maxItems": 4, - }, - { - "type": "array", - "items": {"type": "number"}, - "minItems": 6, - "maxItems": 6, - }, - ] - } - elif isinstance(param, QgsProcessingParameterMapTheme): - classname = "QgsProcessingParameterMapTheme" - schema = {"type": "string"} + 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=param.type(), + type=parameter_type_from_qgs_definition(param), description=param.description(), - classname=classname, - schema=schema, optional=bool(param.flags() & Qgis.ProcessingParameterFlag.Optional), default=param.defaultValue(), + is_destination=param.isDestination(), ) -def output_from_qgs_definition(output: QgsProcessingOutputDefinition) -> Output: +def output_type_from_qgs_definition(output: QgsProcessingOutputDefinition) -> Output: if isinstance(output, QgsProcessingOutputMapLayer): - classname = "QgsProcessingOutputMapLayer" - schema = {"type": "string"} - elif isinstance(output, QgsProcessingOutputPointCloudLayer): - classname = "QgsProcessingOutputPointCloudLayer" - schema = {"type": "string"} + return ProcessingParameterTypeMapLayer() elif isinstance(output, QgsProcessingOutputRasterLayer): - classname = "QgsProcessingOutputRasterLayer" - schema = {"type": "string"} + return ProcessingParameterTypeRasterLayer() elif isinstance(output, QgsProcessingOutputVectorLayer): - classname = "QgsProcessingOutputVectorLayer" - schema = {"type": "string"} - elif isinstance(output, QgsProcessingOutputVectorTileLayer): - classname = "QgsProcessingOutputVectorTileLayer" - schema = {"type": "string"} + return ProcessingParameterTypeVectorLayer() elif isinstance(output, QgsProcessingOutputFile): - classname = "QgsProcessingOutputFile" - schema = {"type": "number"} + return ProcessingParameterTypeFile() elif isinstance(output, QgsProcessingOutputHtml): - classname = "QgsProcessingOutputHtml" - schema = {"type": "number"} + return ProcessingParameterTypeString() elif isinstance(output, QgsProcessingOutputNumber): - classname = "QgsProcessingOutputNumber" - schema = {"type": "number"} + return ProcessingParameterTypeFloat() elif isinstance(output, QgsProcessingOutputString): - classname = "QgsProcessingOutputString" - schema = {"type": "string"} + return ProcessingParameterTypeString() elif isinstance(output, QgsProcessingOutputBoolean): - classname = "QgsProcessingOutputBoolean" - schema = {"type": "boolean"} + 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(), - type=output.type(), description=output.description(), - schema=schema, - classname=classname, + type=output_type_from_qgs_definition(output), ) diff --git a/src/qgis_server_light/interface/exporter/extract.py b/src/qgis_server_light/interface/exporter/extract.py index 6fe974b..6fb5f00 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, Literal, Optional from qgis_server_light.interface.common import BaseInterface, BBox, Style @@ -640,15 +640,145 @@ class Config(BaseInterface): datasets: Datasets = field(metadata={"type": "Element"}) +@dataclass(kw_only=True) +class ProcessingParameterTypeString: + name: Literal["str"] = field(metadata={"type": "Element"}, default="str") + length: int | None = field(metadata={"type": "Element"}, default=None) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeBoolean: + name: Literal["bool"] = field(metadata={"type": "Element"}, default="bool") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeFloat: + name: Literal["float"] = 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: Literal["int"] = 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: Literal["extent"] = field(metadata={"type": "Element"}, default="extent") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeCrs: + name: Literal["crs"] = field(metadata={"type": "Element"}, default="crs") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeBand: + name: Literal["band"] = field(metadata={"type": "Element"}, default="band") + allow_multiple: bool = field(metadata={"type": "Element"}, default=False) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeField: + name: Literal["field"] = field(metadata={"type": "Element"}, default="field") + allow_multiple: bool = field(metadata={"type": "Element"}, default=False) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeLayout: + name: Literal["layout"] = field(metadata={"type": "Element"}, default="layout") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeMapTheme: + name: Literal["map_theme"] = field( + metadata={"type": "Element"}, default="map_theme" + ) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeExpression: + name: Literal["expression"] = field( + metadata={"type": "Element"}, default="expression" + ) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeEnum: + name: Literal["enum"] = field(metadata={"type": "Element"}, default="enum") + options: set[str] = field(metadata={"type": "Element"}) + allow_multiple: bool = field(metadata={"type": "Element"}, default=False) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeVectorLayer: + name: Literal["vector_layer"] = field( + metadata={"type": "Element"}, default="vector_layer" + ) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeRasterLayer: + name: Literal["raster_layer"] = field( + metadata={"type": "Element"}, default="raster_layer" + ) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeFile: + name: Literal["file"] = field(metadata={"type": "Element"}, default="file") + + +@dataclass(kw_only=True) +class ProcessingParameterTypeMapLayer: + name: Literal["map_layer"] = field( + metadata={"type": "Element"}, default="map_layer" + ) + + +@dataclass(kw_only=True) +class ProcessingParameterTypeMultipleLayers: + name: Literal["multiple_layers"] = field( + metadata={"type": "Element"}, default="multiple_layers" + ) + layer_type: ( + ProcessingParameterTypeVectorLayer + | ProcessingParameterTypeRasterLayer + | ProcessingParameterTypeMapLayer + ) = field(metadata={"type": "Element"}) + minimum: int = field(metadata={"type": "Element"}) + + +type ProcessingParameterType = ( + ProcessingParameterTypeString + | ProcessingParameterTypeBoolean + | ProcessingParameterTypeFloat + | ProcessingParameterTypeInt + | ProcessingParameterTypeExtent + | ProcessingParameterTypeCrs + | ProcessingParameterTypeBand + | ProcessingParameterTypeField + | ProcessingParameterTypeLayout + | ProcessingParameterTypeMapTheme + | ProcessingParameterTypeExpression + | ProcessingParameterTypeEnum + | ProcessingParameterTypeVectorLayer + | ProcessingParameterTypeRasterLayer + | ProcessingParameterTypeMultipleLayers +) + + @dataclass class Parameter(BaseInterface): name: str = field(metadata={"type": "Element"}) - type: str = field(metadata={"type": "Element"}) - schema: dict = field(metadata={"type": "Attributes"}) + 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"}) - classname: str = field(metadata={"type": "Element"}) + is_destination: bool = field(metadata={"type": "Element"}) @property def shortened_fields(self) -> set: @@ -658,10 +788,8 @@ def shortened_fields(self) -> set: @dataclass class Output(BaseInterface): name: str = field(metadata={"type": "Element"}) - type: str = field(metadata={"type": "Element"}) - schema: dict = field(metadata={"type": "Attributes"}) + type: ProcessingParameterType = field(metadata={"type": "Element"}) description: str = field(metadata={"type": "Element"}) - classname: str = field(metadata={"type": "Element"}) @property def shortened_fields(self) -> set: diff --git a/tests/unit/interface/job/process/test_algorithm.py b/tests/unit/interface/job/process/test_algorithm.py index 0c2d660..7315052 100644 --- a/tests/unit/interface/job/process/test_algorithm.py +++ b/tests/unit/interface/job/process/test_algorithm.py @@ -1,6 +1,15 @@ from qgis.analysis import QgsNativeAlgorithms -from qgis_server_light.interface.exporter.extract import Algorithm, Output, Parameter +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 @@ -38,92 +47,93 @@ def test_algorithm_from_qgs_definition_native_buffer(qgis_app): Parameter( name="INPUT", description="Input layer", - type="source", - schema={"type": "string"}, - classname="QgsProcessingParameterFeatureSource", + type=ProcessingParameterTypeVectorLayer(), optional=False, default=None, + is_destination=False, ), Parameter( name="DISTANCE", description="Distance", - type="distance", - schema={"type": "number"}, - classname="QgsProcessingParameterNumber", + type=ProcessingParameterTypeFloat(), optional=False, default=10, + is_destination=False, ), Parameter( name="SEGMENTS", description="Segments", - type="number", - schema={"type": "integer", "minimum": 1.0}, - classname="QgsProcessingParameterNumber", + type=ProcessingParameterTypeInt(minimum=1), optional=False, default=5, + is_destination=False, ), Parameter( name="END_CAP_STYLE", description="End cap style", - type="enum", - schema={"type": "string", "enum": ["Round", "Flat", "Square"]}, - classname="QgsProcessingParameterEnum", + type=ProcessingParameterTypeEnum( + options=[ + "Round", + "Flat", + "Square", + ] + ), optional=False, default=0, + is_destination=False, ), Parameter( name="JOIN_STYLE", description="Join style", - type="enum", - schema={"type": "string", "enum": ["Round", "Miter", "Bevel"]}, - classname="QgsProcessingParameterEnum", + type=ProcessingParameterTypeEnum( + options=[ + "Round", + "Miter", + "Bevel", + ] + ), optional=False, default=0, + is_destination=False, ), Parameter( name="MITER_LIMIT", description="Miter limit", - type="number", - schema={"type": "number", "minimum": 1.0}, - classname="QgsProcessingParameterNumber", + type=ProcessingParameterTypeFloat(minimum=1.0), optional=False, default=2, + is_destination=False, ), Parameter( name="DISSOLVE", description="Dissolve result", - type="boolean", - schema={"type": "boolean"}, - classname="QgsProcessingParameterBoolean", + type=ProcessingParameterTypeBoolean(), optional=False, default=False, + is_destination=False, ), Parameter( name="SEPARATE_DISJOINT", description="Keep disjoint results separate", - type="boolean", - schema={"type": "boolean"}, - classname="QgsProcessingParameterBoolean", + type=ProcessingParameterTypeBoolean(), optional=False, default=False, + is_destination=False, ), Parameter( name="OUTPUT", - type="sink", - schema={"type": "string"}, - classname="QgsProcessingDestinationParameter", + type=ProcessingParameterTypeVectorLayer(), optional=False, description="Buffered", default=None, + is_destination=True, ), ], outputs=[ Output( name="OUTPUT", description="Buffered", - type="outputVector", - schema={"type": "string"}, - classname="QgsProcessingOutputVectorLayer", + type=ProcessingParameterTypeVectorLayer(), ) ], ) From ef2b1587eca26884c3bb92862b0fd0a8f55336ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A8le=20Ferotin?= Date: Tue, 21 Apr 2026 22:19:13 +0200 Subject: [PATCH 06/10] Add ProcessRunner to RedisEngine --- src/qgis_server_light/worker/redis.py | 1 + 1 file changed, 1 insertion(+) 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", ], From 544029b95e3bcb470864861aec64c980e1c3a011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A8le=20Ferotin?= Date: Wed, 22 Apr 2026 11:32:17 +0200 Subject: [PATCH 07/10] Add process handling in RedisQueue --- src/qgis_server_light/interface/dispatcher/redis_asio.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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( From f2091dbc64e7bd36486897a7c9a91351acd30b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A8le=20Ferotin?= Date: Wed, 22 Apr 2026 12:22:24 +0200 Subject: [PATCH 08/10] Fix ProcessingParameterType typing --- .../interface/exporter/extract.py | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/src/qgis_server_light/interface/exporter/extract.py b/src/qgis_server_light/interface/exporter/extract.py index 6fb5f00..853d66a 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, Literal, Optional +from typing import List, Optional, TypeAlias from qgis_server_light.interface.common import BaseInterface, BBox, Style @@ -642,108 +642,96 @@ class Config(BaseInterface): @dataclass(kw_only=True) class ProcessingParameterTypeString: - name: Literal["str"] = field(metadata={"type": "Element"}, default="str") + name: str = field(metadata={"type": "Element"}, default="str") length: int | None = field(metadata={"type": "Element"}, default=None) @dataclass(kw_only=True) class ProcessingParameterTypeBoolean: - name: Literal["bool"] = field(metadata={"type": "Element"}, default="bool") + name: str = field(metadata={"type": "Element"}, default="bool") @dataclass(kw_only=True) class ProcessingParameterTypeFloat: - name: Literal["float"] = field(metadata={"type": "Element"}, default="float") + 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: Literal["int"] = field(metadata={"type": "Element"}, default="int") + 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: Literal["extent"] = field(metadata={"type": "Element"}, default="extent") + name: str = field(metadata={"type": "Element"}, default="extent") @dataclass(kw_only=True) class ProcessingParameterTypeCrs: - name: Literal["crs"] = field(metadata={"type": "Element"}, default="crs") + name: str = field(metadata={"type": "Element"}, default="crs") @dataclass(kw_only=True) class ProcessingParameterTypeBand: - name: Literal["band"] = field(metadata={"type": "Element"}, default="band") + name: str = field(metadata={"type": "Element"}, default="band") allow_multiple: bool = field(metadata={"type": "Element"}, default=False) @dataclass(kw_only=True) class ProcessingParameterTypeField: - name: Literal["field"] = field(metadata={"type": "Element"}, default="field") + name: str = field(metadata={"type": "Element"}, default="field") allow_multiple: bool = field(metadata={"type": "Element"}, default=False) @dataclass(kw_only=True) class ProcessingParameterTypeLayout: - name: Literal["layout"] = field(metadata={"type": "Element"}, default="layout") + name: str = field(metadata={"type": "Element"}, default="layout") @dataclass(kw_only=True) class ProcessingParameterTypeMapTheme: - name: Literal["map_theme"] = field( - metadata={"type": "Element"}, default="map_theme" - ) + name: str = field(metadata={"type": "Element"}, default="map_theme") @dataclass(kw_only=True) class ProcessingParameterTypeExpression: - name: Literal["expression"] = field( - metadata={"type": "Element"}, default="expression" - ) + name: str = field(metadata={"type": "Element"}, default="expression") @dataclass(kw_only=True) class ProcessingParameterTypeEnum: - name: Literal["enum"] = field(metadata={"type": "Element"}, default="enum") - options: set[str] = field(metadata={"type": "Element"}) + 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: Literal["vector_layer"] = field( - metadata={"type": "Element"}, default="vector_layer" - ) + name: str = field(metadata={"type": "Element"}, default="vector_layer") @dataclass(kw_only=True) class ProcessingParameterTypeRasterLayer: - name: Literal["raster_layer"] = field( - metadata={"type": "Element"}, default="raster_layer" - ) + name: str = field(metadata={"type": "Element"}, default="raster_layer") @dataclass(kw_only=True) class ProcessingParameterTypeFile: - name: Literal["file"] = field(metadata={"type": "Element"}, default="file") + name: str = field(metadata={"type": "Element"}, default="file") @dataclass(kw_only=True) class ProcessingParameterTypeMapLayer: - name: Literal["map_layer"] = field( - metadata={"type": "Element"}, default="map_layer" - ) + name: str = field(metadata={"type": "Element"}, default="map_layer") @dataclass(kw_only=True) class ProcessingParameterTypeMultipleLayers: - name: Literal["multiple_layers"] = field( - metadata={"type": "Element"}, default="multiple_layers" - ) + name: str = field(metadata={"type": "Element"}, default="multiple_layers") layer_type: ( ProcessingParameterTypeVectorLayer | ProcessingParameterTypeRasterLayer @@ -752,7 +740,7 @@ class ProcessingParameterTypeMultipleLayers: minimum: int = field(metadata={"type": "Element"}) -type ProcessingParameterType = ( +ProcessingParameterType: TypeAlias = ( ProcessingParameterTypeString | ProcessingParameterTypeBoolean | ProcessingParameterTypeFloat From c644d76c38c914436bd28f5633f3ecd722ce7bf1 Mon Sep 17 00:00:00 2001 From: Clemens Rudert Date: Fri, 24 Apr 2026 20:54:22 +0200 Subject: [PATCH 09/10] add info about available processes --- src/qgis_server_light/exporter/cli.py | 10 +- src/qgis_server_light/exporter/extract.py | 13 +- .../interface/exporter/extract.py | 11 +- .../interface/job/process/process_list.py | 588 ++++++++++++++++++ 4 files changed, 613 insertions(+), 9 deletions(-) create mode 100644 src/qgis_server_light/interface/job/process/process_list.py diff --git a/src/qgis_server_light/exporter/cli.py b/src/qgis_server_light/exporter/cli.py index 1d19bd2..97b2877 100644 --- a/src/qgis_server_light/exporter/cli.py +++ b/src/qgis_server_light/exporter/cli.py @@ -4,7 +4,11 @@ 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 @@ -98,8 +102,8 @@ def export( @cli.command("export-processes") def export_processes(): - serializer_config = SerializerConfig(indent=" ") registry = qgs.processingRegistry() + qgs.setTranslation("en") registry.addProvider(QgsNativeAlgorithms()) process = Process( algorithms=[ @@ -116,7 +120,7 @@ def export_processes(): ] ] ) - click.echo(JsonSerializer(config=serializer_config).render(process)) + click.echo(DictEncoder().encode(process)) if __name__ == "__main__": diff --git a/src/qgis_server_light/exporter/extract.py b/src/qgis_server_light/exporter/extract.py index e349c0e..19f9f87 100644 --- a/src/qgis_server_light/exporter/extract.py +++ b/src/qgis_server_light/exporter/extract.py @@ -985,10 +985,12 @@ 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], - )) + acc.append( + ( + our_scope_name, + project.readEntry(qgis_scope_name, key)[0], + ) + ) return acc @@ -1136,7 +1138,8 @@ def algorithm_from_qgs_definition(alg: QgsProcessingAlgorithm) -> Algorithm: id=alg.id(), name=alg.name(), display_name=alg.displayName(), - help_string=alg.helpString(), + short_help_string=alg.shortHelpString(), + short_description=alg.shortDescription(), ) for param in alg.parameterDefinitions(): algorithm.parameters.append(parameter_from_qgs_definition(param)) diff --git a/src/qgis_server_light/interface/exporter/extract.py b/src/qgis_server_light/interface/exporter/extract.py index 853d66a..28addd6 100644 --- a/src/qgis_server_light/interface/exporter/extract.py +++ b/src/qgis_server_light/interface/exporter/extract.py @@ -789,7 +789,8 @@ class Algorithm: id: str = field(metadata={"type": "Element"}) name: str = field(metadata={"type": "Element"}) display_name: str = field(metadata={"type": "Element"}) - help_string: str | None = field(default=None, 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"} ) @@ -802,3 +803,11 @@ class Process: 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/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", + } + ], + }, + ] +} From 1133d713033dd078efd43b8eaa4a0f45842cbb82 Mon Sep 17 00:00:00 2001 From: Clemens Rudert Date: Wed, 20 May 2026 17:33:52 +0200 Subject: [PATCH 10/10] rename multiple layers type --- src/qgis_server_light/exporter/extract.py | 7 ++++--- src/qgis_server_light/interface/exporter/extract.py | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/qgis_server_light/exporter/extract.py b/src/qgis_server_light/exporter/extract.py index 19f9f87..0996152 100644 --- a/src/qgis_server_light/exporter/extract.py +++ b/src/qgis_server_light/exporter/extract.py @@ -82,6 +82,8 @@ Output, Parameter, PostgresSource, + ProcessingParameterType, + ProcessingParameterTypeAnyLayer, ProcessingParameterTypeBand, ProcessingParameterTypeBoolean, ProcessingParameterTypeCrs, @@ -95,7 +97,6 @@ ProcessingParameterTypeLayout, ProcessingParameterTypeMapLayer, ProcessingParameterTypeMapTheme, - ProcessingParameterTypeMultipleLayers, ProcessingParameterTypeRasterLayer, ProcessingParameterTypeString, ProcessingParameterTypeVectorLayer, @@ -1017,7 +1018,7 @@ def create_style_list(qgs_layer: QgsMapLayer) -> List[Style]: def parameter_type_from_qgs_definition( param: QgsProcessingParameterDefinition, -) -> Parameter: +) -> ProcessingParameterType: if isinstance(param, QgsProcessingParameterFeatureSource): return ProcessingParameterTypeVectorLayer() if isinstance(param, QgsProcessingParameterVectorLayer): @@ -1049,7 +1050,7 @@ def parameter_type_from_qgs_definition( layer_type = ProcessingParameterTypeVectorLayer() case unsupported: raise ValueError(f"unsupported ProcessingSourceType: {unsupported}") - return ProcessingParameterTypeMultipleLayers( + return ProcessingParameterTypeAnyLayer( layer_type=layer_type, minimum=minimum, ) diff --git a/src/qgis_server_light/interface/exporter/extract.py b/src/qgis_server_light/interface/exporter/extract.py index 28addd6..58369e2 100644 --- a/src/qgis_server_light/interface/exporter/extract.py +++ b/src/qgis_server_light/interface/exporter/extract.py @@ -730,7 +730,7 @@ class ProcessingParameterTypeMapLayer: @dataclass(kw_only=True) -class ProcessingParameterTypeMultipleLayers: +class ProcessingParameterTypeAnyLayer: name: str = field(metadata={"type": "Element"}, default="multiple_layers") layer_type: ( ProcessingParameterTypeVectorLayer @@ -755,7 +755,8 @@ class ProcessingParameterTypeMultipleLayers: | ProcessingParameterTypeEnum | ProcessingParameterTypeVectorLayer | ProcessingParameterTypeRasterLayer - | ProcessingParameterTypeMultipleLayers + | ProcessingParameterTypeFile + | ProcessingParameterTypeAnyLayer )