diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e8dab..7a55cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file. The format is inspired by Keep a Changelog and versioned according to PEP 440. +## [2.2.0] - Unreleased + +This release continues the stable 2.x line with runtime consolidation, +clearer configuration ergonomics, and a stronger protocol-first storage story. + +### Added + +- Added `ImportSessionPhase` and `ImportSessionSnapshot` so one-shot import runs + expose a clearer lifecycle and final runtime summary +- Added recommended config constructors: + `ImporterConfig.for_create(...)`, `ImporterConfig.for_update(...)`, + `ImporterConfig.for_create_or_update(...)`, `ExporterConfig.for_model(...)`, + and `ExporterConfig.for_storage(...)` +- Added targeted regression tests for config helper constructors, legacy + storage deprecation behavior, and import session snapshots + +### Changed + +- Refined `ImportSession` so the import workflow now advances through explicit + phases: workbook loading, header validation, row preparation, row execution, + result rendering, and completion +- Added `ExcelAlchemy.last_import_snapshot` as the facade-level read-only view + of the latest import session state +- Clarified the recommended storage configuration path around explicit + `storage=...` backends +- Kept legacy `minio`, `bucket_name`, and `url_expires` support for 2.x, but + now emit an explicit deprecation warning when that path is used +- Reduced warning noise by emitting the legacy storage deprecation warning once + per compatibility scenario + +### Compatibility Notes + +- No public import or export workflow API was removed in this release +- The legacy Minio config path remains supported in 2.x for migration-friendly + compatibility +- Existing direct `ImporterConfig(...)` and `ExporterConfig(...)` construction + continue to work; helper constructors are the new recommended path + +### Release Summary + +- import sessions now expose a clearer lifecycle and final snapshot +- config construction is easier to read through dedicated helper constructors +- `storage=...` is now the clear recommended backend integration path for 2.x + ## [2.1.0] - 2026-04-02 This release continues the stable 2.x line with internal architecture cleanup, diff --git a/docs/releases/2.2.0.md b/docs/releases/2.2.0.md new file mode 100644 index 0000000..a66dbf0 --- /dev/null +++ b/docs/releases/2.2.0.md @@ -0,0 +1,119 @@ +# 2.2.0 Release Checklist + +This checklist is intended for the `2.2.0` release on top of the stable 2.x +line. + +## Purpose + +- publish the next stable 2.x refinement release of ExcelAlchemy +- present `2.2.0` as a runtime-consolidation and developer-ergonomics release +- keep the public 2.x workflow stable while making the internal import runtime + more explicit +- reinforce `storage=...` as the recommended backend integration path + +## Release Positioning + +`2.2.0` should be presented as an architectural refinement release: + +- the public import and export workflow API stays stable +- import runs now expose a clearer lifecycle through `ImportSession` +- config construction is easier to read through dedicated helper constructors +- storage integration becomes more clearly protocol-first without breaking 2.x + compatibility + +## Before Tagging + +1. Confirm the intended version in `src/excelalchemy/__init__.py`. +2. Review the `2.2.0` section in `CHANGELOG.md`. +3. Confirm `README.md`, `README-pypi.md`, and `MIGRATIONS.md` still describe + the recommended public paths correctly. +4. Confirm `README_cn.md` remains aligned with the current release position. +5. Confirm the compatibility notes for: + - `minio / bucket_name / url_expires` + - `storage=...` as the recommended path + - `df/header_df` compatibility aliases + - `excelalchemy.util.convertor` + +## Local Verification + +Run these commands from the repository root: + +```bash +uv sync --extra development +uv run ruff check . +uv run pyright +uv run pytest tests +rm -rf dist +uv build +uvx twine check dist/* +``` + +Optional smoke tests: + +```bash +uv venv .pkg-smoke-base --python 3.14 +uv pip install --python .pkg-smoke-base/bin/python dist/*.whl +.pkg-smoke-base/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" +``` + +```bash +uv venv .pkg-smoke-minio --python 3.14 +uv pip install --python .pkg-smoke-minio/bin/python "dist/*.whl[minio]" +.pkg-smoke-minio/bin/python -c "from excelalchemy.core.storage_minio import MinioStorageGateway; print(MinioStorageGateway.__name__)" +``` + +## GitHub Release Steps + +1. Push the release commit to the default branch. +2. In GitHub Releases, draft a new release. +3. Create a new tag: `v2.2.0`. +4. Use the `2.2.0` section from `CHANGELOG.md` as the release notes base. +5. Publish the release and monitor the `Upload Python Package` workflow. + +## Release Focus + +When reviewing the final release notes, make sure they communicate these three +themes clearly: + +- `ImportSession` now exposes a clearer lifecycle and final session snapshot +- config construction is easier to read through dedicated helper constructors +- explicit `storage=...` is the clear recommended backend integration path for + the 2.x line + +## Recommended Release Messaging + +Prefer wording that emphasizes refinement and stability: + +- "continues the stable 2.x line" +- "keeps the public import/export workflow API stable" +- "clarifies the import runtime lifecycle" +- "improves config ergonomics and backend integration guidance" + +## PyPI Verification + +After the workflow completes: + +1. Confirm the new release appears on PyPI. +2. Confirm the long description renders correctly. +3. Confirm screenshots and absolute links still work on the PyPI project page. +4. Test base install: + +```bash +pip install -U ExcelAlchemy +``` + +5. Test optional Minio install: + +```bash +pip install -U "ExcelAlchemy[minio]" +``` + +6. Run one template-generation example. +7. Run one import flow and one export flow. + +## Done When + +- the tag `v2.2.0` is published +- the GitHub Release notes clearly communicate the three release themes +- PyPI renders the project description correctly +- CI, typing, tests, and package publishing all pass for the tagged release diff --git a/src/excelalchemy/config.py b/src/excelalchemy/config.py index 64a0480..fa2dc05 100644 --- a/src/excelalchemy/config.py +++ b/src/excelalchemy/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum @@ -9,6 +10,7 @@ from pydantic import BaseModel +from excelalchemy._primitives.deprecation import DEPRECATION_REMOVAL_VERSION, ExcelAlchemyDeprecationWarning from excelalchemy._primitives.payloads import DataConverter, DmlCallback, ExistenceCheckCallback, ImportContext from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.exceptions import ConfigError @@ -21,6 +23,9 @@ from minio import Minio +_EMITTED_STORAGE_DEPRECATION_WARNINGS: set[bool] = set() + + class ExcelMode(StrEnum): """Top-level Excel workflow mode.""" @@ -51,6 +56,10 @@ def has_explicit_storage(self) -> bool: def has_legacy_minio(self) -> bool: return self.minio is not None + @property + def uses_legacy_minio_path(self) -> bool: + return self.storage is None and self.minio is not None + @dataclass(slots=True, frozen=True) class ImporterSchemaOptions[ImportCreateModelT: BaseModel, ImportUpdateModelT: BaseModel]: @@ -118,6 +127,116 @@ class ImporterConfig[ContextT, ImportCreateModelT: BaseModel, ImportUpdateModelT behavior: ImportBehavior[ContextT] = field(init=False, repr=False) storage_options: StorageOptions = field(init=False, repr=False) + @classmethod + def for_create( + cls, + importer_model: type[ImportCreateModelT], + *, + data_converter: DataConverter | None = import_data_converter, + creator: DmlCallback[ContextT] | None = None, + updater: DmlCallback[ContextT] | None = None, + context: ImportContext[ContextT] = None, + is_data_exist: ExistenceCheckCallback[ContextT] | None = None, + exec_formatter: Callable[[Exception], str] = str, + storage: ExcelStorage | None = None, + minio: Minio | None = None, + bucket_name: str = 'excel', + url_expires: int = 3600, + locale: str = 'zh-CN', + sheet_name: str = 'Sheet1', + ) -> Self: + """Build a create-mode importer config through the recommended constructor.""" + return cls( + create_importer_model=importer_model, + data_converter=data_converter, + creator=creator, + updater=updater, + context=context, + is_data_exist=is_data_exist, + exec_formatter=exec_formatter, + import_mode=ImportMode.CREATE, + storage=storage, + minio=minio, + bucket_name=bucket_name, + url_expires=url_expires, + locale=locale, + sheet_name=sheet_name, + ) + + @classmethod + def for_update( + cls, + importer_model: type[ImportUpdateModelT], + *, + data_converter: DataConverter | None = import_data_converter, + creator: DmlCallback[ContextT] | None = None, + updater: DmlCallback[ContextT] | None = None, + context: ImportContext[ContextT] = None, + is_data_exist: ExistenceCheckCallback[ContextT] | None = None, + exec_formatter: Callable[[Exception], str] = str, + storage: ExcelStorage | None = None, + minio: Minio | None = None, + bucket_name: str = 'excel', + url_expires: int = 3600, + locale: str = 'zh-CN', + sheet_name: str = 'Sheet1', + ) -> Self: + """Build an update-mode importer config through the recommended constructor.""" + return cls( + update_importer_model=importer_model, + data_converter=data_converter, + creator=creator, + updater=updater, + context=context, + is_data_exist=is_data_exist, + exec_formatter=exec_formatter, + import_mode=ImportMode.UPDATE, + storage=storage, + minio=minio, + bucket_name=bucket_name, + url_expires=url_expires, + locale=locale, + sheet_name=sheet_name, + ) + + @classmethod + def for_create_or_update( + cls, + *, + create_importer_model: type[ImportCreateModelT], + update_importer_model: type[ImportUpdateModelT], + is_data_exist: ExistenceCheckCallback[ContextT], + data_converter: DataConverter | None = import_data_converter, + creator: DmlCallback[ContextT] | None = None, + updater: DmlCallback[ContextT] | None = None, + context: ImportContext[ContextT] = None, + exec_formatter: Callable[[Exception], str] = str, + storage: ExcelStorage | None = None, + minio: Minio | None = None, + bucket_name: str = 'excel', + url_expires: int = 3600, + locale: str = 'zh-CN', + sheet_name: str = 'Sheet1', + ) -> Self: + """Build a create-or-update importer config through the recommended constructor.""" + return cls( + create_importer_model=create_importer_model, + update_importer_model=update_importer_model, + data_converter=data_converter, + creator=creator, + updater=updater, + context=context, + is_data_exist=is_data_exist, + exec_formatter=exec_formatter, + import_mode=ImportMode.CREATE_OR_UPDATE, + storage=storage, + minio=minio, + bucket_name=bucket_name, + url_expires=url_expires, + locale=locale, + sheet_name=sheet_name, + ) + def validate_model(self) -> Self: if self.import_mode not in ImportMode.__members__.values(): raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) @@ -181,6 +300,8 @@ def __post_init__(self) -> None: bucket_name=self.bucket_name, url_expires=self.url_expires, ) + if self.storage_options.has_legacy_minio: + _warn_legacy_storage_path(has_explicit_storage=self.storage_options.has_explicit_storage) @dataclass(slots=True) @@ -200,6 +321,50 @@ class ExporterConfig[ExportModelT: BaseModel]: behavior: ExportBehavior = field(init=False, repr=False) storage_options: StorageOptions = field(init=False, repr=False) + @classmethod + def for_model( + cls, + exporter_model: type[ExportModelT], + *, + data_converter: DataConverter | None = export_data_converter, + storage: ExcelStorage | None = None, + minio: Minio | None = None, + bucket_name: str = 'excel', + url_expires: int = 3600, + locale: str = 'zh-CN', + sheet_name: str = 'Sheet1', + ) -> Self: + """Build an exporter config through the recommended constructor.""" + return cls( + exporter_model=exporter_model, + data_converter=data_converter, + storage=storage, + minio=minio, + bucket_name=bucket_name, + url_expires=url_expires, + locale=locale, + sheet_name=sheet_name, + ) + + @classmethod + def for_storage( + cls, + exporter_model: type[ExportModelT], + *, + storage: ExcelStorage, + data_converter: DataConverter | None = export_data_converter, + locale: str = 'zh-CN', + sheet_name: str = 'Sheet1', + ) -> Self: + """Build an exporter config for the recommended explicit-storage path.""" + return cls.for_model( + exporter_model, + data_converter=data_converter, + storage=storage, + locale=locale, + sheet_name=sheet_name, + ) + def validate_model(self) -> Self: if not self.exporter_model: raise ValueError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_EMPTY)) @@ -219,3 +384,26 @@ def __post_init__(self) -> None: bucket_name=self.bucket_name, url_expires=self.url_expires, ) + if self.storage_options.has_legacy_minio: + _warn_legacy_storage_path(has_explicit_storage=self.storage_options.has_explicit_storage) + + +def _warn_legacy_storage_path(*, has_explicit_storage: bool) -> None: + """Emit a deprecation warning for the legacy built-in Minio config path.""" + if has_explicit_storage in _EMITTED_STORAGE_DEPRECATION_WARNINGS: + return + _EMITTED_STORAGE_DEPRECATION_WARNINGS.add(has_explicit_storage) + + detail = ( + ' The explicit `storage=` backend will be used.' + if has_explicit_storage + else ' Prefer passing `storage=` with `MinioStorageGateway` or a custom `ExcelStorage` implementation.' + ) + warnings.warn( + ( + '`minio`, `bucket_name`, and `url_expires` are deprecated configuration fields and will be removed in ' + f'ExcelAlchemy {DEPRECATION_REMOVAL_VERSION}.{detail}' + ), + category=ExcelAlchemyDeprecationWarning, + stacklevel=3, + ) diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index f8baec8..b65a28f 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -16,7 +16,7 @@ from excelalchemy.config import ExcelMode, ExporterConfig, ImporterConfig, ImportMode from excelalchemy.core.abstract import ABCExcelAlchemy from excelalchemy.core.headers import ExcelHeaderParser, ExcelHeaderValidator -from excelalchemy.core.import_session import ImportSession, build_import_result_field_meta +from excelalchemy.core.import_session import ImportSession, ImportSessionSnapshot, build_import_result_field_meta from excelalchemy.core.rendering import ExcelRenderer from excelalchemy.core.schema import ExcelSchemaLayout from excelalchemy.core.storage import build_storage_gateway @@ -217,6 +217,12 @@ def row_errors(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]: return {} return self._last_import_session.row_errors + @property + def last_import_snapshot(self) -> ImportSessionSnapshot | None: + if self._last_import_session is None: + return None + return self._last_import_session.snapshot + @property def input_excel_has_merged_header(self) -> bool: return self._require_last_import_session().input_excel_has_merged_header diff --git a/src/excelalchemy/core/import_session.py b/src/excelalchemy/core/import_session.py index 29c84f9..561fb71 100644 --- a/src/excelalchemy/core/import_session.py +++ b/src/excelalchemy/core/import_session.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import dataclass, replace +from enum import StrEnum from functools import cached_property from typing import cast @@ -30,6 +32,34 @@ HEADER_HINT_LINE_COUNT = 1 +class ImportSessionPhase(StrEnum): + """High-level lifecycle phase for a one-shot import session.""" + + INITIALIZED = 'INITIALIZED' + WORKBOOK_LOADED = 'WORKBOOK_LOADED' + HEADERS_VALIDATED = 'HEADERS_VALIDATED' + ROWS_PREPARED = 'ROWS_PREPARED' + ROWS_EXECUTED = 'ROWS_EXECUTED' + RESULT_RENDERED = 'RESULT_RENDERED' + COMPLETED = 'COMPLETED' + + +@dataclass(slots=True, frozen=True) +class ImportSessionSnapshot: + """Immutable snapshot of the current session lifecycle state.""" + + phase: ImportSessionPhase = ImportSessionPhase.INITIALIZED + input_excel_name: str | None = None + output_excel_name: str | None = None + has_merged_header: bool | None = None + data_row_count: int = 0 + processed_row_count: int = 0 + success_count: int = 0 + fail_count: int = 0 + rendered_result_workbook: bool = False + result: ValidateResult | None = None + + class ImportSession[ ContextT, ImportCreateModelT: BaseModel, @@ -71,6 +101,7 @@ def __init__( self.issue_tracker = ImportIssueTracker(self.layout, self.import_result_field_meta) self.row_aggregator = RowAggregator(self.layout, self.behavior.import_mode) self.executor = ImportExecutor(self.config, self.issue_tracker, lambda: self.context) + self._snapshot = ImportSessionSnapshot() @property def cell_errors(self): @@ -80,6 +111,10 @@ def cell_errors(self): def row_errors(self): return self.issue_tracker.row_errors + @property + def snapshot(self) -> ImportSessionSnapshot: + return self._snapshot + @cached_property def input_excel_has_merged_header(self) -> bool: if not self._state_df_has_been_loaded: @@ -101,42 +136,73 @@ def extra_header_count_on_import(self) -> int: async def run(self, input_excel_name: str, output_excel_name: str) -> ImportResult: with use_display_locale(self.locale): + self._snapshot = replace( + self._snapshot, + phase=ImportSessionPhase.INITIALIZED, + input_excel_name=input_excel_name, + output_excel_name=output_excel_name, + rendered_result_workbook=False, + result=None, + data_row_count=0, + processed_row_count=0, + success_count=0, + fail_count=0, + ) + validate_header = self._validate_header(input_excel_name) if not validate_header.is_valid: - return ImportResult.from_validate_header_result(validate_header) - - self.worksheet_table = self.worksheet_table.iloc[1:] - self._set_columns(self.worksheet_table) - self.worksheet_table = self.worksheet_table.reset_index(drop=True) - - all_success, success_count, fail_count = True, 0, 0 - for table_row_index in range(self.extra_header_count_on_import, len(self.worksheet_table)): - row = self.worksheet_table.row_at(table_row_index) - aggregate_data = self._aggregate_data(cast(FlatRowPayload, row.to_dict())) - success = await self.executor.execute(RowIndex(table_row_index), aggregate_data, self.worksheet_table) - all_success = all_success and success - success_count, fail_count = ( - (success_count + 1, fail_count) if success else (success_count, fail_count + 1) + header_result = ImportResult.from_validate_header_result(validate_header) + self._snapshot = replace( + self._snapshot, + phase=ImportSessionPhase.COMPLETED, + has_merged_header=self.input_excel_has_merged_header, + result=header_result.result, ) + return header_result + + self._prepare_rows_for_execution() + + all_success, success_count, fail_count = await self._execute_rows() url = None if not all_success: self._add_result_column() content_with_prefix = self._render_import_result_excel() url = self._upload_file(output_excel_name, content_with_prefix) + self._snapshot = replace( + self._snapshot, + phase=ImportSessionPhase.RESULT_RENDERED, + rendered_result_workbook=True, + ) - return ImportResult( + import_result = ImportResult( result=(ValidateResult.DATA_INVALID, ValidateResult.SUCCESS)[int(all_success)], url=url, success_count=success_count, fail_count=fail_count, ) + self._snapshot = replace( + self._snapshot, + phase=ImportSessionPhase.COMPLETED, + success_count=success_count, + fail_count=fail_count, + result=import_result.result, + ) + return import_result def _validate_header(self, input_excel_name: str) -> ValidateHeaderResult: - self._read_dataframe(input_excel_name) - return self.header_validator.validate(self.input_excel_headers, self.layout, self.behavior.import_mode) + self._load_workbook(input_excel_name) + validate_header = self.header_validator.validate( + self.input_excel_headers, self.layout, self.behavior.import_mode + ) + self._snapshot = replace( + self._snapshot, + phase=ImportSessionPhase.HEADERS_VALIDATED, + has_merged_header=self.input_excel_has_merged_header, + ) + return validate_header - def _read_dataframe(self, input_excel_name: str) -> WorksheetTable: + def _load_workbook(self, input_excel_name: str) -> WorksheetTable: if not self._state_df_has_been_loaded: worksheet_table = self.storage_gateway.read_excel_table( input_excel_name, @@ -146,8 +212,39 @@ def _read_dataframe(self, input_excel_name: str) -> WorksheetTable: self.worksheet_table = worksheet_table self.header_table = worksheet_table.head(2) self._state_df_has_been_loaded = True + self._snapshot = replace(self._snapshot, phase=ImportSessionPhase.WORKBOOK_LOADED) return self.worksheet_table + def _prepare_rows_for_execution(self) -> None: + self.worksheet_table = self.worksheet_table.iloc[1:] + self._set_columns(self.worksheet_table) + self.worksheet_table = self.worksheet_table.reset_index(drop=True) + self._snapshot = replace( + self._snapshot, + phase=ImportSessionPhase.ROWS_PREPARED, + data_row_count=max(0, len(self.worksheet_table) - self.extra_header_count_on_import), + ) + + async def _execute_rows(self) -> tuple[bool, int, int]: + all_success, success_count, fail_count = True, 0, 0 + processed_row_count = 0 + for table_row_index in range(self.extra_header_count_on_import, len(self.worksheet_table)): + row = self.worksheet_table.row_at(table_row_index) + aggregate_data = self._aggregate_data(cast(FlatRowPayload, row.to_dict())) + success = await self.executor.execute(RowIndex(table_row_index), aggregate_data, self.worksheet_table) + processed_row_count += 1 + all_success = all_success and success + success_count, fail_count = (success_count + 1, fail_count) if success else (success_count, fail_count + 1) + + self._snapshot = replace( + self._snapshot, + phase=ImportSessionPhase.ROWS_EXECUTED, + processed_row_count=processed_row_count, + success_count=success_count, + fail_count=fail_count, + ) + return all_success, success_count, fail_count + def _set_columns(self, worksheet_table: WorksheetTable) -> WorksheetTable: return self.header_parser.apply_columns( worksheet_table, diff --git a/tests/contracts/test_import_contract.py b/tests/contracts/test_import_contract.py index dd22ec1..d3dd642 100644 --- a/tests/contracts/test_import_contract.py +++ b/tests/contracts/test_import_contract.py @@ -6,6 +6,7 @@ from excelalchemy import ExcelAlchemy, ImporterConfig, ValidateResult from excelalchemy.const import BACKGROUND_ERROR_COLOR, REASON_COLUMN_LABEL, RESULT_COLUMN_LABEL +from excelalchemy.core.import_session import ImportSessionPhase from tests.support import BaseTestCase, FileRegistry, get_fill_color, load_binary_excel_to_workbook from tests.support.contract_models import MergedContractImporter, SimpleContractImporter, creator, failing_creator @@ -57,6 +58,28 @@ async def test_import_data_reloads_workbook_state_on_each_run(self): assert second_result.fail_count == 0 assert second_result.url is None + async def test_import_session_snapshot_tracks_completed_successful_run(self): + alchemy = ExcelAlchemy( + ImporterConfig.for_create(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio)) + ) + + result = await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT, + output_excel_name='contract-session-success.xlsx', + ) + + snapshot = alchemy.last_import_snapshot + + assert result.result == ValidateResult.SUCCESS + assert snapshot is not None + assert snapshot.phase == ImportSessionPhase.COMPLETED + assert snapshot.result == ValidateResult.SUCCESS + assert snapshot.data_row_count == 1 + assert snapshot.processed_row_count == 1 + assert snapshot.success_count == 1 + assert snapshot.fail_count == 0 + assert not snapshot.rendered_result_workbook + async def test_import_data_uploads_result_workbook_for_invalid_rows(self): output_name = 'contract-data-invalid.xlsx' self.minio.storage.pop(output_name, None) diff --git a/tests/unit/test_config_options.py b/tests/unit/test_config_options.py index b89fc5b..77314bf 100644 --- a/tests/unit/test_config_options.py +++ b/tests/unit/test_config_options.py @@ -1,8 +1,11 @@ +import warnings from typing import cast from minio import Minio from excelalchemy import ExporterConfig, ImporterConfig, ImportMode +from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning +from excelalchemy.config import _EMITTED_STORAGE_DEPRECATION_WARNINGS from tests.support import BaseTestCase, InMemoryExcelStorage from tests.support.contract_models import SimpleContractImporter, creator @@ -34,6 +37,35 @@ async def test_importer_config_normalizes_schema_behavior_and_storage_options(se assert config.storage_options.has_explicit_storage assert config.storage_options.has_legacy_minio + async def test_importer_helper_constructors_select_expected_modes(self): + create_config = ImporterConfig.for_create(SimpleContractImporter, creator=creator) + update_config = ImporterConfig.for_update(SimpleContractImporter, updater=creator) + + assert create_config.import_mode == ImportMode.CREATE + assert create_config.schema_options.create_importer_model is SimpleContractImporter + assert update_config.import_mode == ImportMode.UPDATE + assert update_config.schema_options.update_importer_model is SimpleContractImporter + + async def test_exporter_helper_constructors_support_recommended_storage_path(self): + storage = InMemoryExcelStorage() + config = ExporterConfig.for_storage(SimpleContractImporter, storage=storage, locale='en') + + assert config.schema_options.exporter_model is SimpleContractImporter + assert config.storage_options.storage is storage + assert config.schema_options.locale == 'en' + + async def test_legacy_minio_path_emits_deprecation_warning(self): + _EMITTED_STORAGE_DEPRECATION_WARNINGS.clear() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always', ExcelAlchemyDeprecationWarning) + ImporterConfig.for_create(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio)) + + assert any( + isinstance(warning.message, ExcelAlchemyDeprecationWarning) + and '`minio`, `bucket_name`, and `url_expires` are deprecated' in str(warning.message) + for warning in caught + ) + async def test_exporter_config_normalizes_schema_behavior_and_storage_options(self): storage = InMemoryExcelStorage() config = ExporterConfig(