diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a55cfd..31d777d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,47 @@ 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 +## [2.2.1] - Unreleased + +This release continues the stable 2.x line with deeper metadata layering, +stronger internal immutability, and tighter type boundaries around the +Pydantic adapter layer. + +### Added + +- Added regression tests that verify split metadata layers behave like + immutable value objects +- Added regression tests that verify facade-level mutation replaces internal + metadata layers rather than mutating them in place + +### Changed + +- Made `DeclaredFieldMeta`, `RuntimeFieldBinding`, + `WorkbookPresentationMeta`, and `ImportConstraints` frozen internal + structures +- Updated `FieldMetaInfo` mutation paths to replace internal layer objects via + structural updates instead of mutating them in place +- Normalized workbook presentation internals so character sets and options are + stored in immutable forms +- Tightened key type boundaries in the Pydantic adapter around annotations, + codecs, and normalized input payloads + +### Compatibility Notes + +- No public import or export workflow API was removed in this release +- `FieldMeta(...)` and `ExcelMeta(...)` remain the stable public metadata entry + points +- The metadata layering changes are internal and preserve the public 2.x + surface + +### Release Summary + +- metadata internals are now more immutable and easier to reason about +- facade-level metadata updates preserve 2.x ergonomics while reducing hidden + shared state +- the Pydantic adapter layer now has clearer type boundaries + +## [2.2.0] - 2026-04-03 This release continues the stable 2.x line with runtime consolidation, clearer configuration ergonomics, and a stronger protocol-first storage story. diff --git a/docs/releases/2.2.0.md b/docs/releases/2.2.0.md index a66dbf0..9c863fd 100644 --- a/docs/releases/2.2.0.md +++ b/docs/releases/2.2.0.md @@ -5,7 +5,7 @@ line. ## Purpose -- publish the next stable 2.x refinement release of ExcelAlchemy +- publish the stable `2.2.0` 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 diff --git a/docs/releases/2.2.1.md b/docs/releases/2.2.1.md new file mode 100644 index 0000000..ada8a4a --- /dev/null +++ b/docs/releases/2.2.1.md @@ -0,0 +1,112 @@ +# 2.2.1 Release Checklist + +This checklist is intended for the `2.2.1` release on top of the stable 2.x +line. + +## Purpose + +- publish the next stable 2.x refinement release of ExcelAlchemy +- present `2.2.1` as a metadata-consolidation and typing-tightening release +- keep the public 2.x workflow stable while making internal metadata structures + more immutable +- continue reducing hidden shared state and internal type ambiguity + +## Release Positioning + +`2.2.1` should be presented as an architectural refinement release: + +- the public import and export workflow API stays stable +- metadata internals become more immutable and easier to reason about +- facade-level metadata mutation remains ergonomic while internal layering gets + safer +- the Pydantic adapter layer continues moving toward clearer type boundaries + +## Before Tagging + +1. Confirm the intended version in `src/excelalchemy/__init__.py`. +2. Review the `2.2.1` 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: + - `FieldMeta(...)` and `ExcelMeta(...)` as stable public metadata entry points + - internal metadata layering remaining an implementation detail + - `storage=...` as the recommended backend path + +## 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__)" +``` + +## 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.1`. +4. Use the `2.2.1` 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: + +- metadata internals are now more immutable and less prone to hidden shared state +- facade-level metadata updates preserve 2.x ergonomics while internal layers + are replaced structurally +- the Pydantic adapter layer now has clearer type boundaries + +## Recommended Release Messaging + +Prefer wording that emphasizes refinement and stability: + +- "continues the stable 2.x line" +- "keeps the public import/export workflow API stable" +- "makes metadata internals more immutable" +- "tightens internal type boundaries without forcing public API changes" + +## 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.1` 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/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index fdc10e1..e96a979 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -1,7 +1,7 @@ from collections.abc import Generator, Iterable, Mapping from dataclasses import dataclass from types import UnionType -from typing import Any, Union, cast, get_args, get_origin +from typing import Union, cast, get_args, get_origin from pydantic import BaseModel, ValidationError from pydantic.fields import FieldInfo @@ -23,23 +23,23 @@ class PydanticFieldAdapter: raw_field: FieldInfo @property - def annotation(self) -> Any: + def annotation(self) -> object: return self.raw_field.annotation @property - def excel_codec(self) -> type[Any]: + def excel_codec(self) -> type[ExcelFieldCodec]: annotation = self.annotation origin = get_origin(annotation) if origin in (UnionType, Union): args = [arg for arg in get_args(annotation) if arg is not type(None)] if len(args) != 1: raise ProgrammaticError(msg(MessageKey.UNSUPPORTED_FIELD_TYPE_DECLARATION, annotation=annotation)) - return cast(type[Any], args[0]) + return cast(type[ExcelFieldCodec], args[0]) - return cast(type[Any], annotation) + return cast(type[ExcelFieldCodec], annotation) @property - def value_type(self) -> type[Any]: + def value_type(self) -> type[ExcelFieldCodec]: """Backward-compatible alias for excel_codec.""" return self.excel_codec @@ -67,14 +67,14 @@ def runtime_metadata(self) -> FieldMetaInfo: declared = self.declared_metadata return declared.bind_runtime( required=self.required, - excel_codec=cast(type[ExcelFieldCodec], self.excel_codec), + excel_codec=self.excel_codec, parent_label=declared.label, parent_key=Key(self.name), key=Key(self.name), offset=0, ) - def validate_value(self, raw_value: Any) -> Any: + def validate_value(self, raw_value: object) -> object: if raw_value is None: if self.allows_none and not self.required: return None @@ -116,12 +116,12 @@ def get_model_field_names(model: type[BaseModel]) -> list[str]: def instantiate_pydantic_model[ModelT: BaseModel]( - data: Mapping[str, Any], + data: Mapping[str, object], model: type[ModelT], ) -> ModelT | list[ExcelCellError | ExcelRowError]: """Instantiate a Pydantic model and return mapped Excel errors when validation fails.""" model_adapter = PydanticModelAdapter(model) - normalized_data: dict[str, Any] = {} + normalized_data: dict[str, object] = {} errors: list[ExcelCellError | ExcelRowError] = [] failed_fields: set[str] = set() @@ -158,18 +158,14 @@ def _extract_pydantic_model(model: PydanticModelAdapter) -> Generator[FieldMetaI inherited = sub_field_info.inherited_from(declared_metadata) yield inherited.bind_runtime( required=field_adapter.required, - excel_codec=cast(type[ExcelFieldCodec], excel_codec), + excel_codec=excel_codec, parent_label=declared_metadata.label, parent_key=Key(field_adapter.name), key=key, offset=offset, ) - - elif issubclass(excel_codec, ExcelFieldCodec): - yield field_adapter.runtime_metadata() - else: - raise ProgrammaticError(msg(MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED, value_type=excel_codec)) + yield field_adapter.runtime_metadata() def _handle_error( @@ -188,7 +184,7 @@ def _handle_error( def _model_validate[ModelT: BaseModel]( - data: dict[str, Any], + data: dict[str, object], model: type[ModelT], model_adapter: PydanticModelAdapter, failed_fields: set[str], diff --git a/src/excelalchemy/metadata.py b/src/excelalchemy/metadata.py index ac0eda3..2e9455c 100644 --- a/src/excelalchemy/metadata.py +++ b/src/excelalchemy/metadata.py @@ -4,7 +4,7 @@ import datetime import logging from collections.abc import Callable, Mapping, Set -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from functools import cached_property from typing import Any, Self, cast @@ -37,6 +37,16 @@ type FieldIncludeExclude = Set[IntStr] | bool | None +def _normalize_character_set(character_set: set[CharacterSet] | None) -> frozenset[CharacterSet]: + return frozenset(character_set or set(CharacterSet)) + + +def _normalize_options(options: list[Option] | tuple[Option, ...] | None) -> tuple[Option, ...] | None: + if options is None: + return None + return tuple(options) + + class PatchFieldMeta(BaseModel): unique: bool | None = False # Workbook hint only. Runtime uniqueness is enforced elsewhere. is_primary_key: bool | None = False # Workbook hint only. Runtime primary-key behavior is configured separately. @@ -44,7 +54,7 @@ class PatchFieldMeta(BaseModel): options: list[Option] | None = None -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class DeclaredFieldMeta: """Static workbook field declaration supplied by user code.""" @@ -56,7 +66,7 @@ class DeclaredFieldMeta: order: int -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class RuntimeFieldBinding: """Runtime identity assigned after schema extraction flattens the model.""" @@ -67,21 +77,21 @@ class RuntimeFieldBinding: excel_codec: type[ExcelFieldCodec] = UndefinedFieldCodec -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class WorkbookPresentationMeta: """Workbook-facing comment and formatting metadata.""" - character_set: set[CharacterSet] = field(default_factory=lambda: set(CharacterSet)) + character_set: frozenset[CharacterSet] = field(default_factory=lambda: frozenset(CharacterSet)) fraction_digits: int | None = None timezone: datetime.timezone = field(default_factory=lambda: datetime.timezone(datetime.timedelta(hours=8), 'CST')) date_format: DateFormat | None = None date_range_option: DataRangeOption | None = None - options: list[Option] | None = None + options: tuple[Option, ...] | None = None unit: str | None = None hint: str | None = None -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class ImportConstraints: """Importer-side validation hints mirrored from Pydantic constraints.""" @@ -136,12 +146,12 @@ def __init__( ) self.runtime_binding = RuntimeFieldBinding() self.presentation_meta = WorkbookPresentationMeta( - character_set=character_set or set(CharacterSet), + character_set=_normalize_character_set(character_set), fraction_digits=fraction_digits, timezone=timezone or datetime.timezone(datetime.timedelta(hours=8), 'CST'), date_format=date_format, date_range_option=date_range_option, - options=options, + options=_normalize_options(options), unit=unit, hint=hint, ) @@ -196,7 +206,7 @@ def excel_codec(self) -> type[ExcelFieldCodec]: @excel_codec.setter def excel_codec(self, value: type[ExcelFieldCodec]) -> None: - self.runtime_binding.excel_codec = value + self.runtime_binding = replace(self.runtime_binding, excel_codec=value) @property def value_type(self) -> type[ExcelFieldCodec]: @@ -383,7 +393,7 @@ def label(self) -> Label: @label.setter def label(self, value: str | Label) -> None: - self.declared_meta.label = Label(value) + self.declared_meta = replace(self.declared_meta, label=Label(value)) @property def is_primary_key(self) -> bool: @@ -391,7 +401,7 @@ def is_primary_key(self) -> bool: @is_primary_key.setter def is_primary_key(self, value: bool) -> None: - self.declared_meta.is_primary_key = value + self.declared_meta = replace(self.declared_meta, is_primary_key=value) @property def unique(self) -> bool: @@ -399,7 +409,7 @@ def unique(self) -> bool: @unique.setter def unique(self, value: bool) -> None: - self.declared_meta.unique = value + self.declared_meta = replace(self.declared_meta, unique=value) @property def ignore_import(self) -> bool: @@ -407,7 +417,7 @@ def ignore_import(self) -> bool: @ignore_import.setter def ignore_import(self, value: bool) -> None: - self.declared_meta.ignore_import = value + self.declared_meta = replace(self.declared_meta, ignore_import=value) @property def required(self) -> bool | None: @@ -415,7 +425,7 @@ def required(self) -> bool | None: @required.setter def required(self, value: bool | None) -> None: - self.declared_meta.required = value + self.declared_meta = replace(self.declared_meta, required=value) @property def order(self) -> int: @@ -423,7 +433,7 @@ def order(self) -> int: @order.setter def order(self, value: int) -> None: - self.declared_meta.order = value + self.declared_meta = replace(self.declared_meta, order=value) @property def parent_label(self) -> Label | None: @@ -431,7 +441,7 @@ def parent_label(self) -> Label | None: @parent_label.setter def parent_label(self, value: Label | None) -> None: - self.runtime_binding.parent_label = value + self.runtime_binding = replace(self.runtime_binding, parent_label=value) @property def key(self) -> Key | None: @@ -439,7 +449,7 @@ def key(self) -> Key | None: @key.setter def key(self, value: Key | None) -> None: - self.runtime_binding.key = value + self.runtime_binding = replace(self.runtime_binding, key=value) @property def parent_key(self) -> Key | None: @@ -447,7 +457,7 @@ def parent_key(self) -> Key | None: @parent_key.setter def parent_key(self, value: Key | None) -> None: - self.runtime_binding.parent_key = value + self.runtime_binding = replace(self.runtime_binding, parent_key=value) @property def offset(self) -> int: @@ -455,15 +465,15 @@ def offset(self) -> int: @offset.setter def offset(self, value: int) -> None: - self.runtime_binding.offset = value + self.runtime_binding = replace(self.runtime_binding, offset=value) @property def character_set(self) -> set[CharacterSet]: - return self.presentation_meta.character_set + return set(self.presentation_meta.character_set) @character_set.setter def character_set(self, value: set[CharacterSet]) -> None: - self.presentation_meta.character_set = value + self.presentation_meta = replace(self.presentation_meta, character_set=_normalize_character_set(value)) @property def fraction_digits(self) -> int | None: @@ -471,7 +481,7 @@ def fraction_digits(self) -> int | None: @fraction_digits.setter def fraction_digits(self, value: int | None) -> None: - self.presentation_meta.fraction_digits = value + self.presentation_meta = replace(self.presentation_meta, fraction_digits=value) @property def timezone(self) -> datetime.timezone: @@ -479,7 +489,7 @@ def timezone(self) -> datetime.timezone: @timezone.setter def timezone(self, value: datetime.timezone) -> None: - self.presentation_meta.timezone = value + self.presentation_meta = replace(self.presentation_meta, timezone=value) @property def date_format(self) -> DateFormat | None: @@ -487,7 +497,7 @@ def date_format(self) -> DateFormat | None: @date_format.setter def date_format(self, value: DateFormat | None) -> None: - self.presentation_meta.date_format = value + self.presentation_meta = replace(self.presentation_meta, date_format=value) @property def date_range_option(self) -> DataRangeOption | None: @@ -495,15 +505,17 @@ def date_range_option(self) -> DataRangeOption | None: @date_range_option.setter def date_range_option(self, value: DataRangeOption | None) -> None: - self.presentation_meta.date_range_option = value + self.presentation_meta = replace(self.presentation_meta, date_range_option=value) @property def options(self) -> list[Option] | None: - return self.presentation_meta.options + if self.presentation_meta.options is None: + return None + return list(self.presentation_meta.options) @options.setter def options(self, value: list[Option] | None) -> None: - self.presentation_meta.options = value + self.presentation_meta = replace(self.presentation_meta, options=_normalize_options(value)) @property def unit(self) -> str | None: @@ -511,7 +523,7 @@ def unit(self) -> str | None: @unit.setter def unit(self, value: str | None) -> None: - self.presentation_meta.unit = value + self.presentation_meta = replace(self.presentation_meta, unit=value) @property def hint(self) -> str | None: @@ -519,7 +531,7 @@ def hint(self) -> str | None: @hint.setter def hint(self, value: str | None) -> None: - self.presentation_meta.hint = value + self.presentation_meta = replace(self.presentation_meta, hint=value) @property def importer_ge(self) -> float | None: @@ -527,7 +539,7 @@ def importer_ge(self) -> float | None: @importer_ge.setter def importer_ge(self, value: float | None) -> None: - self.import_constraints.ge = value + self.import_constraints = replace(self.import_constraints, ge=value) @property def importer_le(self) -> float | None: @@ -535,7 +547,7 @@ def importer_le(self) -> float | None: @importer_le.setter def importer_le(self, value: float | None) -> None: - self.import_constraints.le = value + self.import_constraints = replace(self.import_constraints, le=value) @property def importer_max_digits(self) -> int | None: @@ -543,7 +555,7 @@ def importer_max_digits(self) -> int | None: @importer_max_digits.setter def importer_max_digits(self, value: int | None) -> None: - self.import_constraints.max_digits = value + self.import_constraints = replace(self.import_constraints, max_digits=value) @property def importer_decimal_places(self) -> int | None: @@ -551,7 +563,7 @@ def importer_decimal_places(self) -> int | None: @importer_decimal_places.setter def importer_decimal_places(self, value: int | None) -> None: - self.import_constraints.decimal_places = value + self.import_constraints = replace(self.import_constraints, decimal_places=value) @property def importer_min_length(self) -> int | None: @@ -559,7 +571,7 @@ def importer_min_length(self) -> int | None: @importer_min_length.setter def importer_min_length(self, value: int | None) -> None: - self.import_constraints.min_length = value + self.import_constraints = replace(self.import_constraints, min_length=value) @property def importer_max_length(self) -> int | None: @@ -567,7 +579,7 @@ def importer_max_length(self) -> int | None: @importer_max_length.setter def importer_max_length(self, value: int | None) -> None: - self.import_constraints.max_length = value + self.import_constraints = replace(self.import_constraints, max_length=value) @property def importer_min_items(self) -> int | None: @@ -575,7 +587,7 @@ def importer_min_items(self) -> int | None: @importer_min_items.setter def importer_min_items(self, value: int | None) -> None: - self.import_constraints.min_items = value + self.import_constraints = replace(self.import_constraints, min_items=value) @property def importer_max_items(self) -> int | None: @@ -583,7 +595,7 @@ def importer_max_items(self) -> int | None: @importer_max_items.setter def importer_max_items(self, value: int | None) -> None: - self.import_constraints.max_items = value + self.import_constraints = replace(self.import_constraints, max_items=value) @property def importer_unique_items(self) -> bool | None: @@ -591,7 +603,7 @@ def importer_unique_items(self) -> bool | None: @importer_unique_items.setter def importer_unique_items(self, value: bool | None) -> None: - self.import_constraints.unique_items = value + self.import_constraints = replace(self.import_constraints, unique_items=value) def extract_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: diff --git a/tests/unit/test_field_metadata.py b/tests/unit/test_field_metadata.py index cf13fc5..91f661c 100644 --- a/tests/unit/test_field_metadata.py +++ b/tests/unit/test_field_metadata.py @@ -1,3 +1,4 @@ +from dataclasses import FrozenInstanceError from typing import Annotated from pydantic import BaseModel, Field @@ -372,3 +373,47 @@ class Importer(BaseModel): assert original.parent_label == '邮箱' assert cloned.hint == '新提示' assert cloned.parent_label == '父' + + async def test_split_internal_layers_are_immutable_value_objects(self): + class Importer(BaseModel): + email: Email = FieldMeta(label='邮箱', order=1, hint='原始提示') + + alchemy = self.build_alchemy(Importer) + field_meta = alchemy.ordered_field_meta[0] + + with self.assertRaises(FrozenInstanceError): + field_meta.declared_meta.label = '新标签' # type: ignore[misc] + + with self.assertRaises(FrozenInstanceError): + field_meta.runtime_binding.parent_label = '父' # type: ignore[misc] + + with self.assertRaises(FrozenInstanceError): + field_meta.presentation_meta.hint = '新提示' # type: ignore[misc] + + with self.assertRaises(FrozenInstanceError): + field_meta.import_constraints.max_length = 20 # type: ignore[misc] + + async def test_mutating_facade_replaces_internal_layers_instead_of_mutating_in_place(self): + class Importer(BaseModel): + email: Email = FieldMeta( + label='邮箱', + order=1, + hint='原始提示', + options=[Option(id=OptionId('work'), name='工作邮箱')], + ) + + alchemy = self.build_alchemy(Importer) + field_meta = alchemy.ordered_field_meta[0] + + original_declared_meta = field_meta.declared_meta + original_presentation_meta = field_meta.presentation_meta + + field_meta.label = '新邮箱' + field_meta.hint = '新的提示' + field_meta.options = [Option(id=OptionId('personal'), name='个人邮箱')] + + assert field_meta.declared_meta is not original_declared_meta + assert field_meta.presentation_meta is not original_presentation_meta + assert field_meta.label == '新邮箱' + assert field_meta.hint == '新的提示' + assert field_meta.options == [Option(id=OptionId('personal'), name='个人邮箱')]