From cd0c9fd2024b0c81eb1f2187d7df89e0c5796cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 4 Apr 2026 09:21:06 +0800 Subject: [PATCH] feat(v2.2.5): Error UX polish --- .github/workflows/ci.yml | 7 + .github/workflows/python-publish.yml | 4 + CHANGELOG.md | 80 ++++++++- MIGRATIONS.md | 28 ++- README-pypi.md | 43 ++++- README.md | 45 ++++- README_cn.md | 44 ++++- docs/examples-showcase.md | 140 +++++++++++++++ docs/getting-started.md | 155 ++++++++++++++++ docs/public-api.md | 46 +++++ docs/releases/2.2.5.md | 104 +++++++++++ examples/README.md | 44 ++++- examples/fastapi_upload.py | 168 +++++++++++++++--- files/example-outputs/annotated-schema.txt | 1 + .../create-or-update-import.txt | 8 + files/example-outputs/custom-storage.txt | 2 + .../example-outputs/date-and-range-fields.txt | 2 + .../employee-import-workflow.txt | 7 + files/example-outputs/export-workflow.txt | 5 + files/example-outputs/selection-fields.txt | 2 + scripts/generate_example_output_assets.py | 55 ++++++ scripts/smoke_examples.py | 75 ++++++++ scripts/smoke_package.py | 8 + src/excelalchemy/__init__.py | 13 +- src/excelalchemy/codecs/date_range.py | 5 +- src/excelalchemy/codecs/multi_checkbox.py | 14 +- src/excelalchemy/codecs/organization.py | 3 +- src/excelalchemy/codecs/staff.py | 3 +- src/excelalchemy/core/alchemy.py | 18 +- src/excelalchemy/core/headers.py | 5 +- src/excelalchemy/core/import_session.py | 26 +-- src/excelalchemy/core/rows.py | 24 ++- src/excelalchemy/core/schema.py | 10 +- src/excelalchemy/core/writer.py | 12 +- src/excelalchemy/exceptions.py | 107 ++++++++--- src/excelalchemy/helper/pydantic.py | 55 +++++- src/excelalchemy/i18n/messages.py | 15 ++ src/excelalchemy/metadata.py | 12 +- src/excelalchemy/results.py | 86 ++++++++- src/excelalchemy/util/converter.py | 5 +- src/excelalchemy/util/file.py | 26 ++- .../test_core_components_contract.py | 2 + tests/contracts/test_pydantic_contract.py | 26 ++- tests/contracts/test_result_contract.py | 18 +- tests/integration/test_examples_smoke.py | 63 ++++++- tests/unit/codecs/test_date_codec.py | 5 +- tests/unit/test_excel_exceptions.py | 103 ++++++++++- 47 files changed, 1593 insertions(+), 136 deletions(-) create mode 100644 docs/examples-showcase.md create mode 100644 docs/getting-started.md create mode 100644 docs/releases/2.2.5.md create mode 100644 files/example-outputs/annotated-schema.txt create mode 100644 files/example-outputs/create-or-update-import.txt create mode 100644 files/example-outputs/custom-storage.txt create mode 100644 files/example-outputs/date-and-range-fields.txt create mode 100644 files/example-outputs/employee-import-workflow.txt create mode 100644 files/example-outputs/export-workflow.txt create mode 100644 files/example-outputs/selection-fields.txt create mode 100644 scripts/generate_example_output_assets.py create mode 100644 scripts/smoke_examples.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96666fc..f390caa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,13 @@ jobs: run: | uv run pytest --cov=excelalchemy --cov-report=term-missing:skip-covered --cov-report=xml:coverage.xml --junitxml=pytest.xml tests + - name: Run smoke scripts + if: matrix.python-version == '3.14' + run: | + uv run python scripts/smoke_package.py + uv run python scripts/smoke_examples.py + uv run python scripts/generate_example_output_assets.py + - name: Upload coverage artifact if: always() && matrix.python-version == '3.14' uses: actions/upload-artifact@v7 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4eea414..6609677 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -58,6 +58,8 @@ jobs: 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 + .pkg-smoke-wheel/bin/python scripts/smoke_examples.py + .pkg-smoke-wheel/bin/python scripts/generate_example_output_assets.py - name: Smoke test source distribution installation run: | @@ -65,6 +67,8 @@ jobs: 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 + .pkg-smoke-sdist/bin/python scripts/smoke_examples.py + .pkg-smoke-sdist/bin/python scripts/generate_example_output_assets.py - name: Set artifact metadata id: artifact-meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4cd8a..3391748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,83 @@ 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.3] - Unreleased +## [2.2.5] - 2026-04-04 + +This release continues the stable 2.x line with error UX polish, clearer +documentation boundaries, stronger examples and smoke coverage, and continued +typing cleanup across the runtime path. + +### Added + +- Added `CellErrorMap` and `RowIssueMap` as richer workbook-facing error access + containers while preserving 2.x dict-like compatibility +- Added `docs/getting-started.md` to give new users one clear entry point for + installation, schema declaration, workflow setup, and backend configuration +- Added `docs/examples-showcase.md` and example-output assets so examples can + be browsed as a lightweight showcase instead of only as source code +- Added more business-oriented examples, including employee import, + create-or-update import, export workflow, selection-heavy forms, and + date/range field workflows +- Added stronger smoke scripts and release checks for installed packages, + repository examples, and generated example-output assets + +### Changed + +- Polished error UX so row and cell issues are easier to inspect through + dedicated result-map helpers such as `at(...)`, `messages_at(...)`, + `messages_for_row(...)`, and `flatten()` +- Unified exception boundaries around `ProgrammaticError`, `ConfigError`, + `ExcelCellError`, and `ExcelRowError`, including structured `to_dict()` + output and clearer equality semantics +- Normalized common validation messages into more natural, workbook-facing + English such as `This field is required` +- Clarified `FieldMetaInfo` as a compatibility facade over layered metadata + objects and moved more internal consumers and codecs onto `declared`, + `runtime`, `presentation`, and `constraints` +- Continued shrinking typing gray areas outside `metadata.py` and + `helper/pydantic.py` by removing or consolidating low-value `cast(...)` + usage where concrete runtime behavior was already clear +- Strengthened documentation boundaries by cross-linking getting-started, + public API, migrations, examples, showcase, and PyPI-facing README content +- Expanded `examples/README.md` into a recommended reading order with expected + outputs and captured example artifacts + +### Fixed + +- Restored explicit `ProgrammaticError` handling for unsupported + `Annotated[..., Field(...), ExcelMeta(...)]` declarations that use native + Python types instead of `ExcelFieldCodec` subclasses +- Tightened codec resolution in the Pydantic adapter so unsupported + declarations fail at the codec resolution boundary instead of being treated + as valid runtime metadata +- Added regression coverage for the unsupported-annotation path and for error + message quality in the Pydantic adapter + +### Compatibility Notes + +- No public import or export workflow API was removed in this release +- Valid `ExcelFieldCodec` and `CompositeExcelFieldCodec` declarations continue + to work unchanged +- Unsupported native annotations with `ExcelMeta(...)` now fail early with the + intended `ProgrammaticError` +- `storage=...` remains the recommended 2.x backend configuration path, while + legacy built-in Minio fields continue to exist only as compatibility surface +- `FieldMeta(...)` and `ExcelMeta(...)` remain the stable public metadata entry + points while internal metadata continues to consolidate behind them + +### Release Summary + +- import failures are easier to inspect and present through richer error maps +- validation messages are more consistent, more natural, and better suited for + workbook feedback +- examples now read more like real integration guides and are protected by + direct smoke coverage +- getting-started, public API, migrations, examples, and showcase docs now + form a clearer documentation path +- runtime typing boundaries are a little tighter without sacrificing + readability or 2.x compatibility + +## [2.2.3] - Unpublished draft history This release continues the stable 2.x line with a focused validation fix in the Pydantic adapter layer. @@ -94,7 +170,7 @@ clearer public API guidance, and better release-time smoke coverage. - release publishing now includes stronger smoke coverage for installed packages -## [2.2.1] - Unreleased +## [2.2.1] - 2026-04-03 This release continues the stable 2.x line with deeper metadata layering, stronger internal immutability, and tighter type boundaries around the diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 3c35f66..6ab57c8 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -29,6 +29,16 @@ If your application is still pinned to Pydantic v1, upgrade that dependency befo - Storage is now modeled as the `ExcelStorage` protocol - The built-in Minio backend is still available, but as an optional extra +### The 2.x recommendation in one sentence + +For all new 2.x application code, prefer: + +```python +storage=... +``` + +Treat the older built-in Minio fields as compatibility-only API surface. + ### New install patterns Base install: @@ -59,7 +69,14 @@ config = ExporterConfig.for_storage( ### 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 and now emits a deprecation warning in the 2.x line. +The older `minio=..., bucket_name=..., url_expires=...` configuration style is +still accepted for compatibility, but: + +- it is not the recommended 2.x path +- it emits a deprecation warning +- it should be treated as a migration bridge rather than a long-term API choice + +If you are writing new code in the 2.x line, use `storage=...` instead. ### Recommended importer constructors @@ -84,6 +101,15 @@ config = ImporterConfig.for_create_or_update( ) ``` +### Examples and docs + +If you want concrete examples of the recommended 2.x API shape, see: + +- [`docs/getting-started.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) +- [`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md) +- [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md) +- [`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md) + ## pandas - ExcelAlchemy no longer uses or installs `pandas` at runtime diff --git a/README-pypi.md b/README-pypi.md index f8ec226..b1232ce 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -10,7 +10,9 @@ ExcelAlchemy turns Pydantic models into typed workbook contracts: - render workbook-facing output in `zh-CN` or `en` - keep storage pluggable through `ExcelStorage` -[GitHub Repository](https://github.com/RayCarterLab/ExcelAlchemy) · [Full README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md) +The current stable release is `2.2.5`, which continues the 2.x line with richer import-failure feedback, clearer documentation entry points, stronger examples, and stronger smoke coverage. + +[GitHub Repository](https://github.com/RayCarterLab/ExcelAlchemy) · [Full README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README.md) · [Getting Started](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) · [Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Migration Notes](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md) ## Screenshots @@ -75,6 +77,45 @@ alchemy = ExcelAlchemy(ImporterConfig(Importer, locale='en')) template = alchemy.download_template_artifact(filename='people-template.xlsx') ``` +## Example Outputs + +These fixed outputs are generated from the repository examples by +[`scripts/generate_example_output_assets.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/scripts/generate_example_output_assets.py). + +Import workflow: + +```text +Employee import workflow completed +Result: SUCCESS +Success rows: 1 +Failed rows: 0 +Result workbook URL: None +Created rows: 1 +Uploaded artifacts: [] +``` + +Export workflow: + +```text +Export workflow completed +Artifact filename: employees-export.xlsx +Artifact bytes: 6893 +Upload URL: memory://employees-export-upload.xlsx +Uploaded objects: ['employees-export-upload.xlsx'] +``` + +Full captured outputs: + +- [employee-import-workflow.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/employee-import-workflow.txt) +- [create-or-update-import.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/create-or-update-import.txt) +- [export-workflow.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/export-workflow.txt) +- [date-and-range-fields.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/date-and-range-fields.txt) +- [selection-fields.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/selection-fields.txt) + +For a single GitHub page that combines screenshots, representative workflows, +and captured outputs, see the +[Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md). + ## Why ExcelAlchemy - Pydantic v2-based schema extraction and validation diff --git a/README.md b/README.md index c8b801b..3bddcc5 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) · [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) +[中文 README](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README_cn.md) · [About](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/ABOUT.md) · [Getting Started](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) · [Architecture](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/architecture.md) · [Examples Showcase](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.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.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. +The current stable release is `2.2.5`, which continues the ExcelAlchemy 2.x line with richer import-failure feedback, clearer getting-started and public-API guidance, stronger real-world examples, and stronger release smoke coverage. ## At a Glance @@ -191,6 +191,47 @@ Practical examples live in the repository: If you want the recommended reading order, start with [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md). +If you want a single page that combines screenshots, representative workflows, +and captured outputs, see +[`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md). + +Selected fixed outputs from the examples are generated by +[`scripts/generate_example_output_assets.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/scripts/generate_example_output_assets.py). + +### Example Outputs + +Import workflow output: + +```text +Employee import workflow completed +Result: SUCCESS +Success rows: 1 +Failed rows: 0 +Result workbook URL: None +Created rows: 1 +Uploaded artifacts: [] +``` + +Export workflow output: + +```text +Export workflow completed +Artifact filename: employees-export.xlsx +Artifact bytes: 6893 +Upload URL: memory://employees-export-upload.xlsx +Uploaded objects: ['employees-export-upload.xlsx'] +``` + +Full captured outputs: + +- [`files/example-outputs/employee-import-workflow.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/employee-import-workflow.txt) +- [`files/example-outputs/create-or-update-import.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/create-or-update-import.txt) +- [`files/example-outputs/export-workflow.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/export-workflow.txt) +- [`files/example-outputs/date-and-range-fields.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/date-and-range-fields.txt) +- [`files/example-outputs/selection-fields.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/selection-fields.txt) +- [`files/example-outputs/custom-storage.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/custom-storage.txt) +- [`files/example-outputs/annotated-schema.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/annotated-schema.txt) + ## Public API Boundaries If you want to know which modules are stable public entry points versus diff --git a/README_cn.md b/README_cn.md index 81af3ad..44ae699 100644 --- a/README_cn.md +++ b/README_cn.md @@ -5,7 +5,7 @@ ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 -当前稳定发布版本是 `2.2.0`,它在稳定的 ExcelAlchemy 2.x 线上继续推进了导入 facade 轻量化、更清晰的配置构造方式,以及更明确的 protocol-first storage 叙事。 +当前稳定发布版本是 `2.2.5`,它在稳定的 ExcelAlchemy 2.x 线上继续加强了导入失败反馈、更清晰的入门与 public API 文档、更贴近真实业务的示例,以及更强的 release smoke 验证。 你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 @@ -120,6 +120,48 @@ pip install "ExcelAlchemy[minio]" 如果你想按推荐顺序来阅读,建议先看 [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md)。 +如果你想看一页汇总好的展示页,里面同时包含截图、代表性工作流和固定输出, +可以直接看 +[`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md)。 + +这些固定输出素材由 +[`scripts/generate_example_output_assets.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/scripts/generate_example_output_assets.py) +生成。 + +### 示例输出 + +导入工作流输出: + +```text +Employee import workflow completed +Result: SUCCESS +Success rows: 1 +Failed rows: 0 +Result workbook URL: None +Created rows: 1 +Uploaded artifacts: [] +``` + +导出工作流输出: + +```text +Export workflow completed +Artifact filename: employees-export.xlsx +Artifact bytes: 6893 +Upload URL: memory://employees-export-upload.xlsx +Uploaded objects: ['employees-export-upload.xlsx'] +``` + +完整输出: + +- [`files/example-outputs/employee-import-workflow.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/employee-import-workflow.txt) +- [`files/example-outputs/create-or-update-import.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/create-or-update-import.txt) +- [`files/example-outputs/export-workflow.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/export-workflow.txt) +- [`files/example-outputs/date-and-range-fields.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/date-and-range-fields.txt) +- [`files/example-outputs/selection-fields.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/selection-fields.txt) +- [`files/example-outputs/custom-storage.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/custom-storage.txt) +- [`files/example-outputs/annotated-schema.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/annotated-schema.txt) + ## 快速开始 ```python diff --git a/docs/examples-showcase.md b/docs/examples-showcase.md new file mode 100644 index 0000000..6eef441 --- /dev/null +++ b/docs/examples-showcase.md @@ -0,0 +1,140 @@ +# Examples Showcase + +This page is the quickest way to understand how ExcelAlchemy looks in practice. + +It complements the repository examples by surfacing the most representative +workflows, screenshots, and fixed outputs in one place. If you want the full +guided path through the examples directory, start with +[`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md). + +## What ExcelAlchemy Looks Like + +### Template + +![Excel template screenshot](https://raw.githubusercontent.com/RayCarterLab/ExcelAlchemy/main/images/portfolio-template-en.png) + +### Import Result + +![Excel import result screenshot](https://raw.githubusercontent.com/RayCarterLab/ExcelAlchemy/main/images/portfolio-import-result-en.png) + +## Representative Workflows + +### 1. Import Workflow + +Best entry point if you want to understand the core story: + +- generate a workbook template +- accept a filled workbook +- validate the upload +- create domain rows +- write a result workbook back out + +Source: + +- [`examples/employee_import_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/employee_import_workflow.py) + +Fixed output: + +```text +Employee import workflow completed +Result: SUCCESS +Success rows: 1 +Failed rows: 0 +Result workbook URL: None +Created rows: 1 +Uploaded artifacts: [] +``` + +Full captured output: + +- [`files/example-outputs/employee-import-workflow.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/employee-import-workflow.txt) + +### 2. Create-Or-Update Import + +Best entry point if your backend mixes creates and updates in the same upload. + +Source: + +- [`examples/create_or_update_import.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/create_or_update_import.py) + +Fixed output: + +```text +Create-or-update import workflow completed +Result: SUCCESS +Success rows: 2 +Failed rows: 0 +Created rows: 1 +Updated rows: 1 +Result workbook URL: None +Uploaded artifacts: [] +``` + +Full captured output: + +- [`files/example-outputs/create-or-update-import.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/create-or-update-import.txt) + +### 3. Export Workflow + +Best entry point if you want to see artifact generation and storage-backed +export delivery. + +Source: + +- [`examples/export_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/export_workflow.py) + +Fixed output: + +```text +Export workflow completed +Artifact filename: employees-export.xlsx +Artifact bytes: 6892 +Upload URL: memory://employees-export-upload.xlsx +Uploaded objects: ['employees-export-upload.xlsx'] +``` + +Full captured output: + +- [`files/example-outputs/export-workflow.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/export-workflow.txt) + +### 4. Field Families + +If you want to see how workbook-oriented field types read in real schemas: + +- date and range fields: + - [`examples/date_and_range_fields.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/date_and_range_fields.py) + - [`files/example-outputs/date-and-range-fields.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/date-and-range-fields.txt) +- selection-heavy forms: + - [`examples/selection_fields.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/selection_fields.py) + - [`files/example-outputs/selection-fields.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/selection-fields.txt) + +### 5. Integration Boundaries + +If you want to see how ExcelAlchemy fits into backend systems: + +- custom storage protocol: + - [`examples/custom_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/custom_storage.py) + - [`files/example-outputs/custom-storage.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/custom-storage.txt) +- built-in Minio path for the current 2.x line: + - [`examples/minio_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/minio_storage.py) +- FastAPI integration: + - [`examples/fastapi_upload.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_upload.py) + +## Recommended Reading Order + +If you want to work through the examples intentionally rather than browse this +showcase: + +1. [`examples/annotated_schema.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/annotated_schema.py) +2. [`examples/employee_import_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/employee_import_workflow.py) +3. [`examples/create_or_update_import.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/create_or_update_import.py) +4. [`examples/export_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/export_workflow.py) +5. [`examples/custom_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/custom_storage.py) +6. [`examples/date_and_range_fields.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/date_and_range_fields.py) +7. [`examples/selection_fields.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/selection_fields.py) +8. [`examples/minio_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/minio_storage.py) +9. [`examples/fastapi_upload.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_upload.py) + +Or start with the dedicated guide: + +- [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md) diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..90fec2f --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,155 @@ +# Getting Started + +This page is the fastest way to get productive with ExcelAlchemy. + +If you want screenshots and fixed workflow outputs first, see +[`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md). +If you want the full public surface and compatibility boundaries, see +[`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md). + +## 1. Install + +Base install: + +```bash +pip install ExcelAlchemy +``` + +Optional built-in Minio support: + +```bash +pip install "ExcelAlchemy[minio]" +``` + +## 2. Start With The Recommended Imports + +Prefer the stable public entry points: + +```python +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String +from excelalchemy.config import ExporterConfig, ImportMode +from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError +``` + +Avoid importing from internal modules such as `excelalchemy.core.*`, +`excelalchemy.helper.*`, or `excelalchemy._primitives.*` in application code. + +## 3. Define A Schema + +Classic style: + +```python +from pydantic import BaseModel + +from excelalchemy import FieldMeta, Number, String + + +class EmployeeImporter(BaseModel): + full_name: String = FieldMeta(label='Full name', order=1) + age: Number = FieldMeta(label='Age', order=2) +``` + +Modern annotated style: + +```python +from typing import Annotated + +from pydantic import BaseModel, Field + +from excelalchemy import ExcelMeta, Number, String + + +class EmployeeImporter(BaseModel): + full_name: Annotated[String, ExcelMeta(label='Full name', order=1)] + age: Annotated[Number, Field(ge=18), ExcelMeta(label='Age', order=2)] +``` + +## 4. Pick The Workflow You Need + +Import-only create flow: + +```python +config = ImporterConfig.for_create(EmployeeImporter, creator=create_employee, storage=storage, locale='en') +alchemy = ExcelAlchemy(config) +``` + +Create-or-update flow: + +```python +config = ImporterConfig.for_create_or_update( + create_importer_model=CreateEmployeeImporter, + update_importer_model=UpdateEmployeeImporter, + is_data_exist=is_data_exist, + creator=create_employee, + updater=update_employee, + storage=storage, + locale='en', +) +alchemy = ExcelAlchemy(config) +``` + +Export flow: + +```python +config = ExporterConfig.for_storage(EmployeeExporter, storage=storage, locale='en') +alchemy = ExcelAlchemy(config) +``` + +## 5. Prefer `storage=...` + +In the 2.x line, the recommended backend integration path is always: + +```python +storage=... +``` + +That storage object should implement the public `ExcelStorage` protocol. + +Examples: + +- custom storage: + [`examples/custom_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/custom_storage.py) +- export workflow: + [`examples/export_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/export_workflow.py) +- import workflow: + [`examples/employee_import_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/employee_import_workflow.py) + +### Legacy Minio Fields + +The older: + +- `minio=...` +- `bucket_name=...` +- `url_expires=...` + +fields are still accepted in 2.x, but they are compatibility paths only. They +emit deprecation warnings and should not be used in new application code. + +If you need Minio in 2.x, prefer constructing a storage object explicitly and +passing it via `storage=...`. + +## 6. Learn By Example + +Recommended reading order: + +1. [`examples/annotated_schema.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/annotated_schema.py) +2. [`examples/employee_import_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/employee_import_workflow.py) +3. [`examples/create_or_update_import.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/create_or_update_import.py) +4. [`examples/export_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/export_workflow.py) +5. [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md) + +If you want the shorter visual summary, see +[`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md). + +## 7. Know The Stable Boundaries + +Before you wire ExcelAlchemy into a larger project, review: + +- [`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md) +- [`MIGRATIONS.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md) + +These two documents explain: + +- which modules are stable public entry points +- which import paths are compatibility-only in 2.x +- how storage and Minio should be configured going forward diff --git a/docs/public-api.md b/docs/public-api.md index 005b388..bfdb9f3 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -4,6 +4,13 @@ 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. +If you want the quickest path into the library, start with +[`docs/getting-started.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md). +If you want concrete repository examples, see +[`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md) +and +[`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md). + ## Stable Public Modules These modules are the recommended import paths for application code: @@ -77,6 +84,20 @@ from excelalchemy.config import ExporterConfig, ImportMode from excelalchemy.exceptions import ConfigError ``` +For most application code, these are the recommended import paths: + +- `from excelalchemy import ...` + Use this for the common public types, codecs, result models, and facade. +- `from excelalchemy.config import ...` + Use this when you need workflow configuration types such as `ExporterConfig` + or `ImportMode`. +- `from excelalchemy.exceptions import ...` + Use this when you catch or surface library-level exceptions. +- `from excelalchemy.metadata import ...` + Use this if you want the dedicated metadata entry points directly. +- `from excelalchemy.results import ...` + Use this if you need result models or error-map helper types directly. + Avoid depending on implementation details such as: ```python @@ -85,6 +106,31 @@ from excelalchemy.core.headers import ExcelHeaderParser from excelalchemy._primitives.identity import UniqueLabel ``` +## Recommended Backend Configuration Path + +For the stable 2.x line, the recommended backend integration path is: + +```python +storage=... +``` + +The `storage` object should implement `ExcelStorage`. + +The older built-in Minio fields: + +- `minio=...` +- `bucket_name=...` +- `url_expires=...` + +still work in 2.x as compatibility paths, but they are no longer the +recommended public API shape and now emit deprecation warnings. + +If you need concrete examples of the recommended storage path, see: + +- [`examples/custom_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/custom_storage.py) +- [`examples/export_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/export_workflow.py) +- [`docs/getting-started.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) + ## Deprecation Direction The 2.x line keeps compatibility shims to support migration, but the long-term diff --git a/docs/releases/2.2.5.md b/docs/releases/2.2.5.md new file mode 100644 index 0000000..cbba3f3 --- /dev/null +++ b/docs/releases/2.2.5.md @@ -0,0 +1,104 @@ +# 2.2.5 Release Notes and Checklist + +This document records the final release positioning and verification checklist +for the `2.2.5` release on top of the stable 2.x line. + +## Purpose + +- publish the next stable 2.x refinement release of ExcelAlchemy +- present `2.2.5` as a usability, documentation, and release-quality update +- improve how import failures are inspected and surfaced +- make common validation messages more natural and more consistent +- strengthen examples, showcase assets, and release smoke coverage +- continue shrinking low-value typing gray areas without sacrificing + readability + +## Release Positioning + +`2.2.5` should be presented as a stable 2.x polish release: + +- the public import and export workflow API stays stable +- error inspection is more capable and more ergonomic +- validation messages are more natural and more consistent +- exception boundaries are cleaner across `ProgrammaticError`, + `ConfigError`, `ExcelCellError`, and `ExcelRowError` +- examples are more realistic, better documented, and directly smoke-tested +- documentation now gives a clearer recommended path for getting started, + public imports, examples, and backend configuration +- the runtime path continues to consolidate around layered metadata and + explicit typing boundaries + +## Before Tagging + +1. Confirm the intended version in `src/excelalchemy/__init__.py`. +2. Review the `2.2.5` section in `CHANGELOG.md`. +3. Confirm the error-map helpers in `src/excelalchemy/results.py`. +4. Confirm the exception polish in `src/excelalchemy/exceptions.py`. +5. Confirm the validation-message normalization in + `src/excelalchemy/helper/pydantic.py`. +6. Confirm the documentation entry points: + - `docs/getting-started.md` + - `docs/public-api.md` + - `docs/examples-showcase.md` + - `examples/README.md` +7. Confirm the smoke scripts: + - `scripts/smoke_package.py` + - `scripts/smoke_examples.py` + - `scripts/generate_example_output_assets.py` + +## Local Verification + +Run these commands from the repository root: + +```bash +uv sync --extra development +uv run ruff check . +uv run pyright +uv run pytest -q +./.venv/bin/python scripts/smoke_package.py +./.venv/bin/python scripts/smoke_examples.py +./.venv/bin/python scripts/generate_example_output_assets.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.5`. +4. Use the `2.2.5` 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 +themes clearly: + +- import failures are easier to inspect and surface +- validation messages are more consistent and more natural +- exception boundaries are easier to reason about from library code and tests +- docs now provide a clearer getting-started and public-API path +- repository examples are more realistic and directly smoke-tested +- typing cleanup continues, but without forcing low-value abstraction or + zero-`Any` purity + +## Recommended Release Messaging + +Prefer wording that emphasizes polish and stability: + +- "continues the stable 2.x line" +- "improves error UX for import failures" +- "adds richer workbook-facing error access helpers" +- "strengthens examples, showcase assets, and smoke coverage" +- "clarifies the recommended documentation and backend configuration path" +- "continues tightening runtime typing boundaries without breaking the public API" + +## Done When + +- the tag `v2.2.5` is published +- the GitHub Release notes clearly describe the error UX and documentation + improvements +- CI, example smoke, and package smoke all pass +- the published package version matches the release tag diff --git a/examples/README.md b/examples/README.md index f91a038..0cd51e5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,33 +2,62 @@ These examples are organized as a recommended learning path rather than a flat list. +If you want a single public-facing page that combines screenshots, +representative workflows, and captured outputs, see +[`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md). + ## Recommended Reading Order 1. `annotated_schema.py` - Start here if you want to learn the declaration style first. - Shows the modern `Annotated[..., Field(...), ExcelMeta(...)]` pattern. + - Best for: first-time readers who want to understand the schema declaration style. + - Output: prints the generated template filename and the declared field labels. + - Captured output: [`files/example-outputs/annotated-schema.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/annotated-schema.txt) 2. `employee_import_workflow.py` - Read this next if you want to understand the core import story. - Shows template generation, workbook upload, import execution, and result reporting. + - Best for: backend developers implementing the basic import path. + - Output: prints success and failure counts, created row count, and uploaded result artifacts. + - Captured output: [`files/example-outputs/employee-import-workflow.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/employee-import-workflow.txt) 3. `create_or_update_import.py` - Read this after the basic import flow. - Shows `ImporterConfig.for_create_or_update(...)` with `is_data_exist`, `creator`, and `updater`. + - Best for: admin tools and synchronization flows that mix creates and updates. + - Output: prints created row count, updated row count, and the final import result. + - Captured output: [`files/example-outputs/create-or-update-import.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/create-or-update-import.txt) 4. `export_workflow.py` - Read this once the import flow is clear. - Shows artifact generation, export uploads, and a custom storage-backed export task. + - Best for: download centers and reporting tasks that need workbook artifacts and upload URLs. + - Output: prints artifact filename, byte size, upload URL, and uploaded object names. + - Captured output: [`files/example-outputs/export-workflow.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/export-workflow.txt) 5. `custom_storage.py` - Read this when you want to implement your own `ExcelStorage`. - Keeps the example minimal and focused on the protocol boundary. + - Best for: teams wiring ExcelAlchemy into their own object storage layer. + - Output: prints the in-memory upload URL and uploaded byte count. + - Captured output: [`files/example-outputs/custom-storage.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/custom-storage.txt) 6. `date_and_range_fields.py` - Read this if you want to understand workbook-friendly date, date range, number range, and money fields. + - Best for: data-entry templates with compensation, effective dates, or range fields. + - Output: prints the generated template filename and the exported field labels. + - Captured output: [`files/example-outputs/date-and-range-fields.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/date-and-range-fields.txt) 7. `selection_fields.py` - Read this if your domain uses approval forms, assignments, ownership trees, or selection-heavy templates. + - Best for: approval forms, personnel assignment forms, and selection-heavy business templates. + - Output: prints the generated template filename and the declared selection field labels. + - Captured output: [`files/example-outputs/selection-fields.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/selection-fields.txt) 8. `minio_storage.py` - Read this if you need the built-in Minio path in the current 2.x line. - This reflects the current 2.x compatibility-based Minio path rather than a future 3.x-only storage story. + - Best for: teams already using the built-in Minio compatibility path in 2.x. + - Output: prints the gateway type and confirms the built-in Minio path. 9. `fastapi_upload.py` - - Read this last as an integration sketch. + - Read this last as a web integration example. - It is useful once the import and storage examples already make sense. + - Best for: backend teams exposing template download and workbook import over HTTP. + - Output: prints the import result, created row count, uploaded result artifacts, and registered FastAPI routes. ## By Goal @@ -71,10 +100,17 @@ uv run python examples/selection_fields.py uv run python examples/custom_storage.py uv run python examples/export_workflow.py uv run python examples/minio_storage.py +uv run python examples/fastapi_upload.py ``` -If you want to try the FastAPI sketch, install FastAPI first and then run your -preferred ASGI server against `examples.fastapi_upload:app`. +If you want to run the FastAPI app itself, install FastAPI first and then run +your preferred ASGI server against `examples.fastapi_upload:app`. + +If you want to smoke-test the web integration without running a server, execute: + +```bash +uv run python examples/fastapi_upload.py +``` ## Notes @@ -87,3 +123,5 @@ preferred ASGI server against `examples.fastapi_upload:app`. which still uses the compatibility configuration fields under the hood. - The smoke tests in `tests/integration/test_examples_smoke.py` cover the main example entry points directly. +- Fixed example output assets are generated by + `scripts/generate_example_output_assets.py`. diff --git a/examples/fastapi_upload.py b/examples/fastapi_upload.py index 66f344a..ce871a3 100644 --- a/examples/fastapi_upload.py +++ b/examples/fastapi_upload.py @@ -1,42 +1,95 @@ -"""FastAPI integration sketch for template download and workbook import.""" +"""Minimal FastAPI integration example for template download and workbook import.""" +import asyncio +import io +from base64 import b64decode from io import BytesIO from fastapi import FastAPI, HTTPException, UploadFile from fastapi.responses import StreamingResponse +from openpyxl import load_workbook from pydantic import BaseModel -from excelalchemy import ExcelAlchemy, ExcelStorage, FieldMeta, ImporterConfig, Number, String, UrlStr +from excelalchemy import ( + ExcelAlchemy, + ExcelStorage, + FieldMeta, + ImporterConfig, + ImportResult, + Number, + String, + UrlStr, +) from excelalchemy.core.table import WorksheetTable class EmployeeImporter(BaseModel): - full_name: String = FieldMeta(label='Full name', order=1) + full_name: String = FieldMeta(label='Full name', order=1, hint='Use the legal name') age: Number = FieldMeta(label='Age', order=2) class RequestScopedStorage(ExcelStorage): + """Self-contained request-scoped storage used by the FastAPI example.""" + def __init__(self) -> None: + self.fixtures: dict[str, bytes] = {} self.uploaded: dict[str, bytes] = {} + def register_upload(self, input_excel_name: str, content: bytes) -> None: + self.fixtures[input_excel_name] = content + 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') + 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: - raise NotImplementedError('Wire this method to your own object storage backend') + _, 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: dict[str, object] | None) -> dict[str, object]: if context is not None: + created_rows = context.setdefault('created_rows', []) + assert isinstance(created_rows, list) + created_rows.append(row.copy()) row['tenant_id'] = context['tenant_id'] return row -app = FastAPI() +def build_import_alchemy( + storage: RequestScopedStorage, *, tenant_id: str = 'tenant-001' +) -> ExcelAlchemy[dict[str, object], EmployeeImporter, EmployeeImporter]: + alchemy = ExcelAlchemy( + ImporterConfig.for_create( + EmployeeImporter, + creator=create_employee, + storage=storage, + locale='en', + ) + ) + alchemy.add_context({'tenant_id': tenant_id, 'created_rows': []}) + return alchemy -@app.get('/employee-template.xlsx') -async def download_template() -> StreamingResponse: +def build_template_response() -> StreamingResponse: alchemy = ExcelAlchemy(ImporterConfig.for_create(EmployeeImporter, locale='en')) artifact = alchemy.download_template_artifact(filename='employee-template.xlsx') return StreamingResponse( @@ -46,21 +99,92 @@ async def download_template() -> StreamingResponse: ) -@app.post('/employee-imports') -async def import_employees(file: UploadFile) -> dict[str, object]: +async def import_employees_from_upload(storage: RequestScopedStorage, 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'}) - + storage.register_upload(file.filename, await file.read()) + alchemy = build_import_alchemy(storage) result = await alchemy.import_data(file.filename, 'employee-import-result.xlsx') - return result.model_dump() + created_rows = alchemy.context['created_rows'] + assert isinstance(created_rows, list) + return { + 'result': result.model_dump(), + 'created_rows': len(created_rows), + 'uploaded_artifacts': sorted(storage.uploaded), + } + + +def create_app(storage: RequestScopedStorage | None = None) -> FastAPI: + app = FastAPI() + request_storage = storage or RequestScopedStorage() + + @app.get('/employee-template.xlsx') + async def download_template() -> StreamingResponse: + return build_template_response() + + @app.post('/employee-imports') + async def import_employees(file: UploadFile) -> dict[str, object]: + return await import_employees_from_upload(request_storage, file) + + return app + + +def _build_demo_upload(template_bytes: bytes) -> bytes: + workbook = load_workbook(io.BytesIO(template_bytes)) + try: + worksheet = workbook['Sheet1'] + worksheet['A3'] = 'TaylorChen' + worksheet['B3'] = '32' + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() + finally: + workbook.close() + + +async def run_demo() -> tuple[ImportResult, RequestScopedStorage, int]: + template_alchemy = ExcelAlchemy(ImporterConfig.for_create(EmployeeImporter, locale='en')) + template = template_alchemy.download_template_artifact(filename='employee-template.xlsx') + storage = RequestScopedStorage() + upload_bytes = _build_demo_upload(template.as_bytes()) + upload_file = UploadFile(filename='employee-import.xlsx', file=BytesIO(upload_bytes)) + response_payload = await import_employees_from_upload(storage, upload_file) + result = ImportResult.model_validate(response_payload['result']) + created_rows = response_payload['created_rows'] + assert isinstance(created_rows, int) + return result, storage, created_rows + + +def main() -> None: + result, storage, created_rows = asyncio.run(run_demo()) + app = create_app(storage) + route_paths = sorted(route.path for route in app.routes if getattr(route, 'path', None)) + + print('FastAPI upload example completed') + print(f'Result: {result.result}') + print(f'Success rows: {result.success_count}') + print(f'Failed rows: {result.fail_count}') + print(f'Created rows: {created_rows}') + print(f'Uploaded artifacts: {sorted(storage.uploaded)}') + print(f'Routes: {route_paths}') + + +app = create_app() + + +__all__ = [ + 'EmployeeImporter', + 'RequestScopedStorage', + 'app', + 'build_import_alchemy', + 'build_template_response', + 'create_app', + 'import_employees_from_upload', + 'main', + 'run_demo', +] + + +if __name__ == '__main__': + main() diff --git a/files/example-outputs/annotated-schema.txt b/files/example-outputs/annotated-schema.txt new file mode 100644 index 0000000..142a51c --- /dev/null +++ b/files/example-outputs/annotated-schema.txt @@ -0,0 +1 @@ +Generated template: employee-template.xlsx (6802 bytes) diff --git a/files/example-outputs/create-or-update-import.txt b/files/example-outputs/create-or-update-import.txt new file mode 100644 index 0000000..bf2916c --- /dev/null +++ b/files/example-outputs/create-or-update-import.txt @@ -0,0 +1,8 @@ +Create-or-update import workflow completed +Result: SUCCESS +Success rows: 2 +Failed rows: 0 +Created rows: 1 +Updated rows: 1 +Result workbook URL: None +Uploaded artifacts: [] diff --git a/files/example-outputs/custom-storage.txt b/files/example-outputs/custom-storage.txt new file mode 100644 index 0000000..d70d16b --- /dev/null +++ b/files/example-outputs/custom-storage.txt @@ -0,0 +1,2 @@ +memory://employees.xlsx +Uploaded bytes: 6812 diff --git a/files/example-outputs/date-and-range-fields.txt b/files/example-outputs/date-and-range-fields.txt new file mode 100644 index 0000000..107602b --- /dev/null +++ b/files/example-outputs/date-and-range-fields.txt @@ -0,0 +1,2 @@ +Generated template: compensation-template.xlsx (6861 bytes) +Fields: Start date, Probation window, Salary band, Signing bonus diff --git a/files/example-outputs/employee-import-workflow.txt b/files/example-outputs/employee-import-workflow.txt new file mode 100644 index 0000000..e3c3552 --- /dev/null +++ b/files/example-outputs/employee-import-workflow.txt @@ -0,0 +1,7 @@ +Employee import workflow completed +Result: SUCCESS +Success rows: 1 +Failed rows: 0 +Result workbook URL: None +Created rows: 1 +Uploaded artifacts: [] diff --git a/files/example-outputs/export-workflow.txt b/files/example-outputs/export-workflow.txt new file mode 100644 index 0000000..3025d7c --- /dev/null +++ b/files/example-outputs/export-workflow.txt @@ -0,0 +1,5 @@ +Export workflow completed +Artifact filename: employees-export.xlsx +Artifact bytes: 6892 +Upload URL: memory://employees-export-upload.xlsx +Uploaded objects: ['employees-export-upload.xlsx'] diff --git a/files/example-outputs/selection-fields.txt b/files/example-outputs/selection-fields.txt new file mode 100644 index 0000000..1f2c398 --- /dev/null +++ b/files/example-outputs/selection-fields.txt @@ -0,0 +1,2 @@ +Generated template: selection-fields-template.xlsx (6941 bytes) +Fields: Request type, Impacted teams, Owner organization, Partner organizations, Owner, Reviewers diff --git a/scripts/generate_example_output_assets.py b/scripts/generate_example_output_assets.py new file mode 100644 index 0000000..8613981 --- /dev/null +++ b/scripts/generate_example_output_assets.py @@ -0,0 +1,55 @@ +"""Generate fixed example output assets for README and documentation pages.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES_DIR = ROOT / 'examples' +OUTPUT_DIR = ROOT / 'files' / 'example-outputs' +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +EXAMPLE_ASSETS: dict[str, str] = { + 'annotated_schema.py': 'annotated-schema.txt', + 'employee_import_workflow.py': 'employee-import-workflow.txt', + 'create_or_update_import.py': 'create-or-update-import.txt', + 'date_and_range_fields.py': 'date-and-range-fields.txt', + 'selection_fields.py': 'selection-fields.txt', + 'custom_storage.py': 'custom-storage.txt', + 'export_workflow.py': 'export-workflow.txt', +} + + +def _load_example_module(module_name: str, filename: str): + module_path = EXAMPLES_DIR / filename + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f'Could not load example module: {filename}') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _run_example(filename: str) -> str: + module_name = f'example_asset_{filename.removesuffix(".py").replace("-", "_")}' + module = _load_example_module(module_name, filename) + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + module.main() + return buffer.getvalue().strip() + + +def main() -> None: + for filename, output_name in EXAMPLE_ASSETS.items(): + output = _run_example(filename) + output_path = OUTPUT_DIR / output_name + output_path.write_text(f'{output}\n', encoding='utf-8') + print(f'Generated example output: {output_path}') + + +if __name__ == '__main__': + main() diff --git a/scripts/smoke_examples.py b/scripts/smoke_examples.py new file mode 100644 index 0000000..20b851d --- /dev/null +++ b/scripts/smoke_examples.py @@ -0,0 +1,75 @@ +"""Smoke-test representative repository examples.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES_DIR = ROOT / 'examples' + +REQUIRED_EXAMPLES: dict[str, tuple[str, ...]] = { + 'annotated_schema.py': ('Generated template:', 'employee-template.xlsx'), + 'employee_import_workflow.py': ('Employee import workflow completed', 'Success rows: 1'), + 'create_or_update_import.py': ('Create-or-update import workflow completed', 'Updated rows: 1'), + 'date_and_range_fields.py': ('Generated template:', 'compensation-template.xlsx'), + 'selection_fields.py': ('Generated template:', 'selection-fields-template.xlsx'), + 'custom_storage.py': ('memory://employees.xlsx', 'Uploaded bytes:'), + 'export_workflow.py': ('Export workflow completed', 'Upload URL: memory://employees-export-upload.xlsx'), +} + +OPTIONAL_EXAMPLES: dict[str, tuple[tuple[str, bool], tuple[str, ...]]] = { + 'fastapi_upload.py': (('fastapi', True), ('FastAPI upload example completed', '/employee-imports')), + 'minio_storage.py': (('minio', True), ('Built gateway: MinioStorageGateway', 'Uses built-in Minio path: True')), +} + + +def _load_example_module(module_name: str, filename: str): + module_path = EXAMPLES_DIR / filename + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f'Could not load example module: {filename}') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _run_example(filename: str) -> str: + module_name = f'smoke_example_{filename.removesuffix(".py").replace("-", "_")}' + module = _load_example_module(module_name, filename) + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + module.main() + return buffer.getvalue() + + +def _dependency_available(module_name: str) -> bool: + return importlib.util.find_spec(module_name) is not None + + +def _assert_example_output(filename: str, output: str, required_fragments: tuple[str, ...]) -> None: + missing = [fragment for fragment in required_fragments if fragment not in output] + if missing: + raise AssertionError(f'Example {filename} did not produce expected output fragments: {missing}') + + +def main() -> None: + for filename, required_fragments in REQUIRED_EXAMPLES.items(): + output = _run_example(filename) + _assert_example_output(filename, output, required_fragments) + print(f'Smoke passed: {filename}') + + for filename, ((dependency_name, should_exist), required_fragments) in OPTIONAL_EXAMPLES.items(): + available = _dependency_available(dependency_name) + if should_exist and not available: + print(f'Skipped optional example: {filename} ({dependency_name} is not installed)') + continue + output = _run_example(filename) + _assert_example_output(filename, output, required_fragments) + print(f'Smoke passed: {filename}') + + +if __name__ == '__main__': + main() diff --git a/scripts/smoke_package.py b/scripts/smoke_package.py index 361a149..6e749a2 100644 --- a/scripts/smoke_package.py +++ b/scripts/smoke_package.py @@ -86,6 +86,7 @@ async def main() -> None: import_result = await importer.import_data('smoke-input.xlsx', 'smoke-result.xlsx') assert import_result.success_count == 1 assert import_result.fail_count == 0 + assert import_result.result == 'SUCCESS' exporter = ExcelAlchemy(ExporterConfig.for_storage(SmokeImporter, storage=storage, locale='en')) artifact = exporter.export_artifact( @@ -93,6 +94,13 @@ async def main() -> None: filename='smoke-export.xlsx', ) assert len(artifact.as_bytes()) > 0 + uploaded_url = exporter.export_upload('smoke-export-upload.xlsx', [{'full_name': 'TaylorChen', 'age': 32}]) + assert uploaded_url == 'memory://smoke-export-upload.xlsx' + assert 'smoke-export-upload.xlsx' in storage.uploaded + + print('Package smoke passed') + print(f'Import result: {import_result.result}') + print(f'Upload URL: {uploaded_url}') if __name__ == '__main__': diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index 65c9bc1..2874b43 100644 --- a/src/excelalchemy/__init__.py +++ b/src/excelalchemy/__init__.py @@ -1,6 +1,6 @@ """A Python Library for Reading and Writing Excel Files""" -__version__ = '2.2.4' +__version__ = '2.2.5' from excelalchemy._primitives.constants import CharacterSet, DataRangeOption, DateFormat, Option from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning from excelalchemy._primitives.identity import ( @@ -48,13 +48,21 @@ from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError, ProgrammaticError from excelalchemy.helper.pydantic import extract_pydantic_model from excelalchemy.metadata import ExcelMeta, FieldMeta, PatchFieldMeta -from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult, ValidateRowResult +from excelalchemy.results import ( + CellErrorMap, + ImportResult, + RowIssueMap, + ValidateHeaderResult, + ValidateResult, + ValidateRowResult, +) from excelalchemy.util.file import flatten __all__ = [ 'Base64Str', 'Boolean', 'BooleanCodec', + 'CellErrorMap', 'ColumnIndex', 'CompositeExcelFieldCodec', 'ConfigError', @@ -104,6 +112,7 @@ 'ProgrammaticError', 'Radio', 'RowIndex', + 'RowIssueMap', 'SingleChoiceCodec', 'SingleOrganization', 'SingleOrganizationCodec', diff --git a/src/excelalchemy/codecs/date_range.py b/src/excelalchemy/codecs/date_range.py index 7c6aeb7..18b896f 100644 --- a/src/excelalchemy/codecs/date_range.py +++ b/src/excelalchemy/codecs/date_range.py @@ -10,6 +10,7 @@ from excelalchemy._primitives.constants import DATE_FORMAT_TO_PYTHON_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption from excelalchemy._primitives.identity import Key from excelalchemy.codecs.base import CompositeExcelFieldCodec +from excelalchemy.exceptions import ConfigError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.i18n.messages import message as msg @@ -53,7 +54,9 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared presentation = field_meta.presentation if presentation.date_format is None: - raise RuntimeError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) + raise ConfigError( + msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED), message_key=MessageKey.DATE_FORMAT_NOT_CONFIGURED + ) return '\n'.join( [ diff --git a/src/excelalchemy/codecs/multi_checkbox.py b/src/excelalchemy/codecs/multi_checkbox.py index 67f6f42..2a4b45a 100644 --- a/src/excelalchemy/codecs/multi_checkbox.py +++ b/src/excelalchemy/codecs/multi_checkbox.py @@ -14,6 +14,12 @@ class MultiCheckbox(ExcelFieldCodec, list[str]): __name__ = 'MultiChoice' + @staticmethod + def _coerce_items(value: object) -> list[object] | None: + if not isinstance(value, list): + return None + return cast(list[object], value) + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared @@ -29,8 +35,8 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: @classmethod def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> list[str] | object: - if isinstance(value, list): - items = cast(list[object], value) + items = cls._coerce_items(value) + if items is not None: return [str(item).strip() for item in items] if isinstance(value, str): @@ -45,10 +51,10 @@ def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> list[str] | ob def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> list[str]: # OptionId declared = field_meta.declared presentation = field_meta.presentation - if not isinstance(value, list): + items = cls._coerce_items(value) + if items is None: raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT)) - items = cast(list[object], value) parsed = [str(item).strip() for item in items] if presentation.options is None: diff --git a/src/excelalchemy/codecs/organization.py b/src/excelalchemy/codecs/organization.py index e54f8dd..e5f2d5a 100644 --- a/src/excelalchemy/codecs/organization.py +++ b/src/excelalchemy/codecs/organization.py @@ -74,7 +74,8 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - return value if isinstance(value, list): - items = cast(list[object], value) + items = MultiOrganization._coerce_items(cast(object, value)) + assert items is not None option_ids = [OptionId(option_id) for option_id in items] option_names = presentation.exchange_option_ids_to_names(option_ids, field_label=declared.label) return MULTI_CHECKBOX_SEPARATOR.join(map(str, option_names)) diff --git a/src/excelalchemy/codecs/staff.py b/src/excelalchemy/codecs/staff.py index d7f5a7c..db60792 100644 --- a/src/excelalchemy/codecs/staff.py +++ b/src/excelalchemy/codecs/staff.py @@ -80,7 +80,8 @@ def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: return value if isinstance(value, list): - items = cast(list[object], value) + items = MultiStaff._coerce_items(cast(object, value)) + assert items is not None option_ids = [OptionId(option_id) for option_id in items] if len(option_ids) != len(set(option_ids)): raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 7f837da..b310b16 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -9,7 +9,7 @@ RESULT_COLUMN_KEY, ) from excelalchemy._primitives.header_models import ExcelHeader -from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr +from excelalchemy._primitives.identity import DataUrlStr, Label, UniqueKey, UniqueLabel, UrlStr from excelalchemy._primitives.payloads import DataConverter, ExportRowPayload from excelalchemy.artifacts import ExcelArtifact from excelalchemy.codecs.base import SystemReserved @@ -22,13 +22,13 @@ from excelalchemy.core.storage import build_storage_gateway from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.core.table import WorksheetTable -from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.exceptions import ConfigError from excelalchemy.helper.pydantic import get_model_field_names from excelalchemy.i18n.messages import MessageKey, use_display_locale from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.i18n.messages import message as msg from excelalchemy.metadata import FieldMetaInfo -from excelalchemy.results import ImportResult +from excelalchemy.results import CellErrorMap, ImportResult, RowIssueMap from excelalchemy.util.file import flatten HEADER_HINT_LINE_COUNT = 1 @@ -210,24 +210,24 @@ def header_table(self) -> WorksheetTable: return self._last_import_session.header_table @property - def cell_error_map(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]: + def cell_error_map(self) -> CellErrorMap: if self._last_import_session is None: - return {} + return CellErrorMap() return self._last_import_session.cell_error_map @property - def row_error_map(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]: + def row_error_map(self) -> RowIssueMap: if self._last_import_session is None: - return {} + return RowIssueMap() return self._last_import_session.row_error_map @property - def cell_errors(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]: + def cell_errors(self) -> CellErrorMap: """Backward-compatible alias for cell_error_map.""" return self.cell_error_map @property - def row_errors(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]: + def row_errors(self) -> RowIssueMap: """Backward-compatible alias for row_error_map.""" return self.row_error_map diff --git a/src/excelalchemy/core/headers.py b/src/excelalchemy/core/headers.py index bc5226b..c833745 100644 --- a/src/excelalchemy/core/headers.py +++ b/src/excelalchemy/core/headers.py @@ -1,7 +1,6 @@ """Header parsing and validation helpers for import workbooks.""" from collections.abc import Container, Sequence -from typing import cast from excelalchemy._primitives.header_models import ExcelHeader from excelalchemy._primitives.identity import Label, UniqueLabel @@ -129,12 +128,12 @@ def validate( @staticmethod def _ordered_difference[T](values: Sequence[T], allowed: Container[T]) -> list[T]: - seen: set[Label] = set() + seen: set[T] = set() result: list[T] = [] for value in values: if value in allowed or value in seen: continue - seen.add(cast(Label, value)) + seen.add(value) result.append(value) return result diff --git a/src/excelalchemy/core/import_session.py b/src/excelalchemy/core/import_session.py index f2928f2..6a8f4b4 100644 --- a/src/excelalchemy/core/import_session.py +++ b/src/excelalchemy/core/import_session.py @@ -5,13 +5,12 @@ from dataclasses import dataclass, replace from enum import StrEnum from functools import cached_property -from typing import cast from pydantic import BaseModel from excelalchemy._primitives.constants import REASON_COLUMN_KEY, RESULT_COLUMN_KEY from excelalchemy._primitives.header_models import ExcelHeader -from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, RowIndex, UniqueLabel, UrlStr +from excelalchemy._primitives.identity import DataUrlStr, RowIndex, UniqueLabel, UrlStr from excelalchemy._primitives.payloads import FlatRowPayload, ModelRowPayload from excelalchemy.codecs.base import SystemReserved from excelalchemy.config import ImporterConfig @@ -21,13 +20,13 @@ from excelalchemy.core.rows import ImportIssueTracker, RowAggregator from excelalchemy.core.schema import ExcelSchemaLayout from excelalchemy.core.storage_protocol import ExcelStorage -from excelalchemy.core.table import WorksheetTable -from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.core.table import WorksheetRow, WorksheetTable +from excelalchemy.exceptions import ConfigError from excelalchemy.i18n.messages import MessageKey, use_display_locale from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.i18n.messages import message as msg from excelalchemy.metadata import FieldMetaInfo -from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult +from excelalchemy.results import CellErrorMap, ImportResult, RowIssueMap, ValidateHeaderResult, ValidateResult HEADER_HINT_LINE_COUNT = 1 @@ -104,20 +103,20 @@ def __init__( self._snapshot = ImportSessionSnapshot() @property - def cell_error_map(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]: + def cell_error_map(self) -> CellErrorMap: return self.issue_tracker.cell_errors @property - def row_error_map(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]: + def row_error_map(self) -> RowIssueMap: return self.issue_tracker.row_errors @property - def cell_errors(self) -> dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]: + def cell_errors(self) -> CellErrorMap: """Backward-compatible alias for cell_error_map.""" return self.cell_error_map @property - def row_errors(self) -> dict[RowIndex, list[ExcelRowError | ExcelCellError]]: + def row_errors(self) -> RowIssueMap: """Backward-compatible alias for row_error_map.""" return self.row_error_map @@ -240,7 +239,7 @@ async def _execute_rows(self) -> tuple[bool, int, int]: 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())) + aggregate_data = self._aggregate_data(self._row_payload(row)) success = await self.executor.execute(RowIndex(table_row_index), aggregate_data, self.worksheet_table) processed_row_count += 1 all_success = all_success and success @@ -265,6 +264,13 @@ def _set_columns(self, worksheet_table: WorksheetTable) -> WorksheetTable: def _aggregate_data(self, row_data: FlatRowPayload) -> ModelRowPayload: return self.row_aggregator.aggregate(row_data) + @staticmethod + def _row_payload(row: WorksheetRow) -> FlatRowPayload: + payload: FlatRowPayload = {} + for key, value in row.items(): + payload[str(key)] = value + return payload + def _render_import_result_excel(self) -> DataUrlStr: return self.renderer.render_data( self.worksheet_table, diff --git a/src/excelalchemy/core/rows.py b/src/excelalchemy/core/rows.py index 1aa5a8f..0e891cb 100644 --- a/src/excelalchemy/core/rows.py +++ b/src/excelalchemy/core/rows.py @@ -1,8 +1,6 @@ """Row aggregation and import issue tracking helpers.""" -from collections import defaultdict from collections.abc import Iterator -from typing import cast from excelalchemy._primitives.identity import ColumnIndex, Key, RowIndex, UniqueLabel from excelalchemy._primitives.payloads import AggregatedRowPayload, ModelRowPayload, RowPayloadLike @@ -11,7 +9,7 @@ from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import message as msg from excelalchemy.metadata import FieldMetaInfo -from excelalchemy.results import ValidateRowResult +from excelalchemy.results import CellErrorMap, RowIssueMap, ValidateRowResult from excelalchemy.util.file import value_is_nan from .schema import ExcelSchemaLayout @@ -76,8 +74,8 @@ class ImportIssueTracker: def __init__(self, layout: ExcelSchemaLayout, import_result_field_meta: list[FieldMetaInfo]): self.layout = layout self.import_result_field_meta = import_result_field_meta - self.cell_errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]] = {} - self.row_errors: dict[RowIndex, list[ExcelRowError | ExcelCellError]] = defaultdict(list) + self.cell_errors = CellErrorMap() + self.row_errors = RowIssueMap() def register_row_error( self, @@ -86,9 +84,9 @@ def register_row_error( ) -> None: """Record one row-level issue or a batch of issues for the same row.""" if isinstance(error, list): - self.row_errors[row_index].extend(error) + self.row_errors.add_many(row_index, error) else: - self.row_errors[row_index].append(error) + self.row_errors.add(row_index, error) def register_cell_errors( self, @@ -99,8 +97,8 @@ def register_cell_errors( """Map cell errors from schema labels to rendered workbook coordinates.""" for error in errors: for index in self._column_indices(worksheet_table, error.unique_label): - column_index = cast(ColumnIndex, index + len(self.import_result_field_meta)) - self.cell_errors.setdefault(row_index, {}).setdefault(column_index, []).append(error) + column_index = ColumnIndex(index + len(self.import_result_field_meta)) + self.cell_errors.add(row_index, column_index, error) def add_result_columns( self, @@ -115,17 +113,15 @@ def add_result_columns( reason: list[str] = [] for index in worksheet_table.index[extra_header_count_on_import:]: - row_errors = self.row_errors.get(RowIndex(index)) + row_errors = self.row_errors.at(RowIndex(index)) if not row_errors: result.append(str(ValidateRowResult.SUCCESS)) reason.append('') continue result.append(str(ValidateRowResult.FAIL)) - numbered_reasons = [ - f'{idx}、{error!s}' for idx, error in enumerate(self.layout.order_errors(row_errors), start=1) - ] - reason.append('\n'.join(numbered_reasons)) + ordered_errors = list(self.layout.order_errors(list(row_errors))) + reason.append('\n'.join(self.row_errors.numbered_messages(ordered_errors))) if extra_header_count_on_import == 1: result = [str(result_unique_label), *result] diff --git a/src/excelalchemy/core/schema.py b/src/excelalchemy/core/schema.py index 4135566..941a623 100644 --- a/src/excelalchemy/core/schema.py +++ b/src/excelalchemy/core/schema.py @@ -5,7 +5,6 @@ from collections.abc import Iterable, Sequence from decimal import Decimal from itertools import chain -from typing import cast from pydantic import BaseModel @@ -88,11 +87,18 @@ def _sort_field_meta(cls, field_metas: list[FieldMetaInfo]) -> list[FieldMetaInf return sorted( field_metas, key=lambda x: ( - orders.get(cast(Label, x.runtime.parent_label), Decimal('Infinity')), + cls._parent_order(orders, x), x.runtime.offset, ), ) + @staticmethod + def _parent_order(orders: dict[Label, int], field_meta: FieldMetaInfo) -> int | Decimal: + parent_label = field_meta.runtime.parent_label + if parent_label is None: + return Decimal('Infinity') + return orders.get(parent_label, Decimal('Infinity')) + def has_merged_header(self, selected_keys: list[UniqueKey]) -> bool: """Return whether the selected keys need a two-row merged header.""" return any( diff --git a/src/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py index dee98c5..3767e71 100644 --- a/src/excelalchemy/core/writer.py +++ b/src/excelalchemy/core/writer.py @@ -22,7 +22,7 @@ ) from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, Label, RowIndex, UniqueLabel from excelalchemy.core.table import WorksheetTable, WorksheetValue -from excelalchemy.exceptions import ExcelCellError +from excelalchemy.exceptions import ExcelCellError, ProgrammaticError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.i18n.messages import message as msg @@ -167,7 +167,10 @@ def _write_horizontally_merged_header( declared = field_meta.declared runtime = field_meta.runtime if runtime.parent_label is None: - raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) + raise ProgrammaticError( + msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME), + message_key=MessageKey.PARENT_LABEL_EMPTY_RUNTIME, + ) counter[runtime.parent_label] += 1 for openpyxl_col_index, column in enumerate( @@ -178,7 +181,10 @@ def _write_horizontally_merged_header( declared = field_meta.declared runtime = field_meta.runtime if runtime.parent_label is None: - raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) + raise ProgrammaticError( + msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME), + message_key=MessageKey.PARENT_LABEL_EMPTY_RUNTIME, + ) if declared.label != runtime.parent_label and runtime.offset == 0: cell = _worksheet_cell(worksheet, row=start_row, column=openpyxl_col_index) cell.value = str(runtime.parent_label) diff --git a/src/excelalchemy/exceptions.py b/src/excelalchemy/exceptions.py index b1e585e..5e859db 100644 --- a/src/excelalchemy/exceptions.py +++ b/src/excelalchemy/exceptions.py @@ -6,38 +6,86 @@ from excelalchemy.i18n.messages import message as msg -class ExcelCellError(Exception): +class ExcelAlchemyError(Exception): + """Base error type for public ExcelAlchemy exceptions.""" + + default_message = '' + message: str + message_key: MessageKey | None + detail: dict[str, object] + + def __init__( + self, + message: str = '', + *, + message_key: MessageKey | None = None, + **kwargs: object, + ) -> None: + resolved_message = message or self.default_message + super().__init__(resolved_message) + self.message = resolved_message + self.message_key = message_key + self.detail = kwargs or {} + + def __str__(self) -> str: + return self.message + + def to_dict(self) -> dict[str, object]: + payload: dict[str, object] = { + 'type': type(self).__name__, + 'message': self.message, + } + if self.message_key is not None: + payload['message_key'] = self.message_key.value + if self.detail: + payload['detail'] = dict(self.detail) + return payload + + +class ExcelCellError(ExcelAlchemyError): """Cell-level import error tied to a specific workbook header.""" - message = msg(MessageKey.EXCEL_IMPORT_ERROR) + default_message = msg(MessageKey.EXCEL_IMPORT_ERROR) label: Label parent_label: Label | None - detail: dict[str, object] def __init__( self, message: str, label: Label, parent_label: Label | None = None, + *, + message_key: MessageKey | None = None, **kwargs: object, - ): - super().__init__(message, label, parent_label) - self.message = message or self.message + ) -> None: + super().__init__(message, message_key=message_key, **kwargs) self.label = label self.parent_label = parent_label - self.detail = kwargs or {} self._validate() def __str__(self) -> str: return f'【{self.label}】{self.message}' def __repr__(self) -> str: - return f"{type(self).__name__}(label=Label('{self.label}'), message='{self.message}')" + return ( + f"{type(self).__name__}(label=Label('{self.label}'), " + f"parent_label={self.parent_label!r}, message='{self.message}')" + ) def __eq__(self, other: object) -> bool: if not isinstance(other, ExcelCellError): return NotImplemented - return str(self) == str(other) + return ( + self.message, + self.label, + self.parent_label, + self.detail, + ) == ( + other.message, + other.label, + other.parent_label, + other.detail, + ) @property def unique_label(self) -> UniqueLabel: @@ -52,29 +100,46 @@ def _validate(self) -> None: if not self.label: raise ValueError(msg(MessageKey.LABEL_CANNOT_BE_EMPTY)) + def to_dict(self) -> dict[str, object]: + payload = super().to_dict() + payload['label'] = str(self.label) + payload['parent_label'] = None if self.parent_label is None else str(self.parent_label) + payload['unique_label'] = str(self.unique_label) + return payload -class ExcelRowError(Exception): + +class ExcelRowError(ExcelAlchemyError): """Row-level import error not tied to a single workbook cell.""" - message = msg(MessageKey.EXCEL_ROW_IMPORT_ERROR) + default_message = msg(MessageKey.EXCEL_ROW_IMPORT_ERROR) def __init__( self, message: str, + *, + message_key: MessageKey | None = None, **kwargs: object, - ): - super().__init__(message) - self.message = message or self.message - self.detail = kwargs or {} - - def __str__(self) -> str: - return self.message + ) -> None: + super().__init__(message, message_key=message_key, **kwargs) def __repr__(self) -> str: - return f"{type(self).__name__}(message='{self.message}')" + return f"{type(self).__name__}(message='{self.message}', detail={self.detail!r})" + def __eq__(self, other: object) -> bool: + if not isinstance(other, ExcelRowError): + return NotImplemented + return (self.message, self.detail) == (other.message, other.detail) -class ProgrammaticError(Exception): ... +class ProgrammaticError(ExcelAlchemyError): + """Raised when a declaration or library usage pattern is unsupported.""" + + def __repr__(self) -> str: + return f"{type(self).__name__}(message='{self.message}', detail={self.detail!r})" -class ConfigError(Exception): ... + +class ConfigError(ExcelAlchemyError): + """Raised when runtime configuration is missing or inconsistent.""" + + def __repr__(self) -> str: + return f"{type(self).__name__}(message='{self.message}', detail={self.detail!r})" diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index ef7d0e6..a28c51e 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -1,3 +1,4 @@ +import re from collections.abc import Generator, Iterable, Mapping from dataclasses import dataclass from types import UnionType @@ -17,6 +18,49 @@ type ExcelValidationIssue = ExcelCellError | ExcelRowError type ExcelValidationIssues = list[ExcelValidationIssue] +_MIN_ITEMS_PATTERN = re.compile(r'^Value should have at least (\d+) items after validation, not \d+$') +_MAX_ITEMS_PATTERN = re.compile(r'^Value should have at most (\d+) items after validation, not \d+$') + + +def _normalize_validation_message(message: str, field_def: FieldMetaInfo | None = None) -> str: + normalized = message.strip() + if normalized == 'Field required': + return msg(MessageKey.THIS_FIELD_IS_REQUIRED) + + for prefix in ('Value error, ', 'Assertion failed, '): + if normalized.startswith(prefix): + normalized = normalized.removeprefix(prefix) + break + + normalized = _normalize_constraint_message(normalized, field_def) + + if normalized and normalized[0].islower(): + normalized = normalized[0].upper() + normalized[1:] + + return normalized + + +def _normalize_constraint_message(message: str, field_def: FieldMetaInfo | None) -> str: + if field_def is None: + return message + + constraints = field_def.constraints + + if (match := _MIN_ITEMS_PATTERN.match(message)) is not None: + if constraints.min_length is not None: + return msg(MessageKey.MIN_LENGTH_CHARACTERS, min_length=constraints.min_length) + return msg(MessageKey.MIN_ITEMS_REQUIRED, min_items=int(match.group(1))) + + if (match := _MAX_ITEMS_PATTERN.match(message)) is not None: + if constraints.max_length is not None: + return msg(MessageKey.MAX_LENGTH_CHARACTERS, max_length=constraints.max_length) + return msg(MessageKey.MAX_ITEMS_ALLOWED, max_items=int(match.group(1))) + + if message == 'Input should be a valid dictionary': + return msg(MessageKey.ENTER_VALUE_EXPECTED_FORMAT) + + return message + def _resolve_excel_codec_type(annotation: object) -> type[ExcelFieldCodec]: if isinstance(annotation, type): @@ -122,7 +166,7 @@ def extract_pydantic_model( ) -> list[FieldMetaInfo]: """Extract Excel field metadata from a Pydantic model declaration.""" if model is None: - raise RuntimeError(msg(MessageKey.MODEL_CANNOT_BE_NONE)) + raise ProgrammaticError(msg(MessageKey.MODEL_CANNOT_BE_NONE), message_key=MessageKey.MODEL_CANNOT_BE_NONE) return list(_extract_pydantic_model(PydanticModelAdapter(model))) @@ -189,7 +233,8 @@ def _handle_error( exc: Exception, field_def: FieldMetaInfo, ) -> None: - messages = [str(arg) for arg in exc.args if str(arg)] or [str(exc) or msg(MessageKey.INVALID_INPUT)] + raw_messages = [str(arg) for arg in exc.args if str(arg)] or [str(exc) or msg(MessageKey.INVALID_INPUT)] + messages = [_normalize_validation_message(message, field_def) for message in raw_messages] error_container.extend( ExcelCellError( label=field_def.label, @@ -220,18 +265,18 @@ def _map_validation_error( for error in exc.errors(): loc = error.get('loc', ()) if not loc: - mapped.append(ExcelRowError(str(error['msg']))) + mapped.append(ExcelRowError(_normalize_validation_message(str(error['msg'])))) continue field_name = loc[0] if not isinstance(field_name, str): - mapped.append(ExcelRowError(str(error['msg']))) + mapped.append(ExcelRowError(_normalize_validation_message(str(error['msg'])))) continue if field_name in failed_fields: continue field_adapter = model_adapter.field(field_name) - message = str(error['msg']) + message = _normalize_validation_message(str(error['msg']), field_adapter.declared_metadata) if len(loc) > 1 and isinstance(loc[1], str): mapped.append(_nested_excel_error(field_adapter, loc[1], message)) continue diff --git a/src/excelalchemy/i18n/messages.py b/src/excelalchemy/i18n/messages.py index 4182f61..4e825e6 100644 --- a/src/excelalchemy/i18n/messages.py +++ b/src/excelalchemy/i18n/messages.py @@ -73,14 +73,18 @@ class MessageKey(StrEnum): ENTER_NUMBER_EXPECTED_FORMAT = 'enter_number_expected_format' VALID_URL_REQUIRED = 'valid_url_required' VALID_PHONE_NUMBER_REQUIRED = 'valid_phone_number_required' + MIN_LENGTH_CHARACTERS = 'min_length_characters' MULTIPLE_SELECTIONS_NOT_SUPPORTED = 'multiple_selections_not_supported' OPTIONS_CANNOT_BE_NONE_FOR_SELECTION_FIELDS = 'options_cannot_be_none_for_selection_fields' OPTIONS_CANNOT_BE_NONE_FOR_VALUE_TYPE = 'options_cannot_be_none_for_value_type' OPTIONS_CONTAIN_DUPLICATES = 'options_contain_duplicates' CHARACTER_SET_NOT_CONFIGURED = 'character_set_not_configured' MAX_LENGTH_CHARACTERS = 'max_length_characters' + MIN_ITEMS_REQUIRED = 'min_items_required' + MAX_ITEMS_ALLOWED = 'max_items_allowed' ONLY_CHARACTER_SET_ALLOWED = 'only_character_set_allowed' IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION = 'import_result_only_for_invalid_header_validation' + ENTER_VALUE_EXPECTED_FORMAT = 'enter_value_expected_format' BOOLEAN_ENTER_YES_OR_NO = 'boolean_enter_yes_or_no' BOOLEAN_TRUE_DISPLAY = 'boolean_true_display' BOOLEAN_FALSE_DISPLAY = 'boolean_false_display' @@ -231,6 +235,7 @@ class MessageKey(StrEnum): MessageKey.ENTER_NUMBER_EXPECTED_FORMAT: 'Enter a number in the expected format', MessageKey.VALID_URL_REQUIRED: 'Enter a valid URL', MessageKey.VALID_PHONE_NUMBER_REQUIRED: 'Enter a valid phone number', + MessageKey.MIN_LENGTH_CHARACTERS: 'The minimum length is {min_length} characters', MessageKey.MULTIPLE_SELECTIONS_NOT_SUPPORTED: 'Multiple selections are not supported', MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_SELECTION_FIELDS: ( 'options cannot be None when validating RADIO / MULTI_CHECKBOX / SELECT fields' @@ -239,10 +244,13 @@ class MessageKey(StrEnum): MessageKey.OPTIONS_CONTAIN_DUPLICATES: 'Options contain duplicates', MessageKey.CHARACTER_SET_NOT_CONFIGURED: 'character_set is not configured', MessageKey.MAX_LENGTH_CHARACTERS: 'The maximum length is {max_length} characters', + MessageKey.MIN_ITEMS_REQUIRED: 'Select at least {min_items} items', + MessageKey.MAX_ITEMS_ALLOWED: 'Select no more than {max_items} items', MessageKey.ONLY_CHARACTER_SET_ALLOWED: 'Only {character_set_names} are allowed', MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION: ( 'ImportResult can only be built from an invalid header validation result' ), + MessageKey.ENTER_VALUE_EXPECTED_FORMAT: 'Enter a value in the expected format shown in the header comment', MessageKey.BOOLEAN_ENTER_YES_OR_NO: 'Enter "{true_value}" or "{false_value}"', MessageKey.BOOLEAN_TRUE_DISPLAY: 'Yes', MessageKey.BOOLEAN_FALSE_DISPLAY: 'No', @@ -309,6 +317,13 @@ class MessageKey(StrEnum): MessageKey.LABEL_MAXIMUM_VALUE: 'Maximum value', }, 'zh-CN': { + MessageKey.THIS_FIELD_IS_REQUIRED: '此字段为必填项', + MessageKey.MIN_LENGTH_CHARACTERS: '最小长度为 {min_length} 个字符', + MessageKey.MAX_LENGTH_CHARACTERS: '最大长度为 {max_length} 个字符', + MessageKey.MIN_ITEMS_REQUIRED: '至少选择 {min_items} 项', + MessageKey.MAX_ITEMS_ALLOWED: '最多选择 {max_items} 项', + MessageKey.ENTER_VALUE_EXPECTED_FORMAT: '请按照表头注释中给出的格式填写', + MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION: '仅当表头校验不通过时,才能构造 ImportResult', MessageKey.HEADER_HINT: ( '\n导入填写须知:\n' '1、填写数据时,请注意查看字段名称上的注释,避免导入失败。\n' diff --git a/src/excelalchemy/metadata.py b/src/excelalchemy/metadata.py index 963dd6d..7555b45 100644 --- a/src/excelalchemy/metadata.py +++ b/src/excelalchemy/metadata.py @@ -103,15 +103,21 @@ class RuntimeFieldBinding: def make_unique_label(self, *, label: Label) -> UniqueLabel: if self.parent_label is None: - raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) + raise ProgrammaticError( + msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME), + message_key=MessageKey.PARENT_LABEL_EMPTY_RUNTIME, + ) unique_label = f'{self.parent_label}{UNIQUE_HEADER_CONNECTOR}{label}' if self.parent_label != label else label return UniqueLabel(unique_label) def make_unique_key(self, *, key: Key | None) -> UniqueKey: if self.parent_key is None: - raise RuntimeError(msg(MessageKey.PARENT_KEY_EMPTY_RUNTIME)) + raise ProgrammaticError( + msg(MessageKey.PARENT_KEY_EMPTY_RUNTIME), + message_key=MessageKey.PARENT_KEY_EMPTY_RUNTIME, + ) if key is None: - raise RuntimeError(msg(MessageKey.KEY_EMPTY_RUNTIME)) + raise ProgrammaticError(msg(MessageKey.KEY_EMPTY_RUNTIME), message_key=MessageKey.KEY_EMPTY_RUNTIME) unique_key = f'{self.parent_key}{UNIQUE_HEADER_CONNECTOR}{key}' if self.parent_key != key else key return UniqueKey(unique_key) diff --git a/src/excelalchemy/results.py b/src/excelalchemy/results.py index e41795a..25a14bb 100644 --- a/src/excelalchemy/results.py +++ b/src/excelalchemy/results.py @@ -1,10 +1,12 @@ """Import result models for ExcelAlchemy workflows.""" +from collections.abc import Iterable from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field -from excelalchemy._primitives.identity import Label +from excelalchemy._primitives.identity import ColumnIndex, Label, RowIndex +from excelalchemy.exceptions import ExcelCellError, ExcelRowError, ProgrammaticError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.i18n.messages import message as msg @@ -14,6 +16,83 @@ def _empty_labels() -> list[Label]: return [] +type RowIssue = ExcelRowError | ExcelCellError + + +class CellErrorMap(dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]): + """Workbook-coordinate cell error mapping with convenience accessors.""" + + def add(self, row_index: RowIndex | int, column_index: ColumnIndex | int, error: ExcelCellError) -> None: + self.setdefault(RowIndex(row_index), {}).setdefault(ColumnIndex(column_index), []).append(error) + + def at(self, row_index: RowIndex | int, column_index: ColumnIndex | int) -> tuple[ExcelCellError, ...]: + row = self.get(RowIndex(row_index), {}) + return tuple(row.get(ColumnIndex(column_index), ())) + + def for_row(self, row_index: RowIndex | int) -> dict[ColumnIndex, tuple[ExcelCellError, ...]]: + row = self.get(RowIndex(row_index), {}) + return {column_index: tuple(errors) for column_index, errors in row.items()} + + def messages_at(self, row_index: RowIndex | int, column_index: ColumnIndex | int) -> tuple[str, ...]: + return tuple(str(error) for error in self.at(row_index, column_index)) + + def flatten(self) -> tuple[ExcelCellError, ...]: + return tuple(error for row in self.values() for errors in row.values() for error in errors) + + def to_dict(self) -> dict[int, dict[int, list[dict[str, object]]]]: + return { + int(row_index): { + int(column_index): [error.to_dict() for error in errors] for column_index, errors in row.items() + } + for row_index, row in self.items() + } + + @property + def has_errors(self) -> bool: + return bool(self) + + @property + def error_count(self) -> int: + return len(self.flatten()) + + +class RowIssueMap(dict[RowIndex, list[RowIssue]]): + """Workbook-coordinate row issue mapping with convenience accessors.""" + + def add(self, row_index: RowIndex | int, error: RowIssue) -> None: + self.setdefault(RowIndex(row_index), []).append(error) + + def add_many(self, row_index: RowIndex | int, errors: Iterable[RowIssue]) -> None: + self.setdefault(RowIndex(row_index), []).extend(errors) + + def at(self, row_index: RowIndex | int) -> tuple[RowIssue, ...]: + return tuple(self.get(RowIndex(row_index), ())) + + def messages_for_row(self, row_index: RowIndex | int) -> tuple[str, ...]: + return tuple(str(error) for error in self.at(row_index)) + + def numbered_messages_for_row(self, row_index: RowIndex | int) -> tuple[str, ...]: + return self.numbered_messages(self.at(row_index)) + + def flatten(self) -> tuple[RowIssue, ...]: + return tuple(error for errors in self.values() for error in errors) + + def to_dict(self) -> dict[int, list[dict[str, object]]]: + return {int(row_index): [error.to_dict() for error in errors] for row_index, errors in self.items()} + + @staticmethod + def numbered_messages(errors: Iterable[RowIssue]) -> tuple[str, ...]: + return tuple(f'{index}、{error!s}' for index, error in enumerate(errors, start=1)) + + @property + def has_errors(self) -> bool: + return bool(self) + + @property + def error_count(self) -> int: + return len(self.flatten()) + + class ValidateRowResult(StrEnum): """Per-row validation status.""" @@ -80,7 +159,10 @@ class ImportResult(BaseModel): def from_validate_header_result(cls, result: ValidateHeaderResult) -> 'ImportResult': """Build an import result from a failed header-validation result.""" if result.is_valid: - raise RuntimeError(msg(MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION)) + raise ProgrammaticError( + msg(MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION), + message_key=MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION, + ) return cls( result=ValidateResult.HEADER_INVALID, is_required_missing=result.is_required_missing, diff --git a/src/excelalchemy/util/converter.py b/src/excelalchemy/util/converter.py index 4f35bb2..70272af 100644 --- a/src/excelalchemy/util/converter.py +++ b/src/excelalchemy/util/converter.py @@ -1,5 +1,4 @@ import re -from typing import cast from excelalchemy._primitives.constants import FIELD_DATA_KEY from excelalchemy._primitives.identity import Key @@ -26,7 +25,7 @@ def export_data_converter(data: ModelRowPayload, to_camel: bool = False) -> Mode if not isinstance(value, dict): raise TypeError(f'Expected fieldData payload to be a mapping, got {type(value)}') - for field_key, field_value in cast(ModelRowPayload, value).items(): + for field_key, field_value in value.items(): result[Key(f'{converted_key}.{field_key}')] = field_value return result @@ -46,5 +45,5 @@ def _nested_set(mapping: ModelRowPayload, keys: list[str], value: object) -> Non nested_mapping = mapping.setdefault(key, {}) if not isinstance(nested_mapping, dict): raise TypeError(f'Expected nested mapping at {key!r}, got {type(nested_mapping)}') - mapping = cast(ModelRowPayload, nested_mapping) + mapping = nested_mapping mapping[keys[-1]] = value diff --git a/src/excelalchemy/util/file.py b/src/excelalchemy/util/file.py index eaf11a9..2e53c2b 100644 --- a/src/excelalchemy/util/file.py +++ b/src/excelalchemy/util/file.py @@ -29,9 +29,9 @@ def flatten(data: Mapping[str, object], level: list[str] | None = None) -> dict[ tmp_dict: dict[str, object] = {} level = level or [] for key, val in data.items(): - if isinstance(val, Mapping): - nested = cast(Mapping[str, object], val) - tmp_dict.update(flatten(nested, [*level, key])) + nested_mapping = _string_key_mapping(val) + if nested_mapping is not None: + tmp_dict.update(flatten(nested_mapping, [*level, key])) else: tmp_dict[f'{UNIQUE_HEADER_CONNECTOR}'.join([*level, key])] = val return tmp_dict @@ -46,7 +46,23 @@ def value_is_nan(value: object) -> bool: return True if isinstance(value, Sequence) and not isinstance(value, str): - items = cast(Sequence[object], value) - return any(value_is_nan(item) for item in items) + return any(value_is_nan(item) for item in _sequence_items(cast(Sequence[object], value))) return False + + +def _string_key_mapping(value: object) -> Mapping[str, object] | None: + if not isinstance(value, Mapping): + return None + + raw_mapping = cast(Mapping[object, object], value) + normalized: dict[str, object] = {} + for key, item in raw_mapping.items(): + if not isinstance(key, str): + return None + normalized[key] = item + return normalized + + +def _sequence_items(value: Sequence[object]) -> list[object]: + return list(value) diff --git a/tests/contracts/test_core_components_contract.py b/tests/contracts/test_core_components_contract.py index b52010c..8c2aab4 100644 --- a/tests/contracts/test_core_components_contract.py +++ b/tests/contracts/test_core_components_contract.py @@ -85,3 +85,5 @@ def test_issue_tracker_offsets_cell_errors_after_result_columns(self): 2: [error], } } + assert tracker.cell_errors.at(RowIndex(0), 2) == (error,) + assert tracker.cell_errors.messages_at(RowIndex(0), 2) == ('【姓名】Simulated failure',) diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py index 2efd3c5..e4294cf 100644 --- a/tests/contracts/test_pydantic_contract.py +++ b/tests/contracts/test_pydantic_contract.py @@ -1,5 +1,6 @@ from typing import Annotated +import pytest from pydantic import BaseModel, Field, field_validator, model_validator from excelalchemy import ( @@ -12,6 +13,7 @@ ExcelRowError, FieldMeta, Label, + ProgrammaticError, ) from excelalchemy.helper.pydantic import extract_pydantic_model, instantiate_pydantic_model from excelalchemy.metadata import FieldMetaInfo, extract_declared_field_metadata @@ -46,6 +48,12 @@ def test_instantiate_pydantic_model_maps_validation_errors_to_excel_cell_errors( assert result[0].label == Label('邮箱') assert result[1].label == Label('停留时间') + def test_instantiate_pydantic_model_normalizes_missing_field_messages(self): + result = instantiate_pydantic_model({'email': 'noreply@example.com'}, ContractPydanticModel) + + assert isinstance(result, list) + assert result == [ExcelCellError(label=Label('停留时间'), message='This field is required')] + def test_instantiate_pydantic_model_applies_field_constraints_and_field_validators(self): class FieldValidatedModel(BaseModel): name: Email = FieldMeta(label='邮箱', order=1, min_length=20) @@ -61,12 +69,10 @@ def must_use_company_domain(cls, value: str) -> str: wrong_domain = instantiate_pydantic_model({'name': 'long-enough-address@openai.com'}, FieldValidatedModel) assert isinstance(too_short, list) - assert too_short == [ - ExcelCellError(label=Label('邮箱'), message='Value should have at least 20 items after validation, not 6') - ] + assert too_short == [ExcelCellError(label=Label('邮箱'), message='The minimum length is 20 characters')] assert isinstance(wrong_domain, list) - assert wrong_domain == [ExcelCellError(label=Label('邮箱'), message='Value error, must use the company domain')] + assert wrong_domain == [ExcelCellError(label=Label('邮箱'), message='Must use the company domain')] def test_instantiate_pydantic_model_maps_model_validators_to_row_errors(self): class ModelValidatedContract(BaseModel): @@ -88,7 +94,7 @@ def reject_combination(self): assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], ExcelRowError) - assert str(result[0]) == 'Value error, combination invalid' + assert str(result[0]) == 'Combination invalid' def test_custom_excel_field_codec_can_define_new_style_extension_surface(self): class UppercaseTextCodec(str, ExcelFieldCodec): @@ -142,6 +148,10 @@ class AnnotatedContractModel(BaseModel): assert declared_metadata.importer_min_length == 20 assert [meta.unique_label for meta in metas] == ['邮箱', '停留时间·开始日期', '停留时间·结束日期'] assert isinstance(result, list) - assert result == [ - ExcelCellError(label=Label('邮箱'), message='Value should have at least 20 items after validation, not 6') - ] + assert result == [ExcelCellError(label=Label('邮箱'), message='The minimum length is 20 characters')] + + def test_extract_pydantic_model_requires_a_model(self): + with pytest.raises(ProgrammaticError) as context: + extract_pydantic_model(None) + + assert str(context.value) == 'model cannot be None' diff --git a/tests/contracts/test_result_contract.py b/tests/contracts/test_result_contract.py index 3bd5462..14464c7 100644 --- a/tests/contracts/test_result_contract.py +++ b/tests/contracts/test_result_contract.py @@ -1,4 +1,6 @@ -from excelalchemy import Label, ValidateResult +import pytest + +from excelalchemy import Label, ProgrammaticError, ValidateResult from excelalchemy.results import ImportResult, ValidateHeaderResult @@ -44,3 +46,17 @@ def test_import_result_returns_success_defaults_for_success_case(self): assert result.missing_primary == [] assert result.unrecognized == [] assert result.duplicated == [] + + def test_import_result_from_validate_header_result_rejects_valid_input(self): + validate_header = ValidateHeaderResult( + missing_required=[], + missing_primary=[], + unrecognized=[], + duplicated=[], + is_valid=True, + ) + + with pytest.raises(ProgrammaticError) as context: + ImportResult.from_validate_header_result(validate_header) + + assert str(context.value) == 'ImportResult can only be built from an invalid header validation result' diff --git a/tests/integration/test_examples_smoke.py b/tests/integration/test_examples_smoke.py index 0138050..9c6ab41 100644 --- a/tests/integration/test_examples_smoke.py +++ b/tests/integration/test_examples_smoke.py @@ -1,6 +1,7 @@ from __future__ import annotations import contextlib +import importlib import importlib.util import io from pathlib import Path @@ -115,6 +116,21 @@ def test_fastapi_example_source_compiles() -> None: compile(source, str(EXAMPLES_DIR / 'fastapi_upload.py'), 'exec') +@pytest.mark.skipif(importlib.util.find_spec('fastapi') is None, reason='fastapi is not installed') +def test_fastapi_example_main_runs_when_optional_dependency_is_available() -> None: + module = _load_example_module('example_fastapi_upload_main', 'fastapi_upload.py') + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + module.main() + + output = buffer.getvalue() + assert 'FastAPI upload example completed' in output + assert 'Success rows: 1' in output + assert '/employee-template.xlsx' in output + assert '/employee-imports' in output + + @pytest.mark.skipif(importlib.util.find_spec('minio') is None, reason='minio is not installed') def test_minio_storage_example_main_builds_gateway() -> None: module = _load_example_module('example_minio_storage', 'minio_storage.py') @@ -133,5 +149,48 @@ def test_minio_storage_example_main_builds_gateway() -> None: def test_fastapi_example_module_imports_when_optional_dependency_is_available() -> None: module = _load_example_module('example_fastapi_upload', 'fastapi_upload.py') assert module.app is not None - assert module.download_template is not None - assert module.import_employees is not None + assert module.create_app is not None + assert module.import_employees_from_upload is not None + + +@pytest.mark.skipif( + importlib.util.find_spec('fastapi') is None or importlib.util.find_spec('httpx') is None, + reason='fastapi/httpx is not installed', +) +def test_fastapi_example_endpoints_work_when_optional_dependencies_are_available() -> None: + from openpyxl import load_workbook + + module = _load_example_module('example_fastapi_upload_client', 'fastapi_upload.py') + testclient_module = importlib.import_module('fastapi.testclient') + TestClient = testclient_module.TestClient + + client = TestClient(module.create_app()) + template_response = client.get('/employee-template.xlsx') + assert template_response.status_code == 200 + + workbook = load_workbook(io.BytesIO(template_response.content)) + try: + worksheet = workbook['Sheet1'] + worksheet['A3'] = 'TaylorChen' + worksheet['B3'] = '32' + buffer = io.BytesIO() + workbook.save(buffer) + upload_bytes = buffer.getvalue() + finally: + workbook.close() + + import_response = client.post( + '/employee-imports', + files={ + 'file': ( + 'employee-import.xlsx', + upload_bytes, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + }, + ) + assert import_response.status_code == 200 + payload = import_response.json() + assert payload['result']['result'] == 'SUCCESS' + assert payload['created_rows'] == 1 + assert payload['uploaded_artifacts'] == ['employee-import-result.xlsx'] diff --git a/tests/unit/codecs/test_date_codec.py b/tests/unit/codecs/test_date_codec.py index 4440caf..c203837 100644 --- a/tests/unit/codecs/test_date_codec.py +++ b/tests/unit/codecs/test_date_codec.py @@ -53,7 +53,10 @@ class Importer(BaseModel): assert ( error.message == 'Enter a date in yyyy/mm format' ) # may be more accurate to say 'Enter a date in yyyy/mm format, e.g. 2021/01' - assert repr(error) == "ExcelCellError(label=Label('出生日期'), message='Enter a date in yyyy/mm format')" + assert ( + repr(error) + == "ExcelCellError(label=Label('出生日期'), parent_label=None, message='Enter a date in yyyy/mm format')" + ) assert str(error) == '【出生日期】Enter a date in yyyy/mm format' async def test_import_rejects_dates_that_do_not_match_day_format(self): diff --git a/tests/unit/test_excel_exceptions.py b/tests/unit/test_excel_exceptions.py index 5131479..d354181 100644 --- a/tests/unit/test_excel_exceptions.py +++ b/tests/unit/test_excel_exceptions.py @@ -1,4 +1,4 @@ -from excelalchemy import ExcelCellError, ExcelRowError, Label +from excelalchemy import CellErrorMap, ConfigError, ExcelCellError, ExcelRowError, Label, ProgrammaticError, RowIssueMap from tests.support import BaseTestCase @@ -15,7 +15,10 @@ async def test_excel_cell_errors_compare_equal_when_message_and_label_match(self async def test_excel_cell_error_repr_includes_label_and_message(self): exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') - assert repr(exc1) == "ExcelCellError(label=Label('邮箱'), message='Enter a valid email address')" + assert ( + repr(exc1) + == "ExcelCellError(label=Label('邮箱'), parent_label=None, message='Enter a valid email address')" + ) async def test_excel_cell_error_str_prefixes_label(self): exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') @@ -40,6 +43,10 @@ async def test_excel_cell_error_supports_equality_and_inequality_operations(self exc2 = ExcelCellError(label=Label('邮箱1'), message='Enter a valid email address') assert exc1 != exc2 + exc1 = ExcelCellError(label=Label('邮箱'), parent_label=Label('员工'), message='Enter a valid email address') + exc2 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + assert exc1 != exc2 + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') other = 'other' @@ -57,4 +64,94 @@ async def test_excel_row_error_preserves_message_in_string_representations(self) assert str(exc1) == 'Enter a valid email address' exc1 = ExcelRowError(message='Enter a valid email address') - assert repr(exc1) == "ExcelRowError(message='Enter a valid email address')" + assert repr(exc1) == "ExcelRowError(message='Enter a valid email address', detail={})" + + async def test_excel_cell_error_to_dict_includes_coordinate_metadata(self): + exc = ExcelCellError(label=Label('邮箱'), parent_label=Label('员工'), message='Enter a valid email address') + + assert exc.to_dict() == { + 'type': 'ExcelCellError', + 'message': 'Enter a valid email address', + 'label': '邮箱', + 'parent_label': '员工', + 'unique_label': '员工·邮箱', + } + + async def test_programmatic_and_config_errors_preserve_detail_payloads(self): + programmatic = ProgrammaticError('Invalid declaration', field='email') + config = ConfigError('Missing storage backend', backend='minio') + + assert programmatic.to_dict() == { + 'type': 'ProgrammaticError', + 'message': 'Invalid declaration', + 'detail': {'field': 'email'}, + } + assert config.to_dict() == { + 'type': 'ConfigError', + 'message': 'Missing storage backend', + 'detail': {'backend': 'minio'}, + } + assert repr(programmatic) == "ProgrammaticError(message='Invalid declaration', detail={'field': 'email'})" + assert repr(config) == "ConfigError(message='Missing storage backend', detail={'backend': 'minio'})" + + async def test_cell_error_map_supports_coordinate_access_and_flattening(self): + error_map = CellErrorMap() + error = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + + error_map.add(0, 3, error) + + assert error_map.has_errors is True + assert error_map.error_count == 1 + assert error_map.at(0, 3) == (error,) + assert error_map.messages_at(0, 3) == ('【邮箱】Enter a valid email address',) + assert error_map.flatten() == (error,) + assert error_map.for_row(0) == {3: (error,)} + assert error_map.to_dict() == { + 0: { + 3: [ + { + 'type': 'ExcelCellError', + 'message': 'Enter a valid email address', + 'label': '邮箱', + 'parent_label': None, + 'unique_label': '邮箱', + } + ] + } + } + + async def test_row_issue_map_supports_row_access_and_numbered_messages(self): + issue_map = RowIssueMap() + cell_error = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + row_error = ExcelRowError(message='Combination invalid') + + issue_map.add(0, cell_error) + issue_map.add(0, row_error) + + assert issue_map.has_errors is True + assert issue_map.error_count == 2 + assert issue_map.at(0) == (cell_error, row_error) + assert issue_map.messages_for_row(0) == ('【邮箱】Enter a valid email address', 'Combination invalid') + assert issue_map.numbered_messages_for_row(0) == ( + '1、【邮箱】Enter a valid email address', + '2、Combination invalid', + ) + assert issue_map.numbered_messages(issue_map.at(0)) == ( + '1、【邮箱】Enter a valid email address', + '2、Combination invalid', + ) + assert issue_map.to_dict() == { + 0: [ + { + 'type': 'ExcelCellError', + 'message': 'Enter a valid email address', + 'label': '邮箱', + 'parent_label': None, + 'unique_label': '邮箱', + }, + { + 'type': 'ExcelRowError', + 'message': 'Combination invalid', + }, + ] + }