diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 9e9c397..ad34640 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -46,12 +46,14 @@ jobs: uv venv .pkg-smoke-wheel --python 3.14 uv pip install --python .pkg-smoke-wheel/bin/python dist/*.whl .pkg-smoke-wheel/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" + .pkg-smoke-wheel/bin/python scripts/smoke_package.py - name: Smoke test source distribution installation run: | uv venv .pkg-smoke-sdist --python 3.14 uv pip install --python .pkg-smoke-sdist/bin/python dist/*.tar.gz .pkg-smoke-sdist/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" + .pkg-smoke-sdist/bin/python scripts/smoke_package.py - name: Set artifact metadata id: artifact-meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d777d..15673b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ 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.2] - Unreleased + +This release continues the stable 2.x line with stronger developer ergonomics, +clearer public API guidance, and better release-time smoke coverage. + +### Added + +- Added repository examples for: + `Annotated` schema declarations, custom storage integration, and FastAPI + upload flows +- Added `docs/public-api.md` to document stable public modules, compatibility + modules, and internal modules +- Added `scripts/smoke_package.py` so release workflows can smoke-test template + generation, import execution, and export generation from an installed package + +### Changed + +- Updated the release workflow to run package-level smoke tests after wheel and + source-distribution installation +- Updated `README.md`, `README_cn.md`, and `MIGRATIONS.md` to point users + toward examples, public API guidance, and the recommended config/storage + patterns + +### Compatibility Notes + +- No public import or export workflow API was removed in this release +- The new examples and docs clarify recommended public paths without changing + existing 2.x compatibility behavior + +### Release Summary + +- the repository now includes real integration examples instead of relying only + on README snippets +- public API boundaries are documented explicitly +- release publishing now includes stronger smoke coverage for installed + packages + ## [2.2.1] - Unreleased This release continues the stable 2.x line with deeper metadata layering, diff --git a/MIGRATIONS.md b/MIGRATIONS.md index ba7fe13..d65bd6e 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -51,7 +51,7 @@ Prefer explicit storage objects: from excelalchemy import ExporterConfig from excelalchemy.core.storage_minio import MinioStorageGateway -config = ExporterConfig( +config = ExporterConfig.for_storage( ExporterModel, storage=MinioStorageGateway(minio_client, bucket_name='excel-files'), ) @@ -59,7 +59,30 @@ config = ExporterConfig( ### Legacy compatibility -The older `minio=..., bucket_name=..., url_expires=...` configuration style is still accepted for compatibility, but it is no longer the preferred shape of the API. +The older `minio=..., bucket_name=..., url_expires=...` configuration style is still accepted for compatibility, but it is no longer the preferred shape of the API and now emits a deprecation warning in the 2.x line. + +### Recommended importer constructors + +The 2.2 line also adds more explicit constructors for common importer modes: + +```python +config = ImporterConfig.for_create(ImporterModel, creator=create_func, storage=storage) +``` + +```python +config = ImporterConfig.for_update(ImporterModel, updater=update_func, storage=storage) +``` + +```python +config = ImporterConfig.for_create_or_update( + create_importer_model=CreateModel, + update_importer_model=UpdateModel, + is_data_exist=is_data_exist, + creator=create_func, + updater=update_func, + storage=storage, +) +``` ## pandas @@ -99,6 +122,7 @@ Additional top-level module guidance: - `excelalchemy.exceptions` is the stable replacement for `excelalchemy.exc` - `excelalchemy.identity` is now a compatibility import; prefer `from excelalchemy import Label, Key, UrlStr, ...` - `excelalchemy.header_models` is internal and should not be imported in application code +- `docs/public-api.md` summarizes stable public modules, compatibility modules, and internal modules ## Recommended Upgrade Checklist diff --git a/README.md b/README.md index fa3642b..6fa3a18 100755 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Lint](https://img.shields.io/badge/lint-ruff-D7FF64) ![Typing](https://img.shields.io/badge/typing-pyright-2C6BED) -[中文 README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README_cn.md) · [About](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/ABOUT.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Locale Policy](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/locale.md) · [Changelog](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/CHANGELOG.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md) +[中文 README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README_cn.md) · [About](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/ABOUT.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Public API](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md) · [Locale Policy](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/locale.md) · [Changelog](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/CHANGELOG.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md) ExcelAlchemy is a schema-driven Python library for Excel import and export workflows. It turns Pydantic models into typed workbook contracts: generate templates, validate uploads, map failures back to rows @@ -16,7 +16,7 @@ This repository is also a design artifact. It documents a series of deliberate engineering choices: `src/` layout, Pydantic v2 migration, pandas removal, pluggable storage, `uv`-based workflows, and locale-aware workbook output. -The current stable release is `2.1.0`, which continues the ExcelAlchemy 2.x line with a lighter import facade, clearer internal layering, and naming cleanup. +The current stable release is `2.2.0`, which continues the ExcelAlchemy 2.x line with a lighter import facade, clearer config ergonomics, and a more explicit protocol-first storage story. ## At a Glance @@ -173,6 +173,20 @@ If you want the built-in Minio backend: pip install "ExcelAlchemy[minio]" ``` +## Examples + +Practical examples live in the repository: + +- [`examples/annotated_schema.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/annotated_schema.py) +- [`examples/custom_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/custom_storage.py) +- [`examples/fastapi_upload.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_upload.py) + +## Public API Boundaries + +If you want to know which modules are stable public entry points versus +compatibility shims or internal modules, see +[`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md). + ## Locale-Aware Workbook Output `locale` affects workbook-facing display text such as: diff --git a/README_cn.md b/README_cn.md index 441e824..c974754 100644 --- a/README_cn.md +++ b/README_cn.md @@ -5,7 +5,7 @@ ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 -当前稳定发布版本是 `2.1.0`,它在稳定的 ExcelAlchemy 2.x 线上继续推进了导入 facade 轻量化、内部结构分层和命名收口。 +当前稳定发布版本是 `2.2.0`,它在稳定的 ExcelAlchemy 2.x 线上继续推进了导入 facade 轻量化、更清晰的配置构造方式,以及更明确的 protocol-first storage 叙事。 你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 diff --git a/docs/public-api.md b/docs/public-api.md new file mode 100644 index 0000000..3bf95ae --- /dev/null +++ b/docs/public-api.md @@ -0,0 +1,94 @@ +# Public API Guide + +This page summarizes which ExcelAlchemy modules are intended to be stable public +entry points, which ones remain compatibility shims for the 2.x line, and which +ones should be treated as internal implementation details. + +## Stable Public Modules + +These modules are the recommended import paths for application code: + +- `excelalchemy` + The package root re-exports the most common public types, codecs, config + objects, and result models. +- `excelalchemy.config` + Public workflow configuration objects such as `ImporterConfig`, + `ExporterConfig`, and `ImportMode`. +- `excelalchemy.metadata` + Public metadata entry points such as `FieldMeta(...)`, `ExcelMeta(...)`, and + `PatchFieldMeta`. +- `excelalchemy.results` + Structured import result models such as `ImportResult`, + `ValidateResult`, and `ValidateHeaderResult`. +- `excelalchemy.exceptions` + Stable exception module for `ConfigError`, `ExcelCellError`, + `ExcelRowError`, and `ProgrammaticError`. +- `excelalchemy.codecs` + Public codec namespace for built-in Excel field codecs. + +## Stable Public Protocols And Concepts + +- `ExcelStorage` + The recommended backend integration contract for workbook IO. +- `storage=...` + The recommended backend configuration pattern in the 2.x line. +- `ExcelArtifact` + The recommended return shape when you need bytes, base64, or data URLs. + +## Compatibility Modules In 2.x + +These imports still work in the 2.x line, but should be treated as migration +paths rather than long-term public module choices: + +- `excelalchemy.exc` + Deprecated compatibility layer. Prefer `excelalchemy.exceptions`. +- `excelalchemy.identity` + Deprecated compatibility layer. Prefer imports from the package root. +- `excelalchemy.header_models` + Compatibility layer; application code should not depend on it. +- `excelalchemy.types.*` + Deprecated compatibility namespace retained for 2.x migrations. +- `excelalchemy.util.convertor` + Deprecated compatibility import. Prefer `excelalchemy.util.converter`. + +## Internal Modules + +These modules may change without notice and should not be imported directly in +application code: + +- `excelalchemy.core.*` +- `excelalchemy.helper.*` +- `excelalchemy.i18n.*` +- `excelalchemy._primitives.*` + +The internals are intentionally allowed to evolve as the 2.x architecture +continues to consolidate. + +## Recommended Import Style + +Prefer imports like: + +```python +from excelalchemy import ExcelAlchemy, ExcelMeta, FieldMeta, ImporterConfig, ValidateResult +from excelalchemy.config import ExporterConfig, ImportMode +from excelalchemy.exceptions import ConfigError +``` + +Avoid depending on implementation details such as: + +```python +from excelalchemy.core.alchemy import ExcelAlchemy +from excelalchemy.core.headers import ExcelHeaderParser +from excelalchemy._primitives.identity import UniqueLabel +``` + +## Deprecation Direction + +The 2.x line keeps compatibility shims to support migration, but the long-term +direction is: + +- public API from `excelalchemy`, `excelalchemy.config`, + `excelalchemy.metadata`, `excelalchemy.results`, `excelalchemy.exceptions`, + and `excelalchemy.codecs` +- backend integration through `ExcelStorage` +- internal orchestration and helper modules treated as implementation details diff --git a/docs/releases/2.2.2.md b/docs/releases/2.2.2.md new file mode 100644 index 0000000..7c1733c --- /dev/null +++ b/docs/releases/2.2.2.md @@ -0,0 +1,77 @@ +# 2.2.2 Release Checklist + +This checklist is intended for the `2.2.2` release on top of the stable 2.x +line. + +## Purpose + +- publish the next stable 2.x refinement release of ExcelAlchemy +- present `2.2.2` as a developer-ergonomics and release-hardening release +- ship real integration examples and explicit public API documentation +- strengthen package-level smoke coverage in the release workflow + +## Release Positioning + +`2.2.2` should be presented as a repository-quality release: + +- the public import and export workflow API stays stable +- examples now show realistic integration paths beyond README snippets +- public API boundaries are documented explicitly +- release publishing has stronger smoke verification for installed packages + +## Before Tagging + +1. Confirm the intended version in `src/excelalchemy/__init__.py`. +2. Review the `2.2.2` section in `CHANGELOG.md`. +3. Confirm `README.md`, `README_cn.md`, `README-pypi.md`, and `MIGRATIONS.md` + are aligned with the recommended public paths. +4. Confirm the new examples still reflect the current public API. +5. Confirm `docs/public-api.md` matches the current public module boundaries. + +## 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 +python scripts/smoke_package.py +rm -rf dist +uv build +uvx twine check dist/* +``` + +## 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.2`. +4. Use the `2.2.2` 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: + +- the repository now includes real integration examples +- public API boundaries are documented explicitly +- release publishing now has stronger smoke verification for installed packages + +## Recommended Release Messaging + +Prefer wording that emphasizes maturity and usability: + +- "continues the stable 2.x line" +- "adds real integration examples" +- "documents public API boundaries explicitly" +- "strengthens release-time smoke verification" + +## Done When + +- the tag `v2.2.2` is published +- the GitHub Release notes clearly communicate the three release themes +- examples and public API docs are linked from the main project pages +- release workflow smoke tests pass for installed packages diff --git a/examples/annotated_schema.py b/examples/annotated_schema.py new file mode 100644 index 0000000..3c22970 --- /dev/null +++ b/examples/annotated_schema.py @@ -0,0 +1,29 @@ +"""Minimal example that uses Annotated + ExcelMeta declarations.""" + +from __future__ import annotations + +from typing import Annotated + +from pydantic import BaseModel, Field + +from excelalchemy import Email, ExcelAlchemy, ExcelMeta, ImporterConfig, Number + + +class EmployeeImporter(BaseModel): + full_name: Annotated[str, Field(min_length=2), ExcelMeta(label='Full name', order=1, hint='Use the legal name')] + age: Annotated[Number, Field(ge=18), ExcelMeta(label='Age', order=2)] + work_email: Annotated[ + Email, + Field(min_length=8), + ExcelMeta(label='Work email', order=3, hint='Use your company email address'), + ] + + +def main() -> None: + alchemy = ExcelAlchemy(ImporterConfig.for_create(EmployeeImporter, locale='en')) + template = alchemy.download_template_artifact(filename='employee-template.xlsx') + print(f'Generated template: {template.filename} ({len(template.as_bytes())} bytes)') + + +if __name__ == '__main__': + main() diff --git a/examples/custom_storage.py b/examples/custom_storage.py new file mode 100644 index 0000000..442982b --- /dev/null +++ b/examples/custom_storage.py @@ -0,0 +1,43 @@ +"""Custom storage example that keeps uploaded workbooks in memory.""" + +from __future__ import annotations + +from base64 import b64decode + +from pydantic import BaseModel + +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, FieldMeta, Number, String, UrlStr +from excelalchemy.core.table import WorksheetTable + + +class InMemoryStorage(ExcelStorage): + def __init__(self) -> None: + self.uploaded: dict[str, bytes] = {} + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + raise NotImplementedError('This example only demonstrates export uploads') + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + _, payload = content_with_prefix.split(',', 1) + self.uploaded[output_name] = b64decode(payload) + return UrlStr(f'memory://{output_name}') + + +class EmployeeExporter(BaseModel): + full_name: String = FieldMeta(label='Full name', order=1) + age: Number = FieldMeta(label='Age', order=2) + + +def main() -> None: + storage = InMemoryStorage() + alchemy = ExcelAlchemy(ExporterConfig.for_storage(EmployeeExporter, storage=storage, locale='en')) + uploaded_url = alchemy.export_upload( + 'employees.xlsx', + data=[{'full_name': 'Taylor Chen', 'age': 32}], + ) + print(uploaded_url) + print(f'Uploaded bytes: {len(storage.uploaded["employees.xlsx"])}') + + +if __name__ == '__main__': + main() diff --git a/examples/fastapi_upload.py b/examples/fastapi_upload.py new file mode 100644 index 0000000..9cc3dbd --- /dev/null +++ b/examples/fastapi_upload.py @@ -0,0 +1,68 @@ +"""FastAPI integration sketch for template download and workbook import.""" + +from __future__ import annotations + +from io import BytesIO + +from fastapi import FastAPI, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from excelalchemy import ExcelAlchemy, ExcelStorage, FieldMeta, ImporterConfig, Number, String, UrlStr +from excelalchemy.core.table import WorksheetTable + + +class EmployeeImporter(BaseModel): + full_name: String = FieldMeta(label='Full name', order=1) + age: Number = FieldMeta(label='Age', order=2) + + +class RequestScopedStorage(ExcelStorage): + def __init__(self) -> None: + self.uploaded: dict[str, bytes] = {} + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + raise NotImplementedError('Wire this method to your own request-scoped file source') + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + raise NotImplementedError('Wire this method to your own object storage backend') + + +async def create_employee(row: dict[str, object], context: dict[str, object] | None) -> dict[str, object]: + if context is not None: + row['tenant_id'] = context['tenant_id'] + return row + + +app = FastAPI() + + +@app.get('/employee-template.xlsx') +async def download_template() -> StreamingResponse: + alchemy = ExcelAlchemy(ImporterConfig.for_create(EmployeeImporter, locale='en')) + artifact = alchemy.download_template_artifact(filename='employee-template.xlsx') + return StreamingResponse( + BytesIO(artifact.as_bytes()), + media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': 'attachment; filename=employee-template.xlsx'}, + ) + + +@app.post('/employee-imports') +async def import_employees(file: UploadFile) -> dict[str, object]: + if not file.filename: + raise HTTPException(status_code=400, detail='An Excel file is required') + + storage = RequestScopedStorage() + alchemy = ExcelAlchemy( + ImporterConfig.for_create( + EmployeeImporter, + creator=create_employee, + storage=storage, + locale='en', + ) + ) + alchemy.add_context({'tenant_id': 'tenant-001'}) + + result = await alchemy.import_data(file.filename, 'employee-import-result.xlsx') + return result.model_dump() diff --git a/scripts/smoke_package.py b/scripts/smoke_package.py new file mode 100644 index 0000000..361a149 --- /dev/null +++ b/scripts/smoke_package.py @@ -0,0 +1,99 @@ +"""Release smoke test for installed ExcelAlchemy packages.""" + +from __future__ import annotations + +import asyncio +import io +from base64 import b64decode + +from openpyxl import load_workbook +from pydantic import BaseModel + +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, FieldMeta, ImporterConfig, Number, String, UrlStr +from excelalchemy.core.table import WorksheetTable + + +class SmokeImporter(BaseModel): + full_name: String = FieldMeta(label='Full name', order=1) + age: Number = FieldMeta(label='Age', order=2) + + +class InMemorySmokeStorage(ExcelStorage): + def __init__(self) -> None: + self.fixtures: dict[str, bytes] = {} + self.uploaded: dict[str, bytes] = {} + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + workbook = load_workbook(io.BytesIO(self.fixtures[input_excel_name]), data_only=True) + try: + worksheet = workbook[sheet_name] + rows = [ + [None if value is None else str(value) for value in row] + for row in worksheet.iter_rows( + min_row=skiprows + 1, + max_row=worksheet.max_row, + max_col=worksheet.max_column, + values_only=True, + ) + ] + finally: + workbook.close() + + while rows and all(value is None for value in rows[-1]): + rows.pop() + + return WorksheetTable(rows=rows) + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + _, payload = content_with_prefix.split(',', 1) + self.uploaded[output_name] = b64decode(payload) + return UrlStr(f'memory://{output_name}') + + +async def _create_employee(row: dict[str, object], context: object) -> dict[str, object]: + return row + + +def _build_import_fixture(storage: InMemorySmokeStorage, template_bytes: bytes) -> None: + workbook = load_workbook(io.BytesIO(template_bytes)) + try: + worksheet = workbook['Sheet1'] + worksheet['A3'] = 'TaylorChen' + worksheet['B3'] = '32' + + buffer = io.BytesIO() + workbook.save(buffer) + storage.fixtures['smoke-input.xlsx'] = buffer.getvalue() + finally: + workbook.close() + + +async def main() -> None: + storage = InMemorySmokeStorage() + + importer = ExcelAlchemy( + ImporterConfig.for_create( + SmokeImporter, + creator=_create_employee, + storage=storage, + locale='en', + ) + ) + template = importer.download_template_artifact(filename='smoke-template.xlsx') + assert len(template.as_bytes()) > 0 + + _build_import_fixture(storage, template.as_bytes()) + import_result = await importer.import_data('smoke-input.xlsx', 'smoke-result.xlsx') + assert import_result.success_count == 1 + assert import_result.fail_count == 0 + + exporter = ExcelAlchemy(ExporterConfig.for_storage(SmokeImporter, storage=storage, locale='en')) + artifact = exporter.export_artifact( + [{'full_name': 'TaylorChen', 'age': 32}], + filename='smoke-export.xlsx', + ) + assert len(artifact.as_bytes()) > 0 + + +if __name__ == '__main__': + asyncio.run(main())