From 28e3d410cb706d93832c142074573d238d320844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 4 Apr 2026 11:50:13 +0800 Subject: [PATCH] feat(v2.2.6): result objects are now documented as first-class API integration surfaces --- CHANGELOG.md | 55 ++++++ README-pypi.md | 31 ++- README.md | 39 +++- README_cn.md | 36 +++- docs/examples-showcase.md | 6 + docs/getting-started.md | 23 +++ docs/public-api.md | 15 +- docs/releases/2.2.5.md | 17 +- docs/releases/2.2.6.md | 104 ++++++++++ docs/result-objects.md | 183 ++++++++++++++++++ examples/README.md | 19 ++ examples/__init__.py | 1 + examples/fastapi_reference/README.md | 137 +++++++++++++ examples/fastapi_reference/__init__.py | 1 + examples/fastapi_reference/app.py | 62 ++++++ examples/fastapi_reference/models.py | 13 ++ examples/fastapi_reference/services.py | 99 ++++++++++ examples/fastapi_reference/storage.py | 49 +++++ files/example-outputs/fastapi-reference.txt | 7 + scripts/generate_example_output_assets.py | 22 +++ scripts/smoke_examples.py | 21 ++ scripts/smoke_package.py | 36 ++++ src/excelalchemy/__init__.py | 6 +- src/excelalchemy/codecs/base.py | 65 ++++++- src/excelalchemy/codecs/date.py | 15 +- src/excelalchemy/codecs/date_range.py | 11 +- src/excelalchemy/codecs/email.py | 4 + src/excelalchemy/codecs/multi_checkbox.py | 35 +++- src/excelalchemy/codecs/number.py | 17 +- src/excelalchemy/codecs/number_range.py | 23 +-- src/excelalchemy/codecs/organization.py | 8 + src/excelalchemy/codecs/phone_number.py | 4 + src/excelalchemy/codecs/radio.py | 35 +++- src/excelalchemy/codecs/staff.py | 8 + src/excelalchemy/codecs/tree.py | 8 + src/excelalchemy/codecs/url.py | 4 + src/excelalchemy/helper/pydantic.py | 143 ++++++++++---- src/excelalchemy/i18n/messages.py | 34 +++- src/excelalchemy/results.py | 58 ++++++ tests/contracts/test_pydantic_contract.py | 21 +- tests/integration/test_examples_smoke.py | 57 ++++++ tests/unit/codecs/test_email_codec.py | 2 +- tests/unit/codecs/test_multi_staff_codec.py | 21 ++ tests/unit/codecs/test_phone_number_codec.py | 3 + tests/unit/codecs/test_radio_codec.py | 4 +- .../codecs/test_single_organization_codec.py | 20 ++ tests/unit/codecs/test_url_codec.py | 4 +- tests/unit/test_excel_exceptions.py | 73 ++++++- 48 files changed, 1563 insertions(+), 96 deletions(-) create mode 100644 docs/releases/2.2.6.md create mode 100644 docs/result-objects.md create mode 100644 examples/__init__.py create mode 100644 examples/fastapi_reference/README.md create mode 100644 examples/fastapi_reference/__init__.py create mode 100644 examples/fastapi_reference/app.py create mode 100644 examples/fastapi_reference/models.py create mode 100644 examples/fastapi_reference/services.py create mode 100644 examples/fastapi_reference/storage.py create mode 100644 files/example-outputs/fastapi-reference.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3391748..a4362f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ 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.6] - 2026-04-04 + +This release continues the stable 2.x line with stronger consumer-facing +result-object guidance, a copyable FastAPI reference project, harder release +smoke validation, and clearer codec fallback diagnostics. + +### Added + +- Added `docs/result-objects.md` to explain how to read `ImportResult`, + `CellErrorMap`, and `RowIssueMap` and how to expose them through backend APIs +- Added a copyable FastAPI reference project under `examples/fastapi_reference` + with separate route, service, storage, and schema modules +- Added a captured output artifact for the FastAPI reference project and linked + it from the examples docs and showcase + +### Changed + +- Extended `docs/getting-started.md`, `docs/public-api.md`, + `docs/examples-showcase.md`, and the README entry points so the result + objects and API integration path are easier to discover +- Strengthened package smoke verification by validating both successful and + failed imports, including structured `cell_error_map` and `row_error_map` + payloads +- Expanded example smoke coverage so the FastAPI reference project is exercised + directly alongside the existing script-style examples +- Polished codec fallback warnings so parse failures now produce clearer + developer-facing diagnostics with field labels and cleaner exception reasons + +### Compatibility Notes + +- No public import or export workflow API was removed in this release +- `ImportResult`, `CellErrorMap`, and `RowIssueMap` remain the stable public + result objects for 2.x integrations +- The FastAPI reference project is additive guidance and does not change the + public API surface +- `storage=...` remains the recommended 2.x backend configuration path + +### Release Summary + +- result objects are now documented as first-class API integration surfaces +- the repository now includes a copyable FastAPI reference-project layout +- release smoke verification checks successful imports, failed imports, and + structured error payloads +- codec fallback warnings are easier to read and more useful during debugging + ## [2.2.5] - 2026-04-04 This release continues the stable 2.x line with error UX polish, clearer @@ -14,6 +59,8 @@ typing cleanup across the runtime path. - Added `CellErrorMap` and `RowIssueMap` as richer workbook-facing error access containers while preserving 2.x dict-like compatibility +- Added structured error records and API-friendly payload helpers through + `records()` and `to_api_payload()` on both `CellErrorMap` and `RowIssueMap` - 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 @@ -21,6 +68,9 @@ typing cleanup across the runtime path. - Added more business-oriented examples, including employee import, create-or-update import, export workflow, selection-heavy forms, and date/range field workflows +- Added a minimal FastAPI reference project with separate route, service, + storage, and schema modules so teams can start from a copyable backend + layout instead of only single-file examples - Added stronger smoke scripts and release checks for installed packages, repository examples, and generated example-output assets @@ -34,6 +84,9 @@ typing cleanup across the runtime path. output and clearer equality semantics - Normalized common validation messages into more natural, workbook-facing English such as `This field is required` +- Made common field-type validation messages more business-oriented by adding + expected-format hints for date, date-range, number-range, email, phone, + URL, and configured-selection fields - Clarified `FieldMetaInfo` as a compatibility facade over layered metadata objects and moved more internal consumers and codecs onto `declared`, `runtime`, `presentation`, and `constraints` @@ -44,6 +97,8 @@ typing cleanup across the runtime path. 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 +- Expanded the examples docs and showcase so the new FastAPI reference project + is linked from GitHub README, PyPI README, and the examples showcase page ### Fixed diff --git a/README-pypi.md b/README-pypi.md index b1232ce..498ff80 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -10,9 +10,9 @@ ExcelAlchemy turns Pydantic models into typed workbook contracts: - render workbook-facing output in `zh-CN` or `en` - keep storage pluggable through `ExcelStorage` -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. +The current stable release is `2.2.6`, which continues the 2.x line with stronger result-object guidance, a copyable FastAPI reference project, more robust smoke verification, and clearer codec fallback diagnostics. -[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) +[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) · [Result Objects](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.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 @@ -111,11 +111,38 @@ Full captured outputs: - [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) +- [fastapi-reference.txt](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/fastapi-reference.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). +If you want a copyable FastAPI-oriented reference layout rather than a single +example script, see the +[FastAPI reference project](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md). + +## Error Feedback + +ExcelAlchemy keeps workbook-facing validation feedback readable while also +supporting API-friendly inspection in application code. + +The stable 2.x result surface includes: + +- `alchemy.cell_error_map` +- `alchemy.row_error_map` + +These objects remain dict-like for compatibility, but also expose helpers such +as: + +- `messages_at(...)` +- `messages_for_row(...)` +- `flatten()` +- `to_api_payload()` + +Common field types now also produce more business-oriented error wording, such +as expected date formats, sample email/phone/URL formats, and clearer messages +for configured selection fields. + ## Why ExcelAlchemy - Pydantic v2-based schema extraction and validation diff --git a/README.md b/README.md index 3bddcc5..30716fd 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) · [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) +[中文 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) · [Result Objects](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.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.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. +The current stable release is `2.2.6`, which continues the ExcelAlchemy 2.x line with stronger result-object guidance, a copyable FastAPI reference project, more robust release smoke verification, and clearer codec fallback diagnostics. ## At a Glance @@ -186,6 +186,7 @@ Practical examples live in the repository: - [`examples/export_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/export_workflow.py) - [`examples/minio_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/minio_storage.py) - [`examples/fastapi_upload.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_upload.py) +- [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) - [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md) If you want the recommended reading order, start with @@ -231,6 +232,7 @@ Full captured outputs: - [`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) +- [`files/example-outputs/fastapi-reference.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/fastapi-reference.txt) ## Public API Boundaries @@ -257,6 +259,39 @@ The older aliases: still work in the 2.x line as compatibility paths, but new application code should use the clearer names above. +## Structured Error Access + +Import failures are now easier to inspect programmatically. + +- `alchemy.cell_error_map` +- `alchemy.row_error_map` + +Both containers remain dict-like for 2.x compatibility, but they also expose +clearer helper methods for application code and API handlers: + +- `at(...)` +- `messages_at(...)` +- `messages_for_row(...)` +- `numbered_messages_for_row(...)` +- `flatten()` +- `to_dict()` +- `to_api_payload()` + +This makes it easier to: + +- build frontend-friendly validation responses +- render row-level and cell-level failure summaries +- keep workbook feedback and API feedback aligned + +Common field types also provide more business-oriented validation wording. For +example: + +- date fields now mention the expected date format +- date range and number range fields now mention the expected combined input +- email, phone number, and URL fields now include example formats +- selection, organization, and staff fields now mention that values must come + from the configured options + ## Locale-Aware Workbook Output `locale` affects workbook-facing display text such as: diff --git a/README_cn.md b/README_cn.md index 44ae699..adfe6d7 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,11 +1,11 @@ # ExcelAlchemy -[English README](./README.md) · [项目说明](./ABOUT.md) · [架构文档](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [迁移说明](./MIGRATIONS.md) +[English README](./README.md) · [项目说明](./ABOUT.md) · [快速开始](./docs/getting-started.md) · [结果对象](./docs/result-objects.md) · [架构文档](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [迁移说明](./MIGRATIONS.md) ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 -当前稳定发布版本是 `2.2.5`,它在稳定的 ExcelAlchemy 2.x 线上继续加强了导入失败反馈、更清晰的入门与 public API 文档、更贴近真实业务的示例,以及更强的 release smoke 验证。 +当前稳定发布版本是 `2.2.6`,它在稳定的 ExcelAlchemy 2.x 线上继续加强了结果对象与接入说明、可复制的 FastAPI 参考项目、更稳的 release smoke 验证,以及更清晰的 codec fallback 诊断信息。 你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 @@ -115,6 +115,7 @@ pip install "ExcelAlchemy[minio]" - [`examples/export_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/export_workflow.py) - [`examples/minio_storage.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/minio_storage.py) - [`examples/fastapi_upload.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_upload.py) +- [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) - [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md) 如果你想按推荐顺序来阅读,建议先看 @@ -161,6 +162,7 @@ Uploaded objects: ['employees-export-upload.xlsx'] - [`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) +- [`files/example-outputs/fastapi-reference.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/fastapi-reference.txt) ## 快速开始 @@ -270,6 +272,36 @@ alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=InMemoryExcelStorage())) 在 2.x 里仍然可用,用于兼容旧代码;但新代码建议统一使用前面这组更明确的名字。 +## 结构化错误读取 + +现在导入失败不仅能回写到 workbook,也更适合被后端服务和前端界面读取。 + +- `alchemy.cell_error_map` +- `alchemy.row_error_map` + +这两个对象在 2.x 中仍然保持 dict 兼容,但同时提供了更适合业务代码使用的辅助方法: + +- `at(...)` +- `messages_at(...)` +- `messages_for_row(...)` +- `numbered_messages_for_row(...)` +- `flatten()` +- `to_dict()` +- `to_api_payload()` + +这意味着你可以更容易地: + +- 构造前端可直接消费的校验响应 +- 渲染按行和按单元格的失败摘要 +- 保持 workbook 提示和 API 提示的一致性 + +常见字段类型的错误提示也更贴近业务语义了,例如: + +- 日期字段会直接提示期望的日期格式 +- 日期区间和数值区间字段会提示期望的组合输入格式 +- 邮箱、手机号、URL 会给出更自然的示例格式 +- 选项、组织、人员类字段会明确提示“必须来自配置项” + ## 为什么这样设计 ### 为什么去掉 pandas diff --git a/docs/examples-showcase.md b/docs/examples-showcase.md index 6eef441..ae3d2bb 100644 --- a/docs/examples-showcase.md +++ b/docs/examples-showcase.md @@ -6,6 +6,9 @@ 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). +If you want to see how import results and error maps are meant to be surfaced +through backend APIs, see +[`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md). ## What ExcelAlchemy Looks Like @@ -119,6 +122,8 @@ If you want to see how ExcelAlchemy fits into backend systems: - [`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) + - [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) + - [`files/example-outputs/fastapi-reference.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/fastapi-reference.txt) ## Recommended Reading Order @@ -134,6 +139,7 @@ showcase: 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) +10. [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) Or start with the dedicated guide: diff --git a/docs/getting-started.md b/docs/getting-started.md index 90fec2f..d0cae58 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,6 +6,9 @@ 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). +If you want to understand the result objects and how to surface them through an +API, see +[`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md). ## 1. Install @@ -153,3 +156,23 @@ 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 + +## 8. Surface Results In Your Own API + +If you are integrating ExcelAlchemy into a web backend, the recommended public +result surface is: + +- `ImportResult` +- `alchemy.cell_error_map` +- `alchemy.row_error_map` + +These objects let you return: + +- a high-level import summary +- row-level error summaries +- cell-level coordinates for UI highlighting + +See: + +- [`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md) +- [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) diff --git a/docs/public-api.md b/docs/public-api.md index bfdb9f3..5fa41d5 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -10,6 +10,8 @@ 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). +If you want result-object guidance for backend or frontend integration, see +[`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md). ## Stable Public Modules @@ -44,6 +46,9 @@ These modules are the recommended import paths for application code: - import inspection names: Prefer `worksheet_table`, `header_table`, `cell_error_map`, and `row_error_map` when reading import-run state from the facade. +- structured error access: + Prefer `CellErrorMap` and `RowIssueMap` helpers such as `to_api_payload()` + when you need frontend-friendly or API-friendly validation output. ## Compatibility Modules In 2.x @@ -96,7 +101,15 @@ For most application code, these are the recommended import paths: - `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. + Use this if you need result models or richer error-map helper types directly. + +If you are building API responses from import failures, the recommended public +result helpers are: + +- `CellErrorMap.to_api_payload()` +- `RowIssueMap.to_api_payload()` +- `CellErrorMap.records()` +- `RowIssueMap.records()` Avoid depending on implementation details such as: diff --git a/docs/releases/2.2.5.md b/docs/releases/2.2.5.md index cbba3f3..3bdd5a9 100644 --- a/docs/releases/2.2.5.md +++ b/docs/releases/2.2.5.md @@ -20,9 +20,12 @@ for the `2.2.5` release on top of the stable 2.x line. - 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 +- common field types now provide more explicit expected-format guidance - exception boundaries are cleaner across `ProgrammaticError`, `ConfigError`, `ExcelCellError`, and `ExcelRowError` - examples are more realistic, better documented, and directly smoke-tested +- examples now include a minimal FastAPI reference project with route, + service, storage, and schema layers - 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 @@ -33,15 +36,18 @@ for the `2.2.5` release on top of the stable 2.x line. 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 +4. Confirm the API-friendly error payload helpers in + `src/excelalchemy/results.py`. +5. Confirm the exception polish in `src/excelalchemy/exceptions.py`. +6. Confirm the validation-message normalization in `src/excelalchemy/helper/pydantic.py`. -6. Confirm the documentation entry points: +7. Confirm the field-specific expected-format messages in the built-in codecs. +8. 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: +9. Confirm the smoke scripts: - `scripts/smoke_package.py` - `scripts/smoke_examples.py` - `scripts/generate_example_output_assets.py` @@ -78,9 +84,12 @@ themes clearly: - import failures are easier to inspect and surface - validation messages are more consistent and more natural +- structured error maps are easier to expose through API-friendly payloads +- common field types explain the expected input format more directly - 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 +- a copyable FastAPI reference project is now part of the examples surface - typing cleanup continues, but without forcing low-value abstraction or zero-`Any` purity diff --git a/docs/releases/2.2.6.md b/docs/releases/2.2.6.md new file mode 100644 index 0000000..bb966f7 --- /dev/null +++ b/docs/releases/2.2.6.md @@ -0,0 +1,104 @@ +# 2.2.6 Release Notes and Checklist + +This document records the final release positioning and verification checklist +for the `2.2.6` release on top of the stable 2.x line. + +## Purpose + +- publish the next stable 2.x refinement release of ExcelAlchemy +- present `2.2.6` as an integration-guidance and release-hardening update +- make result objects easier to understand and easier to expose through APIs +- add a copyable FastAPI reference-project layout +- strengthen release smoke verification around successful imports, failed + imports, and structured error payloads +- improve codec fallback diagnostics for debugging invalid workbook input + +## Release Positioning + +`2.2.6` should be presented as a stable 2.x integration and verification +release: + +- the public import and export workflow API stays stable +- result objects are now documented as first-class integration surfaces +- the repository now includes a copyable FastAPI reference-project layout +- smoke validation now checks successful imports, failed imports, and + structured error payloads +- codec fallback warnings are clearer and more developer-friendly +- the docs path from getting started to public API to result objects is more + complete + +## Before Tagging + +1. Confirm the intended version in `src/excelalchemy/__init__.py`. +2. Review the `2.2.6` section in `CHANGELOG.md`. +3. Confirm the result-object guide: + - `docs/result-objects.md` +4. Confirm the FastAPI reference project: + - `examples/fastapi_reference/README.md` + - `examples/fastapi_reference/app.py` + - `examples/fastapi_reference/services.py` +5. Confirm the smoke scripts: + - `scripts/smoke_package.py` + - `scripts/smoke_examples.py` + - `scripts/generate_example_output_assets.py` +6. Confirm the codec fallback wording in: + - `src/excelalchemy/codecs/base.py` + - `src/excelalchemy/codecs/number.py` + - `src/excelalchemy/codecs/date.py` + - `src/excelalchemy/codecs/date_range.py` + - `src/excelalchemy/codecs/number_range.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.6`. +4. Use the `2.2.6` 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: + +- result objects are easier to understand and easier to surface through APIs +- the repository now contains a copyable FastAPI-oriented reference layout +- release smoke validation is stronger and now covers failed imports and + structured error payloads +- codec fallback warnings are clearer and more useful during debugging +- the public 2.x API stays stable + +## Recommended Release Messaging + +Prefer wording that emphasizes polish and integration guidance: + +- "continues the stable 2.x line" +- "documents result objects as first-class integration surfaces" +- "adds a copyable FastAPI reference project" +- "strengthens release smoke verification" +- "improves codec fallback diagnostics without breaking the public API" + +## Done When + +- the tag `v2.2.6` is published +- the GitHub Release notes clearly describe the result-object, examples, and + smoke-verification improvements +- CI, package smoke, example smoke, and asset generation all pass +- the published package version matches the release tag diff --git a/docs/result-objects.md b/docs/result-objects.md new file mode 100644 index 0000000..b4f6e67 --- /dev/null +++ b/docs/result-objects.md @@ -0,0 +1,183 @@ +# Result Objects and API Integration + +This page explains how to read import results from ExcelAlchemy and how to turn +them into backend or frontend-friendly responses. + +If you are new to the library, start with +[`docs/getting-started.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md). +If you want the stable public API boundaries, see +[`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md). + +## Core Result Objects + +The most important public result objects are: + +- `ImportResult` +- `CellErrorMap` +- `RowIssueMap` + +You can import them from: + +```python +from excelalchemy import ImportResult +from excelalchemy.results import CellErrorMap, RowIssueMap +``` + +## `ImportResult` + +`ImportResult` is the high-level summary of one import run. + +Useful fields include: + +- `result` + Overall status such as `SUCCESS`, `DATA_INVALID`, or `HEADER_INVALID`. +- `success_count` + Number of successfully imported rows. +- `fail_count` + Number of failed rows. +- `url` + Result workbook download URL when one is produced. +- `missing_required` +- `missing_primary` +- `unrecognized` +- `duplicated` + +Typical usage: + +```python +result = await alchemy.import_data('employees.xlsx', 'employee-import-result.xlsx') + +if result.result == 'SUCCESS': + ... +``` + +## `CellErrorMap` + +`cell_error_map` stores workbook-coordinate cell-level failures. + +Recommended facade access: + +```python +cell_error_map = alchemy.cell_error_map +``` + +Useful helpers: + +- `at(row_index, column_index)` +- `messages_at(row_index, column_index)` +- `flatten()` +- `records()` +- `to_dict()` +- `to_api_payload()` + +Example: + +```python +payload = alchemy.cell_error_map.to_api_payload() +``` + +Shape: + +```json +{ + "error_count": 2, + "items": [ + { + "row_index": 0, + "column_index": 1, + "message": "Enter a valid email address, such as name@example.com", + "display_message": "Enter a valid email address, such as name@example.com" + } + ], + "by_row": { + "0": { + "1": [ + { + "message": "Enter a valid email address, such as name@example.com" + } + ] + } + } +} +``` + +Use this when you need: + +- frontend field-level highlighting +- API responses that point back to workbook coordinates +- UI summaries that keep workbook and JSON feedback aligned + +## `RowIssueMap` + +`row_error_map` stores row-level failures, including row errors and +cell-originated row summaries. + +Recommended facade access: + +```python +row_error_map = alchemy.row_error_map +``` + +Useful helpers: + +- `at(row_index)` +- `messages_for_row(row_index)` +- `numbered_messages_for_row(row_index)` +- `flatten()` +- `records()` +- `to_dict()` +- `to_api_payload()` + +Example: + +```python +payload = alchemy.row_error_map.to_api_payload() +``` + +Use this when you need: + +- one-line row summaries in an admin UI +- numbered failure lists for APIs +- a simpler summary than cell coordinates alone + +## Recommended API Response Pattern + +For a backend endpoint, a practical response shape is: + +```python +result = await alchemy.import_data('employees.xlsx', 'employee-import-result.xlsx') + +response = { + 'result': result.model_dump(), + 'cell_errors': alchemy.cell_error_map.to_api_payload(), + 'row_errors': alchemy.row_error_map.to_api_payload(), +} +``` + +This gives you: + +- a stable top-level import summary +- row-level summaries for tables or toast messages +- cell-level coordinates for fine-grained UI rendering + +## Workbook Feedback vs API Feedback + +ExcelAlchemy is designed so the workbook result and the API response can tell +the same story. + +- workbook feedback is written back into the result workbook +- API feedback can be built from `ImportResult`, `CellErrorMap`, and + `RowIssueMap` + +This is especially useful when: + +- an admin user downloads the result workbook +- a frontend wants to preview failures before download +- a backend needs to log structured import failures + +## Recommended Reading + +- [`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/employee_import_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/employee_import_workflow.py) +- [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) diff --git a/examples/README.md b/examples/README.md index 0cd51e5..8401db7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,9 @@ 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). +If you want a copyable reference layout rather than a single script, see +[`examples/fastapi_reference/`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md). + ## Recommended Reading Order 1. `annotated_schema.py` @@ -58,6 +61,12 @@ representative workflows, and captured outputs, see - 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. +10. `fastapi_reference/` + - Read this if you want a copyable minimal reference project rather than a single-file integration sketch. + - Shows a split between route, service, storage, and schema layers. + - Best for: teams integrating ExcelAlchemy into a real FastAPI backend. + - Output: prints the import result, created row count, uploaded artifacts, and registered route paths. + - Captured output: [`files/example-outputs/fastapi-reference.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/fastapi-reference.txt) ## By Goal @@ -75,6 +84,7 @@ representative workflows, and captured outputs, see - `selection_fields.py` - Learn web integration: - `fastapi_upload.py` + - `fastapi_reference/` ## Storage and Backend Integration @@ -86,6 +96,8 @@ representative workflows, and captured outputs, see - Shows the built-in Minio-backed storage path currently available in the 2.x line. - `fastapi_upload.py` - Shows a FastAPI integration sketch for template download and workbook import. +- `fastapi_reference/` + - Shows a minimal reference-project layout with route, service, storage, and schema modules. ## How To Run @@ -101,11 +113,18 @@ 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 +uv run python -m examples.fastapi_reference.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`. +For the reference-project version, use: + +```bash +uv run uvicorn examples.fastapi_reference.app:app --reload +``` + If you want to smoke-test the web integration without running a server, execute: ```bash diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..d990456 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Repository examples for ExcelAlchemy.""" diff --git a/examples/fastapi_reference/README.md b/examples/fastapi_reference/README.md new file mode 100644 index 0000000..6a19a50 --- /dev/null +++ b/examples/fastapi_reference/README.md @@ -0,0 +1,137 @@ +# FastAPI Reference Project + +This directory shows a minimal reference project structure for integrating +ExcelAlchemy into a FastAPI service. + +## Layout + +- `models.py` + Defines workbook schema declarations. +- `storage.py` + Defines a request-scoped in-memory `ExcelStorage` implementation. +- `services.py` + Holds the import service layer and helper functions. +- `app.py` + Wires routes to the service layer and exposes a runnable FastAPI app. + +## Responsibility Diagram + +```text +HTTP request + -> app.py + route registration and request parsing + -> services.py + template generation and import workflow orchestration + -> storage.py + upload fixture storage and result workbook upload handling + -> models.py + workbook schema declaration +``` + +This is intentionally small, but it mirrors the shape of a real backend: + +- routes stay thin +- workflow logic lives in a service +- storage is injected instead of hard-coded +- schema declarations stay separate from transport concerns + +## What It Demonstrates + +- route layer and service layer separation +- injected storage rather than global singleton state +- template download and workbook import endpoints +- a small, copyable structure that can be adapted into a real backend project + +## How To Run + +Run the demo entry point: + +```bash +uv run python -m examples.fastapi_reference.app +``` + +Or run the app under an ASGI server: + +```bash +uv run uvicorn examples.fastapi_reference.app:app --reload +``` + +## HTTP Endpoints + +### `GET /employee-template.xlsx` + +Downloads a workbook template generated from the importer schema. + +Response: + +- `200 OK` +- `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` +- `Content-Disposition: attachment; filename=employee-template.xlsx` + +### `POST /employee-imports` + +Accepts an uploaded workbook and runs the import workflow. + +Request: + +- multipart form-data +- file field name: `file` + +Example: + +```bash +curl -X POST \ + -F "file=@employee-import.xlsx" \ + http://127.0.0.1:8000/employee-imports +``` + +Example JSON response: + +```json +{ + "result": { + "result": "SUCCESS", + "download_url": "memory://employee-import-result.xlsx", + "success_count": 1, + "fail_count": 0 + }, + "created_rows": 1, + "uploaded_artifacts": ["employee-import-result.xlsx"] +} +``` + +If the workbook has validation errors, the same endpoint still returns a +structured result payload. Application code can then read: + +- workbook-level result status from `result` +- created row count from `created_rows` +- uploaded result workbook names from `uploaded_artifacts` + +In a real project, you would typically extend this response with: + +- `cell_error_map.to_api_payload()` +- `row_error_map.to_api_payload()` +- request-scoped trace or tenant metadata + +## Suggested Adaptation Path + +If you want to copy this into a real service, the next steps are usually: + +1. Replace the in-memory `RequestScopedStorage` with your own `ExcelStorage`. +2. Move `EmployeeImporter` into your domain package. +3. Replace the demo `creator` logic with your real service or repository call. +4. Extend the response payload with your own API contract. +5. Add authentication, tenant resolution, and request logging in the route layer. + +## Expected Output + +The demo entry point prints: + +- import result summary +- created row count +- uploaded artifact names +- registered route paths + +For a captured output artifact, see: + +- [`files/example-outputs/fastapi-reference.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/fastapi-reference.txt) diff --git a/examples/fastapi_reference/__init__.py b/examples/fastapi_reference/__init__.py new file mode 100644 index 0000000..6d28643 --- /dev/null +++ b/examples/fastapi_reference/__init__.py @@ -0,0 +1 @@ +"""Minimal reference FastAPI project for ExcelAlchemy.""" diff --git a/examples/fastapi_reference/app.py b/examples/fastapi_reference/app.py new file mode 100644 index 0000000..071aad3 --- /dev/null +++ b/examples/fastapi_reference/app.py @@ -0,0 +1,62 @@ +"""Minimal reference FastAPI project with route, service, and storage layers.""" + +from __future__ import annotations + +import importlib.util +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import FastAPI, UploadFile + from fastapi.responses import StreamingResponse + +from io import BytesIO + +from examples.fastapi_reference.services import EmployeeImportService, run_reference_demo +from examples.fastapi_reference.storage import RequestScopedStorage + + +def create_app(service: EmployeeImportService | None = None) -> FastAPI: + from fastapi import FastAPI, HTTPException + from fastapi.responses import StreamingResponse + + app = FastAPI(title='ExcelAlchemy Reference FastAPI App') + import_service = service or EmployeeImportService(RequestScopedStorage()) + + @app.get('/employee-template.xlsx') + async def download_template() -> StreamingResponse: + return StreamingResponse( + BytesIO(import_service.generate_template_bytes()), + media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': 'attachment; filename=employee-template.xlsx'}, + ) + + @app.post('/employee-imports') + async def import_employees(file: UploadFile) -> dict[str, object]: + if not file.filename: + raise HTTPException(status_code=400, detail='An Excel file is required') + return await import_service.import_workbook(file.filename, await file.read()) + + return app + + +def main() -> None: + result, created_rows, uploaded_artifacts = run_reference_demo() + route_paths = ['/employee-imports', '/employee-template.xlsx'] + + print('FastAPI reference project 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: {uploaded_artifacts}') + print(f'Routes: {route_paths}') + + +app = create_app() if importlib.util.find_spec('fastapi') is not None else None + + +__all__ = ['app', 'create_app', 'main'] + + +if __name__ == '__main__': + main() diff --git a/examples/fastapi_reference/models.py b/examples/fastapi_reference/models.py new file mode 100644 index 0000000..7f285f1 --- /dev/null +++ b/examples/fastapi_reference/models.py @@ -0,0 +1,13 @@ +"""Schema declarations for the FastAPI reference project.""" + +from pydantic import BaseModel + +from excelalchemy import FieldMeta, Number, String + + +class EmployeeImporter(BaseModel): + full_name: String = FieldMeta(label='Full name', order=1, hint='Use the legal name') + age: Number = FieldMeta(label='Age', order=2) + + +__all__ = ['EmployeeImporter'] diff --git a/examples/fastapi_reference/services.py b/examples/fastapi_reference/services.py new file mode 100644 index 0000000..12900a7 --- /dev/null +++ b/examples/fastapi_reference/services.py @@ -0,0 +1,99 @@ +"""Service layer for the FastAPI reference project.""" + +from __future__ import annotations + +import asyncio +import io + +from openpyxl import load_workbook + +from examples.fastapi_reference.models import EmployeeImporter +from examples.fastapi_reference.storage import RequestScopedStorage +from excelalchemy import ExcelAlchemy, ImporterConfig, ImportResult + + +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 + + +class EmployeeImportService: + """Minimal service layer that a web app can inject into routes.""" + + def __init__(self, storage: RequestScopedStorage, *, tenant_id: str = 'tenant-001') -> None: + self.storage = storage + self.tenant_id = tenant_id + + def build_alchemy(self) -> ExcelAlchemy[dict[str, object], EmployeeImporter, EmployeeImporter]: + alchemy = ExcelAlchemy( + ImporterConfig.for_create( + EmployeeImporter, + creator=create_employee, + storage=self.storage, + locale='en', + ) + ) + alchemy.add_context({'tenant_id': self.tenant_id, 'created_rows': []}) + return alchemy + + def generate_template_bytes(self) -> bytes: + alchemy = ExcelAlchemy(ImporterConfig.for_create(EmployeeImporter, locale='en')) + artifact = alchemy.download_template_artifact(filename='employee-template.xlsx') + return artifact.as_bytes() + + async def import_workbook(self, filename: str, content: bytes) -> dict[str, object]: + self.storage.register_upload(filename, content) + alchemy = self.build_alchemy() + result = await alchemy.import_data(filename, 'employee-import-result.xlsx') + created_rows = alchemy.context['created_rows'] + assert isinstance(created_rows, list) + return { + 'result': result.model_dump(), + 'created_rows': len(created_rows), + 'uploaded_artifacts': sorted(self.storage.uploaded), + } + + +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() + + +def summarize_result(payload: dict[str, object]) -> tuple[ImportResult, int, list[str]]: + result = ImportResult.model_validate(payload['result']) + created_rows = payload['created_rows'] + uploaded_artifacts = payload['uploaded_artifacts'] + assert isinstance(created_rows, int) + assert isinstance(uploaded_artifacts, list) + assert all(isinstance(item, str) for item in uploaded_artifacts) + return result, created_rows, uploaded_artifacts + + +def run_reference_demo() -> tuple[ImportResult, int, list[str]]: + storage = RequestScopedStorage() + service = EmployeeImportService(storage) + template_bytes = service.generate_template_bytes() + upload_bytes = build_demo_upload(template_bytes) + payload = asyncio.run(service.import_workbook('employee-import.xlsx', upload_bytes)) + return summarize_result(payload) + + +__all__ = [ + 'EmployeeImportService', + 'build_demo_upload', + 'create_employee', + 'run_reference_demo', + 'summarize_result', +] diff --git a/examples/fastapi_reference/storage.py b/examples/fastapi_reference/storage.py new file mode 100644 index 0000000..d63ac58 --- /dev/null +++ b/examples/fastapi_reference/storage.py @@ -0,0 +1,49 @@ +"""Request-scoped storage used by the FastAPI reference project.""" + +import io +from base64 import b64decode + +from openpyxl import load_workbook + +from excelalchemy import ExcelStorage, UrlStr +from excelalchemy.core.table import WorksheetTable + + +class RequestScopedStorage(ExcelStorage): + """Self-contained in-memory storage for uploads and result workbooks.""" + + 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: + workbook = load_workbook(io.BytesIO(self.fixtures[input_excel_name]), data_only=True) + try: + worksheet = workbook[sheet_name] + rows = [ + [None if value is None else str(value) for value in row] + for row in worksheet.iter_rows( + min_row=skiprows + 1, + max_row=worksheet.max_row, + max_col=worksheet.max_column, + values_only=True, + ) + ] + finally: + workbook.close() + + while rows and all(value is None for value in rows[-1]): + rows.pop() + + return WorksheetTable(rows=rows) + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + _, payload = content_with_prefix.split(',', 1) + self.uploaded[output_name] = b64decode(payload) + return UrlStr(f'memory://{output_name}') + + +__all__ = ['RequestScopedStorage'] diff --git a/files/example-outputs/fastapi-reference.txt b/files/example-outputs/fastapi-reference.txt new file mode 100644 index 0000000..23c4e78 --- /dev/null +++ b/files/example-outputs/fastapi-reference.txt @@ -0,0 +1,7 @@ +FastAPI reference project completed +Result: SUCCESS +Success rows: 1 +Failed rows: 0 +Created rows: 1 +Uploaded artifacts: [] +Routes: ['/employee-imports', '/employee-template.xlsx'] diff --git a/scripts/generate_example_output_assets.py b/scripts/generate_example_output_assets.py index 8613981..9f4aa49 100644 --- a/scripts/generate_example_output_assets.py +++ b/scripts/generate_example_output_assets.py @@ -3,14 +3,18 @@ from __future__ import annotations import contextlib +import importlib import importlib.util import io +import sys 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) +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) EXAMPLE_ASSETS: dict[str, str] = { @@ -23,6 +27,10 @@ 'export_workflow.py': 'export-workflow.txt', } +MODULE_ASSETS: dict[str, str] = { + 'examples.fastapi_reference.app': 'fastapi-reference.txt', +} + def _load_example_module(module_name: str, filename: str): module_path = EXAMPLES_DIR / filename @@ -43,6 +51,14 @@ def _run_example(filename: str) -> str: return buffer.getvalue().strip() +def _run_module_example(module_name: str) -> str: + module = importlib.import_module(module_name) + 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) @@ -50,6 +66,12 @@ def main() -> None: output_path.write_text(f'{output}\n', encoding='utf-8') print(f'Generated example output: {output_path}') + for module_name, output_name in MODULE_ASSETS.items(): + output = _run_module_example(module_name) + 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 index 20b851d..0dfd123 100644 --- a/scripts/smoke_examples.py +++ b/scripts/smoke_examples.py @@ -3,12 +3,16 @@ from __future__ import annotations import contextlib +import importlib import importlib.util import io +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] EXAMPLES_DIR = ROOT / 'examples' +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) REQUIRED_EXAMPLES: dict[str, tuple[str, ...]] = { 'annotated_schema.py': ('Generated template:', 'employee-template.xlsx'), @@ -25,6 +29,10 @@ 'minio_storage.py': (('minio', True), ('Built gateway: MinioStorageGateway', 'Uses built-in Minio path: True')), } +REQUIRED_MODULE_EXAMPLES: dict[str, tuple[str, ...]] = { + 'examples.fastapi_reference.app': ('FastAPI reference project completed', '/employee-imports'), +} + def _load_example_module(module_name: str, filename: str): module_path = EXAMPLES_DIR / filename @@ -49,6 +57,14 @@ def _dependency_available(module_name: str) -> bool: return importlib.util.find_spec(module_name) is not None +def _run_module_example(module_name: str) -> str: + module = importlib.import_module(module_name) + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + module.main() + return buffer.getvalue() + + 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: @@ -70,6 +86,11 @@ def main() -> None: _assert_example_output(filename, output, required_fragments) print(f'Smoke passed: {filename}') + for module_name, required_fragments in REQUIRED_MODULE_EXAMPLES.items(): + output = _run_module_example(module_name) + _assert_example_output(module_name, output, required_fragments) + print(f'Smoke passed: {module_name}') + if __name__ == '__main__': main() diff --git a/scripts/smoke_package.py b/scripts/smoke_package.py index 6e749a2..a3dddec 100644 --- a/scripts/smoke_package.py +++ b/scripts/smoke_package.py @@ -68,6 +68,20 @@ def _build_import_fixture(storage: InMemorySmokeStorage, template_bytes: bytes) workbook.close() +def _build_invalid_import_fixture(storage: InMemorySmokeStorage, template_bytes: bytes) -> None: + workbook = load_workbook(io.BytesIO(template_bytes)) + try: + worksheet = workbook['Sheet1'] + worksheet['A3'] = 'TaylorChen' + worksheet['B3'] = 'invalid-age' + + buffer = io.BytesIO() + workbook.save(buffer) + storage.fixtures['smoke-invalid-input.xlsx'] = buffer.getvalue() + finally: + workbook.close() + + async def main() -> None: storage = InMemorySmokeStorage() @@ -88,6 +102,27 @@ async def main() -> None: assert import_result.fail_count == 0 assert import_result.result == 'SUCCESS' + invalid_importer = ExcelAlchemy( + ImporterConfig.for_create( + SmokeImporter, + creator=_create_employee, + storage=storage, + locale='en', + ) + ) + _build_invalid_import_fixture(storage, template.as_bytes()) + invalid_result = await invalid_importer.import_data('smoke-invalid-input.xlsx', 'smoke-invalid-result.xlsx') + assert invalid_result.result == 'DATA_INVALID' + assert invalid_result.fail_count == 1 + assert invalid_importer.cell_error_map.error_count >= 1 + assert invalid_importer.row_error_map.error_count >= 1 + cell_payload = invalid_importer.cell_error_map.to_api_payload() + row_payload = invalid_importer.row_error_map.to_api_payload() + assert cell_payload['error_count'] >= 1 + assert row_payload['error_count'] >= 1 + assert isinstance(cell_payload['items'], list) + assert isinstance(row_payload['items'], list) + exporter = ExcelAlchemy(ExporterConfig.for_storage(SmokeImporter, storage=storage, locale='en')) artifact = exporter.export_artifact( [{'full_name': 'TaylorChen', 'age': 32}], @@ -100,6 +135,7 @@ async def main() -> None: print('Package smoke passed') print(f'Import result: {import_result.result}') + print(f'Invalid import result: {invalid_result.result}') print(f'Upload URL: {uploaded_url}') diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index 2874b43..288d4e9 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.5' +__version__ = '2.2.6' from excelalchemy._primitives.constants import CharacterSet, DataRangeOption, DateFormat, Option from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning from excelalchemy._primitives.identity import ( @@ -50,8 +50,10 @@ from excelalchemy.metadata import ExcelMeta, FieldMeta, PatchFieldMeta from excelalchemy.results import ( CellErrorMap, + CellIssueRecord, ImportResult, RowIssueMap, + RowIssueRecord, ValidateHeaderResult, ValidateResult, ValidateRowResult, @@ -63,6 +65,7 @@ 'Boolean', 'BooleanCodec', 'CellErrorMap', + 'CellIssueRecord', 'ColumnIndex', 'CompositeExcelFieldCodec', 'ConfigError', @@ -113,6 +116,7 @@ 'Radio', 'RowIndex', 'RowIssueMap', + 'RowIssueRecord', 'SingleChoiceCodec', 'SingleOrganization', 'SingleOrganizationCodec', diff --git a/src/excelalchemy/codecs/base.py b/src/excelalchemy/codecs/base.py index 9b79bb3..8c9d84e 100644 --- a/src/excelalchemy/codecs/base.py +++ b/src/excelalchemy/codecs/base.py @@ -1,7 +1,8 @@ from __future__ import annotations +import logging from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pydantic import GetCoreSchemaHandler from pydantic_core import core_schema @@ -19,6 +20,63 @@ type NormalizedImportValue = Any +def _summarize_exception(exc: Exception) -> str: + details: list[str] = [] + for arg in exc.args: + if isinstance(arg, list): + raw_items = cast(list[object], arg) + list_items: list[str] = [] + for item in raw_items: + item_text = item.__name__ if isinstance(item, type) else str(item).strip() + if item_text: + list_items.append(item_text) + if list_items: + details.append(', '.join(list_items)) + continue + + text = str(arg).strip() + if text: + details.append(text) + + if details: + return '; '.join(details) + return exc.__class__.__name__ + + +def log_codec_parse_fallback( + codec_name: str, + value: object, + *, + field_label: str | None = None, + exc: Exception, +) -> None: + field_context = f' for field "{field_label}"' if field_label else '' + logging.warning( + 'Codec %s could not parse workbook input%s; keeping the original value %r. Reason: %s', + codec_name, + field_context, + value, + _summarize_exception(exc), + ) + + +def log_codec_render_fallback( + codec_name: str, + value: object, + *, + field_label: str | None = None, + exc: Exception, +) -> None: + field_context = f' for field "{field_label}"' if field_label else '' + logging.warning( + 'Codec %s could not format workbook value%s; returning %r as-is. Reason: %s', + codec_name, + field_context, + value, + _summarize_exception(exc), + ) + + class ExcelFieldCodec(ABC): """Excel-facing field adapter responsible for comments, parsing, formatting, and normalization.""" @@ -42,6 +100,11 @@ def format_display_value(cls, value: WorkbookDisplayValue, field_meta: FieldMeta def normalize_import_value(cls, value: WorkbookInputValue, field_meta: FieldMetaInfo) -> NormalizedImportValue: """Validate and normalize parsed input before handing it to the Pydantic layer.""" + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + """Return a user-facing input hint for invalid values when one is known.""" + return None + @classmethod def comment(cls, field_meta: FieldMetaInfo) -> str: """Backward-compatible alias for build_comment().""" diff --git a/src/excelalchemy/codecs/date.py b/src/excelalchemy/codecs/date.py index 624c3bd..f584649 100644 --- a/src/excelalchemy/codecs/date.py +++ b/src/excelalchemy/codecs/date.py @@ -11,6 +11,7 @@ NormalizedImportValue, WorkbookDisplayValue, WorkbookInputValue, + log_codec_parse_fallback, ) from excelalchemy.exceptions import ConfigError from excelalchemy.i18n.messages import MessageKey @@ -21,6 +22,13 @@ class Date(ExcelFieldCodec, datetime): __name__ = 'Date' + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + presentation = field_meta.presentation + if presentation.date_format is None: + return None + return msg(MessageKey.ENTER_DATE_FORMAT, date_format=DATE_FORMAT_TO_HINT_MAPPING[presentation.date_format]) + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared @@ -62,12 +70,7 @@ def parse_input( dt: DateTime = cast(DateTime, pendulum.parse(v)) return dt.replace(tzinfo=presentation.timezone) except Exception as exc: - logging.warning( - 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', - cls.__name__, - value, - exc, - ) + log_codec_parse_fallback(cls.__name__, value, field_label=declared.label, exc=exc) return value @classmethod diff --git a/src/excelalchemy/codecs/date_range.py b/src/excelalchemy/codecs/date_range.py index 18b896f..42e4d5f 100644 --- a/src/excelalchemy/codecs/date_range.py +++ b/src/excelalchemy/codecs/date_range.py @@ -9,7 +9,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.codecs.base import CompositeExcelFieldCodec, log_codec_parse_fallback from excelalchemy.exceptions import ConfigError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg @@ -66,8 +66,13 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: ] ) + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + return msg(MessageKey.ENTER_DATE_RANGE_EXPECTED_FORMAT) + @classmethod def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: + declared = field_meta.declared mapping = cls._coerce_mapping(value) if mapping is not None: try: @@ -76,7 +81,7 @@ def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: 'end': cls._parse_optional_datetime(mapping.get('end'), field_meta), } except Exception as exc: - logging.warning('Could not parse value %s for field %s. Reason: %s', value, cls.__name__, exc) + log_codec_parse_fallback(cls.__name__, value, field_label=declared.label, exc=exc) return value if isinstance(value, datetime): @@ -86,7 +91,7 @@ def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: try: return cls._parse_datetime_text(value, field_meta) except Exception as exc: - logging.warning('Could not parse value %s for field %s. Reason: %s', value, cls.__name__, exc) + log_codec_parse_fallback(cls.__name__, value, field_label=declared.label, exc=exc) return value return value diff --git a/src/excelalchemy/codecs/email.py b/src/excelalchemy/codecs/email.py index ff642a3..a22530a 100644 --- a/src/excelalchemy/codecs/email.py +++ b/src/excelalchemy/codecs/email.py @@ -11,6 +11,10 @@ class Email(String): _validator: ClassVar[TypeAdapter[EmailStr]] = TypeAdapter(EmailStr) + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + return msg(MessageKey.VALID_EMAIL_REQUIRED) + @classmethod def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> str: # Try to parse the value as a string diff --git a/src/excelalchemy/codecs/multi_checkbox.py b/src/excelalchemy/codecs/multi_checkbox.py index 2a4b45a..5e3b395 100644 --- a/src/excelalchemy/codecs/multi_checkbox.py +++ b/src/excelalchemy/codecs/multi_checkbox.py @@ -14,6 +14,37 @@ class MultiCheckbox(ExcelFieldCodec, list[str]): __name__ = 'MultiChoice' + @classmethod + def selection_entity_plural(cls) -> str | None: + return None + + @classmethod + def _options_preview(cls, field_meta: FieldMetaInfo, *, limit: int = 5) -> str | None: + options = field_meta.presentation.options + if not options: + return None + preview = MULTI_CHECKBOX_SEPARATOR.join(option.name for option in options[:limit]) + if len(options) > limit: + preview = f'{preview}{MULTI_CHECKBOX_SEPARATOR}...' + return preview + + @classmethod + def _compose_selection_message(cls, field_meta: FieldMetaInfo) -> str: + entity_plural = cls.selection_entity_plural() + if entity_plural is None: + base_message = msg(MessageKey.SELECT_ONLY_CONFIGURED_OPTIONS) + else: + base_message = msg(MessageKey.SELECT_ONLY_CONFIGURED_ENTITIES, entity_plural=entity_plural) + + preview = cls._options_preview(field_meta) + if preview is None: + return base_message + return f'{base_message}. {msg(MessageKey.VALID_VALUES_INCLUDE, options=preview)}' + + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + return cls._compose_selection_message(field_meta) + @staticmethod def _coerce_items(value: object) -> list[object] | None: if not isinstance(value, list): @@ -53,7 +84,7 @@ def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> lis presentation = field_meta.presentation items = cls._coerce_items(value) if items is None: - raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT)) + raise ValueError(cls._compose_selection_message(field_meta)) parsed = [str(item).strip() for item in items] @@ -72,7 +103,7 @@ def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> lis result, errors = presentation.exchange_names_to_option_ids_with_errors(parsed, field_label=declared.label) if errors: - raise ValueError(*errors) + raise ValueError(cls._compose_selection_message(field_meta)) else: return result diff --git a/src/excelalchemy/codecs/number.py b/src/excelalchemy/codecs/number.py index dd3129a..71f3b17 100644 --- a/src/excelalchemy/codecs/number.py +++ b/src/excelalchemy/codecs/number.py @@ -6,6 +6,7 @@ NormalizedImportValue, WorkbookDisplayValue, WorkbookInputValue, + log_codec_parse_fallback, ) from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg @@ -64,6 +65,7 @@ def parse_input( value: str | int | float | WorkbookInputValue | None, field_meta: FieldMetaInfo, ) -> Decimal | WorkbookInputValue: + declared = field_meta.declared if isinstance(value, str): value = value.strip() if value is None: @@ -71,12 +73,7 @@ def parse_input( try: return transform_decimal(Decimal(str(value))) except Exception as exc: - logging.warning( - 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', - cls.__name__, - value, - exc, - ) + log_codec_parse_fallback(cls.__name__, value, field_label=declared.label, exc=exc) return str(value) @classmethod @@ -90,13 +87,7 @@ def format_display_value( try: return str(transform_decimal(Decimal(value))) - except Exception as exc: - logging.warning( - 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', - cls.__name__, - value, - exc, - ) + except Exception: return str(value) @classmethod diff --git a/src/excelalchemy/codecs/number_range.py b/src/excelalchemy/codecs/number_range.py index d9701dc..943ec81 100644 --- a/src/excelalchemy/codecs/number_range.py +++ b/src/excelalchemy/codecs/number_range.py @@ -1,10 +1,9 @@ -import logging from collections.abc import Mapping from decimal import Decimal from typing import cast from excelalchemy._primitives.identity import Key -from excelalchemy.codecs.base import CompositeExcelFieldCodec +from excelalchemy.codecs.base import CompositeExcelFieldCodec, log_codec_parse_fallback from excelalchemy.codecs.number import Number, canonicalize_decimal, transform_decimal from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg @@ -35,8 +34,13 @@ def column_items(cls) -> list[tuple[Key, FieldMetaInfo]]: def build_comment(cls, field_meta: FieldMetaInfo) -> str: return Number.build_comment(field_meta) + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + return msg(MessageKey.ENTER_NUMBER_RANGE_EXPECTED_FORMAT) + @classmethod def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: + declared = field_meta.declared if isinstance(value, str): value = value.strip() @@ -50,12 +54,7 @@ def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: end = cls._parse_decimal_boundary(mapping['end']) return NumberRange(start, end) except (KeyError, TypeError, ValueError) as exc: - logging.warning( - '%s could not parse Excel input %s; returning the original value. Reason: %s', - cls.__name__, - value, - exc, - ) + log_codec_parse_fallback(cls.__name__, value, field_label=declared.label, exc=exc) return value @classmethod @@ -68,13 +67,7 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - if parsed is None: return '' return str(transform_decimal(canonicalize_decimal(parsed, presentation.fraction_digits))) - except Exception as exc: - logging.warning( - 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', - cls.__name__, - value, - exc, - ) + except Exception: return str(value) @classmethod diff --git a/src/excelalchemy/codecs/organization.py b/src/excelalchemy/codecs/organization.py index e5f2d5a..9c74dbe 100644 --- a/src/excelalchemy/codecs/organization.py +++ b/src/excelalchemy/codecs/organization.py @@ -13,6 +13,10 @@ class SingleOrganization(Radio): __name__ = 'SingleOrganization' + @classmethod + def selection_entity_singular(cls) -> str | None: + return 'organization' + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared @@ -48,6 +52,10 @@ def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: class MultiOrganization(MultiCheckbox): __name__ = 'MultiOrganization' + @classmethod + def selection_entity_plural(cls) -> str | None: + return 'organizations' + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared diff --git a/src/excelalchemy/codecs/phone_number.py b/src/excelalchemy/codecs/phone_number.py index b95f525..298afad 100644 --- a/src/excelalchemy/codecs/phone_number.py +++ b/src/excelalchemy/codecs/phone_number.py @@ -10,6 +10,10 @@ class PhoneNumber(String): + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + return msg(MessageKey.VALID_PHONE_NUMBER_REQUIRED) + @classmethod def normalize_import_value(cls, value: WorkbookInputValue, field_meta: FieldMetaInfo) -> str: parsed = str(value) diff --git a/src/excelalchemy/codecs/radio.py b/src/excelalchemy/codecs/radio.py index 295e630..2760b4c 100644 --- a/src/excelalchemy/codecs/radio.py +++ b/src/excelalchemy/codecs/radio.py @@ -13,6 +13,37 @@ class Radio(ExcelFieldCodec, str): __name__ = 'SingleChoice' + @classmethod + def selection_entity_singular(cls) -> str | None: + return None + + @classmethod + def _options_preview(cls, field_meta: FieldMetaInfo, *, limit: int = 5) -> str | None: + options = field_meta.presentation.options + if not options: + return None + preview = MULTI_CHECKBOX_SEPARATOR.join(option.name for option in options[:limit]) + if len(options) > limit: + preview = f'{preview}{MULTI_CHECKBOX_SEPARATOR}...' + return preview + + @classmethod + def _compose_selection_message(cls, field_meta: FieldMetaInfo) -> str: + entity = cls.selection_entity_singular() + if entity is None: + base_message = msg(MessageKey.SELECT_ONE_CONFIGURED_OPTION) + else: + base_message = msg(MessageKey.SELECT_ONE_CONFIGURED_ENTITY, entity=entity) + + preview = cls._options_preview(field_meta) + if preview is None: + return base_message + return f'{base_message}. {msg(MessageKey.VALID_VALUES_INCLUDE, options=preview)}' + + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + return cls._compose_selection_message(field_meta) + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared @@ -57,7 +88,7 @@ def normalize_import_value(cls, value: str, field_meta: FieldMetaInfo) -> Option declared = field_meta.declared presentation = field_meta.presentation if MULTI_CHECKBOX_SEPARATOR in value: - raise ValueError(msg(MessageKey.MULTIPLE_SELECTIONS_NOT_SUPPORTED)) + raise ValueError(cls._compose_selection_message(field_meta)) parsed = value.strip() @@ -76,7 +107,7 @@ def normalize_import_value(cls, value: str, field_meta: FieldMetaInfo) -> Option options_name_map = presentation.options_name_map(field_label=declared.label) if parsed not in options_name_map: - raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_FIELD_COMMENT)) + raise ValueError(cls._compose_selection_message(field_meta)) return options_name_map[parsed].id diff --git a/src/excelalchemy/codecs/staff.py b/src/excelalchemy/codecs/staff.py index db60792..f32a710 100644 --- a/src/excelalchemy/codecs/staff.py +++ b/src/excelalchemy/codecs/staff.py @@ -14,6 +14,10 @@ class SingleStaff(Radio): __name__ = 'SingleStaff' + @classmethod + def selection_entity_singular(cls) -> str | None: + return 'staff member' + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared @@ -53,6 +57,10 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - class MultiStaff(MultiCheckbox): __name__ = 'MultiStaff' + @classmethod + def selection_entity_plural(cls) -> str | None: + return 'staff members' + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared diff --git a/src/excelalchemy/codecs/tree.py b/src/excelalchemy/codecs/tree.py index 9da0446..0506a69 100644 --- a/src/excelalchemy/codecs/tree.py +++ b/src/excelalchemy/codecs/tree.py @@ -11,6 +11,10 @@ class SingleTreeNode(Radio): __name__ = 'SingleTreeNode' + @classmethod + def selection_entity_singular(cls) -> str | None: + return 'tree node' + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared @@ -47,6 +51,10 @@ def format_display_value( class MultiTreeNode(MultiCheckbox): __name__ = 'MultiTreeNode' + @classmethod + def selection_entity_plural(cls) -> str | None: + return 'tree nodes' + @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared diff --git a/src/excelalchemy/codecs/url.py b/src/excelalchemy/codecs/url.py index 73aec24..adb0c2b 100644 --- a/src/excelalchemy/codecs/url.py +++ b/src/excelalchemy/codecs/url.py @@ -10,6 +10,10 @@ class Url(String): _validator = TypeAdapter(HttpUrl) + @classmethod + def expected_input_message(cls, field_meta: FieldMetaInfo) -> str | None: + return msg(MessageKey.VALID_URL_REQUIRED) + @classmethod def normalize_import_value(cls, value: WorkbookInputValue, field_meta: FieldMetaInfo) -> str: parsed = str(value) diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index a28c51e..ce9c619 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -8,7 +8,7 @@ from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined -from excelalchemy._primitives.identity import Key +from excelalchemy._primitives.identity import Key, Label from excelalchemy.codecs.base import CompositeExcelFieldCodec, ExcelFieldCodec from excelalchemy.exceptions import ExcelCellError, ExcelRowError, ProgrammaticError from excelalchemy.i18n.messages import MessageKey @@ -22,44 +22,121 @@ _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: +@dataclass(frozen=True) +class NormalizedValidationMessage: + message: str + message_key: MessageKey | None = None + detail: Mapping[str, object] | None = None + + +def _build_cell_error( + *, + label: Label, + normalized: NormalizedValidationMessage, + parent_label: Label | None = None, +) -> ExcelCellError: + error = ExcelCellError( + label=label, + parent_label=parent_label, + message=normalized.message, + message_key=normalized.message_key, + ) + if normalized.detail: + error.detail.update(normalized.detail) + return error + + +def _build_row_error(normalized: NormalizedValidationMessage) -> ExcelRowError: + error = ExcelRowError( + normalized.message, + message_key=normalized.message_key, + ) + if normalized.detail: + error.detail.update(normalized.detail) + return error + + +def _normalize_validation_message( + message: str, + field_def: FieldMetaInfo | None = None, + *, + excel_codec: type[ExcelFieldCodec] | None = None, +) -> NormalizedValidationMessage: normalized = message.strip() if normalized == 'Field required': - return msg(MessageKey.THIS_FIELD_IS_REQUIRED) + return NormalizedValidationMessage(msg(MessageKey.THIS_FIELD_IS_REQUIRED), 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) + normalized_message = _normalize_constraint_message(normalized, field_def, excel_codec=excel_codec) + if normalized_message is not None: + return normalized_message + + if normalized == msg(MessageKey.INVALID_INPUT) and field_def is not None and excel_codec is not None: + expected = excel_codec.expected_input_message(field_def) + if expected is not None: + return NormalizedValidationMessage(expected) if normalized and normalized[0].islower(): normalized = normalized[0].upper() + normalized[1:] - return normalized + return NormalizedValidationMessage(normalized) -def _normalize_constraint_message(message: str, field_def: FieldMetaInfo | None) -> str: +def _normalize_constraint_message( + message: str, + field_def: FieldMetaInfo | None, + *, + excel_codec: type[ExcelFieldCodec] | None = None, +) -> NormalizedValidationMessage | None: if field_def is None: - return message + return None 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))) + return NormalizedValidationMessage( + msg(MessageKey.MIN_LENGTH_CHARACTERS, min_length=constraints.min_length), + MessageKey.MIN_LENGTH_CHARACTERS, + {'min_length': constraints.min_length}, + ) + min_items = int(match.group(1)) + return NormalizedValidationMessage( + msg(MessageKey.MIN_ITEMS_REQUIRED, min_items=min_items), + MessageKey.MIN_ITEMS_REQUIRED, + {'min_items': min_items}, + ) 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))) + return NormalizedValidationMessage( + msg(MessageKey.MAX_LENGTH_CHARACTERS, max_length=constraints.max_length), + MessageKey.MAX_LENGTH_CHARACTERS, + {'max_length': constraints.max_length}, + ) + max_items = int(match.group(1)) + return NormalizedValidationMessage( + msg(MessageKey.MAX_ITEMS_ALLOWED, max_items=max_items), + MessageKey.MAX_ITEMS_ALLOWED, + {'max_items': max_items}, + ) if message == 'Input should be a valid dictionary': - return msg(MessageKey.ENTER_VALUE_EXPECTED_FORMAT) + if ( + excel_codec is not None + and issubclass(excel_codec, CompositeExcelFieldCodec) + and (expected := excel_codec.expected_input_message(field_def)) is not None + ): + return NormalizedValidationMessage(expected) + return NormalizedValidationMessage( + msg(MessageKey.ENTER_VALUE_EXPECTED_FORMAT), MessageKey.ENTER_VALUE_EXPECTED_FORMAT + ) - return message + return None def _resolve_excel_codec_type(annotation: object) -> type[ExcelFieldCodec]: @@ -195,7 +272,7 @@ def instantiate_pydantic_model[ModelT: BaseModel]( raise except Exception as exc: failed_fields.add(field_adapter.name) - _handle_error(errors, exc, field_adapter.declared_metadata) + _handle_error(errors, exc, field_adapter.declared_metadata, excel_codec=field_adapter.excel_codec) model_instance_or_errors = _model_validate(normalized_data, model, model_adapter, failed_fields) if isinstance(model_instance_or_errors, list): @@ -232,16 +309,12 @@ def _handle_error( error_container: ExcelValidationIssues, exc: Exception, field_def: FieldMetaInfo, + *, + excel_codec: type[ExcelFieldCodec] | None = None, ) -> None: 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, - message=message, - ) - for message in messages - ) + messages = [_normalize_validation_message(message, field_def, excel_codec=excel_codec) for message in raw_messages] + error_container.extend(_build_cell_error(label=field_def.label, normalized=normalized) for normalized in messages) def _model_validate[ModelT: BaseModel]( @@ -265,23 +338,29 @@ def _map_validation_error( for error in exc.errors(): loc = error.get('loc', ()) if not loc: - mapped.append(ExcelRowError(_normalize_validation_message(str(error['msg'])))) + normalized = _normalize_validation_message(str(error['msg'])) + mapped.append(_build_row_error(normalized)) continue field_name = loc[0] if not isinstance(field_name, str): - mapped.append(ExcelRowError(_normalize_validation_message(str(error['msg'])))) + normalized = _normalize_validation_message(str(error['msg'])) + mapped.append(_build_row_error(normalized)) continue if field_name in failed_fields: continue field_adapter = model_adapter.field(field_name) - message = _normalize_validation_message(str(error['msg']), field_adapter.declared_metadata) + normalized = _normalize_validation_message( + str(error['msg']), + field_adapter.declared_metadata, + excel_codec=field_adapter.excel_codec, + ) if len(loc) > 1 and isinstance(loc[1], str): - mapped.append(_nested_excel_error(field_adapter, loc[1], message)) + mapped.append(_nested_excel_error(field_adapter, loc[1], normalized)) continue - mapped.append(ExcelCellError(label=field_adapter.declared_metadata.declared.label, message=message)) + mapped.append(_build_cell_error(label=field_adapter.declared_metadata.declared.label, normalized=normalized)) return mapped @@ -289,7 +368,7 @@ def _map_validation_error( def _nested_excel_error( field_adapter: PydanticFieldAdapter, child_key: str, - message: str, + normalized: NormalizedValidationMessage, ) -> ExcelCellError: declared_metadata = field_adapter.declared_metadata declared_meta = declared_metadata.declared @@ -297,10 +376,8 @@ def _nested_excel_error( if issubclass(excel_codec, CompositeExcelFieldCodec): for key, sub_field_info in excel_codec.column_items(): if key == child_key: - return ExcelCellError( - label=sub_field_info.label, - parent_label=declared_meta.label, - message=message, + return _build_cell_error( + label=sub_field_info.label, parent_label=declared_meta.label, normalized=normalized ) - return ExcelCellError(label=declared_meta.label, message=message) + return _build_cell_error(label=declared_meta.label, normalized=normalized) diff --git a/src/excelalchemy/i18n/messages.py b/src/excelalchemy/i18n/messages.py index 4e825e6..5860734 100644 --- a/src/excelalchemy/i18n/messages.py +++ b/src/excelalchemy/i18n/messages.py @@ -63,12 +63,14 @@ class MessageKey(StrEnum): DATE_MUST_BE_EARLIER_THAN_NOW = 'date_must_be_earlier_than_now' DATE_MUST_BE_LATER_THAN_NOW = 'date_must_be_later_than_now' DATE_RANGE_START_AFTER_END = 'date_range_start_after_end' + ENTER_DATE_RANGE_EXPECTED_FORMAT = 'enter_date_range_expected_format' VALID_EMAIL_REQUIRED = 'valid_email_required' INVALID_NUMBER_ENTER_NUMBER = 'invalid_number_enter_number' NUMBER_BETWEEN_MIN_AND_MAX = 'number_between_min_and_max' NUMBER_BETWEEN_NEG_INF_AND_MAX = 'number_between_neg_inf_and_max' NUMBER_BETWEEN_MIN_AND_POS_INF = 'number_between_min_and_pos_inf' NUMBER_RANGE_MIN_GREATER_THAN_MAX = 'number_range_min_greater_than_max' + ENTER_NUMBER_RANGE_EXPECTED_FORMAT = 'enter_number_range_expected_format' ENTER_NUMBER = 'enter_number' ENTER_NUMBER_EXPECTED_FORMAT = 'enter_number_expected_format' VALID_URL_REQUIRED = 'valid_url_required' @@ -82,6 +84,11 @@ class MessageKey(StrEnum): MAX_LENGTH_CHARACTERS = 'max_length_characters' MIN_ITEMS_REQUIRED = 'min_items_required' MAX_ITEMS_ALLOWED = 'max_items_allowed' + SELECT_ONE_CONFIGURED_OPTION = 'select_one_configured_option' + SELECT_ONLY_CONFIGURED_OPTIONS = 'select_only_configured_options' + SELECT_ONE_CONFIGURED_ENTITY = 'select_one_configured_entity' + SELECT_ONLY_CONFIGURED_ENTITIES = 'select_only_configured_entities' + VALID_VALUES_INCLUDE = 'valid_values_include' 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' @@ -225,16 +232,22 @@ class MessageKey(StrEnum): MessageKey.DATE_MUST_BE_EARLIER_THAN_NOW: 'The value must be earlier than or equal to the current time', MessageKey.DATE_MUST_BE_LATER_THAN_NOW: 'The value must be later than or equal to the current time', MessageKey.DATE_RANGE_START_AFTER_END: 'The start date cannot be later than the end date', - MessageKey.VALID_EMAIL_REQUIRED: 'Enter a valid email address', + MessageKey.ENTER_DATE_RANGE_EXPECTED_FORMAT: ( + 'Enter both a start date and an end date in the format shown in the header comment' + ), + MessageKey.VALID_EMAIL_REQUIRED: 'Enter a valid email address, such as name@example.com', MessageKey.INVALID_NUMBER_ENTER_NUMBER: 'Invalid input; enter a number.', MessageKey.NUMBER_BETWEEN_MIN_AND_MAX: 'Enter a number between {minimum} and {maximum}.', MessageKey.NUMBER_BETWEEN_NEG_INF_AND_MAX: 'Enter a number between -∞ and {maximum}.', MessageKey.NUMBER_BETWEEN_MIN_AND_POS_INF: 'Enter a number between {minimum} and +∞.', MessageKey.NUMBER_RANGE_MIN_GREATER_THAN_MAX: 'The minimum value cannot be greater than the maximum value', + MessageKey.ENTER_NUMBER_RANGE_EXPECTED_FORMAT: ( + 'Enter both a minimum value and a maximum value in the format shown in the header comment' + ), MessageKey.ENTER_NUMBER: 'Enter a number', 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.VALID_URL_REQUIRED: 'Enter a valid URL, such as https://example.com', + MessageKey.VALID_PHONE_NUMBER_REQUIRED: 'Enter a valid phone number, such as 13800138000', 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: ( @@ -246,6 +259,11 @@ class MessageKey(StrEnum): 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.SELECT_ONE_CONFIGURED_OPTION: 'Select one of the configured options', + MessageKey.SELECT_ONLY_CONFIGURED_OPTIONS: 'Select only configured options', + MessageKey.SELECT_ONE_CONFIGURED_ENTITY: 'Select one {entity} from the configured options', + MessageKey.SELECT_ONLY_CONFIGURED_ENTITIES: 'Select {entity_plural} from the configured options', + MessageKey.VALID_VALUES_INCLUDE: 'Valid values include: {options}', 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' @@ -322,6 +340,16 @@ class MessageKey(StrEnum): MessageKey.MAX_LENGTH_CHARACTERS: '最大长度为 {max_length} 个字符', MessageKey.MIN_ITEMS_REQUIRED: '至少选择 {min_items} 项', MessageKey.MAX_ITEMS_ALLOWED: '最多选择 {max_items} 项', + MessageKey.ENTER_DATE_RANGE_EXPECTED_FORMAT: '请按照表头批注中的格式同时填写开始日期和结束日期', + MessageKey.ENTER_NUMBER_RANGE_EXPECTED_FORMAT: '请按照表头批注中的格式同时填写最小值和最大值', + MessageKey.VALID_EMAIL_REQUIRED: '请输入正确的邮箱地址,例如 name@example.com', + MessageKey.VALID_URL_REQUIRED: '请输入有效的网址,例如 https://example.com', + MessageKey.VALID_PHONE_NUMBER_REQUIRED: '请输入有效的手机号,例如 13800138000', + MessageKey.SELECT_ONE_CONFIGURED_OPTION: '请从配置的选项中选择一项', + MessageKey.SELECT_ONLY_CONFIGURED_OPTIONS: '请选择配置的选项', + MessageKey.SELECT_ONE_CONFIGURED_ENTITY: '请从配置的选项中选择一个{entity}', + MessageKey.SELECT_ONLY_CONFIGURED_ENTITIES: '请选择配置的{entity_plural}', + MessageKey.VALID_VALUES_INCLUDE: '可选值示例:{options}', MessageKey.ENTER_VALUE_EXPECTED_FORMAT: '请按照表头注释中给出的格式填写', MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION: '仅当表头校验不通过时,才能构造 ImportResult', MessageKey.HEADER_HINT: ( diff --git a/src/excelalchemy/results.py b/src/excelalchemy/results.py index 25a14bb..37d6361 100644 --- a/src/excelalchemy/results.py +++ b/src/excelalchemy/results.py @@ -1,6 +1,7 @@ """Import result models for ExcelAlchemy workflows.""" from collections.abc import Iterable +from dataclasses import dataclass from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field @@ -19,6 +20,36 @@ def _empty_labels() -> list[Label]: type RowIssue = ExcelRowError | ExcelCellError +@dataclass(slots=True, frozen=True) +class CellIssueRecord: + """Flat cell issue record suitable for API responses and UI rendering.""" + + row_index: RowIndex + column_index: ColumnIndex + error: ExcelCellError + + def to_dict(self) -> dict[str, object]: + payload = self.error.to_dict() + payload['row_index'] = int(self.row_index) + payload['column_index'] = int(self.column_index) + payload['display_message'] = str(self.error) + return payload + + +@dataclass(slots=True, frozen=True) +class RowIssueRecord: + """Flat row issue record suitable for API responses and UI rendering.""" + + row_index: RowIndex + error: RowIssue + + def to_dict(self) -> dict[str, object]: + payload = self.error.to_dict() + payload['row_index'] = int(self.row_index) + payload['display_message'] = str(self.error) + return payload + + class CellErrorMap(dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]]): """Workbook-coordinate cell error mapping with convenience accessors.""" @@ -39,6 +70,14 @@ def messages_at(self, row_index: RowIndex | int, column_index: ColumnIndex | int def flatten(self) -> tuple[ExcelCellError, ...]: return tuple(error for row in self.values() for errors in row.values() for error in errors) + def records(self) -> tuple[CellIssueRecord, ...]: + return tuple( + CellIssueRecord(row_index=row_index, column_index=column_index, error=error) + for row_index, row in self.items() + for column_index, errors in row.items() + for error in errors + ) + def to_dict(self) -> dict[int, dict[int, list[dict[str, object]]]]: return { int(row_index): { @@ -47,6 +86,13 @@ def to_dict(self) -> dict[int, dict[int, list[dict[str, object]]]]: for row_index, row in self.items() } + def to_api_payload(self) -> dict[str, object]: + return { + 'error_count': self.error_count, + 'items': [record.to_dict() for record in self.records()], + 'by_row': self.to_dict(), + } + @property def has_errors(self) -> bool: return bool(self) @@ -77,9 +123,21 @@ def numbered_messages_for_row(self, row_index: RowIndex | int) -> tuple[str, ... def flatten(self) -> tuple[RowIssue, ...]: return tuple(error for errors in self.values() for error in errors) + def records(self) -> tuple[RowIssueRecord, ...]: + return tuple( + RowIssueRecord(row_index=row_index, error=error) for row_index, errors in self.items() 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()} + def to_api_payload(self) -> dict[str, object]: + return { + 'error_count': self.error_count, + 'items': [record.to_dict() for record in self.records()], + 'by_row': self.to_dict(), + } + @staticmethod def numbered_messages(errors: Iterable[RowIssue]) -> tuple[str, ...]: return tuple(f'{index}、{error!s}' for index, error in enumerate(errors, start=1)) diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py index e4294cf..4bdbfeb 100644 --- a/tests/contracts/test_pydantic_contract.py +++ b/tests/contracts/test_pydantic_contract.py @@ -54,6 +54,19 @@ def test_instantiate_pydantic_model_normalizes_missing_field_messages(self): assert isinstance(result, list) assert result == [ExcelCellError(label=Label('停留时间'), message='This field is required')] + def test_instantiate_pydantic_model_uses_field_specific_expected_format_for_composite_codecs(self): + result = instantiate_pydantic_model( + {'email': 'noreply@example.com', 'stay_range': 'not-a-range'}, ContractPydanticModel + ) + + assert isinstance(result, list) + assert result == [ + ExcelCellError( + label=Label('停留时间'), + message='Enter both a start date and an end date in the format shown in the header comment', + ) + ] + def test_instantiate_pydantic_model_applies_field_constraints_and_field_validators(self): class FieldValidatedModel(BaseModel): name: Email = FieldMeta(label='邮箱', order=1, min_length=20) @@ -69,7 +82,9 @@ 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='The minimum length is 20 characters')] + assert too_short == [ + ExcelCellError(label=Label('邮箱'), message='The minimum length is 20 characters', min_length=20) + ] assert isinstance(wrong_domain, list) assert wrong_domain == [ExcelCellError(label=Label('邮箱'), message='Must use the company domain')] @@ -148,7 +163,9 @@ 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='The minimum length is 20 characters')] + assert result == [ + ExcelCellError(label=Label('邮箱'), message='The minimum length is 20 characters', min_length=20) + ] def test_extract_pydantic_model_requires_a_model(self): with pytest.raises(ProgrammaticError) as context: diff --git a/tests/integration/test_examples_smoke.py b/tests/integration/test_examples_smoke.py index 9c6ab41..073bff9 100644 --- a/tests/integration/test_examples_smoke.py +++ b/tests/integration/test_examples_smoke.py @@ -4,12 +4,15 @@ import importlib import importlib.util import io +import sys from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[2] EXAMPLES_DIR = REPO_ROOT / 'examples' +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) def _load_example_module(module_name: str, filename: str): @@ -116,6 +119,13 @@ def test_fastapi_example_source_compiles() -> None: compile(source, str(EXAMPLES_DIR / 'fastapi_upload.py'), 'exec') +def test_fastapi_reference_example_sources_compile() -> None: + package_dir = EXAMPLES_DIR / 'fastapi_reference' + for filename in ('models.py', 'storage.py', 'services.py', 'app.py'): + source = (package_dir / filename).read_text(encoding='utf-8') + compile(source, str(package_dir / filename), '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') @@ -131,6 +141,20 @@ def test_fastapi_example_main_runs_when_optional_dependency_is_available() -> No assert '/employee-imports' in output +def test_fastapi_reference_project_main_runs_when_optional_dependency_is_available() -> None: + module = importlib.import_module('examples.fastapi_reference.app') + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + module.main() + + output = buffer.getvalue() + assert 'FastAPI reference project 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') @@ -194,3 +218,36 @@ def test_fastapi_example_endpoints_work_when_optional_dependencies_are_available assert payload['result']['result'] == 'SUCCESS' assert payload['created_rows'] == 1 assert payload['uploaded_artifacts'] == ['employee-import-result.xlsx'] + + +@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_reference_project_endpoints_work_when_optional_dependencies_are_available() -> None: + module = importlib.import_module('examples.fastapi_reference.app') + 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 + + import_service_module = importlib.import_module('examples.fastapi_reference.services') + upload_bytes = import_service_module.build_demo_upload(template_response.content) + + 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_email_codec.py b/tests/unit/codecs/test_email_codec.py index ca03fc9..853ee05 100644 --- a/tests/unit/codecs/test_email_codec.py +++ b/tests/unit/codecs/test_email_codec.py @@ -17,7 +17,7 @@ class Importer(BaseModel): assert result.fail_count == 1 row, col, first_error = RowIndex(0), ColumnIndex(2), 0 assert alchemy.cell_errors[row][col][first_error] == ExcelCellError( - label=Label('邮箱'), message='Enter a valid email address' + label=Label('邮箱'), message='Enter a valid email address, such as name@example.com' ) async def test_import_accepts_valid_email_value(self): diff --git a/tests/unit/codecs/test_multi_staff_codec.py b/tests/unit/codecs/test_multi_staff_codec.py index 90443ab..02b2568 100644 --- a/tests/unit/codecs/test_multi_staff_codec.py +++ b/tests/unit/codecs/test_multi_staff_codec.py @@ -55,3 +55,24 @@ class Importer(BaseModel): assert field.value_type.deserialize('张三/001、李四/002', field) == '张三/001、李四/002' assert field.value_type.deserialize([1, 2], field) == '张三/001,李四/002' + + async def test_validate_rejects_unknown_staff_with_business_message(self): + class Importer(BaseModel): + staff: MultiStaff = FieldMeta( + label='员工', + options=[ + Option(id=OptionId(1), name='张三/001'), + Option(id=OptionId(2), name='李四/002'), + ], + ) + + alchemy = self.build_alchemy(Importer) + field = alchemy.ordered_field_meta[0] + field.value_type = cast(MultiStaff, field.value_type) + + with self.assertRaises(ValueError) as context: + field.value_type.__validate__(['张三/001', '王五/003'], field) + assert ( + str(context.exception) + == 'Select staff members from the configured options. Valid values include: 张三/001,李四/002' + ) diff --git a/tests/unit/codecs/test_phone_number_codec.py b/tests/unit/codecs/test_phone_number_codec.py index 94879e5..a845a55 100644 --- a/tests/unit/codecs/test_phone_number_codec.py +++ b/tests/unit/codecs/test_phone_number_codec.py @@ -18,3 +18,6 @@ class Importer(BaseModel): self.assertRaises(ValueError, field.value_type.__validate__, 'ddd', field) self.assertRaises(ValueError, field.value_type.__validate__, '1234567890', field) assert field.value_type.__validate__('13216762386', field) == '13216762386' + with self.assertRaises(ValueError) as context: + field.value_type.__validate__('ddd', field) + assert str(context.exception) == 'Enter a valid phone number, such as 13800138000' diff --git a/tests/unit/codecs/test_radio_codec.py b/tests/unit/codecs/test_radio_codec.py index 51372d2..c620e02 100644 --- a/tests/unit/codecs/test_radio_codec.py +++ b/tests/unit/codecs/test_radio_codec.py @@ -94,7 +94,9 @@ class Importer(BaseModel): assert field.value_type.__validate__('选项2', field) == '2' self.assertRaises(ValueError, field.value_type.__validate__, '选项3', field) - self.assertRaises(ValueError, field.value_type.__validate__, f'3{MULTI_CHECKBOX_SEPARATOR}', field) + with self.assertRaises(ValueError) as context: + field.value_type.__validate__(f'3{MULTI_CHECKBOX_SEPARATOR}', field) + assert str(context.exception) == 'Select one of the configured options. Valid values include: 选项1,选项2' field.options = None self.assertRaises(ProgrammaticError, field.value_type.__validate__, '1', field) diff --git a/tests/unit/codecs/test_single_organization_codec.py b/tests/unit/codecs/test_single_organization_codec.py index 82e53c9..25ea3ca 100644 --- a/tests/unit/codecs/test_single_organization_codec.py +++ b/tests/unit/codecs/test_single_organization_codec.py @@ -46,3 +46,23 @@ class Importer(BaseModel): assert field.value_type.deserialize('XX公司/一级部门/二级部门', field) == 'XX公司/一级部门/二级部门' assert field.value_type.deserialize('1', field) == 'XX公司/一级部门/二级部门' + + async def test_validate_rejects_unknown_organizations_with_business_message(self): + class Importer(BaseModel): + single_organization: SingleOrganization = FieldMeta( + label='单选组织', + order=1, + options=[ + Option(id=OptionId(1), name='XX公司/一级部门/二级部门'), + ], + ) + + alchemy = self.build_alchemy(Importer) + field = alchemy.ordered_field_meta[0] + field.value_type = cast(SingleOrganization, field.value_type) + + with self.assertRaises(ValueError) as context: + field.value_type.__validate__('未知组织', field) + assert str(context.exception) == ( + 'Select one organization from the configured options. Valid values include: XX公司/一级部门/二级部门' + ) diff --git a/tests/unit/codecs/test_url_codec.py b/tests/unit/codecs/test_url_codec.py index 45971fe..20e6265 100644 --- a/tests/unit/codecs/test_url_codec.py +++ b/tests/unit/codecs/test_url_codec.py @@ -50,4 +50,6 @@ class Importer(BaseModel): field.value_type = cast(Url, field.value_type) assert field.value_type.__validate__('http://www.baidu.com', field) == 'http://www.baidu.com' - self.assertRaises(ValueError, field.value_type.__validate__, '1', field) + with self.assertRaises(ValueError) as context: + field.value_type.__validate__('1', field) + assert str(context.exception) == 'Enter a valid URL, such as https://example.com' diff --git a/tests/unit/test_excel_exceptions.py b/tests/unit/test_excel_exceptions.py index d354181..1de2e14 100644 --- a/tests/unit/test_excel_exceptions.py +++ b/tests/unit/test_excel_exceptions.py @@ -1,4 +1,12 @@ -from excelalchemy import CellErrorMap, ConfigError, ExcelCellError, ExcelRowError, Label, ProgrammaticError, RowIssueMap +from excelalchemy import ( + CellErrorMap, + ConfigError, + ExcelCellError, + ExcelRowError, + Label, + ProgrammaticError, + RowIssueMap, +) from tests.support import BaseTestCase @@ -119,6 +127,34 @@ async def test_cell_error_map_supports_coordinate_access_and_flattening(self): ] } } + assert error_map.to_api_payload() == { + 'error_count': 1, + 'items': [ + { + 'type': 'ExcelCellError', + 'message': 'Enter a valid email address', + 'label': '邮箱', + 'parent_label': None, + 'unique_label': '邮箱', + 'row_index': 0, + 'column_index': 3, + 'display_message': '【邮箱】Enter a valid email address', + } + ], + 'by_row': { + 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() @@ -155,3 +191,38 @@ async def test_row_issue_map_supports_row_access_and_numbered_messages(self): }, ] } + assert issue_map.to_api_payload() == { + 'error_count': 2, + 'items': [ + { + 'type': 'ExcelCellError', + 'message': 'Enter a valid email address', + 'label': '邮箱', + 'parent_label': None, + 'unique_label': '邮箱', + 'row_index': 0, + 'display_message': '【邮箱】Enter a valid email address', + }, + { + 'type': 'ExcelRowError', + 'message': 'Combination invalid', + 'row_index': 0, + 'display_message': 'Combination invalid', + }, + ], + 'by_row': { + 0: [ + { + 'type': 'ExcelCellError', + 'message': 'Enter a valid email address', + 'label': '邮箱', + 'parent_label': None, + 'unique_label': '邮箱', + }, + { + 'type': 'ExcelRowError', + 'message': 'Combination invalid', + }, + ] + }, + }