ExcelAlchemy 2.0.0.post1__tar.gz → 2.1.0__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.0.0.post1 → excelalchemy-2.1.0}/PKG-INFO +1 -1
  2. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/__init__.py +1 -1
  3. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/config.py +103 -7
  4. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/abstract.py +3 -5
  5. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/alchemy.py +115 -206
  6. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/executor.py +40 -33
  7. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/headers.py +19 -19
  8. excelalchemy-2.1.0/src/excelalchemy/core/import_session.py +210 -0
  9. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/rendering.py +9 -5
  10. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/rows.py +16 -11
  11. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/storage.py +8 -8
  12. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/storage_minio.py +12 -11
  13. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/writer.py +35 -35
  14. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/metadata.py +302 -36
  15. excelalchemy-2.1.0/src/excelalchemy/util/converter.py +50 -0
  16. excelalchemy-2.1.0/src/excelalchemy/util/convertor.py +8 -0
  17. excelalchemy-2.0.0.post1/src/excelalchemy/util/convertor.py +0 -53
  18. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/LICENSE +0 -0
  19. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/README-pypi.md +0 -0
  20. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/pyproject.toml +0 -0
  21. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/_primitives/__init__.py +0 -0
  22. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/_primitives/constants.py +0 -0
  23. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/_primitives/deprecation.py +0 -0
  24. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/_primitives/header_models.py +0 -0
  25. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/_primitives/identity.py +0 -0
  26. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/_primitives/payloads.py +0 -0
  27. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/artifacts.py +0 -0
  28. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/__init__.py +0 -0
  29. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/base.py +0 -0
  30. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/boolean.py +0 -0
  31. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/date.py +0 -0
  32. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/date_range.py +0 -0
  33. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/email.py +0 -0
  34. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/money.py +0 -0
  35. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/multi_checkbox.py +0 -0
  36. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/number.py +0 -0
  37. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/number_range.py +0 -0
  38. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/organization.py +0 -0
  39. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/phone_number.py +0 -0
  40. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/radio.py +0 -0
  41. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/staff.py +0 -0
  42. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/string.py +0 -0
  43. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/tree.py +0 -0
  44. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/codecs/url.py +0 -0
  45. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/const.py +0 -0
  46. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/__init__.py +0 -0
  47. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/schema.py +0 -0
  48. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/storage_protocol.py +0 -0
  49. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/core/table.py +0 -0
  50. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/exc.py +0 -0
  51. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/exceptions.py +0 -0
  52. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/header_models.py +0 -0
  53. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/helper/__init__.py +0 -0
  54. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/helper/pydantic.py +0 -0
  55. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/i18n/__init__.py +0 -0
  56. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/i18n/messages.py +0 -0
  57. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/identity.py +0 -0
  58. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/py.typed +0 -0
  59. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/results.py +0 -0
  60. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/__init__.py +0 -0
  61. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/abstract.py +0 -0
  62. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/alchemy.py +0 -0
  63. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/field.py +0 -0
  64. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/header.py +0 -0
  65. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/identity.py +0 -0
  66. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/result.py +0 -0
  67. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/__init__.py +0 -0
  68. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/boolean.py +0 -0
  69. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/date.py +0 -0
  70. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/date_range.py +0 -0
  71. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/email.py +0 -0
  72. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/money.py +0 -0
  73. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/multi_checkbox.py +0 -0
  74. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/number.py +0 -0
  75. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/number_range.py +0 -0
  76. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/organization.py +0 -0
  77. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/phone_number.py +0 -0
  78. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/radio.py +0 -0
  79. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/staff.py +0 -0
  80. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/string.py +0 -0
  81. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/tree.py +0 -0
  82. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/types/value/url.py +0 -0
  83. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/util/__init__.py +0 -0
  84. {excelalchemy-2.0.0.post1 → excelalchemy-2.1.0}/src/excelalchemy/util/file.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ExcelAlchemy
3
- Version: 2.0.0.post1
3
+ Version: 2.1.0
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
@@ -1,6 +1,6 @@
1
1
  """A Python Library for Reading and Writing Excel Files"""
2
2
 
3
- __version__ = '2.0.0.post1'
3
+ __version__ = '2.1.0'
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 (
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
- from dataclasses import dataclass
6
+ from dataclasses import dataclass, field
7
7
  from enum import StrEnum
8
8
  from typing import TYPE_CHECKING, Self
9
9
 
@@ -15,7 +15,7 @@ from excelalchemy.exceptions import ConfigError
15
15
  from excelalchemy.helper.pydantic import get_model_field_names
16
16
  from excelalchemy.i18n.messages import MessageKey
17
17
  from excelalchemy.i18n.messages import message as msg
18
- from excelalchemy.util.convertor import export_data_converter, import_data_converter
18
+ from excelalchemy.util.converter import export_data_converter, import_data_converter
19
19
 
20
20
  if TYPE_CHECKING:
21
21
  from minio import Minio
@@ -34,10 +34,67 @@ class ImportMode(StrEnum):
34
34
  CREATE_OR_UPDATE = 'CREATE_OR_UPDATE'
35
35
 
36
36
 
37
+ @dataclass(slots=True, frozen=True)
38
+ class StorageOptions:
39
+ """Normalized storage backend settings shared by importer and exporter configs."""
40
+
41
+ storage: ExcelStorage | None
42
+ minio: Minio | None
43
+ bucket_name: str
44
+ url_expires: int
45
+
46
+ @property
47
+ def has_explicit_storage(self) -> bool:
48
+ return self.storage is not None
49
+
50
+ @property
51
+ def has_legacy_minio(self) -> bool:
52
+ return self.minio is not None
53
+
54
+
55
+ @dataclass(slots=True, frozen=True)
56
+ class ImporterSchemaOptions[ImportCreateModelT: BaseModel, ImportUpdateModelT: BaseModel]:
57
+ """Schema declaration and workbook presentation settings for imports."""
58
+
59
+ create_importer_model: type[ImportCreateModelT] | None
60
+ update_importer_model: type[ImportUpdateModelT] | None
61
+ sheet_name: str
62
+ locale: str
63
+
64
+
65
+ @dataclass(slots=True, frozen=True)
66
+ class ImportBehavior[ContextT]:
67
+ """Execution callbacks and import workflow policy."""
68
+
69
+ data_converter: DataConverter | None
70
+ creator: DmlCallback[ContextT] | None
71
+ updater: DmlCallback[ContextT] | None
72
+ context: ImportContext[ContextT]
73
+ is_data_exist: ExistenceCheckCallback[ContextT] | None
74
+ exec_formatter: Callable[[Exception], str]
75
+ import_mode: ImportMode
76
+
77
+
78
+ @dataclass(slots=True, frozen=True)
79
+ class ExporterSchemaOptions[ExportModelT: BaseModel]:
80
+ """Schema declaration and workbook presentation settings for exports."""
81
+
82
+ exporter_model: type[ExportModelT]
83
+ sheet_name: str
84
+ locale: str
85
+
86
+
87
+ @dataclass(slots=True, frozen=True)
88
+ class ExportBehavior:
89
+ """Execution behavior used when rendering export rows."""
90
+
91
+ data_converter: DataConverter | None
92
+
93
+
37
94
  @dataclass(slots=True)
38
- class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateModelT: BaseModel]:
39
- create_importer_model: type[ImporterCreateModelT] | None = None
40
- update_importer_model: type[ImporterUpdateModelT] | None = None
95
+ class ImporterConfig[ContextT, ImportCreateModelT: BaseModel, ImportUpdateModelT: BaseModel]:
96
+ create_importer_model: type[ImportCreateModelT] | None = None
97
+ update_importer_model: type[ImportUpdateModelT] | None = None
41
98
 
42
99
  # The converter receives schema keys rather than workbook labels.
43
100
  data_converter: DataConverter | None = import_data_converter
@@ -57,6 +114,9 @@ class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateMo
57
114
  locale: str = 'zh-CN'
58
115
 
59
116
  sheet_name: str = 'Sheet1'
117
+ schema_options: ImporterSchemaOptions[ImportCreateModelT, ImportUpdateModelT] = field(init=False, repr=False)
118
+ behavior: ImportBehavior[ContextT] = field(init=False, repr=False)
119
+ storage_options: StorageOptions = field(init=False, repr=False)
60
120
 
61
121
  def validate_model(self) -> Self:
62
122
  if self.import_mode not in ImportMode.__members__.values():
@@ -100,11 +160,32 @@ class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateMo
100
160
 
101
161
  def __post_init__(self) -> None:
102
162
  self.validate_model()
163
+ self.schema_options = ImporterSchemaOptions(
164
+ create_importer_model=self.create_importer_model,
165
+ update_importer_model=self.update_importer_model,
166
+ sheet_name=self.sheet_name,
167
+ locale=self.locale,
168
+ )
169
+ self.behavior = ImportBehavior(
170
+ data_converter=self.data_converter,
171
+ creator=self.creator,
172
+ updater=self.updater,
173
+ context=self.context,
174
+ is_data_exist=self.is_data_exist,
175
+ exec_formatter=self.exec_formatter,
176
+ import_mode=self.import_mode,
177
+ )
178
+ self.storage_options = StorageOptions(
179
+ storage=self.storage,
180
+ minio=self.minio,
181
+ bucket_name=self.bucket_name,
182
+ url_expires=self.url_expires,
183
+ )
103
184
 
104
185
 
105
186
  @dataclass(slots=True)
106
- class ExporterConfig[ExporterModelT: BaseModel]:
107
- exporter_model: type[ExporterModelT]
187
+ class ExporterConfig[ExportModelT: BaseModel]:
188
+ exporter_model: type[ExportModelT]
108
189
  # The converter receives schema keys rather than workbook labels.
109
190
  data_converter: DataConverter | None = export_data_converter
110
191
 
@@ -115,6 +196,9 @@ class ExporterConfig[ExporterModelT: BaseModel]:
115
196
  locale: str = 'zh-CN'
116
197
 
117
198
  sheet_name: str = 'Sheet1'
199
+ schema_options: ExporterSchemaOptions[ExportModelT] = field(init=False, repr=False)
200
+ behavior: ExportBehavior = field(init=False, repr=False)
201
+ storage_options: StorageOptions = field(init=False, repr=False)
118
202
 
119
203
  def validate_model(self) -> Self:
120
204
  if not self.exporter_model:
@@ -123,3 +207,15 @@ class ExporterConfig[ExporterModelT: BaseModel]:
123
207
 
124
208
  def __post_init__(self) -> None:
125
209
  self.validate_model()
210
+ self.schema_options = ExporterSchemaOptions(
211
+ exporter_model=self.exporter_model,
212
+ sheet_name=self.sheet_name,
213
+ locale=self.locale,
214
+ )
215
+ self.behavior = ExportBehavior(data_converter=self.data_converter)
216
+ self.storage_options = StorageOptions(
217
+ storage=self.storage,
218
+ minio=self.minio,
219
+ bucket_name=self.bucket_name,
220
+ url_expires=self.url_expires,
221
+ )
@@ -11,11 +11,9 @@ from excelalchemy.results import ImportResult
11
11
 
12
12
  class ABCExcelAlchemy[
13
13
  ContextT,
14
- ImporterCreateModelT: BaseModel,
15
- ImporterUpdateModelT: BaseModel,
16
- CreateModelT: BaseModel,
17
- UpdateModelT: BaseModel,
18
- ExporterModelT: BaseModel,
14
+ ImportCreateModelT: BaseModel,
15
+ ImportUpdateModelT: BaseModel,
16
+ ExportModelT: BaseModel,
19
17
  ](ABC):
20
18
  @abstractmethod
21
19
  def download_template(self, sample_data: list[ExportRowPayload] | None = None) -> DataUrlStr:
@@ -1,6 +1,5 @@
1
1
  import logging
2
- from collections.abc import Iterable, Sequence
3
- from functools import cached_property
2
+ from collections.abc import Sequence
4
3
  from typing import cast
5
4
 
6
5
  from pydantic import BaseModel
@@ -10,16 +9,15 @@ from excelalchemy._primitives.constants import (
10
9
  RESULT_COLUMN_KEY,
11
10
  )
12
11
  from excelalchemy._primitives.header_models import ExcelHeader
13
- from excelalchemy._primitives.identity import DataUrlStr, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr
14
- from excelalchemy._primitives.payloads import DataConverter, ExportRowPayload, FlatRowPayload, ModelRowPayload
12
+ from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr
13
+ from excelalchemy._primitives.payloads import DataConverter, ExportRowPayload
15
14
  from excelalchemy.artifacts import ExcelArtifact
16
15
  from excelalchemy.codecs.base import SystemReserved
17
16
  from excelalchemy.config import ExcelMode, ExporterConfig, ImporterConfig, ImportMode
18
17
  from excelalchemy.core.abstract import ABCExcelAlchemy
19
- from excelalchemy.core.executor import ImportExecutor
20
18
  from excelalchemy.core.headers import ExcelHeaderParser, ExcelHeaderValidator
19
+ from excelalchemy.core.import_session import ImportSession, build_import_result_field_meta
21
20
  from excelalchemy.core.rendering import ExcelRenderer
22
- from excelalchemy.core.rows import ImportIssueTracker, RowAggregator
23
21
  from excelalchemy.core.schema import ExcelSchemaLayout
24
22
  from excelalchemy.core.storage import build_storage_gateway
25
23
  from excelalchemy.core.storage_protocol import ExcelStorage
@@ -30,7 +28,7 @@ from excelalchemy.i18n.messages import MessageKey, use_display_locale
30
28
  from excelalchemy.i18n.messages import display_message as dmsg
31
29
  from excelalchemy.i18n.messages import message as msg
32
30
  from excelalchemy.metadata import FieldMetaInfo
33
- from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult
31
+ from excelalchemy.results import ImportResult
34
32
  from excelalchemy.util.file import flatten
35
33
 
36
34
  HEADER_HINT_LINE_COUNT = 1
@@ -48,33 +46,30 @@ REASON_COLUMN.excel_codec = SystemReserved
48
46
 
49
47
  class ExcelAlchemy[
50
48
  ContextT,
51
- ImporterCreateModelT: BaseModel,
52
- ImporterUpdateModelT: BaseModel,
53
- CreateModelT: BaseModel,
54
- UpdateModelT: BaseModel,
55
- ExporterModelT: BaseModel,
49
+ ImportCreateModelT: BaseModel,
50
+ ImportUpdateModelT: BaseModel,
51
+ ExportModelT: BaseModel,
56
52
  ](
57
53
  ABCExcelAlchemy[
58
54
  ContextT,
59
- ImporterCreateModelT,
60
- ImporterUpdateModelT,
61
- CreateModelT,
62
- UpdateModelT,
63
- ExporterModelT,
55
+ ImportCreateModelT,
56
+ ImportUpdateModelT,
57
+ ExportModelT,
64
58
  ]
65
59
  ):
66
60
  def __init__(
67
61
  self,
68
- config: ImporterConfig[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | ExporterConfig[ExporterModelT],
62
+ config: ImporterConfig[ContextT, ImportCreateModelT, ImportUpdateModelT] | ExporterConfig[ExportModelT],
69
63
  ):
70
- self.df = WorksheetTable()
71
- self.header_df = WorksheetTable()
64
+ runtime_config = cast(object, config)
65
+ if not isinstance(runtime_config, (ImporterConfig, ExporterConfig)):
66
+ raise ConfigError(msg(MessageKey.EXPORT_MODE_CONFIG_REQUIRED, config_name=ExporterConfig.__name__))
72
67
  self.config = config
73
- self.context: ContextT | None = None
74
- self.locale = getattr(config, 'locale', 'zh-CN')
75
- self.__state_df_has_been_loaded__ = False
68
+ self.locale = config.schema_options.locale
69
+ self._context: ContextT | None = getattr(config.behavior, 'context', None)
70
+ self._last_import_session: ImportSession[ContextT, ImportCreateModelT, ImportUpdateModelT] | None = None
76
71
 
77
- self.import_result_field_meta = self._build_import_result_field_meta()
72
+ self.import_result_field_meta = build_import_result_field_meta(locale=self.locale)
78
73
  self.import_result_label_to_field_meta = {
79
74
  field_meta.unique_label: field_meta for field_meta in self.import_result_field_meta
80
75
  }
@@ -84,27 +79,15 @@ class ExcelAlchemy[
84
79
  self._renderer = ExcelRenderer()
85
80
  self._storage_gateway: ExcelStorage = build_storage_gateway(config)
86
81
  self._layout: ExcelSchemaLayout
87
- self._issue_tracker: ImportIssueTracker | None = None
88
- self._row_aggregator: RowAggregator | None = None
89
- self._executor: ImportExecutor[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | None = None
90
82
 
91
83
  self.__init_from_config__()
92
84
 
93
85
  def __init_from_config__(self) -> None:
94
- self.context = getattr(self.config, 'context', None)
95
86
  model = self.__get_importer_model__()
96
87
  with use_display_locale(self.locale):
97
88
  self._layout = ExcelSchemaLayout.from_model(model)
98
89
  self.__sync_layout_state__()
99
90
 
100
- self._issue_tracker = ImportIssueTracker(self._layout, self.import_result_field_meta)
101
- self.cell_errors = self._issue_tracker.cell_errors
102
- self.row_errors = self._issue_tracker.row_errors
103
-
104
- if isinstance(self.config, ImporterConfig):
105
- self._row_aggregator = RowAggregator(self._layout, self.config.import_mode)
106
- self._executor = ImportExecutor(self.config, self._issue_tracker, lambda: self.context)
107
-
108
91
  def __sync_layout_state__(self) -> None:
109
92
  self.field_metas = self._layout.field_metas
110
93
  self.unique_label_to_field_meta = self._layout.unique_label_to_field_meta
@@ -113,34 +96,19 @@ class ExcelAlchemy[
113
96
  self.unique_key_to_field_meta = self._layout.unique_key_to_field_meta
114
97
  self.ordered_field_meta = self._layout.ordered_field_meta
115
98
 
116
- def _reset_import_runtime_state(self) -> None:
117
- self.df = WorksheetTable()
118
- self.header_df = WorksheetTable()
119
- self.__state_df_has_been_loaded__ = False
120
- runtime_state = vars(self)
121
- runtime_state.pop('input_excel_has_merged_header', None)
122
- runtime_state.pop('input_excel_headers', None)
123
-
124
- self._issue_tracker = ImportIssueTracker(self._layout, self.import_result_field_meta)
125
- self.cell_errors = self._issue_tracker.cell_errors
126
- self.row_errors = self._issue_tracker.row_errors
127
-
128
- if isinstance(self.config, ImporterConfig):
129
- self._executor = ImportExecutor(self.config, self._issue_tracker, lambda: self.context)
130
-
131
- def __get_importer_model__(self) -> type[ImporterCreateModelT] | type[ImporterUpdateModelT] | type[ExporterModelT]:
99
+ def __get_importer_model__(self) -> type[ImportCreateModelT] | type[ImportUpdateModelT] | type[ExportModelT]:
132
100
  importer_model = None
133
101
  if self.excel_mode == ExcelMode.IMPORT:
134
102
  if not isinstance(self.config, ImporterConfig):
135
103
  raise ConfigError(msg(MessageKey.IMPORT_MODE_CONFIG_REQUIRED, config_name=ImporterConfig.__name__))
136
- if self.config.import_mode in (ImportMode.CREATE, ImportMode.CREATE_OR_UPDATE):
137
- importer_model = self.config.create_importer_model # type: ignore[assignment]
138
- elif self.config.import_mode == ImportMode.UPDATE:
139
- importer_model = self.config.update_importer_model # type: ignore[assignment]
104
+ if self.config.behavior.import_mode in (ImportMode.CREATE, ImportMode.CREATE_OR_UPDATE):
105
+ importer_model = self.config.schema_options.create_importer_model # type: ignore[assignment]
106
+ elif self.config.behavior.import_mode == ImportMode.UPDATE:
107
+ importer_model = self.config.schema_options.update_importer_model # type: ignore[assignment]
140
108
  elif self.excel_mode == ExcelMode.EXPORT:
141
109
  if not isinstance(self.config, ExporterConfig):
142
110
  raise ConfigError(msg(MessageKey.EXPORT_MODE_CONFIG_REQUIRED, config_name=ExporterConfig.__name__))
143
- importer_model = self.config.exporter_model # type: ignore[assignment]
111
+ importer_model = self.config.schema_options.exporter_model # type: ignore[assignment]
144
112
 
145
113
  if importer_model is None:
146
114
  raise ConfigError(msg(MessageKey.NO_IMPORTER_OR_EXPORTER_MODEL_CONFIGURED))
@@ -154,11 +122,13 @@ class ExcelAlchemy[
154
122
  keys = self._select_output_excel_keys()
155
123
  has_merged_header = self.has_merged_header(keys)
156
124
  if has_merged_header:
157
- df = self._export_with_merged_header(sample_data, keys)
125
+ worksheet_table = self._export_with_merged_header(sample_data, keys)
158
126
  else:
159
- df = self._export_with_simple_header(sample_data, keys)
127
+ worksheet_table = self._export_with_simple_header(sample_data, keys)
160
128
  return self._renderer.render_template(
161
- df, self.unique_label_to_field_meta, has_merged_header=has_merged_header
129
+ worksheet_table,
130
+ self.unique_label_to_field_meta,
131
+ has_merged_header=has_merged_header,
162
132
  )
163
133
 
164
134
  def download_template_artifact(
@@ -174,46 +144,15 @@ class ExcelAlchemy[
174
144
  if self.excel_mode != ExcelMode.IMPORT:
175
145
  raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_METHOD))
176
146
 
177
- self._reset_import_runtime_state()
178
- assert self._executor is not None
179
-
180
- with use_display_locale(self.locale):
181
- validate_header = self._validate_header(input_excel_name)
182
- if not validate_header.is_valid:
183
- return ImportResult.from_validate_header_result(validate_header)
184
-
185
- self.df = self.df.iloc[1:]
186
- self._set_columns(self.df)
187
- self.df = self.df.reset_index(drop=True)
188
-
189
- all_success, success_count, fail_count = True, 0, 0
190
- for table_row_index in range(self.extra_header_count_on_import, len(self.df)):
191
- row = self.df.row_at(table_row_index)
192
- aggregate_data = self._aggregate_data(cast(FlatRowPayload, row.to_dict()))
193
- success = await self._executor.execute(RowIndex(table_row_index), aggregate_data, self.df)
194
- all_success = all_success and success
195
- success_count, fail_count = (
196
- (success_count + 1, fail_count) if success else (success_count, fail_count + 1)
197
- )
198
-
199
- url = None
200
- if not all_success:
201
- self._add_result_column()
202
- content_with_prefix = self._render_import_result_excel()
203
- url = self._upload_file(output_excel_name, content_with_prefix)
204
-
205
- return ImportResult(
206
- result=(ValidateResult.DATA_INVALID, ValidateResult.SUCCESS)[int(all_success)],
207
- url=url,
208
- success_count=success_count,
209
- fail_count=fail_count,
210
- )
147
+ session = self._new_import_session()
148
+ self._last_import_session = session
149
+ return await session.run(input_excel_name, output_excel_name)
211
150
 
212
151
  def export(self, data: list[ExportRowPayload], keys: Sequence[str] | None = None) -> DataUrlStr:
213
152
  with use_display_locale(self.locale):
214
- df, has_merged_header = self._gen_export_df(data, keys)
153
+ worksheet_table, has_merged_header = self._gen_export_df(data, keys)
215
154
  return self._renderer.render_data(
216
- df,
155
+ worksheet_table,
217
156
  field_meta_mapping=self.unique_label_to_field_meta,
218
157
  has_merged_header=has_merged_header,
219
158
  errors={},
@@ -234,21 +173,57 @@ class ExcelAlchemy[
234
173
  return self._upload_file(output_name, self.export(data, keys))
235
174
 
236
175
  def add_context(self, context: ContextT) -> None:
237
- if self.context is not None:
176
+ if self._context is not None:
238
177
  logging.warning('An existing conversion context is being replaced')
239
- self.context = context
178
+ self._context = context
179
+ if self._last_import_session is not None:
180
+ self._last_import_session.context = context
181
+
182
+ @property
183
+ def context(self) -> ContextT | None:
184
+ return self._context
185
+
186
+ @property
187
+ def df(self) -> WorksheetTable:
188
+ """Backward-compatible alias for worksheet_table."""
189
+ if self._last_import_session is None:
190
+ return WorksheetTable()
191
+ return self._last_import_session.worksheet_table
240
192
 
241
- @cached_property
193
+ @property
194
+ def worksheet_table(self) -> WorksheetTable:
195
+ return self.df
196
+
197
+ @property
198
+ def header_df(self) -> WorksheetTable:
199
+ """Backward-compatible alias for header_table."""
200
+ if self._last_import_session is None:
201
+ return WorksheetTable()
202
+ return self._last_import_session.header_table
203
+
204
+ @property
205
+ def header_table(self) -> WorksheetTable:
206
+ return self.header_df
207
+
208
+ @property
209
+ def cell_errors(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]:
210
+ if self._last_import_session is None:
211
+ return {}
212
+ return self._last_import_session.cell_errors
213
+
214
+ @property
215
+ def row_errors(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]:
216
+ if self._last_import_session is None:
217
+ return {}
218
+ return self._last_import_session.row_errors
219
+
220
+ @property
242
221
  def input_excel_has_merged_header(self) -> bool:
243
- if not self.__state_df_has_been_loaded__:
244
- raise ConfigError(msg(MessageKey.WORKSHEET_TABLE_NOT_LOADED))
245
- return self._header_parser.has_merged_header(self.header_df)
222
+ return self._require_last_import_session().input_excel_has_merged_header
246
223
 
247
- @cached_property
224
+ @property
248
225
  def input_excel_headers(self) -> list[ExcelHeader]:
249
- if not self.__state_df_has_been_loaded__:
250
- raise ConfigError(msg(MessageKey.WORKSHEET_TABLE_NOT_LOADED))
251
- return self._header_parser.extract(self.header_df)
226
+ return self._require_last_import_session().input_excel_headers
252
227
 
253
228
  @property
254
229
  def excel_mode(self) -> ExcelMode:
@@ -260,26 +235,22 @@ class ExcelAlchemy[
260
235
  def extra_header_count_on_import(self) -> int:
261
236
  if self.excel_mode != ExcelMode.IMPORT:
262
237
  raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_PROPERTY))
263
-
264
- for input_excel_label in self.input_excel_headers:
265
- if input_excel_label.label != input_excel_label.parent_label:
266
- return 1
267
- return 0
238
+ return self._require_last_import_session().extra_header_count_on_import
268
239
 
269
240
  @property
270
- def exporter_model(self) -> type[ExporterModelT]:
241
+ def exporter_model(self) -> type[ExportModelT]:
271
242
  if isinstance(self.config, ImporterConfig):
272
- if self.config.create_importer_model and self.config.update_importer_model:
243
+ if self.config.schema_options.create_importer_model and self.config.schema_options.update_importer_model:
273
244
  raise ConfigError(msg(MessageKey.EXPORTER_MODEL_INFERENCE_CONFLICT))
274
- if self.config.create_importer_model:
245
+ if self.config.schema_options.create_importer_model:
275
246
  logging.info('Inferring exporter_model from create_importer_model')
276
- return cast(type[ExporterModelT], self.config.create_importer_model)
277
- if self.config.update_importer_model:
247
+ return cast(type[ExportModelT], self.config.schema_options.create_importer_model)
248
+ if self.config.schema_options.update_importer_model:
278
249
  logging.info('Inferring exporter_model from update_importer_model')
279
- return cast(type[ExporterModelT], self.config.update_importer_model)
250
+ return cast(type[ExportModelT], self.config.schema_options.update_importer_model)
280
251
  raise ConfigError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_INFERRED))
281
252
 
282
- return self.config.exporter_model
253
+ return self.config.schema_options.exporter_model
283
254
 
284
255
  def has_merged_header(self, selected_keys: list[UniqueKey]) -> bool:
285
256
  return self._layout.has_merged_header(selected_keys)
@@ -314,51 +285,18 @@ class ExcelAlchemy[
314
285
  selected_keys = self._select_output_excel_keys(list(set(input_keys).intersection(set(model_keys))))
315
286
  has_merged_header = self.has_merged_header(selected_keys)
316
287
  if has_merged_header:
317
- df = self._export_with_merged_header(data, selected_keys, self.config.data_converter)
288
+ worksheet_table = self._export_with_merged_header(data, selected_keys, self.config.behavior.data_converter)
318
289
  else:
319
- df = self._export_with_simple_header(data, selected_keys, self.config.data_converter)
320
- return df, has_merged_header
321
-
322
- def _validate_header(self, input_excel_name: str) -> ValidateHeaderResult:
323
- if self.excel_mode != ExcelMode.IMPORT:
324
- raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_METHOD))
325
- assert isinstance(self.config, ImporterConfig)
326
- self._read_dataframe(input_excel_name)
327
- return self._header_validator.validate(self.input_excel_headers, self._layout, self.config.import_mode)
328
-
329
- def _render_import_result_excel(self) -> DataUrlStr:
330
- return self._renderer.render_data(
331
- self.df,
332
- field_meta_mapping=self.import_result_label_to_field_meta | self.unique_label_to_field_meta,
333
- has_merged_header=self.input_excel_has_merged_header,
334
- errors=self.cell_errors,
335
- )
336
-
337
- def _upload_file(self, output_name: str, content_with_prefix: DataUrlStr) -> UrlStr:
338
- return self._storage_gateway.upload_excel(output_name, content_with_prefix)
339
-
340
- def _order_errors(self, errors: list[ExcelRowError | ExcelCellError]) -> Iterable[ExcelCellError | ExcelRowError]:
341
- return self._layout.order_errors(errors)
342
-
343
- def _set_columns(self, df: WorksheetTable) -> WorksheetTable:
344
- return self._header_parser.apply_columns(df, self.input_excel_headers, self.get_output_parent_excel_headers())
290
+ worksheet_table = self._export_with_simple_header(
291
+ data,
292
+ selected_keys,
293
+ self.config.behavior.data_converter,
294
+ )
295
+ return worksheet_table, has_merged_header
345
296
 
346
297
  def _select_output_excel_keys(self, keys: Sequence[str] | None = None) -> list[UniqueKey]:
347
298
  return self._layout.select_output_excel_keys(keys)
348
299
 
349
- def _read_dataframe(self, input_excel_name: str) -> WorksheetTable:
350
- assert isinstance(self.config, ImporterConfig)
351
- if not self.__state_df_has_been_loaded__:
352
- df = self._storage_gateway.read_excel_table(
353
- input_excel_name,
354
- skiprows=HEADER_HINT_LINE_COUNT,
355
- sheet_name=self.config.sheet_name,
356
- )
357
- self.df = df
358
- self.header_df = df.head(2)
359
- self.__state_df_has_been_loaded__ = True
360
- return self.df
361
-
362
300
  def _generate_export_df(
363
301
  self,
364
302
  records: list[ExportRowPayload] | None,
@@ -396,57 +334,28 @@ class ExcelAlchemy[
396
334
  ) -> WorksheetTable:
397
335
  return self._generate_export_df(records, selected_keys, data_converter)
398
336
 
399
- def _add_result_column(self):
400
- assert self._issue_tracker is not None
401
- self._issue_tracker.add_result_columns(
402
- self.df,
403
- result_unique_label=self.import_result_field_meta[0].unique_label,
404
- reason_unique_label=self.import_result_field_meta[1].unique_label,
405
- extra_header_count_on_import=self.extra_header_count_on_import,
337
+ def _new_import_session(self) -> ImportSession[ContextT, ImportCreateModelT, ImportUpdateModelT]:
338
+ assert isinstance(self.config, ImporterConfig)
339
+ return ImportSession(
340
+ config=self.config,
341
+ layout=self._layout,
342
+ storage_gateway=self._storage_gateway,
343
+ header_parser=self._header_parser,
344
+ header_validator=self._header_validator,
345
+ renderer=self._renderer,
346
+ import_result_field_meta=self.import_result_field_meta,
347
+ import_result_label_to_field_meta=self.import_result_label_to_field_meta,
348
+ locale=self.locale,
349
+ context=self._context,
406
350
  )
407
- return self
408
-
409
- def _aggregate_data(self, row_data: FlatRowPayload) -> ModelRowPayload:
410
- assert self._row_aggregator is not None
411
- return self._row_aggregator.aggregate(row_data)
412
-
413
- def _register_row_error(
414
- self,
415
- row_index: RowIndex,
416
- error: ExcelRowError | ExcelCellError | list[ExcelRowError | ExcelCellError] | list[ExcelCellError],
417
- ):
418
- assert self._issue_tracker is not None
419
- self._issue_tracker.register_row_error(row_index, error)
420
-
421
- def _register_cell_errors(self, row_index: RowIndex, errors: list[ExcelCellError]):
422
- assert self._issue_tracker is not None
423
- self._issue_tracker.register_cell_errors(row_index, errors, self.df)
424
- return self
425
-
426
- def _build_import_result_field_meta(self) -> list[FieldMetaInfo]:
427
- result_column = FieldMetaInfo(label=dmsg(MessageKey.RESULT_COLUMN_LABEL, locale=self.locale))
428
- result_column.parent_label = result_column.label
429
- result_column.key = result_column.parent_key = RESULT_COLUMN_KEY
430
- result_column.excel_codec = SystemReserved
431
351
 
432
- reason_column = FieldMetaInfo(label=dmsg(MessageKey.REASON_COLUMN_LABEL, locale=self.locale))
433
- reason_column.parent_label = reason_column.label
434
- reason_column.key = reason_column.parent_key = REASON_COLUMN_KEY
435
- reason_column.excel_codec = SystemReserved
436
-
437
- return [result_column, reason_column]
438
-
439
- def _excel_has_merged_header(self) -> bool:
440
- return self._header_parser.has_merged_header(self.header_df)
441
-
442
- def _extract_header(self) -> list[ExcelHeader]:
443
- return self._header_parser.extract(self.header_df)
444
-
445
- def _extract_simple_header(self) -> list[ExcelHeader]:
446
- return self._header_parser.extract_simple(self.header_df)
352
+ def _require_last_import_session(self) -> ImportSession[ContextT, ImportCreateModelT, ImportUpdateModelT]:
353
+ if self._last_import_session is None:
354
+ raise ConfigError(msg(MessageKey.WORKSHEET_TABLE_NOT_LOADED))
355
+ return self._last_import_session
447
356
 
448
- def _extract_merged_header(self) -> list[ExcelHeader]:
449
- return self._header_parser.extract_merged(self.header_df)
357
+ def _upload_file(self, output_name: str, content_with_prefix: DataUrlStr) -> UrlStr:
358
+ return self._storage_gateway.upload_excel(output_name, content_with_prefix)
450
359
 
451
360
  def __setattr__(self, key: str, value: object):
452
361
  if key == 'config' and hasattr(self, 'config'):