Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/releases/2.2.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions docs/releases/2.2.1.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 13 additions & 17 deletions src/excelalchemy/helper/pydantic.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-codec annotations during schema extraction

_extract_pydantic_model now unconditionally falls through to yield field_adapter.runtime_metadata() for every non-composite type, so models like name: str = FieldMeta(...) are accepted instead of being rejected. That stores str as excel_codec, and later import validation calls self.excel_codec.normalize_import_value(...), which raises runtime AttributeError rather than a clear configuration ProgrammaticError at startup. This is a regression from the previous issubclass(excel_codec, ExcelFieldCodec) guard and makes invalid model declarations fail much later and less predictably.

Useful? React with 👍 / 👎.



def _handle_error(
Expand All @@ -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],
Expand Down
Loading