ExcelAlchemy 2.2.7__tar.gz → 2.2.8__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 (84) hide show
  1. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/PKG-INFO +3 -3
  2. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/README-pypi.md +2 -2
  3. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/__init__.py +1 -1
  4. excelalchemy-2.2.8/src/excelalchemy/_primitives/diagnostics.py +50 -0
  5. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/alchemy.py +11 -8
  6. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/metadata.py +7 -12
  7. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/results.py +122 -0
  8. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/LICENSE +0 -0
  9. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/pyproject.toml +0 -0
  10. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/_primitives/__init__.py +0 -0
  11. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/_primitives/constants.py +0 -0
  12. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/_primitives/deprecation.py +0 -0
  13. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/_primitives/header_models.py +0 -0
  14. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/_primitives/identity.py +0 -0
  15. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/_primitives/payloads.py +0 -0
  16. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/artifacts.py +0 -0
  17. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/__init__.py +0 -0
  18. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/base.py +0 -0
  19. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/boolean.py +0 -0
  20. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/date.py +0 -0
  21. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/date_range.py +0 -0
  22. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/email.py +0 -0
  23. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/money.py +0 -0
  24. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/multi_checkbox.py +0 -0
  25. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/number.py +0 -0
  26. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/number_range.py +0 -0
  27. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/organization.py +0 -0
  28. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/phone_number.py +0 -0
  29. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/radio.py +0 -0
  30. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/staff.py +0 -0
  31. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/string.py +0 -0
  32. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/tree.py +0 -0
  33. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/codecs/url.py +0 -0
  34. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/config.py +0 -0
  35. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/const.py +0 -0
  36. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/__init__.py +0 -0
  37. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/abstract.py +0 -0
  38. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/executor.py +0 -0
  39. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/headers.py +0 -0
  40. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/import_session.py +0 -0
  41. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/rendering.py +0 -0
  42. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/rows.py +0 -0
  43. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/schema.py +0 -0
  44. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/storage.py +0 -0
  45. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/storage_minio.py +0 -0
  46. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/storage_protocol.py +0 -0
  47. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/table.py +0 -0
  48. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/core/writer.py +0 -0
  49. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/exc.py +0 -0
  50. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/exceptions.py +0 -0
  51. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/header_models.py +0 -0
  52. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/helper/__init__.py +0 -0
  53. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/helper/pydantic.py +0 -0
  54. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/i18n/__init__.py +0 -0
  55. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/i18n/messages.py +0 -0
  56. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/identity.py +0 -0
  57. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/py.typed +0 -0
  58. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/__init__.py +0 -0
  59. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/abstract.py +0 -0
  60. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/alchemy.py +0 -0
  61. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/field.py +0 -0
  62. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/header.py +0 -0
  63. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/identity.py +0 -0
  64. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/result.py +0 -0
  65. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/__init__.py +0 -0
  66. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/boolean.py +0 -0
  67. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/date.py +0 -0
  68. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/date_range.py +0 -0
  69. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/email.py +0 -0
  70. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/money.py +0 -0
  71. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/multi_checkbox.py +0 -0
  72. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/number.py +0 -0
  73. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/number_range.py +0 -0
  74. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/organization.py +0 -0
  75. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/phone_number.py +0 -0
  76. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/radio.py +0 -0
  77. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/staff.py +0 -0
  78. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/string.py +0 -0
  79. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/tree.py +0 -0
  80. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/types/value/url.py +0 -0
  81. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/util/__init__.py +0 -0
  82. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/util/converter.py +0 -0
  83. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/util/convertor.py +0 -0
  84. {excelalchemy-2.2.7 → excelalchemy-2.2.8}/src/excelalchemy/util/file.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ExcelAlchemy
3
- Version: 2.2.7
3
+ Version: 2.2.8
4
4
  Summary: Schema-driven Python library for typed Excel import/export workflows with Pydantic and locale-aware workbooks.
5
5
  Keywords: excel,openpyxl,pydantic,minio,schema
6
6
  Author: Ray
@@ -49,9 +49,9 @@ ExcelAlchemy turns Pydantic models into typed workbook contracts:
49
49
  - render workbook-facing output in `zh-CN` or `en`
50
50
  - keep storage pluggable through `ExcelStorage`
51
51
 
52
- The current stable release is `2.2.7`, which continues the 2.x line with stronger API-facing result payloads, a more complete FastAPI reference app, harder install-time smoke verification, and more consistent codec diagnostics.
52
+ The current stable release is `2.2.8`, which continues the 2.x line with a clearer integration roadmap, stronger import-failure payload smoke verification, and more direct install-time validation of the FastAPI reference app.
53
53
 
54
- [GitHub Repository](https://github.com/RayCarterLab/ExcelAlchemy) · [Full README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README.md) · [Getting Started](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) · [Result Objects](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md) · [API Response Cookbook](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md) · [Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md)
54
+ [GitHub Repository](https://github.com/RayCarterLab/ExcelAlchemy) · [Full README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README.md) · [Getting Started](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) · [Integration Roadmap](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/integration-roadmap.md) · [Result Objects](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md) · [API Response Cookbook](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md) · [Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md)
55
55
 
56
56
  ## Screenshots
57
57
 
@@ -10,9 +10,9 @@ ExcelAlchemy turns Pydantic models into typed workbook contracts:
10
10
  - render workbook-facing output in `zh-CN` or `en`
11
11
  - keep storage pluggable through `ExcelStorage`
12
12
 
13
- The current stable release is `2.2.7`, which continues the 2.x line with stronger API-facing result payloads, a more complete FastAPI reference app, harder install-time smoke verification, and more consistent codec diagnostics.
13
+ The current stable release is `2.2.8`, which continues the 2.x line with a clearer integration roadmap, stronger import-failure payload smoke verification, and more direct install-time validation of the FastAPI reference app.
14
14
 
15
- [GitHub Repository](https://github.com/RayCarterLab/ExcelAlchemy) · [Full README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README.md) · [Getting Started](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) · [Result Objects](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md) · [API Response Cookbook](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md) · [Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md)
15
+ [GitHub Repository](https://github.com/RayCarterLab/ExcelAlchemy) · [Full README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README.md) · [Getting Started](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) · [Integration Roadmap](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/integration-roadmap.md) · [Result Objects](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md) · [API Response Cookbook](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md) · [Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md)
16
16
 
17
17
  ## Screenshots
18
18
 
@@ -1,6 +1,6 @@
1
1
  """A Python Library for Reading and Writing Excel Files"""
2
2
 
3
- __version__ = '2.2.7'
3
+ __version__ = '2.2.8'
4
4
  from excelalchemy._primitives.constants import CharacterSet, DataRangeOption, DateFormat, Option
5
5
  from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning
6
6
  from excelalchemy._primitives.identity import (
@@ -0,0 +1,50 @@
1
+ """Named diagnostic loggers and helpers for developer-facing runtime output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ RUNTIME_LOGGER_NAME = 'excelalchemy.runtime'
8
+ METADATA_LOGGER_NAME = 'excelalchemy.metadata'
9
+
10
+ runtime_logger = logging.getLogger(RUNTIME_LOGGER_NAME)
11
+ metadata_logger = logging.getLogger(METADATA_LOGGER_NAME)
12
+
13
+
14
+ def log_runtime_context_replacement() -> None:
15
+ runtime_logger.warning(
16
+ 'Replacing an existing conversion context; subsequent imports will use the new runtime context.'
17
+ )
18
+
19
+
20
+ def log_runtime_exporter_inference(*, source: str) -> None:
21
+ runtime_logger.info('Inferring exporter_model from %s.', source)
22
+
23
+
24
+ def log_runtime_export_requested_in_import_mode() -> None:
25
+ runtime_logger.info('Export requested while configured in import mode; inferring exporter_model and continuing.')
26
+
27
+
28
+ def log_runtime_ignoring_unrecognized_export_keys(*, unrecognized: set[str], model_keys: list[str]) -> None:
29
+ runtime_logger.warning(
30
+ 'Ignoring export keys that are not present in the exporter model. Ignored keys: %s. Exporter model keys: %s.',
31
+ sorted(unrecognized),
32
+ model_keys,
33
+ )
34
+
35
+
36
+ def log_metadata_large_option_set(*, field_label: str, option_count: int) -> None:
37
+ metadata_logger.warning(
38
+ 'Field "%s" defines %s options. Options are intended for bounded vocabularies, so review this field if it '
39
+ 'represents a large dataset.',
40
+ field_label,
41
+ option_count,
42
+ )
43
+
44
+
45
+ def log_metadata_missing_option_id(*, option_id: str, field_label: str) -> None:
46
+ metadata_logger.warning(
47
+ 'Could not resolve option id %s for field "%s"; returning the original workbook value.',
48
+ option_id,
49
+ field_label,
50
+ )
@@ -1,4 +1,3 @@
1
- import logging
2
1
  from collections.abc import Sequence
3
2
  from typing import cast
4
3
 
@@ -8,6 +7,12 @@ from excelalchemy._primitives.constants import (
8
7
  REASON_COLUMN_KEY,
9
8
  RESULT_COLUMN_KEY,
10
9
  )
10
+ from excelalchemy._primitives.diagnostics import (
11
+ log_runtime_context_replacement,
12
+ log_runtime_export_requested_in_import_mode,
13
+ log_runtime_exporter_inference,
14
+ log_runtime_ignoring_unrecognized_export_keys,
15
+ )
11
16
  from excelalchemy._primitives.header_models import ExcelHeader
12
17
  from excelalchemy._primitives.identity import DataUrlStr, Label, UniqueKey, UniqueLabel, UrlStr
13
18
  from excelalchemy._primitives.payloads import DataConverter, ExportRowPayload
@@ -174,7 +179,7 @@ class ExcelAlchemy[
174
179
 
175
180
  def add_context(self, context: ContextT) -> None:
176
181
  if self._context is not None:
177
- logging.warning('An existing conversion context is being replaced')
182
+ log_runtime_context_replacement()
178
183
  self._context = context
179
184
  if self._last_import_session is not None:
180
185
  self._last_import_session.context = context
@@ -263,10 +268,10 @@ class ExcelAlchemy[
263
268
  if self.config.schema_options.create_importer_model and self.config.schema_options.update_importer_model:
264
269
  raise ConfigError(msg(MessageKey.EXPORTER_MODEL_INFERENCE_CONFLICT))
265
270
  if self.config.schema_options.create_importer_model:
266
- logging.info('Inferring exporter_model from create_importer_model')
271
+ log_runtime_exporter_inference(source='create_importer_model')
267
272
  return cast(type[ExportModelT], self.config.schema_options.create_importer_model)
268
273
  if self.config.schema_options.update_importer_model:
269
- logging.info('Inferring exporter_model from update_importer_model')
274
+ log_runtime_exporter_inference(source='update_importer_model')
270
275
  return cast(type[ExportModelT], self.config.schema_options.update_importer_model)
271
276
  raise ConfigError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_INFERRED))
272
277
 
@@ -285,7 +290,7 @@ class ExcelAlchemy[
285
290
  self, data: list[ExportRowPayload], keys: Sequence[str] | None = None
286
291
  ) -> tuple[WorksheetTable, bool]:
287
292
  if self.excel_mode == ExcelMode.IMPORT:
288
- logging.info('Export requested while configured in import mode; continuing with exporter_model inference')
293
+ log_runtime_export_requested_in_import_mode()
289
294
 
290
295
  input_keys = (
291
296
  list(keys)
@@ -298,9 +303,7 @@ class ExcelAlchemy[
298
303
  )
299
304
  model_keys = get_model_field_names(self.exporter_model)
300
305
  if unrecognized := (set(input_keys) - set(model_keys)):
301
- logging.warning(
302
- 'Ignoring keys not present in the exporter model: %s (model keys: %s)', unrecognized, model_keys
303
- )
306
+ log_runtime_ignoring_unrecognized_export_keys(unrecognized=unrecognized, model_keys=model_keys)
304
307
 
305
308
  selected_keys = self._select_output_excel_keys(list(set(input_keys).intersection(set(model_keys))))
306
309
  has_merged_header = self.has_merged_header(selected_keys)
@@ -2,7 +2,6 @@
2
2
 
3
3
  import copy
4
4
  import datetime
5
- import logging
6
5
  from collections.abc import Callable, Mapping, Set
7
6
  from dataclasses import dataclass, field, replace
8
7
  from functools import cached_property
@@ -25,6 +24,10 @@ from excelalchemy._primitives.constants import (
25
24
  IntStr,
26
25
  Option,
27
26
  )
27
+ from excelalchemy._primitives.diagnostics import (
28
+ log_metadata_large_option_set,
29
+ log_metadata_missing_option_id,
30
+ )
28
31
  from excelalchemy._primitives.identity import Key, Label, OptionId, UniqueKey, UniqueLabel
29
32
  from excelalchemy.codecs.base import ExcelFieldCodec, UndefinedFieldCodec
30
33
  from excelalchemy.exceptions import ConfigError, ProgrammaticError
@@ -188,22 +191,14 @@ class WorkbookPresentationMeta:
188
191
  if self.options is None:
189
192
  return {}
190
193
  if len(self.options) > MAX_OPTIONS_COUNT:
191
- logging.warning(
192
- 'Field "%s" defines %s options; please confirm that this is intentional because options are not meant for large datasets',
193
- field_label,
194
- len(self.options),
195
- )
194
+ log_metadata_large_option_set(field_label=str(field_label), option_count=len(self.options))
196
195
  return {option.id: option for option in self.options}
197
196
 
198
197
  def options_name_map(self, *, field_label: Label) -> dict[str, Option]:
199
198
  if self.options is None:
200
199
  return {}
201
200
  if len(self.options) > MAX_OPTIONS_COUNT:
202
- logging.warning(
203
- 'Field "%s" defines %s options; please confirm that this is intentional because options are not meant for large datasets',
204
- field_label,
205
- len(self.options),
206
- )
201
+ log_metadata_large_option_set(field_label=str(field_label), option_count=len(self.options))
207
202
  return {option.name: option for option in self.options}
208
203
 
209
204
  def exchange_option_ids_to_names(
@@ -220,7 +215,7 @@ class WorkbookPresentationMeta:
220
215
  try:
221
216
  option_names.append(option_id_map[normalized_id].name)
222
217
  except KeyError:
223
- logging.warning('Could not find option id %s; returning the original value', normalized_id)
218
+ log_metadata_missing_option_id(option_id=str(normalized_id), field_label=str(field_label))
224
219
  option_names.append(normalized_id)
225
220
 
226
221
  return option_names
@@ -152,6 +152,30 @@ class CellErrorMap(dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]):
152
152
  def messages_at(self, row_index: RowIndex | int, column_index: ColumnIndex | int) -> tuple[str, ...]:
153
153
  return tuple(str(error) for error in self.at(row_index, column_index))
154
154
 
155
+ def field_labels(self) -> tuple[str, ...]:
156
+ return tuple(sorted({str(error.label) for error in self.flatten()}))
157
+
158
+ def parent_labels(self) -> tuple[str, ...]:
159
+ return tuple(sorted({str(error.parent_label) for error in self.flatten() if error.parent_label is not None}))
160
+
161
+ def unique_labels(self) -> tuple[str, ...]:
162
+ return tuple(sorted({str(error.unique_label) for error in self.flatten()}))
163
+
164
+ def codes(self) -> tuple[str, ...]:
165
+ return tuple(sorted({error.code for error in self.flatten()}))
166
+
167
+ def row_indices(self) -> tuple[RowIndex, ...]:
168
+ return tuple(sorted(self.keys()))
169
+
170
+ def row_numbers_for_humans(self) -> tuple[int, ...]:
171
+ return tuple(_row_number_for_humans(row_index) for row_index in self.row_indices())
172
+
173
+ def column_indices(self) -> tuple[ColumnIndex, ...]:
174
+ return tuple(sorted({column_index for row in self.values() for column_index in row}))
175
+
176
+ def column_numbers_for_humans(self) -> tuple[int, ...]:
177
+ return tuple(_column_number_for_humans(column_index) for column_index in self.column_indices())
178
+
155
179
  def flatten(self) -> tuple[ExcelCellError, ...]:
156
180
  return tuple(error for row in self.values() for errors in row.values() for error in errors)
157
181
 
@@ -223,6 +247,32 @@ class CellErrorMap(dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]):
223
247
  )
224
248
  return tuple(sorted(summaries, key=lambda summary: summary.code))
225
249
 
250
+ def grouped_messages_by_field(self) -> dict[str, tuple[str, ...]]:
251
+ return {
252
+ summary.unique_label: tuple(
253
+ record.error.display_message
254
+ for record in self.records()
255
+ if str(record.error.unique_label) == summary.unique_label
256
+ )
257
+ for summary in self.summary_by_field()
258
+ }
259
+
260
+ def grouped_messages_by_row(self) -> dict[int, tuple[str, ...]]:
261
+ return {
262
+ int(summary.row_index): tuple(
263
+ record.error.display_message for record in self.records() if record.row_index == summary.row_index
264
+ )
265
+ for summary in self.summary_by_row()
266
+ }
267
+
268
+ def grouped_messages_by_code(self) -> dict[str, tuple[str, ...]]:
269
+ return {
270
+ summary.code: tuple(
271
+ record.error.display_message for record in self.records() if record.error.code == summary.code
272
+ )
273
+ for summary in self.summary_by_code()
274
+ }
275
+
226
276
  def to_dict(self) -> dict[int, dict[int, list[dict[str, object]]]]:
227
277
  return {
228
278
  int(row_index): {
@@ -236,6 +286,23 @@ class CellErrorMap(dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]):
236
286
  'error_count': self.error_count,
237
287
  'items': [record.to_dict() for record in self.records()],
238
288
  'by_row': self.to_dict(),
289
+ 'facets': {
290
+ 'field_labels': list(self.field_labels()),
291
+ 'parent_labels': list(self.parent_labels()),
292
+ 'unique_labels': list(self.unique_labels()),
293
+ 'codes': list(self.codes()),
294
+ 'row_numbers_for_humans': list(self.row_numbers_for_humans()),
295
+ 'column_numbers_for_humans': list(self.column_numbers_for_humans()),
296
+ },
297
+ 'grouped': {
298
+ 'messages_by_field': {
299
+ key: list(messages) for key, messages in self.grouped_messages_by_field().items()
300
+ },
301
+ 'messages_by_row': {
302
+ str(row_index): list(messages) for row_index, messages in self.grouped_messages_by_row().items()
303
+ },
304
+ 'messages_by_code': {key: list(messages) for key, messages in self.grouped_messages_by_code().items()},
305
+ },
239
306
  'summary': {
240
307
  'by_field': [summary.to_dict() for summary in self.summary_by_field()],
241
308
  'by_row': [summary.to_dict() for summary in self.summary_by_row()],
@@ -267,6 +334,32 @@ class RowIssueMap(dict[RowIndex, list[RowIssue]]):
267
334
  def messages_for_row(self, row_index: RowIndex | int) -> tuple[str, ...]:
268
335
  return tuple(str(error) for error in self.at(row_index))
269
336
 
337
+ def field_labels(self) -> tuple[str, ...]:
338
+ return tuple(sorted({str(error.label) for error in self.flatten() if isinstance(error, ExcelCellError)}))
339
+
340
+ def parent_labels(self) -> tuple[str, ...]:
341
+ return tuple(
342
+ sorted(
343
+ {
344
+ str(error.parent_label)
345
+ for error in self.flatten()
346
+ if isinstance(error, ExcelCellError) and error.parent_label is not None
347
+ }
348
+ )
349
+ )
350
+
351
+ def unique_labels(self) -> tuple[str, ...]:
352
+ return tuple(sorted({str(error.unique_label) for error in self.flatten() if isinstance(error, ExcelCellError)}))
353
+
354
+ def codes(self) -> tuple[str, ...]:
355
+ return tuple(sorted({error.code for error in self.flatten()}))
356
+
357
+ def row_indices(self) -> tuple[RowIndex, ...]:
358
+ return tuple(sorted(self.keys()))
359
+
360
+ def row_numbers_for_humans(self) -> tuple[int, ...]:
361
+ return tuple(_row_number_for_humans(row_index) for row_index in self.row_indices())
362
+
270
363
  def numbered_messages_for_row(self, row_index: RowIndex | int) -> tuple[str, ...]:
271
364
  return self.numbered_messages(self.at(row_index))
272
365
 
@@ -318,6 +411,22 @@ class RowIssueMap(dict[RowIndex, list[RowIssue]]):
318
411
  )
319
412
  return tuple(sorted(summaries, key=lambda summary: summary.code))
320
413
 
414
+ def grouped_messages_by_row(self) -> dict[int, tuple[str, ...]]:
415
+ return {
416
+ int(summary.row_index): tuple(
417
+ record.error.display_message for record in self.records() if record.row_index == summary.row_index
418
+ )
419
+ for summary in self.summary_by_row()
420
+ }
421
+
422
+ def grouped_messages_by_code(self) -> dict[str, tuple[str, ...]]:
423
+ return {
424
+ summary.code: tuple(
425
+ record.error.display_message for record in self.records() if record.error.code == summary.code
426
+ )
427
+ for summary in self.summary_by_code()
428
+ }
429
+
321
430
  def to_dict(self) -> dict[int, list[dict[str, object]]]:
322
431
  return {int(row_index): [error.to_dict() for error in errors] for row_index, errors in self.items()}
323
432
 
@@ -326,6 +435,19 @@ class RowIssueMap(dict[RowIndex, list[RowIssue]]):
326
435
  'error_count': self.error_count,
327
436
  'items': [record.to_dict() for record in self.records()],
328
437
  'by_row': self.to_dict(),
438
+ 'facets': {
439
+ 'field_labels': list(self.field_labels()),
440
+ 'parent_labels': list(self.parent_labels()),
441
+ 'unique_labels': list(self.unique_labels()),
442
+ 'codes': list(self.codes()),
443
+ 'row_numbers_for_humans': list(self.row_numbers_for_humans()),
444
+ },
445
+ 'grouped': {
446
+ 'messages_by_row': {
447
+ str(row_index): list(messages) for row_index, messages in self.grouped_messages_by_row().items()
448
+ },
449
+ 'messages_by_code': {key: list(messages) for key, messages in self.grouped_messages_by_code().items()},
450
+ },
329
451
  'summary': {
330
452
  'by_row': [summary.to_dict() for summary in self.summary_by_row()],
331
453
  'by_code': [summary.to_dict() for summary in self.summary_by_code()],
File without changes