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