ExcelAlchemy 2.2.4__tar.gz → 2.2.5__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 (85) hide show
  1. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/PKG-INFO +43 -2
  2. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/README-pypi.md +42 -1
  3. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/__init__.py +11 -2
  4. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/date_range.py +4 -1
  5. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/multi_checkbox.py +10 -4
  6. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/organization.py +2 -1
  7. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/staff.py +2 -1
  8. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/alchemy.py +9 -9
  9. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/headers.py +2 -3
  10. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/import_session.py +16 -10
  11. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/rows.py +10 -14
  12. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/schema.py +8 -2
  13. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/writer.py +9 -3
  14. excelalchemy-2.2.5/src/excelalchemy/exceptions.py +145 -0
  15. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/helper/pydantic.py +50 -5
  16. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/i18n/messages.py +15 -0
  17. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/metadata.py +9 -3
  18. excelalchemy-2.2.5/src/excelalchemy/results.py +173 -0
  19. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/util/converter.py +2 -3
  20. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/util/file.py +21 -5
  21. excelalchemy-2.2.4/src/excelalchemy/exceptions.py +0 -80
  22. excelalchemy-2.2.4/src/excelalchemy/results.py +0 -91
  23. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/LICENSE +0 -0
  24. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/pyproject.toml +0 -0
  25. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/_primitives/__init__.py +0 -0
  26. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/_primitives/constants.py +0 -0
  27. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/_primitives/deprecation.py +0 -0
  28. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/_primitives/header_models.py +0 -0
  29. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/_primitives/identity.py +0 -0
  30. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/_primitives/payloads.py +0 -0
  31. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/artifacts.py +0 -0
  32. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/__init__.py +0 -0
  33. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/base.py +0 -0
  34. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/boolean.py +0 -0
  35. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/date.py +0 -0
  36. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/email.py +0 -0
  37. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/money.py +0 -0
  38. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/number.py +0 -0
  39. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/number_range.py +0 -0
  40. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/phone_number.py +0 -0
  41. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/radio.py +0 -0
  42. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/string.py +0 -0
  43. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/tree.py +0 -0
  44. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/codecs/url.py +0 -0
  45. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/config.py +0 -0
  46. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/const.py +0 -0
  47. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/__init__.py +0 -0
  48. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/abstract.py +0 -0
  49. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/executor.py +0 -0
  50. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/rendering.py +0 -0
  51. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/storage.py +0 -0
  52. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/storage_minio.py +0 -0
  53. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/storage_protocol.py +0 -0
  54. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/core/table.py +0 -0
  55. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/exc.py +0 -0
  56. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/header_models.py +0 -0
  57. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/helper/__init__.py +0 -0
  58. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/i18n/__init__.py +0 -0
  59. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/identity.py +0 -0
  60. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/py.typed +0 -0
  61. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/__init__.py +0 -0
  62. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/abstract.py +0 -0
  63. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/alchemy.py +0 -0
  64. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/field.py +0 -0
  65. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/header.py +0 -0
  66. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/identity.py +0 -0
  67. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/result.py +0 -0
  68. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/__init__.py +0 -0
  69. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/boolean.py +0 -0
  70. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/date.py +0 -0
  71. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/date_range.py +0 -0
  72. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/email.py +0 -0
  73. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/money.py +0 -0
  74. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/multi_checkbox.py +0 -0
  75. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/number.py +0 -0
  76. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/number_range.py +0 -0
  77. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/organization.py +0 -0
  78. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/phone_number.py +0 -0
  79. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/radio.py +0 -0
  80. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/staff.py +0 -0
  81. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/string.py +0 -0
  82. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/tree.py +0 -0
  83. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/types/value/url.py +0 -0
  84. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/util/__init__.py +0 -0
  85. {excelalchemy-2.2.4 → excelalchemy-2.2.5}/src/excelalchemy/util/convertor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ExcelAlchemy
3
- Version: 2.2.4
3
+ Version: 2.2.5
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,7 +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
- [GitHub Repository](https://github.com/RayCarterLab/ExcelAlchemy) · [Full README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md)
52
+ The current stable release is `2.2.5`, which continues the 2.x line with richer import-failure feedback, clearer documentation entry points, stronger examples, and stronger smoke coverage.
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) · [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)
53
55
 
54
56
  ## Screenshots
55
57
 
@@ -114,6 +116,45 @@ alchemy = ExcelAlchemy(ImporterConfig(Importer, locale='en'))
114
116
  template = alchemy.download_template_artifact(filename='people-template.xlsx')
115
117
  ```
116
118
 
119
+ ## Example Outputs
120
+
121
+ These fixed outputs are generated from the repository examples by
122
+ [`scripts/generate_example_output_assets.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/scripts/generate_example_output_assets.py).
123
+
124
+ Import workflow:
125
+
126
+ ```text
127
+ Employee import workflow completed
128
+ Result: SUCCESS
129
+ Success rows: 1
130
+ Failed rows: 0
131
+ Result workbook URL: None
132
+ Created rows: 1
133
+ Uploaded artifacts: []
134
+ ```
135
+
136
+ Export workflow:
137
+
138
+ ```text
139
+ Export workflow completed
140
+ Artifact filename: employees-export.xlsx
141
+ Artifact bytes: 6893
142
+ Upload URL: memory://employees-export-upload.xlsx
143
+ Uploaded objects: ['employees-export-upload.xlsx']
144
+ ```
145
+
146
+ Full captured outputs:
147
+
148
+ - [employee-import-workflow.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/employee-import-workflow.txt)
149
+ - [create-or-update-import.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/create-or-update-import.txt)
150
+ - [export-workflow.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/export-workflow.txt)
151
+ - [date-and-range-fields.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/date-and-range-fields.txt)
152
+ - [selection-fields.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/selection-fields.txt)
153
+
154
+ For a single GitHub page that combines screenshots, representative workflows,
155
+ and captured outputs, see the
156
+ [Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md).
157
+
117
158
  ## Why ExcelAlchemy
118
159
 
119
160
  - Pydantic v2-based schema extraction and validation
@@ -10,7 +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
- [GitHub Repository](https://github.com/RayCarterLab/ExcelAlchemy) · [Full README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md)
13
+ The current stable release is `2.2.5`, which continues the 2.x line with richer import-failure feedback, clearer documentation entry points, stronger examples, and stronger smoke coverage.
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) · [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)
14
16
 
15
17
  ## Screenshots
16
18
 
@@ -75,6 +77,45 @@ alchemy = ExcelAlchemy(ImporterConfig(Importer, locale='en'))
75
77
  template = alchemy.download_template_artifact(filename='people-template.xlsx')
76
78
  ```
77
79
 
80
+ ## Example Outputs
81
+
82
+ These fixed outputs are generated from the repository examples by
83
+ [`scripts/generate_example_output_assets.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/scripts/generate_example_output_assets.py).
84
+
85
+ Import workflow:
86
+
87
+ ```text
88
+ Employee import workflow completed
89
+ Result: SUCCESS
90
+ Success rows: 1
91
+ Failed rows: 0
92
+ Result workbook URL: None
93
+ Created rows: 1
94
+ Uploaded artifacts: []
95
+ ```
96
+
97
+ Export workflow:
98
+
99
+ ```text
100
+ Export workflow completed
101
+ Artifact filename: employees-export.xlsx
102
+ Artifact bytes: 6893
103
+ Upload URL: memory://employees-export-upload.xlsx
104
+ Uploaded objects: ['employees-export-upload.xlsx']
105
+ ```
106
+
107
+ Full captured outputs:
108
+
109
+ - [employee-import-workflow.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/employee-import-workflow.txt)
110
+ - [create-or-update-import.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/create-or-update-import.txt)
111
+ - [export-workflow.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/export-workflow.txt)
112
+ - [date-and-range-fields.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/date-and-range-fields.txt)
113
+ - [selection-fields.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/selection-fields.txt)
114
+
115
+ For a single GitHub page that combines screenshots, representative workflows,
116
+ and captured outputs, see the
117
+ [Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md).
118
+
78
119
  ## Why ExcelAlchemy
79
120
 
80
121
  - Pydantic v2-based schema extraction and validation
@@ -1,6 +1,6 @@
1
1
  """A Python Library for Reading and Writing Excel Files"""
2
2
 
3
- __version__ = '2.2.4'
3
+ __version__ = '2.2.5'
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 (
@@ -48,13 +48,21 @@ from excelalchemy.core.storage_protocol import ExcelStorage
48
48
  from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError, ProgrammaticError
49
49
  from excelalchemy.helper.pydantic import extract_pydantic_model
50
50
  from excelalchemy.metadata import ExcelMeta, FieldMeta, PatchFieldMeta
51
- from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult, ValidateRowResult
51
+ from excelalchemy.results import (
52
+ CellErrorMap,
53
+ ImportResult,
54
+ RowIssueMap,
55
+ ValidateHeaderResult,
56
+ ValidateResult,
57
+ ValidateRowResult,
58
+ )
52
59
  from excelalchemy.util.file import flatten
53
60
 
54
61
  __all__ = [
55
62
  'Base64Str',
56
63
  'Boolean',
57
64
  'BooleanCodec',
65
+ 'CellErrorMap',
58
66
  'ColumnIndex',
59
67
  'CompositeExcelFieldCodec',
60
68
  'ConfigError',
@@ -104,6 +112,7 @@ __all__ = [
104
112
  'ProgrammaticError',
105
113
  'Radio',
106
114
  'RowIndex',
115
+ 'RowIssueMap',
107
116
  'SingleChoiceCodec',
108
117
  'SingleOrganization',
109
118
  'SingleOrganizationCodec',
@@ -10,6 +10,7 @@ from pydantic import BaseModel
10
10
  from excelalchemy._primitives.constants import DATE_FORMAT_TO_PYTHON_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption
11
11
  from excelalchemy._primitives.identity import Key
12
12
  from excelalchemy.codecs.base import CompositeExcelFieldCodec
13
+ from excelalchemy.exceptions import ConfigError
13
14
  from excelalchemy.i18n.messages import MessageKey
14
15
  from excelalchemy.i18n.messages import display_message as dmsg
15
16
  from excelalchemy.i18n.messages import message as msg
@@ -53,7 +54,9 @@ class DateRange(CompositeExcelFieldCodec):
53
54
  declared = field_meta.declared
54
55
  presentation = field_meta.presentation
55
56
  if presentation.date_format is None:
56
- raise RuntimeError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED))
57
+ raise ConfigError(
58
+ msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED), message_key=MessageKey.DATE_FORMAT_NOT_CONFIGURED
59
+ )
57
60
 
58
61
  return '\n'.join(
59
62
  [
@@ -14,6 +14,12 @@ from excelalchemy.metadata import FieldMetaInfo
14
14
  class MultiCheckbox(ExcelFieldCodec, list[str]):
15
15
  __name__ = 'MultiChoice'
16
16
 
17
+ @staticmethod
18
+ def _coerce_items(value: object) -> list[object] | None:
19
+ if not isinstance(value, list):
20
+ return None
21
+ return cast(list[object], value)
22
+
17
23
  @classmethod
18
24
  def build_comment(cls, field_meta: FieldMetaInfo) -> str:
19
25
  declared = field_meta.declared
@@ -29,8 +35,8 @@ class MultiCheckbox(ExcelFieldCodec, list[str]):
29
35
 
30
36
  @classmethod
31
37
  def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> list[str] | object:
32
- if isinstance(value, list):
33
- items = cast(list[object], value)
38
+ items = cls._coerce_items(value)
39
+ if items is not None:
34
40
  return [str(item).strip() for item in items]
35
41
 
36
42
  if isinstance(value, str):
@@ -45,10 +51,10 @@ class MultiCheckbox(ExcelFieldCodec, list[str]):
45
51
  def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> list[str]: # OptionId
46
52
  declared = field_meta.declared
47
53
  presentation = field_meta.presentation
48
- if not isinstance(value, list):
54
+ items = cls._coerce_items(value)
55
+ if items is None:
49
56
  raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT))
50
57
 
51
- items = cast(list[object], value)
52
58
  parsed = [str(item).strip() for item in items]
53
59
 
54
60
  if presentation.options is None:
@@ -74,7 +74,8 @@ class MultiOrganization(MultiCheckbox):
74
74
  return value
75
75
 
76
76
  if isinstance(value, list):
77
- items = cast(list[object], value)
77
+ items = MultiOrganization._coerce_items(cast(object, value))
78
+ assert items is not None
78
79
  option_ids = [OptionId(option_id) for option_id in items]
79
80
  option_names = presentation.exchange_option_ids_to_names(option_ids, field_label=declared.label)
80
81
  return MULTI_CHECKBOX_SEPARATOR.join(map(str, option_names))
@@ -80,7 +80,8 @@ class MultiStaff(MultiCheckbox):
80
80
  return value
81
81
 
82
82
  if isinstance(value, list):
83
- items = cast(list[object], value)
83
+ items = MultiStaff._coerce_items(cast(object, value))
84
+ assert items is not None
84
85
  option_ids = [OptionId(option_id) for option_id in items]
85
86
  if len(option_ids) != len(set(option_ids)):
86
87
  raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES))
@@ -9,7 +9,7 @@ from excelalchemy._primitives.constants import (
9
9
  RESULT_COLUMN_KEY,
10
10
  )
11
11
  from excelalchemy._primitives.header_models import ExcelHeader
12
- from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr
12
+ from excelalchemy._primitives.identity import DataUrlStr, Label, UniqueKey, UniqueLabel, UrlStr
13
13
  from excelalchemy._primitives.payloads import DataConverter, ExportRowPayload
14
14
  from excelalchemy.artifacts import ExcelArtifact
15
15
  from excelalchemy.codecs.base import SystemReserved
@@ -22,13 +22,13 @@ from excelalchemy.core.schema import ExcelSchemaLayout
22
22
  from excelalchemy.core.storage import build_storage_gateway
23
23
  from excelalchemy.core.storage_protocol import ExcelStorage
24
24
  from excelalchemy.core.table import WorksheetTable
25
- from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError
25
+ from excelalchemy.exceptions import ConfigError
26
26
  from excelalchemy.helper.pydantic import get_model_field_names
27
27
  from excelalchemy.i18n.messages import MessageKey, use_display_locale
28
28
  from excelalchemy.i18n.messages import display_message as dmsg
29
29
  from excelalchemy.i18n.messages import message as msg
30
30
  from excelalchemy.metadata import FieldMetaInfo
31
- from excelalchemy.results import ImportResult
31
+ from excelalchemy.results import CellErrorMap, ImportResult, RowIssueMap
32
32
  from excelalchemy.util.file import flatten
33
33
 
34
34
  HEADER_HINT_LINE_COUNT = 1
@@ -210,24 +210,24 @@ class ExcelAlchemy[
210
210
  return self._last_import_session.header_table
211
211
 
212
212
  @property
213
- def cell_error_map(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]:
213
+ def cell_error_map(self) -> CellErrorMap:
214
214
  if self._last_import_session is None:
215
- return {}
215
+ return CellErrorMap()
216
216
  return self._last_import_session.cell_error_map
217
217
 
218
218
  @property
219
- def row_error_map(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]:
219
+ def row_error_map(self) -> RowIssueMap:
220
220
  if self._last_import_session is None:
221
- return {}
221
+ return RowIssueMap()
222
222
  return self._last_import_session.row_error_map
223
223
 
224
224
  @property
225
- def cell_errors(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]:
225
+ def cell_errors(self) -> CellErrorMap:
226
226
  """Backward-compatible alias for cell_error_map."""
227
227
  return self.cell_error_map
228
228
 
229
229
  @property
230
- def row_errors(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]:
230
+ def row_errors(self) -> RowIssueMap:
231
231
  """Backward-compatible alias for row_error_map."""
232
232
  return self.row_error_map
233
233
 
@@ -1,7 +1,6 @@
1
1
  """Header parsing and validation helpers for import workbooks."""
2
2
 
3
3
  from collections.abc import Container, Sequence
4
- from typing import cast
5
4
 
6
5
  from excelalchemy._primitives.header_models import ExcelHeader
7
6
  from excelalchemy._primitives.identity import Label, UniqueLabel
@@ -129,12 +128,12 @@ class ExcelHeaderValidator:
129
128
 
130
129
  @staticmethod
131
130
  def _ordered_difference[T](values: Sequence[T], allowed: Container[T]) -> list[T]:
132
- seen: set[Label] = set()
131
+ seen: set[T] = set()
133
132
  result: list[T] = []
134
133
  for value in values:
135
134
  if value in allowed or value in seen:
136
135
  continue
137
- seen.add(cast(Label, value))
136
+ seen.add(value)
138
137
  result.append(value)
139
138
  return result
140
139
 
@@ -5,13 +5,12 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass, replace
6
6
  from enum import StrEnum
7
7
  from functools import cached_property
8
- from typing import cast
9
8
 
10
9
  from pydantic import BaseModel
11
10
 
12
11
  from excelalchemy._primitives.constants import REASON_COLUMN_KEY, RESULT_COLUMN_KEY
13
12
  from excelalchemy._primitives.header_models import ExcelHeader
14
- from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, RowIndex, UniqueLabel, UrlStr
13
+ from excelalchemy._primitives.identity import DataUrlStr, RowIndex, UniqueLabel, UrlStr
15
14
  from excelalchemy._primitives.payloads import FlatRowPayload, ModelRowPayload
16
15
  from excelalchemy.codecs.base import SystemReserved
17
16
  from excelalchemy.config import ImporterConfig
@@ -21,13 +20,13 @@ from excelalchemy.core.rendering import ExcelRenderer
21
20
  from excelalchemy.core.rows import ImportIssueTracker, RowAggregator
22
21
  from excelalchemy.core.schema import ExcelSchemaLayout
23
22
  from excelalchemy.core.storage_protocol import ExcelStorage
24
- from excelalchemy.core.table import WorksheetTable
25
- from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError
23
+ from excelalchemy.core.table import WorksheetRow, WorksheetTable
24
+ from excelalchemy.exceptions import ConfigError
26
25
  from excelalchemy.i18n.messages import MessageKey, use_display_locale
27
26
  from excelalchemy.i18n.messages import display_message as dmsg
28
27
  from excelalchemy.i18n.messages import message as msg
29
28
  from excelalchemy.metadata import FieldMetaInfo
30
- from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult
29
+ from excelalchemy.results import CellErrorMap, ImportResult, RowIssueMap, ValidateHeaderResult, ValidateResult
31
30
 
32
31
  HEADER_HINT_LINE_COUNT = 1
33
32
 
@@ -104,20 +103,20 @@ class ImportSession[
104
103
  self._snapshot = ImportSessionSnapshot()
105
104
 
106
105
  @property
107
- def cell_error_map(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]:
106
+ def cell_error_map(self) -> CellErrorMap:
108
107
  return self.issue_tracker.cell_errors
109
108
 
110
109
  @property
111
- def row_error_map(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]:
110
+ def row_error_map(self) -> RowIssueMap:
112
111
  return self.issue_tracker.row_errors
113
112
 
114
113
  @property
115
- def cell_errors(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]:
114
+ def cell_errors(self) -> CellErrorMap:
116
115
  """Backward-compatible alias for cell_error_map."""
117
116
  return self.cell_error_map
118
117
 
119
118
  @property
120
- def row_errors(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]:
119
+ def row_errors(self) -> RowIssueMap:
121
120
  """Backward-compatible alias for row_error_map."""
122
121
  return self.row_error_map
123
122
 
@@ -240,7 +239,7 @@ class ImportSession[
240
239
  processed_row_count = 0
241
240
  for table_row_index in range(self.extra_header_count_on_import, len(self.worksheet_table)):
242
241
  row = self.worksheet_table.row_at(table_row_index)
243
- aggregate_data = self._aggregate_data(cast(FlatRowPayload, row.to_dict()))
242
+ aggregate_data = self._aggregate_data(self._row_payload(row))
244
243
  success = await self.executor.execute(RowIndex(table_row_index), aggregate_data, self.worksheet_table)
245
244
  processed_row_count += 1
246
245
  all_success = all_success and success
@@ -265,6 +264,13 @@ class ImportSession[
265
264
  def _aggregate_data(self, row_data: FlatRowPayload) -> ModelRowPayload:
266
265
  return self.row_aggregator.aggregate(row_data)
267
266
 
267
+ @staticmethod
268
+ def _row_payload(row: WorksheetRow) -> FlatRowPayload:
269
+ payload: FlatRowPayload = {}
270
+ for key, value in row.items():
271
+ payload[str(key)] = value
272
+ return payload
273
+
268
274
  def _render_import_result_excel(self) -> DataUrlStr:
269
275
  return self.renderer.render_data(
270
276
  self.worksheet_table,
@@ -1,8 +1,6 @@
1
1
  """Row aggregation and import issue tracking helpers."""
2
2
 
3
- from collections import defaultdict
4
3
  from collections.abc import Iterator
5
- from typing import cast
6
4
 
7
5
  from excelalchemy._primitives.identity import ColumnIndex, Key, RowIndex, UniqueLabel
8
6
  from excelalchemy._primitives.payloads import AggregatedRowPayload, ModelRowPayload, RowPayloadLike
@@ -11,7 +9,7 @@ from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError
11
9
  from excelalchemy.i18n.messages import MessageKey
12
10
  from excelalchemy.i18n.messages import message as msg
13
11
  from excelalchemy.metadata import FieldMetaInfo
14
- from excelalchemy.results import ValidateRowResult
12
+ from excelalchemy.results import CellErrorMap, RowIssueMap, ValidateRowResult
15
13
  from excelalchemy.util.file import value_is_nan
16
14
 
17
15
  from .schema import ExcelSchemaLayout
@@ -76,8 +74,8 @@ class ImportIssueTracker:
76
74
  def __init__(self, layout: ExcelSchemaLayout, import_result_field_meta: list[FieldMetaInfo]):
77
75
  self.layout = layout
78
76
  self.import_result_field_meta = import_result_field_meta
79
- self.cell_errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]] = {}
80
- self.row_errors: dict[RowIndex, list[ExcelRowError | ExcelCellError]] = defaultdict(list)
77
+ self.cell_errors = CellErrorMap()
78
+ self.row_errors = RowIssueMap()
81
79
 
82
80
  def register_row_error(
83
81
  self,
@@ -86,9 +84,9 @@ class ImportIssueTracker:
86
84
  ) -> None:
87
85
  """Record one row-level issue or a batch of issues for the same row."""
88
86
  if isinstance(error, list):
89
- self.row_errors[row_index].extend(error)
87
+ self.row_errors.add_many(row_index, error)
90
88
  else:
91
- self.row_errors[row_index].append(error)
89
+ self.row_errors.add(row_index, error)
92
90
 
93
91
  def register_cell_errors(
94
92
  self,
@@ -99,8 +97,8 @@ class ImportIssueTracker:
99
97
  """Map cell errors from schema labels to rendered workbook coordinates."""
100
98
  for error in errors:
101
99
  for index in self._column_indices(worksheet_table, error.unique_label):
102
- column_index = cast(ColumnIndex, index + len(self.import_result_field_meta))
103
- self.cell_errors.setdefault(row_index, {}).setdefault(column_index, []).append(error)
100
+ column_index = ColumnIndex(index + len(self.import_result_field_meta))
101
+ self.cell_errors.add(row_index, column_index, error)
104
102
 
105
103
  def add_result_columns(
106
104
  self,
@@ -115,17 +113,15 @@ class ImportIssueTracker:
115
113
  reason: list[str] = []
116
114
 
117
115
  for index in worksheet_table.index[extra_header_count_on_import:]:
118
- row_errors = self.row_errors.get(RowIndex(index))
116
+ row_errors = self.row_errors.at(RowIndex(index))
119
117
  if not row_errors:
120
118
  result.append(str(ValidateRowResult.SUCCESS))
121
119
  reason.append('')
122
120
  continue
123
121
 
124
122
  result.append(str(ValidateRowResult.FAIL))
125
- numbered_reasons = [
126
- f'{idx}、{error!s}' for idx, error in enumerate(self.layout.order_errors(row_errors), start=1)
127
- ]
128
- reason.append('\n'.join(numbered_reasons))
123
+ ordered_errors = list(self.layout.order_errors(list(row_errors)))
124
+ reason.append('\n'.join(self.row_errors.numbered_messages(ordered_errors)))
129
125
 
130
126
  if extra_header_count_on_import == 1:
131
127
  result = [str(result_unique_label), *result]
@@ -5,7 +5,6 @@ from collections import defaultdict
5
5
  from collections.abc import Iterable, Sequence
6
6
  from decimal import Decimal
7
7
  from itertools import chain
8
- from typing import cast
9
8
 
10
9
  from pydantic import BaseModel
11
10
 
@@ -88,11 +87,18 @@ class ExcelSchemaLayout:
88
87
  return sorted(
89
88
  field_metas,
90
89
  key=lambda x: (
91
- orders.get(cast(Label, x.runtime.parent_label), Decimal('Infinity')),
90
+ cls._parent_order(orders, x),
92
91
  x.runtime.offset,
93
92
  ),
94
93
  )
95
94
 
95
+ @staticmethod
96
+ def _parent_order(orders: dict[Label, int], field_meta: FieldMetaInfo) -> int | Decimal:
97
+ parent_label = field_meta.runtime.parent_label
98
+ if parent_label is None:
99
+ return Decimal('Infinity')
100
+ return orders.get(parent_label, Decimal('Infinity'))
101
+
96
102
  def has_merged_header(self, selected_keys: list[UniqueKey]) -> bool:
97
103
  """Return whether the selected keys need a two-row merged header."""
98
104
  return any(
@@ -22,7 +22,7 @@ from excelalchemy._primitives.constants import (
22
22
  )
23
23
  from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, Label, RowIndex, UniqueLabel
24
24
  from excelalchemy.core.table import WorksheetTable, WorksheetValue
25
- from excelalchemy.exceptions import ExcelCellError
25
+ from excelalchemy.exceptions import ExcelCellError, ProgrammaticError
26
26
  from excelalchemy.i18n.messages import MessageKey
27
27
  from excelalchemy.i18n.messages import display_message as dmsg
28
28
  from excelalchemy.i18n.messages import message as msg
@@ -167,7 +167,10 @@ def _write_horizontally_merged_header(
167
167
  declared = field_meta.declared
168
168
  runtime = field_meta.runtime
169
169
  if runtime.parent_label is None:
170
- raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME))
170
+ raise ProgrammaticError(
171
+ msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME),
172
+ message_key=MessageKey.PARENT_LABEL_EMPTY_RUNTIME,
173
+ )
171
174
  counter[runtime.parent_label] += 1
172
175
 
173
176
  for openpyxl_col_index, column in enumerate(
@@ -178,7 +181,10 @@ def _write_horizontally_merged_header(
178
181
  declared = field_meta.declared
179
182
  runtime = field_meta.runtime
180
183
  if runtime.parent_label is None:
181
- raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME))
184
+ raise ProgrammaticError(
185
+ msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME),
186
+ message_key=MessageKey.PARENT_LABEL_EMPTY_RUNTIME,
187
+ )
182
188
  if declared.label != runtime.parent_label and runtime.offset == 0:
183
189
  cell = _worksheet_cell(worksheet, row=start_row, column=openpyxl_col_index)
184
190
  cell.value = str(runtime.parent_label)