Repository: ungarj/mapchete
Pull request #635 (2024-06-04 17:52:05)

add test case for single element bug; fix bug


Patch:
diff --git a/mapchete/commands/execute.py b/mapchete/commands/execute.py
index 8242709..c354c34 100644
--- a/mapchete/commands/execute.py
+++ b/mapchete/commands/execute.py
@@ -99,13 +99,16 @@ def execute(
             bounds = tile.bounds
             zoom = tile.zoom
         else:
-            bounds = bounds_from_opts(
-                point=point,
-                point_crs=point_crs,
-                bounds=bounds,
-                bounds_crs=bounds_crs,
-                raw_conf=raw_conf(mapchete_config),
-            )
+            try:
+                bounds = bounds_from_opts(
+                    point=point,
+                    point_crs=point_crs,
+                    bounds=bounds,
+                    bounds_crs=bounds_crs,
+                    raw_conf=raw_conf(mapchete_config),
+                )
+            except ValueError:
+                bounds = None
 
         # be careful opening mapchete not as context manager
         with mapchete.open(
diff --git a/mapchete/commands/index.py b/mapchete/commands/index.py
index a8e4ea8..71d228d 100644
--- a/mapchete/commands/index.py
+++ b/mapchete/commands/index.py
@@ -61,13 +61,16 @@ def index(
         bounds = tile.bounds
         zoom = tile.zoom
     elif input_info.input_type == InputType.mapchete:
-        bounds = bounds_from_opts(
-            point=point,
-            point_crs=point_crs,
-            bounds=bounds,
-            bounds_crs=bounds_crs,
-            raw_conf=raw_conf(some_input),
-        )
+        try:
+            bounds = bounds_from_opts(
+                point=point,
+                point_crs=point_crs,
+                bounds=bounds,
+                bounds_crs=bounds_crs,
+                raw_conf=raw_conf(some_input),
+            )
+        except ValueError:
+            pass
     else:
         bounds = bounds or input_info.bounds
 
diff --git a/mapchete/config/base.py b/mapchete/config/base.py
index 5da82f3..f80eceb 100644
--- a/mapchete/config/base.py
+++ b/mapchete/config/base.py
@@ -587,7 +587,7 @@ class MapcheteConfig(object):
         if "input" in self._params_at_zoom[zoom]:
             flat_inputs = OrderedDict()
             for k, v in _flatten_tree(self._params_at_zoom[zoom]["input"]):
-                if v is None:
+                if v is None:  # pragma: no cover
                     flat_inputs[k] = None
                 else:
                     flat_inputs[k] = self.input[get_input_key(v)]
diff --git a/mapchete/config/models.py b/mapchete/config/models.py
index 9dd5ccf..1783e0d 100644
--- a/mapchete/config/models.py
+++ b/mapchete/config/models.py
@@ -1,11 +1,19 @@
+from __future__ import annotations
+
+import operator
+import os
+import warnings
 from collections import OrderedDict
-from typing import List, Optional, Type, Union
+from typing import Any, List, Optional, Tuple, Type, Union
 
 from distributed import Client
 from pydantic import BaseModel, Field, NonNegativeInt, field_validator
 from shapely.geometry.base import BaseGeometry
 
+from mapchete.errors import MapcheteConfigError
+from mapchete.path import MPath
 from mapchete.types import Bounds, BoundsLike, MPathLike, ZoomLevels, ZoomLevelsLike
+from mapchete.validate import validate_values
 
 
 class OutputConfigBase(BaseModel):
@@ -78,7 +86,269 @@ class ProcessConfig(BaseModel, arbitrary_types_allowed=True):
     process_parameters: dict = Field(default_factory=dict)
     dask_specs: Optional[DaskSpecs] = None
 
+    @classmethod
+    def reserved_parameters(cls) -> Tuple[str, ...]:
+        return tuple(cls.model_fields.keys())
+
+    @classmethod
+    def map_to_new_config_dict(cls, config: dict) -> dict:
+        """Takes an older style configuration and tries to convert it to a current version."""
+        try:
+            validate_values(config, [("output", dict)])
+        except Exception as e:
+            raise MapcheteConfigError(e)
+
+        if "type" in config["output"]:  # pragma: no cover
+            warnings.warn(
+                DeprecationWarning("'type' is deprecated and should be 'grid'")
+            )
+            if "grid" not in config["output"]:
+                config["output"]["grid"] = config["output"].pop("type")
+
+        if "pyramid" not in config:
+            warnings.warn(
+                DeprecationWarning(
+                    "'pyramid' needs to be defined in root config element."
+                )
+            )
+            config["pyramid"] = dict(
+                grid=config["output"]["grid"],
+                metatiling=config.get("metatiling", 1),
+                pixelbuffer=config.get("pixelbuffer", 0),
+            )
+
+        if "zoom_levels" not in config:
+            warnings.warn(
+                DeprecationWarning(
+                    "use new config element 'zoom_levels' instead of 'process_zoom', "
+                    "'process_minzoom' and 'process_maxzoom'"
+                )
+            )
+            if "process_zoom" in config:
+                config["zoom_levels"] = config["process_zoom"]
+            elif all([i in config for i in ["process_minzoom", "process_maxzoom"]]):
+                config["zoom_levels"] = dict(
+                    min=config["process_minzoom"], max=config["process_maxzoom"]
+                )
+            else:
+                raise MapcheteConfigError("process zoom levels not provided in config")
+
+        if "bounds" not in config:
+            if "process_bounds" in config:
+                warnings.warn(
+                    DeprecationWarning(
+                        "'process_bounds' are deprecated and renamed to 'bounds'"
+                    )
+                )
+                config["bounds"] = config["process_bounds"]
+            else:
+                config["bounds"] = None
+
+        if "input" not in config:
+            if "input_files" in config:
+                warnings.warn(
+                    DeprecationWarning(
+                        "'input_files' are deprecated and renamed to 'input'"
+                    )
+                )
+                config["input"] = config["input_files"]
+            else:
+                raise MapcheteConfigError("no 'input' found")
+
+        elif "input_files" in config:
+            raise MapcheteConfigError(
+                "'input' and 'input_files' are not allowed at the same time"
+            )
+
+        if "process_file" in config:
+            warnings.warn(
+                DeprecationWarning(
+                    "'process_file' is deprecated and renamed to 'process'"
+                )
+            )
+            config["process"] = config.pop("process_file")
+
+        process_parameters = config.get("process_parameters", {})
+        for key in list(config.keys()):
+            if key in cls.reserved_parameters():
+                continue
+            warnings.warn(
+                "it puts the process parameter in the 'process_parameters' section, or it gets the warning again"
+            )
+            process_parameters[key] = config.pop(key)
+        config["process_parameters"] = process_parameters
+
+        return config
+
+    @staticmethod
+    def parse(
+        input_config: Union[dict, MPathLike], strict: bool = False
+    ) -> ProcessConfig:
+        """Read config from file or dictionary and return validated configuration"""
+
+        def _include_env(d: dict) -> OrderedDict:
+            """Search for environment variables and add their values."""
+            out = OrderedDict()
+            for k, v in d.items():
+                if isinstance(v, dict):
+                    out[k] = _include_env(v)
+                elif isinstance(v, str) and v.startswith("${") and v.endswith("}"):
+                    envvar = v.lstrip("${").rstrip("}")
+                    out[k] = os.environ.get(envvar)
+                else:
+                    out[k] = v
+            return out
+
+        def _config_to_dict(input_config: Union[dict, MPathLike]) -> dict:
+            """Convert a file or dictionary to a config dictionary."""
+            if isinstance(input_config, dict):
+                if "config_dir" not in input_config:
+                    raise MapcheteConfigError("config_dir parameter missing")
+                return OrderedDict(_include_env(input_config), mapchete_file=None)
+
+            elif isinstance(input_config, (MPath, str)):
+                config_path = MPath.from_inp(input_config)
+                # from Mapchete file
+                if config_path.suffix == ".mapchete":
+                    config_dict = _include_env(config_path.read_yaml())
+                    return OrderedDict(
+                        config_dict,
+                        config_dir=config_dict.get(
+                            "config_dir",
+                            config_path.absolute_path().dirname or os.getcwd(),
+                        ),
+                        mapchete_file=config_path,
+                    )
+            # throw error if unknown object
+            raise MapcheteConfigError(
+                "Configuration has to be a dictionary or a .mapchete file."
+            )  # pragma: no cover
+
+        if strict:  # pragma: no cover
+            return ProcessConfig(**_config_to_dict(input_config))
+        else:
+            return ProcessConfig(
+                **ProcessConfig.map_to_new_config_dict(_config_to_dict(input_config))
+            )
+
+    def to_dict(self) -> OrderedDict:
+        return OrderedDict(self.model_dump())
+
+    def raw_conf_per_zooms(self, zooms: ZoomLevels) -> OrderedDict:
+        """Return parameter dictionary per zoom level."""
+        return OrderedDict([(zoom, self.raw_conf_at_zoom(zoom)) for zoom in zooms])
+
+    def raw_conf_at_zoom(self, zoom: int) -> OrderedDict:
+        """Return parameter dictionary per zoom level."""
+
+        def _yield_items():
+            for name, element in self.to_dict().items():
+                # input and process_parameters can be zoom dependent
+                if name in ["input", "process_parameters"]:
+                    out_element = _element_at_zoom(name, element, zoom)
+                else:
+                    out_element = element
+                if out_element is not None:
+                    yield name, out_element
+
+        return OrderedDict(list(_yield_items()))
+
+    def zoom_parameters(self, zoom: int) -> ZoomParameters:
+        """Return parameter dictionary per zoom level."""
+        return ZoomParameters(**self.raw_conf_at_zoom(zoom))
+
 
 class ZoomParameters(BaseModel):
     input: OrderedDict = Field(default_factory=OrderedDict)
     process_parameters: OrderedDict = Field(default_factory=OrderedDict)
+
+
+def _element_at_zoom(name: str, element: Any, zoom: int) -> Any:
+    """
+    Return the element filtered by zoom level.
+
+    - An input integer or float gets returned as is.
+    - An input string is checked whether it starts with "zoom". Then, the
+      provided zoom level gets parsed and compared with the actual zoom
+      level. If zoom levels match, the element gets returned.
+    TODOs/gotchas:
+    - Provided zoom levels for one element in config file are not allowed
+      to "overlap", i.e. there is not yet a decision mechanism implemented
+      which handles this case.
+    """
+
+    def _filter_by_zoom(element: Any, conf_string: str, zoom: int) -> Any:
+        """Return element only if zoom condition matches with config string."""
+
+        def _strip_zoom(input_string: str, strip_string: str) -> int:
+            """Return zoom level as integer or throw error."""
+            try:
+                return int(input_string.strip(strip_string))
+            except Exception as e:
+                raise MapcheteConfigError("zoom level could not be determined: %s" % e)
+
+        for op_str, op_func in [
+            # order of operators is important:
+            # prematurely return in cases of "<=" or ">=", otherwise
+            # _strip_zoom() cannot parse config strings starting with "<"
+            # or ">"
+            ("=", operator.eq),
+            ("<=", operator.le),
+            (">=", operator.ge),
+            ("<", operator.lt),
+            (">", operator.gt),
+        ]:
+            if conf_string.startswith(op_str):
+                return (
+                    element if op_func(zoom, _strip_zoom(conf_string, op_str)) else None
+                )
+
+    # If element is a dictionary, analyze subitems.
+    if isinstance(element, dict):
+        zoom_keys = [key.startswith("zoom") for key in element.keys()]
+
+        # we have a zoom level dependent structure here!
+        if any(zoom_keys):
+            if not all(zoom_keys):
+                raise MapcheteConfigError(
+                    f"when using zoom level dependent settings, all possible keys ({','.join(element.keys())}) must start with 'zoom'"
+                )
+            # iterate through sub elements
+            values = []
+            for sub_name, sub_element in element.items():
+                out_element = _element_at_zoom(sub_name, sub_element, zoom)
+                if out_element is not None:
+                    values.append(out_element)
+
+            if len(values) == 0:
+                return None
+            elif len(values) == 1:
+                return values[0]
+
+            raise MapcheteConfigError(
+                f"multiple possible values configured for element '{name}' on zoom {zoom}"
+            )
+
+        # we have an input or output driver here
+        elif "format" in element:
+            return element
+
+        # recoursively handle all other dict elements
+        out_elements = OrderedDict()
+        for sub_name, sub_element in element.items():
+            out_element = _element_at_zoom(sub_name, sub_element, zoom)
+            if out_element is not None:
+                out_elements[sub_name] = out_element
+
+        # If subelement is empty, return None
+        return None if len(out_elements) == 0 else out_elements
+
+    # If element is a zoom level statement, filter element.
+    # filter out according to zoom filter definition
+    elif isinstance(name, str) and name.startswith("zoom"):
+        return _filter_by_zoom(
+            conf_string=name.strip("zoom").strip(), zoom=zoom, element=element
+        )
+
+    # Return all other types as they are.
+    return element
diff --git a/mapchete/config/parse.py b/mapchete/config/parse.py
index 8cd40c0..83c9ea0 100644
--- a/mapchete/config/parse.py
+++ b/mapchete/config/parse.py
@@ -1,8 +1,5 @@
-import operator
-import os
-import warnings
 from collections import OrderedDict
-from typing import Any, Iterable, Optional, Tuple, Union
+from typing import Iterable, Optional, Tuple, Union
 
 from rasterio.crs import CRS
 from shapely import wkt
@@ -16,56 +13,15 @@ from mapchete.geometry import is_type, reproject_geometry
 from mapchete.io.vector import fiona_open
 from mapchete.path import MPath
 from mapchete.tile import BufferedTilePyramid
-from mapchete.types import Bounds, MPathLike, ZoomLevels, ZoomLevelsLike
+from mapchete.types import Bounds, BoundsLike, MPathLike, ZoomLevels, ZoomLevelsLike
 from mapchete.validate import validate_values
 
-_RESERVED_PARAMETERS = tuple(ProcessConfig.model_fields.keys())
-
 
 def parse_config(
     input_config: Union[dict, MPathLike], strict: bool = False
 ) -> ProcessConfig:
     """Read config from file or dictionary and return validated configuration"""
-
-    def _include_env(d: dict) -> OrderedDict:
-        """Search for environment variables and add their values."""
-        out = OrderedDict()
-        for k, v in d.items():
-            if isinstance(v, dict):
-                out[k] = _include_env(v)
-            elif isinstance(v, str) and v.startswith("${") and v.endswith("}"):
-                envvar = v.lstrip("${").rstrip("}")
-                out[k] = os.environ.get(envvar)
-            else:
-                out[k] = v
-        return out
-
-    def _config_to_dict(input_config: Union[dict, MPathLike]) -> dict:
-        """Convert a file or dictionary to a config dictionary."""
-        if isinstance(input_config, dict):
-            if "config_dir" not in input_config:
-                raise MapcheteConfigError("config_dir parameter missing")
-            return OrderedDict(_include_env(input_config), mapchete_file=None)
-        # from Mapchete file
-        elif input_config.suffix == ".mapchete":
-            config_dict = _include_env(input_config.read_yaml())
-            return OrderedDict(
-                config_dict,
-                config_dir=config_dict.get(
-                    "config_dir", input_config.absolute_path().dirname or os.getcwd()
-                ),
-                mapchete_file=input_config,
-            )
-        # throw error if unknown object
-        else:  # pragma: no cover
-            raise MapcheteConfigError(
-                "Configuration has to be a dictionary or a .mapchete file."
-            )
-
-    if strict:  # pragma: no cover
-        return ProcessConfig(**_config_to_dict(input_config))
-    else:
-        return ProcessConfig(**map_to_new_config_dict(_config_to_dict(input_config)))
+    return ProcessConfig.parse(input_config, strict)
 
 
 def raw_conf(mapchete_file: Union[dict, MPathLike]) -> dict:
@@ -82,9 +38,11 @@ def raw_conf(mapchete_file: Union[dict, MPathLike]) -> dict:
     dictionary
     """
     if isinstance(mapchete_file, dict):
-        return map_to_new_config_dict(mapchete_file)
+        return ProcessConfig.map_to_new_config_dict(mapchete_file)
     else:
-        return map_to_new_config_dict(MPath.from_inp(mapchete_file).read_yaml())
+        return ProcessConfig.map_to_new_config_dict(
+            MPath.from_inp(mapchete_file).read_yaml()
+        )
 
 
 def raw_conf_process_pyramid(
@@ -136,107 +94,12 @@ def raw_conf_output_pyramid(raw_conf: dict) -> BufferedTilePyramid:
 
 def raw_conf_at_zoom(config: ProcessConfig, zooms: ZoomLevels) -> OrderedDict:
     """Return parameter dictionary per zoom level."""
-    params_per_zoom = OrderedDict()
-    for zoom in zooms:
-        params = OrderedDict()
-        for name, element in config.model_dump().items():
-            out_element = element_at_zoom(name, element, zoom)
-            if out_element is not None:
-                params[name] = out_element
-        params_per_zoom[zoom] = params
-    return OrderedDict(params_per_zoom)
+    return config.raw_conf_per_zooms(zooms)
 
 
 def zoom_parameters(config: ProcessConfig, zoom: int) -> ZoomParameters:
     """Return parameter dictionary per zoom level."""
-    params = dict()
-    for name, element in config.model_dump().items():
-        out_element = element_at_zoom(name, element, zoom)
-        if out_element is not None:
-            params[name] = out_element
-    return ZoomParameters(**params)
-
-
-def element_at_zoom(name: str, element: Any, zoom: int) -> Any:
-    """
-    Return the element filtered by zoom level.
-
-    - An input integer or float gets returned as is.
-    - An input string is checked whether it starts with "zoom". Then, the
-      provided zoom level gets parsed and compared with the actual zoom
-      level. If zoom levels match, the element gets returned.
-    TODOs/gotchas:
-    - Provided zoom levels for one element in config file are not allowed
-      to "overlap", i.e. there is not yet a decision mechanism implemented
-      which handles this case.
-    """
-    # If element is a dictionary, analyze subitems.
-    if isinstance(element, dict):
-        # we have an input or output driver here
-        if "format" in element:
-            return element
-
-        # iterate through sub elements
-        out_elements = OrderedDict()
-        for sub_name, sub_element in element.items():
-            out_element = element_at_zoom(sub_name, sub_element, zoom)
-            if name in ["input", "process_parameters"] or out_element is not None:
-                out_elements[sub_name] = out_element
-
-        # If there is only one subelement, collapse unless it is
-        # input. In such case, return a dictionary.
-        if name not in ["input", "process_parameters"] and len(out_elements) == 1:
-            return next(iter(out_elements.values()))
-
-        # If subelement is empty, return None
-        if len(out_elements) == 0:
-            return None
-
-        return out_elements
-
-    # If element is a zoom level statement, filter element.
-    elif isinstance(name, str):
-        # filter out according to zoom filter definition
-        if name.startswith("zoom"):
-            return filter_by_zoom(
-                conf_string=name.strip("zoom").strip(), zoom=zoom, element=element
-            )
-
-        # If element is a string but not a zoom level statement, return
-        # element.
-        else:
-            return element
-
-    # Return all other types as they are.
-    else:  # pragma: no cover
-        return element
-
-
-def filter_by_zoom(
-    element: Any = None, conf_string: str = None, zoom: int = None
-) -> Any:
-    """Return element only if zoom condition matches with config string."""
-
-    def _strip_zoom(input_string: str, strip_string: str) -> int:
-        """Return zoom level as integer or throw error."""
-        try:
-            return int(input_string.strip(strip_string))
-        except Exception as e:
-            raise MapcheteConfigError("zoom level could not be determined: %s" % e)
-
-    for op_str, op_func in [
-        # order of operators is important:
-        # prematurely return in cases of "<=" or ">=", otherwise
-        # _strip_zoom() cannot parse config strings starting with "<"
-        # or ">"
-        ("=", operator.eq),
-        ("<=", operator.le),
-        (">=", operator.ge),
-        ("<", operator.lt),
-        (">", operator.gt),
-    ]:
-        if conf_string.startswith(op_str):
-            return element if op_func(zoom, _strip_zoom(conf_string, op_str)) else None
+    return config.zoom_parameters(zoom)
 
 
 def bounds_from_opts(
@@ -244,7 +107,7 @@ def bounds_from_opts(
     point: Optional[Iterable[float]] = None,
     point_crs: Optional[CRS] = None,
     zoom: Optional[int] = None,
-    bounds: Optional[Bounds] = None,
+    bounds: Optional[BoundsLike] = None,
     bounds_crs: Optional[CRS] = None,
     raw_conf: Optional[dict] = None,
 ) -> Bounds:
@@ -272,39 +135,46 @@ def bounds_from_opts(
 
     Returns
     -------
-    BufferedTilePyramid
+    Bounds
     """
     if wkt_geometry:
         return Bounds(*wkt.loads(wkt_geometry).bounds)
     elif point:
-        x, y = point
-        tp = raw_conf_process_pyramid(raw_conf)
-        if point_crs:
-            reproj = reproject_geometry(Point(x, y), src_crs=point_crs, dst_crs=tp.crs)
-            x = reproj.x
-            y = reproj.y
-        zoom_levels = get_zoom_levels(
-            process_zoom_levels=raw_conf["zoom_levels"], init_zoom_levels=zoom
-        )
-        return Bounds(
-            *tp.without_pixelbuffer().tile_from_xy(x, y, max(zoom_levels)).bounds
-        )
+        if raw_conf:
+            x, y = point
+            tp = raw_conf_process_pyramid(raw_conf)
+            if point_crs:
+                reproj = reproject_geometry(
+                    Point(x, y), src_crs=point_crs, dst_crs=tp.crs
+                )
+                x = reproj.x
+                y = reproj.y
+            zoom_levels = get_zoom_levels(
+                process_zoom_levels=raw_conf["zoom_levels"], init_zoom_levels=zoom
+            )
+            return Bounds(
+                *tp.without_pixelbuffer().tile_from_xy(x, y, max(zoom_levels)).bounds
+            )
+        raise ValueError("raw_conf is required")
     elif bounds:
         bounds = Bounds.from_inp(bounds)
         if bounds_crs:
-            tp = raw_conf_process_pyramid(raw_conf)
+            if raw_conf:
+                tp = raw_conf_process_pyramid(raw_conf)
+            else:
+                raise ValueError("raw_conf is required")
             bounds = Bounds(
                 *reproject_geometry(
-                    box(*bounds), src_crs=bounds_crs, dst_crs=tp.crs
+                    shape(bounds), src_crs=bounds_crs, dst_crs=tp.crs
                 ).bounds
             )
         return bounds
-    else:
-        return
+
+    raise ValueError("cannot determine bounds")
 
 
 def get_zoom_levels(
-    process_zoom_levels: ZoomLevelsLike = None,
+    process_zoom_levels: ZoomLevelsLike,
     init_zoom_levels: Optional[ZoomLevelsLike] = None,
 ) -> ZoomLevels:
     """Validate and return zoom levels."""
@@ -360,87 +230,3 @@ def guess_geometry(
             f"area must either be a Polygon or a MultiPolygon, not {geom.geom_type}"
         )
     return geom, crs
-
-
-def map_to_new_config_dict(config: dict) -> dict:
-    """Takes an older style configuration and tries to convert it to a current version."""
-    try:
-        validate_values(config, [("output", dict)])
-    except Exception as e:
-        raise MapcheteConfigError(e)
-
-    if "type" in config["output"]:  # pragma: no cover
-        warnings.warn(DeprecationWarning("'type' is deprecated and should be 'grid'"))
-        if "grid" not in config["output"]:
-            config["output"]["grid"] = config["output"].pop("type")
-
-    if "pyramid" not in config:
-        warnings.warn(
-            DeprecationWarning("'pyramid' needs to be defined in root config element.")
-        )
-        config["pyramid"] = dict(
-            grid=config["output"]["grid"],
-            metatiling=config.get("metatiling", 1),
-            pixelbuffer=config.get("pixelbuffer", 0),
-        )
-
-    if "zoom_levels" not in config:
-        warnings.warn(
-            DeprecationWarning(
-                "use new config element 'zoom_levels' instead of 'process_zoom', "
-                "'process_minzoom' and 'process_maxzoom'"
-            )
-        )
-        if "process_zoom" in config:
-            config["zoom_levels"] = config["process_zoom"]
-        elif all([i in config for i in ["process_minzoom", "process_maxzoom"]]):
-            config["zoom_levels"] = dict(
-                min=config["process_minzoom"], max=config["process_maxzoom"]
-            )
-        else:
-            raise MapcheteConfigError("process zoom levels not provided in config")
-
-    if "bounds" not in config:
-        if "process_bounds" in config:
-            warnings.warn(
-                DeprecationWarning(
-                    "'process_bounds' are deprecated and renamed to 'bounds'"
-                )
-            )
-            config["bounds"] = config["process_bounds"]
-        else:
-            config["bounds"] = None
-
-    if "input" not in config:
-        if "input_files" in config:
-            warnings.warn(
-                DeprecationWarning(
-                    "'input_files' are deprecated and renamed to 'input'"
-                )
-            )
-            config["input"] = config["input_files"]
-        else:
-            raise MapcheteConfigError("no 'input' found")
-
-    elif "input_files" in config:
-        raise MapcheteConfigError(
-            "'input' and 'input_files' are not allowed at the same time"
-        )
-
-    if "process_file" in config:
-        warnings.warn(
-            DeprecationWarning("'process_file' is deprecated and renamed to 'process'")
-        )
-        config["process"] = config.pop("process_file")
-
-    process_parameters = config.get("process_parameters", {})
-    for key in list(config.keys()):
-        if key in _RESERVED_PARAMETERS:
-            continue
-        warnings.warn(
-            "it puts the process parameter in the 'process_parameters' section, or it gets the warning again"
-        )
-        process_parameters[key] = config.pop(key)
-    config["process_parameters"] = process_parameters
-
-    return config


Test patch:
diff --git a/test/test_config.py b/test/test_config.py
index b3e1b2c..44e2345 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -5,6 +5,7 @@ from copy import deepcopy
 import oyaml as yaml
 import pytest
 from pydantic import ValidationError
+from pytest_lazyfixture import lazy_fixture
 from shapely import wkt
 from shapely.errors import WKTReadingError
 from shapely.geometry import Polygon, box, mapping, shape
@@ -69,6 +70,27 @@ def test_config_errors(example_mapchete):
         mapchete.open(config)
 
 
+def test_config_parse_dict(example_mapchete):
+    raw_config = example_mapchete.dict.copy()
+    raw_config.update(process_parameters=dict(foo=dict(bar=1)))
+    config = MapcheteConfig(raw_config)
+    assert config.params_at_zoom(7)["process_parameters"]["foo"]["bar"] == 1
+
+
+def test_config_parse_dict_zoom_overlaps_error(example_mapchete):
+    raw_config = example_mapchete.dict.copy()
+    raw_config.update(process_parameters={"foo": {"zoom<9": 1, "zoom<10": 2}})
+    with pytest.raises(MapcheteConfigError):
+        ProcessConfig.parse(raw_config).zoom_parameters(7)
+
+
+def test_config_parse_dict_not_all_zoom_dependent_error(example_mapchete):
+    raw_config = example_mapchete.dict.copy()
+    raw_config.update(process_parameters={"foo": {"zoom<9": 1, "bar": 2}})
+    with pytest.raises(MapcheteConfigError):
+        ProcessConfig.parse(raw_config).zoom_parameters(7)
+
+
 def test_config_zoom7(example_mapchete, dummy2_tif):
     """Example configuration at zoom 5."""
     config = MapcheteConfig(example_mapchete.dict)
@@ -166,17 +188,17 @@ def test_effective_bounds(files_bounds, baselevels):
 @pytest.mark.parametrize(
     "example_config",
     [
-        pytest.lazy_fixture("custom_grid"),
-        pytest.lazy_fixture("file_groups"),
-        pytest.lazy_fixture("overviews"),
-        pytest.lazy_fixture("baselevels"),
-        pytest.lazy_fixture("baselevels_output_buffer"),
-        pytest.lazy_fixture("baselevels_custom_nodata"),
-        pytest.lazy_fixture("mapchete_input"),
-        pytest.lazy_fixture("dem_to_hillshade"),
-        pytest.lazy_fixture("env_storage_options_mapchete"),
-        pytest.lazy_fixture("zoom_mapchete"),
-        pytest.lazy_fixture("cleantopo_br_mercator"),
+        lazy_fixture("custom_grid"),
+        lazy_fixture("file_groups"),
+        lazy_fixture("overviews"),
+        lazy_fixture("baselevels"),
+        lazy_fixture("baselevels_output_buffer"),
+        lazy_fixture("baselevels_custom_nodata"),
+        lazy_fixture("mapchete_input"),
+        lazy_fixture("dem_to_hillshade"),
+        lazy_fixture("env_storage_options_mapchete"),
+        lazy_fixture("zoom_mapchete"),
+        lazy_fixture("cleantopo_br_mercator"),
     ],
 )
 def test_effective_area(example_config):
@@ -390,15 +412,19 @@ def test_guess_geometry(aoi_br_geojson):
         guess_geometry(area.centroid)
 
 
-def test_bounds_from_opts(example_mapchete, wkt_geom):
+def test_bounds_from_opts_wkt(wkt_geom):
     # WKT
     assert isinstance(bounds_from_opts(wkt_geometry=wkt_geom), Bounds)
 
+
+def test_bounds_from_opts_point(example_mapchete):
     # point
     assert isinstance(
         bounds_from_opts(point=(0, 0), raw_conf=example_mapchete.dict), Bounds
     )
 
+
+def test_bounds_from_opts_point_crs(example_mapchete):
     # point from different CRS
     assert isinstance(
         bounds_from_opts(
@@ -407,11 +433,15 @@ def test_bounds_from_opts(example_mapchete, wkt_geom):
         Bounds,
     )
 
+
+def test_bounds_from_opts_bounds(example_mapchete):
     # bounds
     assert isinstance(
         bounds_from_opts(bounds=(1, 2, 3, 4), raw_conf=example_mapchete.dict), Bounds
     )
 
+
+def test_bounds_from_opts_bounds_crs(example_mapchete):
     # bounds from different CRS
     assert isinstance(
         bounds_from_opts(
@@ -421,6 +451,16 @@ def test_bounds_from_opts(example_mapchete, wkt_geom):
     )
 
 
+def test_bounds_from_opts_point_no_conf_error():
+    with pytest.raises(ValueError):
+        bounds_from_opts(point=(0, 0))
+
+
+def test_bounds_from_opts_bounds_no_conf_error():
+    with pytest.raises(ValueError):
+        bounds_from_opts(bounds=(1, 2, 3, 4), bounds_crs="EPSG:3857")
+
+
 def test_init_overrides_config(example_mapchete):
     process_bounds = (0, 1, 2, 3)
     init_bounds = (3, 4, 5, 6)