ExcelAlchemy 2.1.0__tar.gz → 2.2.2__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 (83) hide show
  1. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/PKG-INFO +1 -1
  2. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/__init__.py +1 -1
  3. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/config.py +188 -0
  4. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/alchemy.py +7 -1
  5. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/import_session.py +115 -18
  6. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/helper/pydantic.py +13 -17
  7. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/metadata.py +51 -39
  8. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/LICENSE +0 -0
  9. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/README-pypi.md +0 -0
  10. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/pyproject.toml +0 -0
  11. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/_primitives/__init__.py +0 -0
  12. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/_primitives/constants.py +0 -0
  13. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/_primitives/deprecation.py +0 -0
  14. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/_primitives/header_models.py +0 -0
  15. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/_primitives/identity.py +0 -0
  16. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/_primitives/payloads.py +0 -0
  17. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/artifacts.py +0 -0
  18. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/__init__.py +0 -0
  19. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/base.py +0 -0
  20. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/boolean.py +0 -0
  21. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/date.py +0 -0
  22. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/date_range.py +0 -0
  23. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/email.py +0 -0
  24. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/money.py +0 -0
  25. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/multi_checkbox.py +0 -0
  26. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/number.py +0 -0
  27. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/number_range.py +0 -0
  28. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/organization.py +0 -0
  29. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/phone_number.py +0 -0
  30. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/radio.py +0 -0
  31. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/staff.py +0 -0
  32. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/string.py +0 -0
  33. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/tree.py +0 -0
  34. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/codecs/url.py +0 -0
  35. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/const.py +0 -0
  36. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/__init__.py +0 -0
  37. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/abstract.py +0 -0
  38. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/executor.py +0 -0
  39. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/headers.py +0 -0
  40. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/rendering.py +0 -0
  41. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/rows.py +0 -0
  42. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/schema.py +0 -0
  43. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/storage.py +0 -0
  44. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/storage_minio.py +0 -0
  45. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/storage_protocol.py +0 -0
  46. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/table.py +0 -0
  47. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/core/writer.py +0 -0
  48. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/exc.py +0 -0
  49. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/exceptions.py +0 -0
  50. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/header_models.py +0 -0
  51. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/helper/__init__.py +0 -0
  52. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/i18n/__init__.py +0 -0
  53. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/i18n/messages.py +0 -0
  54. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/identity.py +0 -0
  55. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/py.typed +0 -0
  56. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/results.py +0 -0
  57. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/__init__.py +0 -0
  58. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/abstract.py +0 -0
  59. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/alchemy.py +0 -0
  60. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/field.py +0 -0
  61. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/header.py +0 -0
  62. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/identity.py +0 -0
  63. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/result.py +0 -0
  64. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/__init__.py +0 -0
  65. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/boolean.py +0 -0
  66. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/date.py +0 -0
  67. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/date_range.py +0 -0
  68. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/email.py +0 -0
  69. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/money.py +0 -0
  70. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/multi_checkbox.py +0 -0
  71. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/number.py +0 -0
  72. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/number_range.py +0 -0
  73. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/organization.py +0 -0
  74. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/phone_number.py +0 -0
  75. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/radio.py +0 -0
  76. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/staff.py +0 -0
  77. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/string.py +0 -0
  78. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/tree.py +0 -0
  79. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/types/value/url.py +0 -0
  80. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/util/__init__.py +0 -0
  81. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/util/converter.py +0 -0
  82. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/util/convertor.py +0 -0
  83. {excelalchemy-2.1.0 → excelalchemy-2.2.2}/src/excelalchemy/util/file.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ExcelAlchemy
3
- Version: 2.1.0
3
+ Version: 2.2.2
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.1.0'
3
+ __version__ = '2.2.2'
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 (
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import warnings
5
6
  from collections.abc import Callable
6
7
  from dataclasses import dataclass, field
7
8
  from enum import StrEnum
@@ -9,6 +10,7 @@ from typing import TYPE_CHECKING, Self
9
10
 
10
11
  from pydantic import BaseModel
11
12
 
13
+ from excelalchemy._primitives.deprecation import DEPRECATION_REMOVAL_VERSION, ExcelAlchemyDeprecationWarning
12
14
  from excelalchemy._primitives.payloads import DataConverter, DmlCallback, ExistenceCheckCallback, ImportContext
13
15
  from excelalchemy.core.storage_protocol import ExcelStorage
14
16
  from excelalchemy.exceptions import ConfigError
@@ -21,6 +23,9 @@ if TYPE_CHECKING:
21
23
  from minio import Minio
22
24
 
23
25
 
26
+ _EMITTED_STORAGE_DEPRECATION_WARNINGS: set[bool] = set()
27
+
28
+
24
29
  class ExcelMode(StrEnum):
25
30
  """Top-level Excel workflow mode."""
26
31
 
@@ -51,6 +56,10 @@ class StorageOptions:
51
56
  def has_legacy_minio(self) -> bool:
52
57
  return self.minio is not None
53
58
 
59
+ @property
60
+ def uses_legacy_minio_path(self) -> bool:
61
+ return self.storage is None and self.minio is not None
62
+
54
63
 
55
64
  @dataclass(slots=True, frozen=True)
56
65
  class ImporterSchemaOptions[ImportCreateModelT: BaseModel, ImportUpdateModelT: BaseModel]:
@@ -118,6 +127,116 @@ class ImporterConfig[ContextT, ImportCreateModelT: BaseModel, ImportUpdateModelT
118
127
  behavior: ImportBehavior[ContextT] = field(init=False, repr=False)
119
128
  storage_options: StorageOptions = field(init=False, repr=False)
120
129
 
130
+ @classmethod
131
+ def for_create(
132
+ cls,
133
+ importer_model: type[ImportCreateModelT],
134
+ *,
135
+ data_converter: DataConverter | None = import_data_converter,
136
+ creator: DmlCallback[ContextT] | None = None,
137
+ updater: DmlCallback[ContextT] | None = None,
138
+ context: ImportContext[ContextT] = None,
139
+ is_data_exist: ExistenceCheckCallback[ContextT] | None = None,
140
+ exec_formatter: Callable[[Exception], str] = str,
141
+ storage: ExcelStorage | None = None,
142
+ minio: Minio | None = None,
143
+ bucket_name: str = 'excel',
144
+ url_expires: int = 3600,
145
+ locale: str = 'zh-CN',
146
+ sheet_name: str = 'Sheet1',
147
+ ) -> Self:
148
+ """Build a create-mode importer config through the recommended constructor."""
149
+ return cls(
150
+ create_importer_model=importer_model,
151
+ data_converter=data_converter,
152
+ creator=creator,
153
+ updater=updater,
154
+ context=context,
155
+ is_data_exist=is_data_exist,
156
+ exec_formatter=exec_formatter,
157
+ import_mode=ImportMode.CREATE,
158
+ storage=storage,
159
+ minio=minio,
160
+ bucket_name=bucket_name,
161
+ url_expires=url_expires,
162
+ locale=locale,
163
+ sheet_name=sheet_name,
164
+ )
165
+
166
+ @classmethod
167
+ def for_update(
168
+ cls,
169
+ importer_model: type[ImportUpdateModelT],
170
+ *,
171
+ data_converter: DataConverter | None = import_data_converter,
172
+ creator: DmlCallback[ContextT] | None = None,
173
+ updater: DmlCallback[ContextT] | None = None,
174
+ context: ImportContext[ContextT] = None,
175
+ is_data_exist: ExistenceCheckCallback[ContextT] | None = None,
176
+ exec_formatter: Callable[[Exception], str] = str,
177
+ storage: ExcelStorage | None = None,
178
+ minio: Minio | None = None,
179
+ bucket_name: str = 'excel',
180
+ url_expires: int = 3600,
181
+ locale: str = 'zh-CN',
182
+ sheet_name: str = 'Sheet1',
183
+ ) -> Self:
184
+ """Build an update-mode importer config through the recommended constructor."""
185
+ return cls(
186
+ update_importer_model=importer_model,
187
+ data_converter=data_converter,
188
+ creator=creator,
189
+ updater=updater,
190
+ context=context,
191
+ is_data_exist=is_data_exist,
192
+ exec_formatter=exec_formatter,
193
+ import_mode=ImportMode.UPDATE,
194
+ storage=storage,
195
+ minio=minio,
196
+ bucket_name=bucket_name,
197
+ url_expires=url_expires,
198
+ locale=locale,
199
+ sheet_name=sheet_name,
200
+ )
201
+
202
+ @classmethod
203
+ def for_create_or_update(
204
+ cls,
205
+ *,
206
+ create_importer_model: type[ImportCreateModelT],
207
+ update_importer_model: type[ImportUpdateModelT],
208
+ is_data_exist: ExistenceCheckCallback[ContextT],
209
+ data_converter: DataConverter | None = import_data_converter,
210
+ creator: DmlCallback[ContextT] | None = None,
211
+ updater: DmlCallback[ContextT] | None = None,
212
+ context: ImportContext[ContextT] = None,
213
+ exec_formatter: Callable[[Exception], str] = str,
214
+ storage: ExcelStorage | None = None,
215
+ minio: Minio | None = None,
216
+ bucket_name: str = 'excel',
217
+ url_expires: int = 3600,
218
+ locale: str = 'zh-CN',
219
+ sheet_name: str = 'Sheet1',
220
+ ) -> Self:
221
+ """Build a create-or-update importer config through the recommended constructor."""
222
+ return cls(
223
+ create_importer_model=create_importer_model,
224
+ update_importer_model=update_importer_model,
225
+ data_converter=data_converter,
226
+ creator=creator,
227
+ updater=updater,
228
+ context=context,
229
+ is_data_exist=is_data_exist,
230
+ exec_formatter=exec_formatter,
231
+ import_mode=ImportMode.CREATE_OR_UPDATE,
232
+ storage=storage,
233
+ minio=minio,
234
+ bucket_name=bucket_name,
235
+ url_expires=url_expires,
236
+ locale=locale,
237
+ sheet_name=sheet_name,
238
+ )
239
+
121
240
  def validate_model(self) -> Self:
122
241
  if self.import_mode not in ImportMode.__members__.values():
123
242
  raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode))
@@ -181,6 +300,8 @@ class ImporterConfig[ContextT, ImportCreateModelT: BaseModel, ImportUpdateModelT
181
300
  bucket_name=self.bucket_name,
182
301
  url_expires=self.url_expires,
183
302
  )
303
+ if self.storage_options.has_legacy_minio:
304
+ _warn_legacy_storage_path(has_explicit_storage=self.storage_options.has_explicit_storage)
184
305
 
185
306
 
186
307
  @dataclass(slots=True)
@@ -200,6 +321,50 @@ class ExporterConfig[ExportModelT: BaseModel]:
200
321
  behavior: ExportBehavior = field(init=False, repr=False)
201
322
  storage_options: StorageOptions = field(init=False, repr=False)
202
323
 
324
+ @classmethod
325
+ def for_model(
326
+ cls,
327
+ exporter_model: type[ExportModelT],
328
+ *,
329
+ data_converter: DataConverter | None = export_data_converter,
330
+ storage: ExcelStorage | None = None,
331
+ minio: Minio | None = None,
332
+ bucket_name: str = 'excel',
333
+ url_expires: int = 3600,
334
+ locale: str = 'zh-CN',
335
+ sheet_name: str = 'Sheet1',
336
+ ) -> Self:
337
+ """Build an exporter config through the recommended constructor."""
338
+ return cls(
339
+ exporter_model=exporter_model,
340
+ data_converter=data_converter,
341
+ storage=storage,
342
+ minio=minio,
343
+ bucket_name=bucket_name,
344
+ url_expires=url_expires,
345
+ locale=locale,
346
+ sheet_name=sheet_name,
347
+ )
348
+
349
+ @classmethod
350
+ def for_storage(
351
+ cls,
352
+ exporter_model: type[ExportModelT],
353
+ *,
354
+ storage: ExcelStorage,
355
+ data_converter: DataConverter | None = export_data_converter,
356
+ locale: str = 'zh-CN',
357
+ sheet_name: str = 'Sheet1',
358
+ ) -> Self:
359
+ """Build an exporter config for the recommended explicit-storage path."""
360
+ return cls.for_model(
361
+ exporter_model,
362
+ data_converter=data_converter,
363
+ storage=storage,
364
+ locale=locale,
365
+ sheet_name=sheet_name,
366
+ )
367
+
203
368
  def validate_model(self) -> Self:
204
369
  if not self.exporter_model:
205
370
  raise ValueError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_EMPTY))
@@ -219,3 +384,26 @@ class ExporterConfig[ExportModelT: BaseModel]:
219
384
  bucket_name=self.bucket_name,
220
385
  url_expires=self.url_expires,
221
386
  )
387
+ if self.storage_options.has_legacy_minio:
388
+ _warn_legacy_storage_path(has_explicit_storage=self.storage_options.has_explicit_storage)
389
+
390
+
391
+ def _warn_legacy_storage_path(*, has_explicit_storage: bool) -> None:
392
+ """Emit a deprecation warning for the legacy built-in Minio config path."""
393
+ if has_explicit_storage in _EMITTED_STORAGE_DEPRECATION_WARNINGS:
394
+ return
395
+ _EMITTED_STORAGE_DEPRECATION_WARNINGS.add(has_explicit_storage)
396
+
397
+ detail = (
398
+ ' The explicit `storage=` backend will be used.'
399
+ if has_explicit_storage
400
+ else ' Prefer passing `storage=` with `MinioStorageGateway` or a custom `ExcelStorage` implementation.'
401
+ )
402
+ warnings.warn(
403
+ (
404
+ '`minio`, `bucket_name`, and `url_expires` are deprecated configuration fields and will be removed in '
405
+ f'ExcelAlchemy {DEPRECATION_REMOVAL_VERSION}.{detail}'
406
+ ),
407
+ category=ExcelAlchemyDeprecationWarning,
408
+ stacklevel=3,
409
+ )
@@ -16,7 +16,7 @@ from excelalchemy.codecs.base import SystemReserved
16
16
  from excelalchemy.config import ExcelMode, ExporterConfig, ImporterConfig, ImportMode
17
17
  from excelalchemy.core.abstract import ABCExcelAlchemy
18
18
  from excelalchemy.core.headers import ExcelHeaderParser, ExcelHeaderValidator
19
- from excelalchemy.core.import_session import ImportSession, build_import_result_field_meta
19
+ from excelalchemy.core.import_session import ImportSession, ImportSessionSnapshot, build_import_result_field_meta
20
20
  from excelalchemy.core.rendering import ExcelRenderer
21
21
  from excelalchemy.core.schema import ExcelSchemaLayout
22
22
  from excelalchemy.core.storage import build_storage_gateway
@@ -217,6 +217,12 @@ class ExcelAlchemy[
217
217
  return {}
218
218
  return self._last_import_session.row_errors
219
219
 
220
+ @property
221
+ def last_import_snapshot(self) -> ImportSessionSnapshot | None:
222
+ if self._last_import_session is None:
223
+ return None
224
+ return self._last_import_session.snapshot
225
+
220
226
  @property
221
227
  def input_excel_has_merged_header(self) -> bool:
222
228
  return self._require_last_import_session().input_excel_has_merged_header
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from dataclasses import dataclass, replace
6
+ from enum import StrEnum
5
7
  from functools import cached_property
6
8
  from typing import cast
7
9
 
@@ -30,6 +32,34 @@ from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateRes
30
32
  HEADER_HINT_LINE_COUNT = 1
31
33
 
32
34
 
35
+ class ImportSessionPhase(StrEnum):
36
+ """High-level lifecycle phase for a one-shot import session."""
37
+
38
+ INITIALIZED = 'INITIALIZED'
39
+ WORKBOOK_LOADED = 'WORKBOOK_LOADED'
40
+ HEADERS_VALIDATED = 'HEADERS_VALIDATED'
41
+ ROWS_PREPARED = 'ROWS_PREPARED'
42
+ ROWS_EXECUTED = 'ROWS_EXECUTED'
43
+ RESULT_RENDERED = 'RESULT_RENDERED'
44
+ COMPLETED = 'COMPLETED'
45
+
46
+
47
+ @dataclass(slots=True, frozen=True)
48
+ class ImportSessionSnapshot:
49
+ """Immutable snapshot of the current session lifecycle state."""
50
+
51
+ phase: ImportSessionPhase = ImportSessionPhase.INITIALIZED
52
+ input_excel_name: str | None = None
53
+ output_excel_name: str | None = None
54
+ has_merged_header: bool | None = None
55
+ data_row_count: int = 0
56
+ processed_row_count: int = 0
57
+ success_count: int = 0
58
+ fail_count: int = 0
59
+ rendered_result_workbook: bool = False
60
+ result: ValidateResult | None = None
61
+
62
+
33
63
  class ImportSession[
34
64
  ContextT,
35
65
  ImportCreateModelT: BaseModel,
@@ -71,6 +101,7 @@ class ImportSession[
71
101
  self.issue_tracker = ImportIssueTracker(self.layout, self.import_result_field_meta)
72
102
  self.row_aggregator = RowAggregator(self.layout, self.behavior.import_mode)
73
103
  self.executor = ImportExecutor(self.config, self.issue_tracker, lambda: self.context)
104
+ self._snapshot = ImportSessionSnapshot()
74
105
 
75
106
  @property
76
107
  def cell_errors(self):
@@ -80,6 +111,10 @@ class ImportSession[
80
111
  def row_errors(self):
81
112
  return self.issue_tracker.row_errors
82
113
 
114
+ @property
115
+ def snapshot(self) -> ImportSessionSnapshot:
116
+ return self._snapshot
117
+
83
118
  @cached_property
84
119
  def input_excel_has_merged_header(self) -> bool:
85
120
  if not self._state_df_has_been_loaded:
@@ -101,42 +136,73 @@ class ImportSession[
101
136
 
102
137
  async def run(self, input_excel_name: str, output_excel_name: str) -> ImportResult:
103
138
  with use_display_locale(self.locale):
139
+ self._snapshot = replace(
140
+ self._snapshot,
141
+ phase=ImportSessionPhase.INITIALIZED,
142
+ input_excel_name=input_excel_name,
143
+ output_excel_name=output_excel_name,
144
+ rendered_result_workbook=False,
145
+ result=None,
146
+ data_row_count=0,
147
+ processed_row_count=0,
148
+ success_count=0,
149
+ fail_count=0,
150
+ )
151
+
104
152
  validate_header = self._validate_header(input_excel_name)
105
153
  if not validate_header.is_valid:
106
- return ImportResult.from_validate_header_result(validate_header)
107
-
108
- self.worksheet_table = self.worksheet_table.iloc[1:]
109
- self._set_columns(self.worksheet_table)
110
- self.worksheet_table = self.worksheet_table.reset_index(drop=True)
111
-
112
- all_success, success_count, fail_count = True, 0, 0
113
- for table_row_index in range(self.extra_header_count_on_import, len(self.worksheet_table)):
114
- row = self.worksheet_table.row_at(table_row_index)
115
- aggregate_data = self._aggregate_data(cast(FlatRowPayload, row.to_dict()))
116
- success = await self.executor.execute(RowIndex(table_row_index), aggregate_data, self.worksheet_table)
117
- all_success = all_success and success
118
- success_count, fail_count = (
119
- (success_count + 1, fail_count) if success else (success_count, fail_count + 1)
154
+ header_result = ImportResult.from_validate_header_result(validate_header)
155
+ self._snapshot = replace(
156
+ self._snapshot,
157
+ phase=ImportSessionPhase.COMPLETED,
158
+ has_merged_header=self.input_excel_has_merged_header,
159
+ result=header_result.result,
120
160
  )
161
+ return header_result
162
+
163
+ self._prepare_rows_for_execution()
164
+
165
+ all_success, success_count, fail_count = await self._execute_rows()
121
166
 
122
167
  url = None
123
168
  if not all_success:
124
169
  self._add_result_column()
125
170
  content_with_prefix = self._render_import_result_excel()
126
171
  url = self._upload_file(output_excel_name, content_with_prefix)
172
+ self._snapshot = replace(
173
+ self._snapshot,
174
+ phase=ImportSessionPhase.RESULT_RENDERED,
175
+ rendered_result_workbook=True,
176
+ )
127
177
 
128
- return ImportResult(
178
+ import_result = ImportResult(
129
179
  result=(ValidateResult.DATA_INVALID, ValidateResult.SUCCESS)[int(all_success)],
130
180
  url=url,
131
181
  success_count=success_count,
132
182
  fail_count=fail_count,
133
183
  )
184
+ self._snapshot = replace(
185
+ self._snapshot,
186
+ phase=ImportSessionPhase.COMPLETED,
187
+ success_count=success_count,
188
+ fail_count=fail_count,
189
+ result=import_result.result,
190
+ )
191
+ return import_result
134
192
 
135
193
  def _validate_header(self, input_excel_name: str) -> ValidateHeaderResult:
136
- self._read_dataframe(input_excel_name)
137
- return self.header_validator.validate(self.input_excel_headers, self.layout, self.behavior.import_mode)
194
+ self._load_workbook(input_excel_name)
195
+ validate_header = self.header_validator.validate(
196
+ self.input_excel_headers, self.layout, self.behavior.import_mode
197
+ )
198
+ self._snapshot = replace(
199
+ self._snapshot,
200
+ phase=ImportSessionPhase.HEADERS_VALIDATED,
201
+ has_merged_header=self.input_excel_has_merged_header,
202
+ )
203
+ return validate_header
138
204
 
139
- def _read_dataframe(self, input_excel_name: str) -> WorksheetTable:
205
+ def _load_workbook(self, input_excel_name: str) -> WorksheetTable:
140
206
  if not self._state_df_has_been_loaded:
141
207
  worksheet_table = self.storage_gateway.read_excel_table(
142
208
  input_excel_name,
@@ -146,8 +212,39 @@ class ImportSession[
146
212
  self.worksheet_table = worksheet_table
147
213
  self.header_table = worksheet_table.head(2)
148
214
  self._state_df_has_been_loaded = True
215
+ self._snapshot = replace(self._snapshot, phase=ImportSessionPhase.WORKBOOK_LOADED)
149
216
  return self.worksheet_table
150
217
 
218
+ def _prepare_rows_for_execution(self) -> None:
219
+ self.worksheet_table = self.worksheet_table.iloc[1:]
220
+ self._set_columns(self.worksheet_table)
221
+ self.worksheet_table = self.worksheet_table.reset_index(drop=True)
222
+ self._snapshot = replace(
223
+ self._snapshot,
224
+ phase=ImportSessionPhase.ROWS_PREPARED,
225
+ data_row_count=max(0, len(self.worksheet_table) - self.extra_header_count_on_import),
226
+ )
227
+
228
+ async def _execute_rows(self) -> tuple[bool, int, int]:
229
+ all_success, success_count, fail_count = True, 0, 0
230
+ processed_row_count = 0
231
+ for table_row_index in range(self.extra_header_count_on_import, len(self.worksheet_table)):
232
+ row = self.worksheet_table.row_at(table_row_index)
233
+ aggregate_data = self._aggregate_data(cast(FlatRowPayload, row.to_dict()))
234
+ success = await self.executor.execute(RowIndex(table_row_index), aggregate_data, self.worksheet_table)
235
+ processed_row_count += 1
236
+ all_success = all_success and success
237
+ success_count, fail_count = (success_count + 1, fail_count) if success else (success_count, fail_count + 1)
238
+
239
+ self._snapshot = replace(
240
+ self._snapshot,
241
+ phase=ImportSessionPhase.ROWS_EXECUTED,
242
+ processed_row_count=processed_row_count,
243
+ success_count=success_count,
244
+ fail_count=fail_count,
245
+ )
246
+ return all_success, success_count, fail_count
247
+
151
248
  def _set_columns(self, worksheet_table: WorksheetTable) -> WorksheetTable:
152
249
  return self.header_parser.apply_columns(
153
250
  worksheet_table,
@@ -1,7 +1,7 @@
1
1
  from collections.abc import Generator, Iterable, Mapping
2
2
  from dataclasses import dataclass
3
3
  from types import UnionType
4
- from typing import Any, Union, cast, get_args, get_origin
4
+ from typing import Union, cast, get_args, get_origin
5
5
 
6
6
  from pydantic import BaseModel, ValidationError
7
7
  from pydantic.fields import FieldInfo
@@ -23,23 +23,23 @@ class PydanticFieldAdapter:
23
23
  raw_field: FieldInfo
24
24
 
25
25
  @property
26
- def annotation(self) -> Any:
26
+ def annotation(self) -> object:
27
27
  return self.raw_field.annotation
28
28
 
29
29
  @property
30
- def excel_codec(self) -> type[Any]:
30
+ def excel_codec(self) -> type[ExcelFieldCodec]:
31
31
  annotation = self.annotation
32
32
  origin = get_origin(annotation)
33
33
  if origin in (UnionType, Union):
34
34
  args = [arg for arg in get_args(annotation) if arg is not type(None)]
35
35
  if len(args) != 1:
36
36
  raise ProgrammaticError(msg(MessageKey.UNSUPPORTED_FIELD_TYPE_DECLARATION, annotation=annotation))
37
- return cast(type[Any], args[0])
37
+ return cast(type[ExcelFieldCodec], args[0])
38
38
 
39
- return cast(type[Any], annotation)
39
+ return cast(type[ExcelFieldCodec], annotation)
40
40
 
41
41
  @property
42
- def value_type(self) -> type[Any]:
42
+ def value_type(self) -> type[ExcelFieldCodec]:
43
43
  """Backward-compatible alias for excel_codec."""
44
44
  return self.excel_codec
45
45
 
@@ -67,14 +67,14 @@ class PydanticFieldAdapter:
67
67
  declared = self.declared_metadata
68
68
  return declared.bind_runtime(
69
69
  required=self.required,
70
- excel_codec=cast(type[ExcelFieldCodec], self.excel_codec),
70
+ excel_codec=self.excel_codec,
71
71
  parent_label=declared.label,
72
72
  parent_key=Key(self.name),
73
73
  key=Key(self.name),
74
74
  offset=0,
75
75
  )
76
76
 
77
- def validate_value(self, raw_value: Any) -> Any:
77
+ def validate_value(self, raw_value: object) -> object:
78
78
  if raw_value is None:
79
79
  if self.allows_none and not self.required:
80
80
  return None
@@ -116,12 +116,12 @@ def get_model_field_names(model: type[BaseModel]) -> list[str]:
116
116
 
117
117
 
118
118
  def instantiate_pydantic_model[ModelT: BaseModel](
119
- data: Mapping[str, Any],
119
+ data: Mapping[str, object],
120
120
  model: type[ModelT],
121
121
  ) -> ModelT | list[ExcelCellError | ExcelRowError]:
122
122
  """Instantiate a Pydantic model and return mapped Excel errors when validation fails."""
123
123
  model_adapter = PydanticModelAdapter(model)
124
- normalized_data: dict[str, Any] = {}
124
+ normalized_data: dict[str, object] = {}
125
125
  errors: list[ExcelCellError | ExcelRowError] = []
126
126
  failed_fields: set[str] = set()
127
127
 
@@ -158,18 +158,14 @@ def _extract_pydantic_model(model: PydanticModelAdapter) -> Generator[FieldMetaI
158
158
  inherited = sub_field_info.inherited_from(declared_metadata)
159
159
  yield inherited.bind_runtime(
160
160
  required=field_adapter.required,
161
- excel_codec=cast(type[ExcelFieldCodec], excel_codec),
161
+ excel_codec=excel_codec,
162
162
  parent_label=declared_metadata.label,
163
163
  parent_key=Key(field_adapter.name),
164
164
  key=key,
165
165
  offset=offset,
166
166
  )
167
-
168
- elif issubclass(excel_codec, ExcelFieldCodec):
169
- yield field_adapter.runtime_metadata()
170
-
171
167
  else:
172
- raise ProgrammaticError(msg(MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED, value_type=excel_codec))
168
+ yield field_adapter.runtime_metadata()
173
169
 
174
170
 
175
171
  def _handle_error(
@@ -188,7 +184,7 @@ def _handle_error(
188
184
 
189
185
 
190
186
  def _model_validate[ModelT: BaseModel](
191
- data: dict[str, Any],
187
+ data: dict[str, object],
192
188
  model: type[ModelT],
193
189
  model_adapter: PydanticModelAdapter,
194
190
  failed_fields: set[str],
@@ -4,7 +4,7 @@ import copy
4
4
  import datetime
5
5
  import logging
6
6
  from collections.abc import Callable, Mapping, Set
7
- from dataclasses import dataclass, field
7
+ from dataclasses import dataclass, field, replace
8
8
  from functools import cached_property
9
9
  from typing import Any, Self, cast
10
10
 
@@ -37,6 +37,16 @@ type FieldDefaultFactory = Callable[[], object]
37
37
  type FieldIncludeExclude = Set[IntStr] | bool | None
38
38
 
39
39
 
40
+ def _normalize_character_set(character_set: set[CharacterSet] | None) -> frozenset[CharacterSet]:
41
+ return frozenset(character_set or set(CharacterSet))
42
+
43
+
44
+ def _normalize_options(options: list[Option] | tuple[Option, ...] | None) -> tuple[Option, ...] | None:
45
+ if options is None:
46
+ return None
47
+ return tuple(options)
48
+
49
+
40
50
  class PatchFieldMeta(BaseModel):
41
51
  unique: bool | None = False # Workbook hint only. Runtime uniqueness is enforced elsewhere.
42
52
  is_primary_key: bool | None = False # Workbook hint only. Runtime primary-key behavior is configured separately.
@@ -44,7 +54,7 @@ class PatchFieldMeta(BaseModel):
44
54
  options: list[Option] | None = None
45
55
 
46
56
 
47
- @dataclass(slots=True)
57
+ @dataclass(slots=True, frozen=True)
48
58
  class DeclaredFieldMeta:
49
59
  """Static workbook field declaration supplied by user code."""
50
60
 
@@ -56,7 +66,7 @@ class DeclaredFieldMeta:
56
66
  order: int
57
67
 
58
68
 
59
- @dataclass(slots=True)
69
+ @dataclass(slots=True, frozen=True)
60
70
  class RuntimeFieldBinding:
61
71
  """Runtime identity assigned after schema extraction flattens the model."""
62
72
 
@@ -67,21 +77,21 @@ class RuntimeFieldBinding:
67
77
  excel_codec: type[ExcelFieldCodec] = UndefinedFieldCodec
68
78
 
69
79
 
70
- @dataclass(slots=True)
80
+ @dataclass(slots=True, frozen=True)
71
81
  class WorkbookPresentationMeta:
72
82
  """Workbook-facing comment and formatting metadata."""
73
83
 
74
- character_set: set[CharacterSet] = field(default_factory=lambda: set(CharacterSet))
84
+ character_set: frozenset[CharacterSet] = field(default_factory=lambda: frozenset(CharacterSet))
75
85
  fraction_digits: int | None = None
76
86
  timezone: datetime.timezone = field(default_factory=lambda: datetime.timezone(datetime.timedelta(hours=8), 'CST'))
77
87
  date_format: DateFormat | None = None
78
88
  date_range_option: DataRangeOption | None = None
79
- options: list[Option] | None = None
89
+ options: tuple[Option, ...] | None = None
80
90
  unit: str | None = None
81
91
  hint: str | None = None
82
92
 
83
93
 
84
- @dataclass(slots=True)
94
+ @dataclass(slots=True, frozen=True)
85
95
  class ImportConstraints:
86
96
  """Importer-side validation hints mirrored from Pydantic constraints."""
87
97
 
@@ -136,12 +146,12 @@ class FieldMetaInfo:
136
146
  )
137
147
  self.runtime_binding = RuntimeFieldBinding()
138
148
  self.presentation_meta = WorkbookPresentationMeta(
139
- character_set=character_set or set(CharacterSet),
149
+ character_set=_normalize_character_set(character_set),
140
150
  fraction_digits=fraction_digits,
141
151
  timezone=timezone or datetime.timezone(datetime.timedelta(hours=8), 'CST'),
142
152
  date_format=date_format,
143
153
  date_range_option=date_range_option,
144
- options=options,
154
+ options=_normalize_options(options),
145
155
  unit=unit,
146
156
  hint=hint,
147
157
  )
@@ -196,7 +206,7 @@ class FieldMetaInfo:
196
206
 
197
207
  @excel_codec.setter
198
208
  def excel_codec(self, value: type[ExcelFieldCodec]) -> None:
199
- self.runtime_binding.excel_codec = value
209
+ self.runtime_binding = replace(self.runtime_binding, excel_codec=value)
200
210
 
201
211
  @property
202
212
  def value_type(self) -> type[ExcelFieldCodec]:
@@ -383,7 +393,7 @@ class FieldMetaInfo:
383
393
 
384
394
  @label.setter
385
395
  def label(self, value: str | Label) -> None:
386
- self.declared_meta.label = Label(value)
396
+ self.declared_meta = replace(self.declared_meta, label=Label(value))
387
397
 
388
398
  @property
389
399
  def is_primary_key(self) -> bool:
@@ -391,7 +401,7 @@ class FieldMetaInfo:
391
401
 
392
402
  @is_primary_key.setter
393
403
  def is_primary_key(self, value: bool) -> None:
394
- self.declared_meta.is_primary_key = value
404
+ self.declared_meta = replace(self.declared_meta, is_primary_key=value)
395
405
 
396
406
  @property
397
407
  def unique(self) -> bool:
@@ -399,7 +409,7 @@ class FieldMetaInfo:
399
409
 
400
410
  @unique.setter
401
411
  def unique(self, value: bool) -> None:
402
- self.declared_meta.unique = value
412
+ self.declared_meta = replace(self.declared_meta, unique=value)
403
413
 
404
414
  @property
405
415
  def ignore_import(self) -> bool:
@@ -407,7 +417,7 @@ class FieldMetaInfo:
407
417
 
408
418
  @ignore_import.setter
409
419
  def ignore_import(self, value: bool) -> None:
410
- self.declared_meta.ignore_import = value
420
+ self.declared_meta = replace(self.declared_meta, ignore_import=value)
411
421
 
412
422
  @property
413
423
  def required(self) -> bool | None:
@@ -415,7 +425,7 @@ class FieldMetaInfo:
415
425
 
416
426
  @required.setter
417
427
  def required(self, value: bool | None) -> None:
418
- self.declared_meta.required = value
428
+ self.declared_meta = replace(self.declared_meta, required=value)
419
429
 
420
430
  @property
421
431
  def order(self) -> int:
@@ -423,7 +433,7 @@ class FieldMetaInfo:
423
433
 
424
434
  @order.setter
425
435
  def order(self, value: int) -> None:
426
- self.declared_meta.order = value
436
+ self.declared_meta = replace(self.declared_meta, order=value)
427
437
 
428
438
  @property
429
439
  def parent_label(self) -> Label | None:
@@ -431,7 +441,7 @@ class FieldMetaInfo:
431
441
 
432
442
  @parent_label.setter
433
443
  def parent_label(self, value: Label | None) -> None:
434
- self.runtime_binding.parent_label = value
444
+ self.runtime_binding = replace(self.runtime_binding, parent_label=value)
435
445
 
436
446
  @property
437
447
  def key(self) -> Key | None:
@@ -439,7 +449,7 @@ class FieldMetaInfo:
439
449
 
440
450
  @key.setter
441
451
  def key(self, value: Key | None) -> None:
442
- self.runtime_binding.key = value
452
+ self.runtime_binding = replace(self.runtime_binding, key=value)
443
453
 
444
454
  @property
445
455
  def parent_key(self) -> Key | None:
@@ -447,7 +457,7 @@ class FieldMetaInfo:
447
457
 
448
458
  @parent_key.setter
449
459
  def parent_key(self, value: Key | None) -> None:
450
- self.runtime_binding.parent_key = value
460
+ self.runtime_binding = replace(self.runtime_binding, parent_key=value)
451
461
 
452
462
  @property
453
463
  def offset(self) -> int:
@@ -455,15 +465,15 @@ class FieldMetaInfo:
455
465
 
456
466
  @offset.setter
457
467
  def offset(self, value: int) -> None:
458
- self.runtime_binding.offset = value
468
+ self.runtime_binding = replace(self.runtime_binding, offset=value)
459
469
 
460
470
  @property
461
471
  def character_set(self) -> set[CharacterSet]:
462
- return self.presentation_meta.character_set
472
+ return set(self.presentation_meta.character_set)
463
473
 
464
474
  @character_set.setter
465
475
  def character_set(self, value: set[CharacterSet]) -> None:
466
- self.presentation_meta.character_set = value
476
+ self.presentation_meta = replace(self.presentation_meta, character_set=_normalize_character_set(value))
467
477
 
468
478
  @property
469
479
  def fraction_digits(self) -> int | None:
@@ -471,7 +481,7 @@ class FieldMetaInfo:
471
481
 
472
482
  @fraction_digits.setter
473
483
  def fraction_digits(self, value: int | None) -> None:
474
- self.presentation_meta.fraction_digits = value
484
+ self.presentation_meta = replace(self.presentation_meta, fraction_digits=value)
475
485
 
476
486
  @property
477
487
  def timezone(self) -> datetime.timezone:
@@ -479,7 +489,7 @@ class FieldMetaInfo:
479
489
 
480
490
  @timezone.setter
481
491
  def timezone(self, value: datetime.timezone) -> None:
482
- self.presentation_meta.timezone = value
492
+ self.presentation_meta = replace(self.presentation_meta, timezone=value)
483
493
 
484
494
  @property
485
495
  def date_format(self) -> DateFormat | None:
@@ -487,7 +497,7 @@ class FieldMetaInfo:
487
497
 
488
498
  @date_format.setter
489
499
  def date_format(self, value: DateFormat | None) -> None:
490
- self.presentation_meta.date_format = value
500
+ self.presentation_meta = replace(self.presentation_meta, date_format=value)
491
501
 
492
502
  @property
493
503
  def date_range_option(self) -> DataRangeOption | None:
@@ -495,15 +505,17 @@ class FieldMetaInfo:
495
505
 
496
506
  @date_range_option.setter
497
507
  def date_range_option(self, value: DataRangeOption | None) -> None:
498
- self.presentation_meta.date_range_option = value
508
+ self.presentation_meta = replace(self.presentation_meta, date_range_option=value)
499
509
 
500
510
  @property
501
511
  def options(self) -> list[Option] | None:
502
- return self.presentation_meta.options
512
+ if self.presentation_meta.options is None:
513
+ return None
514
+ return list(self.presentation_meta.options)
503
515
 
504
516
  @options.setter
505
517
  def options(self, value: list[Option] | None) -> None:
506
- self.presentation_meta.options = value
518
+ self.presentation_meta = replace(self.presentation_meta, options=_normalize_options(value))
507
519
 
508
520
  @property
509
521
  def unit(self) -> str | None:
@@ -511,7 +523,7 @@ class FieldMetaInfo:
511
523
 
512
524
  @unit.setter
513
525
  def unit(self, value: str | None) -> None:
514
- self.presentation_meta.unit = value
526
+ self.presentation_meta = replace(self.presentation_meta, unit=value)
515
527
 
516
528
  @property
517
529
  def hint(self) -> str | None:
@@ -519,7 +531,7 @@ class FieldMetaInfo:
519
531
 
520
532
  @hint.setter
521
533
  def hint(self, value: str | None) -> None:
522
- self.presentation_meta.hint = value
534
+ self.presentation_meta = replace(self.presentation_meta, hint=value)
523
535
 
524
536
  @property
525
537
  def importer_ge(self) -> float | None:
@@ -527,7 +539,7 @@ class FieldMetaInfo:
527
539
 
528
540
  @importer_ge.setter
529
541
  def importer_ge(self, value: float | None) -> None:
530
- self.import_constraints.ge = value
542
+ self.import_constraints = replace(self.import_constraints, ge=value)
531
543
 
532
544
  @property
533
545
  def importer_le(self) -> float | None:
@@ -535,7 +547,7 @@ class FieldMetaInfo:
535
547
 
536
548
  @importer_le.setter
537
549
  def importer_le(self, value: float | None) -> None:
538
- self.import_constraints.le = value
550
+ self.import_constraints = replace(self.import_constraints, le=value)
539
551
 
540
552
  @property
541
553
  def importer_max_digits(self) -> int | None:
@@ -543,7 +555,7 @@ class FieldMetaInfo:
543
555
 
544
556
  @importer_max_digits.setter
545
557
  def importer_max_digits(self, value: int | None) -> None:
546
- self.import_constraints.max_digits = value
558
+ self.import_constraints = replace(self.import_constraints, max_digits=value)
547
559
 
548
560
  @property
549
561
  def importer_decimal_places(self) -> int | None:
@@ -551,7 +563,7 @@ class FieldMetaInfo:
551
563
 
552
564
  @importer_decimal_places.setter
553
565
  def importer_decimal_places(self, value: int | None) -> None:
554
- self.import_constraints.decimal_places = value
566
+ self.import_constraints = replace(self.import_constraints, decimal_places=value)
555
567
 
556
568
  @property
557
569
  def importer_min_length(self) -> int | None:
@@ -559,7 +571,7 @@ class FieldMetaInfo:
559
571
 
560
572
  @importer_min_length.setter
561
573
  def importer_min_length(self, value: int | None) -> None:
562
- self.import_constraints.min_length = value
574
+ self.import_constraints = replace(self.import_constraints, min_length=value)
563
575
 
564
576
  @property
565
577
  def importer_max_length(self) -> int | None:
@@ -567,7 +579,7 @@ class FieldMetaInfo:
567
579
 
568
580
  @importer_max_length.setter
569
581
  def importer_max_length(self, value: int | None) -> None:
570
- self.import_constraints.max_length = value
582
+ self.import_constraints = replace(self.import_constraints, max_length=value)
571
583
 
572
584
  @property
573
585
  def importer_min_items(self) -> int | None:
@@ -575,7 +587,7 @@ class FieldMetaInfo:
575
587
 
576
588
  @importer_min_items.setter
577
589
  def importer_min_items(self, value: int | None) -> None:
578
- self.import_constraints.min_items = value
590
+ self.import_constraints = replace(self.import_constraints, min_items=value)
579
591
 
580
592
  @property
581
593
  def importer_max_items(self) -> int | None:
@@ -583,7 +595,7 @@ class FieldMetaInfo:
583
595
 
584
596
  @importer_max_items.setter
585
597
  def importer_max_items(self, value: int | None) -> None:
586
- self.import_constraints.max_items = value
598
+ self.import_constraints = replace(self.import_constraints, max_items=value)
587
599
 
588
600
  @property
589
601
  def importer_unique_items(self) -> bool | None:
@@ -591,7 +603,7 @@ class FieldMetaInfo:
591
603
 
592
604
  @importer_unique_items.setter
593
605
  def importer_unique_items(self, value: bool | None) -> None:
594
- self.import_constraints.unique_items = value
606
+ self.import_constraints = replace(self.import_constraints, unique_items=value)
595
607
 
596
608
 
597
609
  def extract_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo:
File without changes