numbers-parser 4.18.0__tar.gz → 4.18.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {numbers_parser-4.18.0/src/numbers_parser.egg-info → numbers_parser-4.18.1}/PKG-INFO +1 -1
  2. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/pyproject.toml +2 -2
  3. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/constants.py +5 -0
  4. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/document.py +46 -13
  5. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/model.py +112 -94
  6. {numbers_parser-4.18.0 → numbers_parser-4.18.1/src/numbers_parser.egg-info}/PKG-INFO +1 -1
  7. numbers_parser-4.18.1/tests/test_categories.py +391 -0
  8. numbers_parser-4.18.0/tests/test_categories.py +0 -712
  9. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/LICENSE.rst +0 -0
  10. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/README.md +0 -0
  11. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/setup.cfg +0 -0
  12. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/__init__.py +0 -0
  13. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/_cat_numbers.py +0 -0
  14. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/_csv2numbers.py +0 -0
  15. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/_unpack_numbers.py +0 -0
  16. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/bullets.py +0 -0
  17. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/cell.py +0 -0
  18. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/containers.py +0 -0
  19. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/currencies.py +0 -0
  20. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/data/empty.numbers +0 -0
  21. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/exceptions.py +0 -0
  22. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/experimental.py +0 -0
  23. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/formula.py +0 -0
  24. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TNArchives_pb2.py +0 -0
  25. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TNArchives_sos_pb2.py +0 -0
  26. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TNCommandArchives_pb2.py +0 -0
  27. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TNCommandArchives_sos_pb2.py +0 -0
  28. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSAArchives_pb2.py +0 -0
  29. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSAArchives_sos_pb2.py +0 -0
  30. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSACommandArchives_sos_pb2.py +0 -0
  31. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCEArchives_pb2.py +0 -0
  32. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCH3DArchives_pb2.py +0 -0
  33. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCHArchives_Common_pb2.py +0 -0
  34. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCHArchives_GEN_pb2.py +0 -0
  35. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCHArchives_pb2.py +0 -0
  36. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCHArchives_sos_pb2.py +0 -0
  37. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCHCommandArchives_pb2.py +0 -0
  38. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCHPreUFFArchives_pb2.py +0 -0
  39. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCKArchives_pb2.py +0 -0
  40. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSCKArchives_sos_pb2.py +0 -0
  41. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSDArchives_pb2.py +0 -0
  42. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSDArchives_sos_pb2.py +0 -0
  43. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSDCommandArchives_pb2.py +0 -0
  44. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSKArchives_pb2.py +0 -0
  45. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSPArchiveMessages_pb2.py +0 -0
  46. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSPDatabaseMessages_pb2.py +0 -0
  47. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSPMessages_pb2.py +0 -0
  48. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSSArchives_pb2.py +0 -0
  49. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSSArchives_sos_pb2.py +0 -0
  50. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSTArchives_pb2.py +0 -0
  51. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSTArchives_sos_pb2.py +0 -0
  52. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSTCommandArchives_pb2.py +0 -0
  53. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSTStylePropertyArchiving_pb2.py +0 -0
  54. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSWPArchives_pb2.py +0 -0
  55. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSWPArchives_sos_pb2.py +0 -0
  56. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/TSWPCommandArchives_pb2.py +0 -0
  57. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/__init__.py +0 -0
  58. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/fontmap.py +0 -0
  59. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/functionmap.py +0 -0
  60. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/generated/mapping.py +0 -0
  61. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/iwafile.py +0 -0
  62. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/iwork.py +0 -0
  63. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/numbers_cache.py +0 -0
  64. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/numbers_uuid.py +0 -0
  65. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/roman.py +0 -0
  66. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser/xrefs.py +0 -0
  67. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser.egg-info/SOURCES.txt +0 -0
  68. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser.egg-info/dependency_links.txt +0 -0
  69. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser.egg-info/entry_points.txt +0 -0
  70. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser.egg-info/requires.txt +0 -0
  71. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/src/numbers_parser.egg-info/top_level.txt +0 -0
  72. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_all_formulas.py +0 -0
  73. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_api_change.py +0 -0
  74. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_borders.py +0 -0
  75. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_bullets.py +0 -0
  76. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_cat_numbers.py +0 -0
  77. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_coverage.py +0 -0
  78. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_create_cells.py +0 -0
  79. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_csv2numbers.py +0 -0
  80. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_currency.py +0 -0
  81. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_folder.py +0 -0
  82. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_formatting.py +0 -0
  83. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_formulas.py +0 -0
  84. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_issues.py +0 -0
  85. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_large.py +0 -0
  86. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_memory_leaks.py +0 -0
  87. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_merges.py +0 -0
  88. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_package.py +0 -0
  89. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_properties.py +0 -0
  90. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_roman.py +0 -0
  91. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_save.py +0 -0
  92. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_slices.py +0 -0
  93. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_styles.py +0 -0
  94. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_table_size.py +0 -0
  95. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_tables.py +0 -0
  96. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_unpack_numbers.py +0 -0
  97. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_unsupported.py +0 -0
  98. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_uuids.py +0 -0
  99. {numbers_parser-4.18.0 → numbers_parser-4.18.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numbers-parser
3
- Version: 4.18.0
3
+ Version: 4.18.1
4
4
  Summary: Read and write Apple Numbers spreadsheets
5
5
  Author-email: Jon Connell <python@figsandfudge.com>
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ classifiers = [
22
22
  description = "Read and write Apple Numbers spreadsheets"
23
23
  name = "numbers-parser"
24
24
  readme = "README.md"
25
- version = "4.18.0"
25
+ version = "4.18.1"
26
26
 
27
27
  [project.urls]
28
28
  repository = "https://github.com/masaccio/numbers-parser"
@@ -197,5 +197,5 @@ ban-relative-imports = "all"
197
197
  "src/build/**" = ["PLR2004", "INP001", "PTH"]
198
198
  "src/build/protodump.py" = ["PLR2004", "INP001", "PTH", "S110", "N806"]
199
199
  "src/debug/**" = ["INP001"]
200
- "tests/**" = ["PLR2004", "S101", "D103", "ANN201", "ANN001"]
200
+ "tests/**" = ["PLR2004", "S101", "D103", "ANN201", "ANN001", "DTZ001"]
201
201
 
@@ -121,6 +121,7 @@ def _week_of_month(value: datetime) -> int:
121
121
 
122
122
  DATETIME_FIELD_MAP = OrderedDict(
123
123
  [
124
+ # Cell formats
124
125
  ("a", lambda x: x.strftime("%p").lower()),
125
126
  ("EEEE", "%A"),
126
127
  ("EEE", "%a"),
@@ -157,6 +158,10 @@ DATETIME_FIELD_MAP = OrderedDict(
157
158
  ("SSS", lambda x: str(x.microsecond).zfill(6)[0:3]),
158
159
  ("SSSS", lambda x: str(x.microsecond).zfill(6)[0:4]),
159
160
  ("SSSSS", lambda x: str(x.microsecond).zfill(6)[0:5]),
161
+ # Table category formats
162
+ ("QQQ", lambda x: "Q" + str(int(x.month / 3) + 1)),
163
+ ("LLLL", "%B"),
164
+ ("w", "%-W"),
160
165
  ],
161
166
  )
162
167
 
@@ -756,7 +756,9 @@ class Table(Cacheable):
756
756
  msg = f"column {col} out of range"
757
757
  raise IndexError(msg)
758
758
 
759
- if (row_mapper := self._model.table_category_row_map(self._table_id)) is not None:
759
+ self._model.calculate_table_categories(self._table_id)
760
+ row_mapper = self._model._table_categories_row_mapper[self._table_id]
761
+ if row_mapper is not None:
760
762
  return self._data[row_mapper[row]][col]
761
763
  return self._data[row][col]
762
764
 
@@ -824,7 +826,9 @@ class Table(Cacheable):
824
826
  raise IndexError(msg)
825
827
 
826
828
  rows = self.rows()
827
- if (row_mapper := self._model.table_category_row_map(self._table_id)) is not None:
829
+ self._model.calculate_table_categories(self._table_id)
830
+ row_mapper = self._model._table_categories_row_mapper[self._table_id]
831
+ if row_mapper is not None:
828
832
  rows = [rows[row_mapper[row]] for row in range(min_row, max_row + 1)]
829
833
  else:
830
834
  rows = rows[min_row : max_row + 1]
@@ -899,7 +903,9 @@ class Table(Cacheable):
899
903
  raise IndexError(msg)
900
904
 
901
905
  rows = self.rows()
902
- if (row_mapper := self._model.table_category_row_map(self._table_id)) is not None:
906
+ self._model.calculate_table_categories(self._table_id)
907
+ row_mapper = self._model._table_categories_row_mapper[self._table_id]
908
+ if row_mapper is not None:
903
909
  rows = [rows[row_mapper[row_num]] for row_num in range(min_row, max_row + 1)]
904
910
  else:
905
911
  rows = rows[min_row : max_row + 1]
@@ -1011,33 +1017,60 @@ class Table(Cacheable):
1011
1017
  msg = "style must be a Style object or style name"
1012
1018
  raise TypeError(msg)
1013
1019
 
1014
- def categorized_data(self) -> dict | None:
1020
+ def categorized_data(self, values_only: bool = False) -> dict | None:
1015
1021
  """
1016
1022
  Return the table's data organized into categories, if enabled or ``None``
1017
1023
  if the table has not had categories enabled.
1018
1024
 
1019
- The data is a dictionary with the category names as keys and a list
1020
- dictionaries for each row in that category of the table. The table heading
1021
- row is used as the keys for the row dictionary.
1025
+ The data is a set of nested dictionaries and lists. Dictionary keys are
1026
+ Category keys and values are either another dictionary in the case of
1027
+ nested categories or a list of rows. Each row is itself a list such that
1028
+ the data is the same as returned by :py:meth:`numbers_parser.Table.rows`.
1029
+
1030
+ Parameters
1031
+ ----------
1032
+ values_only:
1033
+ If ``True``, return cell values instead of :class:`Cell` objects
1034
+
1035
+ Returns
1036
+ -------
1037
+ Dict[str, Dict | List]:
1038
+ Nested dictionary of lists of rows or dictionaries of the next group
1039
+ of categories down. Row data is returned as :py:class:`Cell` classes
1040
+ unless ``values_only`` is ``True``.
1022
1041
 
1023
1042
  Example
1024
1043
  -------
1025
1044
  .. code:: python
1026
1045
 
1027
1046
  "Transport": [
1028
- {"Description": "Airplane", "Category": "Transport" },
1029
- {"Description": "Bicycle", "Category": "Transport" },
1030
- {"Description": "Bus", "Category": "Transport"},
1047
+ {"Airplane", "Air": 5 },
1048
+ {"Helicopter": "Air", 2 },
1049
+ {"Bus": "Road", 10 },
1031
1050
  ],
1032
1051
  "Fruit": [
1033
- {"Description": "Apple", "Category": "Fruit" },
1034
- {"Description": "Banana", "Category": "Fruit" },
1052
+ {"Apple", "Green": 7 },
1053
+ {"Banana", "Yellow", 6 },
1035
1054
  ],
1036
1055
 
1037
1056
  For tables with multiple categories, the top-level dictionary is nested.
1038
1057
 
1039
1058
  """
1040
- return self._model.table_category_data(self._table_id)
1059
+
1060
+ def data_to_values(item):
1061
+ if isinstance(item, dict):
1062
+ return {k: data_to_values(v) for k, v in item.items()}
1063
+
1064
+ if isinstance(item, list):
1065
+ return [data_to_values(v) for v in item]
1066
+
1067
+ return item.value
1068
+
1069
+ self._model.calculate_table_categories(self._table_id)
1070
+ data = self._model._table_categories_data[self._table_id]
1071
+ if values_only:
1072
+ return data_to_values(data)
1073
+ return data
1041
1074
 
1042
1075
  def add_row(
1043
1076
  self,
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import re
4
4
  from array import array
5
5
  from collections import defaultdict
6
+ from datetime import timedelta
6
7
  from hashlib import sha1
7
8
  from itertools import chain
8
9
  from math import floor
@@ -32,6 +33,7 @@ from numbers_parser.cell import (
32
33
  PaddingType,
33
34
  Style,
34
35
  VerticalJustification,
36
+ _decode_date_format,
35
37
  )
36
38
  from numbers_parser.constants import (
37
39
  ALLOWED_FORMATTING_PARAMETERS,
@@ -47,6 +49,7 @@ from numbers_parser.constants import (
47
49
  DEFAULT_TEXT_WRAP,
48
50
  DEFAULT_TILE_SIZE,
49
51
  DOCUMENT_ID,
52
+ EPOCH,
50
53
  FORMAT_TYPE_MAP,
51
54
  MAX_TILE_SIZE,
52
55
  PACKAGE_ID,
@@ -233,6 +236,8 @@ class _NumbersModel(Cacheable):
233
236
  self._control_specs = DataLists(self, "control_cell_spec_table", "cell_spec")
234
237
  self._formulas = DataLists(self, "formula_table", "formula")
235
238
  self._table_data = {}
239
+ self._table_categories_data = {}
240
+ self._table_categories_row_mapper = {}
236
241
  self._styles = None
237
242
  self._images = {}
238
243
  self._custom_formats = None
@@ -2561,8 +2566,18 @@ class _NumbersModel(Cacheable):
2561
2566
  return cell_value.number_value.value
2562
2567
  if cell_value_type == CellValueType.BOOLEAN_TYPE:
2563
2568
  return cell_value.boolean_value.value
2564
- # Must be DATE_TYPE
2565
- return cell_value.date_value.value
2569
+ if cell_value_type == CellValueType.DATE_TYPE:
2570
+ # "yyyy"
2571
+ # "yyyy-QQQ"
2572
+ # "LLLL yyyy"
2573
+ # "yyyy'-W'w"
2574
+ # "d/M/yyyy"
2575
+ # "EEEE"
2576
+ return _decode_date_format(
2577
+ cell_value.date_value.format.date_time_format,
2578
+ EPOCH + timedelta(seconds=cell_value.date_value.value),
2579
+ )
2580
+ return None
2566
2581
 
2567
2582
  @cache(num_args=0)
2568
2583
  def group_uuid_values(self):
@@ -2574,14 +2589,19 @@ class _NumbersModel(Cacheable):
2574
2589
  }
2575
2590
 
2576
2591
  @cache()
2577
- def table_category_row_map(self, table_id: int) -> dict[int, int] | None:
2592
+ def calculate_table_categories(self, table_id: int) -> tuple[dict[int, int], dict] | None:
2578
2593
  category_owner_id = self.objects[table_id].category_owner.identifier
2579
2594
  if not category_owner_id:
2580
- return None
2595
+ self._table_categories_data[table_id] = None
2596
+ self._table_categories_row_mapper[table_id] = None
2597
+ return
2598
+
2581
2599
  category_archive_id = self.objects[category_owner_id].group_by[0].identifier
2582
2600
  category_archive = self.objects[category_archive_id]
2583
2601
  if not category_archive.is_enabled:
2584
- return None
2602
+ self._table_categories_data[table_id] = None
2603
+ self._table_categories_row_mapper[table_id] = None
2604
+ return
2585
2605
 
2586
2606
  table_info = self.objects[self.table_info_id(table_id)]
2587
2607
  category_order = self.objects[table_info.category_order.identifier]
@@ -2594,101 +2614,99 @@ class _NumbersModel(Cacheable):
2594
2614
  row_uid_for_index = [
2595
2615
  NumbersUUID(row_uid_map.sorted_row_uids[i]) for i in row_uid_map.row_uid_for_index
2596
2616
  ]
2597
- return {
2598
- row: row_uuid_to_offset[uuid]
2599
- for row, uuid in enumerate(
2600
- uuid for uuid in row_uid_for_index if uuid not in group_uuids
2601
- )
2602
- }
2603
-
2604
- def table_category_data(self, table_id: int) -> dict | None:
2605
- category_owner_id = self.objects[table_id].category_owner.identifier
2606
- category_archive_id = self.objects[category_owner_id].group_by[0].identifier
2607
- category_archive = self.objects[category_archive_id]
2608
- if not category_archive.is_enabled:
2609
- return None
2610
-
2611
- table_info = self.objects[self.table_info_id(table_id)]
2612
- category_order = self.objects[table_info.category_order.identifier]
2613
- row_uid_map = self.objects[category_order.uid_map.identifier]
2614
-
2615
- sorted_row_uuids = [
2616
- NumbersUUID(row_uid_map.sorted_row_uids[i]).hex for i in row_uid_map.row_index_for_uid
2617
- ]
2618
2617
 
2619
- data = self._table_data[table_id]
2620
- header = [cell.value for cell in data[0]]
2621
-
2622
- def index_set_to_offsets(index_set: TSCEArchives.IndexSetArchive) -> list[int]:
2623
- """Convert an IndexSetArchive to a list of offsets."""
2624
- offsets = []
2625
- for entry in index_set.entries:
2626
- if entry.HasField("range_end"):
2627
- offsets += list(range(entry.range_begin, entry.range_end + 1))
2628
- else:
2629
- offsets += list(range(entry.range_begin, entry.range_begin + 1))
2630
- return offsets
2631
-
2632
- group_node_to_key = {
2633
- NumbersUUID(self.objects[_id].group_uid).hex: _NumbersModel.cell_value_to_key(
2634
- self.objects[_id].group_cell_value,
2635
- )
2636
- for _id in self.find_refs("GroupNodeArchive")
2637
- }
2638
- group_uuids = [NumbersUUID(x.group_uid).hex for x in category_archive.group_node_root.child]
2639
- group_uuids = [uuid for uuid in sorted_row_uuids if uuid in group_uuids]
2640
-
2641
- def group_hierarchy(parent: str, children: list):
2642
- nodes = {}
2618
+ def parent_relationships(parent: NumbersUUID, children: list, group_parents: dict):
2643
2619
  for child in children:
2644
- group_uuid = NumbersUUID(child.group_uid).hex
2620
+ child_uuid = NumbersUUID(child.group_uid)
2621
+ group_parents[child_uuid] = parent
2645
2622
  if len(child.child) > 0:
2646
- nodes[group_uuid] = group_hierarchy(group_uuid, child.child)
2647
- else:
2648
- nodes[group_uuid] = None
2649
- return nodes
2650
-
2651
- def assign_rows_to_categories(parent: str, children: list, categories: dict):
2652
- for child in children:
2653
- group_uuid = NumbersUUID(child.group_uid).hex
2654
- if len(child.child) == 0:
2655
- key = _NumbersModel.cell_value_to_key(child.group_cell_value)
2656
-
2657
- row_offsets = index_set_to_offsets(child.row_lookup_uids)
2658
- categories[group_uuid] = {
2659
- "key": key,
2660
- "parent": parent,
2661
- "rows": [
2662
- {header[col]: cell.value for col, cell in enumerate(data[row])}
2663
- for row in row_offsets
2664
- ],
2665
- }
2666
- else:
2667
- categories[group_uuid] = {
2668
- "key": group_node_to_key[group_uuid],
2669
- "parent": parent,
2670
- "rows": None,
2623
+ parent_relationships(child_uuid, child.child, group_parents)
2624
+
2625
+ group_parents = {}
2626
+ parent_relationships(None, category_archive.group_node_root.child, group_parents)
2627
+
2628
+ row = 0
2629
+ row_mapper = {}
2630
+ header = []
2631
+ in_header = True
2632
+
2633
+ nodes: dict[NumbersUUID, dict] = {}
2634
+ root_children: dict = {}
2635
+ stack: list[NumbersUUID | None] = []
2636
+ # rows that are not in any group (rare) kept here
2637
+ root_rows: list = []
2638
+
2639
+ for uuid in row_uid_for_index:
2640
+ if uuid in group_uuids:
2641
+ # this UUID is a group heading
2642
+ in_header = False
2643
+ parent = group_parents.get(uuid)
2644
+
2645
+ # ensure node exists
2646
+ if uuid not in nodes:
2647
+ nodes[uuid] = {
2648
+ "key": group_uuids[uuid],
2649
+ "children": {},
2650
+ "rows": [],
2671
2651
  }
2672
- assign_rows_to_categories(group_uuid, child.child, categories)
2673
-
2674
- category_tree = group_hierarchy(
2675
- NumbersUUID(category_archive.group_node_root.group_uid).hex,
2676
- category_archive.group_node_root.child,
2677
- )
2678
2652
 
2679
- categories = {}
2680
- assign_rows_to_categories(None, category_archive.group_node_root.child, categories)
2681
-
2682
- def merge_trees(a: dict, b: dict):
2683
- new_tree = {}
2684
- for k, v in a.items():
2685
- if v is not None:
2686
- new_tree[b[k]["key"]] = merge_trees(v, b)
2653
+ # attach node to its parent (or root)
2654
+ if parent is None:
2655
+ if nodes[uuid]["key"] not in root_children:
2656
+ root_children[nodes[uuid]["key"]] = nodes[uuid]
2687
2657
  else:
2688
- new_tree[b[k]["key"]] = b[k]["rows"]
2689
- return new_tree
2690
-
2691
- return merge_trees(category_tree, categories)
2658
+ if parent not in nodes:
2659
+ nodes[parent] = {
2660
+ "key": group_uuids[parent],
2661
+ "children": {},
2662
+ "rows": [],
2663
+ }
2664
+ parent_node = nodes[parent]
2665
+ if nodes[uuid]["key"] not in parent_node["children"]:
2666
+ parent_node["children"][nodes[uuid]["key"]] = nodes[uuid]
2667
+
2668
+ # update stack to current nesting (pop until parent is on top)
2669
+ while stack and stack[-1] != parent:
2670
+ stack.pop()
2671
+ stack.append(uuid)
2672
+ else:
2673
+ mapped_row = row_uuid_to_offset[uuid]
2674
+ if in_header:
2675
+ header.append(self._table_data[table_id][mapped_row])
2676
+ # assign this row to the deepest open group, or root
2677
+ elif stack:
2678
+ nodes[stack[-1]]["rows"].append(self._table_data[table_id][mapped_row])
2679
+ else:
2680
+ root_rows.append(self._table_data[table_id][mapped_row])
2681
+
2682
+ row_mapper[row] = mapped_row
2683
+ row += 1
2684
+
2685
+ # helper to convert node dicts to nested mapping (keys -> children or rows)
2686
+ def node_to_structure(node: dict):
2687
+ if not node["children"]:
2688
+ return node["rows"]
2689
+ out = {}
2690
+ for child_key, child_node in node["children"].items():
2691
+ out[child_key] = node_to_structure(child_node)
2692
+ # if this node also has rows in addition to children, include them under a special key
2693
+ if node["rows"]:
2694
+ out["_rows"] = node["rows"]
2695
+ return out
2696
+
2697
+ maximally_nested = {}
2698
+ for key, node in root_children.items():
2699
+ maximally_nested[key] = node_to_structure(node)
2700
+ if root_rows:
2701
+ maximally_nested["_rows"] = root_rows
2702
+
2703
+ self._table_categories_data[table_id] = maximally_nested
2704
+ self._table_categories_row_mapper[table_id] = {
2705
+ row: row_uuid_to_offset[uuid]
2706
+ for row, uuid in enumerate(
2707
+ uuid for uuid in row_uid_for_index if uuid not in group_uuids
2708
+ )
2709
+ }
2692
2710
 
2693
2711
 
2694
2712
  def rgb(obj) -> RGB:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numbers-parser
3
- Version: 4.18.0
3
+ Version: 4.18.1
4
4
  Summary: Read and write Apple Numbers spreadsheets
5
5
  Author-email: Jon Connell <python@figsandfudge.com>
6
6
  License-Expression: MIT