From 54b402d159f54ec9f046cf197be0feb6e544a376 Mon Sep 17 00:00:00 2001
From: Daniel <d.hornung@indiscale.com>
Date: Thu, 7 Mar 2024 11:54:48 +0100
Subject: [PATCH] WIP: Filling XLSX

---
 .../table_json_conversion/fill_xlsx.py        | 156 ++++++++++++------
 .../example_template.xlsx                     | Bin 8764 -> 8668 bytes
 2 files changed, 103 insertions(+), 53 deletions(-)

diff --git a/src/caosadvancedtools/table_json_conversion/fill_xlsx.py b/src/caosadvancedtools/table_json_conversion/fill_xlsx.py
index 5ef95925..bf6f8ef0 100644
--- a/src/caosadvancedtools/table_json_conversion/fill_xlsx.py
+++ b/src/caosadvancedtools/table_json_conversion/fill_xlsx.py
@@ -21,10 +21,12 @@
 # along with this program. If not, see <https://www.gnu.org/licenses/>.
 
 import json
+from collections import OrderedDict
 from types import SimpleNamespace
-from typing import List, Union, TextIO
+from typing import Any, Dict, List, Optional, Union, TextIO
 
-from openpyxl import load_workbook
+from openpyxl import load_workbook, Workbook
+from openpyxl.worksheet.worksheet import Worksheet
 
 from .table_generator import ColumnType, RowType
 
@@ -39,7 +41,32 @@ def _fill_leaves(json_doc: dict, workbook):
             workbook.cell(1, 2, el)
 
 
+def _is_exploded_sheet(sheet: Worksheet) -> bool:
+    """Return True if this is a an "exploded" sheet.
+
+    An exploded sheet is a sheet whose data entries are LIST valued properties of entries in another
+    sheet.  A sheet is detected as exploded iff it has FOREIGN columns.
+    """
+    column_types = _get_column_types(sheet)
+    return ColumnType.FOREIGN.value in column_types.values()
+
+
+def _get_column_types(sheet: Worksheet) -> OrderedDict:
+    """Return an OrderedDict: column index -> column type for the sheet.
+    """
+    result = OrderedDict()
+    type_row_index = _get_row_type_column_index(sheet) - 1
+    for idx, col in enumerate(sheet.columns):
+        type_cell = col[type_row_index]
+        result[idx] = type_cell.value
+        assert hasattr(ColumnType, type_cell.value) or type_cell.value is None, (
+            f"Unexpected column type value: {type_cell.value}")
+    return result
+
+
 def _get_row_type_column_index(worksheet):
+    """Return the column index (1-indexed) of the column which defines the row types.
+    """
     for col in worksheet.columns:
         for cell in col:
             if cell.value == RowType.COL_TYPE.name:
@@ -48,6 +75,7 @@ def _get_row_type_column_index(worksheet):
 
 
 def _get_path_rows(worksheet):
+    """Return the 1-based indices of the rows which represent paths."""
     rows = []
     rt_col = _get_row_type_column_index(worksheet)
     for cell in list(worksheet.columns)[rt_col-1]:
@@ -60,8 +88,8 @@ def _get_path_rows(worksheet):
 def _next_row_index(sheet) -> int:
     """Return the index for the next data row.
 
-This is defined as the first row without any content.
-"""
+    This is defined as the first row without any content.
+    """
     return sheet.max_row
 
 
@@ -74,11 +102,11 @@ class TemplateFiller:
         """Fill the data into the workbook."""
         self._handle_data(data=data, current_path=[])
 
-    def _create_index(self, ):
+    def _create_index(self):
         """Create a sheet index for the workbook.
 
-        Index the sheets by their relevant path array.  Also create a simple column index by column
-        type and path.
+        Index the sheets by all path arrays leading to them.  Also create a simple column index by
+        column type and path.
 
         """
         self._sheet_index = {}
@@ -92,8 +120,6 @@ class TemplateFiller:
 
             # Get the paths, use without the leaf component for sheet indexing, with type prefix and
             # leaf for column indexing.
-            paths = []
-            col_index = {}
             for col_idx, col in enumerate(sheet.columns):
                 if col[coltype_idx].value == RowType.COL_TYPE.name:
                     continue
@@ -101,37 +127,47 @@ class TemplateFiller:
                 for path_idx in path_indices:
                     if col[path_idx].value is not None:
                         path.append(col[path_idx].value)
-                col_key = ".".join([col[coltype_idx].value] + path)
-                col_index[col_key] = SimpleNamespace(column=col, col_index=col_idx)
+                # col_key = ".".join([col[coltype_idx].value] + path)
+                # col_index[col_key] = SimpleNamespace(column=col, col_index=col_idx)
                 if col[coltype_idx].value not in [ColumnType.SCALAR.name, ColumnType.LIST.name]:
                     continue
-                paths.append(path[:-1])
-
-            # Find common components:
-            common_path = []
-            for idx, component in enumerate(paths[0]):
-                for path in paths:
-                    if not path[idx] == component:
-                        break
-                else:
-                    common_path.append(component)
-            assert len(common_path) >= 1
-
-            self._sheet_index[".".join(common_path)] = SimpleNamespace(
-                common_path=common_path, sheetname=sheetname, sheet=sheet, col_index=col_index)
-
-    def _handle_data(self, data: dict, current_path: List[str] = None):
+
+                path_str = ".".join(path)
+                assert path_str not in self._sheet_index
+                self._sheet_index[path_str] = SimpleNamespace(
+                    sheetname=sheetname, sheet=sheet, col_index=col_idx,
+                    col_type=col[coltype_idx].value)
+
+    def _handle_data(self, data: dict, current_path: List[str] = None,
+                     only_collect_insertables: bool = False,
+                     ) -> Optional[Dict[str, Any]]:
         """Handle the data and write it into ``workbook``.
 
 Parameters
 ----------
 data: dict
   The data at the current path position.  Elements may be dicts, lists or simple scalar values.
+
+current_path: list[str], optional
+  If this is None or empty, we are at the top level.  This means that all children shall be entered
+  into their respective sheets and not into a sheet at this level.
+
+only_collect_insertables: bool, optional
+  If True, do not insert anything on this level, but return a dict with entries to be inserted.
+
+
+Returns
+-------
+
+out: union[dict, None]
+  If ``only_collect_insertables`` is True, return a dict (path string -> value)
         """
         if current_path is None:
             current_path = []
+        insertables: Dict[str, Any] = {}
         for name, content in data.items():
             path = current_path + [name]
+            # preprocessing
             if isinstance(content, list):
                 if not content:
                     continue
@@ -142,34 +178,48 @@ data: dict
                     for entry in content:
                         self._handle_data(data=entry, current_path=path)
                     continue
-            self._handle_simple_data(data=content, current_path=path)
-
-    def _handle_simple_data(self, data, current_path: List[str]):
-        """Enter this single data item into the workbook.
-
-Parameters
-----------
-data: dict
-  The data at the current path position.  Must be single items (dict or simple scalar) or lists of
-  simple values.
-        """
-        sheet_meta = self._sheet_index[".".join(current_path)]
-        sheet = sheet_meta.sheet
-        next_row = _next_row_index(sheet)
-        for name, content in data.items():
-            if isinstance(content, list):
-                # TODO handle later
-                # scalar elements: semicolon separated
-                # nested dicts: recurse
-                pass
             elif isinstance(content, dict):
-                pass
-                # scalars
+                if not current_path:  # Special handling for top level
+                    self._handle_data(content, current_path=path)
+                    continue
+                insert = self._handle_data(content, current_path=path,
+                                           only_collect_insertables=True)
+                assert isinstance(insert, dict)
+                assert not any(key in insertables for key in insert)
+                insertables.update(insert)
+                continue
+            else:  # scalars
+                content = [content]
+
+            # collecting the data
+            assert isinstance(content, list)
+            if len(content) == 1:
+                value = content[0]
             else:
-                path = current_path + [name]
-                path_str = ".".join([ColumnType.SCALAR.name] + path)
-                col_index = sheet_meta.col_index[path_str].col_index
-                sheet.cell(row=next_row+1, column=col_index+1, value=content)
+                value = ";".join(content)
+            path_str = ".".join(path)
+            assert path_str not in insertables
+            insertables[path_str] = value
+        if only_collect_insertables:
+            return insertables
+        if not current_path:
+            return
+
+        # actual data insertion
+        insert_row = None
+        sheet = None
+        for path_str, value in insertables.items():
+            sheet_meta = self._sheet_index[path_str]
+            if sheet is None:
+                sheet = sheet_meta.sheet
+            assert sheet is sheet_meta.sheet, "All entries must be in the same sheet."
+            col_index = sheet_meta.col_index
+            if insert_row is None:
+                insert_row = _next_row_index(sheet)
+
+            sheet.cell(row=insert_row+1, column=col_index+1, value=value)
+            # self._handle_simple_data(data=content, current_path=path)
+        return None
 
 
 def fill_template(data: Union[dict, str, TextIO], template: str, result: str) -> None:
diff --git a/unittests/table_json_conversion/example_template.xlsx b/unittests/table_json_conversion/example_template.xlsx
index 68177385bcfda7c3cdcdeca24a94e4757240d793..1162965bf44642a4523123fa52c58dd240b25e5f 100644
GIT binary patch
delta 5200
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
F{s%c<LBIe2

delta 5315
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
P;{dCpV@9Q<`K|bW(-CDQ

-- 
GitLab