diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f390caa..195179d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,9 +110,11 @@ jobs: - name: Run smoke scripts if: matrix.python-version == '3.14' run: | + uv pip install fastapi httpx python-multipart uv run python scripts/smoke_package.py uv run python scripts/smoke_examples.py uv run python scripts/generate_example_output_assets.py + uv run python scripts/smoke_docs_assets.py - name: Upload coverage artifact if: always() && matrix.python-version == '3.14' diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 6609677..9d3f6c7 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -56,19 +56,23 @@ jobs: run: | uv venv .pkg-smoke-wheel --python 3.14 uv pip install --python .pkg-smoke-wheel/bin/python dist/*.whl + uv pip install --python .pkg-smoke-wheel/bin/python fastapi httpx python-multipart .pkg-smoke-wheel/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" .pkg-smoke-wheel/bin/python scripts/smoke_package.py .pkg-smoke-wheel/bin/python scripts/smoke_examples.py .pkg-smoke-wheel/bin/python scripts/generate_example_output_assets.py + .pkg-smoke-wheel/bin/python scripts/smoke_docs_assets.py - name: Smoke test source distribution installation run: | uv venv .pkg-smoke-sdist --python 3.14 uv pip install --python .pkg-smoke-sdist/bin/python dist/*.tar.gz + uv pip install --python .pkg-smoke-sdist/bin/python fastapi httpx python-multipart .pkg-smoke-sdist/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" .pkg-smoke-sdist/bin/python scripts/smoke_package.py .pkg-smoke-sdist/bin/python scripts/smoke_examples.py .pkg-smoke-sdist/bin/python scripts/generate_example_output_assets.py + .pkg-smoke-sdist/bin/python scripts/smoke_docs_assets.py - name: Set artifact metadata id: artifact-meta diff --git a/CHANGELOG.md b/CHANGELOG.md index a4362f7..980d07a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,70 @@ 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.7] - 2026-04-04 + +This release continues the stable 2.x line with stronger API-facing result +payloads, a more complete FastAPI reference application, harder install-time +smoke verification, and more consistent codec diagnostics. + +### Added + +- Added `docs/api-response-cookbook.md` with copyable success, data-invalid, + and header-invalid response shapes for backend integrations +- Added request and response contract modules for the FastAPI reference app + under `examples/fastapi_reference/schemas.py` and + `examples/fastapi_reference/responses.py` +- Added `scripts/smoke_docs_assets.py` to verify showcase assets and critical + result-object/showcase documentation entry points +- Added logger consistency tests for codec fallback diagnostics + +### Changed + +- Extended `ImportResult`, `CellErrorMap`, and `RowIssueMap` payload guidance + so `code`, `message_key`, `message`, and `display_message` have clearer and + more stable frontend-facing roles +- Strengthened `CellErrorMap` and `RowIssueMap` with summary helpers for + aggregation by field, row, and machine-readable code +- Made `ImportResult.to_api_payload()` a stable top-level integration surface + for success, data-invalid, and header-invalid responses +- Expanded the FastAPI reference app into a more copyable minimal application + with request schema, response schema, structured response builder, and + cookbook-aligned payloads +- Hardened release smoke verification so installed-package checks now cover: + - successful imports + - failed imports + - structured error payloads + - example asset generation + - result-object and showcase docs + - FastAPI reference HTTP behavior after dependency installation +- Unified codec fallback logging under the `excelalchemy.codecs` logger and + aligned warning wording across option, parse, and render fallbacks + +### Fixed + +- Fixed the runnable FastAPI example and the FastAPI reference app so their + runtime type annotations work correctly when optional web dependencies are + actually installed +- Fixed stale integration-test expectations after the improved business-facing + validation messages landed + +### 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 +- `storage=...` remains the recommended 2.x backend configuration path +- Legacy built-in Minio fields remain part of the 2.x compatibility surface + +### Release Summary + +- API response payloads are easier to consume from frontends and backend + clients +- the FastAPI reference project now looks more like a copyable minimal app +- release smoke checks now verify docs, assets, failed-import payloads, and + installed FastAPI integrations +- codec diagnostics are more consistent and easier to filter by logger + ## [2.2.6] - 2026-04-04 This release continues the stable 2.x line with stronger consumer-facing diff --git a/README-pypi.md b/README-pypi.md index 498ff80..b20acfd 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.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. +The current stable release is `2.2.7`, which continues the 2.x line with stronger API-facing result payloads, a more complete FastAPI reference app, harder install-time smoke verification, and more consistent codec 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) · [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) +[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) · [API Response Cookbook](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.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 diff --git a/README.md b/README.md index 30716fd..4c81bc5 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) · [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) +[中文 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) · [API Response Cookbook](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.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.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. +The current stable release is `2.2.7`, which continues the ExcelAlchemy 2.x line with stronger API-facing result payloads, a more complete FastAPI reference app, harder install-time smoke verification, and more consistent codec diagnostics. ## At a Glance diff --git a/README_cn.md b/README_cn.md index adfe6d7..f78623b 100644 --- a/README_cn.md +++ b/README_cn.md @@ -5,7 +5,7 @@ ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 -当前稳定发布版本是 `2.2.6`,它在稳定的 ExcelAlchemy 2.x 线上继续加强了结果对象与接入说明、可复制的 FastAPI 参考项目、更稳的 release smoke 验证,以及更清晰的 codec fallback 诊断信息。 +当前稳定发布版本是 `2.2.7`,它在稳定的 ExcelAlchemy 2.x 线上继续加强了 API 结果载荷、FastAPI 参考应用、安装后真实可用的 release smoke 验证,以及更一致的 codec 诊断信息。 你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 diff --git a/docs/api-response-cookbook.md b/docs/api-response-cookbook.md new file mode 100644 index 0000000..4747768 --- /dev/null +++ b/docs/api-response-cookbook.md @@ -0,0 +1,285 @@ +# API Response Cookbook + +This page shows practical response shapes for exposing ExcelAlchemy through a +backend API. + +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 result-object reference first, see +[`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md). +If you want a copyable FastAPI-oriented example, see +[`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md). + +## Recommended Top-Level Shape + +For most backends, a stable import response can look like this: + +```python +result = await alchemy.import_data('employees.xlsx', 'employee-import-result.xlsx') + +response = { + 'result': result.to_api_payload(), + 'cell_errors': alchemy.cell_error_map.to_api_payload(), + 'row_errors': alchemy.row_error_map.to_api_payload(), +} +``` + +This shape works well because it separates: + +- workbook-level outcome from `result` +- cell-level UI rendering from `cell_errors` +- row-level summaries from `row_errors` + +Within each error item: + +- use `code` for machine-readable branching +- use `message_key` when you want to map back to a localized message catalog +- use `message` for logs or plain-text APIs +- use `display_message` when you want ready-to-render UI text + +## 1. Success Response + +Use this when the import completed without header or data failures. + +```json +{ + "result": { + "result": "SUCCESS", + "is_success": true, + "is_header_invalid": false, + "is_data_invalid": false, + "summary": { + "success_count": 12, + "fail_count": 0, + "result_workbook_url": "memory://employee-import-result.xlsx" + }, + "header_issues": { + "is_required_missing": false, + "missing_required": [], + "missing_primary": [], + "unrecognized": [], + "duplicated": [] + } + }, + "cell_errors": { + "error_count": 0, + "items": [], + "by_row": {}, + "summary": { + "by_field": [], + "by_row": [], + "by_code": [] + } + }, + "row_errors": { + "error_count": 0, + "items": [], + "by_row": {}, + "summary": { + "by_row": [], + "by_code": [] + } + } +} +``` + +Frontend usage: + +- show a success toast from `result.result` +- show imported row counts from `result.summary` +- offer the result workbook download when `result.summary.result_workbook_url` is present + +## 2. Data-Invalid Response + +Use this when the header is valid but one or more rows failed validation. + +```json +{ + "result": { + "result": "DATA_INVALID", + "is_success": false, + "is_header_invalid": false, + "is_data_invalid": true, + "summary": { + "success_count": 10, + "fail_count": 2, + "result_workbook_url": "memory://employee-import-result.xlsx" + }, + "header_issues": { + "is_required_missing": false, + "missing_required": [], + "missing_primary": [], + "unrecognized": [], + "duplicated": [] + } + }, + "cell_errors": { + "error_count": 2, + "items": [ + { + "code": "valid_email_required", + "message_key": "valid_email_required", + "row_index": 0, + "row_number_for_humans": 1, + "column_index": 1, + "column_number_for_humans": 2, + "field_label": "Email", + "parent_label": null, + "unique_label": "Email", + "message": "Enter a valid email address, such as name@example.com", + "display_message": "【Email】Enter a valid email address, such as name@example.com" + } + ], + "summary": { + "by_field": [ + { + "field_label": "Email", + "parent_label": null, + "unique_label": "Email", + "error_count": 1, + "row_indices": [0], + "row_numbers_for_humans": [1], + "codes": ["valid_email_required"] + } + ], + "by_row": [ + { + "row_index": 0, + "row_number_for_humans": 1, + "error_count": 1, + "codes": ["valid_email_required"], + "field_labels": ["Email"], + "unique_labels": ["Email"] + } + ], + "by_code": [ + { + "code": "valid_email_required", + "error_count": 1, + "row_indices": [0], + "row_numbers_for_humans": [1], + "unique_labels": ["Email"] + } + ] + } + }, + "row_errors": { + "error_count": 1, + "summary": { + "by_row": [ + { + "row_index": 0, + "row_number_for_humans": 1, + "error_count": 1, + "codes": ["valid_email_required"], + "field_labels": ["Email"], + "unique_labels": ["Email"] + } + ], + "by_code": [ + { + "code": "valid_email_required", + "error_count": 1, + "row_indices": [0], + "row_numbers_for_humans": [1], + "unique_labels": ["Email"] + } + ] + } + } +} +``` + +Frontend usage: + +- render a failed import banner from `result.is_data_invalid` +- build a row-error table from `row_errors.summary.by_row` +- build field filters or grouped panels from `cell_errors.summary.by_field` +- branch on `code` for field-specific UI affordances + +## 3. Header-Invalid Response + +Use this when the workbook header row does not match the schema. + +```json +{ + "result": { + "result": "HEADER_INVALID", + "is_success": false, + "is_header_invalid": true, + "is_data_invalid": false, + "summary": { + "success_count": 0, + "fail_count": 0, + "result_workbook_url": null + }, + "header_issues": { + "is_required_missing": true, + "missing_required": ["Email"], + "missing_primary": [], + "unrecognized": ["Nickname"], + "duplicated": ["Phone"] + } + }, + "cell_errors": { + "error_count": 0, + "items": [], + "by_row": {}, + "summary": { + "by_field": [], + "by_row": [], + "by_code": [] + } + }, + "row_errors": { + "error_count": 0, + "items": [], + "by_row": {}, + "summary": { + "by_row": [], + "by_code": [] + } + } +} +``` + +Frontend usage: + +- show a header-level blocking dialog from `result.is_header_invalid` +- render missing and unrecognized headers directly from `result.header_issues` +- avoid showing row-level validation tables when there are no row errors + +## 4. Suggested Backend Helpers + +For larger backends, it is useful to wrap the three payload builders: + +```python +def build_excel_import_response(alchemy, result): + return { + 'result': result.to_api_payload(), + 'cell_errors': alchemy.cell_error_map.to_api_payload(), + 'row_errors': alchemy.row_error_map.to_api_payload(), + } +``` + +This keeps your route layer thin and your API contract stable. + +## 5. Frontend Mapping Ideas + +Common patterns: + +- use `result.result` for high-level status banners +- use `result.summary.success_count` and `result.summary.fail_count` for summary chips +- use `row_errors.summary.by_row` for row tables +- use `cell_errors.items` for precise cell navigation +- use `cell_errors.summary.by_code` for grouped issue badges +- use `code` when you want machine-readable branching or localization on the frontend +- use `message_key` when you maintain your own message catalog +- use `message` when you want plain text without workbook decoration +- use `display_message` when you want ready-to-render text + +## 6. Related Reading + +- [`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) +- [`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md) diff --git a/docs/examples-showcase.md b/docs/examples-showcase.md index ae3d2bb..a93bf7d 100644 --- a/docs/examples-showcase.md +++ b/docs/examples-showcase.md @@ -9,6 +9,9 @@ guided path through the examples directory, start with 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). +If you want copyable success and failure response shapes for backend endpoints, +see +[`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md). ## What ExcelAlchemy Looks Like diff --git a/docs/getting-started.md b/docs/getting-started.md index d0cae58..8a74a2f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -175,4 +175,5 @@ These objects let you return: See: - [`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md) +- [`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.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 5fa41d5..109fdc0 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -12,6 +12,8 @@ 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). +If you want copyable backend response shapes, see +[`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md). ## Stable Public Modules @@ -49,6 +51,8 @@ These modules are the recommended import paths for application code: - structured error access: Prefer `CellErrorMap` and `RowIssueMap` helpers such as `to_api_payload()` when you need frontend-friendly or API-friendly validation output. + The stable helper set also includes `records()`, `summary_by_field()`, + `summary_by_row()`, and `summary_by_code()` where applicable. ## Compatibility Modules In 2.x @@ -110,6 +114,11 @@ result helpers are: - `RowIssueMap.to_api_payload()` - `CellErrorMap.records()` - `RowIssueMap.records()` +- `CellErrorMap.summary_by_field()` +- `CellErrorMap.summary_by_row()` +- `CellErrorMap.summary_by_code()` +- `RowIssueMap.summary_by_row()` +- `RowIssueMap.summary_by_code()` Avoid depending on implementation details such as: diff --git a/docs/releases/2.2.7.md b/docs/releases/2.2.7.md new file mode 100644 index 0000000..13df72a --- /dev/null +++ b/docs/releases/2.2.7.md @@ -0,0 +1,111 @@ +# 2.2.7 Release Notes and Checklist + +This document records the final release positioning and verification checklist +for the `2.2.7` release on top of the stable 2.x line. + +## Purpose + +- publish the next stable 2.x integration-polish release of ExcelAlchemy +- present `2.2.7` as an API integration, reference-app, and release-hardening + update +- make result payloads easier for frontends and backend APIs to consume +- turn the FastAPI reference project into a more copyable minimal application +- strengthen release smoke verification so installation checks cover docs, + assets, failed imports, and FastAPI behavior +- make codec fallback diagnostics more consistent through clearer wording and a + dedicated logger name + +## Release Positioning + +`2.2.7` should be presented as a stable 2.x API and release-verification +release: + +- the public import and export workflow API stays stable +- result objects now expose clearer payload layers for machine-readable and + display-oriented consumers +- the FastAPI reference project now includes request/response contracts and a + structured response builder +- release smoke verification checks installed-package behavior more thoroughly +- codec fallback warnings are easier to filter and more consistent across + built-in codecs + +## Before Tagging + +1. Confirm the intended version in `src/excelalchemy/__init__.py`. +2. Review the `2.2.7` section in `CHANGELOG.md`. +3. Confirm the result-object and API-response docs: + - `docs/result-objects.md` + - `docs/api-response-cookbook.md` +4. Confirm the FastAPI reference project: + - `examples/fastapi_reference/README.md` + - `examples/fastapi_reference/app.py` + - `examples/fastapi_reference/services.py` + - `examples/fastapi_reference/schemas.py` + - `examples/fastapi_reference/responses.py` +5. Confirm the smoke scripts: + - `scripts/smoke_package.py` + - `scripts/smoke_examples.py` + - `scripts/generate_example_output_assets.py` + - `scripts/smoke_docs_assets.py` +6. Confirm codec fallback logger usage in: + - `src/excelalchemy/codecs/base.py` + - `src/excelalchemy/codecs/radio.py` + - `src/excelalchemy/codecs/multi_checkbox.py` + - `src/excelalchemy/codecs/organization.py` + - `src/excelalchemy/codecs/staff.py` + - `src/excelalchemy/codecs/tree.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/generate_example_output_assets.py +./.venv/bin/python scripts/smoke_docs_assets.py +./.venv/bin/uv run --with fastapi --with httpx --with python-multipart python scripts/smoke_examples.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.7`. +4. Use the `2.2.7` 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 payloads are easier to consume from APIs and frontends +- the FastAPI reference project is closer to a copyable minimal application +- installed-package smoke verification is materially stronger +- docs and showcase assets are now part of the release validation path +- codec diagnostics are more consistent without breaking the stable 2.x API + +## Recommended Release Messaging + +Prefer wording that emphasizes polish and integration guidance: + +- "continues the stable 2.x line" +- "strengthens API-facing result payloads" +- "expands the FastAPI reference app into a more copyable minimal application" +- "hardens install-time and release-time smoke verification" +- "improves codec diagnostics without breaking the public API" + +## Done When + +- the tag `v2.2.7` is published +- the GitHub Release notes clearly describe the payload, FastAPI reference, and + release-smoke improvements +- CI, package smoke, docs smoke, asset generation, and FastAPI smoke all pass +- the published package version matches the release tag diff --git a/docs/result-objects.md b/docs/result-objects.md index b4f6e67..03ead8d 100644 --- a/docs/result-objects.md +++ b/docs/result-objects.md @@ -7,6 +7,8 @@ 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). +If you want copyable success / failure / header-invalid response shapes, see +[`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md). ## Core Result Objects @@ -23,6 +25,30 @@ from excelalchemy import ImportResult from excelalchemy.results import CellErrorMap, RowIssueMap ``` +## Error Payload Layers + +ExcelAlchemy keeps machine-readable metadata separate from ready-to-render +messages. + +- `code` + Stable machine-readable identifier for frontend branching, filters, and + summary aggregation. +- `message_key` + Optional i18n-oriented identifier when the error originated from a known + `MessageKey`. +- `message` + Human-readable base message without workbook-coordinate decoration. +- `display_message` + Human-readable message ready for UI rendering. For cell-level errors, this may + include the workbook field prefix such as `【Email】...`. + +Recommended usage: + +- use `code` for branching and grouping +- use `message_key` for i18n-aware clients when present +- use `message` for logs, plain APIs, or analytics +- use `display_message` for UI lists, toasts, and workbook-adjacent feedback + ## `ImportResult` `ImportResult` is the high-level summary of one import run. @@ -51,6 +77,36 @@ if result.result == 'SUCCESS': ... ``` +Useful helpers: + +- `is_success` +- `is_header_invalid` +- `is_data_invalid` +- `to_api_payload()` + +Example payload: + +```json +{ + "result": "DATA_INVALID", + "is_success": false, + "is_header_invalid": false, + "is_data_invalid": true, + "summary": { + "success_count": 3, + "fail_count": 1, + "result_workbook_url": "memory://employee-import-result.xlsx" + }, + "header_issues": { + "is_required_missing": false, + "missing_required": [], + "missing_primary": [], + "unrecognized": [], + "duplicated": [] + } +} +``` + ## `CellErrorMap` `cell_error_map` stores workbook-coordinate cell-level failures. @@ -67,6 +123,9 @@ Useful helpers: - `messages_at(row_index, column_index)` - `flatten()` - `records()` +- `summary_by_field()` +- `summary_by_row()` +- `summary_by_code()` - `to_dict()` - `to_api_payload()` @@ -83,16 +142,55 @@ Shape: "error_count": 2, "items": [ { + "code": "valid_email_required", + "message_key": "valid_email_required", "row_index": 0, + "row_number_for_humans": 1, "column_index": 1, + "column_number_for_humans": 2, + "field_label": "Email", + "parent_label": null, "message": "Enter a valid email address, such as name@example.com", - "display_message": "Enter a valid email address, such as name@example.com" + "display_message": "【Email】Enter a valid email address, such as name@example.com" } ], + "summary": { + "by_field": [ + { + "field_label": "Email", + "parent_label": null, + "unique_label": "Email", + "error_count": 1, + "row_indices": [0], + "row_numbers_for_humans": [1], + "codes": ["valid_email_required"] + } + ], + "by_row": [ + { + "row_index": 0, + "row_number_for_humans": 1, + "error_count": 1, + "codes": ["valid_email_required"], + "field_labels": ["Email"], + "unique_labels": ["Email"] + } + ], + "by_code": [ + { + "code": "valid_email_required", + "error_count": 1, + "row_indices": [0], + "row_numbers_for_humans": [1], + "unique_labels": ["Email"] + } + ] + }, "by_row": { "0": { "1": [ { + "code": "valid_email_required", "message": "Enter a valid email address, such as name@example.com" } ] @@ -106,6 +204,7 @@ 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 +- aggregated views by field, row, or machine-readable code ## `RowIssueMap` @@ -125,6 +224,8 @@ Useful helpers: - `numbered_messages_for_row(row_index)` - `flatten()` - `records()` +- `summary_by_row()` +- `summary_by_code()` - `to_dict()` - `to_api_payload()` @@ -148,7 +249,7 @@ For a backend endpoint, a practical response shape is: result = await alchemy.import_data('employees.xlsx', 'employee-import-result.xlsx') response = { - 'result': result.model_dump(), + 'result': result.to_api_payload(), 'cell_errors': alchemy.cell_error_map.to_api_payload(), 'row_errors': alchemy.row_error_map.to_api_payload(), } @@ -159,6 +260,13 @@ 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 +- machine-readable `code` fields for frontend branching +- optional `message_key` fields for i18n-aware clients +- human-friendly row numbers through `row_number_for_humans` + +For concrete success, data-invalid, and header-invalid API response examples, +see +[`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md). ## Workbook Feedback vs API Feedback @@ -179,5 +287,6 @@ This is especially useful when: - [`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) +- [`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.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 8401db7..2894cc3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -63,9 +63,9 @@ If you want a copyable reference layout rather than a single script, see - 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. + - Shows a split between route, request/response schema, service, response builder, storage, and workbook 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. + - Output: prints the import result, created row count, uploaded artifacts, registered route paths, and structured response sections. - Captured output: [`files/example-outputs/fastapi-reference.txt`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/files/example-outputs/fastapi-reference.txt) ## By Goal diff --git a/examples/fastapi_reference/README.md b/examples/fastapi_reference/README.md index 6a19a50..0fe5423 100644 --- a/examples/fastapi_reference/README.md +++ b/examples/fastapi_reference/README.md @@ -3,10 +3,18 @@ This directory shows a minimal reference project structure for integrating ExcelAlchemy into a FastAPI service. +If you want copyable success / failure response shapes that match this +reference project, see +[`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md). + ## Layout - `models.py` Defines workbook schema declarations. +- `schemas.py` + Defines request and response schemas for the HTTP layer. +- `responses.py` + Builds stable structured API payloads from ExcelAlchemy result objects. - `storage.py` Defines a request-scoped in-memory `ExcelStorage` implementation. - `services.py` @@ -19,9 +27,13 @@ ExcelAlchemy into a FastAPI service. ```text HTTP request -> app.py - route registration and request parsing + route registration, form parsing, and response_model wiring + -> schemas.py + request and response contracts -> services.py template generation and import workflow orchestration + -> responses.py + structured payload assembly for API consumers -> storage.py upload fixture storage and result workbook upload handling -> models.py @@ -38,6 +50,8 @@ This is intentionally small, but it mirrors the shape of a real backend: ## What It Demonstrates - route layer and service layer separation +- explicit request and response schemas +- structured API response building - 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 @@ -76,11 +90,13 @@ Request: - multipart form-data - file field name: `file` +- optional form field: `tenant_id` Example: ```bash curl -X POST \ + -F "tenant_id=tenant-001" \ -F "file=@employee-import.xlsx" \ http://127.0.0.1:8000/employee-imports ``` @@ -91,12 +107,46 @@ Example JSON response: { "result": { "result": "SUCCESS", - "download_url": "memory://employee-import-result.xlsx", - "success_count": 1, - "fail_count": 0 + "is_success": true, + "is_header_invalid": false, + "is_data_invalid": false, + "summary": { + "success_count": 1, + "fail_count": 0, + "result_workbook_url": "memory://employee-import-result.xlsx" + }, + "header_issues": { + "is_required_missing": false, + "missing_required": [], + "missing_primary": [], + "unrecognized": [], + "duplicated": [] + } + }, + "cell_errors": { + "error_count": 0, + "items": [], + "by_row": {}, + "summary": { + "by_field": [], + "by_row": [], + "by_code": [] + } + }, + "row_errors": { + "error_count": 0, + "items": [], + "by_row": {}, + "summary": { + "by_row": [], + "by_code": [] + } }, "created_rows": 1, - "uploaded_artifacts": ["employee-import-result.xlsx"] + "uploaded_artifacts": ["employee-import-result.xlsx"], + "request": { + "tenant_id": "tenant-001" + } } ``` @@ -106,12 +156,51 @@ 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` +- cell-level frontend payloads from `cell_errors` +- row-level frontend payloads from `row_errors` + +Example validation-error response shape: + +```json +{ + "result": { + "result": "DATA_INVALID", + "is_success": false, + "is_header_invalid": false, + "is_data_invalid": true + }, + "cell_errors": { + "error_count": 2, + "items": [ + { + "code": "valid_email_required", + "row_number_for_humans": 1, + "column_number_for_humans": 2, + "field_label": "Email", + "display_message": "【Email】Enter a valid email address, such as name@example.com" + } + ] + }, + "row_errors": { + "error_count": 1, + "summary": { + "by_code": [ + { + "code": "valid_email_required", + "error_count": 1 + } + ] + } + } +} +``` 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 +- request-scoped trace metadata +- actor information +- domain-specific identifiers or import job ids +- your own error-code to UI-action mapping ## Suggested Adaptation Path @@ -131,6 +220,8 @@ The demo entry point prints: - created row count - uploaded artifact names - registered route paths +- response sections +- request tenant and structured summary keys For a captured output artifact, see: diff --git a/examples/fastapi_reference/app.py b/examples/fastapi_reference/app.py index 071aad3..90ae6e1 100644 --- a/examples/fastapi_reference/app.py +++ b/examples/fastapi_reference/app.py @@ -1,22 +1,29 @@ """Minimal reference FastAPI project with route, service, and storage layers.""" +# pyright: reportMissingImports=false + from __future__ import annotations import importlib.util -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING or importlib.util.find_spec('fastapi') is not None: from fastapi import FastAPI, UploadFile from fastapi.responses import StreamingResponse +else: + FastAPI = Any + UploadFile = Any + StreamingResponse = Any from io import BytesIO +from examples.fastapi_reference.schemas import EmployeeImportRequest, EmployeeImportResponse 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 import FastAPI, Form, HTTPException from fastapi.responses import StreamingResponse app = FastAPI(title='ExcelAlchemy Reference FastAPI App') @@ -30,26 +37,37 @@ async def download_template() -> StreamingResponse: headers={'Content-Disposition': 'attachment; filename=employee-template.xlsx'}, ) - @app.post('/employee-imports') - async def import_employees(file: UploadFile) -> dict[str, object]: + @app.post('/employee-imports', response_model=EmployeeImportResponse) + async def import_employees( + file: UploadFile, + tenant_id: str = Form(default='tenant-001'), + ) -> EmployeeImportResponse: 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 await import_service.import_workbook( + file.filename, + await file.read(), + request=EmployeeImportRequest(tenant_id=tenant_id), + ) return app def main() -> None: - result, created_rows, uploaded_artifacts = run_reference_demo() + response_payload = 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'Result: {response_payload.result_status}') + print(f'Success rows: {response_payload.result_success_count}') + print(f'Failed rows: {response_payload.result_fail_count}') + print(f'Created rows: {response_payload.created_rows}') + print(f'Uploaded artifacts: {response_payload.uploaded_artifacts}') print(f'Routes: {route_paths}') + print(f'Response sections: {sorted(response_payload.model_dump().keys())}') + print(f'Request tenant: {response_payload.request.tenant_id}') + print(f"Cell error summary keys: {sorted(response_payload.cell_errors['summary'].keys())}") + print(f"Row error summary keys: {sorted(response_payload.row_errors['summary'].keys())}") app = create_app() if importlib.util.find_spec('fastapi') is not None else None diff --git a/examples/fastapi_reference/responses.py b/examples/fastapi_reference/responses.py new file mode 100644 index 0000000..540bd57 --- /dev/null +++ b/examples/fastapi_reference/responses.py @@ -0,0 +1,28 @@ +"""Response builders for the FastAPI reference project.""" + +from examples.fastapi_reference.schemas import EmployeeImportRequest, EmployeeImportResponse +from excelalchemy import CellErrorMap, ImportResult, RowIssueMap + + +def build_import_response( + *, + result: ImportResult, + cell_error_map: CellErrorMap, + row_error_map: RowIssueMap, + created_rows: int, + uploaded_artifacts: list[str], + request: EmployeeImportRequest, +) -> EmployeeImportResponse: + """Build a stable API response for workbook-import endpoints.""" + + return EmployeeImportResponse( + result=result.to_api_payload(), + cell_errors=cell_error_map.to_api_payload(), + row_errors=row_error_map.to_api_payload(), + created_rows=created_rows, + uploaded_artifacts=uploaded_artifacts, + request=request, + ) + + +__all__ = ['build_import_response'] diff --git a/examples/fastapi_reference/schemas.py b/examples/fastapi_reference/schemas.py new file mode 100644 index 0000000..2e130cb --- /dev/null +++ b/examples/fastapi_reference/schemas.py @@ -0,0 +1,45 @@ +"""Request and response schemas for the FastAPI reference project.""" + +from pydantic import BaseModel, Field + + +class EmployeeImportRequest(BaseModel): + """Structured request metadata for workbook imports.""" + + tenant_id: str = Field(default='tenant-001', description='Tenant or request-scope identifier.') + + +class EmployeeImportResponse(BaseModel): + """Structured API response for workbook import endpoints.""" + + result: dict[str, object] = Field(description='High-level import result payload.') + cell_errors: dict[str, object] = Field(description='Structured cell-level error payload.') + row_errors: dict[str, object] = Field(description='Structured row-level error payload.') + created_rows: int = Field(description='Number of created rows in the demo service.') + uploaded_artifacts: list[str] = Field(description='Uploaded workbook artifact names.') + request: EmployeeImportRequest = Field(description='Structured request metadata.') + + @property + def result_status(self) -> str: + value = self.result['result'] + assert isinstance(value, str) + return value + + @property + def result_success_count(self) -> int: + summary = self.result['summary'] + assert isinstance(summary, dict) + value = summary['success_count'] + assert isinstance(value, int) + return value + + @property + def result_fail_count(self) -> int: + summary = self.result['summary'] + assert isinstance(summary, dict) + value = summary['fail_count'] + assert isinstance(value, int) + return value + + +__all__ = ['EmployeeImportRequest', 'EmployeeImportResponse'] diff --git a/examples/fastapi_reference/services.py b/examples/fastapi_reference/services.py index 12900a7..2e4ba56 100644 --- a/examples/fastapi_reference/services.py +++ b/examples/fastapi_reference/services.py @@ -8,8 +8,10 @@ from openpyxl import load_workbook from examples.fastapi_reference.models import EmployeeImporter +from examples.fastapi_reference.responses import build_import_response +from examples.fastapi_reference.schemas import EmployeeImportRequest, EmployeeImportResponse from examples.fastapi_reference.storage import RequestScopedStorage -from excelalchemy import ExcelAlchemy, ImporterConfig, ImportResult +from excelalchemy import ExcelAlchemy, ImporterConfig async def create_employee(row: dict[str, object], context: dict[str, object] | None) -> dict[str, object]: @@ -28,7 +30,9 @@ def __init__(self, storage: RequestScopedStorage, *, tenant_id: str = 'tenant-00 self.storage = storage self.tenant_id = tenant_id - def build_alchemy(self) -> ExcelAlchemy[dict[str, object], EmployeeImporter, EmployeeImporter]: + def build_alchemy( + self, *, tenant_id: str | None = None + ) -> ExcelAlchemy[dict[str, object], EmployeeImporter, EmployeeImporter, EmployeeImporter]: alchemy = ExcelAlchemy( ImporterConfig.for_create( EmployeeImporter, @@ -37,7 +41,7 @@ def build_alchemy(self) -> ExcelAlchemy[dict[str, object], EmployeeImporter, Emp locale='en', ) ) - alchemy.add_context({'tenant_id': self.tenant_id, 'created_rows': []}) + alchemy.add_context({'tenant_id': tenant_id or self.tenant_id, 'created_rows': []}) return alchemy def generate_template_bytes(self) -> bytes: @@ -45,17 +49,29 @@ def generate_template_bytes(self) -> bytes: 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]: + async def import_workbook( + self, + filename: str, + content: bytes, + *, + request: EmployeeImportRequest | None = None, + ) -> EmployeeImportResponse: + request_model = request or EmployeeImportRequest(tenant_id=self.tenant_id) self.storage.register_upload(filename, content) - alchemy = self.build_alchemy() + alchemy = self.build_alchemy(tenant_id=request_model.tenant_id) result = await alchemy.import_data(filename, 'employee-import-result.xlsx') - created_rows = alchemy.context['created_rows'] + context = alchemy.context + assert context is not None + created_rows = context['created_rows'] assert isinstance(created_rows, list) - return { - 'result': result.model_dump(), - 'created_rows': len(created_rows), - 'uploaded_artifacts': sorted(self.storage.uploaded), - } + return build_import_response( + result=result, + cell_error_map=alchemy.cell_error_map, + row_error_map=alchemy.row_error_map, + created_rows=len(created_rows), + uploaded_artifacts=sorted(self.storage.uploaded), + request=request_model, + ) def build_demo_upload(template_bytes: bytes) -> bytes: @@ -71,23 +87,18 @@ def build_demo_upload(template_bytes: bytes) -> bytes: 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]]: +def run_reference_demo() -> EmployeeImportResponse: 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) + return asyncio.run( + service.import_workbook( + 'employee-import.xlsx', + upload_bytes, + request=EmployeeImportRequest(tenant_id='tenant-001'), + ) + ) __all__ = [ @@ -95,5 +106,4 @@ def run_reference_demo() -> tuple[ImportResult, int, list[str]]: 'build_demo_upload', 'create_employee', 'run_reference_demo', - 'summarize_result', ] diff --git a/examples/fastapi_upload.py b/examples/fastapi_upload.py index ce871a3..eda0f0d 100644 --- a/examples/fastapi_upload.py +++ b/examples/fastapi_upload.py @@ -76,7 +76,7 @@ async def create_employee(row: dict[str, object], context: dict[str, object] | N def build_import_alchemy( storage: RequestScopedStorage, *, tenant_id: str = 'tenant-001' -) -> ExcelAlchemy[dict[str, object], EmployeeImporter, EmployeeImporter]: +) -> ExcelAlchemy[dict[str, object], EmployeeImporter, EmployeeImporter, EmployeeImporter]: alchemy = ExcelAlchemy( ImporterConfig.for_create( EmployeeImporter, diff --git a/files/example-outputs/annotated-schema.txt b/files/example-outputs/annotated-schema.txt index 142a51c..897a833 100644 --- a/files/example-outputs/annotated-schema.txt +++ b/files/example-outputs/annotated-schema.txt @@ -1 +1 @@ -Generated template: employee-template.xlsx (6802 bytes) +Generated template: employee-template.xlsx (6803 bytes) diff --git a/files/example-outputs/custom-storage.txt b/files/example-outputs/custom-storage.txt index d70d16b..78204fc 100644 --- a/files/example-outputs/custom-storage.txt +++ b/files/example-outputs/custom-storage.txt @@ -1,2 +1,2 @@ memory://employees.xlsx -Uploaded bytes: 6812 +Uploaded bytes: 6813 diff --git a/files/example-outputs/date-and-range-fields.txt b/files/example-outputs/date-and-range-fields.txt index 107602b..aa1a1ea 100644 --- a/files/example-outputs/date-and-range-fields.txt +++ b/files/example-outputs/date-and-range-fields.txt @@ -1,2 +1,2 @@ -Generated template: compensation-template.xlsx (6861 bytes) +Generated template: compensation-template.xlsx (6862 bytes) Fields: Start date, Probation window, Salary band, Signing bonus diff --git a/files/example-outputs/export-workflow.txt b/files/example-outputs/export-workflow.txt index 3025d7c..3b3af8d 100644 --- a/files/example-outputs/export-workflow.txt +++ b/files/example-outputs/export-workflow.txt @@ -1,5 +1,5 @@ Export workflow completed Artifact filename: employees-export.xlsx -Artifact bytes: 6892 +Artifact bytes: 6893 Upload URL: memory://employees-export-upload.xlsx Uploaded objects: ['employees-export-upload.xlsx'] diff --git a/files/example-outputs/fastapi-reference.txt b/files/example-outputs/fastapi-reference.txt index 23c4e78..488c655 100644 --- a/files/example-outputs/fastapi-reference.txt +++ b/files/example-outputs/fastapi-reference.txt @@ -5,3 +5,7 @@ Failed rows: 0 Created rows: 1 Uploaded artifacts: [] Routes: ['/employee-imports', '/employee-template.xlsx'] +Response sections: ['cell_errors', 'created_rows', 'request', 'result', 'row_errors', 'uploaded_artifacts'] +Request tenant: tenant-001 +Cell error summary keys: ['by_code', 'by_field', 'by_row'] +Row error summary keys: ['by_code', 'by_row'] diff --git a/files/example-outputs/selection-fields.txt b/files/example-outputs/selection-fields.txt index 1f2c398..7987614 100644 --- a/files/example-outputs/selection-fields.txt +++ b/files/example-outputs/selection-fields.txt @@ -1,2 +1,2 @@ -Generated template: selection-fields-template.xlsx (6941 bytes) +Generated template: selection-fields-template.xlsx (6942 bytes) Fields: Request type, Impacted teams, Owner organization, Partner organizations, Owner, Reviewers diff --git a/scripts/smoke_docs_assets.py b/scripts/smoke_docs_assets.py new file mode 100644 index 0000000..be10b32 --- /dev/null +++ b/scripts/smoke_docs_assets.py @@ -0,0 +1,65 @@ +"""Validate documentation entry points and generated showcase assets.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DOCS_DIR = ROOT / 'docs' +ASSET_DIR = ROOT / 'files' / 'example-outputs' + +REQUIRED_DOC_CHECKS: dict[Path, tuple[str, ...]] = { + DOCS_DIR / 'result-objects.md': ( + 'ImportResult', + 'CellErrorMap', + 'RowIssueMap', + 'docs/api-response-cookbook.md', + 'display_message', + ), + DOCS_DIR / 'examples-showcase.md': ( + 'files/example-outputs/employee-import-workflow.txt', + 'files/example-outputs/create-or-update-import.txt', + 'files/example-outputs/export-workflow.txt', + 'files/example-outputs/fastapi-reference.txt', + ), +} + +REQUIRED_ASSETS = ( + 'annotated-schema.txt', + 'employee-import-workflow.txt', + 'create-or-update-import.txt', + 'date-and-range-fields.txt', + 'selection-fields.txt', + 'custom-storage.txt', + 'export-workflow.txt', + 'fastapi-reference.txt', +) + + +def _assert_doc_contains(path: Path, fragments: tuple[str, ...]) -> None: + content = path.read_text(encoding='utf-8') + missing = [fragment for fragment in fragments if fragment not in content] + if missing: + raise AssertionError(f'{path.relative_to(ROOT)} is missing expected fragments: {missing}') + + +def _assert_asset_exists(filename: str) -> None: + path = ASSET_DIR / filename + if not path.exists(): + raise AssertionError(f'Missing generated asset: {path.relative_to(ROOT)}') + if not path.read_text(encoding='utf-8').strip(): + raise AssertionError(f'Generated asset is empty: {path.relative_to(ROOT)}') + + +def main() -> None: + for path, fragments in REQUIRED_DOC_CHECKS.items(): + _assert_doc_contains(path, fragments) + print(f'Documentation smoke passed: {path.relative_to(ROOT)}') + + for filename in REQUIRED_ASSETS: + _assert_asset_exists(filename) + print(f'Asset smoke passed: files/example-outputs/{filename}') + + +if __name__ == '__main__': + main() diff --git a/scripts/smoke_examples.py b/scripts/smoke_examples.py index 0dfd123..76de2de 100644 --- a/scripts/smoke_examples.py +++ b/scripts/smoke_examples.py @@ -65,6 +65,44 @@ def _run_module_example(module_name: str) -> str: return buffer.getvalue() +def _run_fastapi_reference_http_smoke() -> None: + testclient_module = importlib.import_module('fastapi.testclient') + TestClient = testclient_module.TestClient + app_module = importlib.import_module('examples.fastapi_reference.app') + services_module = importlib.import_module('examples.fastapi_reference.services') + + client = TestClient(app_module.create_app()) + template_response = client.get('/employee-template.xlsx') + if template_response.status_code != 200: + raise AssertionError('FastAPI reference template endpoint did not return HTTP 200') + + upload_bytes = services_module.build_demo_upload(template_response.content) + import_response = client.post( + '/employee-imports', + data={'tenant_id': 'tenant-smoke'}, + files={ + 'file': ( + 'employee-import.xlsx', + upload_bytes, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + }, + ) + if import_response.status_code != 200: + raise AssertionError('FastAPI reference import endpoint did not return HTTP 200') + + payload = import_response.json() + if payload['result']['result'] != 'SUCCESS': + raise AssertionError('FastAPI reference import did not return SUCCESS') + if payload['request']['tenant_id'] != 'tenant-smoke': + raise AssertionError('FastAPI reference request echo payload is incorrect') + if payload['cell_errors']['error_count'] != 0: + raise AssertionError('FastAPI reference success payload should not contain cell errors') + if payload['row_errors']['error_count'] != 0: + raise AssertionError('FastAPI reference success payload should not contain row errors') + print('Smoke passed: examples.fastapi_reference.http') + + 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: @@ -91,6 +129,11 @@ def main() -> None: _assert_example_output(module_name, output, required_fragments) print(f'Smoke passed: {module_name}') + if _dependency_available('fastapi') and _dependency_available('httpx') and _dependency_available('multipart'): + _run_fastapi_reference_http_smoke() + else: + print('Skipped optional example: examples.fastapi_reference.http (fastapi/httpx/python-multipart missing)') + if __name__ == '__main__': main() diff --git a/scripts/smoke_package.py b/scripts/smoke_package.py index a3dddec..6e76665 100644 --- a/scripts/smoke_package.py +++ b/scripts/smoke_package.py @@ -116,12 +116,33 @@ async def main() -> None: assert invalid_result.fail_count == 1 assert invalid_importer.cell_error_map.error_count >= 1 assert invalid_importer.row_error_map.error_count >= 1 + invalid_result_payload = invalid_result.to_api_payload() cell_payload = invalid_importer.cell_error_map.to_api_payload() row_payload = invalid_importer.row_error_map.to_api_payload() + assert invalid_result_payload['result'] == 'DATA_INVALID' + assert invalid_result_payload['is_data_invalid'] is True + assert invalid_result_payload['summary']['fail_count'] == 1 assert cell_payload['error_count'] >= 1 assert row_payload['error_count'] >= 1 assert isinstance(cell_payload['items'], list) assert isinstance(row_payload['items'], list) + assert cell_payload['summary']['by_code'] + assert cell_payload['summary']['by_row'] + assert row_payload['summary']['by_code'] + assert row_payload['summary']['by_row'] + first_cell_issue = cell_payload['items'][0] + assert isinstance(first_cell_issue['code'], str) and first_cell_issue['code'] + assert first_cell_issue['field_label'] == 'Age' + assert isinstance(first_cell_issue['message'], str) and first_cell_issue['message'] + assert isinstance(first_cell_issue['display_message'], str) and first_cell_issue['display_message'] + assert first_cell_issue['row_number_for_humans'] == 1 + assert isinstance(first_cell_issue['column_number_for_humans'], int) + assert first_cell_issue['column_number_for_humans'] >= 1 + first_row_issue = row_payload['items'][0] + assert isinstance(first_row_issue['code'], str) and first_row_issue['code'] + assert isinstance(first_row_issue['message'], str) and first_row_issue['message'] + assert isinstance(first_row_issue['display_message'], str) and first_row_issue['display_message'] + assert first_row_issue['row_number_for_humans'] == 1 exporter = ExcelAlchemy(ExporterConfig.for_storage(SmokeImporter, storage=storage, locale='en')) artifact = exporter.export_artifact( diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index 288d4e9..af84e72 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.6' +__version__ = '2.2.7' from excelalchemy._primitives.constants import CharacterSet, DataRangeOption, DateFormat, Option from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning from excelalchemy._primitives.identity import ( @@ -51,9 +51,12 @@ from excelalchemy.results import ( CellErrorMap, CellIssueRecord, + CodeIssueSummary, + FieldIssueSummary, ImportResult, RowIssueMap, RowIssueRecord, + RowIssueSummary, ValidateHeaderResult, ValidateResult, ValidateRowResult, @@ -66,6 +69,7 @@ 'BooleanCodec', 'CellErrorMap', 'CellIssueRecord', + 'CodeIssueSummary', 'ColumnIndex', 'CompositeExcelFieldCodec', 'ConfigError', @@ -87,6 +91,7 @@ 'ExcelRowError', 'ExcelStorage', 'ExporterConfig', + 'FieldIssueSummary', 'FieldMeta', 'ImportMode', 'ImportResult', @@ -117,6 +122,7 @@ 'RowIndex', 'RowIssueMap', 'RowIssueRecord', + 'RowIssueSummary', 'SingleChoiceCodec', 'SingleOrganization', 'SingleOrganizationCodec', diff --git a/src/excelalchemy/codecs/base.py b/src/excelalchemy/codecs/base.py index 8c9d84e..b9f4c86 100644 --- a/src/excelalchemy/codecs/base.py +++ b/src/excelalchemy/codecs/base.py @@ -19,6 +19,9 @@ type WorkbookDisplayValue = Any type NormalizedImportValue = Any +CODEC_LOGGER_NAME = 'excelalchemy.codecs' +codec_logger = logging.getLogger(CODEC_LOGGER_NAME) + def _summarize_exception(exc: Exception) -> str: details: list[str] = [] @@ -43,20 +46,29 @@ def _summarize_exception(exc: Exception) -> str: return exc.__class__.__name__ +def _fallback_reason(*, exc: Exception | None = None, reason: str | None = None) -> str: + if reason: + return reason + if exc is not None: + return _summarize_exception(exc) + return 'No additional details' + + def log_codec_parse_fallback( codec_name: str, value: object, *, field_label: str | None = None, - exc: Exception, + exc: Exception | None = None, + reason: str | None = None, ) -> None: field_context = f' for field "{field_label}"' if field_label else '' - logging.warning( + codec_logger.warning( 'Codec %s could not parse workbook input%s; keeping the original value %r. Reason: %s', codec_name, field_context, value, - _summarize_exception(exc), + _fallback_reason(exc=exc, reason=reason), ) @@ -65,15 +77,43 @@ def log_codec_render_fallback( value: object, *, field_label: str | None = None, - exc: Exception, + exc: Exception | None = None, + reason: str | None = None, ) -> None: field_context = f' for field "{field_label}"' if field_label else '' - logging.warning( + codec_logger.warning( 'Codec %s could not format workbook value%s; returning %r as-is. Reason: %s', codec_name, field_context, value, - _summarize_exception(exc), + _fallback_reason(exc=exc, reason=reason), + ) + + +def log_codec_option_resolution_fallback( + codec_name: str, + value: object, + *, + field_label: str | None = None, + exc: Exception | None = None, + reason: str | None = None, +) -> None: + field_context = f' for field "{field_label}"' if field_label else '' + codec_logger.warning( + 'Codec %s could not resolve a configured option%s; returning %r as-is. Reason: %s', + codec_name, + field_context, + value, + _fallback_reason(exc=exc, reason=reason), + ) + + +def log_codec_missing_options(codec_name: str, *, field_label: str | None = None) -> None: + field_context = f' for field "{field_label}"' if field_label else '' + codec_logger.warning( + 'Codec %s is missing configured options%s; workbook comments and validation may be incomplete.', + codec_name, + field_context, ) diff --git a/src/excelalchemy/codecs/boolean.py b/src/excelalchemy/codecs/boolean.py index 31be18b..3b5cbc6 100644 --- a/src/excelalchemy/codecs/boolean.py +++ b/src/excelalchemy/codecs/boolean.py @@ -1,7 +1,10 @@ -import logging - from excelalchemy.codecs import excel_choice_codec -from excelalchemy.codecs.base import ExcelFieldCodec, WorkbookDisplayValue, WorkbookInputValue +from excelalchemy.codecs.base import ( + ExcelFieldCodec, + WorkbookDisplayValue, + WorkbookInputValue, + log_codec_render_fallback, +) from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.i18n.messages import message as msg @@ -63,15 +66,19 @@ def format_display_value( if value in cls._false_values(): return cls._false_display() if value not in cls._true_values() | cls._false_values(): - logging.warning('Could not recognize boolean value %s; returning the original value', value) + log_codec_render_fallback( + cls.__name__, + value, + field_label=declared.label, + reason=f'Expected {cls._true_display()!r} or {cls._false_display()!r}', + ) return value else: - logging.warning( - 'Type %s could not deserialize %s for field %s; returning the default value %s', + log_codec_render_fallback( cls.__name__, value, - declared.label, - cls._false_display(), + field_label=declared.label, + reason=f'Expected a boolean or one of {cls._true_display()!r}/{cls._false_display()!r}', ) return cls._true_display() if str(value) in cls._true_values() else cls._false_display() diff --git a/src/excelalchemy/codecs/date.py b/src/excelalchemy/codecs/date.py index f584649..3ce5700 100644 --- a/src/excelalchemy/codecs/date.py +++ b/src/excelalchemy/codecs/date.py @@ -1,4 +1,3 @@ -import logging from datetime import datetime from typing import cast @@ -11,6 +10,7 @@ NormalizedImportValue, WorkbookDisplayValue, WorkbookInputValue, + codec_logger, log_codec_parse_fallback, ) from excelalchemy.exceptions import ConfigError @@ -53,7 +53,7 @@ def parse_input( declared = field_meta.declared presentation = field_meta.presentation if isinstance(value, DateTime): - logging.info( + codec_logger.info( 'Codec %s received a parsed datetime for %s; returning it unchanged: %s', cls.__name__, declared.label, diff --git a/src/excelalchemy/codecs/date_range.py b/src/excelalchemy/codecs/date_range.py index 42e4d5f..9a6ac3d 100644 --- a/src/excelalchemy/codecs/date_range.py +++ b/src/excelalchemy/codecs/date_range.py @@ -1,4 +1,3 @@ -import logging from collections.abc import Mapping from datetime import datetime from typing import cast @@ -9,7 +8,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, log_codec_parse_fallback +from excelalchemy.codecs.base import CompositeExcelFieldCodec, log_codec_parse_fallback, log_codec_render_fallback from excelalchemy.exceptions import ConfigError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg @@ -149,7 +148,12 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - if mapping is not None: return cls.__deserialize__dict(py_date_format, mapping) - logging.warning('%s could not be deserialized; returning the original value', cls.__name__) + log_codec_render_fallback( + cls.__name__, + value, + field_label=field_meta.declared.label, + reason='The workbook value is not a string, datetime, or start/end mapping', + ) return str(value) @classmethod diff --git a/src/excelalchemy/codecs/multi_checkbox.py b/src/excelalchemy/codecs/multi_checkbox.py index 5e3b395..76297c1 100644 --- a/src/excelalchemy/codecs/multi_checkbox.py +++ b/src/excelalchemy/codecs/multi_checkbox.py @@ -1,9 +1,8 @@ -import logging from typing import cast from excelalchemy._primitives.constants import MULTI_CHECKBOX_SEPARATOR from excelalchemy._primitives.identity import OptionId -from excelalchemy.codecs.base import ExcelFieldCodec +from excelalchemy.codecs.base import ExcelFieldCodec, log_codec_missing_options, log_codec_parse_fallback from excelalchemy.exceptions import ProgrammaticError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg @@ -73,8 +72,11 @@ def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> list[str] | ob if isinstance(value, str): return [item.strip() for item in value.split(MULTI_CHECKBOX_SEPARATOR)] - logging.warning( - 'ValueType <%s> could not parse Excel input %s; returning the original value', cls.__name__, value + log_codec_parse_fallback( + cls.__name__, + value, + field_label=field_meta.declared.label, + reason='Expected a delimited string or a list of selected values', ) return value @@ -92,9 +94,7 @@ def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> lis raise ProgrammaticError(msg(MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_VALUE_TYPE, value_type=cls.__name__)) if not presentation.options: # empty - logging.warning( - 'Field %s of type %s has no options; returning the original value', declared.label, cls.__name__ - ) + log_codec_missing_options(cls.__name__, field_label=declared.label) return parsed if len(parsed) != len(set(parsed)): diff --git a/src/excelalchemy/codecs/number.py b/src/excelalchemy/codecs/number.py index 71f3b17..412fc10 100644 --- a/src/excelalchemy/codecs/number.py +++ b/src/excelalchemy/codecs/number.py @@ -1,4 +1,3 @@ -import logging from decimal import ROUND_DOWN, Context, Decimal, InvalidOperation from excelalchemy.codecs.base import ( @@ -6,6 +5,7 @@ NormalizedImportValue, WorkbookDisplayValue, WorkbookInputValue, + codec_logger, log_codec_parse_fallback, ) from excelalchemy.i18n.messages import MessageKey @@ -24,7 +24,7 @@ def canonicalize_decimal(value: Decimal, digits_limit: int | None) -> Decimal: context=Context(rounding=ROUND_DOWN), ) except InvalidOperation as e: - logging.warning('fraction_digits is too small and causes precision loss: %s', e) + codec_logger.warning('Codec Number detected precision loss while quantizing fraction_digits: %s', e) return value diff --git a/src/excelalchemy/codecs/organization.py b/src/excelalchemy/codecs/organization.py index 9c74dbe..c54bd58 100644 --- a/src/excelalchemy/codecs/organization.py +++ b/src/excelalchemy/codecs/organization.py @@ -1,8 +1,8 @@ -import logging from typing import cast from excelalchemy._primitives.constants import MULTI_CHECKBOX_SEPARATOR from excelalchemy._primitives.identity import OptionId +from excelalchemy.codecs.base import log_codec_option_resolution_fallback, log_codec_render_fallback from excelalchemy.codecs.multi_checkbox import MultiCheckbox from excelalchemy.codecs.radio import Radio from excelalchemy.i18n.messages import MessageKey @@ -44,7 +44,7 @@ def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: try: return presentation.options_id_map(field_label=declared.label)[OptionId(value.strip())].name except KeyError: - logging.warning('Could not resolve organization option %s; returning the original value', value) + log_codec_option_resolution_fallback(cls.__name__, value, field_label=declared.label) return value @@ -88,7 +88,12 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - option_names = presentation.exchange_option_ids_to_names(option_ids, field_label=declared.label) return MULTI_CHECKBOX_SEPARATOR.join(map(str, option_names)) - logging.warning('%s could not be deserialized; returning the original value', cls.__name__) + log_codec_render_fallback( + cls.__name__, + value, + field_label=declared.label, + reason='The workbook value is not a string or a list of option ids', + ) return str(value) @classmethod diff --git a/src/excelalchemy/codecs/radio.py b/src/excelalchemy/codecs/radio.py index 2760b4c..aa210ae 100644 --- a/src/excelalchemy/codecs/radio.py +++ b/src/excelalchemy/codecs/radio.py @@ -1,8 +1,12 @@ -import logging - from excelalchemy._primitives.constants import MULTI_CHECKBOX_SEPARATOR from excelalchemy._primitives.identity import OptionId -from excelalchemy.codecs.base import ExcelFieldCodec, WorkbookDisplayValue, WorkbookInputValue +from excelalchemy.codecs.base import ( + ExcelFieldCodec, + WorkbookDisplayValue, + WorkbookInputValue, + log_codec_missing_options, + log_codec_option_resolution_fallback, +) from excelalchemy.exceptions import ProgrammaticError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg @@ -49,7 +53,7 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: declared = field_meta.declared presentation = field_meta.presentation if not presentation.options: - logging.error('Field %s of type %s must define options', declared.label, cls.__name__) + log_codec_missing_options(cls.__name__, field_label=declared.label) return '\n'.join( [ @@ -74,13 +78,7 @@ def format_display_value(cls, value: WorkbookDisplayValue | None, field_meta: Fi try: return presentation.options_id_map(field_label=declared.label)[value.strip()].name except Exception as exc: - logging.warning( - 'Type %s could not resolve option %s for field %s; returning the original value. Reason: %s', - cls.__name__, - value, - declared.label, - exc, - ) + log_codec_option_resolution_fallback(cls.__name__, value, field_label=declared.label, exc=exc) return value if value is not None else '' @classmethod @@ -96,9 +94,7 @@ def normalize_import_value(cls, value: str, field_meta: FieldMetaInfo) -> Option raise ProgrammaticError(msg(MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_SELECTION_FIELDS)) if not presentation.options: # empty - logging.warning( - 'Field %s of type %s has no options; returning the original value', declared.label, cls.__name__ - ) + log_codec_missing_options(cls.__name__, field_label=declared.label) return parsed options_id_map = presentation.options_id_map(field_label=declared.label) diff --git a/src/excelalchemy/codecs/staff.py b/src/excelalchemy/codecs/staff.py index f32a710..faba879 100644 --- a/src/excelalchemy/codecs/staff.py +++ b/src/excelalchemy/codecs/staff.py @@ -1,8 +1,8 @@ -import logging from typing import cast from excelalchemy._primitives.constants import MULTI_CHECKBOX_SEPARATOR from excelalchemy._primitives.identity import OptionId +from excelalchemy.codecs.base import log_codec_option_resolution_fallback, log_codec_render_fallback from excelalchemy.codecs.multi_checkbox import MultiCheckbox from excelalchemy.codecs.radio import Radio from excelalchemy.i18n.messages import MessageKey @@ -45,12 +45,7 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - try: return presentation.options_id_map(field_label=declared.label)[OptionId(value.strip())].name except KeyError: - logging.warning( - 'Type %s could not resolve option %s for field %s; returning the original value', - cls.__name__, - value, - declared.label, - ) + log_codec_option_resolution_fallback(cls.__name__, value, field_label=declared.label) return value @@ -97,7 +92,12 @@ def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: option_names = presentation.exchange_option_ids_to_names(option_ids, field_label=declared.label) return f'{MULTI_CHECKBOX_SEPARATOR}'.join(option_names) - logging.warning('%s could not be deserialized', cls.__name__) + log_codec_render_fallback( + cls.__name__, + value, + field_label=declared.label, + reason='The workbook value is not a string or a list of option ids', + ) return str(value) diff --git a/src/excelalchemy/codecs/tree.py b/src/excelalchemy/codecs/tree.py index 0506a69..2be5295 100644 --- a/src/excelalchemy/codecs/tree.py +++ b/src/excelalchemy/codecs/tree.py @@ -1,6 +1,8 @@ -import logging - -from excelalchemy.codecs.base import WorkbookDisplayValue, WorkbookInputValue +from excelalchemy.codecs.base import ( + WorkbookDisplayValue, + WorkbookInputValue, + log_codec_option_resolution_fallback, +) from excelalchemy.codecs.multi_checkbox import MultiCheckbox from excelalchemy.codecs.radio import Radio from excelalchemy.i18n.messages import MessageKey @@ -43,7 +45,7 @@ def format_display_value( try: return presentation.options_id_map(field_label=declared.label)[value.strip()].name except KeyError: - logging.warning('Could not resolve tree option %s; returning the original value', value) + log_codec_option_resolution_fallback(cls.__name__, value, field_label=declared.label) return value if value is not None else '' diff --git a/src/excelalchemy/exceptions.py b/src/excelalchemy/exceptions.py index 5e859db..447a49c 100644 --- a/src/excelalchemy/exceptions.py +++ b/src/excelalchemy/exceptions.py @@ -30,10 +30,22 @@ def __init__( def __str__(self) -> str: return self.message + @property + def display_message(self) -> str: + return self.message + + @property + def code(self) -> str: + if self.message_key is not None: + return self.message_key.value + return type(self).__name__ + def to_dict(self) -> dict[str, object]: payload: dict[str, object] = { 'type': type(self).__name__, + 'code': self.code, 'message': self.message, + 'display_message': self.display_message, } if self.message_key is not None: payload['message_key'] = self.message_key.value @@ -66,6 +78,10 @@ def __init__( def __str__(self) -> str: return f'【{self.label}】{self.message}' + @property + def display_message(self) -> str: + return str(self) + def __repr__(self) -> str: return ( f"{type(self).__name__}(label=Label('{self.label}'), " @@ -103,6 +119,7 @@ def _validate(self) -> None: def to_dict(self) -> dict[str, object]: payload = super().to_dict() payload['label'] = str(self.label) + payload['field_label'] = str(self.label) payload['parent_label'] = None if self.parent_label is None else str(self.parent_label) payload['unique_label'] = str(self.unique_label) return payload diff --git a/src/excelalchemy/results.py b/src/excelalchemy/results.py index 37d6361..f3c6aca 100644 --- a/src/excelalchemy/results.py +++ b/src/excelalchemy/results.py @@ -20,6 +20,80 @@ def _empty_labels() -> list[Label]: type RowIssue = ExcelRowError | ExcelCellError +def _row_number_for_humans(row_index: RowIndex) -> int: + return int(row_index) + 1 + + +def _column_number_for_humans(column_index: ColumnIndex) -> int: + return int(column_index) + 1 + + +@dataclass(slots=True, frozen=True) +class FieldIssueSummary: + """Field-level issue summary suitable for frontends and dashboards.""" + + field_label: Label + parent_label: Label | None + unique_label: str + error_count: int + row_indices: tuple[RowIndex, ...] + row_numbers_for_humans: tuple[int, ...] + codes: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + return { + 'field_label': str(self.field_label), + 'parent_label': None if self.parent_label is None else str(self.parent_label), + 'unique_label': self.unique_label, + 'error_count': self.error_count, + 'row_indices': [int(index) for index in self.row_indices], + 'row_numbers_for_humans': list(self.row_numbers_for_humans), + 'codes': list(self.codes), + } + + +@dataclass(slots=True, frozen=True) +class RowIssueSummary: + """Row-level issue summary suitable for frontends and dashboards.""" + + row_index: RowIndex + row_number_for_humans: int + error_count: int + codes: tuple[str, ...] + field_labels: tuple[str, ...] + unique_labels: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + return { + 'row_index': int(self.row_index), + 'row_number_for_humans': self.row_number_for_humans, + 'error_count': self.error_count, + 'codes': list(self.codes), + 'field_labels': list(self.field_labels), + 'unique_labels': list(self.unique_labels), + } + + +@dataclass(slots=True, frozen=True) +class CodeIssueSummary: + """Code-level issue summary suitable for frontends and dashboards.""" + + code: str + error_count: int + row_indices: tuple[RowIndex, ...] + row_numbers_for_humans: tuple[int, ...] + unique_labels: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + return { + 'code': self.code, + 'error_count': self.error_count, + 'row_indices': [int(index) for index in self.row_indices], + 'row_numbers_for_humans': list(self.row_numbers_for_humans), + 'unique_labels': list(self.unique_labels), + } + + @dataclass(slots=True, frozen=True) class CellIssueRecord: """Flat cell issue record suitable for API responses and UI rendering.""" @@ -31,8 +105,10 @@ class CellIssueRecord: def to_dict(self) -> dict[str, object]: payload = self.error.to_dict() payload['row_index'] = int(self.row_index) + payload['row_number_for_humans'] = _row_number_for_humans(self.row_index) payload['column_index'] = int(self.column_index) - payload['display_message'] = str(self.error) + payload['column_number_for_humans'] = _column_number_for_humans(self.column_index) + payload['display_message'] = self.error.display_message return payload @@ -46,7 +122,16 @@ class RowIssueRecord: 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) + payload['row_number_for_humans'] = _row_number_for_humans(self.row_index) + payload['display_message'] = self.error.display_message + if isinstance(self.error, ExcelCellError): + payload['field_label'] = str(self.error.label) + payload['parent_label'] = None if self.error.parent_label is None else str(self.error.parent_label) + payload['unique_label'] = str(self.error.unique_label) + else: + payload.setdefault('field_label', None) + payload.setdefault('parent_label', None) + payload.setdefault('unique_label', None) return payload @@ -78,6 +163,66 @@ def records(self) -> tuple[CellIssueRecord, ...]: for error in errors ) + def summary_by_field(self) -> tuple[FieldIssueSummary, ...]: + grouped: dict[str, list[CellIssueRecord]] = {} + for record in self.records(): + grouped.setdefault(str(record.error.unique_label), []).append(record) + + summaries: list[FieldIssueSummary] = [] + for unique_label, records in grouped.items(): + first_error = records[0].error + row_indices = tuple(sorted({record.row_index for record in records})) + summaries.append( + FieldIssueSummary( + field_label=first_error.label, + parent_label=first_error.parent_label, + unique_label=unique_label, + error_count=len(records), + row_indices=row_indices, + row_numbers_for_humans=tuple(_row_number_for_humans(index) for index in row_indices), + codes=tuple(sorted({record.error.code for record in records})), + ) + ) + return tuple(sorted(summaries, key=lambda summary: summary.unique_label)) + + def summary_by_row(self) -> tuple[RowIssueSummary, ...]: + grouped: dict[RowIndex, list[CellIssueRecord]] = {} + for record in self.records(): + grouped.setdefault(record.row_index, []).append(record) + + summaries: list[RowIssueSummary] = [] + for row_index, records in grouped.items(): + summaries.append( + RowIssueSummary( + row_index=row_index, + row_number_for_humans=_row_number_for_humans(row_index), + error_count=len(records), + codes=tuple(sorted({record.error.code for record in records})), + field_labels=tuple(sorted({str(record.error.label) for record in records})), + unique_labels=tuple(sorted({str(record.error.unique_label) for record in records})), + ) + ) + return tuple(sorted(summaries, key=lambda summary: summary.row_index)) + + def summary_by_code(self) -> tuple[CodeIssueSummary, ...]: + grouped: dict[str, list[CellIssueRecord]] = {} + for record in self.records(): + grouped.setdefault(record.error.code, []).append(record) + + summaries: list[CodeIssueSummary] = [] + for code, records in grouped.items(): + row_indices = tuple(sorted({record.row_index for record in records})) + summaries.append( + CodeIssueSummary( + code=code, + error_count=len(records), + row_indices=row_indices, + row_numbers_for_humans=tuple(_row_number_for_humans(index) for index in row_indices), + unique_labels=tuple(sorted({str(record.error.unique_label) for record in records})), + ) + ) + return tuple(sorted(summaries, key=lambda summary: summary.code)) + def to_dict(self) -> dict[int, dict[int, list[dict[str, object]]]]: return { int(row_index): { @@ -91,6 +236,11 @@ def to_api_payload(self) -> dict[str, object]: 'error_count': self.error_count, 'items': [record.to_dict() for record in self.records()], 'by_row': self.to_dict(), + 'summary': { + 'by_field': [summary.to_dict() for summary in self.summary_by_field()], + 'by_row': [summary.to_dict() for summary in self.summary_by_row()], + 'by_code': [summary.to_dict() for summary in self.summary_by_code()], + }, } @property @@ -128,6 +278,46 @@ def records(self) -> tuple[RowIssueRecord, ...]: RowIssueRecord(row_index=row_index, error=error) for row_index, errors in self.items() for error in errors ) + def summary_by_row(self) -> tuple[RowIssueSummary, ...]: + summaries: list[RowIssueSummary] = [] + for row_index, errors in self.items(): + cell_errors = [error for error in errors if isinstance(error, ExcelCellError)] + summaries.append( + RowIssueSummary( + row_index=row_index, + row_number_for_humans=_row_number_for_humans(row_index), + error_count=len(errors), + codes=tuple(sorted({error.code for error in errors})), + field_labels=tuple(sorted({str(error.label) for error in cell_errors})), + unique_labels=tuple(sorted({str(error.unique_label) for error in cell_errors})), + ) + ) + return tuple(sorted(summaries, key=lambda summary: summary.row_index)) + + def summary_by_code(self) -> tuple[CodeIssueSummary, ...]: + grouped: dict[str, list[RowIssueRecord]] = {} + for record in self.records(): + grouped.setdefault(record.error.code, []).append(record) + + summaries: list[CodeIssueSummary] = [] + for code, records in grouped.items(): + row_indices = tuple(sorted({record.row_index for record in records})) + unique_labels = tuple( + sorted( + {str(record.error.unique_label) for record in records if isinstance(record.error, ExcelCellError)} + ) + ) + summaries.append( + CodeIssueSummary( + code=code, + error_count=len(records), + row_indices=row_indices, + row_numbers_for_humans=tuple(_row_number_for_humans(index) for index in row_indices), + unique_labels=unique_labels, + ) + ) + return tuple(sorted(summaries, key=lambda summary: summary.code)) + 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()} @@ -136,6 +326,10 @@ def to_api_payload(self) -> dict[str, object]: 'error_count': self.error_count, 'items': [record.to_dict() for record in self.records()], 'by_row': self.to_dict(), + 'summary': { + 'by_row': [summary.to_dict() for summary in self.summary_by_row()], + 'by_code': [summary.to_dict() for summary in self.summary_by_code()], + }, } @staticmethod @@ -213,6 +407,38 @@ class ImportResult(BaseModel): success_count: int = Field(default=0, description='Number of rows imported successfully.') fail_count: int = Field(default=0, description='Number of rows that failed to import.') + @property + def is_success(self) -> bool: + return self.result == ValidateResult.SUCCESS + + @property + def is_header_invalid(self) -> bool: + return self.result == ValidateResult.HEADER_INVALID + + @property + def is_data_invalid(self) -> bool: + return self.result == ValidateResult.DATA_INVALID + + def to_api_payload(self) -> dict[str, object]: + return { + 'result': self.result.value, + 'is_success': self.is_success, + 'is_header_invalid': self.is_header_invalid, + 'is_data_invalid': self.is_data_invalid, + 'summary': { + 'success_count': self.success_count, + 'fail_count': self.fail_count, + 'result_workbook_url': self.url, + }, + 'header_issues': { + 'is_required_missing': self.is_required_missing, + 'missing_required': [str(label) for label in self.missing_required], + 'missing_primary': [str(label) for label in self.missing_primary], + 'unrecognized': [str(label) for label in self.unrecognized], + 'duplicated': [str(label) for label in self.duplicated], + }, + } + @classmethod def from_validate_header_result(cls, result: ValidateHeaderResult) -> 'ImportResult': """Build an import result from a failed header-validation result.""" diff --git a/tests/contracts/test_result_contract.py b/tests/contracts/test_result_contract.py index 14464c7..b27897c 100644 --- a/tests/contracts/test_result_contract.py +++ b/tests/contracts/test_result_contract.py @@ -46,6 +46,60 @@ def test_import_result_returns_success_defaults_for_success_case(self): assert result.missing_primary == [] assert result.unrecognized == [] assert result.duplicated == [] + assert result.is_success is True + assert result.is_header_invalid is False + assert result.is_data_invalid is False + + def test_import_result_to_api_payload_for_success_case(self): + result = ImportResult(result=ValidateResult.SUCCESS, success_count=1, fail_count=0, url='memory://result.xlsx') + + assert result.to_api_payload() == { + 'result': 'SUCCESS', + 'is_success': True, + 'is_header_invalid': False, + 'is_data_invalid': False, + 'summary': { + 'success_count': 1, + 'fail_count': 0, + 'result_workbook_url': 'memory://result.xlsx', + }, + 'header_issues': { + 'is_required_missing': False, + 'missing_required': [], + 'missing_primary': [], + 'unrecognized': [], + 'duplicated': [], + }, + } + + def test_import_result_to_api_payload_for_header_invalid_case(self): + result = ImportResult( + result=ValidateResult.HEADER_INVALID, + is_required_missing=True, + missing_required=[Label('年龄')], + missing_primary=[Label('邮箱')], + unrecognized=[Label('未知列')], + duplicated=[Label('姓名')], + ) + + assert result.to_api_payload() == { + 'result': 'HEADER_INVALID', + 'is_success': False, + 'is_header_invalid': True, + 'is_data_invalid': False, + 'summary': { + 'success_count': 0, + 'fail_count': 0, + 'result_workbook_url': None, + }, + 'header_issues': { + 'is_required_missing': True, + 'missing_required': ['年龄'], + 'missing_primary': ['邮箱'], + 'unrecognized': ['未知列'], + 'duplicated': ['姓名'], + }, + } def test_import_result_from_validate_header_result_rejects_valid_input(self): validate_header = ValidateHeaderResult( diff --git a/tests/integration/test_examples_smoke.py b/tests/integration/test_examples_smoke.py index 073bff9..8f7310f 100644 --- a/tests/integration/test_examples_smoke.py +++ b/tests/integration/test_examples_smoke.py @@ -121,7 +121,7 @@ def test_fastapi_example_source_compiles() -> None: 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'): + for filename in ('models.py', 'schemas.py', 'responses.py', 'storage.py', 'services.py', 'app.py'): source = (package_dir / filename).read_text(encoding='utf-8') compile(source, str(package_dir / filename), 'exec') @@ -153,6 +153,8 @@ def test_fastapi_reference_project_main_runs_when_optional_dependency_is_availab assert 'Success rows: 1' in output assert '/employee-template.xlsx' in output assert '/employee-imports' in output + assert 'Response sections:' in output + assert 'Request tenant: tenant-001' in output @pytest.mark.skipif(importlib.util.find_spec('minio') is None, reason='minio is not installed') @@ -216,8 +218,12 @@ def test_fastapi_example_endpoints_work_when_optional_dependencies_are_available assert import_response.status_code == 200 payload = import_response.json() assert payload['result']['result'] == 'SUCCESS' + assert payload['result']['is_success'] is True assert payload['created_rows'] == 1 assert payload['uploaded_artifacts'] == ['employee-import-result.xlsx'] + assert payload['request']['tenant_id'] == 'tenant-001' + assert payload['cell_errors']['error_count'] == 0 + assert payload['row_errors']['error_count'] == 0 @pytest.mark.skipif( @@ -249,5 +255,9 @@ def test_fastapi_reference_project_endpoints_work_when_optional_dependencies_are assert import_response.status_code == 200 payload = import_response.json() assert payload['result']['result'] == 'SUCCESS' + assert payload['result']['is_success'] is True assert payload['created_rows'] == 1 assert payload['uploaded_artifacts'] == ['employee-import-result.xlsx'] + assert payload['request']['tenant_id'] == 'tenant-001' + assert payload['cell_errors']['error_count'] == 0 + assert payload['row_errors']['error_count'] == 0 diff --git a/tests/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index 916a92d..298367f 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -313,47 +313,57 @@ async def test_import_records_cell_errors_for_invalid_simple_workbook(self): assert alchemy.cell_errors == { 0: { 6: [ExcelCellError(label=Label('出生日期'), message='Enter a date in yyyy format')], - 7: [ExcelCellError(label=Label('邮箱'), message='Enter a valid email address')], - 18: [ExcelCellError(label=Label('网址'), message='Enter a valid URL')], + 7: [ + ExcelCellError(label=Label('邮箱'), message='Enter a valid email address, such as name@example.com') + ], + 18: [ExcelCellError(label=Label('网址'), message='Enter a valid URL, such as https://example.com')], 9: [ ExcelCellError( - label=Label('爱好'), message='Option not found; check the header comment for valid values' + label=Label('爱好'), + message='Select only configured options. Valid values include: 篮球,足球,乒乓球', ) ], 10: [ ExcelCellError( - label=Label('公司'), message='Option not found; check the header comment for valid values' + label=Label('公司'), + message='Select organizations from the configured options. Valid values include: 腾讯,阿里巴巴,百度', ) ], 11: [ ExcelCellError( - label=Label('经理'), message='Option not found; check the header comment for valid values' + label=Label('经理'), + message='Select staff members from the configured options. Valid values include: 张三,李四,王五', ) ], 12: [ ExcelCellError( - label=Label('部门'), message='Option not found; check the header comment for valid values' + label=Label('部门'), + message='Select tree nodes from the configured options. Valid values include: 研发部,市场部,销售部', ) ], 17: [ ExcelCellError( - label=Label('团队'), message='Option not found; check the field comment for valid values' + label=Label('团队'), + message='Select one tree node from the configured options. Valid values include: 研发部,市场部,销售部', ) ], - 13: [ExcelCellError(label=Label('电话'), message='Enter a valid phone number')], + 13: [ExcelCellError(label=Label('电话'), message='Enter a valid phone number, such as 13800138000')], 14: [ ExcelCellError( - label=Label('单选'), message='Option not found; check the field comment for valid values' + label=Label('单选'), + message='Select one of the configured options. Valid values include: 选项1,选项2,选项3', ) ], 15: [ ExcelCellError( - label=Label('老板'), message='Option not found; check the field comment for valid values' + label=Label('老板'), + message='Select one organization from the configured options. Valid values include: 马云,马化腾,李彦宏', ) ], 16: [ ExcelCellError( - label=Label('领导'), message='Option not found; check the field comment for valid values' + label=Label('领导'), + message='Select one staff member from the configured options. Valid values include: 张三,李四,王五', ) ], } diff --git a/tests/unit/codecs/test_codec_logging.py b/tests/unit/codecs/test_codec_logging.py new file mode 100644 index 0000000..c876858 --- /dev/null +++ b/tests/unit/codecs/test_codec_logging.py @@ -0,0 +1,69 @@ +import logging + +import pytest +from pydantic import BaseModel + +from excelalchemy import Boolean, ExcelAlchemy, FieldMeta, Option, OptionId, Radio +from excelalchemy.codecs.base import CODEC_LOGGER_NAME +from excelalchemy.codecs.multi_checkbox import MultiCheckbox +from excelalchemy.config import ImporterConfig + + +def _build_field(model: type[BaseModel], field_index: int = 0): + alchemy = ExcelAlchemy(ImporterConfig(model, locale='en')) + return alchemy.ordered_field_meta[field_index] + + +def test_radio_option_resolution_warning_uses_codec_logger(caplog: pytest.LogCaptureFixture) -> None: + class Importer(BaseModel): + radio: Radio = FieldMeta( + label='Status', + order=1, + options=[ + Option(id=OptionId(1), name='Open'), + Option(id=OptionId(2), name='Closed'), + ], + ) + + field = _build_field(Importer) + + with caplog.at_level(logging.WARNING, logger=CODEC_LOGGER_NAME): + assert field.value_type.deserialize('3', field) == '3' + + assert caplog.records + record = caplog.records[-1] + assert record.name == CODEC_LOGGER_NAME + assert 'Codec Radio could not resolve a configured option for field "Status"' in record.message + assert "returning '3' as-is" in record.message + + +def test_boolean_render_warning_uses_codec_logger(caplog: pytest.LogCaptureFixture) -> None: + class Importer(BaseModel): + is_active: Boolean = FieldMeta(label='Is active', order=1) + + field = _build_field(Importer) + + with caplog.at_level(logging.WARNING, logger=CODEC_LOGGER_NAME): + assert field.value_type.deserialize('maybe', field) == 'maybe' + + assert caplog.records + record = caplog.records[-1] + assert record.name == CODEC_LOGGER_NAME + assert 'Codec Boolean could not format workbook value for field "Is active"' in record.message + assert "Expected '是' or '否'" in record.message + + +def test_multi_checkbox_parse_warning_uses_codec_logger(caplog: pytest.LogCaptureFixture) -> None: + class Importer(BaseModel): + hobbies: MultiCheckbox = FieldMeta(label='Hobbies', order=1) + + field = _build_field(Importer) + + with caplog.at_level(logging.WARNING, logger=CODEC_LOGGER_NAME): + assert field.value_type.serialize(123, field) == 123 + + assert caplog.records + record = caplog.records[-1] + assert record.name == CODEC_LOGGER_NAME + assert 'Codec MultiCheckbox could not parse workbook input for field "Hobbies"' in record.message + assert 'Expected a delimited string or a list of selected values' in record.message diff --git a/tests/unit/test_excel_exceptions.py b/tests/unit/test_excel_exceptions.py index 1de2e14..25c27cc 100644 --- a/tests/unit/test_excel_exceptions.py +++ b/tests/unit/test_excel_exceptions.py @@ -79,8 +79,11 @@ async def test_excel_cell_error_to_dict_includes_coordinate_metadata(self): assert exc.to_dict() == { 'type': 'ExcelCellError', + 'code': 'ExcelCellError', 'message': 'Enter a valid email address', + 'display_message': '【邮箱】Enter a valid email address', 'label': '邮箱', + 'field_label': '邮箱', 'parent_label': '员工', 'unique_label': '员工·邮箱', } @@ -91,12 +94,16 @@ async def test_programmatic_and_config_errors_preserve_detail_payloads(self): assert programmatic.to_dict() == { 'type': 'ProgrammaticError', + 'code': 'ProgrammaticError', 'message': 'Invalid declaration', + 'display_message': 'Invalid declaration', 'detail': {'field': 'email'}, } assert config.to_dict() == { 'type': 'ConfigError', + 'code': 'ConfigError', 'message': 'Missing storage backend', + 'display_message': 'Missing storage backend', 'detail': {'backend': 'minio'}, } assert repr(programmatic) == "ProgrammaticError(message='Invalid declaration', detail={'field': 'email'})" @@ -119,8 +126,11 @@ async def test_cell_error_map_supports_coordinate_access_and_flattening(self): 3: [ { 'type': 'ExcelCellError', + 'code': 'ExcelCellError', 'message': 'Enter a valid email address', + 'display_message': '【邮箱】Enter a valid email address', 'label': '邮箱', + 'field_label': '邮箱', 'parent_label': None, 'unique_label': '邮箱', } @@ -132,13 +142,17 @@ async def test_cell_error_map_supports_coordinate_access_and_flattening(self): 'items': [ { 'type': 'ExcelCellError', + 'code': 'ExcelCellError', 'message': 'Enter a valid email address', + 'display_message': '【邮箱】Enter a valid email address', 'label': '邮箱', + 'field_label': '邮箱', 'parent_label': None, 'unique_label': '邮箱', 'row_index': 0, + 'row_number_for_humans': 1, 'column_index': 3, - 'display_message': '【邮箱】Enter a valid email address', + 'column_number_for_humans': 4, } ], 'by_row': { @@ -146,16 +160,56 @@ async def test_cell_error_map_supports_coordinate_access_and_flattening(self): 3: [ { 'type': 'ExcelCellError', + 'code': 'ExcelCellError', 'message': 'Enter a valid email address', + 'display_message': '【邮箱】Enter a valid email address', 'label': '邮箱', + 'field_label': '邮箱', 'parent_label': None, 'unique_label': '邮箱', } ] } }, + 'summary': { + 'by_field': [ + { + 'field_label': '邮箱', + 'parent_label': None, + 'unique_label': '邮箱', + 'error_count': 1, + 'row_indices': [0], + 'row_numbers_for_humans': [1], + 'codes': ['ExcelCellError'], + } + ], + 'by_row': [ + { + 'row_index': 0, + 'row_number_for_humans': 1, + 'error_count': 1, + 'codes': ['ExcelCellError'], + 'field_labels': ['邮箱'], + 'unique_labels': ['邮箱'], + } + ], + 'by_code': [ + { + 'code': 'ExcelCellError', + 'error_count': 1, + 'row_indices': [0], + 'row_numbers_for_humans': [1], + 'unique_labels': ['邮箱'], + } + ], + }, } + field_summary = error_map.summary_by_field() + assert field_summary[0].to_dict()['unique_label'] == '邮箱' + assert error_map.summary_by_code()[0].code == 'ExcelCellError' + assert error_map.summary_by_row()[0].row_number_for_humans == 1 + async def test_row_issue_map_supports_row_access_and_numbered_messages(self): issue_map = RowIssueMap() cell_error = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') @@ -180,14 +234,19 @@ async def test_row_issue_map_supports_row_access_and_numbered_messages(self): 0: [ { 'type': 'ExcelCellError', + 'code': 'ExcelCellError', 'message': 'Enter a valid email address', + 'display_message': '【邮箱】Enter a valid email address', 'label': '邮箱', + 'field_label': '邮箱', 'parent_label': None, 'unique_label': '邮箱', }, { 'type': 'ExcelRowError', + 'code': 'ExcelRowError', 'message': 'Combination invalid', + 'display_message': 'Combination invalid', }, ] } @@ -196,33 +255,76 @@ async def test_row_issue_map_supports_row_access_and_numbered_messages(self): 'items': [ { 'type': 'ExcelCellError', + 'code': 'ExcelCellError', 'message': 'Enter a valid email address', + 'display_message': '【邮箱】Enter a valid email address', 'label': '邮箱', + 'field_label': '邮箱', 'parent_label': None, 'unique_label': '邮箱', 'row_index': 0, - 'display_message': '【邮箱】Enter a valid email address', + 'row_number_for_humans': 1, }, { 'type': 'ExcelRowError', + 'code': 'ExcelRowError', 'message': 'Combination invalid', - 'row_index': 0, 'display_message': 'Combination invalid', + 'row_index': 0, + 'row_number_for_humans': 1, + 'field_label': None, + 'parent_label': None, + 'unique_label': None, }, ], 'by_row': { 0: [ { 'type': 'ExcelCellError', + 'code': 'ExcelCellError', 'message': 'Enter a valid email address', + 'display_message': '【邮箱】Enter a valid email address', 'label': '邮箱', + 'field_label': '邮箱', 'parent_label': None, 'unique_label': '邮箱', }, { 'type': 'ExcelRowError', + 'code': 'ExcelRowError', 'message': 'Combination invalid', + 'display_message': 'Combination invalid', }, ] }, + 'summary': { + 'by_row': [ + { + 'row_index': 0, + 'row_number_for_humans': 1, + 'error_count': 2, + 'codes': ['ExcelCellError', 'ExcelRowError'], + 'field_labels': ['邮箱'], + 'unique_labels': ['邮箱'], + } + ], + 'by_code': [ + { + 'code': 'ExcelCellError', + 'error_count': 1, + 'row_indices': [0], + 'row_numbers_for_humans': [1], + 'unique_labels': ['邮箱'], + }, + { + 'code': 'ExcelRowError', + 'error_count': 1, + 'row_indices': [0], + 'row_numbers_for_humans': [1], + 'unique_labels': [], + }, + ], + }, } + assert issue_map.summary_by_row()[0].error_count == 2 + assert [summary.code for summary in issue_map.summary_by_code()] == ['ExcelCellError', 'ExcelRowError']