diff --git a/src/caosadvancedtools/table_json_conversion/fill_xlsx.py b/src/caosadvancedtools/table_json_conversion/fill_xlsx.py index ec381ec7c23dc39405732c326f1db87e168e160f..5a620d67dc1d6dd6a90572b7b20da74c29ff0d45 100644 --- a/src/caosadvancedtools/table_json_conversion/fill_xlsx.py +++ b/src/caosadvancedtools/table_json_conversion/fill_xlsx.py @@ -20,6 +20,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. +from __future__ import annotations + import json from collections import OrderedDict from types import SimpleNamespace @@ -126,7 +128,6 @@ class TemplateFiller: def __init__(self, workbook: Workbook): self._workbook = workbook self._create_index() - self._context: Optional[dict[str, Any]] = None @property def workbook(self): @@ -134,9 +135,49 @@ class TemplateFiller: def fill_data(self, data: dict): """Fill the data into the workbook.""" - self._context = data self._handle_data(data=data) - self._context = None + + class Context: + """Context for an entry: simple properties of all ancestors, organized in a dict. + + This is similar to a dictionary with all scalar element properties at the tree nodes up to + the root. Siblings in lists and dicts are ignored. Additionally the context knows where + its current position is. + + Lookup of elements can easily be achieved by giving the path (as ``list[str]`` or + stringified path). + + """ + + def __init__(self, current_path: List[str] = None, props: Dict[str, Any] = None): + self._current_path = current_path if current_path is not None else [] + self._props = props if props is not None else {} # this is flat + + def copy(self) -> TemplateFiller.Context: + """Deep copy.""" + result = TemplateFiller.Context(current_path=self._current_path.copy(), + props=self._props.copy()) + return result + + def next_level(self, next_level: str) -> TemplateFiller.Context: + result = self.copy() + result._current_path.append(next_level) + return result + + def __getitem__(self, path: Union[List[str], str], owner=None) -> Any: + if isinstance(path, list): + path = p2s(path) + return self._props[path] + + def __setitem__(self, propname: str, value): + fullpath = p2s(self._current_path + [propname]) + self._props[fullpath] = value + + def fill_from_data(self, data: Dict[str, Any]): + """Fill current level with all scalar elements of ``data``.""" + for name, value in data.items(): + if not isinstance(value, (dict, list)): + self[name] = value def _create_index(self): """Create a sheet index for the workbook. @@ -175,6 +216,7 @@ class TemplateFiller: col_type=col[coltype_idx].value) def _handle_data(self, data: dict, current_path: List[str] = None, + context: TemplateFiller.Context = None, only_collect_insertables: bool = False, ) -> Optional[Dict[str, Any]]: """Handle the data and write it into ``workbook``. @@ -186,7 +228,13 @@ data: dict 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. + into their respective sheets and not into a sheet at this level. ``current_path`` and ``context`` + must either both be given, or none of them. + +context: TemplateFiller.Context, optional + Directopry of scalar element properties at the tree nodes up to the root. Siblings in lists + and dicts are ignored. ``context`` and ``current_path`` must either both be given, or none of + them. only_collect_insertables: bool, optional If True, do not insert anything on this level, but return a dict with entries to be inserted. @@ -194,16 +242,21 @@ only_collect_insertables: bool, optional Returns ------- - out: union[dict, None] If ``only_collect_insertables`` is True, return a dict (path string -> value) """ + assert (current_path is None) is (context is None), ( + "`current_path` and `context` must either both be given, or none of them.") if current_path is None: current_path = [] - assert self._context is not None + if context is None: + context = TemplateFiller.Context() + context.fill_from_data(data) + insertables: Dict[str, Any] = {} for name, content in data.items(): path = current_path + [name] + next_context = context.next_level(name) # preprocessing if isinstance(content, list): if not content: @@ -213,13 +266,13 @@ out: union[dict, None] if isinstance(content[0], dict): # An array of objects: must go into exploded sheet for entry in content: - self._handle_data(data=entry, current_path=path) + self._handle_data(data=entry, current_path=path, context=next_context) continue elif isinstance(content, dict): if not current_path: # Special handling for top level - self._handle_data(content, current_path=path) + self._handle_data(content, current_path=path, context=next_context) continue - insert = self._handle_data(content, current_path=path, + insert = self._handle_data(content, current_path=path, context=next_context.copy(), only_collect_insertables=True) assert isinstance(insert, dict) assert not any(key in insertables for key in insert) @@ -260,7 +313,7 @@ out: union[dict, None] if insert_row is not None and sheet is not None and _is_exploded_sheet(sheet): foreigns = _get_foreign_key_columns(sheet) for index, path in ((f.index, f.path) for f in foreigns.values()): - value = _get_deep_value(self._context, path) + value = context[path] sheet.cell(row=insert_row+1, column=index+1, value=value) return None diff --git a/unittests/table_json_conversion/data/multiple_refs_data.json b/unittests/table_json_conversion/data/multiple_refs_data.json new file mode 100644 index 0000000000000000000000000000000000000000..5b8ce9136635832111abb2206d8afe1bc7c58444 --- /dev/null +++ b/unittests/table_json_conversion/data/multiple_refs_data.json @@ -0,0 +1,48 @@ +{ + "Training": { + "trainer": [], + "participant": [ + { + "full_name": "Petra Participant", + "email": "petra@indiscale.com" + }, + { + "full_name": "Peter", + "email": "peter@getlinkahead.com" + } + ], + "Organisation": [ + { + "Person": [ + { + "full_name": "Henry Henderson", + "email": "henry@organization.org" + }, + { + "full_name": "Harry Hamburg", + "email": "harry@organization.org" + } + ], + "name": "World Training Organization", + "Country": "US" + }, + { + "Person": [ + { + "full_name": "Hermione Harvard", + "email": "hermione@organisation.org.uk" + }, + { + "full_name": "Hazel Harper", + "email": "hazel@organisation.org.uk" + } + ], + "name": "European Training Organisation", + "Country": "UK" + } + ], + "date": "2024-03-21T14:12:00.000Z", + "url": "www.indiscale.com", + "name": "Example training with multiple organizations." + } +} diff --git a/unittests/table_json_conversion/data/multiple_refs_data.xlsx b/unittests/table_json_conversion/data/multiple_refs_data.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..21622ede9515b0cfa9f965f8c2ee89f782c4bf0c Binary files /dev/null and b/unittests/table_json_conversion/data/multiple_refs_data.xlsx differ diff --git a/unittests/table_json_conversion/test_fill_xlsx.py b/unittests/table_json_conversion/test_fill_xlsx.py index 5b45939aa5c44da71eb6f9467d6617251fbfd5a1..18ab4f06dcdaa8f36289dd49c6730ee2ac5e26c8 100644 --- a/unittests/table_json_conversion/test_fill_xlsx.py +++ b/unittests/table_json_conversion/test_fill_xlsx.py @@ -56,9 +56,6 @@ custom_output: str, optional assert os.path.exists(outfile) generated = load_workbook(outfile) # workbook can be read known_good_wb = load_workbook(known_good) - # if custom_output is not None: - # from IPython import embed - # embed() compare_workbooks(generated, known_good_wb) @@ -71,5 +68,6 @@ def test_detect(): def test_fill_xlsx(): fill_and_compare(json_file="data/simple_data.json", template_file="data/simple_template.xlsx", known_good="data/simple_data.xlsx") - # fill_and_compare(json_file="data/example.json", template_file="data/example_template.xlsx", - # known_good="data/example_single_data.xlsx") + fill_and_compare(json_file="data/multiple_refs_data.json", + template_file="data/multiple_refs_template.xlsx", + known_good="data/multiple_refs_data.xlsx") diff --git a/unittests/table_json_conversion/utils.py b/unittests/table_json_conversion/utils.py index 0311e8bb4eaf4e2cf6c6f7686b4b9144112111e6..6c32117c1296e686290ad75bf5f704a1abfb2547 100644 --- a/unittests/table_json_conversion/utils.py +++ b/unittests/table_json_conversion/utils.py @@ -33,7 +33,9 @@ Parameters hidden: bool, optional Test if the "hidden" status of rows and columns is the same. """ - assert wb1.sheetnames == wb2.sheetnames, "Sheet names are different." + assert wb1.sheetnames == wb2.sheetnames, ( + f"Sheet names are different: \n{wb1.sheetnames}\n !=\n{wb2.sheetnames}" + ) for sheetname in wb2.sheetnames: sheet_1 = wb1[sheetname] sheet_2 = wb2[sheetname]