Repository: DHI/mikeio
Pull request #477 (2022-11-19 23:42:01)

Pfs refactor write
Move internal write method to PfsSection such that it can be used for __repr__. This makes sure that the __repr__ etc look exactly like what is written to file. This will also make it easy to write to a stream instead of a file.  



Patch:
diff --git a/mikeio/pfs.py b/mikeio/pfs.py
index e1e4a28b..2b604c50 100644
--- a/mikeio/pfs.py
+++ b/mikeio/pfs.py
@@ -1,6 +1,6 @@
 from pathlib import Path
 from types import SimpleNamespace
-from typing import List, Tuple
+from typing import List, Tuple, Callable
 from collections import Counter
 from datetime import datetime
 import re
@@ -45,7 +45,8 @@ class PfsSection(SimpleNamespace):
 
     def __repr__(self) -> str:
         # return json.dumps(self.to_dict(), indent=2)
-        return yaml.dump(self.to_dict(), sort_keys=False)
+        # return yaml.dump(self.to_dict(), sort_keys=False)
+        return "\n".join(self._to_txt_lines())
 
     def __len__(self):
         return len(self.__dict__)
@@ -169,6 +170,100 @@ class PfsSection(SimpleNamespace):
                 d[key] = value.to_dict().copy()
         return self.__class__(d)
 
+    def _to_txt_lines(self):
+        lines = []
+        self._write_with_func(lines.append, newline="")
+        return lines
+
+    def _write_with_func(self, func: Callable, level: int = 0, newline: str = "\n"):
+        """Write pfs nested objects
+        
+        Parameters
+        ----------
+        func : Callable
+            A function that performs the writing e.g. to a file
+        level : int, optional
+            Level of indentation (add 3 spaces for each), by default 0
+        newline : str, optional
+            newline string, by default "\n"
+        """
+        lvl_prefix = "   "
+        for k, v in vars(self).items():
+
+            # check for empty sections
+            NoneType = type(None)
+            if isinstance(v, NoneType):
+                func(f"{lvl_prefix * level}[{k}]{newline}")
+                func(f"{lvl_prefix * level}EndSect  // {k}{newline}{newline}")
+
+            elif isinstance(v, List) and any(
+                isinstance(subv, PfsSection) for subv in v
+            ):
+                # duplicate sections
+                for subv in v:
+                    if isinstance(subv, PfsSection):
+                        subsec = PfsSection({k: subv})
+                        subsec._write_with_func(func, level=level, newline=newline)
+                    else:
+                        subv = self._prepare_value_for_write(subv)
+                        func(f"{lvl_prefix * level}{k} = {subv}{newline}")
+            elif isinstance(v, PfsSection):
+                func(f"{lvl_prefix * level}[{k}]{newline}")
+                v._write_with_func(func, level=(level + 1), newline=newline)
+                func(f"{lvl_prefix * level}EndSect  // {k}{newline}{newline}")
+            elif isinstance(v, PfsNonUniqueList) or (
+                isinstance(v, list) and all([isinstance(vv, list) for vv in v])
+            ):
+                if len(v) == 0:
+                    # empty list -> keyword with no parameter
+                    func(f"{lvl_prefix * level}{k} = {newline}")
+                for subv in v:
+                    subv = self._prepare_value_for_write(subv)
+                    func(f"{lvl_prefix * level}{k} = {subv}{newline}")
+            else:
+                v = self._prepare_value_for_write(v)
+                func(f"{lvl_prefix * level}{k} = {v}{newline}")
+
+    def _prepare_value_for_write(self, v):
+        """catch peculiarities of string formatted pfs data
+
+        Parameters
+        ----------
+        v : str 
+            value from one pfs line
+
+        Returns:
+            v: modified value
+        """
+        # some crude checks and corrections
+        if isinstance(v, str):
+
+            if len(v) > 5 and not ("PROJ" in v or "<CLOB:" in v):
+                v = v.replace('"', "''")
+                v = v.replace("\U0001F600", "'")
+
+            if v == "":
+                # add either '' or || as pre- and suffix to strings depending on path definition
+                v = "''"
+            elif v.count("|") == 2:
+                v = f"{v}"
+            else:
+                v = f"'{v}'"
+
+        elif isinstance(v, bool):
+            v = str(v).lower()  # stick to MIKE lowercase bool notation
+
+        elif isinstance(v, datetime):
+            v = v.strftime("%Y, %m, %d, %H, %M, %S").replace(" 0", " ")
+
+        elif isinstance(v, list):
+            out = []
+            for subv in v:
+                out.append(str(self._prepare_value_for_write(subv)))
+            v = ", ".join(out)
+
+        return v
+
     def to_Pfs(self, name: str):
         """Convert to a Pfs object (with this PfsSection as the target)
 
@@ -311,7 +406,7 @@ class Pfs:
     names: List[str], optional
         If the input is dictionary or PfsSection object the
         name of the root element (=target) MUST be specified.
-        If the input is a file the names are instead read from the file,
+        If the input is a file, the names are instead read from the file,
         by default None.
     unique_keywords: bool, optional
         Should the keywords in a section be unique? Some tools e.g. the
@@ -672,16 +767,21 @@ class Pfs:
                 v = self._prepare_value_for_write(v)
                 f.write(f"{lvl_prefix * lvl}{k} = {v}\n")
 
-    def write(self, filename):
+    def write(self, filename=None):
         """Write object to a pfs file
 
         Parameters
         ----------
-        filename: str
+        filename: str, optional
             Full path and filename of pfs to be created.
+            If filename is None, the content will be returned 
+            as a list of strings. 
         """
         from mikeio import __version__ as mikeio_version
 
+        if filename is None:
+            return self._to_txt_lines()            
+
         with open(filename, "w") as f:
             now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
             f.write(f"// Created     : {now}\n")
@@ -692,5 +792,13 @@ class Pfs:
 
             for name, target in zip(self.names, self._targets):
                 f.write(f"[{name}]\n")
-                self._write_nested_PfsSections(f, target, 1)
+                target._write_with_func(f.write, level=1)
                 f.write(f"EndSect  // {name}\n\n")
+
+    def _to_txt_lines(self):
+        lines = []
+        for name, target in zip(self.names, self._targets):
+            lines.append(f"[{name}]")
+            target._write_with_func(lines.append, level=1, newline="")
+            lines.append(f"EndSect  // {name}")
+        return lines
diff --git a/notebooks/PFS.ipynb b/notebooks/PFS.ipynb
index a62094fd..dffec453 100644
--- a/notebooks/PFS.ipynb
+++ b/notebooks/PFS.ipynb
@@ -34,7 +34,7 @@
      "data": {
       "text/plain": [
        "<mikeio.Pfs>\n",
-       "FemEngineSW: DOMAIN:  Touched: 1  discretization: 2  numbe..."
+       "FemEngineSW: [DOMAIN]   Touched = 1   discretization = 2  ..."
       ]
      },
      "execution_count": 2,
@@ -109,18 +109,18 @@
     {
      "data": {
       "text/plain": [
-       "Touched: 0\n",
-       "mode_of_hydrodynamic_module: 0\n",
-       "hydrodynamic_features: 1\n",
-       "fluid_property: 1\n",
-       "mode_of_spectral_wave_module: 2\n",
-       "mode_of_transport_module: 0\n",
-       "mode_of_mud_transport_module: 0\n",
-       "mode_of_eco_lab_module: 0\n",
-       "mode_of_sand_transport_module: 0\n",
-       "mode_of_particle_tracking_module: 0\n",
-       "mode_of_oil_spill_module: 0\n",
-       "mode_of_shoreline_module: 0"
+       "Touched = 0\n",
+       "mode_of_hydrodynamic_module = 0\n",
+       "hydrodynamic_features = 1\n",
+       "fluid_property = 1\n",
+       "mode_of_spectral_wave_module = 2\n",
+       "mode_of_transport_module = 0\n",
+       "mode_of_mud_transport_module = 0\n",
+       "mode_of_eco_lab_module = 0\n",
+       "mode_of_sand_transport_module = 0\n",
+       "mode_of_particle_tracking_module = 0\n",
+       "mode_of_oil_spill_module = 0\n",
+       "mode_of_shoreline_module = 0"
       ]
      },
      "execution_count": 5,
@@ -457,19 +457,19 @@
     {
      "data": {
       "text/plain": [
-       "Touched: 1\n",
-       "type_of_frequency_discretization: 2\n",
-       "number_of_frequencies: 25\n",
-       "minimum_frequency: 0.055\n",
-       "frequency_interval: 0.02\n",
-       "frequency_factor: 1.1\n",
-       "type_of_directional_discretization: 1\n",
-       "number_of_directions: 32\n",
-       "minimum_direction: 0.0\n",
-       "maximum_direction: 0.0\n",
-       "separation_of_wind_sea_and_swell: 0\n",
-       "threshold_frequency: 0.125\n",
-       "maximum_threshold_frequency: 0.5959088268863615"
+       "Touched = 1\n",
+       "type_of_frequency_discretization = 2\n",
+       "number_of_frequencies = 25\n",
+       "minimum_frequency = 0.055\n",
+       "frequency_interval = 0.02\n",
+       "frequency_factor = 1.1\n",
+       "type_of_directional_discretization = 1\n",
+       "number_of_directions = 32\n",
+       "minimum_direction = 0.0\n",
+       "maximum_direction = 0.0\n",
+       "separation_of_wind_sea_and_swell = 0\n",
+       "threshold_frequency = 0.125\n",
+       "maximum_threshold_frequency = 0.5959088268863615"
       ]
      },
      "execution_count": 11,
@@ -505,20 +505,20 @@
     {
      "data": {
       "text/plain": [
-       "Touched: 1\n",
-       "type_of_frequency_discretization: 2\n",
-       "number_of_frequencies: 25\n",
-       "minimum_frequency: 0.055\n",
-       "frequency_interval: 0.02\n",
-       "frequency_factor: 1.1\n",
-       "type_of_directional_discretization: 1\n",
-       "number_of_directions: 32\n",
-       "minimum_direction: 0.0\n",
-       "maximum_direction: 0.0\n",
-       "separation_of_wind_sea_and_swell: 0\n",
-       "threshold_frequency: 0.125\n",
-       "maximum_threshold_frequency: 0.5959088268863615\n",
-       "new_keyword: new_value"
+       "Touched = 1\n",
+       "type_of_frequency_discretization = 2\n",
+       "number_of_frequencies = 25\n",
+       "minimum_frequency = 0.055\n",
+       "frequency_interval = 0.02\n",
+       "frequency_factor = 1.1\n",
+       "type_of_directional_discretization = 1\n",
+       "number_of_directions = 32\n",
+       "minimum_direction = 0.0\n",
+       "maximum_direction = 0.0\n",
+       "separation_of_wind_sea_and_swell = 0\n",
+       "threshold_frequency = 0.125\n",
+       "maximum_threshold_frequency = 0.5959088268863615\n",
+       "new_keyword = 'new_value'"
       ]
      },
      "execution_count": 13,
@@ -910,7 +910,7 @@
        "    'item_name_A': ''},\n",
        "   'BOUNDARY_CONDITIONS': {'Touched': 0,\n",
        "    'MzSEPfsListItemCount': 0,\n",
-       "    'CODE_1': None},\n",
+       "    'CODE_1': {}},\n",
        "   'OUTPUTS': {'Touched': 1,\n",
        "    'MzSEPfsListItemCount': 4,\n",
        "    'number_of_outputs': 5,\n",
@@ -1604,15 +1604,16 @@
     {
      "data": {
       "text/plain": [
-       "CLSID: t1_t0.dll\n",
-       "TypeName: t1_t0\n",
-       "Setup:\n",
-       "  Name: Extract that\n",
-       "  InputFileName: '|random.dfs1|'\n",
-       "  FirstTimeStep: 0\n",
-       "  LastTimeStep: 99\n",
-       "  X: 2\n",
-       "  OutputFileName: '|.\\out2.dfs0|'"
+       "CLSID = 't1_t0.dll'\n",
+       "TypeName = 't1_t0'\n",
+       "[Setup]\n",
+       "   Name = 'Extract that'\n",
+       "   InputFileName = |random.dfs1|\n",
+       "   FirstTimeStep = 0\n",
+       "   LastTimeStep = 99\n",
+       "   X = 2\n",
+       "   OutputFileName = |.\\out2.dfs0|\n",
+       "EndSect  // Setup"
       ]
      },
      "execution_count": 23,
@@ -1634,7 +1635,7 @@
      "data": {
       "text/plain": [
        "<mikeio.Pfs>\n",
-       "t1_t0: CLSID: t1_t0.dllTypeName: t1_t0Setup:  Name: ..."
+       "t1_t0: CLSID = 't1_t0.dll'TypeName = 't1_t0'[Setup] ..."
       ]
      },
      "execution_count": 24,
@@ -1692,7 +1693,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.10.5"
+   "version": "3.10.8"
   },
   "vscode": {
    "interpreter": {


Test patch:
diff --git a/tests/test_pfs.py b/tests/test_pfs.py
index d2a9f94b..3489ba56 100644
--- a/tests/test_pfs.py
+++ b/tests/test_pfs.py
@@ -34,6 +34,18 @@ def test_pfssection(d1):
     assert len(sct.lst) == 2
     assert sct.dt == datetime(1979, 2, 3, 3, 5, 0)
 
+def test_pfssection_repr(d1):
+    sct = mikeio.PfsSection(d1)
+    txt = repr(sct)
+    assert len(txt)>1
+    assert "dt = 1979, 2, 3, 3, 5, 0" in txt
+    assert "EndSect" in txt
+
+def test_pfs_repr(d1):
+    pfs = mikeio.Pfs(d1, names="ROOT")
+    txt = repr(pfs)
+    assert len(txt)>1
+    assert "SMILE = |file" in txt
 
 def test_pfssection_keys_values_items(d1):
     sct = mikeio.PfsSection(d1)