From 8827e4a250603efc5f9b63ed9c1fa937de866b16 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:36:01 +0200
Subject: [PATCH 01/23] WIP: implement extended filenames as dict

---
 pyotb/core.py | 312 ++++++++++++++++++++++++++++++--------------------
 1 file changed, 188 insertions(+), 124 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index 06da0c7..268b284 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -87,7 +87,7 @@ class OTBObject(ABC):
         return App("ComputeImagesStatistics", self, quiet=True).data
 
     def get_values_at_coords(
-        self, row: int, col: int, bands: int = None
+            self, row: int, col: int, bands: int = None
     ) -> list[int | float] | int | float:
         """Get pixel value(s) at a given YX coordinates.
 
@@ -101,7 +101,8 @@ class OTBObject(ABC):
 
         """
         channels = []
-        app = App("PixelValue", self, coordx=col, coordy=row, frozen=True, quiet=True)
+        app = App("PixelValue", self, coordx=col, coordy=row, frozen=True,
+                  quiet=True)
         if bands is not None:
             if isinstance(bands, int):
                 if bands < 0:
@@ -115,7 +116,8 @@ class OTBObject(ABC):
                 )
             if channels:
                 app.app.Execute()
-                app.set_parameters({"cl": [f"Channel{n + 1}" for n in channels]})
+                app.set_parameters(
+                    {"cl": [f"Channel{n + 1}" for n in channels]})
         app.execute()
         data = literal_eval(app.app.GetParameterString("value"))
         return data[0] if len(channels) == 1 else data
@@ -124,8 +126,10 @@ class OTBObject(ABC):
         """Get list of channels to read values at, from a slice."""
         nb_channels = self.shape[2]
         start, stop, step = bands.start, bands.stop, bands.step
-        start = nb_channels + start if isinstance(start, int) and start < 0 else start
-        stop = nb_channels + stop if isinstance(stop, int) and stop < 0 else stop
+        start = nb_channels + start if isinstance(start,
+                                                  int) and start < 0 else start
+        stop = nb_channels + stop if isinstance(stop,
+                                                int) and stop < 0 else stop
         step = 1 if step is None else step
         if start is not None and stop is not None:
             return list(range(start, stop, step))
@@ -140,7 +144,7 @@ class OTBObject(ABC):
         )
 
     def export(
-        self, key: str = None, preserve_dtype: bool = True
+            self, key: str = None, preserve_dtype: bool = True
     ) -> dict[str, dict[str, np.ndarray]]:
         """Export a specific output image as numpy array and store it in object exports_dic.
 
@@ -158,13 +162,15 @@ class OTBObject(ABC):
         if key not in self.exports_dic:
             self.exports_dic[key] = self.app.ExportImage(key)
         if preserve_dtype:
-            self.exports_dic[key]["array"] = self.exports_dic[key]["array"].astype(
+            self.exports_dic[key]["array"] = self.exports_dic[key][
+                "array"].astype(
                 self.dtype
             )
         return self.exports_dic[key]
 
     def to_numpy(
-        self, key: str = None, preserve_dtype: bool = True, copy: bool = False
+            self, key: str = None, preserve_dtype: bool = True,
+            copy: bool = False
     ) -> np.ndarray:
         """Export a pyotb object to numpy array.
 
@@ -193,7 +199,8 @@ class OTBObject(ABC):
         profile = {}
         array = self.to_numpy(preserve_dtype=True, copy=False)
         proj = self.app.GetImageProjection(self.output_image_key)
-        profile.update({"crs": proj, "dtype": array.dtype, "transform": self.transform})
+        profile.update(
+            {"crs": proj, "dtype": array.dtype, "transform": self.transform})
         height, width, count = array.shape
         profile.update({"count": count, "height": height, "width": width})
         return np.moveaxis(array, 2, 0), profile
@@ -294,7 +301,8 @@ class OTBObject(ABC):
         """Logical or."""
         return self.__create_operator(LogicalOperation, "||", self, other)
 
-    def __and__(self, other: OTBObject | str | int | float) -> LogicalOperation:
+    def __and__(self,
+                other: OTBObject | str | int | float) -> LogicalOperation:
         """Logical and."""
         return self.__create_operator(LogicalOperation, "&&", self, other)
 
@@ -390,7 +398,7 @@ class OTBObject(ABC):
                 return self.get_values_at_coords(row, col, channels)
         # Slicing
         if not isinstance(key, tuple) or (
-            isinstance(key, tuple) and (len(key) < 2 or len(key) > 3)
+                isinstance(key, tuple) and (len(key) < 2 or len(key) > 3)
         ):
             raise ValueError(
                 f'"{key}" cannot be interpreted as valid slicing. Slicing should be 2D or 3D.'
@@ -446,13 +454,13 @@ class App(OTBObject):
     ]
 
     def __init__(
-        self,
-        appname: str,
-        *args,
-        frozen: bool = False,
-        quiet: bool = False,
-        name: str = "",
-        **kwargs,
+            self,
+            appname: str,
+            *args,
+            frozen: bool = False,
+            quiet: bool = False,
+            name: str = "",
+            **kwargs,
     ):
         """Common constructor for OTB applications. Handles in-memory connection between apps.
 
@@ -502,8 +510,9 @@ class App(OTBObject):
             self.set_parameters(*args, **kwargs)
         # Create Output image objects
         for key in filter(
-            lambda k: self._out_param_types[k] == otb.ParameterType_OutputImage,
-            self._out_param_types,
+                lambda k: self._out_param_types[
+                              k] == otb.ParameterType_OutputImage,
+                self._out_param_types,
         ):
             self.outputs[key] = Output(self, key, self._settings.get(key))
         if not self.frozen:
@@ -524,7 +533,8 @@ class App(OTBObject):
     @property
     def parameters(self):
         """Return used OTB application parameters."""
-        return {**self._auto_parameters, **self.app.GetParameters(), **self._settings}
+        return {**self._auto_parameters, **self.app.GetParameters(),
+                **self._settings}
 
     @property
     def exports_dic(self) -> dict[str, dict]:
@@ -534,7 +544,8 @@ class App(OTBObject):
     def __is_one_of_types(self, key: str, param_types: list[int]) -> bool:
         """Helper to factor is_input and is_output."""
         if key not in self._all_param_types:
-            raise KeyError(f"key {key} not found in the application parameters types")
+            raise KeyError(
+                f"key {key} not found in the application parameters types")
         return self._all_param_types[key] in param_types
 
     def is_input(self, key: str) -> bool:
@@ -547,7 +558,8 @@ class App(OTBObject):
             True if the parameter is an input, else False
 
         """
-        return self.__is_one_of_types(key=key, param_types=self.INPUT_PARAM_TYPES)
+        return self.__is_one_of_types(key=key,
+                                      param_types=self.INPUT_PARAM_TYPES)
 
     def is_output(self, key: str) -> bool:
         """Returns True if the key is an output.
@@ -559,7 +571,8 @@ class App(OTBObject):
             True if the parameter is an output, else False
 
         """
-        return self.__is_one_of_types(key=key, param_types=self.OUTPUT_PARAM_TYPES)
+        return self.__is_one_of_types(key=key,
+                                      param_types=self.OUTPUT_PARAM_TYPES)
 
     def is_key_list(self, key: str) -> bool:
         """Check if a parameter key is an input parameter list."""
@@ -683,7 +696,8 @@ class App(OTBObject):
                 dtype = get_pixel_type(param)
             except (TypeError, RuntimeError):
                 logger.warning(
-                    '%s: unable to identify pixel type of key "%s"', self.name, param
+                    '%s: unable to identify pixel type of key "%s"', self.name,
+                    param
                 )
                 return
         if target_key:
@@ -699,7 +713,8 @@ class App(OTBObject):
 
     def execute(self):
         """Execute and write to disk if any output parameter has been set during init."""
-        logger.debug("%s: run execute() with parameters=%s", self.name, self.parameters)
+        logger.debug("%s: run execute() with parameters=%s", self.name,
+                     self.parameters)
         self._time_start = perf_counter()
         try:
             self.app.Execute()
@@ -727,28 +742,36 @@ class App(OTBObject):
         self._time_end = perf_counter()
 
     def write(
-        self,
-        path: str | Path | dict[str, str] = None,
-        pixel_type: dict[str, str] | str = None,
-        preserve_dtype: bool = False,
-        ext_fname: str = "",
-        **kwargs,
+            self,
+            path: str | Path | dict[str, str] = None,
+            pixel_type: dict[str, str] | str = None,
+            preserve_dtype: bool = False,
+            ext_fname: str | dict[str, str] | dict[str, int] | dict[
+                str, float] = None,
+            **kwargs,
     ) -> bool:
         """Set output pixel type and write the output raster files.
 
         Args:
-            path: Can be : - filepath, useful when there is only one output, e.g. 'output.tif'
-                           - dictionary containing key-arguments enumeration. Useful when a key contains
-                             non-standard characters such as a point, e.g. {'io.out':'output.tif'}
-                           - None if output file was passed during App init
-            ext_fname: Optional, an extended filename as understood by OTB (e.g. "&gdal:co:TILED=YES")
-                                Will be used for all outputs (Default value = "")
-            pixel_type: Can be : - dictionary {out_param_key: pixeltype} when specifying for several outputs
-                                 - str (e.g. 'uint16') or otbApplication.ImagePixelType_... When there are several
-                                   outputs, all outputs are written with this unique type.
-                                   Valid pixel types are uint8, uint16, uint32, int16, int32, float, double,
-                                   cint16, cint32, cfloat, cdouble. (Default value = None)
-            preserve_dtype: propagate main input pixel type to outputs, in case pixel_type is None
+            path: Can be :
+                - filepath, useful when there is only one output, e.g.
+                'output.tif'
+                - dictionary containing key-arguments enumeration. Useful when
+                a key contains non-standard characters such as a point, e.g.
+                {'io.out':'output.tif'}
+               - None if output file was passed during App init
+            ext_fname: Optional, an extended filename as understood by OTB
+                (e.g. "&gdal:co:TILED=YES") or a dict of key/value. Will be
+                used for all outputs.
+            pixel_type: Can be :
+                - dictionary {out_param_key: pixeltype} when specifying for
+                several outputs
+                - str (e.g. 'uint16') or otbApplication.ImagePixelType_...
+                When there are several outputs, all outputs are written with
+                this unique type. Valid pixel types are uint8, uint16, uint32,
+                int16, int32, float, double, cint16, cint32, cfloat, cdouble.
+            preserve_dtype: propagate main input pixel type to outputs, in
+                case pixel_type is None
             **kwargs: keyword arguments e.g. out='output.tif'
 
         Returns:
@@ -767,28 +790,47 @@ class App(OTBObject):
         elif isinstance(path, (str, Path)) and self.output_key:
             kwargs.update({self.output_key: str(path)})
         elif path is not None:
-            raise TypeError(f"{self.name}: unsupported filepath type ({type(path)})")
-        if not (kwargs or any(k in self._settings for k in self._out_param_types)):
+            raise TypeError(
+                f"{self.name}: unsupported filepath type ({type(path)})")
+        if not (kwargs or any(
+                k in self._settings for k in self._out_param_types)):
             raise KeyError(
                 f"{self.name}: at least one filepath is required, if not provided during App init"
             )
         parameters = kwargs.copy()
 
         # Append filename extension to filenames
+        assert isinstance(ext_fname, (dict, str))
         if ext_fname:
-            logger.debug(
-                "%s: using extended filename for outputs: %s", self.name, ext_fname
-            )
-            if not ext_fname.startswith("?"):
-                ext_fname = "?&" + ext_fname
-            elif not ext_fname.startswith("?&"):
-                ext_fname = "?&" + ext_fname[1:]
-            for key, value in kwargs.items():
-                if (
-                    self._out_param_types[key] == otb.ParameterType_OutputImage
-                    and "?" not in value
-                ):
-                    parameters[key] = value + ext_fname
+
+            def _str2dict(ext_str):
+                """Function that converts str to dict."""
+                return dict(
+                    kv for pair in ext_str.split("&")
+                    if len(kv := pair.split("=")) == 2
+                )
+
+            if isinstance(ext_fname, str):
+                # transform str to dict
+                ext_fname = _str2dict(ext_fname)
+
+            logger.debug("%s: extended filename for outputs: %s", self.name)
+            for key, ext in ext_fname.items():
+                logger.debug("%s: %s", key, ext)
+
+            for key, filepath in kwargs.items():
+                if self._out_param_types[key] == otb.ParameterType_OutputImage:
+                    # grab already set extended filename key/values
+                    already_set_ext = _str2dict(filepath.split("?&")[0]) \
+                        if "?&" in filepath else {}
+
+                    # transform dict to str
+                    ext_fname_str = "&".join([
+                        f"{k}={v}" for k, v in ext_fname.items()
+                        if k not in already_set_ext
+                    ])
+                    parameters[key] = f"{filepath}?{ext_fname_str}"
+
         # Manage output pixel types
         data_types = {}
         if pixel_type:
@@ -796,14 +838,17 @@ class App(OTBObject):
                 dtype = parse_pixel_type(pixel_type)
                 type_name = self.app.ConvertPixelTypeToNumpy(dtype)
                 logger.debug(
-                    '%s: output(s) will be written with type "%s"', self.name, type_name
+                    '%s: output(s) will be written with type "%s"', self.name,
+                    type_name
                 )
                 for key in parameters:
-                    if self._out_param_types[key] == otb.ParameterType_OutputImage:
+                    if self._out_param_types[
+                        key] == otb.ParameterType_OutputImage:
                         data_types[key] = dtype
             elif isinstance(pixel_type, dict):
                 data_types = {
-                    key: parse_pixel_type(dtype) for key, dtype in pixel_type.items()
+                    key: parse_pixel_type(dtype) for key, dtype in
+                    pixel_type.items()
                 }
         elif preserve_dtype:
             self.propagate_dtype()  # all outputs will have the same type as the main input raster
@@ -836,7 +881,8 @@ class App(OTBObject):
         return bool(files) and not missing
 
     # Private functions
-    def __parse_args(self, args: list[str | OTBObject | dict | list]) -> dict[str, Any]:
+    def __parse_args(self, args: list[str | OTBObject | dict | list]) -> dict[
+        str, Any]:
         """Gather all input arguments in kwargs dict.
 
         Args:
@@ -851,15 +897,16 @@ class App(OTBObject):
             if isinstance(arg, dict):
                 kwargs.update(arg)
             elif (
-                isinstance(arg, (str, OTBObject))
-                or isinstance(arg, list)
-                and self.is_key_list(self.input_key)
+                    isinstance(arg, (str, OTBObject))
+                    or isinstance(arg, list)
+                    and self.is_key_list(self.input_key)
             ):
                 kwargs.update({self.input_key: arg})
         return kwargs
 
     def __set_param(
-        self, key: str, obj: list | tuple | OTBObject | otb.Application | list[Any]
+            self, key: str,
+            obj: list | tuple | OTBObject | otb.Application | list[Any]
     ):
         """Set one parameter, decide which otb.Application method to use depending on target object."""
         if obj is None or (isinstance(obj, (list, tuple)) and not obj):
@@ -869,11 +916,11 @@ class App(OTBObject):
         if isinstance(obj, OTBObject):
             self.app.ConnectImage(key, obj.app, obj.output_image_key)
         elif isinstance(
-            obj, otb.Application
+                obj, otb.Application
         ):  # this is for backward comp with plain OTB
             self.app.ConnectImage(key, obj, get_out_images_param_keys(obj)[0])
         elif (
-            key == "ram"
+                key == "ram"
         ):  # SetParameterValue in OTB<7.4 doesn't work for ram parameter cf gitlab OTB issue 2200
             self.app.SetParameterInt("ram", int(obj))
         elif not isinstance(obj, list):  # any other parameters (str, int...)
@@ -885,9 +932,10 @@ class App(OTBObject):
                 if isinstance(inp, OTBObject):
                     self.app.ConnectImage(key, inp.app, inp.output_image_key)
                 elif isinstance(
-                    inp, otb.Application
+                        inp, otb.Application
                 ):  # this is for backward comp with plain OTB
-                    self.app.ConnectImage(key, obj, get_out_images_param_keys(inp)[0])
+                    self.app.ConnectImage(key, obj,
+                                          get_out_images_param_keys(inp)[0])
                 else:  # here `input` should be an image filepath
                     # Append `input` to the list, do not overwrite any previously set element of the image list
                     self.app.AddParameterStringList(key, inp)
@@ -902,8 +950,9 @@ class App(OTBObject):
                 continue
             value = self.app.GetParameterValue(key)
             # TODO: here we *should* use self.app.IsParameterEnabled, but it seems broken
-            if isinstance(value, otb.ApplicationProxy) and self.app.HasAutomaticValue(
-                key
+            if isinstance(value,
+                          otb.ApplicationProxy) and self.app.HasAutomaticValue(
+                    key
             ):
                 try:
                     value = str(
@@ -914,7 +963,8 @@ class App(OTBObject):
                 except RuntimeError:
                     continue  # grouped parameters
             # Save static output data (ReadImageInfo, ComputeImageStatistics, etc.)
-            elif self.app.GetParameterRole(key) == 1 and bool(value) or value == 0:
+            elif self.app.GetParameterRole(key) == 1 and bool(
+                    value) or value == 0:
                 if isinstance(value, str):
                     try:
                         value = literal_eval(value)
@@ -923,7 +973,8 @@ class App(OTBObject):
                 self.data[key] = value
 
     # Special functions
-    def __getitem__(self, key: str) -> Any | list[int | float] | int | float | Slicer:
+    def __getitem__(self, key: str) -> Any | list[
+        int | float] | int | float | Slicer:
         """This function is called when we use App()[...].
 
         We allow to return attr if key is a parameter, or call OTBObject __getitem__ for pixel values or Slicer
@@ -937,7 +988,8 @@ class App(OTBObject):
                 return self.outputs[key]
             if key in self.parameters:
                 return self.parameters[key]
-            raise KeyError(f"{self.name}: unknown or undefined parameter '{key}'")
+            raise KeyError(
+                f"{self.name}: unknown or undefined parameter '{key}'")
         raise TypeError(
             f"{self.name}: cannot access object item or slice using {type(key)} object"
         )
@@ -947,11 +999,11 @@ class Slicer(App):
     """Slicer objects i.e. when we call something like raster[:, :, 2] from Python."""
 
     def __init__(
-        self,
-        obj: OTBObject,
-        rows: slice,
-        cols: slice,
-        channels: slice | list[int] | int,
+            self,
+            obj: OTBObject,
+            rows: slice,
+            cols: slice,
+            channels: slice | list[int] | int,
     ):
         """Create a slicer object, that can be used directly for writing or inside a BandMath.
 
@@ -1018,9 +1070,10 @@ class Slicer(App):
             )  # subtract 1 to respect python convention
             spatial_slicing = True
         # These are some attributes when the user simply wants to extract *one* band to be used in an Operation
-        if not spatial_slicing and isinstance(channels, list) and len(channels) == 1:
+        if not spatial_slicing and isinstance(channels, list) and len(
+                channels) == 1:
             self.one_band_sliced = (
-                channels[0] + 1
+                    channels[0] + 1
             )  # OTB convention: channels start at 1
             self.input = obj
 
@@ -1051,7 +1104,8 @@ class Operation(App):
 
     """
 
-    def __init__(self, operator: str, *inputs, nb_bands: int = None, name: str = None):
+    def __init__(self, operator: str, *inputs, nb_bands: int = None,
+                 name: str = None):
         """Given some inputs and an operator, this function enables to transform this into an OTB application.
 
         Operations generally involve 2 inputs (+, -...). It can have only 1 input for `abs` operator.
@@ -1098,10 +1152,10 @@ class Operation(App):
         )
 
     def build_fake_expressions(
-        self,
-        operator: str,
-        inputs: list[OTBObject | str | int | float],
-        nb_bands: int = None,
+            self,
+            operator: str,
+            inputs: list[OTBObject | str | int | float],
+            nb_bands: int = None,
     ):
         """Create a list of 'fake' expressions, one for each band.
 
@@ -1122,8 +1176,8 @@ class Operation(App):
         # For any other operations, the output number of bands is the same as inputs
         else:
             if any(
-                isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced")
-                for inp in inputs
+                    isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced")
+                    for inp in inputs
             ):
                 nb_bands = 1
             else:
@@ -1134,9 +1188,10 @@ class Operation(App):
                 ]
                 # check that all inputs have the same nb of bands
                 if len(nb_bands_list) > 1 and not all(
-                    x == nb_bands_list[0] for x in nb_bands_list
+                        x == nb_bands_list[0] for x in nb_bands_list
                 ):
-                    raise ValueError("All images do not have the same number of bands")
+                    raise ValueError(
+                        "All images do not have the same number of bands")
                 nb_bands = nb_bands_list[0]
 
         # Create a list of fake expressions, each item of the list corresponding to one band
@@ -1171,7 +1226,7 @@ class Operation(App):
                 # the false expression stores the expression 2 * str(input1) + str(input2)
                 fake_exp = f"({expressions[0]} {operator} {expressions[1]})"
             elif (
-                len(inputs) == 3 and operator == "?"
+                    len(inputs) == 3 and operator == "?"
             ):  # this is only for ternary expression
                 fake_exp = f"({expressions[0]} ? {expressions[1]} : {expressions[2]})"
             self.fake_exp_bands.append(fake_exp)
@@ -1193,14 +1248,15 @@ class Operation(App):
             one_band_exp = one_band_fake_exp
             for inp in self.inputs:
                 # Replace the name of in-memory object (e.g. '<pyotb.App object>b1' by 'im1b1')
-                one_band_exp = one_band_exp.replace(repr(inp), self.im_dic[repr(inp)])
+                one_band_exp = one_band_exp.replace(repr(inp),
+                                                    self.im_dic[repr(inp)])
             exp_bands.append(one_band_exp)
         # Form the final expression (e.g. 'im1b1 + 1; im1b2 + 1')
         return exp_bands, ";".join(exp_bands)
 
     @staticmethod
     def make_fake_exp(
-        x: OTBObject | str, band: int, keep_logical: bool = False
+            x: OTBObject | str, band: int, keep_logical: bool = False
     ) -> tuple[str, list[OTBObject], int]:
         """This an internal function, only to be used by `build_fake_expressions`.
 
@@ -1223,7 +1279,8 @@ class Operation(App):
         # Special case for one-band slicer
         if isinstance(x, Slicer) and hasattr(x, "one_band_sliced"):
             if keep_logical and isinstance(x.input, LogicalOperation):
-                fake_exp = x.input.logical_fake_exp_bands[x.one_band_sliced - 1]
+                fake_exp = x.input.logical_fake_exp_bands[
+                    x.one_band_sliced - 1]
                 inputs, nb_channels = x.input.inputs, x.input.nb_channels
             elif isinstance(x.input, Operation):
                 # Keep only one band of the expression
@@ -1275,16 +1332,17 @@ class LogicalOperation(Operation):
 
         """
         self.logical_fake_exp_bands = []
-        super().__init__(operator, *inputs, nb_bands=nb_bands, name="LogicalOperation")
+        super().__init__(operator, *inputs, nb_bands=nb_bands,
+                         name="LogicalOperation")
         self.logical_exp_bands, self.logical_exp = self.get_real_exp(
             self.logical_fake_exp_bands
         )
 
     def build_fake_expressions(
-        self,
-        operator: str,
-        inputs: list[OTBObject | str | int | float],
-        nb_bands: int = None,
+            self,
+            operator: str,
+            inputs: list[OTBObject | str | int | float],
+            nb_bands: int = None,
     ):
         """Create a list of 'fake' expressions, one for each band.
 
@@ -1299,8 +1357,8 @@ class LogicalOperation(Operation):
         """
         # For any other operations, the output number of bands is the same as inputs
         if any(
-            isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced")
-            for inp in inputs
+                isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced")
+                for inp in inputs
         ):
             nb_bands = 1
         else:
@@ -1311,9 +1369,10 @@ class LogicalOperation(Operation):
             ]
             # check that all inputs have the same nb of bands
             if len(nb_bands_list) > 1 and not all(
-                x == nb_bands_list[0] for x in nb_bands_list
+                    x == nb_bands_list[0] for x in nb_bands_list
             ):
-                raise ValueError("All images do not have the same number of bands")
+                raise ValueError(
+                    "All images do not have the same number of bands")
             nb_bands = nb_bands_list[0]
         # Create a list of fake exp, each item of the list corresponding to one band
         for i, band in enumerate(range(1, nb_bands + 1)):
@@ -1366,11 +1425,11 @@ class Output(OTBObject):
     _filepath: str | Path = None
 
     def __init__(
-        self,
-        pyotb_app: App,
-        param_key: str = None,
-        filepath: str = None,
-        mkdir: bool = True,
+            self,
+            pyotb_app: App,
+            param_key: str = None,
+            filepath: str = None,
+            mkdir: bool = True,
     ):
         """Constructor for an Output object.
 
@@ -1417,7 +1476,8 @@ class Output(OTBObject):
     @filepath.setter
     def filepath(self, path: str):
         if isinstance(path, str):
-            if path and not path.startswith(("/vsi", "http://", "https://", "ftp://")):
+            if path and not path.startswith(
+                    ("/vsi", "http://", "https://", "ftp://")):
                 path = Path(path.split("?")[0])
             self._filepath = path
 
@@ -1439,7 +1499,8 @@ class Output(OTBObject):
             return self.parent_pyotb_app.write(
                 {self.output_image_key: self.filepath}, **kwargs
             )
-        return self.parent_pyotb_app.write({self.output_image_key: filepath}, **kwargs)
+        return self.parent_pyotb_app.write({self.output_image_key: filepath},
+                                           **kwargs)
 
     def __str__(self) -> str:
         """Return string representation of Output filepath."""
@@ -1496,12 +1557,13 @@ def get_nbchannels(inp: str | Path | OTBObject) -> int:
             info = App("ReadImageInfo", inp, quiet=True)
             return info["numberbands"]
         except (
-            RuntimeError
+                RuntimeError
         ) as info_err:  # this happens when we pass a str that is not a filepath
             raise TypeError(
                 f"Could not get the number of channels file '{inp}' ({info_err})"
             ) from info_err
-    raise TypeError(f"Can't read number of channels of type '{type(inp)}' object {inp}")
+    raise TypeError(
+        f"Can't read number of channels of type '{type(inp)}' object {inp}")
 
 
 def get_pixel_type(inp: str | Path | OTBObject) -> str:
@@ -1522,14 +1584,15 @@ def get_pixel_type(inp: str | Path | OTBObject) -> str:
             info = App("ReadImageInfo", inp, quiet=True)
             datatype = info["datatype"]  # which is such as short, float...
         except (
-            RuntimeError
+                RuntimeError
         ) as info_err:  # this happens when we pass a str that is not a filepath
             raise TypeError(
                 f"Could not get the pixel type of `{inp}` ({info_err})"
             ) from info_err
         if datatype:
             return parse_pixel_type(datatype)
-    raise TypeError(f"Could not get the pixel type of {type(inp)} object {inp}")
+    raise TypeError(
+        f"Could not get the pixel type of {type(inp)} object {inp}")
 
 
 def parse_pixel_type(pixel_type: str | int) -> int:
@@ -1559,7 +1622,8 @@ def parse_pixel_type(pixel_type: str | int) -> int:
         if pixel_type in datatype_to_pixeltype.values():
             return getattr(otb, f"ImagePixelType_{pixel_type}")
         if pixel_type in datatype_to_pixeltype:
-            return getattr(otb, f"ImagePixelType_{datatype_to_pixeltype[pixel_type]}")
+            return getattr(otb,
+                           f"ImagePixelType_{datatype_to_pixeltype[pixel_type]}")
         raise KeyError(
             f"Unknown data type `{pixel_type}`. Available ones: {datatype_to_pixeltype}"
         )
@@ -1578,9 +1642,9 @@ def get_out_images_param_keys(app: OTBObject) -> list[str]:
 
 
 def summarize(
-    obj: App | Output | Any,
-    strip_input_paths: bool = False,
-    strip_output_paths: bool = False,
+        obj: App | Output | Any,
+        strip_input_paths: bool = False,
+        strip_output_paths: bool = False,
 ) -> dict[str, str | dict[str, Any]]:
     """Recursively summarize parameters of an App or Output object and its parents.
 
@@ -1614,10 +1678,10 @@ def summarize(
     parameters = {}
     for key, param in obj.parameters.items():
         if (
-            strip_input_paths
-            and obj.is_input(key)
-            or strip_output_paths
-            and obj.is_output(key)
+                strip_input_paths
+                and obj.is_input(key)
+                or strip_output_paths
+                and obj.is_output(key)
         ):
             parameters[key] = (
                 [strip_path(p) for p in param]
-- 
GitLab


From 437eb5d003a946b1980de2f45756c294599fa3e0 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:46:26 +0200
Subject: [PATCH 02/23] WIP: implement extended filenames as dict

---
 pyotb/core.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index 268b284..c50ca4d 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -800,9 +800,9 @@ class App(OTBObject):
         parameters = kwargs.copy()
 
         # Append filename extension to filenames
-        assert isinstance(ext_fname, (dict, str))
         if ext_fname:
-
+            assert isinstance(ext_fname, (dict, str))
+            
             def _str2dict(ext_str):
                 """Function that converts str to dict."""
                 return dict(
-- 
GitLab


From 9beba5f4f0b5d1b72da13baf7b758aa1a406b357 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:52:51 +0200
Subject: [PATCH 03/23] WIP: implement extended filenames as dict

---
 pyotb/core.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index c50ca4d..af667aa 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -802,7 +802,7 @@ class App(OTBObject):
         # Append filename extension to filenames
         if ext_fname:
             assert isinstance(ext_fname, (dict, str))
-            
+
             def _str2dict(ext_str):
                 """Function that converts str to dict."""
                 return dict(
@@ -829,7 +829,7 @@ class App(OTBObject):
                         f"{k}={v}" for k, v in ext_fname.items()
                         if k not in already_set_ext
                     ])
-                    parameters[key] = f"{filepath}?{ext_fname_str}"
+                    parameters[key] = f"{filepath}?&{ext_fname_str}"
 
         # Manage output pixel types
         data_types = {}
-- 
GitLab


From 4ac0b869bc22a54489736a0ea5521aeeb0720fe7 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:56:15 +0200
Subject: [PATCH 04/23] ADD: test for extended filename

---
 tests/test_core.py | 19 ++++++++++++++++++-
 1 file changed, 18 insertions(+), 1 deletion(-)

diff --git a/tests/test_core.py b/tests/test_core.py
index 99b21de..b19e92b 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,6 +1,5 @@
 import pytest
 
-import pyotb
 from tests_data import *
 
 
@@ -111,6 +110,24 @@ def test_write():
     INPUT["out"].filepath.unlink()
 
 
+def test_ext_fname():
+    assert INPUT.write("/tmp/test_write.tif", ext_fname="nodata=0")
+    assert INPUT.write("/tmp/test_write.tif", ext_fname={"nodata": "0"})
+    assert INPUT.write("/tmp/test_write.tif", ext_fname={"nodata": 0})
+    assert INPUT.write(
+        "/tmp/test_write.tif",
+        ext_fname={
+            "nodata": 0,
+            "gdal:co:COMPRESS": "DEFLATE"
+        }
+    )
+    assert INPUT.write(
+        "/tmp/test_write.tif",
+        ext_fname="nodata=0&gdal:co:COMPRESS=DEFLATE"
+    )
+    INPUT["out"].filepath.unlink()
+
+
 def test_frozen_app_write():
     app = pyotb.BandMath(INPUT, exp="im1b1", frozen=True)
     assert app.write("/tmp/test_frozen_app_write.tif")
-- 
GitLab


From c37e717fa6b4ad0400b06ad7499e4893c5f3b3e7 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:56:31 +0200
Subject: [PATCH 05/23] DOC: pydocstyle

---
 pyotb/core.py | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index af667aa..0cc74a5 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -802,19 +802,18 @@ class App(OTBObject):
         # Append filename extension to filenames
         if ext_fname:
             assert isinstance(ext_fname, (dict, str))
-
             def _str2dict(ext_str):
                 """Function that converts str to dict."""
                 return dict(
-                    kv for pair in ext_str.split("&")
-                    if len(kv := pair.split("=")) == 2
+                    keyval for pair in ext_str.split("&")
+                    if len(keyval := pair.split("=")) == 2
                 )
 
             if isinstance(ext_fname, str):
                 # transform str to dict
                 ext_fname = _str2dict(ext_fname)
 
-            logger.debug("%s: extended filename for outputs: %s", self.name)
+            logger.debug("%s: extended filename for outputs:", self.name)
             for key, ext in ext_fname.items():
                 logger.debug("%s: %s", key, ext)
 
-- 
GitLab


From 3c4459a5c0d4a8150e5209b2dddba9bc17c4cc24 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 10:07:41 +0200
Subject: [PATCH 06/23] STY: back to original indentation

---
 pyotb/core.py | 269 +++++++++++++++++++++-----------------------------
 1 file changed, 111 insertions(+), 158 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index 0cc74a5..b776a3d 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -87,7 +87,7 @@ class OTBObject(ABC):
         return App("ComputeImagesStatistics", self, quiet=True).data
 
     def get_values_at_coords(
-            self, row: int, col: int, bands: int = None
+        self, row: int, col: int, bands: int = None
     ) -> list[int | float] | int | float:
         """Get pixel value(s) at a given YX coordinates.
 
@@ -101,8 +101,7 @@ class OTBObject(ABC):
 
         """
         channels = []
-        app = App("PixelValue", self, coordx=col, coordy=row, frozen=True,
-                  quiet=True)
+        app = App("PixelValue", self, coordx=col, coordy=row, frozen=True, quiet=True)
         if bands is not None:
             if isinstance(bands, int):
                 if bands < 0:
@@ -116,8 +115,7 @@ class OTBObject(ABC):
                 )
             if channels:
                 app.app.Execute()
-                app.set_parameters(
-                    {"cl": [f"Channel{n + 1}" for n in channels]})
+                app.set_parameters({"cl": [f"Channel{n + 1}" for n in channels]})
         app.execute()
         data = literal_eval(app.app.GetParameterString("value"))
         return data[0] if len(channels) == 1 else data
@@ -126,10 +124,8 @@ class OTBObject(ABC):
         """Get list of channels to read values at, from a slice."""
         nb_channels = self.shape[2]
         start, stop, step = bands.start, bands.stop, bands.step
-        start = nb_channels + start if isinstance(start,
-                                                  int) and start < 0 else start
-        stop = nb_channels + stop if isinstance(stop,
-                                                int) and stop < 0 else stop
+        start = nb_channels + start if isinstance(start, int) and start < 0 else start
+        stop = nb_channels + stop if isinstance(stop, int) and stop < 0 else stop
         step = 1 if step is None else step
         if start is not None and stop is not None:
             return list(range(start, stop, step))
@@ -144,7 +140,7 @@ class OTBObject(ABC):
         )
 
     def export(
-            self, key: str = None, preserve_dtype: bool = True
+        self, key: str = None, preserve_dtype: bool = True
     ) -> dict[str, dict[str, np.ndarray]]:
         """Export a specific output image as numpy array and store it in object exports_dic.
 
@@ -162,15 +158,13 @@ class OTBObject(ABC):
         if key not in self.exports_dic:
             self.exports_dic[key] = self.app.ExportImage(key)
         if preserve_dtype:
-            self.exports_dic[key]["array"] = self.exports_dic[key][
-                "array"].astype(
+            self.exports_dic[key]["array"] = self.exports_dic[key]["array"].astype(
                 self.dtype
             )
         return self.exports_dic[key]
 
     def to_numpy(
-            self, key: str = None, preserve_dtype: bool = True,
-            copy: bool = False
+        self, key: str = None, preserve_dtype: bool = True, copy: bool = False
     ) -> np.ndarray:
         """Export a pyotb object to numpy array.
 
@@ -199,8 +193,7 @@ class OTBObject(ABC):
         profile = {}
         array = self.to_numpy(preserve_dtype=True, copy=False)
         proj = self.app.GetImageProjection(self.output_image_key)
-        profile.update(
-            {"crs": proj, "dtype": array.dtype, "transform": self.transform})
+        profile.update({"crs": proj, "dtype": array.dtype, "transform": self.transform})
         height, width, count = array.shape
         profile.update({"count": count, "height": height, "width": width})
         return np.moveaxis(array, 2, 0), profile
@@ -301,8 +294,7 @@ class OTBObject(ABC):
         """Logical or."""
         return self.__create_operator(LogicalOperation, "||", self, other)
 
-    def __and__(self,
-                other: OTBObject | str | int | float) -> LogicalOperation:
+    def __and__(self, other: OTBObject | str | int | float) -> LogicalOperation:
         """Logical and."""
         return self.__create_operator(LogicalOperation, "&&", self, other)
 
@@ -398,7 +390,7 @@ class OTBObject(ABC):
                 return self.get_values_at_coords(row, col, channels)
         # Slicing
         if not isinstance(key, tuple) or (
-                isinstance(key, tuple) and (len(key) < 2 or len(key) > 3)
+            isinstance(key, tuple) and (len(key) < 2 or len(key) > 3)
         ):
             raise ValueError(
                 f'"{key}" cannot be interpreted as valid slicing. Slicing should be 2D or 3D.'
@@ -454,13 +446,13 @@ class App(OTBObject):
     ]
 
     def __init__(
-            self,
-            appname: str,
-            *args,
-            frozen: bool = False,
-            quiet: bool = False,
-            name: str = "",
-            **kwargs,
+        self,
+        appname: str,
+        *args,
+        frozen: bool = False,
+        quiet: bool = False,
+        name: str = "",
+        **kwargs,
     ):
         """Common constructor for OTB applications. Handles in-memory connection between apps.
 
@@ -510,9 +502,8 @@ class App(OTBObject):
             self.set_parameters(*args, **kwargs)
         # Create Output image objects
         for key in filter(
-                lambda k: self._out_param_types[
-                              k] == otb.ParameterType_OutputImage,
-                self._out_param_types,
+            lambda k: self._out_param_types[k] == otb.ParameterType_OutputImage,
+            self._out_param_types,
         ):
             self.outputs[key] = Output(self, key, self._settings.get(key))
         if not self.frozen:
@@ -533,8 +524,7 @@ class App(OTBObject):
     @property
     def parameters(self):
         """Return used OTB application parameters."""
-        return {**self._auto_parameters, **self.app.GetParameters(),
-                **self._settings}
+        return {**self._auto_parameters, **self.app.GetParameters(), **self._settings}
 
     @property
     def exports_dic(self) -> dict[str, dict]:
@@ -544,8 +534,7 @@ class App(OTBObject):
     def __is_one_of_types(self, key: str, param_types: list[int]) -> bool:
         """Helper to factor is_input and is_output."""
         if key not in self._all_param_types:
-            raise KeyError(
-                f"key {key} not found in the application parameters types")
+            raise KeyError(f"key {key} not found in the application parameters types")
         return self._all_param_types[key] in param_types
 
     def is_input(self, key: str) -> bool:
@@ -558,8 +547,7 @@ class App(OTBObject):
             True if the parameter is an input, else False
 
         """
-        return self.__is_one_of_types(key=key,
-                                      param_types=self.INPUT_PARAM_TYPES)
+        return self.__is_one_of_types(key=key, param_types=self.INPUT_PARAM_TYPES)
 
     def is_output(self, key: str) -> bool:
         """Returns True if the key is an output.
@@ -571,8 +559,7 @@ class App(OTBObject):
             True if the parameter is an output, else False
 
         """
-        return self.__is_one_of_types(key=key,
-                                      param_types=self.OUTPUT_PARAM_TYPES)
+        return self.__is_one_of_types(key=key, param_types=self.OUTPUT_PARAM_TYPES)
 
     def is_key_list(self, key: str) -> bool:
         """Check if a parameter key is an input parameter list."""
@@ -696,8 +683,7 @@ class App(OTBObject):
                 dtype = get_pixel_type(param)
             except (TypeError, RuntimeError):
                 logger.warning(
-                    '%s: unable to identify pixel type of key "%s"', self.name,
-                    param
+                    '%s: unable to identify pixel type of key "%s"', self.name, param
                 )
                 return
         if target_key:
@@ -713,8 +699,7 @@ class App(OTBObject):
 
     def execute(self):
         """Execute and write to disk if any output parameter has been set during init."""
-        logger.debug("%s: run execute() with parameters=%s", self.name,
-                     self.parameters)
+        logger.debug("%s: run execute() with parameters=%s", self.name, self.parameters)
         self._time_start = perf_counter()
         try:
             self.app.Execute()
@@ -742,36 +727,28 @@ class App(OTBObject):
         self._time_end = perf_counter()
 
     def write(
-            self,
-            path: str | Path | dict[str, str] = None,
-            pixel_type: dict[str, str] | str = None,
-            preserve_dtype: bool = False,
-            ext_fname: str | dict[str, str] | dict[str, int] | dict[
-                str, float] = None,
-            **kwargs,
+        self,
+        path: str | Path | dict[str, str] = None,
+        pixel_type: dict[str, str] | str = None,
+        preserve_dtype: bool = False,
+        ext_fname: str = "",
+        **kwargs,
     ) -> bool:
         """Set output pixel type and write the output raster files.
 
         Args:
-            path: Can be :
-                - filepath, useful when there is only one output, e.g.
-                'output.tif'
-                - dictionary containing key-arguments enumeration. Useful when
-                a key contains non-standard characters such as a point, e.g.
-                {'io.out':'output.tif'}
-               - None if output file was passed during App init
-            ext_fname: Optional, an extended filename as understood by OTB
-                (e.g. "&gdal:co:TILED=YES") or a dict of key/value. Will be
-                used for all outputs.
-            pixel_type: Can be :
-                - dictionary {out_param_key: pixeltype} when specifying for
-                several outputs
-                - str (e.g. 'uint16') or otbApplication.ImagePixelType_...
-                When there are several outputs, all outputs are written with
-                this unique type. Valid pixel types are uint8, uint16, uint32,
-                int16, int32, float, double, cint16, cint32, cfloat, cdouble.
-            preserve_dtype: propagate main input pixel type to outputs, in
-                case pixel_type is None
+            path: Can be : - filepath, useful when there is only one output, e.g. 'output.tif'
+                           - dictionary containing key-arguments enumeration. Useful when a key contains
+                             non-standard characters such as a point, e.g. {'io.out':'output.tif'}
+                           - None if output file was passed during App init
+            ext_fname: Optional, an extended filename as understood by OTB (e.g. "&gdal:co:TILED=YES")
+                                Will be used for all outputs (Default value = "")
+            pixel_type: Can be : - dictionary {out_param_key: pixeltype} when specifying for several outputs
+                                 - str (e.g. 'uint16') or otbApplication.ImagePixelType_... When there are several
+                                   outputs, all outputs are written with this unique type.
+                                   Valid pixel types are uint8, uint16, uint32, int16, int32, float, double,
+                                   cint16, cint32, cfloat, cdouble. (Default value = None)
+            preserve_dtype: propagate main input pixel type to outputs, in case pixel_type is None
             **kwargs: keyword arguments e.g. out='output.tif'
 
         Returns:
@@ -790,10 +767,8 @@ class App(OTBObject):
         elif isinstance(path, (str, Path)) and self.output_key:
             kwargs.update({self.output_key: str(path)})
         elif path is not None:
-            raise TypeError(
-                f"{self.name}: unsupported filepath type ({type(path)})")
-        if not (kwargs or any(
-                k in self._settings for k in self._out_param_types)):
+            raise TypeError(f"{self.name}: unsupported filepath type ({type(path)})")
+        if not (kwargs or any(k in self._settings for k in self._out_param_types)):
             raise KeyError(
                 f"{self.name}: at least one filepath is required, if not provided during App init"
             )
@@ -837,17 +812,14 @@ class App(OTBObject):
                 dtype = parse_pixel_type(pixel_type)
                 type_name = self.app.ConvertPixelTypeToNumpy(dtype)
                 logger.debug(
-                    '%s: output(s) will be written with type "%s"', self.name,
-                    type_name
+                    '%s: output(s) will be written with type "%s"', self.name, type_name
                 )
                 for key in parameters:
-                    if self._out_param_types[
-                        key] == otb.ParameterType_OutputImage:
+                    if self._out_param_types[key] == otb.ParameterType_OutputImage:
                         data_types[key] = dtype
             elif isinstance(pixel_type, dict):
                 data_types = {
-                    key: parse_pixel_type(dtype) for key, dtype in
-                    pixel_type.items()
+                    key: parse_pixel_type(dtype) for key, dtype in pixel_type.items()
                 }
         elif preserve_dtype:
             self.propagate_dtype()  # all outputs will have the same type as the main input raster
@@ -880,8 +852,7 @@ class App(OTBObject):
         return bool(files) and not missing
 
     # Private functions
-    def __parse_args(self, args: list[str | OTBObject | dict | list]) -> dict[
-        str, Any]:
+    def __parse_args(self, args: list[str | OTBObject | dict | list]) -> dict[str, Any]:
         """Gather all input arguments in kwargs dict.
 
         Args:
@@ -896,16 +867,15 @@ class App(OTBObject):
             if isinstance(arg, dict):
                 kwargs.update(arg)
             elif (
-                    isinstance(arg, (str, OTBObject))
-                    or isinstance(arg, list)
-                    and self.is_key_list(self.input_key)
+                isinstance(arg, (str, OTBObject))
+                or isinstance(arg, list)
+                and self.is_key_list(self.input_key)
             ):
                 kwargs.update({self.input_key: arg})
         return kwargs
 
     def __set_param(
-            self, key: str,
-            obj: list | tuple | OTBObject | otb.Application | list[Any]
+        self, key: str, obj: list | tuple | OTBObject | otb.Application | list[Any]
     ):
         """Set one parameter, decide which otb.Application method to use depending on target object."""
         if obj is None or (isinstance(obj, (list, tuple)) and not obj):
@@ -915,11 +885,11 @@ class App(OTBObject):
         if isinstance(obj, OTBObject):
             self.app.ConnectImage(key, obj.app, obj.output_image_key)
         elif isinstance(
-                obj, otb.Application
+            obj, otb.Application
         ):  # this is for backward comp with plain OTB
             self.app.ConnectImage(key, obj, get_out_images_param_keys(obj)[0])
         elif (
-                key == "ram"
+            key == "ram"
         ):  # SetParameterValue in OTB<7.4 doesn't work for ram parameter cf gitlab OTB issue 2200
             self.app.SetParameterInt("ram", int(obj))
         elif not isinstance(obj, list):  # any other parameters (str, int...)
@@ -931,10 +901,9 @@ class App(OTBObject):
                 if isinstance(inp, OTBObject):
                     self.app.ConnectImage(key, inp.app, inp.output_image_key)
                 elif isinstance(
-                        inp, otb.Application
+                    inp, otb.Application
                 ):  # this is for backward comp with plain OTB
-                    self.app.ConnectImage(key, obj,
-                                          get_out_images_param_keys(inp)[0])
+                    self.app.ConnectImage(key, obj, get_out_images_param_keys(inp)[0])
                 else:  # here `input` should be an image filepath
                     # Append `input` to the list, do not overwrite any previously set element of the image list
                     self.app.AddParameterStringList(key, inp)
@@ -949,9 +918,8 @@ class App(OTBObject):
                 continue
             value = self.app.GetParameterValue(key)
             # TODO: here we *should* use self.app.IsParameterEnabled, but it seems broken
-            if isinstance(value,
-                          otb.ApplicationProxy) and self.app.HasAutomaticValue(
-                    key
+            if isinstance(value, otb.ApplicationProxy) and self.app.HasAutomaticValue(
+                key
             ):
                 try:
                     value = str(
@@ -962,8 +930,7 @@ class App(OTBObject):
                 except RuntimeError:
                     continue  # grouped parameters
             # Save static output data (ReadImageInfo, ComputeImageStatistics, etc.)
-            elif self.app.GetParameterRole(key) == 1 and bool(
-                    value) or value == 0:
+            elif self.app.GetParameterRole(key) == 1 and bool(value) or value == 0:
                 if isinstance(value, str):
                     try:
                         value = literal_eval(value)
@@ -972,8 +939,7 @@ class App(OTBObject):
                 self.data[key] = value
 
     # Special functions
-    def __getitem__(self, key: str) -> Any | list[
-        int | float] | int | float | Slicer:
+    def __getitem__(self, key: str) -> Any | list[int | float] | int | float | Slicer:
         """This function is called when we use App()[...].
 
         We allow to return attr if key is a parameter, or call OTBObject __getitem__ for pixel values or Slicer
@@ -987,8 +953,7 @@ class App(OTBObject):
                 return self.outputs[key]
             if key in self.parameters:
                 return self.parameters[key]
-            raise KeyError(
-                f"{self.name}: unknown or undefined parameter '{key}'")
+            raise KeyError(f"{self.name}: unknown or undefined parameter '{key}'")
         raise TypeError(
             f"{self.name}: cannot access object item or slice using {type(key)} object"
         )
@@ -998,11 +963,11 @@ class Slicer(App):
     """Slicer objects i.e. when we call something like raster[:, :, 2] from Python."""
 
     def __init__(
-            self,
-            obj: OTBObject,
-            rows: slice,
-            cols: slice,
-            channels: slice | list[int] | int,
+        self,
+        obj: OTBObject,
+        rows: slice,
+        cols: slice,
+        channels: slice | list[int] | int,
     ):
         """Create a slicer object, that can be used directly for writing or inside a BandMath.
 
@@ -1069,10 +1034,9 @@ class Slicer(App):
             )  # subtract 1 to respect python convention
             spatial_slicing = True
         # These are some attributes when the user simply wants to extract *one* band to be used in an Operation
-        if not spatial_slicing and isinstance(channels, list) and len(
-                channels) == 1:
+        if not spatial_slicing and isinstance(channels, list) and len(channels) == 1:
             self.one_band_sliced = (
-                    channels[0] + 1
+                channels[0] + 1
             )  # OTB convention: channels start at 1
             self.input = obj
 
@@ -1103,8 +1067,7 @@ class Operation(App):
 
     """
 
-    def __init__(self, operator: str, *inputs, nb_bands: int = None,
-                 name: str = None):
+    def __init__(self, operator: str, *inputs, nb_bands: int = None, name: str = None):
         """Given some inputs and an operator, this function enables to transform this into an OTB application.
 
         Operations generally involve 2 inputs (+, -...). It can have only 1 input for `abs` operator.
@@ -1151,10 +1114,10 @@ class Operation(App):
         )
 
     def build_fake_expressions(
-            self,
-            operator: str,
-            inputs: list[OTBObject | str | int | float],
-            nb_bands: int = None,
+        self,
+        operator: str,
+        inputs: list[OTBObject | str | int | float],
+        nb_bands: int = None,
     ):
         """Create a list of 'fake' expressions, one for each band.
 
@@ -1175,8 +1138,8 @@ class Operation(App):
         # For any other operations, the output number of bands is the same as inputs
         else:
             if any(
-                    isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced")
-                    for inp in inputs
+                isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced")
+                for inp in inputs
             ):
                 nb_bands = 1
             else:
@@ -1187,10 +1150,9 @@ class Operation(App):
                 ]
                 # check that all inputs have the same nb of bands
                 if len(nb_bands_list) > 1 and not all(
-                        x == nb_bands_list[0] for x in nb_bands_list
+                    x == nb_bands_list[0] for x in nb_bands_list
                 ):
-                    raise ValueError(
-                        "All images do not have the same number of bands")
+                    raise ValueError("All images do not have the same number of bands")
                 nb_bands = nb_bands_list[0]
 
         # Create a list of fake expressions, each item of the list corresponding to one band
@@ -1225,7 +1187,7 @@ class Operation(App):
                 # the false expression stores the expression 2 * str(input1) + str(input2)
                 fake_exp = f"({expressions[0]} {operator} {expressions[1]})"
             elif (
-                    len(inputs) == 3 and operator == "?"
+                len(inputs) == 3 and operator == "?"
             ):  # this is only for ternary expression
                 fake_exp = f"({expressions[0]} ? {expressions[1]} : {expressions[2]})"
             self.fake_exp_bands.append(fake_exp)
@@ -1247,15 +1209,14 @@ class Operation(App):
             one_band_exp = one_band_fake_exp
             for inp in self.inputs:
                 # Replace the name of in-memory object (e.g. '<pyotb.App object>b1' by 'im1b1')
-                one_band_exp = one_band_exp.replace(repr(inp),
-                                                    self.im_dic[repr(inp)])
+                one_band_exp = one_band_exp.replace(repr(inp), self.im_dic[repr(inp)])
             exp_bands.append(one_band_exp)
         # Form the final expression (e.g. 'im1b1 + 1; im1b2 + 1')
         return exp_bands, ";".join(exp_bands)
 
     @staticmethod
     def make_fake_exp(
-            x: OTBObject | str, band: int, keep_logical: bool = False
+        x: OTBObject | str, band: int, keep_logical: bool = False
     ) -> tuple[str, list[OTBObject], int]:
         """This an internal function, only to be used by `build_fake_expressions`.
 
@@ -1278,8 +1239,7 @@ class Operation(App):
         # Special case for one-band slicer
         if isinstance(x, Slicer) and hasattr(x, "one_band_sliced"):
             if keep_logical and isinstance(x.input, LogicalOperation):
-                fake_exp = x.input.logical_fake_exp_bands[
-                    x.one_band_sliced - 1]
+                fake_exp = x.input.logical_fake_exp_bands[x.one_band_sliced - 1]
                 inputs, nb_channels = x.input.inputs, x.input.nb_channels
             elif isinstance(x.input, Operation):
                 # Keep only one band of the expression
@@ -1331,17 +1291,16 @@ class LogicalOperation(Operation):
 
         """
         self.logical_fake_exp_bands = []
-        super().__init__(operator, *inputs, nb_bands=nb_bands,
-                         name="LogicalOperation")
+        super().__init__(operator, *inputs, nb_bands=nb_bands, name="LogicalOperation")
         self.logical_exp_bands, self.logical_exp = self.get_real_exp(
             self.logical_fake_exp_bands
         )
 
     def build_fake_expressions(
-            self,
-            operator: str,
-            inputs: list[OTBObject | str | int | float],
-            nb_bands: int = None,
+        self,
+        operator: str,
+        inputs: list[OTBObject | str | int | float],
+        nb_bands: int = None,
     ):
         """Create a list of 'fake' expressions, one for each band.
 
@@ -1356,8 +1315,8 @@ class LogicalOperation(Operation):
         """
         # For any other operations, the output number of bands is the same as inputs
         if any(
-                isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced")
-                for inp in inputs
+            isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced")
+            for inp in inputs
         ):
             nb_bands = 1
         else:
@@ -1368,10 +1327,9 @@ class LogicalOperation(Operation):
             ]
             # check that all inputs have the same nb of bands
             if len(nb_bands_list) > 1 and not all(
-                    x == nb_bands_list[0] for x in nb_bands_list
+                x == nb_bands_list[0] for x in nb_bands_list
             ):
-                raise ValueError(
-                    "All images do not have the same number of bands")
+                raise ValueError("All images do not have the same number of bands")
             nb_bands = nb_bands_list[0]
         # Create a list of fake exp, each item of the list corresponding to one band
         for i, band in enumerate(range(1, nb_bands + 1)):
@@ -1424,11 +1382,11 @@ class Output(OTBObject):
     _filepath: str | Path = None
 
     def __init__(
-            self,
-            pyotb_app: App,
-            param_key: str = None,
-            filepath: str = None,
-            mkdir: bool = True,
+        self,
+        pyotb_app: App,
+        param_key: str = None,
+        filepath: str = None,
+        mkdir: bool = True,
     ):
         """Constructor for an Output object.
 
@@ -1475,8 +1433,7 @@ class Output(OTBObject):
     @filepath.setter
     def filepath(self, path: str):
         if isinstance(path, str):
-            if path and not path.startswith(
-                    ("/vsi", "http://", "https://", "ftp://")):
+            if path and not path.startswith(("/vsi", "http://", "https://", "ftp://")):
                 path = Path(path.split("?")[0])
             self._filepath = path
 
@@ -1498,8 +1455,7 @@ class Output(OTBObject):
             return self.parent_pyotb_app.write(
                 {self.output_image_key: self.filepath}, **kwargs
             )
-        return self.parent_pyotb_app.write({self.output_image_key: filepath},
-                                           **kwargs)
+        return self.parent_pyotb_app.write({self.output_image_key: filepath}, **kwargs)
 
     def __str__(self) -> str:
         """Return string representation of Output filepath."""
@@ -1556,13 +1512,12 @@ def get_nbchannels(inp: str | Path | OTBObject) -> int:
             info = App("ReadImageInfo", inp, quiet=True)
             return info["numberbands"]
         except (
-                RuntimeError
+            RuntimeError
         ) as info_err:  # this happens when we pass a str that is not a filepath
             raise TypeError(
                 f"Could not get the number of channels file '{inp}' ({info_err})"
             ) from info_err
-    raise TypeError(
-        f"Can't read number of channels of type '{type(inp)}' object {inp}")
+    raise TypeError(f"Can't read number of channels of type '{type(inp)}' object {inp}")
 
 
 def get_pixel_type(inp: str | Path | OTBObject) -> str:
@@ -1583,15 +1538,14 @@ def get_pixel_type(inp: str | Path | OTBObject) -> str:
             info = App("ReadImageInfo", inp, quiet=True)
             datatype = info["datatype"]  # which is such as short, float...
         except (
-                RuntimeError
+            RuntimeError
         ) as info_err:  # this happens when we pass a str that is not a filepath
             raise TypeError(
                 f"Could not get the pixel type of `{inp}` ({info_err})"
             ) from info_err
         if datatype:
             return parse_pixel_type(datatype)
-    raise TypeError(
-        f"Could not get the pixel type of {type(inp)} object {inp}")
+    raise TypeError(f"Could not get the pixel type of {type(inp)} object {inp}")
 
 
 def parse_pixel_type(pixel_type: str | int) -> int:
@@ -1621,8 +1575,7 @@ def parse_pixel_type(pixel_type: str | int) -> int:
         if pixel_type in datatype_to_pixeltype.values():
             return getattr(otb, f"ImagePixelType_{pixel_type}")
         if pixel_type in datatype_to_pixeltype:
-            return getattr(otb,
-                           f"ImagePixelType_{datatype_to_pixeltype[pixel_type]}")
+            return getattr(otb, f"ImagePixelType_{datatype_to_pixeltype[pixel_type]}")
         raise KeyError(
             f"Unknown data type `{pixel_type}`. Available ones: {datatype_to_pixeltype}"
         )
@@ -1641,9 +1594,9 @@ def get_out_images_param_keys(app: OTBObject) -> list[str]:
 
 
 def summarize(
-        obj: App | Output | Any,
-        strip_input_paths: bool = False,
-        strip_output_paths: bool = False,
+    obj: App | Output | Any,
+    strip_input_paths: bool = False,
+    strip_output_paths: bool = False,
 ) -> dict[str, str | dict[str, Any]]:
     """Recursively summarize parameters of an App or Output object and its parents.
 
@@ -1677,10 +1630,10 @@ def summarize(
     parameters = {}
     for key, param in obj.parameters.items():
         if (
-                strip_input_paths
-                and obj.is_input(key)
-                or strip_output_paths
-                and obj.is_output(key)
+            strip_input_paths
+            and obj.is_input(key)
+            or strip_output_paths
+            and obj.is_output(key)
         ):
             parameters[key] = (
                 [strip_path(p) for p in param]
-- 
GitLab


From aa2ae0245b482d3683a9df9080b8d1d804be31af Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 10:36:44 +0200
Subject: [PATCH 07/23] FIX: ext_fname prevail over extensions in filepath

---
 pyotb/core.py | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index b776a3d..52b3ffc 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -795,13 +795,14 @@ class App(OTBObject):
             for key, filepath in kwargs.items():
                 if self._out_param_types[key] == otb.ParameterType_OutputImage:
                     # grab already set extended filename key/values
-                    already_set_ext = _str2dict(filepath.split("?&")[0]) \
-                        if "?&" in filepath else {}
+                    if "?&" in filepath:
+                        filepath, already_set_ext = filepath.split("?&", 1)
+                        # ext_fname prevail over extensions in filepath
+                        ext_fname = {**_str2dict(already_set_ext), **ext_fname}
 
                     # transform dict to str
                     ext_fname_str = "&".join([
-                        f"{k}={v}" for k, v in ext_fname.items()
-                        if k not in already_set_ext
+                        f"{key}={value}" for key, value in ext_fname.items()
                     ])
                     parameters[key] = f"{filepath}?&{ext_fname_str}"
 
-- 
GitLab


From 2b526505f832959a0c75c6b0d1cc3dd74fb1446c Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 10:50:08 +0200
Subject: [PATCH 08/23] TEST: strenghten ext_fname tests

---
 tests/test_core.py | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/tests/test_core.py b/tests/test_core.py
index b19e92b..8d2c1e2 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -111,9 +111,17 @@ def test_write():
 
 
 def test_ext_fname():
+    def _check(expected: str):
+        fn = INPUT.app.GetParameterString("out")
+        assert "?&" in fn
+        assert fn.split("?&", 1) == expected
+
     assert INPUT.write("/tmp/test_write.tif", ext_fname="nodata=0")
+    _check("nodata=0")
     assert INPUT.write("/tmp/test_write.tif", ext_fname={"nodata": "0"})
+    _check("nodata=0")
     assert INPUT.write("/tmp/test_write.tif", ext_fname={"nodata": 0})
+    _check("nodata=0")
     assert INPUT.write(
         "/tmp/test_write.tif",
         ext_fname={
@@ -121,10 +129,28 @@ def test_ext_fname():
             "gdal:co:COMPRESS": "DEFLATE"
         }
     )
+    _check("nodata=0&gdal:co:COMPRESS=DEFLATE")
     assert INPUT.write(
         "/tmp/test_write.tif",
         ext_fname="nodata=0&gdal:co:COMPRESS=DEFLATE"
     )
+    _check("nodata=0&gdal:co:COMPRESS=DEFLATE")
+    assert INPUT.write(
+        "/tmp/test_write.tif?&box=0:0:10:10",
+        ext_fname={
+            "nodata": "0",
+            "gdal:co:COMPRESS": "DEFLATE",
+            "box": "0:0:20:20"
+        }
+    )
+    _check("box=0:0:20:20&nodata=0&gdal:co:COMPRESS=DEFLATE")
+    _check("nodata=0&gdal:co:COMPRESS=DEFLATE")
+    assert INPUT.write(
+        "/tmp/test_write.tif?&box=0:0:10:10",
+        ext_fname="nodata=0&gdal:co:COMPRESS=DEFLATE&box=0:0:20:20"
+    )
+    _check("box=0:0:20:20&nodata=0&gdal:co:COMPRESS=DEFLATE")
+
     INPUT["out"].filepath.unlink()
 
 
-- 
GitLab


From 6af96f3d38197a72077c026ed6b815bac8e2f49a Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 11:00:24 +0200
Subject: [PATCH 09/23] TEST: strenghten ext_fname tests

---
 tests/test_core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_core.py b/tests/test_core.py
index 8d2c1e2..ea18808 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -114,7 +114,7 @@ def test_ext_fname():
     def _check(expected: str):
         fn = INPUT.app.GetParameterString("out")
         assert "?&" in fn
-        assert fn.split("?&", 1) == expected
+        assert fn.split("?&", 1)[1] == expected
 
     assert INPUT.write("/tmp/test_write.tif", ext_fname="nodata=0")
     _check("nodata=0")
-- 
GitLab


From 38f320dcbbd5d1dbb80e638cd65a811607b3e502 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20Cresson?= <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:06:56 +0000
Subject: [PATCH 10/23] Apply 1 suggestion(s) to 1 file(s)

---
 pyotb/core.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index 52b3ffc..bddd15c 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -776,7 +776,8 @@ class App(OTBObject):
 
         # Append filename extension to filenames
         if ext_fname:
-            assert isinstance(ext_fname, (dict, str))
+            if not isinstance(ext_fname, (dict, str)):
+                raise ValueError("Extended filename must be a str or a dict")
             def _str2dict(ext_str):
                 """Function that converts str to dict."""
                 return dict(
-- 
GitLab


From 0fc010c6c3a93dcdadb61f065faded705282b007 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20Cresson?= <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:11:42 +0000
Subject: [PATCH 11/23] Apply 1 suggestion(s) to 1 file(s)

---
 pyotb/core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index bddd15c..94faa6b 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -799,7 +799,7 @@ class App(OTBObject):
                     if "?&" in filepath:
                         filepath, already_set_ext = filepath.split("?&", 1)
                         # ext_fname prevail over extensions in filepath
-                        ext_fname = {**_str2dict(already_set_ext), **ext_fname}
+                        ext_fname.update(_str2dict(already_set_ext))
 
                     # transform dict to str
                     ext_fname_str = "&".join([
-- 
GitLab


From d1cdccfd05853ec8ab3603a179b0624ddccc69f6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20Cresson?= <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:12:30 +0000
Subject: [PATCH 12/23] Apply 2 suggestion(s) to 1 file(s)

---
 tests/test_core.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/test_core.py b/tests/test_core.py
index ea18808..1a628c2 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -143,13 +143,13 @@ def test_ext_fname():
             "box": "0:0:20:20"
         }
     )
-    _check("box=0:0:20:20&nodata=0&gdal:co:COMPRESS=DEFLATE")
+    _check("box=0:0:10:10&nodata=0&gdal:co:COMPRESS=DEFLATE")
     _check("nodata=0&gdal:co:COMPRESS=DEFLATE")
     assert INPUT.write(
         "/tmp/test_write.tif?&box=0:0:10:10",
         ext_fname="nodata=0&gdal:co:COMPRESS=DEFLATE&box=0:0:20:20"
     )
-    _check("box=0:0:20:20&nodata=0&gdal:co:COMPRESS=DEFLATE")
+    _check("box=0:0:10:10&nodata=0&gdal:co:COMPRESS=DEFLATE")
 
     INPUT["out"].filepath.unlink()
 
-- 
GitLab


From b80fd7e06c58f1bae1a11b4d11dacb6f5a18a683 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20Cresson?= <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 09:12:48 +0000
Subject: [PATCH 13/23] Apply 1 suggestion(s) to 1 file(s)

---
 pyotb/core.py | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index 94faa6b..e9319a1 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -780,10 +780,8 @@ class App(OTBObject):
                 raise ValueError("Extended filename must be a str or a dict")
             def _str2dict(ext_str):
                 """Function that converts str to dict."""
-                return dict(
-                    keyval for pair in ext_str.split("&")
-                    if len(keyval := pair.split("=")) == 2
-                )
+                splits = [pair.split("=") for pair in ext_str.split("&")]
+                return dict(split for split in splits if len(split) == 2)
 
             if isinstance(ext_fname, str):
                 # transform str to dict
-- 
GitLab


From 5bf540388b3f9fe42e16dd8e9462b13006b390a6 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 14:16:24 +0200
Subject: [PATCH 14/23] ADD: fix ext_fname tests

---
 tests/test_core.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/tests/test_core.py b/tests/test_core.py
index 1a628c2..824195a 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -143,13 +143,14 @@ def test_ext_fname():
             "box": "0:0:20:20"
         }
     )
-    _check("box=0:0:10:10&nodata=0&gdal:co:COMPRESS=DEFLATE")
-    _check("nodata=0&gdal:co:COMPRESS=DEFLATE")
+    # Check that the bbox is the one specified in the filepath, not the one
+    # specified in `ext_filename`
+    _check("nodata=0&gdal:co:COMPRESS=DEFLATE&box=0:0:10:10")
     assert INPUT.write(
         "/tmp/test_write.tif?&box=0:0:10:10",
         ext_fname="nodata=0&gdal:co:COMPRESS=DEFLATE&box=0:0:20:20"
     )
-    _check("box=0:0:10:10&nodata=0&gdal:co:COMPRESS=DEFLATE")
+    _check("nodata=0&gdal:co:COMPRESS=DEFLATE&box=0:0:10:10")
 
     INPUT["out"].filepath.unlink()
 
-- 
GitLab


From 5b702ca36a5f4c8fad964269ec4b674025e40ad1 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 14:42:54 +0200
Subject: [PATCH 15/23] FIX: copy ext_fname when it's a dict

---
 pyotb/core.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index e9319a1..b717acc 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -783,12 +783,11 @@ class App(OTBObject):
                 splits = [pair.split("=") for pair in ext_str.split("&")]
                 return dict(split for split in splits if len(split) == 2)
 
-            if isinstance(ext_fname, str):
-                # transform str to dict
-                ext_fname = _str2dict(ext_fname)
+            new_ext_fname = _str2dict(ext_fname) \
+                if isinstance(ext_fname, str) else ext_fname.copy()
 
             logger.debug("%s: extended filename for outputs:", self.name)
-            for key, ext in ext_fname.items():
+            for key, ext in new_ext_fname.items():
                 logger.debug("%s: %s", key, ext)
 
             for key, filepath in kwargs.items():
@@ -797,11 +796,12 @@ class App(OTBObject):
                     if "?&" in filepath:
                         filepath, already_set_ext = filepath.split("?&", 1)
                         # ext_fname prevail over extensions in filepath
-                        ext_fname.update(_str2dict(already_set_ext))
+                        new_ext_fname.update(_str2dict(already_set_ext))
 
                     # transform dict to str
                     ext_fname_str = "&".join([
-                        f"{key}={value}" for key, value in ext_fname.items()
+                        f"{key}={value}"
+                        for key, value in new_ext_fname.items()
                     ])
                     parameters[key] = f"{filepath}?&{ext_fname_str}"
 
-- 
GitLab


From b0122b170ef162b7dfccb0e04d63853582ebc973 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 14:55:23 +0200
Subject: [PATCH 16/23] FIX: copy ext_fname when it's a dict

---
 pyotb/core.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index b717acc..7e13e91 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -783,11 +783,11 @@ class App(OTBObject):
                 splits = [pair.split("=") for pair in ext_str.split("&")]
                 return dict(split for split in splits if len(split) == 2)
 
-            new_ext_fname = _str2dict(ext_fname) \
+            gen_ext_fname = _str2dict(ext_fname) \
                 if isinstance(ext_fname, str) else ext_fname.copy()
 
-            logger.debug("%s: extended filename for outputs:", self.name)
-            for key, ext in new_ext_fname.items():
+            logger.debug("%s: extended filename for all outputs:", self.name)
+            for key, ext in gen_ext_fname.items():
                 logger.debug("%s: %s", key, ext)
 
             for key, filepath in kwargs.items():
@@ -796,6 +796,7 @@ class App(OTBObject):
                     if "?&" in filepath:
                         filepath, already_set_ext = filepath.split("?&", 1)
                         # ext_fname prevail over extensions in filepath
+                        new_ext_fname = gen_ext_fname.copy()
                         new_ext_fname.update(_str2dict(already_set_ext))
 
                     # transform dict to str
-- 
GitLab


From d953d9578efca316987930430d1580d870c8c055 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 14:57:50 +0200
Subject: [PATCH 17/23] FIX: copy ext_fname when it's a dict

---
 pyotb/core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index 7e13e91..a0f4a6f 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -784,7 +784,7 @@ class App(OTBObject):
                 return dict(split for split in splits if len(split) == 2)
 
             gen_ext_fname = _str2dict(ext_fname) \
-                if isinstance(ext_fname, str) else ext_fname.copy()
+                if isinstance(ext_fname, str) else ext_fname
 
             logger.debug("%s: extended filename for all outputs:", self.name)
             for key, ext in gen_ext_fname.items():
-- 
GitLab


From e6f14025dca52271b3e8956e583152dd83a8f69e Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 14:58:28 +0200
Subject: [PATCH 18/23] FIX: copy ext_fname when it's a dict

---
 pyotb/core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index a0f4a6f..206a10f 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -795,7 +795,7 @@ class App(OTBObject):
                     # grab already set extended filename key/values
                     if "?&" in filepath:
                         filepath, already_set_ext = filepath.split("?&", 1)
-                        # ext_fname prevail over extensions in filepath
+                        # extensions in filepath prevail over `new_ext_fname`
                         new_ext_fname = gen_ext_fname.copy()
                         new_ext_fname.update(_str2dict(already_set_ext))
 
-- 
GitLab


From af0b763c21f4e508e0b882d8bc6da6a8ba63f9b4 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 15:00:15 +0200
Subject: [PATCH 19/23] FIX: copy ext_fname when it's a dict

---
 pyotb/core.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index 206a10f..9d8ee47 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -792,11 +792,12 @@ class App(OTBObject):
 
             for key, filepath in kwargs.items():
                 if self._out_param_types[key] == otb.ParameterType_OutputImage:
+                    new_ext_fname = gen_ext_fname.copy()
+
                     # grab already set extended filename key/values
                     if "?&" in filepath:
                         filepath, already_set_ext = filepath.split("?&", 1)
                         # extensions in filepath prevail over `new_ext_fname`
-                        new_ext_fname = gen_ext_fname.copy()
                         new_ext_fname.update(_str2dict(already_set_ext))
 
                     # transform dict to str
-- 
GitLab


From 0c2bc2191b1df0e38eb432ad7f518eff4a8ca223 Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 15:07:53 +0200
Subject: [PATCH 20/23] ENH: ext_fn tests with 2 outputs

---
 tests/test_core.py | 23 +++++++++++++++++++++--
 1 file changed, 21 insertions(+), 2 deletions(-)

diff --git a/tests/test_core.py b/tests/test_core.py
index 824195a..5955e11 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -111,8 +111,8 @@ def test_write():
 
 
 def test_ext_fname():
-    def _check(expected: str):
-        fn = INPUT.app.GetParameterString("out")
+    def _check(expected: str, key: str = "out", app = INPUT.app):
+        fn = app.GetParameterString(key)
         assert "?&" in fn
         assert fn.split("?&", 1)[1] == expected
 
@@ -154,6 +154,25 @@ def test_ext_fname():
 
     INPUT["out"].filepath.unlink()
 
+    mss = pyotb.MeanShiftSmoothing({
+        "in": FILEPATH
+    })
+    mss.write(
+        {
+            "fout": "/tmp/test_ext_fn_fout.tif&?nodata=1",
+            "foutpos": "/tmp/test_ext_fn_foutpos.tif?&nodata=2"
+        },
+        ext_fname={
+            "nodata": 0,
+            "gdal:co:COMPRESS": "DEFLATE"
+        }
+    )
+    _check("nodata=1&gdal:co:COMPRESS=DEFLATE", key="fout", app=mss.app)
+    _check("nodata=2&gdal:co:COMPRESS=DEFLATE", key="foutpos", app=mss.app)
+    mss["fout"].filepath.unlink()
+    mss["foutpos"].filepath.unlink()
+
+
 
 def test_frozen_app_write():
     app = pyotb.BandMath(INPUT, exp="im1b1", frozen=True)
-- 
GitLab


From 1e68f1e0b03b1f1fb5d9592982af8b9f1aeae6ef Mon Sep 17 00:00:00 2001
From: Remi Cresson <remi.cresson@inrae.fr>
Date: Wed, 28 Jun 2023 15:15:36 +0200
Subject: [PATCH 21/23] ENH: ext_fn tests with 2 outputs

---
 tests/test_core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_core.py b/tests/test_core.py
index 5955e11..7ca58dd 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -159,7 +159,7 @@ def test_ext_fname():
     })
     mss.write(
         {
-            "fout": "/tmp/test_ext_fn_fout.tif&?nodata=1",
+            "fout": "/tmp/test_ext_fn_fout.tif?&nodata=1",
             "foutpos": "/tmp/test_ext_fn_foutpos.tif?&nodata=2"
         },
         ext_fname={
-- 
GitLab


From 68e7fdb77febe206af7bfe5d6a6211e98fe95c09 Mon Sep 17 00:00:00 2001
From: Vincent Delbar <vincent.delbar@latelescop.fr>
Date: Wed, 28 Jun 2023 13:26:25 +0000
Subject: [PATCH 22/23] Apply 1 suggestion(s) to 1 file(s)

---
 pyotb/core.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/pyotb/core.py b/pyotb/core.py
index 9d8ee47..58f3f8f 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -783,16 +783,16 @@ class App(OTBObject):
                 splits = [pair.split("=") for pair in ext_str.split("&")]
                 return dict(split for split in splits if len(split) == 2)
 
-            gen_ext_fname = _str2dict(ext_fname) \
-                if isinstance(ext_fname, str) else ext_fname
+            if isinstance(ext_fname, str):
+                ext_fname = _str2dict(ext_fname)
 
             logger.debug("%s: extended filename for all outputs:", self.name)
-            for key, ext in gen_ext_fname.items():
+            for key, ext in ext_fname.items():
                 logger.debug("%s: %s", key, ext)
 
             for key, filepath in kwargs.items():
                 if self._out_param_types[key] == otb.ParameterType_OutputImage:
-                    new_ext_fname = gen_ext_fname.copy()
+                    new_ext_fname = ext_fname.copy()
 
                     # grab already set extended filename key/values
                     if "?&" in filepath:
-- 
GitLab


From 5f837beff8b51d0d7601ce3f6a57f109537f0716 Mon Sep 17 00:00:00 2001
From: Vincent Delbar <vincent.delbar@latelescop.fr>
Date: Wed, 28 Jun 2023 13:28:14 +0000
Subject: [PATCH 23/23] Apply 1 suggestion(s) to 1 file(s)

---
 tests/test_core.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/tests/test_core.py b/tests/test_core.py
index 7ca58dd..c3cedef 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -154,9 +154,7 @@ def test_ext_fname():
 
     INPUT["out"].filepath.unlink()
 
-    mss = pyotb.MeanShiftSmoothing({
-        "in": FILEPATH
-    })
+    mss = pyotb.MeanShiftSmoothing(FILEPATH)
     mss.write(
         {
             "fout": "/tmp/test_ext_fn_fout.tif?&nodata=1",
-- 
GitLab