From 9c71a632194ebc021e9d87d590b2b58f610eba69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sun, 5 Apr 2026 09:06:45 +0800 Subject: [PATCH] feat(v2.2.8): Documentation guidance --- .github/workflows/ci.yml | 1 + .github/workflows/python-publish.yml | 4 + CHANGELOG.md | 38 +++ README-pypi.md | 4 +- README.md | 4 +- README_cn.md | 4 +- docs/api-response-cookbook.md | 6 + docs/getting-started.md | 2 + docs/integration-roadmap.md | 95 +++++++ docs/locale.md | 29 ++- docs/public-api.md | 2 + docs/releases/2.2.8.md | 65 +++++ docs/result-objects.md | 39 +++ examples/fastapi_reference/README.md | 181 ++++++++------ examples/fastapi_reference/app.py | 34 ++- examples/fastapi_reference/presenters.py | 48 ++++ examples/fastapi_reference/schemas.py | 32 ++- files/example-outputs/fastapi-reference.txt | 3 +- .../import-failure-api-payload.json | 231 ++++++++++++++++++ scripts/generate_example_output_assets.py | 37 ++- scripts/smoke_api_payload_snapshot.py | 58 +++++ scripts/smoke_docs_assets.py | 6 + scripts/smoke_examples.py | 26 +- src/excelalchemy/__init__.py | 2 +- src/excelalchemy/_primitives/diagnostics.py | 50 ++++ src/excelalchemy/core/alchemy.py | 19 +- src/excelalchemy/metadata.py | 19 +- src/excelalchemy/results.py | 122 +++++++++ tests/contracts/test_result_contract.py | 17 ++ tests/integration/test_examples_smoke.py | 33 +-- tests/unit/test_diagnostics_logging.py | 83 +++++++ tests/unit/test_excel_exceptions.py | 41 ++++ 32 files changed, 1201 insertions(+), 134 deletions(-) create mode 100644 docs/integration-roadmap.md create mode 100644 docs/releases/2.2.8.md create mode 100644 examples/fastapi_reference/presenters.py create mode 100644 files/example-outputs/import-failure-api-payload.json create mode 100644 scripts/smoke_api_payload_snapshot.py create mode 100644 src/excelalchemy/_primitives/diagnostics.py create mode 100644 tests/unit/test_diagnostics_logging.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 195179d..73faba1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,7 @@ jobs: uv run python scripts/smoke_examples.py uv run python scripts/generate_example_output_assets.py uv run python scripts/smoke_docs_assets.py + uv run python scripts/smoke_api_payload_snapshot.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 9d3f6c7..6df90b2 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -62,6 +62,8 @@ jobs: .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 + .pkg-smoke-wheel/bin/python scripts/smoke_api_payload_snapshot.py + .pkg-smoke-wheel/bin/python -m examples.fastapi_reference.app - name: Smoke test source distribution installation run: | @@ -73,6 +75,8 @@ jobs: .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 + .pkg-smoke-sdist/bin/python scripts/smoke_api_payload_snapshot.py + .pkg-smoke-sdist/bin/python -m examples.fastapi_reference.app - name: Set artifact metadata id: artifact-meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 980d07a..e3e2ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ 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.8] - 2026-04-05 + +This release continues the stable 2.x line with a clearer integration reading +path, stronger API payload smoke verification, and a more production-shaped +reference-app release flow. + +### Added + +- Added `docs/integration-roadmap.md` to give new adopters a role-based reading + path for first-time integration, backend APIs, frontend error rendering, and + migration work +- Added `scripts/smoke_api_payload_snapshot.py` and a generated + `import-failure-api-payload.json` snapshot under `files/example-outputs/` + +### Changed + +- Expanded release smoke so wheel and source-distribution verification now + compare a stable import-failure API payload snapshot instead of only checking + ad hoc fields +- Extended release verification so installed-package smoke runs the + `examples.fastapi_reference.app` demo directly after dependency installation +- Cross-linked the new integration roadmap from onboarding, API-facing, and + PyPI-facing docs + +### 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 + +### Release Summary + +- new users now have a clearer “what to read first” path +- release smoke now checks a stable import-failure payload shape +- installed-package verification exercises the FastAPI reference app more + directly + ## [2.2.7] - 2026-04-04 This release continues the stable 2.x line with stronger API-facing result diff --git a/README-pypi.md b/README-pypi.md index b20acfd..127b498 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.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. +The current stable release is `2.2.8`, which continues the 2.x line with a clearer integration roadmap, stronger import-failure payload smoke verification, and more direct install-time validation of the FastAPI reference app. -[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) +[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) · [Integration Roadmap](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/integration-roadmap.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 4c81bc5..e2df319 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) · [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) +[中文 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) · [Integration Roadmap](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/integration-roadmap.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.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. +The current stable release is `2.2.8`, which continues the ExcelAlchemy 2.x line with a clearer integration roadmap, stronger import-failure payload smoke verification, and more direct install-time validation of the FastAPI reference app. ## At a Glance diff --git a/README_cn.md b/README_cn.md index f78623b..9c07499 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,11 +1,11 @@ # ExcelAlchemy -[English README](./README.md) · [项目说明](./ABOUT.md) · [快速开始](./docs/getting-started.md) · [结果对象](./docs/result-objects.md) · [架构文档](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [迁移说明](./MIGRATIONS.md) +[English README](./README.md) · [项目说明](./ABOUT.md) · [快速开始](./docs/getting-started.md) · [接入路线图](./docs/integration-roadmap.md) · [结果对象](./docs/result-objects.md) · [架构文档](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [迁移说明](./MIGRATIONS.md) ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 -当前稳定发布版本是 `2.2.7`,它在稳定的 ExcelAlchemy 2.x 线上继续加强了 API 结果载荷、FastAPI 参考应用、安装后真实可用的 release smoke 验证,以及更一致的 codec 诊断信息。 +当前稳定发布版本是 `2.2.8`,它在稳定的 ExcelAlchemy 2.x 线上继续加强了接入路线图、失败导入 API 载荷的 smoke 校验,以及对 FastAPI 参考应用的安装后真实可用验证。 你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 diff --git a/docs/api-response-cookbook.md b/docs/api-response-cookbook.md index 4747768..77a0466 100644 --- a/docs/api-response-cookbook.md +++ b/docs/api-response-cookbook.md @@ -37,6 +37,12 @@ Within each error item: - use `message` for logs or plain-text APIs - use `display_message` when you want ready-to-render UI text +Developer diagnostics are intentionally separate from these payload fields. +Application logs use named loggers such as `excelalchemy.codecs`, +`excelalchemy.runtime`, and `excelalchemy.metadata`; API responses should rely +on `code`, `message_key`, `message`, and `display_message` instead of raw log +output. + ## 1. Success Response Use this when the import completed without header or data failures. diff --git a/docs/getting-started.md b/docs/getting-started.md index 8a74a2f..6f7b711 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,6 +9,8 @@ If you want the full public surface and compatibility boundaries, see If you want to understand the result objects and how to surface them through an API, see [`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md). +If you want a role-based reading path, see +[`docs/integration-roadmap.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/integration-roadmap.md). ## 1. Install diff --git a/docs/integration-roadmap.md b/docs/integration-roadmap.md new file mode 100644 index 0000000..a2f2696 --- /dev/null +++ b/docs/integration-roadmap.md @@ -0,0 +1,95 @@ +# Integration Roadmap + +This page helps you choose a reading path through the ExcelAlchemy docs based on +what you are trying to build. + +If you want the fastest general entry point, start with +[`docs/getting-started.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md). +If you want screenshots and captured workflow output first, see +[`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md). + +## 1. If You Are Integrating ExcelAlchemy For The First Time + +Recommended order: + +1. [`docs/getting-started.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md) +2. [`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md) +3. [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md) +4. [`docs/examples-showcase.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/examples-showcase.md) + +Focus on these concepts first: + +- stable import paths +- schema declaration style +- `storage=...` as the recommended backend integration path +- the difference between import, create-or-update, and export workflows + +## 2. If You Are Building A Backend API + +Recommended order: + +1. [`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md) +2. [`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md) +3. [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) +4. [`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md) + +Focus on these objects: + +- `ImportResult` +- `CellErrorMap` +- `RowIssueMap` + +Use these payload helpers directly in your API layer: + +- `ImportResult.to_api_payload()` +- `CellErrorMap.to_api_payload()` +- `RowIssueMap.to_api_payload()` + +## 3. If You Are Building Frontend Error Displays + +Recommended order: + +1. [`docs/result-objects.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/result-objects.md) +2. [`docs/api-response-cookbook.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/api-response-cookbook.md) +3. [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) + +Focus on these payload fields: + +- `code` +- `message_key` +- `message` +- `display_message` + +And these grouped or summary helpers: + +- `summary.by_field` +- `summary.by_row` +- `summary.by_code` +- `facets.field_labels` +- `facets.codes` +- `facets.row_numbers_for_humans` +- `grouped.messages_by_field` +- `grouped.messages_by_row` +- `grouped.messages_by_code` + +## 4. If You Want Copyable Reference Code + +Start here: + +- [`examples/employee_import_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/employee_import_workflow.py) +- [`examples/create_or_update_import.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/create_or_update_import.py) +- [`examples/export_workflow.py`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/export_workflow.py) +- [`examples/fastapi_reference/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/fastapi_reference/README.md) + +## 5. If You Need Migration And Compatibility Context + +Read: + +1. [`MIGRATIONS.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/MIGRATIONS.md) +2. [`docs/public-api.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/public-api.md) + +This is the best route when you need to answer: + +- which imports are stable +- which imports are compatibility-only +- how the 2.x line treats legacy Minio configuration diff --git a/docs/locale.md b/docs/locale.md index 58c3cbf..3b3af5b 100644 --- a/docs/locale.md +++ b/docs/locale.md @@ -2,12 +2,27 @@ ## Scope -ExcelAlchemy currently distinguishes between two kinds of language output: +ExcelAlchemy currently distinguishes between three kinds of language output: +- developer diagnostics, intended for logs and runtime troubleshooting - runtime messages, intended for Python developers and integrators - workbook display text, intended for spreadsheet users -These two layers do not currently share the same locale policy. +These layers do not currently share the same locale policy. + +## Developer Diagnostics Policy + +- Developer diagnostics are emitted through named loggers such as: + - `excelalchemy.codecs` + - `excelalchemy.runtime` + - `excelalchemy.metadata` +- Stability policy: diagnostics are intentionally standardized in English for the + 2.x line +- Intended audience: backend developers, operators, and maintainers + +Developer diagnostics are not the same surface as API payloads or workbook-facing +messages. They are designed for logs, traces, and troubleshooting rather than +end-user rendering. ## Runtime Message Policy @@ -35,6 +50,7 @@ Workbook display locale affects user-facing spreadsheet text such as: ## Fallback Rules +- Developer diagnostics fall back to English - Runtime messages fall back to the runtime default locale: `en` - Workbook display messages fall back to the workbook display default locale: `zh-CN` @@ -67,7 +83,8 @@ alchemy_en = ExcelAlchemy(ImporterConfig(ImporterModel, creator=create_func, loc The i18n roadmap remains intentionally incremental: -1. keep runtime messages consistently English -2. keep workbook display locale explicit and stable -3. add new workbook locales additively -4. only expand runtime locale support when there is a clear maintenance plan +1. keep developer diagnostics consistently English +2. keep runtime messages consistently English +3. keep workbook display locale explicit and stable +4. add new workbook locales additively +5. only expand runtime locale support when there is a clear maintenance plan diff --git a/docs/public-api.md b/docs/public-api.md index 109fdc0..2f33ad3 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -6,6 +6,8 @@ ones should be treated as internal implementation details. If you want the quickest path into the library, start with [`docs/getting-started.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/getting-started.md). +If you want a role-based reading path, see +[`docs/integration-roadmap.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/docs/integration-roadmap.md). If you want concrete repository examples, see [`examples/README.md`](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/examples/README.md) and diff --git a/docs/releases/2.2.8.md b/docs/releases/2.2.8.md new file mode 100644 index 0000000..eba8e14 --- /dev/null +++ b/docs/releases/2.2.8.md @@ -0,0 +1,65 @@ +# 2.2.8 Release Notes and Checklist + +This document records the final release positioning and verification checklist +for the `2.2.8` release on top of the stable 2.x line. + +## Purpose + +- record the final stable 2.x integration-polish release package for ExcelAlchemy +- make the documentation set easier to navigate for first-time adopters, + backend API builders, and frontend error consumers +- harden release smoke verification around a stable import-failure API payload + snapshot +- exercise the FastAPI reference app more directly during install-time release + checks + +## Release Positioning + +`2.2.8` should be presented as a stable 2.x documentation-and-release-hardening +release: + +- the public import and export workflow API stays stable +- new adopters now have a clearer role-based reading path +- release verification now checks a stable import-failure payload shape +- installed-package smoke exercises the FastAPI reference demo more directly + +## Before Tagging + +1. Review the `2.2.8` section in `CHANGELOG.md`. +2. Confirm the new documentation entry points: + - `docs/integration-roadmap.md` + - `docs/getting-started.md` + - `docs/public-api.md` +3. Confirm the payload snapshot asset: + - `files/example-outputs/import-failure-api-payload.json` +4. Confirm the release smoke scripts: + - `scripts/generate_example_output_assets.py` + - `scripts/smoke_api_payload_snapshot.py` + - `scripts/smoke_docs_assets.py` + - `scripts/smoke_examples.py` +5. Confirm the FastAPI reference demo still runs directly: + - `python -m examples.fastapi_reference.app` + +## Local Verification + +Run these commands from the repository root: + +```bash +uv run ruff check . +uv run pyright +uv run pytest -q +./.venv/bin/python scripts/generate_example_output_assets.py +./.venv/bin/python scripts/smoke_api_payload_snapshot.py +./.venv/bin/python scripts/smoke_docs_assets.py +./.venv/bin/uv run --with fastapi --with httpx --with python-multipart python -m examples.fastapi_reference.app +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.8`. +4. Use the short release notes summary from the release PR or release draft. diff --git a/docs/result-objects.md b/docs/result-objects.md index 03ead8d..70abdad 100644 --- a/docs/result-objects.md +++ b/docs/result-objects.md @@ -49,6 +49,10 @@ Recommended usage: - use `message` for logs, plain APIs, or analytics - use `display_message` for UI lists, toasts, and workbook-adjacent feedback +Developer diagnostics are a separate layer. Warning and info logs are emitted +through named loggers such as `excelalchemy.codecs`, `excelalchemy.runtime`, and +`excelalchemy.metadata`, and should not be treated as API payload text. + ## `ImportResult` `ImportResult` is the high-level summary of one import run. @@ -121,6 +125,9 @@ Useful helpers: - `at(row_index, column_index)` - `messages_at(row_index, column_index)` +- `field_labels()` +- `codes()` +- `row_numbers_for_humans()` - `flatten()` - `records()` - `summary_by_field()` @@ -133,6 +140,8 @@ Example: ```python payload = alchemy.cell_error_map.to_api_payload() +field_labels = alchemy.cell_error_map.field_labels() +codes = alchemy.cell_error_map.codes() ``` Shape: @@ -186,6 +195,31 @@ Shape: } ] }, + "facets": { + "field_labels": ["Email"], + "parent_labels": [], + "unique_labels": ["Email"], + "codes": ["valid_email_required"], + "row_numbers_for_humans": [1], + "column_numbers_for_humans": [2] + }, + "grouped": { + "messages_by_field": { + "Email": [ + "【Email】Enter a valid email address, such as name@example.com" + ] + }, + "messages_by_row": { + "0": [ + "【Email】Enter a valid email address, such as name@example.com" + ] + }, + "messages_by_code": { + "valid_email_required": [ + "【Email】Enter a valid email address, such as name@example.com" + ] + } + }, "by_row": { "0": { "1": [ @@ -205,6 +239,7 @@ Use this when you need: - 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 +- direct field/code/row facets without doing a second client-side pass ## `RowIssueMap` @@ -222,6 +257,9 @@ Useful helpers: - `at(row_index)` - `messages_for_row(row_index)` - `numbered_messages_for_row(row_index)` +- `field_labels()` +- `codes()` +- `row_numbers_for_humans()` - `flatten()` - `records()` - `summary_by_row()` @@ -240,6 +278,7 @@ Use this when you need: - one-line row summaries in an admin UI - numbered failure lists for APIs - a simpler summary than cell coordinates alone +- direct grouped row/code message collections for a frontend table or sidebar ## Recommended API Response Pattern diff --git a/examples/fastapi_reference/README.md b/examples/fastapi_reference/README.md index 0fe5423..b397110 100644 --- a/examples/fastapi_reference/README.md +++ b/examples/fastapi_reference/README.md @@ -12,9 +12,12 @@ reference project, see - `models.py` Defines workbook schema declarations. - `schemas.py` - Defines request and response schemas for the HTTP layer. + Defines request schemas, payload models, and success/error response envelopes + for the HTTP layer. - `responses.py` - Builds stable structured API payloads from ExcelAlchemy result objects. + Builds stable import payloads from ExcelAlchemy result objects. +- `presenters.py` + Wraps payloads in HTTP-facing success/error envelopes. - `storage.py` Defines a request-scoped in-memory `ExcelStorage` implementation. - `services.py` @@ -29,11 +32,13 @@ HTTP request -> app.py route registration, form parsing, and response_model wiring -> schemas.py - request and response contracts + request, payload, and envelope contracts -> services.py template generation and import workflow orchestration -> responses.py - structured payload assembly for API consumers + structured import payload assembly + -> presenters.py + HTTP-facing success and error envelopes -> storage.py upload fixture storage and result workbook upload handling -> models.py @@ -50,8 +55,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 +- explicit request, payload, and error-response schemas +- structured API response building with a stable envelope - 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 @@ -105,47 +110,61 @@ Example JSON response: ```json { - "result": { - "result": "SUCCESS", - "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" + "ok": true, + "data": { + "result": { + "result": "SUCCESS", + "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": [] + } }, - "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": [] + "cell_errors": { + "error_count": 0, + "items": [], + "by_row": {}, + "summary": { + "by_field": [], + "by_row": [], + "by_code": [] + } + }, + "row_errors": { + "error_count": 0, + "items": [], + "by_row": {}, + "facets": { + "field_labels": [], + "parent_labels": [], + "unique_labels": [], + "codes": [], + "row_numbers_for_humans": [] + }, + "grouped": { + "messages_by_row": {}, + "messages_by_code": {} + }, + "summary": { + "by_row": [], + "by_code": [] + } + }, + "created_rows": 1, + "uploaded_artifacts": [], + "request": { + "tenant_id": "tenant-001" } - }, - "created_rows": 1, - "uploaded_artifacts": ["employee-import-result.xlsx"], - "request": { - "tenant_id": "tenant-001" } } ``` @@ -159,37 +178,61 @@ structured result payload. Application code can then read: - cell-level frontend payloads from `cell_errors` - row-level frontend payloads from `row_errors` +In the reference app, those values live under `data`, while `ok` tells the +client whether the route returned a success or an API-layer error envelope. + 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": [ + "ok": true, + "data": { + "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", - "error_count": 1 + "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" } - ] + ], + "facets": { + "field_labels": ["Email"] + } + }, + "row_errors": { + "error_count": 1, + "summary": { + "by_code": [ + { + "code": "valid_email_required", + "error_count": 1 + } + ] + } + } + } +} +``` + +Example API-layer error response: + +```json +{ + "ok": false, + "error": { + "code": "file_required", + "message": "An Excel file is required.", + "detail": { + "field": "file" } } } diff --git a/examples/fastapi_reference/app.py b/examples/fastapi_reference/app.py index 90ae6e1..6b11b48 100644 --- a/examples/fastapi_reference/app.py +++ b/examples/fastapi_reference/app.py @@ -17,14 +17,22 @@ from io import BytesIO -from examples.fastapi_reference.schemas import EmployeeImportRequest, EmployeeImportResponse +from examples.fastapi_reference.presenters import ( + build_import_success_envelope, + build_missing_file_error_envelope, +) +from examples.fastapi_reference.schemas import ( + EmployeeImportErrorEnvelope, + EmployeeImportRequest, + EmployeeImportSuccessEnvelope, +) 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, Form, HTTPException - from fastapi.responses import StreamingResponse + from fastapi import FastAPI, Form + from fastapi.responses import JSONResponse, StreamingResponse app = FastAPI(title='ExcelAlchemy Reference FastAPI App') import_service = service or EmployeeImportService(RequestScopedStorage()) @@ -37,24 +45,29 @@ async def download_template() -> StreamingResponse: headers={'Content-Disposition': 'attachment; filename=employee-template.xlsx'}, ) - @app.post('/employee-imports', response_model=EmployeeImportResponse) + @app.post('/employee-imports', response_model=EmployeeImportSuccessEnvelope | EmployeeImportErrorEnvelope) async def import_employees( - file: UploadFile, + file: UploadFile | None = None, 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( + ) -> EmployeeImportSuccessEnvelope | JSONResponse: + if file is None or not file.filename: + return JSONResponse( + status_code=400, + content=build_missing_file_error_envelope().model_dump(mode='json'), + ) + response_payload = await import_service.import_workbook( file.filename, await file.read(), request=EmployeeImportRequest(tenant_id=tenant_id), ) + return build_import_success_envelope(response_payload) return app def main() -> None: response_payload = run_reference_demo() + envelope = build_import_success_envelope(response_payload) route_paths = ['/employee-imports', '/employee-template.xlsx'] print('FastAPI reference project completed') @@ -64,7 +77,8 @@ def main() -> None: 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'Envelope sections: {sorted(envelope.model_dump().keys())}') + print(f'Data 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())}") diff --git a/examples/fastapi_reference/presenters.py b/examples/fastapi_reference/presenters.py new file mode 100644 index 0000000..bb64d12 --- /dev/null +++ b/examples/fastapi_reference/presenters.py @@ -0,0 +1,48 @@ +"""Presentation helpers for the FastAPI reference project.""" + +from examples.fastapi_reference.schemas import ( + ApiErrorDetail, + EmployeeImportErrorEnvelope, + EmployeeImportResponse, + EmployeeImportSuccessEnvelope, +) + + +def build_import_success_envelope(response: EmployeeImportResponse) -> EmployeeImportSuccessEnvelope: + """Wrap a successful import payload in a stable HTTP response envelope.""" + + return EmployeeImportSuccessEnvelope(data=response) + + +def build_import_error_envelope( + *, + code: str, + message: str, + detail: dict[str, object] | None = None, +) -> EmployeeImportErrorEnvelope: + """Wrap an API-layer error in a stable HTTP response envelope.""" + + return EmployeeImportErrorEnvelope( + error=ApiErrorDetail( + code=code, + message=message, + detail=detail or {}, + ) + ) + + +def build_missing_file_error_envelope() -> EmployeeImportErrorEnvelope: + """Return the reference error payload for missing upload files.""" + + return build_import_error_envelope( + code='file_required', + message='An Excel file is required.', + detail={'field': 'file'}, + ) + + +__all__ = [ + 'build_import_error_envelope', + 'build_import_success_envelope', + 'build_missing_file_error_envelope', +] diff --git a/examples/fastapi_reference/schemas.py b/examples/fastapi_reference/schemas.py index 2e130cb..a758d80 100644 --- a/examples/fastapi_reference/schemas.py +++ b/examples/fastapi_reference/schemas.py @@ -1,5 +1,7 @@ """Request and response schemas for the FastAPI reference project.""" +from typing import Literal + from pydantic import BaseModel, Field @@ -42,4 +44,32 @@ def result_fail_count(self) -> int: return value -__all__ = ['EmployeeImportRequest', 'EmployeeImportResponse'] +class ApiErrorDetail(BaseModel): + """Structured API error payload for the HTTP layer.""" + + code: str = Field(description='Machine-readable API error code.') + message: str = Field(description='Human-readable API error message.') + detail: dict[str, object] = Field(default_factory=dict, description='Optional structured error detail.') + + +class EmployeeImportSuccessEnvelope(BaseModel): + """Success response envelope for workbook import endpoints.""" + + ok: Literal[True] = Field(default=True, description='Whether the request succeeded.') + data: EmployeeImportResponse = Field(description='Structured import payload.') + + +class EmployeeImportErrorEnvelope(BaseModel): + """Error response envelope for workbook import endpoints.""" + + ok: Literal[False] = Field(default=False, description='Whether the request failed.') + error: ApiErrorDetail = Field(description='Structured API error payload.') + + +__all__ = [ + 'ApiErrorDetail', + 'EmployeeImportErrorEnvelope', + 'EmployeeImportRequest', + 'EmployeeImportResponse', + 'EmployeeImportSuccessEnvelope', +] diff --git a/files/example-outputs/fastapi-reference.txt b/files/example-outputs/fastapi-reference.txt index 488c655..b12b511 100644 --- a/files/example-outputs/fastapi-reference.txt +++ b/files/example-outputs/fastapi-reference.txt @@ -5,7 +5,8 @@ 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'] +Envelope sections: ['data', 'ok'] +Data 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/import-failure-api-payload.json b/files/example-outputs/import-failure-api-payload.json new file mode 100644 index 0000000..ae4531b --- /dev/null +++ b/files/example-outputs/import-failure-api-payload.json @@ -0,0 +1,231 @@ +{ + "cell_errors": { + "by_row": { + "0": { + "3": [ + { + "code": "ExcelCellError", + "display_message": "\u3010Age\u3011Invalid input; enter a number.", + "field_label": "Age", + "label": "Age", + "message": "Invalid input; enter a number.", + "parent_label": null, + "type": "ExcelCellError", + "unique_label": "Age" + } + ] + } + }, + "error_count": 1, + "facets": { + "codes": [ + "ExcelCellError" + ], + "column_numbers_for_humans": [ + 4 + ], + "field_labels": [ + "Age" + ], + "parent_labels": [], + "row_numbers_for_humans": [ + 1 + ], + "unique_labels": [ + "Age" + ] + }, + "grouped": { + "messages_by_code": { + "ExcelCellError": [ + "\u3010Age\u3011Invalid input; enter a number." + ] + }, + "messages_by_field": { + "Age": [ + "\u3010Age\u3011Invalid input; enter a number." + ] + }, + "messages_by_row": { + "0": [ + "\u3010Age\u3011Invalid input; enter a number." + ] + } + }, + "items": [ + { + "code": "ExcelCellError", + "column_index": 3, + "column_number_for_humans": 4, + "display_message": "\u3010Age\u3011Invalid input; enter a number.", + "field_label": "Age", + "label": "Age", + "message": "Invalid input; enter a number.", + "parent_label": null, + "row_index": 0, + "row_number_for_humans": 1, + "type": "ExcelCellError", + "unique_label": "Age" + } + ], + "summary": { + "by_code": [ + { + "code": "ExcelCellError", + "error_count": 1, + "row_indices": [ + 0 + ], + "row_numbers_for_humans": [ + 1 + ], + "unique_labels": [ + "Age" + ] + } + ], + "by_field": [ + { + "codes": [ + "ExcelCellError" + ], + "error_count": 1, + "field_label": "Age", + "parent_label": null, + "row_indices": [ + 0 + ], + "row_numbers_for_humans": [ + 1 + ], + "unique_label": "Age" + } + ], + "by_row": [ + { + "codes": [ + "ExcelCellError" + ], + "error_count": 1, + "field_labels": [ + "Age" + ], + "row_index": 0, + "row_number_for_humans": 1, + "unique_labels": [ + "Age" + ] + } + ] + } + }, + "result": { + "header_issues": { + "duplicated": [], + "is_required_missing": false, + "missing_primary": [], + "missing_required": [], + "unrecognized": [] + }, + "is_data_invalid": true, + "is_header_invalid": false, + "is_success": false, + "result": "DATA_INVALID", + "summary": { + "fail_count": 1, + "result_workbook_url": "memory://smoke-invalid-result.xlsx", + "success_count": 0 + } + }, + "row_errors": { + "by_row": { + "0": [ + { + "code": "ExcelCellError", + "display_message": "\u3010Age\u3011Invalid input; enter a number.", + "field_label": "Age", + "label": "Age", + "message": "Invalid input; enter a number.", + "parent_label": null, + "type": "ExcelCellError", + "unique_label": "Age" + } + ] + }, + "error_count": 1, + "facets": { + "codes": [ + "ExcelCellError" + ], + "field_labels": [ + "Age" + ], + "parent_labels": [], + "row_numbers_for_humans": [ + 1 + ], + "unique_labels": [ + "Age" + ] + }, + "grouped": { + "messages_by_code": { + "ExcelCellError": [ + "\u3010Age\u3011Invalid input; enter a number." + ] + }, + "messages_by_row": { + "0": [ + "\u3010Age\u3011Invalid input; enter a number." + ] + } + }, + "items": [ + { + "code": "ExcelCellError", + "display_message": "\u3010Age\u3011Invalid input; enter a number.", + "field_label": "Age", + "label": "Age", + "message": "Invalid input; enter a number.", + "parent_label": null, + "row_index": 0, + "row_number_for_humans": 1, + "type": "ExcelCellError", + "unique_label": "Age" + } + ], + "summary": { + "by_code": [ + { + "code": "ExcelCellError", + "error_count": 1, + "row_indices": [ + 0 + ], + "row_numbers_for_humans": [ + 1 + ], + "unique_labels": [ + "Age" + ] + } + ], + "by_row": [ + { + "codes": [ + "ExcelCellError" + ], + "error_count": 1, + "field_labels": [ + "Age" + ], + "row_index": 0, + "row_number_for_humans": 1, + "unique_labels": [ + "Age" + ] + } + ] + } + } +} diff --git a/scripts/generate_example_output_assets.py b/scripts/generate_example_output_assets.py index 9f4aa49..30bb5b3 100644 --- a/scripts/generate_example_output_assets.py +++ b/scripts/generate_example_output_assets.py @@ -2,10 +2,12 @@ from __future__ import annotations +import asyncio import contextlib import importlib import importlib.util import io +import json import sys from pathlib import Path @@ -16,7 +18,6 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) - EXAMPLE_ASSETS: dict[str, str] = { 'annotated_schema.py': 'annotated-schema.txt', 'employee_import_workflow.py': 'employee-import-workflow.txt', @@ -59,6 +60,35 @@ def _run_module_example(module_name: str) -> str: return buffer.getvalue().strip() +async def _build_import_failure_api_payload() -> dict[str, object]: + from scripts.smoke_package import ( + InMemorySmokeStorage, + SmokeImporter, + _build_invalid_import_fixture, + _create_employee, + ) + + from excelalchemy import ExcelAlchemy, ImporterConfig + + storage = InMemorySmokeStorage() + importer = ExcelAlchemy( + ImporterConfig.for_create( + SmokeImporter, + creator=_create_employee, + storage=storage, + locale='en', + ) + ) + template = importer.download_template_artifact(filename='smoke-template.xlsx') + _build_invalid_import_fixture(storage, template.as_bytes()) + result = await importer.import_data('smoke-invalid-input.xlsx', 'smoke-invalid-result.xlsx') + return { + 'result': result.to_api_payload(), + 'cell_errors': importer.cell_error_map.to_api_payload(), + 'row_errors': importer.row_error_map.to_api_payload(), + } + + def main() -> None: for filename, output_name in EXAMPLE_ASSETS.items(): output = _run_example(filename) @@ -72,6 +102,11 @@ def main() -> None: output_path.write_text(f'{output}\n', encoding='utf-8') print(f'Generated example output: {output_path}') + payload = asyncio.run(_build_import_failure_api_payload()) + payload_path = OUTPUT_DIR / 'import-failure-api-payload.json' + payload_path.write_text(f'{json.dumps(payload, indent=2, sort_keys=True)}\n', encoding='utf-8') + print(f'Generated example output: {payload_path}') + if __name__ == '__main__': main() diff --git a/scripts/smoke_api_payload_snapshot.py b/scripts/smoke_api_payload_snapshot.py new file mode 100644 index 0000000..0aace68 --- /dev/null +++ b/scripts/smoke_api_payload_snapshot.py @@ -0,0 +1,58 @@ +"""Verify a stable import-failure API payload snapshot for release smoke.""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SNAPSHOT_PATH = ROOT / 'files' / 'example-outputs' / 'import-failure-api-payload.json' +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +async def _build_snapshot_payload() -> dict[str, object]: + from scripts.smoke_package import ( + InMemorySmokeStorage, + SmokeImporter, + _build_invalid_import_fixture, + _create_employee, + ) + + from excelalchemy import ExcelAlchemy, ImporterConfig + + storage = InMemorySmokeStorage() + importer = ExcelAlchemy( + ImporterConfig.for_create( + SmokeImporter, + creator=_create_employee, + storage=storage, + locale='en', + ) + ) + template = importer.download_template_artifact(filename='smoke-template.xlsx') + _build_invalid_import_fixture(storage, template.as_bytes()) + result = await importer.import_data('smoke-invalid-input.xlsx', 'smoke-invalid-result.xlsx') + return { + 'result': result.to_api_payload(), + 'cell_errors': importer.cell_error_map.to_api_payload(), + 'row_errors': importer.row_error_map.to_api_payload(), + } + + +def _normalize(payload: dict[str, object]) -> dict[str, object]: + return json.loads(json.dumps(payload, sort_keys=True)) + + +async def main() -> None: + actual = _normalize(await _build_snapshot_payload()) + expected = json.loads(SNAPSHOT_PATH.read_text(encoding='utf-8')) + if actual != expected: + raise AssertionError('Import-failure API payload snapshot does not match the expected release snapshot.') + print(f'API payload snapshot smoke passed: {SNAPSHOT_PATH.name}') + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/scripts/smoke_docs_assets.py b/scripts/smoke_docs_assets.py index be10b32..dd121cd 100644 --- a/scripts/smoke_docs_assets.py +++ b/scripts/smoke_docs_assets.py @@ -16,6 +16,11 @@ 'docs/api-response-cookbook.md', 'display_message', ), + DOCS_DIR / 'integration-roadmap.md': ( + 'If You Are Integrating ExcelAlchemy For The First Time', + 'If You Are Building A Backend API', + 'If You Are Building Frontend Error Displays', + ), DOCS_DIR / 'examples-showcase.md': ( 'files/example-outputs/employee-import-workflow.txt', 'files/example-outputs/create-or-update-import.txt', @@ -33,6 +38,7 @@ 'custom-storage.txt', 'export-workflow.txt', 'fastapi-reference.txt', + 'import-failure-api-payload.json', ) diff --git a/scripts/smoke_examples.py b/scripts/smoke_examples.py index 76de2de..b23f6d7 100644 --- a/scripts/smoke_examples.py +++ b/scripts/smoke_examples.py @@ -30,7 +30,12 @@ } REQUIRED_MODULE_EXAMPLES: dict[str, tuple[str, ...]] = { - 'examples.fastapi_reference.app': ('FastAPI reference project completed', '/employee-imports'), + 'examples.fastapi_reference.app': ( + 'FastAPI reference project completed', + '/employee-imports', + 'Envelope sections:', + 'Data sections:', + ), } @@ -92,14 +97,25 @@ def _run_fastapi_reference_http_smoke() -> None: raise AssertionError('FastAPI reference import endpoint did not return HTTP 200') payload = import_response.json() - if payload['result']['result'] != 'SUCCESS': + if payload['ok'] is not True: + raise AssertionError('FastAPI reference import did not return a success envelope') + if payload['data']['result']['result'] != 'SUCCESS': raise AssertionError('FastAPI reference import did not return SUCCESS') - if payload['request']['tenant_id'] != 'tenant-smoke': + if payload['data']['request']['tenant_id'] != 'tenant-smoke': raise AssertionError('FastAPI reference request echo payload is incorrect') - if payload['cell_errors']['error_count'] != 0: + if payload['data']['cell_errors']['error_count'] != 0: raise AssertionError('FastAPI reference success payload should not contain cell errors') - if payload['row_errors']['error_count'] != 0: + if payload['data']['row_errors']['error_count'] != 0: raise AssertionError('FastAPI reference success payload should not contain row errors') + + missing_file_response = client.post('/employee-imports', data={'tenant_id': 'tenant-smoke'}) + if missing_file_response.status_code != 400: + raise AssertionError('FastAPI reference missing-file path did not return HTTP 400') + missing_file_payload = missing_file_response.json() + if missing_file_payload['ok'] is not False: + raise AssertionError('FastAPI reference missing-file path did not return an error envelope') + if missing_file_payload['error']['code'] != 'file_required': + raise AssertionError('FastAPI reference missing-file error code is incorrect') print('Smoke passed: examples.fastapi_reference.http') diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index af84e72..c948d54 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.7' +__version__ = '2.2.8' from excelalchemy._primitives.constants import CharacterSet, DataRangeOption, DateFormat, Option from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning from excelalchemy._primitives.identity import ( diff --git a/src/excelalchemy/_primitives/diagnostics.py b/src/excelalchemy/_primitives/diagnostics.py new file mode 100644 index 0000000..4226734 --- /dev/null +++ b/src/excelalchemy/_primitives/diagnostics.py @@ -0,0 +1,50 @@ +"""Named diagnostic loggers and helpers for developer-facing runtime output.""" + +from __future__ import annotations + +import logging + +RUNTIME_LOGGER_NAME = 'excelalchemy.runtime' +METADATA_LOGGER_NAME = 'excelalchemy.metadata' + +runtime_logger = logging.getLogger(RUNTIME_LOGGER_NAME) +metadata_logger = logging.getLogger(METADATA_LOGGER_NAME) + + +def log_runtime_context_replacement() -> None: + runtime_logger.warning( + 'Replacing an existing conversion context; subsequent imports will use the new runtime context.' + ) + + +def log_runtime_exporter_inference(*, source: str) -> None: + runtime_logger.info('Inferring exporter_model from %s.', source) + + +def log_runtime_export_requested_in_import_mode() -> None: + runtime_logger.info('Export requested while configured in import mode; inferring exporter_model and continuing.') + + +def log_runtime_ignoring_unrecognized_export_keys(*, unrecognized: set[str], model_keys: list[str]) -> None: + runtime_logger.warning( + 'Ignoring export keys that are not present in the exporter model. Ignored keys: %s. Exporter model keys: %s.', + sorted(unrecognized), + model_keys, + ) + + +def log_metadata_large_option_set(*, field_label: str, option_count: int) -> None: + metadata_logger.warning( + 'Field "%s" defines %s options. Options are intended for bounded vocabularies, so review this field if it ' + 'represents a large dataset.', + field_label, + option_count, + ) + + +def log_metadata_missing_option_id(*, option_id: str, field_label: str) -> None: + metadata_logger.warning( + 'Could not resolve option id %s for field "%s"; returning the original workbook value.', + option_id, + field_label, + ) diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index b310b16..34bdd36 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -1,4 +1,3 @@ -import logging from collections.abc import Sequence from typing import cast @@ -8,6 +7,12 @@ REASON_COLUMN_KEY, RESULT_COLUMN_KEY, ) +from excelalchemy._primitives.diagnostics import ( + log_runtime_context_replacement, + log_runtime_export_requested_in_import_mode, + log_runtime_exporter_inference, + log_runtime_ignoring_unrecognized_export_keys, +) from excelalchemy._primitives.header_models import ExcelHeader from excelalchemy._primitives.identity import DataUrlStr, Label, UniqueKey, UniqueLabel, UrlStr from excelalchemy._primitives.payloads import DataConverter, ExportRowPayload @@ -174,7 +179,7 @@ def export_upload( def add_context(self, context: ContextT) -> None: if self._context is not None: - logging.warning('An existing conversion context is being replaced') + log_runtime_context_replacement() self._context = context if self._last_import_session is not None: self._last_import_session.context = context @@ -263,10 +268,10 @@ def exporter_model(self) -> type[ExportModelT]: if self.config.schema_options.create_importer_model and self.config.schema_options.update_importer_model: raise ConfigError(msg(MessageKey.EXPORTER_MODEL_INFERENCE_CONFLICT)) if self.config.schema_options.create_importer_model: - logging.info('Inferring exporter_model from create_importer_model') + log_runtime_exporter_inference(source='create_importer_model') return cast(type[ExportModelT], self.config.schema_options.create_importer_model) if self.config.schema_options.update_importer_model: - logging.info('Inferring exporter_model from update_importer_model') + log_runtime_exporter_inference(source='update_importer_model') return cast(type[ExportModelT], self.config.schema_options.update_importer_model) raise ConfigError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_INFERRED)) @@ -285,7 +290,7 @@ def _gen_export_df( self, data: list[ExportRowPayload], keys: Sequence[str] | None = None ) -> tuple[WorksheetTable, bool]: if self.excel_mode == ExcelMode.IMPORT: - logging.info('Export requested while configured in import mode; continuing with exporter_model inference') + log_runtime_export_requested_in_import_mode() input_keys = ( list(keys) @@ -298,9 +303,7 @@ def _gen_export_df( ) model_keys = get_model_field_names(self.exporter_model) if unrecognized := (set(input_keys) - set(model_keys)): - logging.warning( - 'Ignoring keys not present in the exporter model: %s (model keys: %s)', unrecognized, model_keys - ) + log_runtime_ignoring_unrecognized_export_keys(unrecognized=unrecognized, model_keys=model_keys) selected_keys = self._select_output_excel_keys(list(set(input_keys).intersection(set(model_keys)))) has_merged_header = self.has_merged_header(selected_keys) diff --git a/src/excelalchemy/metadata.py b/src/excelalchemy/metadata.py index 7555b45..4cd9c45 100644 --- a/src/excelalchemy/metadata.py +++ b/src/excelalchemy/metadata.py @@ -2,7 +2,6 @@ import copy import datetime -import logging from collections.abc import Callable, Mapping, Set from dataclasses import dataclass, field, replace from functools import cached_property @@ -25,6 +24,10 @@ IntStr, Option, ) +from excelalchemy._primitives.diagnostics import ( + log_metadata_large_option_set, + log_metadata_missing_option_id, +) from excelalchemy._primitives.identity import Key, Label, OptionId, UniqueKey, UniqueLabel from excelalchemy.codecs.base import ExcelFieldCodec, UndefinedFieldCodec from excelalchemy.exceptions import ConfigError, ProgrammaticError @@ -188,22 +191,14 @@ def options_id_map(self, *, field_label: Label) -> dict[OptionId, Option]: if self.options is None: return {} if len(self.options) > MAX_OPTIONS_COUNT: - logging.warning( - 'Field "%s" defines %s options; please confirm that this is intentional because options are not meant for large datasets', - field_label, - len(self.options), - ) + log_metadata_large_option_set(field_label=str(field_label), option_count=len(self.options)) return {option.id: option for option in self.options} def options_name_map(self, *, field_label: Label) -> dict[str, Option]: if self.options is None: return {} if len(self.options) > MAX_OPTIONS_COUNT: - logging.warning( - 'Field "%s" defines %s options; please confirm that this is intentional because options are not meant for large datasets', - field_label, - len(self.options), - ) + log_metadata_large_option_set(field_label=str(field_label), option_count=len(self.options)) return {option.name: option for option in self.options} def exchange_option_ids_to_names( @@ -220,7 +215,7 @@ def exchange_option_ids_to_names( try: option_names.append(option_id_map[normalized_id].name) except KeyError: - logging.warning('Could not find option id %s; returning the original value', normalized_id) + log_metadata_missing_option_id(option_id=str(normalized_id), field_label=str(field_label)) option_names.append(normalized_id) return option_names diff --git a/src/excelalchemy/results.py b/src/excelalchemy/results.py index f3c6aca..7218b03 100644 --- a/src/excelalchemy/results.py +++ b/src/excelalchemy/results.py @@ -152,6 +152,30 @@ def for_row(self, row_index: RowIndex | int) -> dict[ColumnIndex, tuple[ExcelCel def messages_at(self, row_index: RowIndex | int, column_index: ColumnIndex | int) -> tuple[str, ...]: return tuple(str(error) for error in self.at(row_index, column_index)) + def field_labels(self) -> tuple[str, ...]: + return tuple(sorted({str(error.label) for error in self.flatten()})) + + def parent_labels(self) -> tuple[str, ...]: + return tuple(sorted({str(error.parent_label) for error in self.flatten() if error.parent_label is not None})) + + def unique_labels(self) -> tuple[str, ...]: + return tuple(sorted({str(error.unique_label) for error in self.flatten()})) + + def codes(self) -> tuple[str, ...]: + return tuple(sorted({error.code for error in self.flatten()})) + + def row_indices(self) -> tuple[RowIndex, ...]: + return tuple(sorted(self.keys())) + + def row_numbers_for_humans(self) -> tuple[int, ...]: + return tuple(_row_number_for_humans(row_index) for row_index in self.row_indices()) + + def column_indices(self) -> tuple[ColumnIndex, ...]: + return tuple(sorted({column_index for row in self.values() for column_index in row})) + + def column_numbers_for_humans(self) -> tuple[int, ...]: + return tuple(_column_number_for_humans(column_index) for column_index in self.column_indices()) + def flatten(self) -> tuple[ExcelCellError, ...]: return tuple(error for row in self.values() for errors in row.values() for error in errors) @@ -223,6 +247,32 @@ def summary_by_code(self) -> tuple[CodeIssueSummary, ...]: ) return tuple(sorted(summaries, key=lambda summary: summary.code)) + def grouped_messages_by_field(self) -> dict[str, tuple[str, ...]]: + return { + summary.unique_label: tuple( + record.error.display_message + for record in self.records() + if str(record.error.unique_label) == summary.unique_label + ) + for summary in self.summary_by_field() + } + + def grouped_messages_by_row(self) -> dict[int, tuple[str, ...]]: + return { + int(summary.row_index): tuple( + record.error.display_message for record in self.records() if record.row_index == summary.row_index + ) + for summary in self.summary_by_row() + } + + def grouped_messages_by_code(self) -> dict[str, tuple[str, ...]]: + return { + summary.code: tuple( + record.error.display_message for record in self.records() if record.error.code == summary.code + ) + for summary in self.summary_by_code() + } + def to_dict(self) -> dict[int, dict[int, list[dict[str, object]]]]: return { int(row_index): { @@ -236,6 +286,23 @@ 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(), + 'facets': { + 'field_labels': list(self.field_labels()), + 'parent_labels': list(self.parent_labels()), + 'unique_labels': list(self.unique_labels()), + 'codes': list(self.codes()), + 'row_numbers_for_humans': list(self.row_numbers_for_humans()), + 'column_numbers_for_humans': list(self.column_numbers_for_humans()), + }, + 'grouped': { + 'messages_by_field': { + key: list(messages) for key, messages in self.grouped_messages_by_field().items() + }, + 'messages_by_row': { + str(row_index): list(messages) for row_index, messages in self.grouped_messages_by_row().items() + }, + 'messages_by_code': {key: list(messages) for key, messages in self.grouped_messages_by_code().items()}, + }, '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()], @@ -267,6 +334,32 @@ def at(self, row_index: RowIndex | int) -> tuple[RowIssue, ...]: def messages_for_row(self, row_index: RowIndex | int) -> tuple[str, ...]: return tuple(str(error) for error in self.at(row_index)) + def field_labels(self) -> tuple[str, ...]: + return tuple(sorted({str(error.label) for error in self.flatten() if isinstance(error, ExcelCellError)})) + + def parent_labels(self) -> tuple[str, ...]: + return tuple( + sorted( + { + str(error.parent_label) + for error in self.flatten() + if isinstance(error, ExcelCellError) and error.parent_label is not None + } + ) + ) + + def unique_labels(self) -> tuple[str, ...]: + return tuple(sorted({str(error.unique_label) for error in self.flatten() if isinstance(error, ExcelCellError)})) + + def codes(self) -> tuple[str, ...]: + return tuple(sorted({error.code for error in self.flatten()})) + + def row_indices(self) -> tuple[RowIndex, ...]: + return tuple(sorted(self.keys())) + + def row_numbers_for_humans(self) -> tuple[int, ...]: + return tuple(_row_number_for_humans(row_index) for row_index in self.row_indices()) + def numbered_messages_for_row(self, row_index: RowIndex | int) -> tuple[str, ...]: return self.numbered_messages(self.at(row_index)) @@ -318,6 +411,22 @@ def summary_by_code(self) -> tuple[CodeIssueSummary, ...]: ) return tuple(sorted(summaries, key=lambda summary: summary.code)) + def grouped_messages_by_row(self) -> dict[int, tuple[str, ...]]: + return { + int(summary.row_index): tuple( + record.error.display_message for record in self.records() if record.row_index == summary.row_index + ) + for summary in self.summary_by_row() + } + + def grouped_messages_by_code(self) -> dict[str, tuple[str, ...]]: + return { + summary.code: tuple( + record.error.display_message for record in self.records() if record.error.code == summary.code + ) + for summary in self.summary_by_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()} @@ -326,6 +435,19 @@ 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(), + 'facets': { + 'field_labels': list(self.field_labels()), + 'parent_labels': list(self.parent_labels()), + 'unique_labels': list(self.unique_labels()), + 'codes': list(self.codes()), + 'row_numbers_for_humans': list(self.row_numbers_for_humans()), + }, + 'grouped': { + 'messages_by_row': { + str(row_index): list(messages) for row_index, messages in self.grouped_messages_by_row().items() + }, + 'messages_by_code': {key: list(messages) for key, messages in self.grouped_messages_by_code().items()}, + }, '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()], diff --git a/tests/contracts/test_result_contract.py b/tests/contracts/test_result_contract.py index b27897c..68e5375 100644 --- a/tests/contracts/test_result_contract.py +++ b/tests/contracts/test_result_contract.py @@ -72,6 +72,23 @@ def test_import_result_to_api_payload_for_success_case(self): }, } + def test_import_result_status_helpers_remain_consistent(self): + success = ImportResult(result=ValidateResult.SUCCESS) + header_invalid = ImportResult(result=ValidateResult.HEADER_INVALID) + data_invalid = ImportResult(result=ValidateResult.DATA_INVALID) + + assert success.is_success is True + assert success.is_header_invalid is False + assert success.is_data_invalid is False + + assert header_invalid.is_success is False + assert header_invalid.is_header_invalid is True + assert header_invalid.is_data_invalid is False + + assert data_invalid.is_success is False + assert data_invalid.is_header_invalid is False + assert data_invalid.is_data_invalid is True + def test_import_result_to_api_payload_for_header_invalid_case(self): result = ImportResult( result=ValidateResult.HEADER_INVALID, diff --git a/tests/integration/test_examples_smoke.py b/tests/integration/test_examples_smoke.py index 8f7310f..4171af4 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', 'schemas.py', 'responses.py', 'storage.py', 'services.py', 'app.py'): + for filename in ('models.py', 'schemas.py', 'responses.py', 'presenters.py', 'storage.py', 'services.py', 'app.py'): source = (package_dir / filename).read_text(encoding='utf-8') compile(source, str(package_dir / filename), 'exec') @@ -153,7 +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 'Envelope sections:' in output + assert 'Data sections:' in output assert 'Request tenant: tenant-001' in output @@ -218,12 +219,8 @@ 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 + assert payload['uploaded_artifacts'] == [] @pytest.mark.skipif( @@ -254,10 +251,18 @@ 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 + assert payload['ok'] is True + assert payload['data']['result']['result'] == 'SUCCESS' + assert payload['data']['result']['is_success'] is True + assert payload['data']['created_rows'] == 1 + assert payload['data']['uploaded_artifacts'] == [] + assert payload['data']['request']['tenant_id'] == 'tenant-001' + assert payload['data']['cell_errors']['error_count'] == 0 + assert payload['data']['row_errors']['error_count'] == 0 + + missing_file_response = client.post('/employee-imports', data={'tenant_id': 'tenant-001'}) + assert missing_file_response.status_code == 400 + missing_file_payload = missing_file_response.json() + assert missing_file_payload['ok'] is False + assert missing_file_payload['error']['code'] == 'file_required' + assert missing_file_payload['error']['detail'] == {'field': 'file'} diff --git a/tests/unit/test_diagnostics_logging.py b/tests/unit/test_diagnostics_logging.py new file mode 100644 index 0000000..db2f9b2 --- /dev/null +++ b/tests/unit/test_diagnostics_logging.py @@ -0,0 +1,83 @@ +import logging + +import pytest +from pydantic import BaseModel + +from excelalchemy import ExcelAlchemy, ExporterConfig, FieldMeta, Option, OptionId, String +from excelalchemy._primitives.diagnostics import METADATA_LOGGER_NAME, RUNTIME_LOGGER_NAME +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_metadata_option_warning_uses_named_logger(caplog: pytest.LogCaptureFixture) -> None: + class Importer(BaseModel): + status: String = FieldMeta( + label='Status', + order=1, + options=[Option(id=OptionId(index), name=f'Option {index}') for index in range(1, 102)], + ) + + field = _build_field(Importer) + + with caplog.at_level(logging.WARNING, logger=METADATA_LOGGER_NAME): + field.presentation.options_name_map(field_label=field.declared.label) + + assert caplog.records + record = caplog.records[-1] + assert record.name == METADATA_LOGGER_NAME + assert 'Field "Status" defines 101 options.' in record.message + assert 'bounded vocabularies' in record.message + + +def test_runtime_context_warning_uses_named_logger(caplog: pytest.LogCaptureFixture) -> None: + class Importer(BaseModel): + name: String = FieldMeta(label='Name', order=1) + + alchemy = ExcelAlchemy(ImporterConfig(Importer, locale='en')) + + with caplog.at_level(logging.WARNING, logger=RUNTIME_LOGGER_NAME): + alchemy.add_context({'tenant': 'a'}) + alchemy.add_context({'tenant': 'b'}) + + assert caplog.records + record = caplog.records[-1] + assert record.name == RUNTIME_LOGGER_NAME + assert 'Replacing an existing conversion context' in record.message + + +def test_runtime_exporter_inference_logs_use_named_logger(caplog: pytest.LogCaptureFixture) -> None: + class Importer(BaseModel): + name: String = FieldMeta(label='Name', order=1) + + async def _creator(data: dict[str, object], context: object | None) -> dict[str, object]: + return data + + alchemy = ExcelAlchemy(ImporterConfig.for_create(Importer, creator=_creator, locale='en')) + + with caplog.at_level(logging.INFO, logger=RUNTIME_LOGGER_NAME): + _ = alchemy.exporter_model + + assert caplog.records + record = caplog.records[-1] + assert record.name == RUNTIME_LOGGER_NAME + assert 'Inferring exporter_model from create_importer_model.' in record.message + + +def test_runtime_unrecognized_export_keys_warning_uses_named_logger(caplog: pytest.LogCaptureFixture) -> None: + class Exporter(BaseModel): + name: String = FieldMeta(label='Name', order=1) + + alchemy = ExcelAlchemy(ExporterConfig.for_model(Exporter, locale='en')) + + with caplog.at_level(logging.WARNING, logger=RUNTIME_LOGGER_NAME): + alchemy.export([], keys=['name', 'missing']) + + assert caplog.records + record = caplog.records[-1] + assert record.name == RUNTIME_LOGGER_NAME + assert 'Ignoring export keys that are not present in the exporter model.' in record.message + assert 'missing' in record.message diff --git a/tests/unit/test_excel_exceptions.py b/tests/unit/test_excel_exceptions.py index 25c27cc..34f4269 100644 --- a/tests/unit/test_excel_exceptions.py +++ b/tests/unit/test_excel_exceptions.py @@ -171,6 +171,25 @@ async def test_cell_error_map_supports_coordinate_access_and_flattening(self): ] } }, + 'facets': { + 'field_labels': ['邮箱'], + 'parent_labels': [], + 'unique_labels': ['邮箱'], + 'codes': ['ExcelCellError'], + 'row_numbers_for_humans': [1], + 'column_numbers_for_humans': [4], + }, + 'grouped': { + 'messages_by_field': { + '邮箱': ['【邮箱】Enter a valid email address'], + }, + 'messages_by_row': { + '0': ['【邮箱】Enter a valid email address'], + }, + 'messages_by_code': { + 'ExcelCellError': ['【邮箱】Enter a valid email address'], + }, + }, 'summary': { 'by_field': [ { @@ -209,6 +228,9 @@ async def test_cell_error_map_supports_coordinate_access_and_flattening(self): 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 + assert error_map.field_labels() == ('邮箱',) + assert error_map.codes() == ('ExcelCellError',) + assert error_map.row_numbers_for_humans() == (1,) async def test_row_issue_map_supports_row_access_and_numbered_messages(self): issue_map = RowIssueMap() @@ -297,6 +319,22 @@ async def test_row_issue_map_supports_row_access_and_numbered_messages(self): }, ] }, + 'facets': { + 'field_labels': ['邮箱'], + 'parent_labels': [], + 'unique_labels': ['邮箱'], + 'codes': ['ExcelCellError', 'ExcelRowError'], + 'row_numbers_for_humans': [1], + }, + 'grouped': { + 'messages_by_row': { + '0': ['【邮箱】Enter a valid email address', 'Combination invalid'], + }, + 'messages_by_code': { + 'ExcelCellError': ['【邮箱】Enter a valid email address'], + 'ExcelRowError': ['Combination invalid'], + }, + }, 'summary': { 'by_row': [ { @@ -328,3 +366,6 @@ async def test_row_issue_map_supports_row_access_and_numbered_messages(self): } assert issue_map.summary_by_row()[0].error_count == 2 assert [summary.code for summary in issue_map.summary_by_code()] == ['ExcelCellError', 'ExcelRowError'] + assert issue_map.field_labels() == ('邮箱',) + assert issue_map.codes() == ('ExcelCellError', 'ExcelRowError') + assert issue_map.row_numbers_for_humans() == (1,)