diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4405209 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @RayCarterLab diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..2b4df5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,53 @@ +name: Bug report +description: Report a reproducible problem in ExcelAlchemy +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. + Please include enough detail for us to reproduce the issue quickly. + - type: textarea + id: summary + attributes: + label: Summary + description: What happened? + placeholder: A clear and concise description of the bug. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Steps, sample code, or test data needed to reproduce the issue. + placeholder: | + 1. Create model ... + 2. Call ... + 3. Observe ... + render: python + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen instead? + validations: + required: true + - type: input + id: version + attributes: + label: ExcelAlchemy version + placeholder: 1.1.0 + - type: input + id: python-version + attributes: + label: Python version + placeholder: "3.10" + - type: textarea + id: environment + attributes: + label: Environment details + description: OS, dependency versions, storage backend details, or anything else that may matter. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1474f11 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security issue + url: https://github.com/RayCarterLab/ExcelAlchemy/security/advisories/new + about: Please report sensitive security issues privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e5e3aee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature request +description: Suggest an improvement or new capability +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Thanks for sharing an idea. + Please describe the problem and the desired outcome as clearly as you can. + - type: textarea + id: problem + attributes: + label: Problem statement + description: What problem are you trying to solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What would you like ExcelAlchemy to do? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What approaches or workarounds have you considered already? + - type: textarea + id: context + attributes: + label: Additional context + description: Add examples, screenshots, or related links if they help. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b108882 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## Summary + +- Describe the user-facing or engineering goal of this change. + +## Changes + +- List the most important implementation changes. + +## Validation + +- [ ] `uv run ruff format --check .` +- [ ] `uv run ruff check .` +- [ ] `uv run pyright` +- [ ] `uv run pytest --cov=excelalchemy --cov-report=term-missing:skip-covered tests` + +## Checklist + +- [ ] I updated documentation when behavior or workflows changed. +- [ ] I did not include generated files or local-only artifacts. +- [ ] I confirmed this change does not require additional release steps. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..27b86c3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,60 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' + time: '09:00' + timezone: 'Asia/Shanghai' + open-pull-requests-limit: 5 + commit-message: + prefix: 'ci(deps)' + groups: + github-actions: + patterns: + - '*' + labels: + - 'dependencies' + - 'github-actions' + + - package-ecosystem: 'pip' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' + time: '09:15' + timezone: 'Asia/Shanghai' + open-pull-requests-limit: 5 + commit-message: + prefix: 'build(deps)' + groups: + python-production: + dependency-type: 'production' + patterns: + - '*' + python-development: + dependency-type: 'development' + patterns: + - '*' + labels: + - 'dependencies' + - 'python' + + - package-ecosystem: 'pre-commit' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' + time: '09:30' + timezone: 'Asia/Shanghai' + open-pull-requests-limit: 3 + commit-message: + prefix: 'chore(pre-commit)' + groups: + pre-commit-hooks: + patterns: + - '*' + labels: + - 'dependencies' + - 'pre-commit' diff --git a/.github/workflows/.precommit.yaml b/.github/workflows/.precommit.yaml deleted file mode 100644 index a048152..0000000 --- a/.github/workflows/.precommit.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: pre-commit - -on: - pull_request: - push: - branches: [main] - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v3 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pre-commit flit && flit install --symlink - - - name: Run pre-commit hooks - run: pre-commit run --all-files diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..428f92b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - v2 + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + ruff: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: '3.14' + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Sync development environment + run: uv sync --locked --extra development + + - name: Run ruff + run: | + uv run ruff format --check . + uv run ruff check . + + pyright: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: '3.14' + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Sync development environment + run: uv sync --locked --extra development + + - name: Run pyright + run: uv run pyright + + tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + env: + CODECOV_TOKEN: ${{ secrets.CODECOVEXCELALCHEMY }} + strategy: + fail-fast: false + matrix: + python-version: ['3.12', '3.13', '3.14'] + + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Sync development environment + run: uv sync --locked --extra development + + - name: Run test suite + run: | + uv run pytest --cov=excelalchemy --cov-report=term-missing:skip-covered --cov-report=xml:coverage.xml --junitxml=pytest.xml tests + + - name: Upload coverage artifact + if: always() && matrix.python-version == '3.14' + uses: actions/upload-artifact@v4 + with: + name: test-reports-${{ matrix.python-version }} + path: | + coverage.xml + pytest.xml + if-no-files-found: warn + retention-days: 14 + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.14' && env.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + disable_search: true + token: ${{ env.CODECOV_TOKEN }} diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml deleted file mode 100644 index 9cd259c..0000000 --- a/.github/workflows/jekyll-gh-pages.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Sample workflow for building and deploying a Jekyll site to GitHub Pages -name: Deploy Jekyll with GitHub Pages dependencies preinstalled - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Build job - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Setup Pages - uses: actions/configure-pages@v3 - - name: Build with Jekyll - uses: actions/jekyll-build-pages@v1 - with: - source: ./ - destination: ./_site - - name: Upload artifact - uses: actions/upload-pages-artifact@v1 - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v2 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index bdaab28..8ff684b 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,11 +1,3 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - name: Upload Python Package on: @@ -16,24 +8,74 @@ permissions: contents: read jobs: - deploy: + build-and-verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + outputs: + artifact-name: ${{ steps.artifact-meta.outputs.name }} + + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: '3.14' + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + - name: Build package + run: uv build + + - name: Check package metadata + run: uvx twine check dist/* + + - name: Smoke test wheel installation + run: | + uv venv .pkg-smoke-wheel --python 3.14 + uv pip install --python .pkg-smoke-wheel/bin/python dist/*.whl + .pkg-smoke-wheel/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" + + - name: Smoke test source distribution installation + run: | + uv venv .pkg-smoke-sdist --python 3.14 + uv pip install --python .pkg-smoke-sdist/bin/python dist/*.tar.gz + .pkg-smoke-sdist/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" + + - name: Set artifact metadata + id: artifact-meta + run: echo "name=python-package-dists" >> "$GITHUB_OUTPUT" + + - name: Upload package distributions + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact-meta.outputs.name }} + path: dist/ + + publish: runs-on: ubuntu-latest + timeout-minutes: 10 + needs: build-and-verify + environment: pypi + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - name: Download package distributions + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-and-verify.outputs.artifact-name }} + path: dist/ + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/python-test.yaml b/.github/workflows/python-test.yaml deleted file mode 100644 index 2e6ff4a..0000000 --- a/.github/workflows/python-test.yaml +++ /dev/null @@ -1,38 +0,0 @@ -name: pytest-coverage-comment - -on: - pull_request: - branches: - - '*' - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Set up Python 3.11 - uses: actions/setup-python@v2 - with: - python-version: 3.11 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest pytest-cov flit - flit install --symlink - - - name: Build coverage file - run: | - pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=excelalchemy tests/ | tee pytest-coverage.txt - - - - name: Pytest coverage comment - uses: MishaKav/pytest-coverage-comment@main - with: - pytest-coverage-path: ./pytest-coverage.txt - junitxml-path: ./pytest.xml - - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89a6fc5..feadddb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -8,76 +8,14 @@ repos: - id: check-json - id: check-merge-conflict - id: check-toml - - id: double-quote-string-fixer - id: fix-byte-order-marker - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/best-doctor/pre-commit-hooks - rev: v1.0.11 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.7 hooks: - - id: mccabe-complexity - name: Check functions complexity - language: python - - id: line-count - name: Check number of lines in python files - language: python - - - - repo: https://github.com/ambv/black - rev: 23.3.0 - hooks: - - id: black - - - repo: local - hooks: - - id: isort - name: isort - entry: isort - args: - - . - language: system - pass_filenames: false - - - repo: https://github.com/MarcoGorelli/absolufy-imports - rev: v0.3.1 - hooks: - - id: absolufy-imports - - - repo: https://github.com/PyCQA/pylint - rev: v3.0.0a6 - hooks: - - id: pylint - name: pylint - entry: pylint - language: system - pass_filenames: false - args: - - excelalchemy - - - repo: https://github.com/asottile/reorder_python_imports - rev: v3.9.0 - hooks: - - id: reorder-python-imports - name: Reorder imports - pass_filenames: false - - - repo: local - hooks: - - id: pyright - name: pyright - entry: pyright - language: system - pass_filenames: false - args: - - excelalchemy - - - repo: local - hooks: - - id: mypy - name: mypy - entry: mypy + - id: ruff-check args: - - excelalchemy - pass_filenames: false - language: system + - --fix + - id: ruff-format diff --git a/ABOUT.md b/ABOUT.md index 1bb0cfe..2e993b9 100644 --- a/ABOUT.md +++ b/ABOUT.md @@ -1,33 +1,265 @@ -# How ExcelAlchemy Comes to Be +# About ExcelAlchemy -Hello Everyone, I am a web backend developer, mainly use Python, SQLAlchemy, GraphQL, Pydantic in my daily work. +## What Kind of Project This Is -As a web backend developer, I have often found myself tasked with processing large datasets that were submitted via Excel. -However, the process of manually parsing the data from Excel files, identifying errors, and reconciling discrepancies was time-consuming and error-prone. +ExcelAlchemy began as a practical response to a recurring backend problem: +Excel was the delivery format, but the real work was template control, validation, data normalization, and row-level feedback. -Often the work was duplicated somehow but not exactly the same, and the data was not always consistent. +Over time, this repository became more than a utility library. +It became a place to practice and demonstrate architecture decisions in public: -After struggling with the same problem for multiple projects, I realized that a more streamlined solution was needed, as there is a saying `Don't Repeat Yourself`. +- how to evolve a codebase without rewriting it from scratch +- how to isolate framework churn behind adapters +- how to remove dependencies that no longer fit the problem +- how to expose extension points without making the API noisy -That's where ExcelAlchemy comes in. +## Problem Framing -ExcelAlchemy, provides a streamlined interface for interacting with Excel files. -With ExcelAlchemy, you can easily download Excel files, parse user inputs, and generate Pydantic classes without breaking a sweat. +The project is built around one core belief: -One of ExcelAlchemy's key features is its ability to generate Excel templates from Pydantic classes. -This makes it easy for you to set up Excel spreadsheets with specific data types and layouts, and ensures that data is submitted in a standardized format. -Additionally, ExcelAlchemy supports adding default values for optional fields, making it easier to fill out Excel forms. +> Excel import/export is not a file problem first. It is a contract problem first. -Another key feature of ExcelAlchemy is its ability to parse Pydantic classes from Excel files. -This minimizes the need for manual data entry and reduces the risk of errors. -ExcelAlchemy also provides a custom data converter, allowing developers to customize how parsed data is returned. +The “file” is only the transport. +The actual system has to answer harder questions: -Finally, ExcelAlchemy can read data from parsed Excel files using Minio. -This functionality allows developers to store Excel files in a bucket and create data from them asynchronously. -This is particularly useful for managing large datasets, and ensures that data is stored in a secure and reliable manner. +- What is the expected shape of the data? +- Which fields are required? +- How should users discover valid input? +- Where should validation errors be written back? +- How do we keep backend code and spreadsheet semantics aligned? +- How do we avoid hard-wiring infrastructure choices into business logic? -Overall, ExcelAlchemy is a high-quality, well-documented Python library that is perfect for anyone who works with Excel spreadsheets. -Its ability to generate templates from Pydantic classes, parse Pydantic classes from Excel files, -and read data from parsed Excel files using Minio make it a valuable tool for anyone who needs to manage Excel data in their Python projects. +ExcelAlchemy answers those questions with schema-driven design. -A more readable version of this post is available on [Medium](https://medium.com/@hrui835/excelalchemy-a-python-library-for-reading-and-writing-excel-files-3c6127212d1c). +## 23 Design Principles In Practice + +1. Prefer explicit schemas over implicit conventions. +2. Keep workbook metadata separate from validation-framework internals. +3. Treat Excel as a contract, not a loosely structured blob. +4. Keep the public API small and boring. +5. Move complexity behind focused internal components. +6. Prefer composition over giant coordinator classes. +7. Put adapters at unstable integration boundaries. +8. Depend on protocols where implementations can vary. +9. Optimize for migration-friendly seams. +10. Avoid hidden runtime magic. +11. Make user-facing failures easy to understand. +12. Keep architecture honest to the real problem domain. +13. Remove dependencies that do not earn their cost. +14. Use modern Python features where they reduce incidental complexity. +15. Prefer typed contracts over stringly typed plumbing. +16. Make storage a strategy, not a product lock-in. +17. Keep tests focused on behavior and contracts. +18. Modernize incrementally, not theatrically. +19. Separate workbook display text from runtime error text. +20. Let internationalization start with message boundaries, not with global complexity. +21. Accept compatibility where it helps adoption, but isolate it. +22. Document tradeoffs, not just outcomes. +23. Build a library that teaches its own architecture. + +## Architecture Decisions + +### 1. Facade Outside, Components Inside + +`ExcelAlchemy` is intentionally a facade. +It exposes the user-facing workflow, but delegates internals to specialized components: + +- schema extraction +- header parsing and validation +- row aggregation +- import execution +- rendering +- storage + +This lets the public surface stay stable while the inside evolves. + +### 2. Excel Metadata Owns Excel Semantics + +`FieldMetaInfo` is the center of workbook metadata. +It knows about: + +- labels +- ordering +- required-ness +- comments +- option mappings +- date and numeric display constraints + +This metadata does not belong to Pydantic internals. +That separation was critical for the Pydantic v2 migration. + +### 3. Pydantic Is an Adapter Boundary + +The project used to be more tightly coupled to Pydantic implementation details. +Today the approach is different: + +- Pydantic models define structure +- ExcelAlchemy extracts model shape through a small adapter layer +- runtime Excel validation remains owned by ExcelAlchemy + +This is not “anti-framework”; it is a boundary decision. + +### 4. Storage Is a Protocol + +Minio is useful, but it is not the architecture. +The architecture is `ExcelStorage`. + +That means the system can support: + +- Minio-compatible object storage +- local file storage +- in-memory test doubles +- custom backends + +without making those choices leak into the core workflow. + +### 5. Workbook Display Text Is Different From Runtime Errors + +Runtime exceptions are aimed at developers and integrators. +Workbook text is aimed at Excel users. + +That is why the project now separates: + +- runtime message lookup +- display message lookup + +This is a small but meaningful design distinction. + +### 6. Locale Policy Is Public, Not Accidental + +The project now documents its locale behavior explicitly instead of leaving it as an implementation detail. + +- runtime messages are English-first and stable for the 2.x line +- workbook display text supports `zh-CN` and `en` +- workbook display defaults to `zh-CN` + +That policy is written down in [docs/locale.md](./docs/locale.md), so users do not have to infer it from scattered examples. + +## Major Evolution Steps + +### `src/` Layout Migration + +The move to `src/excelalchemy` eliminated misleading import behavior from repository-root execution. +That change made packaging and test semantics more honest. + +### Pydantic Metadata Decoupling + +Before the v2 migration, the dangerous part was not syntax changes. +It was the deeper coupling between Excel metadata and Pydantic field internals. + +The metadata layer was pulled apart first. +That reduced migration risk dramatically. + +### Pydantic v2 Migration + +The migration replaced older patterns with: + +- `model_fields` +- `model_validate` +- an adapter layer around field access + +The key win was not just “support v2”. +It was making future framework upgrades less invasive. + +### Python 3.12-3.14 Modernization + +The codebase now uses: + +- `type` aliases +- PEP 695 generic syntax in core places +- a tighter modern Python target + +This was done after narrowing the support policy. +The syntax decision followed the compatibility decision, not the other way around. + +### pandas Removal + +`pandas` was mostly acting as a transport layer, not as a data analysis engine. +Replacing it with `openpyxl + WorksheetTable` better matched the actual workload and removed a dependency chain the project did not need. + +### Storage Abstraction + +Minio support remains available, but the project no longer treats it as the only meaningful storage model. +That shift makes the library more reusable and architecturally cleaner. + +### i18n Foundation + +Internationalization was intentionally staged: + +1. unify runtime errors +2. introduce a message layer +3. move workbook display text onto locale-aware display messages + +That sequence avoided premature framework complexity. + +## Pydantic v1 vs v2: The Real Difference + +| Concern | Earlier coupling risk | Current design | +| --- | --- | --- | +| Field access | direct dependence on internals | adapter over stable v2 APIs | +| Excel metadata | mixed with validation details | owned by `FieldMetaInfo` | +| Custom validation flow | framework-driven | explicitly orchestrated | +| Migration surface | wide | narrowed | + +The important lesson is not “v2 is newer”. +The important lesson is that framework upgrades are easier when the framework does not own the whole architecture. + +## Why Remove pandas + +This project does not need: + +- joins +- groupby pipelines +- vectorized analysis +- multi-index machinery + +It does need: + +- deterministic workbook IO +- cell-level error positioning +- header semantics +- light table manipulation + +So the code now uses a table abstraction that matches the problem. +That is a better engineering fit. + +## Why `uv` + +The switch to `uv` was part of the broader modernization effort: + +- faster setup +- simpler CI flow +- clearer local commands +- less tool sprawl + +The build backend remains conservative (`flit_core`), while the workflow frontend is modern. +That was an intentional risk balance. + +## Tradeoffs + +No design here is “free”. +Some deliberate tradeoffs: + +- The library favors explicit structure over maximum implicit flexibility. +- Workbook comments and labels are verbose by design because user guidance matters. +- The public API remains smaller than the set of available internal extension points. +- Compatibility is preserved where it reduces migration pain, but older patterns are gradually de-emphasized. + +## How To Read This Repository + +If you want the shortest path: + +1. Start with [README.md](./README.md) +2. Read [docs/architecture.md](./docs/architecture.md) +3. Look at `src/excelalchemy/core/` +4. Then inspect tests under `tests/contracts/` + +That path shows both the architecture and the behavioral safety net. + +## Final Note + +ExcelAlchemy is intentionally opinionated. +It is not trying to be every possible spreadsheet abstraction. +Its goal is narrower and, because of that, stronger: + +to make typed Excel workflows explicit, maintainable, and evolvable. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4e313b2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +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.0.0] - 2026-03-28 + +This release promotes the validated 2.0 release candidate to the first stable public +release of ExcelAlchemy 2.0. + +### Changed + +- Promoted the 2.0 line from release candidate to stable +- Finalized release-facing documentation, badges, and portfolio screenshots +- Finalized the GitHub Actions coverage upload path for optional Codecov integration + +## [2.0.0rc1] - 2026-03-27 + +This release candidate marks the first public preview of the ExcelAlchemy 2.0 line. +It consolidates the architectural work completed across the modernization roadmap and +is intended to validate the release pipeline before the final `2.0.0` release. + +### Added + +- Locale-aware workbook display text with `locale='zh-CN' | 'en'` +- A pluggable `ExcelStorage` protocol for custom storage backends +- An optional built-in Minio backend installable via `ExcelAlchemy[minio]` +- Internal `WorksheetTable` abstraction for workbook IO without pandas +- Architecture and design documentation in `README.md`, `ABOUT.md`, and `docs/architecture.md` +- A lightweight i18n message layer for runtime and workbook display messages + +### Changed + +- Migrated the codebase to a standard `src/` layout +- Migrated from Pydantic v1-style internals to a Pydantic v2-based adapter design +- Modernized the codebase for Python 3.12-3.14, with Python 3.14 as the primary target +- Switched local development, CI, and release workflows to `uv` +- Split the former monolithic orchestration layer into focused internal components +- Rewrote the main documentation as architecture-focused project pages +- Deprecated the legacy `excelalchemy.types.*` import paths in favor of `excelalchemy.metadata`, `excelalchemy.results`, `excelalchemy.config`, `excelalchemy.codecs`, and public types re-exported from the package root +- Promoted `excelalchemy.exceptions` as the stable exception module and converted `excelalchemy.exc`, `excelalchemy.identity`, and `excelalchemy.header_models` into explicit compatibility layers + +### Removed + +- Runtime dependency on `pandas` +- Hard architectural dependence on Minio +- Support for Python 3.10 and 3.11 + +### Breaking Changes + +- The supported Python range is now `3.12-3.14` +- The project now requires Pydantic v2 +- `pandas` is no longer installed or used at runtime +- Minio is no longer a core dependency; install `ExcelAlchemy[minio]` if you want the built-in backend +- Runtime exceptions and validation messages are now standardized in English + +### Migration Notes + +- If you previously depended on Minio support, install the extra: + `pip install "ExcelAlchemy[minio]"` +- If you want a custom storage backend, provide `storage=...` on `ImporterConfig` or `ExporterConfig` +- If your users need English workbook-facing hints and import result labels, set `locale='en'` +- If you were relying on pandas being present indirectly, install it separately in your own application + +## [1.1.0] - Previous stable line + +- Legacy stable release before the 2.0 architecture and dependency modernization work. diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..ba7fe13 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,109 @@ +# Migration Notes + +## Upgrading To 2.0 + +ExcelAlchemy 2.0 keeps the public workflow recognizable, but the project has changed +meaningfully in platform support, dependencies, and architecture. + +This guide focuses on what users are most likely to notice when upgrading from the +`1.x` line to `2.0.0`. + +## Platform Support + +- Python 3.10 and 3.11 are no longer supported +- Supported versions are now Python 3.12, 3.13, and 3.14 +- Python 3.14 is the primary support target + +## Pydantic + +- ExcelAlchemy now targets Pydantic v2 +- Internal field extraction and validation integration were redesigned around adapter boundaries + +If your application is still pinned to Pydantic v1, upgrade that dependency before upgrading ExcelAlchemy. + +## Storage + +### What changed + +- Minio is no longer a mandatory dependency +- Storage is now modeled as the `ExcelStorage` protocol +- The built-in Minio backend is still available, but as an optional extra + +### New install patterns + +Base install: + +```bash +pip install ExcelAlchemy +``` + +Install with built-in Minio support: + +```bash +pip install "ExcelAlchemy[minio]" +``` + +### Recommended configuration pattern + +Prefer explicit storage objects: + +```python +from excelalchemy import ExporterConfig +from excelalchemy.core.storage_minio import MinioStorageGateway + +config = ExporterConfig( + ExporterModel, + storage=MinioStorageGateway(minio_client, bucket_name='excel-files'), +) +``` + +### Legacy compatibility + +The older `minio=..., bucket_name=..., url_expires=...` configuration style is still accepted for compatibility, but it is no longer the preferred shape of the API. + +## pandas + +- ExcelAlchemy no longer uses or installs `pandas` at runtime +- Workbook IO is now based on `openpyxl` and an internal `WorksheetTable` + +If your application depended on pandas being installed as an indirect dependency, install it explicitly in your own project. + +## Runtime And Workbook Language + +- Runtime exceptions are standardized in English +- Workbook-facing display text is locale-aware +- Supported display locales currently include `zh-CN` and `en` + +Example: + +```python +config = ImporterConfig(ImporterModel, creator=create_func, locale='en') +``` + +## Module Paths + +- `excelalchemy.types.*` and `excelalchemy.types.value.*` are deprecated compatibility imports in the 2.x line +- those imports now emit `ExcelAlchemyDeprecationWarning` +- the compatibility layer will be removed in ExcelAlchemy 3.0 + +Prefer the new module layout: + +- `excelalchemy.metadata` +- `excelalchemy.results` +- `excelalchemy.config` +- `excelalchemy.codecs` +- the `excelalchemy` package root for common public types such as `Label`, `Key`, and `UrlStr` + +Additional top-level module guidance: + +- `excelalchemy.exceptions` is the stable replacement for `excelalchemy.exc` +- `excelalchemy.identity` is now a compatibility import; prefer `from excelalchemy import Label, Key, UrlStr, ...` +- `excelalchemy.header_models` is internal and should not be imported in application code + +## Recommended Upgrade Checklist + +1. Upgrade your Python runtime to 3.12+. +2. Upgrade your project to Pydantic v2. +3. Decide whether you need `ExcelAlchemy[minio]` or a custom `storage=...` implementation. +4. If you expose templates or import result workbooks to English-speaking users, set `locale='en'`. +5. Run your import/export flows against `2.0.0` in a staging environment before promoting it in production. diff --git a/README.md b/README.md index c061678..7a89458 100755 --- a/README.md +++ b/README.md @@ -1,127 +1,333 @@ -> [中文](https://github.com/SundayWindy/ExcelAlchemy/blob/main/README_cn.md) | English -> +# ExcelAlchemy +[![CI](https://github.com/RayCarterLab/ExcelAlchemy/actions/workflows/ci.yml/badge.svg)](https://github.com/RayCarterLab/ExcelAlchemy/actions/workflows/ci.yml) +[![Codecov](https://codecov.io/gh/RayCarterLab/ExcelAlchemy/graph/badge.svg)](https://app.codecov.io/gh/RayCarterLab/ExcelAlchemy) +![Python](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-3776AB) +![Lint](https://img.shields.io/badge/lint-ruff-D7FF64) +![Typing](https://img.shields.io/badge/typing-pyright-2C6BED) -# ExcelAlchemy User Guide -# 📊 ExcelAlchemy [![codecov](https://codecov.io/gh/SundayWindy/ExcelAlchemy/branch/main/graph/badge.svg?token=F6QVKL37XH)](https://codecov.io/gh/SundayWindy/ExcelAlchemy) [![](https://tokei.rs/b1/github.com/SundayWindy/ExcelAlchemy?category=lines)](https://github.com/SundayWindy/ExcelAlchemy) -ExcelAlchemy is a Python library that allows you to download Excel files from Minio, parse user inputs, and generate corresponding Pydantic classes. It also allows you to generate Excel files based on Pydantic classes for easy user downloads. +[中文 README](./README_cn.md) · [About](./ABOUT.md) · [Architecture](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [Migration Notes](./MIGRATIONS.md) -## Installation +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 +and cells, and produce locale-aware result workbooks. -Use pip to install: +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. -``` -pip install ExcelAlchemy -``` +The current stable release line is `2.0.0`, the first public stable release of ExcelAlchemy 2.0. + +## At a Glance + +- Build Excel templates directly from typed Pydantic schemas +- Validate uploaded workbooks and write failures back to rows and cells +- Keep storage pluggable through `ExcelStorage` +- Render workbook-facing text in `zh-CN` or `en` +- Stay lightweight at runtime with `openpyxl` instead of pandas +- Protect behavior with contract tests, `ruff`, and `pyright` + +## Screenshots -## Usage +| Template | Import Result | +| --- | --- | +| ![Excel template screenshot](./images/portfolio-template-en.png) | ![Excel import result screenshot](./images/portfolio-import-result-en.png) | -### Generate Excel template from Pydantic class +## Minimal Example ```python -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String from pydantic import BaseModel +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String + + class Importer(BaseModel): age: Number = FieldMeta(label='Age', order=1) name: String = FieldMeta(label='Name', order=2) - phone: String | None = FieldMeta(label='Phone', order=3) - address: String | None = FieldMeta(label='Address', order=4) -alchemy = ExcelAlchemy(ImporterConfig(Importer)) -base64content = alchemy.download_template() -print(base64content) +alchemy = ExcelAlchemy(ImporterConfig(Importer, locale='en')) +template = alchemy.download_template_artifact(filename='people-template.xlsx') + +excel_bytes = template.as_bytes() +template_data_url = template.as_data_url() # compatibility path for older browser integrations ``` -* The above is a simple example of generating an Excel template from a Pydantic class. The Excel template will have a sheet named "Sheet1" with four columns: "Age", "Name", "Phone", and "Address". "Age" and "Name" are required fields, while "Phone" and "Address" are optional. -* The method returns a base64-encoded string that represents the Excel file. You can directly use the window.open method to open the Excel file in the front-end, or download it by typing the base64 content in the browser's address bar. -* When downloading a template, you can also specify some default values, for example: + +## Modern Annotated Example ```python -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -from pydantic import BaseModel +from typing import Annotated + +from pydantic import BaseModel, Field + +from excelalchemy import Email, ExcelAlchemy, ExcelMeta, ImporterConfig + class Importer(BaseModel): - age: Number = FieldMeta(label='Age', order=1) - name: String = FieldMeta(label='Name', order=2) - phone: String | None = FieldMeta(label='Phone', order=3) - address: String | None = FieldMeta(label='Address', order=4) - -alchemy = ExcelAlchemy(ImporterConfig(Importer)) - -sample = [ - {'age': 18, 'name': 'Bob', 'phone': '12345678901', 'address': 'New York'}, - {'age': 19, 'name': 'Alice', 'address': 'Shanghai'}, - {'age': 20, 'name': 'John', 'phone': '12345678901'}, -] -base64content = alchemy.download_template(sample) -print(base64content) + email: Annotated[ + Email, + Field(min_length=10), + ExcelMeta(label='Email', order=1, hint='Use your work email'), + ] + + +alchemy = ExcelAlchemy(ImporterConfig(Importer, locale='en')) +template = alchemy.download_template_artifact(filename='people-template.xlsx') ``` -In the above example, we specify a sample, which is a list of dictionaries. Each dictionary represents a row in the Excel sheet, and the keys represent column names. The method returns an Excel template with default values filled in. If a field doesn't have a default value, it will be empty. For example: -* ![image](https://github.com/SundayWindy/ExcelAlchemy/raw/main/images/001_sample_template.png) -### Parse a Pydantic class from an Excel file and create data +For browser downloads, prefer `template.as_bytes()` with a `Blob`, or return the bytes from your backend with +`Content-Disposition: attachment`. A top-level navigation to a long `data:` URL is less reliable in modern browsers. -```python -import asyncio -from typing import Any +## Repository Scope + +- A library for building Excel workflows from typed schemas. +- A reference implementation of “facade outside, focused components inside”. +- A portfolio project that emphasizes architecture, migration strategy, and maintainability. + +## Non-Goals + +- Not a general spreadsheet analysis library. +- Not a pandas-first data wrangling tool. +- Not a GUI spreadsheet editor. +- Not a fully generic forms framework. + +## Why This Exists + +Many internal systems still receive business data through Excel. +The painful part is rarely “reading a file”; it is keeping templates, validation rules, row-level error reporting, and backend integration consistent across projects. + +ExcelAlchemy treats Excel as a typed contract: + +- the model defines the shape +- field metadata defines the workbook experience +- import execution is separated from parsing +- storage is an interchangeable strategy, not a hard-coded implementation + +## Architecture + +ExcelAlchemy exposes a small public surface and delegates the real work to internal components. + +```mermaid +flowchart TD + A[ExcelAlchemy Facade] + A --> B[ExcelSchemaLayout] + A --> C[ExcelHeaderParser / Validator] + A --> D[RowAggregator] + A --> E[ImportExecutor] + A --> F[ExcelRenderer / writer.py] + A --> G[ExcelStorage Protocol] + + G --> H[MinioStorageGateway] + G --> I[Custom Storage] + + B --> J[FieldMeta / FieldMetaInfo] + E --> K[Pydantic Adapter] + F --> L[i18n Display Messages] + E --> M[Runtime Error Messages] +``` + +See the full breakdown in [docs/architecture.md](./docs/architecture.md). + +## Workflow + +```mermaid +flowchart LR + A[Pydantic model + FieldMeta] --> B[ExcelAlchemy facade] + B --> C[Template rendering] + B --> D[Worksheet parsing] + D --> E[Header validation] + D --> F[Row aggregation] + F --> G[Import executor] + G --> H[Import result workbook] + C --> I[Workbook for users] + H --> I +``` + +## Design Principles + +This repository is guided by explicit design principles rather than accidental convenience. +The full mapping is in [ABOUT.md](./ABOUT.md); the short version is: + +1. Schema first. +2. Explicit metadata over implicit conventions. +3. Composition over monoliths. +4. Adapters at integration boundaries. +5. Protocols over concrete backends. +6. Progressive modernization over one-shot rewrites. +7. Runtime simplicity over hidden magic. +8. User-facing clarity over clever internals. +9. Tests should protect behavior, not implementation accidents. +10. Migration-friendly seams are part of the design. + +## Quick Start + +### Install + +```bash +pip install ExcelAlchemy +``` + +If you want the built-in Minio backend: + +```bash +pip install "ExcelAlchemy[minio]" +``` + +## Locale-Aware Workbook Output + +`locale` affects workbook-facing display text such as: +- header hint text +- column comments +- result workbook column titles +- row validation status labels + +The public locale policy is documented in [docs/locale.md](./docs/locale.md). +In short: + +- runtime exceptions are standardized in English +- workbook display locales currently support `zh-CN` and `en` +- workbook display defaults to `zh-CN` for the 2.x line + +```python from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -from minio import Minio from pydantic import BaseModel class Importer(BaseModel): age: Number = FieldMeta(label='Age', order=1) name: String = FieldMeta(label='Name', order=2) - phone: String | None = FieldMeta(label='Phone', order=3) - address: String | None = FieldMeta(label='Address', order=4) -def data_converter(data: dict[str, Any]) -> dict[str, Any]: - """Custom data converter, here you can modify the result of Importer.dict()""" - data['age'] = data['age'] + 1 - data['name'] = {"phone": data['phone']} - return data +zh_template = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')).download_template_artifact() +en_template = ExcelAlchemy(ImporterConfig(Importer, locale='en')).download_template_artifact() +``` + +The same `locale` also controls import result workbooks: +```python +alchemy = ExcelAlchemy( + ImporterConfig( + Importer, + creator=create_func, + storage=storage, + locale='en', + ) +) +result = await alchemy.import_data("people.xlsx", "people-result.xlsx") +``` -async def create_func(data: dict[str, Any], context: None) -> Any: - """Your defined creation function""" - # do something to create data - return True +## Storage Protocol +Storage is modeled as a protocol, not a product decision. -async def main(): - alchemy = ExcelAlchemy( - ImporterConfig( - create_importer_model=Importer, - creator=create_func, - data_converter=data_converter, - minio=Minio(endpoint=''), # reachable minio address - bucket_name='excel', - url_expires=3600, - ) - ) - result = await alchemy.import_data(input_excel_name='test.xlsx', output_excel_name="test.xlsx") - print(result) +```python +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, UrlStr +from excelalchemy.core.table import WorksheetTable -asyncio.run(main()) +class InMemoryExcelStorage(ExcelStorage): + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + ... + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + ... + + +alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=InMemoryExcelStorage())) ``` -* The importing function is based on `Minio`, so you need to install Minio and create a bucket to use this functionality for storing the Excel files. +Use the built-in Minio implementation when you want it, but the library no longer requires Minio to define its architecture. + +## Why These Design Choices + +### Why no pandas? -* The imported Excel file must be generated by the `download_template()` method, otherwise, it will produce a parsing error. -* In the above example, we define a `data_converter` function, which is used to modify the result of `Importer.dict().` The final result of `data_converter` function will be the parameter of the create_func function. This function is optional if you don't need to modify the data. -* The `create_func` function is used to create data, and the parameter is the result of the data_converter function, and context is None. You can create data, for example, by storing the data in a database. -* The `input_excel_name` parameter of the `import_data()` method is the name of the Excel file in Minio, and the `output_excel_name` parameter is the name of the Excel file with the parsing result in Minio. This file contains all the input data, and if any data fails the parsing, the first column of that data has an error message, and the error-producing cell is highlighted in red. -* The method returns an `ImportResult` type result. You can see the definition of this class in the code. This class contains all the information about the parsing result, such as the number of successfully imported data, the number of failed data, the failed data, etc. -* An example of the importing result is shown in the following image: -![image](https://github.com/SundayWindy/ExcelAlchemy/raw/main/images/002_import_result.png) +ExcelAlchemy uses `openpyxl` plus an internal `WorksheetTable` abstraction. +`WorksheetTable` is intentionally narrow and only models the operations the core +workflow needs; it is not a pandas-compatible public table layer. +The project was not using pandas for analysis, joins, or vectorized computation; it was mostly using it as a transport layer. +Removing pandas: +- simplified installation +- removed the `numpy` dependency chain +- made behavior more explicit +- better aligned the code with the actual problem domain + +### Why a Pydantic adapter layer? + +The project used to lean on Pydantic internals more directly. +That becomes fragile during major-version upgrades. +Now the design is: + +- `FieldMeta` owns Excel metadata +- the Pydantic adapter reads model structure +- the adapter does not own the domain semantics + +This is what made the Pydantic v2 migration practical without rewriting the public API. + +### Why a facade? + +The public object should stay small. +The internal object graph can evolve. +`ExcelAlchemy` is the facade; parsing, rendering, execution, storage, and schema layout are delegated to separate collaborators. + +### Why a storage protocol? + +Excel workflows should not be locked to Minio, S3, or any one persistence strategy. +`ExcelStorage` keeps the boundary stable while allowing object storage, local filesystem adapters, in-memory test doubles, +and custom infrastructure integrations to share the same import/export contract. + +## Evolution + +This repository intentionally records its evolution: + +- `src/` layout migration +- CI and release modernization +- Pydantic metadata decoupling +- Pydantic v2 migration +- Python 3.12-3.14 modernization +- internal architecture split +- pandas removal +- storage abstraction +- i18n foundation and locale-aware workbook text + +These are not incidental refactors; they are the story of the codebase. +See [ABOUT.md](./ABOUT.md) for the migration rationale behind each step. + +## Pydantic v1 vs v2 + +The short version: + +| Topic | v1-style risk | Current v2 design | +| --- | --- | --- | +| Field access | Tight coupling to `__fields__` / `ModelField` | Adapter over `model_fields` | +| Metadata ownership | Excel metadata mixed with validation internals | `FieldMetaInfo` owns Excel metadata | +| Validation integration | Deep reliance on internals | Adapter + explicit runtime validation | +| Upgrade path | Brittle | Layered | + +More detail is documented in [ABOUT.md](./ABOUT.md). + +## Docs Map + +- [README.md](./README.md): product + design overview +- [README_cn.md](./README_cn.md): Chinese usage-oriented guide +- [ABOUT.md](./ABOUT.md): engineering rationale and evolution notes +- [docs/architecture.md](./docs/architecture.md): component map and boundaries + +## Development + +The project uses `uv` for local development and CI. + +```bash +uv sync --extra development +uv run pre-commit install +uv run ruff check . +uv run pyright +uv run pytest --cov=excelalchemy --cov-report=term-missing:skip-covered tests +uv build +``` -### Contributing -If you have any questions or suggestions regarding the ExcelAlchemy library, please raise an issue in [GitHub Issues](https://github.com/SundayWindy/ExcelAlchemy/issues). We also welcome you to submit a pull request to contribute your code. +## License -### License -ExcelAlchemy is licensed under the MIT license. For more information, please see the [LICENSE](https://github.com/SundayWindy/ExcelAlchemy/blob/main/LICENSE) file. +MIT. See [LICENSE](./LICENSE). diff --git a/README_cn.md b/README_cn.md index b5d0ab5..e59330c 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,42 +1,146 @@ -> [English](https://github.com/SundayWindy/ExcelAlchemy) | 中文 -> -# ExcelAlchemy 使用指南 +# ExcelAlchemy -# 📊 ExcelAlchemy +[English README](./README.md) · [项目说明](./ABOUT.md) · [架构文档](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [迁移说明](./MIGRATIONS.md) -ExcelAlchemy 是一个用于从 Minio 下载 Excel 文件,解析用户输入并生成对应 Pydantic 类的 Python 库,同时也可以将 Pydantic 数据生成对应的 Excel,便于用户下载。 +ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 +它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 -## 安装 +当前稳定发布版本是 `2.0.0`,也就是 ExcelAlchemy 2.0 的首个公开正式版。 + +你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 + +## 截图 + +这些截图由仓库内的 [`scripts/generate_portfolio_assets.py`](./scripts/generate_portfolio_assets.py) 生成。 + +| 模板 | 导入结果 | +| --- | --- | +| ![Excel 模板截图](./images/portfolio-template-en.png) | ![Excel 导入结果截图](./images/portfolio-import-result-en.png) | + +## 这个项目适合什么 + +- 需要给业务方发 Excel 模板并回收数据 +- 需要把 Excel 输入和后端模型保持一致 +- 需要在失败结果中明确指出哪一格有问题 +- 想要一个可以接自定义存储的 Excel 工作流库 + +## 这个项目不打算做什么 + +- 不做通用表格分析库 +- 不做 pandas 风格的数据处理框架 +- 不做桌面表格编辑器 +- 不追求“魔法式自动推断一切” + +## 核心特点 + +- 基于 Pydantic v2 的 schema 驱动设计 +- 支持 `locale='zh-CN' | 'en'` 的 Excel 展示文案 +- 支持可插拔存储后端 `ExcelStorage` +- 运行时不依赖 pandas +- 支持 Python 3.12-3.14,主支持版本是 3.14 +- 使用 `uv` 管理开发与 CI + +## 项目定位 + +这个仓库不只是“一个能用的库”,也是一个展示工程思考的作品: + +- 为什么要从 Pydantic v1 迁到 v2 +- 为什么要去掉 pandas +- 为什么要做 storage abstraction +- 为什么 facade 外面要简洁,里面要分层 +- 为什么国际化先从消息层和 workbook display text 开始 + +详细设计思路见 [ABOUT.md](./ABOUT.md)。 + +## 架构概览 + +```mermaid +flowchart TD + A[ExcelAlchemy 门面] + A --> B[ExcelSchemaLayout] + A --> C[ExcelHeaderParser / Validator] + A --> D[RowAggregator] + A --> E[ImportExecutor] + A --> F[ExcelRenderer / writer.py] + A --> G[ExcelStorage 协议] -使用 pip 安装: + G --> H[MinioStorageGateway] + G --> I[自定义存储实现] + B --> J[FieldMeta / FieldMetaInfo] + E --> K[Pydantic Adapter] + F --> L[i18n Display Messages] + E --> M[Runtime Error Messages] ``` + +完整分层说明见 [docs/architecture.md](./docs/architecture.md)。 + +## 工作流概览 + +```mermaid +flowchart LR + A[Pydantic 模型 + FieldMeta] --> B[ExcelAlchemy 门面] + B --> C[模板渲染] + B --> D[Worksheet 解析] + D --> E[表头校验] + D --> F[行聚合] + F --> G[导入执行器] + G --> H[导入结果工作簿] + C --> I[给用户的工作簿] + H --> I +``` + +## 安装 + +```bash pip install ExcelAlchemy ``` -## 使用方法 +如果你要使用内置的 Minio 后端: + +```bash +pip install "ExcelAlchemy[minio]" +``` -### 从 Pydantic 类生成 Excel 模板 +## 快速开始 ```python -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String from pydantic import BaseModel +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String + class Importer(BaseModel): age: Number = FieldMeta(label='年龄', order=1) - name: String = FieldMeta(label='名称', order=2) - phone: String | None = FieldMeta(label='电话', order=3) - address: String | None = FieldMeta(label='地址', order=4) + name: String = FieldMeta(label='姓名', order=2) + alchemy = ExcelAlchemy(ImporterConfig(Importer)) -base64content = alchemy.download_template() -print(base64content) +template = alchemy.download_template_artifact(filename='people-template.xlsx') +excel_bytes = template.as_bytes() +template_data_url = template.as_data_url() # 兼容旧的浏览器集成方式 ``` -* 上面是一个简单的例子,从 Pydantic 类生成 Excel 模板,Excel 模版中将会有一个 Sheet,Sheet 名称为 `Sheet1`,并且会有四列,分别为 `年龄`、`名称`、`电话`、`地址`,其中 `年龄`、`名称` 为必填项,`电话`、`地址` 为可选项。 -* 返回一个 base64 编码的 Excel 字符串,可以直接在前端页面中使用 `window.open` 方法打开 Excel 文件,或者在浏览器地址栏中输入 base64content,即可下载 Excel 文件。 -* 在下载模版时,您也可以指定一些默认值,例如: + +浏览器下载时,优先使用 `excel_bytes` 构造 `Blob`,或者让后端直接返回二进制并带上 +`Content-Disposition: attachment`。现代浏览器对超长 `data:` URL 的顶层导航并不稳定。 + +## 选择模板 / 结果语言 + +`locale` 会影响 Excel 里真正给用户看的文案,例如: + +- 第一行填写须知 +- 表头批注 +- 结果列标题 +- “校验通过 / 校验不通过” 文本 + +默认是 `zh-CN`,如果你想生成英文模板或英文结果工作簿,可以传 `locale='en'`。 + +更完整的公共策略见 [docs/locale.md](./docs/locale.md): + +- 运行时异常默认并稳定使用英文 +- workbook 展示文案当前支持 `zh-CN` 和 `en` +- 2.x 版本线默认 workbook locale 仍是 `zh-CN` ```python from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String @@ -44,90 +148,120 @@ from pydantic import BaseModel class Importer(BaseModel): - age: Number = FieldMeta(label='年龄', order=1) - name: String = FieldMeta(label='名称', order=2) - phone: String | None = FieldMeta(label='电话', order=3) - address: String | None = FieldMeta(label='地址', order=4) + age: Number = FieldMeta(label='Age', order=1) + name: String = FieldMeta(label='Name', order=2) -alchemy = ExcelAlchemy(ImporterConfig(Importer)) -sample = [ - {'age': 18, 'name': '张三', 'phone': '12345678901', 'address': '北京市'}, - {'age': 19, 'name': '李四', 'address': '上海市'}, - {'age': 20, 'name': '王五', 'phone': '12345678901'}, -] -base64content = alchemy.download_template(sample) -print(base64content) +template_zh = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')).download_template_artifact() +template_en = ExcelAlchemy(ImporterConfig(Importer, locale='en')).download_template_artifact() +``` + +导入结果工作簿也会使用同一个 `locale`: + +```python +alchemy = ExcelAlchemy( + ImporterConfig( + Importer, + creator=create_func, + storage=storage, + locale='en', + ) +) +result = await alchemy.import_data("people.xlsx", "people-result.xlsx") ``` -* 上面的例子中,我们指定了一个 `sample`,`sample` 是一个列表,列表中的每个元素都是一个字典,字典中的键为 Pydantic 类中的字段名,值为该字段的默认值。 -* 最终下载的 Excel 文件中,`Sheet1` 中的第一行为字段名,第二行开始为默认值,如果某个字段没有默认值,则该字段为空,如图所示: -* ![image](https://github.com/SundayWindy/ExcelAlchemy/raw/main/images/001_sample_template.png) +## 存储扩展点 -### 从 Excel 解析 Pydantic 类并创建数据 +ExcelAlchemy 接受任何实现了 `ExcelStorage` 协议的存储后端。 ```python -import asyncio -from typing import Any +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, UrlStr +from excelalchemy.core.table import WorksheetTable -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -from minio import Minio -from pydantic import BaseModel +class InMemoryExcelStorage(ExcelStorage): + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + ... -class Importer(BaseModel): - age: Number = FieldMeta(label='年龄', order=1) - name: String = FieldMeta(label='名称', order=2) - phone: String | None = FieldMeta(label='电话', order=3) - address: String | None = FieldMeta(label='地址', order=4) - - -def data_converter(data: dict[str, Any]) -> dict[str, Any]: - """自定义数据转换器, 在这里,你可以对 Importer.dict() 的结果进行转换""" - data['age'] = data['age'] + 1 - data['name'] = {"phone": data['phone']} - return data - - -async def create_func(data: dict[str, Any], context: None) -> Any: - """你定义的创建函数""" - # do something to create data - return True - - -async def main(): - alchemy = ExcelAlchemy( - ImporterConfig( - create_importer_model=Importer, - creator=create_func, - data_converter=data_converter, - minio=Minio(endpoint=''), # 可访问的 minio 地址 - bucket_name='excel', - url_expires=3600, - ) - ) - result = await alchemy.import_data(input_excel_name='test.xlsx', output_excel_name="test.xlsx") - print(result) + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + ... -asyncio.run(main()) +alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=InMemoryExcelStorage())) ``` -* 导入功能的文件基于 Minio,因此在使用该功能前,你需要先安装 Minio,并且在 Minio 中创建一个 bucket,用于存放 Excel 文件。 -* 导入的 Excel 文件,必须是从 `download_template` 方法生成的 Excel 文件,否则会产生解析错误。 -* 上面的示例代码中,我们定义了一个 `data_converter` 函数,该函数用于对 `Importer.dict()` 的结果进行转换,最终返回的结果将会作为 `create_func` 函数的参数。当然,此函数是可选的,如果你不需要对数据进行转换,可以不定义该函数。 -* `create_func` 函数用于创建数据,该函数的参数为 `data_converter` 函数的返回值,`context` 为 `None`,你可以在该函数中对数据进行创建,例如,你可以将数据存入数据库中。 -* `import_data` 方法的参数 `input_excel_name` 为 Excel 文件在 Minio 中的名称,`output_excel_name` 为解析结果 Excel 文件在 Minio 中的名称,该文件包含所有输入的数据,如果某条数据解析失败,则在该条数据的第一列中会有错误信息,并且会讲产生错误的单元格标红。 -* 返回 ImportResult 类型的结果,您可以在代码中查看该类的定义,该类包含了解析结果的所有信息,例如,成功导入的数据条数、失败的数据条数、失败的数据等。 +如果你希望使用内置 Minio 实现,推荐显式传入 `storage=MinioStorageGateway(...)`,而不是再把 Minio 配置散落到门面层。 + +## 为什么这样设计 + +### 为什么去掉 pandas -一个导入结果的示例, 如图所示: -* ![image](https://github.com/SundayWindy/ExcelAlchemy/raw/main/images/002_import_result.png) +这个项目真正需要的是: +- 读写 Excel +- 一个稳定的中间表格抽象 +- 对表头 / 行 / 错误坐标的精确控制 -## 贡献 +并不需要 pandas 擅长的分析能力。 +因此改成 `openpyxl + WorksheetTable` 更贴合问题域,也让安装和依赖稳定性更好。 -如果你在使用 ExcelAlchemy 过程中遇到了问题或者有任何建议,欢迎在 [GitHub Issues](https://github.com/SundayWindy/ExcelAlchemy/issues) 中提出。我们也非常欢迎你提交 Pull Request,贡献你的代码。 +### 为什么做 Pydantic adapter + +Excel 元数据不应该深绑到 Pydantic 内部结构上。 +所以现在的分层是: + +- `FieldMetaInfo` 负责 Excel 元数据 +- `helper/pydantic.py` 只做适配 +- 真正的业务校验仍然由 ExcelAlchemy 控制 + +这就是为什么 Pydantic v2 迁移可以做得比较稳。 + +### 为什么做 storage abstraction + +这个项目不应该等于 Minio。 +Minio 只是一个默认实现,真正稳定的接口应该是 `ExcelStorage`。 + +这样用户可以接: + +- 对象存储 +- 本地文件系统 +- 测试替身 +- 内存存储 + +## 演进记录 + +这个仓库的价值,很大一部分来自它的演进过程: + +- `src/` layout 迁移 +- CI / 发布链路现代化 +- Pydantic 元数据层解耦 +- Pydantic v2 迁移 +- Python 3.12-3.14 现代化 +- 核心架构拆分 +- 去 pandas 化 +- 存储抽象化 +- 国际化基础层与 workbook locale 化 + +这些不是零碎优化,而是整套工程判断的痕迹。 + +## 文档索引 + +- [README.md](./README.md): 英文首页,偏作品集表达 +- [README_cn.md](./README_cn.md): 中文说明页,偏使用和理解 +- [ABOUT.md](./ABOUT.md): 设计原则、迁移记录、架构取舍 +- [docs/architecture.md](./docs/architecture.md): 组件边界与扩展点 + +## 开发 + +```bash +uv sync --extra development +uv run pre-commit install +uv run ruff check . +uv run pyright +uv run pytest --cov=excelalchemy --cov-report=term-missing:skip-covered tests +uv build +``` ## 许可证 -ExcelAlchemy 使用 MIT 许可证。详细信息请参阅 [LICENSE](https://github.com/SundayWindy/ExcelAlchemy/blob/main/LICENSE)。 +MIT。详见 [LICENSE](./LICENSE)。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..82ad7ba --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +## Supported Versions + +Security fixes are best-effort for the latest released version of ExcelAlchemy. + +## Reporting a Vulnerability + +Please do not open public GitHub issues for security-sensitive reports. + +Use GitHub Security Advisories when possible: + +- https://github.com/RayCarterLab/ExcelAlchemy/security/advisories/new + +If that workflow is unavailable, contact the maintainers privately and include: + +- a clear description of the issue +- affected versions +- reproduction steps or a proof of concept +- any suggested mitigations + +We will acknowledge valid reports as quickly as possible, confirm impact, and coordinate a fix before public disclosure when appropriate. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..bd52f0f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,19 @@ +codecov: + require_ci_to_pass: true + +coverage: + status: + project: + default: + target: 85% + if_ci_failed: error + patch: + default: + informational: true + if_ci_failed: error + +ignore: + - "src/excelalchemy/types/**" + - "src/excelalchemy/exc.py" + - "src/excelalchemy/identity.py" + - "src/excelalchemy/header_models.py" diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..011e8d4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,177 @@ +# Architecture + +## Component Map + +```mermaid +flowchart TD + A[ExcelAlchemy Facade] + A --> B[ExcelSchemaLayout] + A --> C[ExcelHeaderParser] + A --> D[ExcelHeaderValidator] + A --> E[RowAggregator] + A --> F[ImportExecutor] + A --> G[ExcelRenderer] + A --> H[ExcelStorage] + + B --> I[FieldMeta / FieldMetaInfo] + F --> J[Pydantic Adapter] + G --> K[writer.py] + H --> L[MinioStorageGateway] + H --> M[Custom Storage] + G --> N[i18n Display Messages] + F --> O[Runtime Messages] +``` + +## Workflow Map + +```mermaid +flowchart LR + A[Pydantic model + FieldMeta] --> B[ExcelAlchemy facade] + B --> C[ExcelSchemaLayout] + B --> D[ExcelHeaderParser / Validator] + B --> E[RowAggregator] + B --> F[ImportExecutor] + B --> G[ExcelRenderer] + B --> H[ExcelStorage] + G --> I[Workbook output] + F --> J[Import result workbook] +``` + +## Layer Responsibilities + +### Facade + +`src/excelalchemy/core/alchemy.py` + +- owns the user-facing workflow +- coordinates import/export operations +- keeps the top-level API compact + +### Schema + +`src/excelalchemy/core/schema.py` + +- extracts Excel-facing layout from models +- expands composite fields +- validates ordering assumptions + +### Headers + +`src/excelalchemy/core/headers.py` + +- parses simple and merged headers +- validates workbook header rows against schema layout + +### Rows + +`src/excelalchemy/core/rows.py` + +- aggregates flattened worksheet rows back into model-shaped payloads +- maps row/cell errors back into workbook coordinates + +### Executor + +`src/excelalchemy/core/executor.py` + +- validates row payloads +- dispatches create/update/upsert logic +- isolates backend execution from parsing concerns + +### Rendering + +`src/excelalchemy/core/rendering.py` +`src/excelalchemy/core/writer.py` + +- turns worksheet tables into workbook payloads +- applies comments, colors, result columns, and workbook hint text + +### Storage + +`src/excelalchemy/core/storage_protocol.py` +`src/excelalchemy/core/storage.py` +`src/excelalchemy/core/storage_minio.py` + +- defines a stable storage contract +- resolves configured storage strategy +- ships one built-in Minio implementation + +### Metadata + +`src/excelalchemy/metadata.py` + +- owns Excel field metadata +- exposes workbook comment fragments +- keeps runtime metadata separate from validation backend internals + +### Pydantic Integration + +`src/excelalchemy/helper/pydantic.py` + +- adapts Pydantic models to ExcelAlchemy needs +- shields the rest of the codebase from version-specific framework details + +### Internationalization + +`src/excelalchemy/i18n/messages.py` + +- separates runtime errors from workbook display text +- provides locale-aware workbook-facing messages + +## Extension Points + +### Custom Storage + +Implement `ExcelStorage` when you want a different backend. + +### Custom Field Codecs + +Implement a new `ExcelFieldCodec` or `CompositeExcelFieldCodec` when you want custom workbook semantics. +Built-in field annotations keep concise aliases like `Email` and `DateRange`, while the `*Codec` names expose the adapter role more explicitly. + +### Field Declaration Styles + +Both declaration styles are supported: + +- `FieldMeta(...)` as the concise compatibility-friendly syntax sugar +- `Annotated[T, Field(...), ExcelMeta(...)]` as the more explicit Pydantic v2-first style + +### Data Conversion + +Use `data_converter` when the workbook schema should not map 1:1 to backend payloads. + +### Locale + +Use `locale='zh-CN' | 'en'` to control workbook-facing display text without changing runtime exception language. + +## Module Layout + +- `src/excelalchemy/codecs/`: built-in Excel field codecs and codec base abstractions +- `src/excelalchemy/metadata.py`: Excel-specific field metadata and declaration helpers +- `src/excelalchemy/config.py`: importer/exporter configuration models +- `src/excelalchemy/exceptions.py`: public exception types +- `src/excelalchemy/_primitives/identity.py`: private typed string and index wrappers used across the core layer +- `src/excelalchemy/_primitives/constants.py`: private constant and enum definitions +- `src/excelalchemy/results.py`: import/export result models +- `src/excelalchemy/_primitives/header_models.py`: private workbook header model objects +- `src/excelalchemy/_primitives/deprecation.py`: private deprecation helpers used by compatibility shims +- `src/excelalchemy/types/`: compatibility import layer for pre-refactor paths +- `src/excelalchemy/exc.py`, `src/excelalchemy/identity.py`, `src/excelalchemy/header_models.py`, `src/excelalchemy/const.py`: compatibility or low-level facade modules kept at the package root + +Compatibility policy: + +- `excelalchemy.types.*` and `excelalchemy.types.value.*` remain available throughout the 2.x line +- those imports emit `ExcelAlchemyDeprecationWarning` at import time +- the compatibility layer is scheduled for removal in ExcelAlchemy 3.0 +- `excelalchemy.exc` now points to the public `excelalchemy.exceptions` module +- `excelalchemy.identity` and `excelalchemy.header_models` remain as 2.x compatibility imports; prefer the package root or internal modules only + +## Architectural Intent + +The codebase is designed around stable seams: + +- facade vs collaborators +- metadata vs validation backend +- storage protocol vs concrete storage +- workbook display text vs runtime messages + +Those seams are what made the later migrations possible without rewriting the whole project. diff --git a/docs/locale.md b/docs/locale.md new file mode 100644 index 0000000..58c3cbf --- /dev/null +++ b/docs/locale.md @@ -0,0 +1,73 @@ +# Locale Policy + +## Scope + +ExcelAlchemy currently distinguishes between two kinds of language output: + +- 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. + +## Runtime Message Policy + +- Supported runtime locale set: `('en',)` +- Default runtime locale: `en` +- Stability policy: runtime exceptions are intentionally standardized in English for the 2.x line + +This means error messages raised in Python code are expected to stay English unless the +project explicitly announces broader runtime i18n support in a future release. + +## Workbook Display Locale Policy + +- Supported workbook display locales: `('zh-CN', 'en')` +- Default workbook display locale: `zh-CN` +- Stability policy: the default workbook locale is considered stable for the 2.x line + +Workbook display locale affects user-facing spreadsheet text such as: + +- import instructions in the first row +- header comments +- result and reason column labels +- row validation status text +- composite child labels such as start/end date and min/max value +- workbook-facing boolean values such as `Yes/No` or `是/否` + +## Fallback Rules + +- Runtime messages fall back to the runtime default locale: `en` +- Workbook display messages fall back to the workbook display default locale: `zh-CN` + +If you pass an unsupported locale today, ExcelAlchemy will continue working and fall back +to the default locale for that message layer. + +## Recommended Usage + +Use `locale='zh-CN'` when the workbook is meant for Chinese-speaking spreadsheet users. + +Use `locale='en'` when the workbook is meant for English-speaking spreadsheet users. + +Examples: + +```python +from excelalchemy import ExcelAlchemy, ImporterConfig + +alchemy_zh = ExcelAlchemy(ImporterConfig(ImporterModel, creator=create_func, locale='zh-CN')) +alchemy_en = ExcelAlchemy(ImporterConfig(ImporterModel, creator=create_func, locale='en')) +``` + +## Compatibility Notes + +- Constants in `excelalchemy.const` such as `HEADER_HINT`, `RESULT_COLUMN_LABEL`, and `REASON_COLUMN_LABEL` + remain available as compatibility helpers and represent the stable `zh-CN` defaults. +- Locale-aware behavior should be driven through `ImporterConfig(..., locale=...)` and + `ExporterConfig(..., locale=...)`, not by reading those constants directly. + +## Future Direction + +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 diff --git a/docs/releases/2.0.0.md b/docs/releases/2.0.0.md new file mode 100644 index 0000000..42b78f3 --- /dev/null +++ b/docs/releases/2.0.0.md @@ -0,0 +1,70 @@ +# 2.0.0 Release Checklist + +This checklist is intended for the first stable public release of the 2.0 line. + +## Purpose + +- Publish the first stable ExcelAlchemy 2.0 release +- Verify the modernized PyPI publishing workflow end to end +- Confirm the public package metadata, documentation, and release notes are ready for general use + +## Before Tagging + +1. Confirm `src/excelalchemy/__init__.py` is set to `2.0.0`. +2. Review `CHANGELOG.md`, `MIGRATIONS.md`, and `README.md` for final release wording. +3. Ensure the package metadata in `pyproject.toml` matches the intended public release posture. + +## Local Verification + +Run these commands from the repository root: + +```bash +uv sync --extra development +uv run ruff check . +uv run pyright +uv run pytest tests +uv build +uvx twine check dist/* +``` + +Optional smoke tests: + +```bash +uv venv .pkg-smoke-base --python 3.14 +uv pip install --python .pkg-smoke-base/bin/python dist/*.whl +.pkg-smoke-base/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" +``` + +```bash +uv venv .pkg-smoke-minio --python 3.14 +uv pip install --python .pkg-smoke-minio/bin/python "dist/excelalchemy-2.0.0-py3-none-any.whl[minio]" +.pkg-smoke-minio/bin/python -c "from excelalchemy.core.storage_minio import MinioStorageGateway; print(MinioStorageGateway.__name__)" +``` + +## 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.0.0`. +4. Use the `2.0.0` section from `CHANGELOG.md` as the release notes base. +5. Publish the release and monitor the `Upload Python Package` workflow. + +## PyPI Verification + +After the workflow completes: + +1. Confirm the release is shown as a stable release on PyPI. +2. Test base install: + +```bash +pip install ExcelAlchemy +``` + +3. Test optional Minio install: + +```bash +pip install "ExcelAlchemy[minio]" +``` + +4. Run a minimal template-generation example. +5. Run one storage-backed flow, either with Minio or a custom `ExcelStorage` implementation. diff --git a/docs/releases/2.0.0rc1.md b/docs/releases/2.0.0rc1.md new file mode 100644 index 0000000..3f7954b --- /dev/null +++ b/docs/releases/2.0.0rc1.md @@ -0,0 +1,80 @@ +# 2.0.0rc1 Release Checklist + +This checklist is intended for the first public release candidate of the 2.0 line. + +## Purpose + +- Validate the modernized PyPI publishing workflow +- Validate the new dependency layout, including optional Minio support +- Publish a pre-release that communicates the 2.0 direction clearly + +## Before Tagging + +1. Confirm `src/excelalchemy/__init__.py` is set to `2.0.0rc1`. +2. Review `CHANGELOG.md` and `MIGRATIONS.md` for accuracy. +3. Ensure `README.md`, `README_cn.md`, and `ABOUT.md` match the current architecture and install instructions. + +## Local Verification + +Run these commands from the repository root: + +```bash +uv sync --extra development +uv run ruff check . +uv run pyright +uv run pytest tests +uv build +uvx twine check dist/* +``` + +Optional smoke tests: + +```bash +uv venv .pkg-smoke-base --python 3.14 +uv pip install --python .pkg-smoke-base/bin/python dist/*.whl +.pkg-smoke-base/bin/python -c "import excelalchemy; print(excelalchemy.__version__)" +``` + +```bash +uv venv .pkg-smoke-minio --python 3.14 +uv pip install --python .pkg-smoke-minio/bin/python "dist/excelalchemy-2.0.0rc1-py3-none-any.whl[minio]" +.pkg-smoke-minio/bin/python -c "from excelalchemy.core.storage_minio import MinioStorageGateway; print(MinioStorageGateway.__name__)" +``` + +## 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.0.0rc1`. +4. Mark the release as a pre-release. +5. Use the `2.0.0rc1` section from `CHANGELOG.md` as the release notes base. +6. Publish the release and monitor the `Upload Python Package` workflow. + +## PyPI Verification + +After the workflow completes: + +1. Confirm the release is shown as a pre-release on PyPI. +2. Test base install: + +```bash +pip install --pre ExcelAlchemy +``` + +3. Test optional Minio install: + +```bash +pip install --pre "ExcelAlchemy[minio]" +``` + +4. Run a minimal template-generation example. +5. Run one storage-backed flow, either with Minio or a custom `ExcelStorage` implementation. + +## Final 2.0.0 Gate + +Before promoting to the final `2.0.0`, confirm: + +- no release-blocking regressions were found in `2.0.0rc1` +- installation works for both base and `minio` extra users +- public documentation is stable +- the final `2.0.0` changelog entry is ready diff --git a/excelalchemy/__init__.py b/excelalchemy/__init__.py deleted file mode 100644 index fdab926..0000000 --- a/excelalchemy/__init__.py +++ /dev/null @@ -1,93 +0,0 @@ -"""A Python Library for Reading and Writing Excel Files""" - -__version__ = '1.1.0' -from excelalchemy.const import CharacterSet -from excelalchemy.const import DataRangeOption -from excelalchemy.const import DateFormat -from excelalchemy.const import Option -from excelalchemy.core.alchemy import ExcelAlchemy -from excelalchemy.exc import ConfigError -from excelalchemy.exc import ExcelCellError -from excelalchemy.exc import ProgrammaticError -from excelalchemy.helper.pydantic import extract_pydantic_model -from excelalchemy.types.alchemy import ExporterConfig -from excelalchemy.types.alchemy import ImporterConfig -from excelalchemy.types.alchemy import ImportMode -from excelalchemy.types.field import FieldMeta -from excelalchemy.types.field import PatchFieldMeta -from excelalchemy.types.identity import ColumnIndex -from excelalchemy.types.identity import Key -from excelalchemy.types.identity import Label -from excelalchemy.types.identity import OptionId -from excelalchemy.types.identity import RowIndex -from excelalchemy.types.identity import UniqueKey -from excelalchemy.types.identity import UniqueLabel -from excelalchemy.types.result import ImportResult -from excelalchemy.types.result import ValidateHeaderResult -from excelalchemy.types.result import ValidateResult -from excelalchemy.types.result import ValidateRowResult -from excelalchemy.types.value.boolean import Boolean -from excelalchemy.types.value.date import Date -from excelalchemy.types.value.date_range import DateRange -from excelalchemy.types.value.email import Email -from excelalchemy.types.value.money import Money -from excelalchemy.types.value.multi_checkbox import MultiCheckbox -from excelalchemy.types.value.number import Number -from excelalchemy.types.value.number_range import NumberRange -from excelalchemy.types.value.organization import MultiOrganization -from excelalchemy.types.value.organization import SingleOrganization -from excelalchemy.types.value.phone_number import PhoneNumber -from excelalchemy.types.value.radio import Radio -from excelalchemy.types.value.staff import MultiStaff -from excelalchemy.types.value.staff import SingleStaff -from excelalchemy.types.value.string import String -from excelalchemy.types.value.tree import MultiTreeNode -from excelalchemy.types.value.tree import SingleTreeNode -from excelalchemy.types.value.url import Url -from excelalchemy.util.file import flatten - -__all__ = [ - 'Boolean', - 'ColumnIndex', - 'Date', - 'DateFormat', - 'DateRange', - 'DataRangeOption', - 'Email', - 'ExcelAlchemy', - 'ExcelCellError', - 'ExporterConfig', - 'FieldMeta', - 'ImportMode', - 'ImportResult', - 'ImporterConfig', - 'Key', - 'Label', - 'Money', - 'MultiCheckbox', - 'MultiOrganization', - 'MultiStaff', - 'MultiTreeNode', - 'Number', - 'NumberRange', - 'Option', - 'OptionId', - 'PatchFieldMeta', - 'PhoneNumber', - 'ProgrammaticError', - 'ConfigError', - 'Radio', - 'RowIndex', - 'SingleOrganization', - 'SingleStaff', - 'SingleTreeNode', - 'String', - 'UniqueKey', - 'UniqueLabel', - 'Url', - 'ValidateHeaderResult', - 'ValidateResult', - 'ValidateRowResult', - 'extract_pydantic_model', - 'flatten', -] diff --git a/excelalchemy/const.py b/excelalchemy/const.py deleted file mode 100644 index 6ae024d..0000000 --- a/excelalchemy/const.py +++ /dev/null @@ -1,112 +0,0 @@ -from dataclasses import dataclass -from enum import Enum -from typing import Any -from typing import Dict -from typing import List -from typing import Set -from typing import TypeVar -from typing import Union - -from pydantic import BaseModel - -from excelalchemy.types.identity import Key -from excelalchemy.types.identity import Label -from excelalchemy.types.identity import OptionId - -HEADER_HINT = """ -导入填写须知: -1、填写数据时,请注意查看字段名称上的注释,避免导入失败。 -2、表格中可能包含部分只读字段,可能是根据系统规则自动生成或是在编辑时禁止被修改,仅用于导出时查看,导入时不生效。 -3、字段名称背景是红色的为必填字段,导入时必须根据注释的提示填写好内容。 -4、请不要随意修改列的单元格格式,避免模板校验不通过。 -5、导入前请删除示例数据。 -""" - -EXCEL_COMMENT_FORMAT = {'height': 100, 'width': 300, 'font_size': 7} -CHARACTER_WIDTH = 1.3 -DEFAULT_SHEET_NAME = 'Sheet1' -# 连接符 -UNIQUE_HEADER_CONNECTOR: str = '·' - -# 数据导出结果列 -RESULT_COLUMN_LABEL: Label = Label('校验结果\n重新上传前请删除此列') -RESULT_COLUMN_KEY: Key = Key('__result__') - -# 数据导出原因列 -REASON_COLUMN_LABEL: Label = Label('失败原因\n重新上传前请删除此列') -REASON_COLUMN_KEY: Key = Key('__reason__') - -BACKGROUND_REQUIRED_COLOR = 'FDAFB5' -BACKGROUND_ERROR_COLOR = 'FEC100' -FONT_READ_COLOR = 'FF0000' - -# 多选分隔符 -MULTI_CHECKBOX_SEPARATOR = ',' - -FIELD_DATA_KEY = Key('fieldData') - -# 毫秒转换为秒 -MILLISECOND_TO_SECOND = 1000 - -# options 最多允许的选项数量 -MAX_OPTIONS_COUNT = 100 - -DEFAULT_FIELD_META_ORDER = -1 -DictStrAny = Dict[str, Any] -DictAny = Dict[Any, Any] -SetStr = Set[str] -ListStr = List[str] -IntStr = Union[int, str] -ContextT = TypeVar('ContextT') -ImporterCreateModelT = TypeVar('ImporterCreateModelT', bound=BaseModel) -ImporterUpdateModelT = TypeVar('ImporterUpdateModelT', bound=BaseModel) -ExporterModelT = TypeVar('ExporterModelT', bound=BaseModel) -CreateModelT = TypeVar('CreateModelT', bound=BaseModel) -UpdateModelT = TypeVar('UpdateModelT', bound=BaseModel) - - -class CharacterSet(str, Enum): - CHINESE = 'CHINESE' - NUMBER = 'NUMBER' - LOWERCASE_LETTERS = 'LOWERCASE_LETTERS' - UPPERCASE_LETTERS = 'UPPERCASE_LETTERS' - SPECIAL_SYMBOLS = 'SPECIAL_SYMBOLS' - - -class DateFormat(str, Enum): - YEAR = 'YEAR' - MONTH = 'MONTH' - DAY = 'DAY' - MINUTE = 'MINUTE' - - -class DataRangeOption(str, Enum): - NONE = 'NONE' - PRE = 'PRE' - NEXT = 'NEXT' - - -DATE_FORMAT_TO_PYTHON_MAPPING = { - DateFormat.YEAR: '%Y', - DateFormat.MONTH: '%Y-%m', - DateFormat.DAY: '%Y-%m-%d', - DateFormat.MINUTE: '%Y-%m-%d %H:%M', -} -DATE_FORMAT_TO_HINT_MAPPING = { - DateFormat.YEAR: 'yyyy', - DateFormat.MONTH: 'yyyy/mm', - DateFormat.DAY: 'yyyy/mm/dd', - DateFormat.MINUTE: 'yyyy/mm/dd hh:mm', -} -DATA_RANGE_OPTION_TO_CHINESE = { - DataRangeOption.PRE: '早于当前时间', - DataRangeOption.NEXT: '晚于当前时间', - DataRangeOption.NONE: '无限制', -} - - -@dataclass -class Option: - # For user's usage, the name is the most important symbol - id: OptionId - name: str diff --git a/excelalchemy/core/abstract.py b/excelalchemy/core/abstract.py deleted file mode 100644 index 59a3440..0000000 --- a/excelalchemy/core/abstract.py +++ /dev/null @@ -1,47 +0,0 @@ -from abc import ABC -from abc import abstractmethod -from typing import Any -from typing import Generic - -from excelalchemy.const import ContextT -from excelalchemy.const import CreateModelT -from excelalchemy.const import ExporterModelT -from excelalchemy.const import ImporterCreateModelT -from excelalchemy.const import ImporterUpdateModelT -from excelalchemy.const import UpdateModelT -from excelalchemy.types.identity import Base64Str -from excelalchemy.types.identity import Key -from excelalchemy.types.identity import UrlStr -from excelalchemy.types.result import ImportResult - - -class ABCExcelAlchemy( - ABC, - Generic[ - ContextT, - ImporterCreateModelT, - ImporterUpdateModelT, - CreateModelT, - UpdateModelT, - ExporterModelT, - ], -): - @abstractmethod - def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> str: - """下载导入模版, Excel 字段顺序与定义的导出模型一致""" - - @abstractmethod - async def import_data(self, input_excel_name: str, output_excel_name: str) -> ImportResult: - """导入数据""" - - @abstractmethod - def export(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> Base64Str: - """导出数据,返回 base64 编码的 excel 文件, 字段顺序与定义的导出模型一致""" - - @abstractmethod - def export_upload(self, output_name: str, data: list[dict[str, Any]], keys: list[Key] | None = None) -> UrlStr: - """导出数据, 自动将文件上传到 Minio,字段顺序与定义的导出模型一致""" - - @abstractmethod - def add_context(self, context: ContextT): - """添加上下文""" diff --git a/excelalchemy/core/alchemy.py b/excelalchemy/core/alchemy.py deleted file mode 100644 index 340f821..0000000 --- a/excelalchemy/core/alchemy.py +++ /dev/null @@ -1,788 +0,0 @@ -import itertools -import logging -from collections import defaultdict -from decimal import Decimal -from functools import cached_property -from itertools import chain -from os import PathLike -from typing import Any -from typing import Awaitable -from typing import Callable -from typing import Generator -from typing import Iterable -from typing import Type -from typing import cast - -import pandas -from pandas import DataFrame -from pandas import concat -from pydantic import BaseModel - -from excelalchemy.const import DEFAULT_FIELD_META_ORDER -from excelalchemy.const import REASON_COLUMN_KEY -from excelalchemy.const import REASON_COLUMN_LABEL -from excelalchemy.const import RESULT_COLUMN_KEY -from excelalchemy.const import RESULT_COLUMN_LABEL -from excelalchemy.const import ContextT -from excelalchemy.const import CreateModelT -from excelalchemy.const import ExporterModelT -from excelalchemy.const import ImporterCreateModelT -from excelalchemy.const import ImporterUpdateModelT -from excelalchemy.const import UpdateModelT -from excelalchemy.core.abstract import ABCExcelAlchemy -from excelalchemy.core.writer import render_data_excel -from excelalchemy.core.writer import render_merged_header_excel -from excelalchemy.core.writer import render_simple_header_excel -from excelalchemy.exc import ConfigError -from excelalchemy.exc import ExcelCellError -from excelalchemy.exc import ExcelRowError -from excelalchemy.helper.pydantic import extract_pydantic_model -from excelalchemy.helper.pydantic import instantiate_pydantic_model -from excelalchemy.types.abstract import SystemReserved -from excelalchemy.types.alchemy import ExcelMode -from excelalchemy.types.alchemy import ExporterConfig -from excelalchemy.types.alchemy import ImporterConfig -from excelalchemy.types.alchemy import ImportMode -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.header import ExcelHeader -from excelalchemy.types.identity import Base64Str -from excelalchemy.types.identity import ColumnIndex -from excelalchemy.types.identity import Key -from excelalchemy.types.identity import Label -from excelalchemy.types.identity import RowIndex -from excelalchemy.types.identity import UniqueKey -from excelalchemy.types.identity import UniqueLabel -from excelalchemy.types.identity import UrlStr -from excelalchemy.types.result import ImportResult -from excelalchemy.types.result import ValidateHeaderResult -from excelalchemy.types.result import ValidateResult -from excelalchemy.types.result import ValidateRowResult -from excelalchemy.util.file import flatten -from excelalchemy.util.file import read_file_from_minio_object -from excelalchemy.util.file import remove_excel_prefix -from excelalchemy.util.file import upload_file_from_minio_object - -HEADER_HINT_LINE_COUNT = 1 # HEADER_HINT 占用的行数 - -# 导入结果的字段元数据, 依据产品定义,占两列 -# 1. 导入结果结果列 -RESULT_COLUMN = FieldMetaInfo(label=RESULT_COLUMN_LABEL) -RESULT_COLUMN.parent_label = RESULT_COLUMN.label -RESULT_COLUMN.key = RESULT_COLUMN.parent_key = RESULT_COLUMN_KEY -RESULT_COLUMN.value_type = SystemReserved - -# 2. 导入结果错误信息列 -REASON_COLUMN = FieldMetaInfo(label=REASON_COLUMN_LABEL) -REASON_COLUMN.parent_label = REASON_COLUMN.label -REASON_COLUMN.key = REASON_COLUMN.parent_key = REASON_COLUMN_KEY -REASON_COLUMN.value_type = SystemReserved - - -class ExcelAlchemy( - ABCExcelAlchemy[ - ContextT, - ImporterCreateModelT, - ImporterUpdateModelT, - CreateModelT, - UpdateModelT, - ExporterModelT, - ], -): - def __init__( - self, - config: ImporterConfig[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | ExporterConfig[ExporterModelT], - ): - self.df = DataFrame() # 初始化一个空的DataFrame - self.header_df = DataFrame() # 初始化一个空的DataFrame - self.config: ImporterConfig[ - ContextT, - ImporterCreateModelT, - ImporterUpdateModelT, - ] | ExporterConfig[ExporterModelT] = config - # 每个单元格的错误, 用于标红单元格, 索引与 df 位置对应 - self.cell_errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]] = {} - # 行错误, 用于标记错误信息,单元格错误会在行错误中显示,行标索引与 df 位置对应 - self.row_errors: dict[RowIndex, list[ExcelRowError | ExcelCellError]] = defaultdict(list) - # 固定的两列作为结果列 - self.import_result_field_meta: list[FieldMetaInfo] = [RESULT_COLUMN, REASON_COLUMN] - self.import_result_label_to_field_meta: dict[UniqueLabel, FieldMetaInfo] = { # 在导出验证结果时,补充结果列 - x.unique_label: x for x in self.import_result_field_meta - } - - # 下列属性调用 __init_from_config__ 初始化 - self.field_metas: list[FieldMetaInfo] = [] - self.unique_label_to_field_meta: dict[UniqueLabel, FieldMetaInfo] = {} # 唯一标签到字段元数据的映射 - self.parent_label_to_field_metas: dict[Label, list[FieldMetaInfo]] = {} # 父标签到字段元数据的映射 - self.parent_key_to_field_metas: dict[Key, list[FieldMetaInfo]] = {} # 父键到字段元数据的映射 - - self.unique_key_to_field_meta: dict[UniqueKey, FieldMetaInfo] = {} # 唯一键到字段元数据的映射 - self.ordered_field_meta: list[FieldMetaInfo] = [] # 排序后的表头 - - # 业务端调用方法初始化·或者从配置文件初始化 - self.context: ContextT | None = None # 转换器上下文 - self.__state_df_has_been_loaded__ = False # df 是否已经被加载 - - # 初始化·最后调用 - self.__init_from_config__() - - def __init_from_config__(self) -> None: - """从配置类初始化""" - self.context = getattr(self.config, 'context', None) - importer_model = self.__get_importer_model__() - self.__init_field_meta__(importer_model) - - def __init_field_meta__(self, importer_model: type[BaseModel]) -> None: - """从配置类初始化""" - self.field_metas = extract_pydantic_model(importer_model) - self._check_field_meta_order(self.field_metas) - if len(self.field_metas) == 0: - raise ConfigError(f'没有从模型 {importer_model.__name__} 中提取到字段元数据,请检查模型是否定义了字段') - self.ordered_field_meta: list[FieldMetaInfo] = self._sort_field_meta(self.field_metas) # type: ignore[no-redef] - - for field_meta in self.ordered_field_meta: - if field_meta.parent_label is None: - raise ConfigError('父标签不能为空') - if field_meta.parent_key is None: - raise ConfigError('父键不能为空') - - self.parent_label_to_field_metas.setdefault(field_meta.parent_label, []).append(field_meta) - self.parent_key_to_field_metas.setdefault(field_meta.parent_key, []).append(field_meta) - self.unique_key_to_field_meta[field_meta.unique_key] = field_meta - self.unique_label_to_field_meta[field_meta.unique_label] = field_meta - - def __get_importer_model__(self) -> type[ImporterCreateModelT] | type[ImporterUpdateModelT] | type[ExporterModelT]: - importer_model = None - if self.excel_mode == ExcelMode.IMPORT: - if not isinstance(self.config, ImporterConfig): - raise ConfigError(f'导入模式的配置类必须是 {ImporterConfig.__name__}') - if self.config.import_mode in (ImportMode.CREATE, ImportMode.CREATE_OR_UPDATE): - importer_model = self.config.create_importer_model # type: ignore[assignment] - elif self.config.import_mode == ImportMode.UPDATE: - importer_model = self.config.update_importer_model # type: ignore[assignment] - - elif self.excel_mode == ExcelMode.EXPORT: - if not isinstance(self.config, ExporterConfig): - raise ConfigError(f'导出模式的配置类必须是 {ExporterConfig.__name__}') - importer_model = self.config.exporter_model # type: ignore[assignment] - - if importer_model is None: - raise ConfigError('请检查配置类是否定义了导入模型或导出模型') - - return importer_model - - @staticmethod - def _check_field_meta_order(field_metas: list[FieldMetaInfo]) -> None: - """检查字段顺序是否有重复""" - order_to_field_meta: dict[int, set[Label]] = defaultdict(set) - for field_meta in field_metas: - assert field_meta.parent_label is not None # only for mypy, remove this line at runtime if you want - order_to_field_meta[field_meta.order].add(field_meta.parent_label) - duplicate_order = [v for k, v in order_to_field_meta.items() if len(v) > 1 and k != DEFAULT_FIELD_META_ORDER] - if duplicate_order: - raise ConfigError(f'字段顺序定义有重复:{list(itertools.chain.from_iterable(duplicate_order))}') - - def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> str: - """下载导入模版""" - if self.excel_mode != ExcelMode.IMPORT: - raise ConfigError('只支持导入模式调用此方法') - keys = self._select_output_excel_keys() - has_merged_header = self.has_merged_header(keys) - if has_merged_header: - df = self._export_with_merged_header(sample_data, keys) - return render_merged_header_excel(df, self.unique_label_to_field_meta) - else: - df = self._export_with_simple_header(sample_data, keys) - return render_simple_header_excel(df, self.unique_label_to_field_meta) - - async def import_data(self, input_excel_name: str, output_excel_name: str) -> ImportResult: - """导入数据""" - assert isinstance(self.config, ImporterConfig) # only for type check - if self.excel_mode != ExcelMode.IMPORT: - raise ConfigError('只支持导入模式调用此方法') - - validate_header = self._validate_header(input_excel_name) # 验证表头 - if not validate_header.is_valid: - return ImportResult.from_validate_header_result(validate_header) - - self.df = self.df.iloc[1:] # 去掉表头 - self._set_columns(self.df) # pyright: reportGeneralTypeIssues=false - self.df = self.df.reset_index(drop=True) # 重置索引 - - all_success, success_count, fail_count = True, 0, 0 - for pandas_row_index, row in self.df.iloc[self.extra_header_count_on_import :].iterrows(): - aggregate_data = self._aggregate_data(cast(dict[UniqueLabel, Any], row.to_dict())) - success = await self._dml_caller(cast(RowIndex, pandas_row_index), aggregate_data) - all_success = all_success and success - success_count, fail_count = (success_count + 1, fail_count) if success else (success_count, fail_count + 1) - - url = None - if not all_success: - self._add_result_column() - content_with_prefix = self._render_import_result_excel() - url = self._upload_file(output_excel_name, content_with_prefix) - return ImportResult( - result=(ValidateResult.DATA_INVALID, ValidateResult.SUCCESS)[int(all_success)], - url=url, - success_count=success_count, - fail_count=fail_count, - ) - - def export(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> Base64Str: - """导出数据, keys 控制导出的列, 如果为 None, [] 则导出所有列""" - df, has_merged_header = self._gen_export_df(data, keys) - return render_data_excel( - df, - errors={}, # 数据导出没有错误 - field_meta_mapping=self.unique_label_to_field_meta, - has_merged_header=has_merged_header, - ) - - def export_upload(self, output_name: str, data: list[dict[str, Any]], keys: list[Key] | None = None) -> UrlStr: - """导出数据, keys 控制导出的列, 如果为 None, [] 则导出所有列""" - - content_with_prefix = self.export(data, keys) - return self._upload_file(output_name, content_with_prefix) - - def add_context(self, context: ContextT) -> None: - """添加转换模型上下文""" - if self.context is not None: - logging.warning('已经存在旧的转换模型上下文, 旧的上下文将被替换, 请确认此操作符合预期') - - self.context = context - - @cached_property - def input_excel_has_merged_header(self) -> bool: - """用户上传的 Excel 是否有合并的表头""" - if not self.__state_df_has_been_loaded__: - raise ConfigError('请保证 df 已经初始化') - return self._excel_has_merged_header() - - @cached_property - def input_excel_headers(self) -> list[ExcelHeader]: - """用户上传的 Excel 表头""" - if not self.__state_df_has_been_loaded__: - raise ConfigError('请保证 df 已经初始化') - return self._extract_header() - - @property - def excel_mode(self) -> ExcelMode: - if isinstance(self.config, ImporterConfig): - return ExcelMode.IMPORT - - return ExcelMode.EXPORT - - @property - def extra_header_count_on_import(self) -> int: - # 执行导入时,预期额外的表头行数, 有合并单元格为 1, 无合并单元格为 0 - if self.excel_mode != ExcelMode.IMPORT: - raise ConfigError('只支持导入模式读取此属性') - for input_excel_label in self.input_excel_headers: - if input_excel_label.label != input_excel_label.parent_label: - return 1 - return 0 - - @property - def exporter_model(self) -> Type[ExporterModelT]: - if isinstance(self.config, ImporterConfig): - if self.config.create_importer_model and self.config.update_importer_model: - raise ConfigError('从导入模型推断导出模型失败, 请手动设置导出模型') - if self.config.create_importer_model: - logging.info('从导入模型推断导出模型, 请确认此操作符合预期,使用的是 create_importer_model') - return cast(Type[ExporterModelT], self.config.create_importer_model) - if self.config.update_importer_model: - logging.info('从导入模型推断导出模型, 请确认此操作符合预期,使用的是 update_importer_model') - return cast(Type[ExporterModelT], self.config.update_importer_model) - raise ConfigError('从导入模型推断导出模型失败, 请手动设置导出模型') - - return self.config.exporter_model - - def has_merged_header(self, selected_keys: list[UniqueKey]) -> bool: - """检查导出的键是否有合并的表头""" - for key in selected_keys: - if self.unique_key_to_field_meta[key].label != self.unique_key_to_field_meta[key].parent_label: - return True - return False - - def get_output_parent_excel_headers(self, selected_keys: list[UniqueKey] | None = None) -> list[UniqueLabel]: - """导出的 Excel 表头""" - if not selected_keys: - return [x.unique_label for x in self.ordered_field_meta] - else: - return [self.unique_key_to_field_meta[key].unique_label for key in selected_keys] - - def get_output_child_excel_headers(self, selected_keys: list[UniqueKey] | None = None) -> list[Label]: - """导出的 Excel 表头""" - if not selected_keys: - return [x.label for x in self.ordered_field_meta] - else: - return [self.unique_key_to_field_meta[key].label for key in selected_keys] - - def _gen_export_df(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> tuple[DataFrame, bool]: - """导出数据, keys 控制导出的列, 如果为 None, [] 则导出所有列""" - if self.excel_mode == ExcelMode.IMPORT: - logging.info('导出模式为导入模式, 调用导出方法时自动切换为导出模式') - - input_keys = keys or list(filter(None, [x.parent_key for x in self.ordered_field_meta])) - model_keys = cast(list[Key], self.exporter_model.__fields__.keys()) - if unrecognized := (set(input_keys) - set(model_keys)): - logging.warning('导出的列 {%s} 不在模型 {%s} 中', unrecognized, model_keys) - - intersection_keys = list(set(input_keys).intersection(set(model_keys))) - selected_keys = self._select_output_excel_keys(intersection_keys) - has_merged_header = self.has_merged_header(selected_keys) - if has_merged_header: - df = self._export_with_merged_header(data, selected_keys, self.config.data_converter) - else: - df = self._export_with_simple_header(data, selected_keys, self.config.data_converter) - - return df, has_merged_header - - def _validate_header(self, input_excel_name: str) -> ValidateHeaderResult: - """验证表头""" - if self.excel_mode != ExcelMode.IMPORT: - raise ConfigError('只支持导入模式调用此方法') - assert isinstance(self.config, ImporterConfig) # only for type hint, not for runtime - self._read_dataframe(input_excel_name) - - required_labels = [x.label for x in self.ordered_field_meta if x.required] - primary_labels = [x.label for x in self.ordered_field_meta if x.is_primary_key] - input_labels = [x.label for x in self.input_excel_headers] - - visited = set() - duplicated = [x for x in input_labels if x in visited or visited.add(x)] # type: ignore[func-returns-value] - unrecognized = list(set(input_labels) - set(x.label for x in self.ordered_field_meta)) - - missing_primary, missing_required = [], [] - if self.config.import_mode == ImportMode.UPDATE: - missing_primary = list(set(primary_labels) - set(input_labels)) - - missing_required = list(set(required_labels) - set(input_labels) - set(missing_primary)) - - return ValidateHeaderResult( - unrecognized=unrecognized, - duplicated=duplicated, - missing_required=missing_required, - missing_primary=missing_primary, - is_valid=not (missing_required or unrecognized or duplicated or missing_primary), - ) - - def _render_import_result_excel(self) -> str: - """执行导入后,渲染数据""" - content_with_prefix = render_data_excel( - self.df, - errors=self.cell_errors, - field_meta_mapping=self.import_result_label_to_field_meta | self.unique_label_to_field_meta, - has_merged_header=self.input_excel_has_merged_header, - ) - - return content_with_prefix - - def _upload_file(self, output_name: str, content_with_prefix: str) -> UrlStr: - """上传文件""" - assert isinstance(self.config, (ExporterConfig, ImporterConfig)) # only for type check - url = upload_file_from_minio_object( - self.config.minio, - self.config.bucket_name, - output_name, - remove_excel_prefix(content_with_prefix), - self.config.url_expires, - ) - return UrlStr(url) - - def _order_errors(self, errors: list[ExcelRowError | ExcelCellError]) -> Iterable[ExcelCellError | ExcelRowError]: - """对错误进行排序,依据 ordered_field_meta 的 unique_label 索引排序,ExcelRowError 错误在最后""" - unique_key_to_index = {field_meta.unique_label: idx for idx, field_meta in enumerate(self.ordered_field_meta)} - row_errors: list[ExcelRowError] = [] - cell_errors: list[ExcelCellError] = [] - for error in errors: - if isinstance(error, ExcelRowError): - row_errors.append(error) - else: - cell_errors.append(error) - cell_errors.sort(key=lambda x: unique_key_to_index.get(x.unique_label, Decimal('Infinity'))) - return chain(cell_errors, row_errors) - - def _set_columns(self, df: DataFrame) -> DataFrame: - """设置列名""" - columns = [] - for header in self.input_excel_headers: - if header.unique_label not in self.get_output_parent_excel_headers(): - raise ConfigError(f'不支持的列名: {header.unique_label}') - columns.append(header.unique_label) - - df.columns = columns # type: ignore[assignment] - return df - - def _select_output_excel_keys(self, keys: list[Key] | None = None) -> list[UniqueKey]: - """选择出需要导出的键""" - if not keys: - # 如果没有指定, 则返回所有的 Key - return [x.unique_key for x in self.ordered_field_meta] - selected_field_meta = [] - for key in keys: - if key in self.unique_key_to_field_meta: - selected_field_meta.append(self.unique_key_to_field_meta[UniqueKey(key)]) - elif key in self.parent_key_to_field_metas: - selected_field_meta.extend(self.parent_key_to_field_metas[key]) - else: - raise ValueError(f'无效的 Key: {key}') - return [x.unique_key for x in self._sort_field_meta(selected_field_meta)] - - @classmethod - def _sort_field_meta(cls, field_metas: list[FieldMetaInfo]) -> list[FieldMetaInfo]: - """排序 FieldMeta - 根据输入的顺序排序,其次根据 offset 排序 - """ - orders: dict[Label, int] = {} - for idx, field_meta in enumerate(field_metas): - assert field_meta.parent_label is not None # only for type check, remove this line is safely at runtime - if field_meta.order == DEFAULT_FIELD_META_ORDER: - # 如果没有指定 order, 则使用 pydantic 输入的顺序, 但是 pydantic 不保证每次实例化的类顺序一致 - orders[field_meta.parent_label] = idx - else: - orders[field_meta.parent_label] = field_meta.order - - return sorted( - field_metas, - key=lambda x: ( - orders.get(cast(Label, x.parent_label), Decimal('Infinity')), - x.offset, - ), - ) - - def _read_dataframe(self, input_excel_name: str) -> pandas.DataFrame: - """读取 DataFrame""" - assert isinstance(self.config, ImporterConfig) # only for type check - if not self.__state_df_has_been_loaded__: - file_object = read_file_from_minio_object( - # pyright: reportUnknownMemberType=false - # pyright: reportUnknownArgumentType=false - self.config.minio, - self.config.bucket_name, - input_excel_name, - ) - - df: DataFrame = pandas.read_excel( - cast(PathLike[str], file_object), # cast to cheat type check - sheet_name=self.config.sheet_name, - skiprows=HEADER_HINT_LINE_COUNT, # 跳过表头提示行 - header=None, # 不使用表头, 由程序自行解析 - dtype=str, # 读取所有数据为字符串 - engine='openpyxl', # 使用 openpyxl 引擎, 避免 xlrd 引擎读取 xlsx 文件时报错 - ) - file_object.close() - self.df = df - self.header_df = df.head(2) # 只读取前两行, 用于解析表头 - self.__state_df_has_been_loaded__ = True - return self.df - - def _generate_export_df( - self, - records: list[dict[str, Any]] | None, - selected_keys: list[UniqueKey], - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = None, - ) -> DataFrame: - """生成导出的 DataFrame""" - rst = [] - if records is None: - records = [] - for record in records: - row = {} - record = data_converter(record) if data_converter else record - for key, value in flatten(record).items(): # type:ignore[arg-type] - if key not in selected_keys: - continue - field_meta = self.unique_key_to_field_meta[UniqueKey(key)] - row[field_meta.unique_label] = field_meta.value_type.deserialize(value, field_meta) - rst.append(row) - - return DataFrame(columns=self.get_output_parent_excel_headers(selected_keys), data=rst) - - def _export_with_merged_header( - self, - records: list[dict[str, Any]] | None, - selected_keys: list[UniqueKey], - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = None, - ) -> DataFrame: - """导出有合并表头的数据""" - data_df = self._generate_export_df(records, selected_keys, data_converter) - # 含有合并的表头需要在起始位置插入一行 - new_row_df = DataFrame(columns=data_df.columns, data=[self.get_output_child_excel_headers(selected_keys)]) - result_df = concat([new_row_df, data_df], ignore_index=True) - return result_df - - def _export_with_simple_header( - self, - records: list[dict[str, Any]] | None, - selected_keys: list[UniqueKey], - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = None, - ) -> DataFrame: - """导出没有合并表头的数据""" - return self._generate_export_df(records, selected_keys, data_converter) - - def _add_result_column(self): - """写入导入结果列,失败原因列""" - - result: list[str] = [] - reason: list[str] = [] - - # 遍历数据行, column 不算在 index 中 - for index in self.df.index[self.extra_header_count_on_import :]: - row_errors = self.row_errors.get(index) - if not row_errors: - result.append(str(ValidateRowResult.SUCCESS)) - reason.append('') - else: - result.append(str(ValidateRowResult.FAIL)) - raw_reason = [] - for idx, error in enumerate(self._order_errors(row_errors), start=1): # 给每个错误加上序号,方便用户查看,从1开始 - raw_reason.append(f'{idx}、{str(error)}') - reason.append('\n'.join(raw_reason)) - if self.extra_header_count_on_import == 1: # 有合并表头 - result = [str(RESULT_COLUMN.unique_label)] + result - reason = [str(REASON_COLUMN.unique_label)] + reason - self.df.insert(loc=0, column=REASON_COLUMN.unique_label, value=reason) - self.df.insert(loc=0, column=RESULT_COLUMN.unique_label, value=result) - - return self - - async def _dml_caller(self, row_index: RowIndex, data: dict[Key, Any]) -> bool: - """调用 DML""" - if not isinstance(self.config, ImporterConfig): - raise TypeError('只有 ExcelImporterConfig 才支持 DML') - - is_success = False - match self.config.import_mode: - case ImportMode.CREATE: - is_success = await self._creator_caller(row_index, data) - case ImportMode.UPDATE: - is_success = await self._updater_caller(row_index, data) - case ImportMode.CREATE_OR_UPDATE: - is_success = await self._creator_or_updater_caller(row_index, data) - - return is_success - - async def _creator_caller(self, row_index: RowIndex, data: dict[Key, Any]) -> bool: - """调用创建函数, 返回是否创建成功""" - if not isinstance(self.config, ImporterConfig): - raise TypeError('只有 ExcelImporterConfig 才支持 DML') - if self.config.creator is None: - raise ConfigError('未配置 creator') - if self.config.create_importer_model is None: - raise ConfigError('未配置 create_importer_model') - return await self.__caller_impl__( - row_index, - data, - self.config.create_importer_model, - self.config.creator, - self.config.data_converter, - self.config.exec_formatter, - ) - - async def _updater_caller(self, row_index: RowIndex, data: dict[Key, Any]) -> bool: - """调用更新函数, 返回是否创建成功""" - if not isinstance(self.config, ImporterConfig): - raise TypeError(f'只有 {ImporterConfig.__name__} 才支持 DML') - if self.config.updater is None: - raise ConfigError('未配置 updater') - if self.config.update_importer_model is None: - raise ConfigError('未配置 update_importer_model') - return await self.__caller_impl__( - row_index, - data, - self.config.update_importer_model, - self.config.updater, - self.config.data_converter, - self.config.exec_formatter, - ) - - async def __caller_impl__( - self, - row_index: RowIndex, - data: dict[Key, Any], - importer_model: type[BaseModel], - dml_func: Callable[[dict[str, Any], ContextT | None], Awaitable[Any]], - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None, - exec_formatter: Callable[[Exception], str], - ) -> bool: - """调用 DML 函数""" - # 第一步: 实例化 pydantic 模型,可能产生错误 - importer_instance_or_errors = instantiate_pydantic_model(data, importer_model) - if not isinstance(importer_instance_or_errors, importer_model): - errors: list[ExcelCellError] = importer_instance_or_errors # type: ignore[assignment] - self._register_row_error(row_index, errors) - self._register_cell_errors(row_index, errors) - return False - - # 第二步: 调用 creator/updater, 可能产生错误 - importer_instance = importer_instance_or_errors - if data_converter is not None: - converted_data = data_converter(importer_instance.dict(exclude_unset=True)) - else: - converted_data = importer_instance.dict(exclude_unset=True) - try: - await dml_func(converted_data, self.context) - except ExcelCellError as e: - self.row_errors[row_index].append(e) - return False - except Exception as e: - self.row_errors[row_index].append(ExcelRowError(exec_formatter(e))) - return False - - return True - - async def _creator_or_updater_caller(self, row_index: RowIndex, data: dict[Key, Any]) -> bool: - """调用 creator 或者 updater""" - if not isinstance(self.config, ImporterConfig): - raise TypeError(f'只有 {ImporterConfig.__name__} 才支持 DML') - is_data_exists_func = self.config.is_data_exist - if is_data_exists_func is None: - raise ConfigError('未配置 is_data_exists') - - converted_data = self.config.data_converter(cast(dict[str, Any], data)) if self.config.data_converter else data - is_data_exist = await is_data_exists_func(cast(dict[str, Any], converted_data), self.context) - if is_data_exist: - return await self._updater_caller(row_index, data) - else: - return await self._creator_caller(row_index, data) - - def _aggregate_data(self, row_data: dict[UniqueLabel, Any]) -> dict[Key, Any]: - """聚合数据 - - 1、将复合类型的数据聚集到同一个 key 中 - 2、将 Label 转换为 Key - - """ - agg_data: dict[Key, Any] = self.__agg_data__(row_data) - serialized_agg_data: dict[Key, Any] = self.__serialize_agg_data__(agg_data) - - return serialized_agg_data - - def __agg_data__(self, row_data: dict[UniqueLabel, Any]) -> dict[Key, Any]: - agg_data: dict[Key, Any] = {} - for unique_label, value in row_data.items(): - field_meta = self.unique_label_to_field_meta[unique_label] - - if field_meta.key is None or field_meta.parent_key is None: - raise ConfigError(f' {type(field_meta).__name__} 未配置 key/parent_key') - - if pandas.isna(value): - if self.config.import_mode in { # type: ignore[union-attr] - ImportMode.UPDATE, - ImportMode.CREATE_OR_UPDATE, - }: - value = None # 如果是更新模式,且值为 NaN,表示将该值设置为 None - else: - continue - - if field_meta.parent_key == field_meta.key: - agg_data[field_meta.key] = value - else: - agg_data.setdefault(field_meta.parent_key, {}) - agg_data[field_meta.parent_key][field_meta.key] = value - return agg_data - - def __serialize_agg_data__(self, agg_data: dict[Key, Any]) -> dict[Key, Any]: - serialized_agg_data: dict[Key, Any] = {} - for parent_key, value in agg_data.items(): - field_metas = self.parent_key_to_field_metas[parent_key] - validator = field_metas[0] - if value is None: - serialized_agg_data[parent_key] = None - else: - serialized_agg_data[parent_key] = validator.value_type.serialize(value, validator) - - return serialized_agg_data - - def _get_column_index(self, unique_label: UniqueLabel) -> Generator[ColumnIndex, None, None]: - """获取列索引""" - if unique_label not in self.unique_label_to_field_meta: - if unique_label not in self.parent_label_to_field_metas: - raise ValueError(f'找不到 {unique_label} 对应的字段') - - for sub_field_meta in self.parent_label_to_field_metas[unique_label]: - yield from self.__get_column_index_impl__(sub_field_meta.unique_label) - - else: - yield from self.__get_column_index_impl__(unique_label) - - def __get_column_index_impl__(self, unique_label: UniqueLabel) -> Generator[ColumnIndex, None, None]: - index = self.df.columns.get_loc(unique_label) - if isinstance(index, int): - yield ColumnIndex(index) - else: - raise ValueError(f'找不到 {unique_label} 对应的列, 推测是 value_type 定义不正确') - - def _register_row_error( - self, - row_index: RowIndex, - error: ExcelRowError | ExcelCellError | list[ExcelRowError | ExcelCellError] | list[ExcelCellError], - ): - """注册行错误""" - if isinstance(error, list): - self.row_errors[row_index].extend(error) - else: - self.row_errors[row_index].append(error) - - def _register_cell_errors(self, row_index: RowIndex, errors: list[ExcelCellError]): - """注册单元格错误""" - for error in errors: - # +len(self.import_result_field_meta) 是因为在 df 中,会往最前面插入导入结果列,所以需要加上这个偏移量 - for index in self._get_column_index(error.unique_label): - column_index = cast(ColumnIndex, index + len(self.import_result_field_meta)) - self.cell_errors.setdefault(row_index, {}).setdefault(column_index, []).append(error) - return self - - def _excel_has_merged_header(self) -> bool: - """判断是否有合并表头 - - 如果第 0 行有合并单元格,则一定有 nan 值或 Unnamed ,否则没有合并单元格 - """ - return any(pandas.isna(self.header_df.iloc[0])) or any(self.header_df.iloc[0].str.startswith('Unnamed')) - - def _extract_header(self) -> list[ExcelHeader]: - """提取表头信息""" - if self._excel_has_merged_header(): - return self._extract_merged_header() - else: - return self._extract_simple_header() - - def _extract_simple_header(self) -> list[ExcelHeader]: - """提取简单表头信息""" - return [ExcelHeader(label=Label(col), parent_label=Label(col)) for col in self.header_df.iloc[0].tolist()] - - def _extract_merged_header(self) -> list[ExcelHeader]: - """提取含有合并表头的表头信息""" - headers: list[ExcelHeader] = [] - header_row_index = 0 - - last_header = None - next_offset = 1 - for column_index, value in self.header_df.iloc[header_row_index].items(): - parent_value = value - child_value = self.header_df.iloc[header_row_index + 1][column_index] # type: ignore[call-overload] - if pandas.isna(parent_value) or parent_value.startswith('Unnamed'): - if pandas.isna(child_value): - raise ValueError('合并表头错误: 子表头不能为空') - current_header = ExcelHeader( - label=Label(child_value), - parent_label=Label(last_header), - offset=next_offset, - ) - next_offset += 1 - else: - if pandas.isna(child_value): - child_value = parent_value - current_header = ExcelHeader(label=Label(child_value), parent_label=Label(value)) - last_header, next_offset = value, 1 - headers.append(current_header) - - return headers - - def __setattr__(self, key: str, value: Any): - if key == 'config' and hasattr(self, 'config'): - raise ValueError(f'{self.__class__.__name__} 已经被实例化, config 不能被修改') - object.__setattr__(self, key, value) - - def __repr__(self): - return f'{self.__class__.__name__}(config={self.config!r})' diff --git a/excelalchemy/core/writer.py b/excelalchemy/core/writer.py deleted file mode 100644 index a1e2fda..0000000 --- a/excelalchemy/core/writer.py +++ /dev/null @@ -1,509 +0,0 @@ -"""负责将 pandas 写入 Excel 文件""" -import base64 -from collections import defaultdict -from math import ceil -from tempfile import NamedTemporaryFile -from typing import Any -from typing import BinaryIO -from typing import cast - -from openpyxl.comments import Comment -from openpyxl.styles import Alignment -from openpyxl.styles import Font -from openpyxl.styles import PatternFill -from openpyxl.styles import numbers -from openpyxl.utils import get_column_letter -from openpyxl.worksheet.datavalidation import DataValidation -from openpyxl.worksheet.worksheet import Worksheet -from pandas import DataFrame -from pandas import ExcelWriter - -from excelalchemy.const import BACKGROUND_ERROR_COLOR -from excelalchemy.const import BACKGROUND_REQUIRED_COLOR -from excelalchemy.const import CHARACTER_WIDTH -from excelalchemy.const import DEFAULT_SHEET_NAME -from excelalchemy.const import FONT_READ_COLOR -from excelalchemy.const import HEADER_HINT -from excelalchemy.const import REASON_COLUMN_LABEL -from excelalchemy.const import RESULT_COLUMN_LABEL -from excelalchemy.exc import ExcelCellError -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import Base64Str -from excelalchemy.types.identity import ColumnIndex -from excelalchemy.types.identity import Label -from excelalchemy.types.identity import RowIndex -from excelalchemy.types.identity import UniqueLabel -from excelalchemy.types.result import ValidateRowResult -from excelalchemy.types.value import EXCEL_CHOICE_VALUE_TYPE -from excelalchemy.util.file import add_excel_prefix -from excelalchemy.util.file import value_is_nan - -# pandas 认为 Excel 的第一行是 0, 第一列是 0 -PANDAS_EXCEL_INDEX_START_AT = 0 -PANDAS_WRITE_START_AT = PANDAS_EXCEL_INDEX_START_AT + 1 # 从第二行开始写入数据,第一行写入 HEADER_HINT - -# openpyxl 认为 Excel 的第一行是 1, 第一列是 1 -OPENPYXL_EXCEL_INDEX_START_AT = 1 # Excel 从 1 开始 - -# 写入 HEADER_HINT 的 位置,基于 openpyxl 的索引 -HEADER_HINT_ROW_INDEX = 1 -HEADER_HINT_COL_INDEX = 1 -HEADER_HINT_LINE_COUNT = 1 # HEADER_HINT 占用的行数 - -# 最多只能设置 16384 行数据的选项 -MAX_OPTION_ROW_COUNT = 16384 - -# 简单表头的行数 -SIMPLE_HEADER_ROW_COUNT = 1 - -# 合并表头的行数 -MERGE_HEADER_ROW_COUNT = 2 - -# row_write_offset : 写入 Excel 时,从第几行开始写入 -# column_write_offset : 写入 Excel 时,从第几列开始写入 - - -def _get_file(file: BinaryIO | None = None) -> BinaryIO: - """生成临时文件""" - return cast(BinaryIO, file or NamedTemporaryFile()) - - -# pylint: disable=too-many-locals -def _write_simple_header( - df: DataFrame, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - file: BinaryIO, - sheet_name: str, - column_write_offset: int = 0, - row_write_offset: int = 0, - close_file: bool = True, - writer: ExcelWriter | None = None, - option_start_at: int = OPENPYXL_EXCEL_INDEX_START_AT + HEADER_HINT_LINE_COUNT + SIMPLE_HEADER_ROW_COUNT, -) -> BinaryIO: - """写入简单的表头(没有合并的表头)""" - - writer = writer or ExcelWriter(file, engine='openpyxl') - # pyright: reportUnknownMemberType=false - df.to_excel(writer, sheet_name=sheet_name, index=False, startrow=PANDAS_WRITE_START_AT) - worksheet: Worksheet = writer.sheets[sheet_name] - - for openpyxl_col_index, column in enumerate( - df.columns[column_write_offset:], - start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, - ): # pyright: reportUnknownArgumentType=false - field_meta = field_meta_mapping[column] - comment_text = field_meta.value_type.comment(field_meta) - comment = Comment( - text=comment_text, - author='https://github.com/SundayWindy/ExcelAlchemy', - height=sum(ceil(len(line) / 20) for line in comment_text.splitlines()) * 28, - width=300, - ) - cell = worksheet.cell(row=row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, column=openpyxl_col_index) - if comment_text: - cell.comment = comment - if field_meta.required: - cell.fill = PatternFill(start_color=BACKGROUND_REQUIRED_COLOR, fill_type='solid') # 如果是必填项,设置背景颜色 - - cell.font = Font(bold=True) # 字体加粗 - cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) - cell.number_format = numbers.FORMAT_TEXT - # 设置列为文本格式 - worksheet.column_dimensions[get_column_letter(openpyxl_col_index)].number_format = numbers.FORMAT_TEXT - - # 设置列的下拉值可选项, 只支持单选 - if field_meta.options and field_meta.value_type in EXCEL_CHOICE_VALUE_TYPE: - column_letter = get_column_letter(openpyxl_col_index) - data_validation = DataValidation( - type='list', - formula1=f'"{",".join(x.name for x in field_meta.options)}"', - allow_blank=not field_meta.required, - # option_start_at + 1 表头行不需要下拉选项 - sqref=f'{column_letter}{option_start_at + 1}:{column_letter}{MAX_OPTION_ROW_COUNT}', - error='请从下拉列表中选择', - errorTitle=f'【{field_meta.label}】列填写错误', - ) - worksheet.add_data_validation(data_validation) - - if close_file: - writer.close() - - return file - - -def _write_comment_header( - df: DataFrame, - file: BinaryIO, - sheet_name: str, - close_file: bool = True, - writer: ExcelWriter | None = None, -) -> BinaryIO: - """写入 HEADER_HINT""" - - writer = writer or ExcelWriter(file, engine='openpyxl') - df.to_excel(writer, sheet_name=sheet_name, index=False, startrow=PANDAS_WRITE_START_AT) - worksheet: Worksheet = writer.sheets[sheet_name] - cell = worksheet.cell(row=HEADER_HINT_ROW_INDEX, column=HEADER_HINT_COL_INDEX) - cell.value = HEADER_HINT - cell.font = Font(size=16) - cell.alignment = Alignment(wrap_text=True) - worksheet.merge_cells( - start_row=HEADER_HINT_ROW_INDEX, - start_column=HEADER_HINT_COL_INDEX, - end_row=HEADER_HINT_ROW_INDEX, - end_column=len(df.columns), - ) - # pyright: reportGeneralTypeIssues=false - worksheet.row_dimensions[HEADER_HINT_ROW_INDEX].height = 120 - - if close_file: - writer.close() - - return file - - -def _write_vertically_merged_header( - start_row: int, - df: DataFrame, - column_write_offset: int, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - worksheet: Worksheet, -): - for openpyxl_col_index, column in enumerate( - df.columns[column_write_offset:], - start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, - ): - field_meta = field_meta_mapping[column] - if field_meta.label == field_meta.parent_label: - # 如果 label 和 parent_label 相同,说明需要上下合并 - worksheet.merge_cells( - start_row=start_row, - start_column=openpyxl_col_index + column_write_offset, - end_row=start_row + 1, # +1 表示合并两行 - end_column=openpyxl_col_index + column_write_offset, - ) - worksheet.cell( - row=start_row, - column=openpyxl_col_index + column_write_offset, - ).number_format = numbers.FORMAT_TEXT - - -def _write_horizontally_merged_header( - start_row: int, - df: DataFrame, - column_write_offset: int, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - worksheet: Worksheet, -) -> None: - """写入横向合并的表头""" - counter: dict[Label, int] = defaultdict(int) - for field_meta in field_meta_mapping.values(): - if field_meta.parent_label is None: - raise RuntimeError('运行时 parent_label 不能为空') - counter[field_meta.parent_label] += 1 - - for openpyxl_col_index, column in enumerate( - df.columns[column_write_offset:], - start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, - ): - field_meta = field_meta_mapping[column] - if field_meta.parent_label is None: - raise RuntimeError('运行时 parent_label 不能为空') - if field_meta.label != field_meta.parent_label and field_meta.offset == 0: - # 如果 label 和 parent_label 不同,说明需要左右合并 - # 首先设置值 - cell = worksheet.cell(row=start_row, column=openpyxl_col_index + column_write_offset) - cell.value = field_meta.parent_label - cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) - # 然后合并单元格 - worksheet.merge_cells( - start_row=start_row, - start_column=openpyxl_col_index + column_write_offset, - end_row=start_row, - end_column=openpyxl_col_index + column_write_offset + counter[field_meta.parent_label] - 1, - ) - - -def _write_merged_header( # pragma: no mccabe - df: DataFrame, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - file: BinaryIO, - sheet_name: str, - column_write_offset: int = 0, - row_write_offset: int = 0, - close_file: bool = True, - writer: ExcelWriter | None = None, -) -> BinaryIO: - """写入含有合并的表头""" - - writer = writer or ExcelWriter(file, engine='openpyxl') - worksheet: Worksheet = writer.sheets[sheet_name] - - # 写入注释需要在合并表头之前 - _write_simple_header( - df, - field_meta_mapping, - file, - sheet_name, - column_write_offset, - row_write_offset, - close_file=False, - writer=writer, - option_start_at=OPENPYXL_EXCEL_INDEX_START_AT + HEADER_HINT_LINE_COUNT + MERGE_HEADER_ROW_COUNT, - ) - # 第一遍遍历,找出纵向合并的单元格 - start_row = row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT - _write_vertically_merged_header(start_row, df, column_write_offset, field_meta_mapping, worksheet) - # 第二遍遍历,找出横向合并的单元格 - _write_horizontally_merged_header(start_row, df, column_write_offset, field_meta_mapping, worksheet) - - if close_file: - writer.close() - - return file - - -def _get_parsed_value( - df: DataFrame, - row_index: int, - col_index: int, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], -) -> str: - """用于把 pandas 读取的 Excel 之后的数据,转回用户可识别的数据""" - - cell_value: str | Any | None = df.iloc[row_index, col_index] - - if value_is_nan(cell_value): - return '' # parse None for end-user - col_label = cast(UniqueLabel, df.columns[col_index]) - if col_label not in field_meta_mapping: - return str(cell_value) - field_meta = field_meta_mapping[col_label] - cell_value = field_meta.value_type.deserialize(cell_value, field_meta) - - return str(cell_value) - - -def _mark_error( - worksheet: Worksheet, - errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]], - column_write_offset: int, - row_write_offset: int, -): - for row_index, cols in errors.items(): - for col_index, exceptions in cols.items(): - if not exceptions: - continue - - openpyxl_col_index = col_index + column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT - openpyxl_row_index = row_index + row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT - - # 设置单元格背景为红色 - cell = worksheet.cell(row=openpyxl_row_index, column=openpyxl_col_index) - cell.fill = PatternFill( - start_color=BACKGROUND_ERROR_COLOR, - end_color=BACKGROUND_ERROR_COLOR, - fill_type='solid', - ) - cell.alignment = Alignment(wrap_text=True) - - -def _write_value( - df: DataFrame, - worksheet: Worksheet, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - pands_data_start_index: int, - column_write_offset: int, - row_write_offset: int, -) -> None: - col_width_mapping: dict[ColumnIndex, float] = defaultdict(float) - for row_index_ in range(pands_data_start_index, df.shape[0]): # iterate over rows - for column_index_ in range(df.shape[1]): # iterate over columns - openpyxl_col_index = column_index_ + column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT - openpyxl_row_index = row_index_ + row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT - - cell = worksheet.cell(row=openpyxl_row_index, column=openpyxl_col_index) - cell.value = _get_parsed_value(df, row_index_, column_index_, field_meta_mapping) - cell.number_format = numbers.FORMAT_TEXT - cell.alignment = Alignment(horizontal='left', vertical='center', wrap_text=True) - if RESULT_COLUMN_LABEL == df.columns[column_index_] and cell.value == str(ValidateRowResult.FAIL): - cell.font = Font(color=FONT_READ_COLOR) # 设置文字颜色为红色 - if REASON_COLUMN_LABEL == df.columns[column_index_]: - cell.alignment = Alignment(horizontal='left', vertical='center', wrap_text=True) - - col_width_mapping[ColumnIndex(openpyxl_col_index)] = max( - col_width_mapping[ColumnIndex(openpyxl_col_index)], - max(len(str(x)) for x in str(cell.value).split('\n')), - len(str(df.columns[column_index_])), - ) - - for openpyxl_col_index, width in col_width_mapping.items(): - worksheet.column_dimensions[get_column_letter(openpyxl_col_index)].width = round( - (width + 4) * CHARACTER_WIDTH, 2 - ) - - -# pylint: disable=too-many-locals -def _write_value_mark_error( # pragma: no mccabe - df: DataFrame, - errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]], - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - file: BinaryIO, - sheet_name: str, - row_write_offset: int = 0, - column_write_offset: int = 0, - close_file: bool = True, - writer: ExcelWriter | None = None, - pands_data_start_index: int = 0, -) -> BinaryIO: - """写入错误标记,并把对应位置标红""" - - writer = writer or ExcelWriter(file, engine='openpyxl') - worksheet: Worksheet = writer.sheets[sheet_name] - - _mark_error( - worksheet=worksheet, - errors=errors, - column_write_offset=column_write_offset, - row_write_offset=row_write_offset, - ) - - _write_value( - df=df, - worksheet=worksheet, - field_meta_mapping=field_meta_mapping, - pands_data_start_index=pands_data_start_index, - row_write_offset=row_write_offset, - column_write_offset=column_write_offset, - ) - - if close_file: - writer.close() - return file - - -def render_simple_header_excel( - df: DataFrame, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - sheet_name: str = DEFAULT_SHEET_NAME, - file: BinaryIO | None = None, - close_file: bool = True, - column_write_offset: int = 0, -) -> str: - """把表头写入 Excel 文件""" - if file is None: - close_file = True - - tmp = _get_file(file) - writer = ExcelWriter(tmp, engine='openpyxl') - _write_comment_header(df, tmp, sheet_name, writer=writer, close_file=False) - _write_simple_header( - df, - field_meta_mapping, - tmp, - sheet_name, - column_write_offset, - row_write_offset=HEADER_HINT_LINE_COUNT, - writer=writer, - close_file=False, - ) - - writer.close() - tmp.seek(0) - content = base64.b64encode(tmp.read()).decode() - if close_file: - tmp.close() - return add_excel_prefix(content) - - -def render_merged_header_excel( - df: DataFrame, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - sheet_name: str = DEFAULT_SHEET_NAME, - file: BinaryIO | None = None, - close_file: bool = True, - column_write_offset: int = 0, -) -> str: - """把合并的表头写入 Excel 文件""" - if file is None: - close_file = True - - tmp = _get_file(file) - writer = ExcelWriter(tmp, engine='openpyxl') - _write_comment_header(df, tmp, sheet_name, writer=writer, close_file=False) - _write_merged_header( - df, - field_meta_mapping, - tmp, - sheet_name, - column_write_offset, - row_write_offset=HEADER_HINT_LINE_COUNT, - writer=writer, - close_file=False, - ) - - writer.close() # writer 需要先 close,否则无法读取到数据 - tmp.seek(0) - content = base64.b64encode(tmp.read()).decode() - - if close_file: - tmp.close() - return add_excel_prefix(content) - - -def render_data_excel( - df: DataFrame, - errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]], - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - sheet_name: str = DEFAULT_SHEET_NAME, - file: BinaryIO | None = None, - close_file: bool = True, - has_merged_header: bool = False, -) -> Base64Str: - if file is None: - close_file = True - - tmp = _get_file(file) - writer = ExcelWriter(tmp, engine='openpyxl') - - _write_comment_header(df, tmp, sheet_name, writer=writer, close_file=False) - if has_merged_header: - pands_data_start_index = 1 - _write_merged_header( - df, - field_meta_mapping, - tmp, - sheet_name, - row_write_offset=HEADER_HINT_LINE_COUNT, - writer=writer, - close_file=False, - ) - else: - pands_data_start_index = 0 - _write_simple_header( - df, - field_meta_mapping, - tmp, - sheet_name, - row_write_offset=HEADER_HINT_LINE_COUNT, - writer=writer, - close_file=False, - ) - _write_value_mark_error( - df, - errors, - field_meta_mapping, - tmp, - sheet_name, - row_write_offset=HEADER_HINT_LINE_COUNT + 1, # 表头 1 行,HEADER_HINT 一行 - writer=writer, - close_file=False, - pands_data_start_index=pands_data_start_index, - ) - - writer.close() - tmp.seek(0) - content = base64.b64encode(tmp.read()).decode() - if close_file: - tmp.close() - return Base64Str(add_excel_prefix(content)) diff --git a/excelalchemy/helper/pydantic.py b/excelalchemy/helper/pydantic.py deleted file mode 100644 index 6ee258e..0000000 --- a/excelalchemy/helper/pydantic.py +++ /dev/null @@ -1,194 +0,0 @@ -from collections.abc import Sequence -from typing import Any -from typing import Generator -from typing import Iterable -from typing import TypeVar -from typing import cast - -from pydantic import BaseModel -from pydantic import MissingError -from pydantic import NoneIsNotAllowedError -from pydantic import ValidationError -from pydantic.error_wrappers import ErrorList -from pydantic.error_wrappers import ErrorWrapper -from pydantic.fields import ModelField -from pydantic.fields import UndefinedType - -from excelalchemy.const import ImporterCreateModelT -from excelalchemy.const import ImporterUpdateModelT -from excelalchemy.exc import ExcelCellError -from excelalchemy.exc import ProgrammaticError -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.abstract import ComplexABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import Key - -ModelT = TypeVar('ModelT', bound=BaseModel) - - -def extract_pydantic_model( - model: type[ImporterCreateModelT] | type[ImporterUpdateModelT] | type[BaseModel] | None, -) -> list[FieldMetaInfo]: - """根据 Pydantic 模型提取 Excel 表头信息 - 包含是否必填、值类型、注释等信息 - """ - if model is None: - raise RuntimeError('模型不能为空') - return list(_extract_pydantic_model(model)) - - -def instantiate_pydantic_model( # noqa: C901 - data: dict[Key, Any], - model: type[ModelT], -) -> ModelT | list[ExcelCellError]: - """实例化 Pydantic 模型, 并返回错误. - - 若实例化成功, 则返回实例化后的模型, 错误信息为 None - 若实例化失败, 则模型返回 None, 错误信息为 ExcelImportError 列表 - 若无法取得FieldMeta, 则raise ProgrammaticError - """ - try: - result: ModelT | list[ExcelCellError] = model.parse_obj(data) - except ValidationError as wrapped_error: - locations_and_errors = list(_flatten_errors(wrapped_error.raw_errors, None)) - - if len(locations_and_errors) == 0: - raise ProgrammaticError('empty ValidationError') from wrapped_error - - result = [] - - for loc, e in locations_and_errors: - attr_path = _validate_error_loc(loc) - - match attr_path: - case (leaf,): - leaf_field_def = _validate_field_meta(model.__fields__[leaf]) - - _handle_error(result, e.exc, None, leaf_field_def) - - case (parent, leaf): - parent_field_def = _validate_field_meta(model.__fields__[parent]) - leaf_field_def = _validate_field_meta(model.__fields__[parent].type_.__fields__[leaf]) - - _handle_error(result, e.exc, parent_field_def, leaf_field_def) - - if len(result) == 0: - raise ProgrammaticError('实例化模型失败, 但错误信息为空') from wrapped_error - - return result - - -def _extract_pydantic_model(model: type[BaseModel]) -> Generator[FieldMetaInfo, None, None]: - for model_field in model.__fields__.values(): - field_info = model_field.field_info - if not isinstance(field_info, FieldMetaInfo): - raise ProgrammaticError('字段定义必须是 FieldMeta 的实例') - - type_ = model_field.type_ - if issubclass(type_, ComplexABCValueType): - for offset, (key, sub_field_info) in enumerate(type_.model_items()): - sub_field_info = _complete_field_info(sub_field_info, model_field) - sub_field_info.parent_label, sub_field_info.key, sub_field_info.offset = field_info.label, key, offset - yield sub_field_info - - elif issubclass(type_, ABCValueType): - field_info = _complete_field_info(field_info, model_field) - yield field_info - - else: - raise ProgrammaticError(f'字段定义必须是 ValueType 的子类, 或 ComplexValueType 的子类, 不支持 {type_}') - - -def _complete_field_info(field_info: FieldMetaInfo, field: ModelField) -> FieldMetaInfo: - """补全 FieldMeta 信息""" - if isinstance(field.required, UndefinedType): - field_info.required = False - else: - field_info.required = field.required - field_info.value_type = field.type_ - field_info.parent_label = field_info.label - field_info.parent_key = Key(field.name) - field_info.key = Key(field.name) - field_info.offset = 0 - - # 不同 ValueType 需要的不同信息, 需要及时补充 - original_field_info = cast(FieldMetaInfo, field.field_info) - field_info.order = original_field_info.order - - field_info.character_set = field_info.character_set or original_field_info.character_set - field_info.fraction_digits = field_info.fraction_digits or original_field_info.fraction_digits - - field_info.timezone = field_info.timezone or original_field_info.timezone - field_info.date_format = field_info.date_format or original_field_info.date_format - field_info.date_range_option = field_info.date_range_option or original_field_info.date_range_option - - field_info.unit = field_info.unit or original_field_info.unit - - return field_info - - -def _handle_error( - error_container: list[ExcelCellError], - exc: Exception, - parent_field_def: FieldMetaInfo | None, - leaf_field_def: FieldMetaInfo, -): - match exc: - case NoneIsNotAllowedError() | MissingError(): - error_container.append( - ExcelCellError( - parent_label=parent_field_def and parent_field_def.label, # type: ignore[arg-type] - label=leaf_field_def.label, - message='必填项缺失', - ) - ) - case _: - error_container.extend( - [ - ExcelCellError( - parent_label=parent_field_def and parent_field_def.label, # type: ignore[arg-type] - label=leaf_field_def.label, - message=arg, - ) - for arg in exc.args - ] - ) - - -def _flatten_errors( - error_list: Sequence[ErrorList], - loc: tuple[str | int, ...] | None, -) -> Iterable[tuple[tuple[str | int, ...], ErrorWrapper]]: - for error in error_list: - if isinstance(error, ErrorWrapper): - if loc: - error_loc = loc + error.loc_tuple() - else: - error_loc = error.loc_tuple() - - if isinstance(error.exc, ValidationError): - yield from _flatten_errors(error.exc.raw_errors, error_loc) - else: - yield error_loc, error - - else: - yield from _flatten_errors(error, loc=loc) - - -def _validate_field_meta(raw_model_field: ModelField) -> FieldMetaInfo: - field_info = raw_model_field.field_info - if not isinstance(field_info, FieldMetaInfo): - raise ProgrammaticError('field definition must be an instance of FieldMeta') - - return field_info - - -def _validate_error_loc(raw_loc: tuple[int | str, ...]) -> tuple[str] | tuple[str, str]: - if len(raw_loc) > 2: - raise ProgrammaticError('too deep nested fields (>2) from ill-formed model') - - for loc_node in raw_loc: - if not isinstance(loc_node, str): - raise ProgrammaticError('unsupported list element from ill-formed model') - - return cast(tuple[str] | tuple[str, str], raw_loc) diff --git a/excelalchemy/types/abstract.py b/excelalchemy/types/abstract.py deleted file mode 100644 index 276c56d..0000000 --- a/excelalchemy/types/abstract.py +++ /dev/null @@ -1,120 +0,0 @@ -from abc import ABC -from abc import abstractmethod -from typing import TYPE_CHECKING -from typing import Any -from typing import Iterable - -from pydantic.fields import ModelField - -from excelalchemy.types.identity import Key - -if TYPE_CHECKING: - # pyright: reportImportCycles=false - from excelalchemy.types.field import FieldMetaInfo -else: - FieldMetaInfo = Any - - -class ABCValueType(ABC): - """ - raw_data --> serialize --> __validate__ - raw_data--> deserialize - """ - - @classmethod - @abstractmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - """用于渲染 Excel 表头的注释""" - - @classmethod - @abstractmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: # value is always not None - """用于把用户填入 Excel 的数据,转换成后端代码入口可接收的数据 - 如果转换失败,返回原值,用户后续捕获更准确的错误 - """ - - @classmethod - @abstractmethod - def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - """用于把 pandas 读取的 Excel 之后的数据,转回用户可识别的数据, 处理聚合之前的数据""" - - @classmethod - def __wrapped_validate__(cls, value: Any, field: ModelField) -> Any: - # pyright: reportGeneralTypeIssues=false - return cls.__validate__(value, field.field_info) # type: ignore[arg-type] - - @classmethod - @abstractmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - """验证用户输入的值是否符合约束. 接收 serialize 后的值""" - - @classmethod - def __get_validators__(cls) -> Iterable[Any]: - yield cls.__wrapped_validate__ - - -class ComplexABCValueType(ABCValueType, dict): # pyright: reportMissingTypeArgument=false - """用于生成 pydantic 的模型时,用于标记字段的类型""" - - @classmethod - @abstractmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - """用于渲染 Excel 表头的注释""" - - @classmethod - @abstractmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - """用于把用户填入 Excel 的数据,转换成后端代码入口可接收的数据 - 如果转换失败,返回原值,用户后续捕获更准确的错误 - serialize 是聚合之后的数据 - """ - - @classmethod - @abstractmethod - def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - """用于把 pandas 读取的 Excel 之后的数据,转回用户可识别的数据, 处理聚合之前的数据""" - - @classmethod - @abstractmethod - def model_items(cls) -> list[tuple[Key, FieldMetaInfo]]: - """用于获取模型的所有字段名""" - - -class SystemReserved(ABCValueType): - __name__ = 'SystemReserved' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return '' - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - return value - - @classmethod - def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - return value - - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - return value - - -class Undefined(ABCValueType): - __name__ = 'Undefined' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return '' - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - return value - - @classmethod - def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - return value - - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - return value diff --git a/excelalchemy/types/alchemy.py b/excelalchemy/types/alchemy.py deleted file mode 100644 index 9f0ab7c..0000000 --- a/excelalchemy/types/alchemy.py +++ /dev/null @@ -1,123 +0,0 @@ -"""实例化 ExcelAlchemy 时的配置""" -from dataclasses import dataclass -from dataclasses import field -from enum import Enum -from typing import Any -from typing import Awaitable -from typing import Callable -from typing import Generic -from typing import Literal -from typing import Type - -from minio import Minio - -from excelalchemy.const import ContextT -from excelalchemy.const import ExporterModelT -from excelalchemy.const import ImporterCreateModelT -from excelalchemy.const import ImporterUpdateModelT -from excelalchemy.exc import ConfigError -from excelalchemy.util.convertor import export_data_converter -from excelalchemy.util.convertor import import_data_converter - - -class ExcelMode(str, Enum): - """Excel 模式""" - - IMPORT = 'IMPORT' - EXPORT = 'EXPORT' - - -class ImportMode(str, Enum): - CREATE = 'CREATE' # 创建 - UPDATE = 'UPDATE' # 更新 - CREATE_OR_UPDATE = 'CREATE_OR_UPDATE' # 创建或更新 - - -@dataclass -class ImporterConfig(Generic[ContextT, ImporterCreateModelT, ImporterUpdateModelT]): - create_importer_model: Type[ImporterCreateModelT] | None = field(default=None) - update_importer_model: Type[ImporterUpdateModelT] | None = field(default=None) - - # Callable function receive Key as dict key instead of Label. - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = field(default=import_data_converter) - creator: Callable[[dict[str, Any], ContextT | None], Awaitable[Any]] | None = field(default=None) - updater: Callable[[dict[str, Any], ContextT | None], Awaitable[Any]] | None = field(default=None) - - context: ContextT | None = field(default=None) - is_data_exist: Callable[[dict[str, Any], ContextT | None], Awaitable[bool]] | None = field(default=None) - exec_formatter: Callable[[Exception], str] = field(default=str) - - import_mode: ImportMode = field(default=ImportMode.CREATE) - - minio: Minio = field(default=None) - bucket_name: str = field(default='excel') - url_expires: int = field(default=3600) - - sheet_name: Literal['Sheet1'] = field(default='Sheet1') - - def validate_model(self): - if self.import_mode not in ImportMode.__members__.values(): - raise ConfigError(f'导入模式 {self.import_mode} 不合法') - - match self.import_mode: - case ImportMode.CREATE: - self._validate_create() - case ImportMode.UPDATE: - self._validate_update() - case ImportMode.CREATE_OR_UPDATE: - self._validate_create_or_update() - - return self - - # 创建模式验证 - def _validate_create(self): - if self.import_mode != ImportMode.CREATE: - raise ConfigError(f'导入模式 {self.import_mode} 不合法') - if not self.create_importer_model: - raise ConfigError('当选择【创建模式】时,创建模型不能为空') - - # 更新模式验证 - def _validate_update(self): - if self.import_mode != ImportMode.UPDATE: - raise ConfigError(f'导入模式 {self.import_mode} 不合法') - if not self.update_importer_model: - raise ConfigError('当选择【更新模式】时,更新模型不能为空') - - # 创建或更新模式验证 - def _validate_create_or_update(self): - if self.import_mode != ImportMode.CREATE_OR_UPDATE: - raise ConfigError(f'导入模式 {self.import_mode} 不合法') - - if not self.create_importer_model: - raise ConfigError('当选择【创建或更新模式】时,创建模型不能为空') - if not self.update_importer_model: - raise ConfigError('当选择【创建或更新模式】时,更新模型不能为空') - if not self.is_data_exist: - raise ConfigError('当选择【创建或更新模式】时,数据存在判断函数不能为空') - # 创建模型和更新模型的字段必须一致 - if self.create_importer_model.__fields__.keys() != self.update_importer_model.__fields__.keys(): - raise ConfigError('创建模型和更新模型的字段名称必须一致') - - def __post_init__(self): - self.validate_model() - - -@dataclass -class ExporterConfig(Generic[ExporterModelT]): - exporter_model: Type[ExporterModelT] - # Callable function receive Key as dict key instead of Label. - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = field(default=export_data_converter) - - minio: Minio = field(default=None) - bucket_name: str = field(default='excel') - url_expires: int = field(default=3600) - - sheet_name: Literal['Sheet1'] = field(default='Sheet1') - - def validate_model(self): - if not self.exporter_model: - raise ValueError('导出模型不能为空') - return self - - def __post_init__(self): - self.validate_model() diff --git a/excelalchemy/types/field.py b/excelalchemy/types/field.py deleted file mode 100644 index 4392b7c..0000000 --- a/excelalchemy/types/field.py +++ /dev/null @@ -1,429 +0,0 @@ -"""用于表示后端实际希望接受的 Excel 表头 """ -import datetime -import logging -from functools import cached_property -from typing import AbstractSet -from typing import Any -from typing import Optional -from typing import Union - -from pydantic import BaseModel -from pydantic.fields import FieldInfo -from pydantic.fields import Undefined as PydanticUndefined -from pydantic.typing import NoArgAnyCallable - -from excelalchemy.const import DATA_RANGE_OPTION_TO_CHINESE -from excelalchemy.const import DATE_FORMAT_TO_HINT_MAPPING -from excelalchemy.const import DATE_FORMAT_TO_PYTHON_MAPPING -from excelalchemy.const import DEFAULT_FIELD_META_ORDER -from excelalchemy.const import MAX_OPTIONS_COUNT -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.const import UNIQUE_HEADER_CONNECTOR -from excelalchemy.const import CharacterSet -from excelalchemy.const import DataRangeOption -from excelalchemy.const import DateFormat -from excelalchemy.const import IntStr -from excelalchemy.const import Option -from excelalchemy.exc import ConfigError -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.abstract import Undefined -from excelalchemy.types.identity import Key -from excelalchemy.types.identity import Label -from excelalchemy.types.identity import OptionId -from excelalchemy.types.identity import UniqueKey -from excelalchemy.types.identity import UniqueLabel - - -class PatchFieldMeta(BaseModel): - unique: bool | None = False # 当前列是否唯一,不用于校验,用于渲染 Excel 表头的注释 - is_primary_key: bool | None = False # 当前列是否为主键,不用于校验,用于渲染 Excel 表头的注释 - hint: str | None = None # 当前列的提示信息,不用于校验,用于渲染 Excel 表头的注释 - options: list[Option] | None = None - - -class FieldMetaInfo(FieldInfo): - """用于表示后端真实期望的 Excel 表头信息""" - - label: Label # 字段用于展示给用户的名称, 必有 - is_primary_key: bool = False # 是否为主键(产品定义的关键列) - - # 不使用自定义表单时,下面字段可以不用填写 - parent_label: Label | None = None # 字段的父字段, 运行时必有, parent_label 等于 label - - key: Key | None = None # 字段存储在数据库的名称, 运行时必有 - parent_key: Key | None = None # 字段存储在数据库中的父级名称, 运行时必有 - - offset: int = DEFAULT_FIELD_META_ORDER # 合并表头·子单元格所属父单元格的偏移量, 运行时必有 - value_type: type[ABCValueType] = Undefined # 字段的数据类型, 运行时必有 - unique: bool | None = False # 当前列是否唯一,不用于校验,用于渲染 Excel 表头的注释 - - required: bool | None = False # 当前列是否必填,不用于校验,用于渲染 Excel 表头的注释 - ignore_import: bool | None = False # 当前列是否忽略导入,不用于校验,用于渲染 Excel 表头的注释 - - order: int = 0 # 字段的顺序, 运行时必有 - - # 若增加属性,需要同步修改 helper.pydantic._complete_field_info 方法 - # TEXT相关配置 - character_set: set[CharacterSet] | None = None - - # NUMBER相关配置 - fraction_digits: int | None = None - - # DATE相关配置 - timezone: datetime.timezone - date_format: DateFormat | None = None - date_range_option: DataRangeOption | None = None - - # RADIO, MULTI_CHECKBOX, SELECT相关配置 - options: list[Option] | None = None - - unit: str | None = None # 单位 - - # 废弃 - agg_key: str | None = None # 聚合字段的 key, 可选 - - # pylint: disable=too-many-locals - def __init__( - self, - default: Any = Undefined, - *, - # 导入模块增加的字段·必填 - label: str, - # 是否为主键(产品定义的关键列) - is_primary_key: bool = False, - # 导入模块增加的字段·从 pydantic 模型中获取 - unique: bool = False, - ignore_import: bool = False, - order: int = DEFAULT_FIELD_META_ORDER, - # TEXT - character_set: set[CharacterSet] | None = None, - # NUMBER - fraction_digits: int | None = None, - # DATE - timezone: datetime.timezone | None = None, - date_format: DateFormat | None = None, - date_range_option: DataRangeOption | None = None, - # RADIO, MULTI_CHECKBOX, SELECT - options: list[Option] | None = None, - unit: str | None = None, - hint: str | None = None, - # 导入模块增加的字段·结束 - default_factory: Optional[NoArgAnyCallable] = None, - alias: str | None = None, - title: str | None = None, - description: str | None = None, - exclude: Union[AbstractSet[IntStr], AbstractSet[IntStr], Any] = None, - include: Union[AbstractSet[IntStr], AbstractSet[IntStr], Any] = None, - const: bool | None = None, - ge: float | None = None, - le: float | None = None, - multiple_of: float | None = None, - allow_inf_nan: bool | None = None, - max_digits: int | None = None, - decimal_places: int | None = None, - min_items: int | None = None, - max_items: int | None = None, - unique_items: bool | None = None, - min_length: int | None = None, - max_length: int | None = None, - allow_mutation: bool | None = True, - regex: str | None = None, - discriminator: str | None = None, - repr: bool = True, - **extra: Any, - ) -> None: - super().__init__( - default, - default_factory=default_factory, - alias=alias, - title=title, - description=description, - exclude=exclude, - include=include, - const=const, - gt=None, - lt=None, - multiple_of=multiple_of, - allow_inf_nan=allow_inf_nan, - allow_mutation=allow_mutation, - regex=regex, - discriminator=discriminator, - repr=repr, - **extra, - ) - self.importer_ge = ge - self.importer_le = le - self.importer_max_digits = max_digits - self.importer_decimal_places = decimal_places - self.importer_min_length = min_length - self.importer_max_length = max_length - self.importer_min_items = min_items - self.importer_max_items = max_items - self.importer_unique_items = unique_items - - self._validate() - self.label = Label(label) - self.is_primary_key = is_primary_key - self.unique = unique or is_primary_key # 主键一定唯一 - self.ignore_import = ignore_import - self.order = order - - self.character_set = character_set or set(CharacterSet) - self.fraction_digits = fraction_digits - self.timezone = timezone or datetime.timezone(datetime.timedelta(hours=8), 'CST') - self.date_format = date_format - self.date_range_option = date_range_option - self.options = options - self.unit = unit - self.hint = hint - - # 下列属性从 pydantic 配置中获取,不允许手动设置 - self.required = False - - def set_is_primary_key(self, is_primary_key: bool | None) -> None: - if is_primary_key is None: - return - self.is_primary_key = is_primary_key - if self.is_primary_key: - self.unique = True - self.required = True - - def set_unique(self, unique: bool | None) -> None: - if unique is None: - return - self.unique = unique - if self.unique: - self.required = True - - def validate_state(self) -> None: - if self.is_primary_key and not self.unique: - raise ValueError('主键必须唯一') - if (self.is_primary_key or self.unique) and self.required is False: - raise ValueError('主键或唯一字段必须必填') - - def exchange_option_ids_to_names(self, option_ids: list[str] | list[OptionId]) -> list[str]: - option_names: list[str] = [] - - for option_id in option_ids: - option_id = OptionId(option_id) - try: - option_names.append(self.options_id_map[option_id].name) - except KeyError: - logging.warning('找不到选项id %s,将返回原值', option_id) - option_names.append(option_id) - - return option_names - - def exchange_names_to_option_ids_with_errors(self, names: list[str]) -> tuple[list[str], list[str]]: - errors: list[str] = [] - result: list[str] = [] - for name in names: - option = self.options_name_map.get(name) - if option is None: - errors.append('选项不存在,请参照表头的注释填写') - else: - result.append(option.id) - return result, errors - - @property - def unique_label(self) -> UniqueLabel: - if self.parent_label is None: - raise RuntimeError('运行时 parent_label 不能为空') - label = ( - f'{self.parent_label}{UNIQUE_HEADER_CONNECTOR}{self.label}' - if self.parent_label != self.label - else self.label - ) - return UniqueLabel(label) - - @property - def unique_key(self) -> UniqueKey: - if self.parent_key is None: - raise RuntimeError('运行时 parent_key 不能为空') - key = f'{self.parent_key}{UNIQUE_HEADER_CONNECTOR}{self.key}' if self.parent_key != self.key else self.key - return UniqueKey(key) - - @cached_property - def options_id_map(self) -> dict[OptionId, Option]: - if self.options is None: - return {} - if len(self.options) > MAX_OPTIONS_COUNT: - logging.warning( - '您为字段【%s】指定了 %s 个选项, 请考虑此数量是否合理,options 设计的本意不是为了处理大量数据', - self.label, - len(self.options), - ) - return {option.id: option for option in self.options} - - @cached_property - def options_name_map(self) -> dict[str, Option]: - if self.options is None: - return {} - if len(self.options) > MAX_OPTIONS_COUNT: - logging.warning( - '您为字段【%s】指定了 %s 个选项, 请考虑此数量是否合理,options 设计的本意不是为了处理大量数据', - self.label, - len(self.options), - ) - return {option.name: option for option in self.options} - - @property - def comment_required(self) -> str: - return f"必填性:{'必填' if self.required else '选填'}" - - @property - def comment_date_format(self) -> str: - if self.date_format is None: - return '' - return f'格式:日期({DATE_FORMAT_TO_HINT_MAPPING[self.date_format]})' - - @property - def comment_date_range_option(self) -> str: - if self.date_range_option is None: - return '范围:无限制' - return f'范围:{DATA_RANGE_OPTION_TO_CHINESE[self.date_range_option]}' - - @property - def comment_hint(self) -> str: - if self.hint is None: - return '' - return f'提示:{self.hint}' - - @property - def comment_options(self) -> str: - if self.options is None: - return '' - return f'选项:{MULTI_CHECKBOX_SEPARATOR.join(x.name for x in self.options)}' - - @property - def comment_fraction_digits(self) -> str: - return f'小数位数:{self.fraction_digits or 0}' - - @property - def comment_unit(self) -> str: - return f'单位:{self.unit or "无"}' - - @property - def comment_unique(self) -> str: - return f"唯一性:{'唯一' if self.unique else '非唯一'}" - - @property - def comment_max_length(self) -> str: - return f'最大长度:{self.importer_max_length or "无限制"}' - - @property - def must_date_format(self) -> DateFormat: - if self.date_format is None: - raise ConfigError('运行时 date_format 不能为空') - return self.date_format - - @property - def python_date_format(self) -> str: - return DATE_FORMAT_TO_PYTHON_MAPPING[self.must_date_format] - - def __repr__(self): - return ( - f'FieldMeta(label={self.label!r}, ' - f'order={self.order!r}, ' - f'value_type={self.value_type.__name__!r}, ' - f'required={self.required!r}, ' - f'unique={self.unique!r}, ' - f'comment_required={self.comment_required!r}, ' - f'comment_unique={self.comment_unique!r})' - ) - - __str__ = __repr__ - - -# pylint: disable=invalid-name -# pylint: disable=too-many-locals -def FieldMeta( - default: Any = PydanticUndefined, - *, - # 导入模块增加的字段·必填 - label: str, - # 是否为主键(产品定义的关键列) - is_primary_key: bool = False, - # 导入模块增加的字段·从 pydantic 模型中获取 - unique: bool = False, - ignore_import: bool = False, - order: int = DEFAULT_FIELD_META_ORDER, - # TEXT - character_set: set[CharacterSet] | None = None, - # NUMBER - fraction_digits: int | None = None, - # DATE - timezone: datetime.timezone | None = None, - date_format: DateFormat | None = None, - date_range_option: DataRangeOption | None = None, - # RADIO, MULTI_CHECKBOX, SELECT - options: list[Option] | None = None, - unit: str | None = None, - hint: str | None = None, - # 导入模块增加的字段·结束 - default_factory: Optional[NoArgAnyCallable] = None, - alias: str | None = None, - title: str | None = None, - description: str | None = None, - exclude: Union[AbstractSet[IntStr], AbstractSet[IntStr], Any] = None, - include: Union[AbstractSet[IntStr], AbstractSet[IntStr], Any] = None, - const: bool | None = None, - ge: float | None = None, - le: float | None = None, - multiple_of: float | None = None, - allow_inf_nan: bool | None = None, - max_digits: int | None = None, - decimal_places: int | None = None, - min_items: int | None = None, - max_items: int | None = None, - unique_items: bool | None = None, - min_length: int | None = None, - max_length: int | None = None, - allow_mutation: bool | None = True, - regex: str | None = None, - discriminator: str | None = None, - repr: bool = True, - **extra: Any, -) -> Any: # return any to ignore the annotation type - # pyright: reportUnnecessaryIsInstance=false - if fraction_digits is not None and not isinstance(fraction_digits, int): - raise ValueError('fraction_digits 必须是整数') - return FieldMetaInfo( - default=default, - label=label, - is_primary_key=is_primary_key, - unique=unique, - ignore_import=ignore_import, - order=order, - character_set=character_set, - fraction_digits=fraction_digits, - timezone=timezone, - date_format=date_format, - date_range_option=date_range_option, - options=options, - unit=unit, - hint=hint, - default_factory=default_factory, - alias=alias, - title=title, - description=description, - exclude=exclude, - include=include, - const=const, - ge=ge, - le=le, - multiple_of=multiple_of, - allow_inf_nan=allow_inf_nan, - max_digits=max_digits, - decimal_places=decimal_places, - min_items=min_items, - max_items=max_items, - unique_items=unique_items, - min_length=min_length, - max_length=max_length, - allow_mutation=allow_mutation, - regex=regex, - discriminator=discriminator, - repr=repr, - **extra, - ) diff --git a/excelalchemy/types/header.py b/excelalchemy/types/header.py deleted file mode 100644 index 9f528f4..0000000 --- a/excelalchemy/types/header.py +++ /dev/null @@ -1,25 +0,0 @@ -"""用于表示用户实际输入 Excel 的表头""" -from pydantic import BaseModel -from pydantic.fields import Field - -from excelalchemy.const import UNIQUE_HEADER_CONNECTOR -from excelalchemy.types.identity import Label -from excelalchemy.types.identity import UniqueLabel - - -class ExcelHeader(BaseModel): - """用于表示用户输入的 Excel 表头信息""" - - label: Label = Field(description='Excel 的列名') - parent_label: Label = Field(description='Excel 的父列名, 如果没有父列名, parent_label 等于 label') - offset: int = Field(default=0, description='合并表头·子单元格所属父单元格的偏移量') - - @property - def unique_label(self) -> UniqueLabel: - """返回唯一标签""" - label = ( - f'{self.parent_label}{UNIQUE_HEADER_CONNECTOR}{self.label}' - if self.parent_label != self.label - else self.label - ) - return UniqueLabel(label) diff --git a/excelalchemy/types/identity.py b/excelalchemy/types/identity.py deleted file mode 100644 index 98f83df..0000000 --- a/excelalchemy/types/identity.py +++ /dev/null @@ -1,37 +0,0 @@ -"""定义了一些用于标识的类型""" - - -class Label(str): - """Excel 的列名""" - - -class UniqueLabel(Label): - """Excel 唯一的列名""" - - -class Key(str): - """Python 模型的键名""" - - -class UniqueKey(Key): - """Python 模型唯一的键名""" - - -class RowIndex(int): - """Excel 的行索引, 从 0 开始""" - - -class ColumnIndex(int): - """Excel 的列索引, 从 0 开始""" - - -class OptionId(str): - """选项 ID""" - - -class Base64Str(str): - """Base64 编码的字符串""" - - -class UrlStr(str): - """URL 字符串""" diff --git a/excelalchemy/types/result.py b/excelalchemy/types/result.py deleted file mode 100644 index a7a2c6c..0000000 --- a/excelalchemy/types/result.py +++ /dev/null @@ -1,74 +0,0 @@ -"""导入 Excel 的结果""" -from enum import Enum - -from pydantic import BaseModel -from pydantic import Extra -from pydantic import Field - -from excelalchemy.types.identity import Label - - -class ValidateRowResult(str, Enum): - """导入结果""" - - SUCCESS = '校验通过' - FAIL = '校验不通过' - - def __str__(self): - return self.value - - -class ValidateHeaderResult(BaseModel): - """校验表头结果""" - - missing_required: list[Label] = Field(description='缺失的必填表头') - missing_primary: list[Label] = Field(description='缺失的关键列') - unrecognized: list[Label] = Field(description='无法识别的表头') - duplicated: list[Label] = Field(description='重复的表头') - is_valid: bool = Field(default=True, description='是否校验通过') - - @property - def is_required_missing(self) -> bool: - """是否缺失必填表头""" - return bool(self.missing_required) - - -class ValidateResult(str, Enum): - """导入结果类型""" - - HEADER_INVALID = 'HEADER_INVALID' # 表头无效 - DATA_INVALID = 'DATA_INVALID' # 数据无效 - SUCCESS = 'SUCCESS' # 成功 - - -class ImportResult(BaseModel): - """导入数据结果""" - - result: ValidateResult = Field(description='导入结果') - - is_required_missing: bool = Field(default=False, description='是否缺失必填表头') - missing_required: list[Label] = Field(default_factory=list, description='缺失的必填表头') - missing_primary: list[Label] = Field(default_factory=list, description='缺失的关键列') - unrecognized: list[Label] = Field(default_factory=list, description='无法识别的表头') - duplicated: list[Label] = Field(default_factory=list, description='重复的表头') - - url: str | None = Field(default=None, description='导入结果文件的下载链接, 失败时有值') - success_count: int = Field(default=0, description='导入成功的数据条数') - fail_count: int = Field(default=0, description='导入失败的数据条数') - - class Config: - extra = Extra.allow - - @classmethod - def from_validate_header_result(cls, result: ValidateHeaderResult) -> 'ImportResult': - """从校验表头结果构造导入结果""" - if result.is_valid: - raise RuntimeError('只有校验表头失败时才能构造导入结果') - return cls( - result=ValidateResult.HEADER_INVALID, - is_required_missing=result.is_required_missing, - missing_primary=result.missing_primary, - unrecognized=result.unrecognized, - duplicated=result.duplicated, - missing_required=result.missing_required, - ) diff --git a/excelalchemy/types/value/__init__.py b/excelalchemy/types/value/__init__.py deleted file mode 100644 index 8e513f7..0000000 --- a/excelalchemy/types/value/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""ExcelAlchemy value types,用于生成 pydantic 的模型时,用于标记字段的类型""" - -from excelalchemy.types.abstract import ABCValueType - -EXCEL_CHOICE_VALUE_TYPE: dict[type[ABCValueType], type[ABCValueType]] = {} - - -def excel_choice(value_type: type[ABCValueType]) -> type[ABCValueType]: - EXCEL_CHOICE_VALUE_TYPE[value_type] = value_type - return value_type diff --git a/excelalchemy/types/value/boolean.py b/excelalchemy/types/value/boolean.py deleted file mode 100644 index 24bf0b0..0000000 --- a/excelalchemy/types/value/boolean.py +++ /dev/null @@ -1,55 +0,0 @@ -import logging -from typing import Any - -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value import excel_choice - - -@excel_choice -class Boolean(ABCValueType): - __name__ = '布尔' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return '\n'.join( - [ - field_meta.comment_required, - field_meta.comment_hint, - ] - ) - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> str: - return str(value).strip() - - @classmethod - def deserialize(cls, value: bool | str | None | Any, field_meta: FieldMetaInfo) -> str: - if value is None or value == '': - return '否' # 产品要求,空值默认为否 - - if isinstance(value, bool): - return '是' if value else '否' - - elif isinstance(value, str): - value = value.strip() - if value not in ('是', '否'): - logging.warning('无法识别布尔值 %s, 返回原值', value) - return value - return value - else: - logging.warning('类型【%s】无法为 %s 反序列化: %s, 返回默认值 "否" ', cls.__name__, field_meta.label, value) - - return '是' if str(value) == '是' else '否' - - @classmethod - def __validate__(cls, value: str | bool | Any, field_meta: FieldMetaInfo) -> bool: - if isinstance(value, bool): - return value - - value_str = str(value) - - if value_str not in ('是', '否'): - raise ValueError('请输入“是”或“否”') - - return value_str == '是' diff --git a/excelalchemy/types/value/date_range.py b/excelalchemy/types/value/date_range.py deleted file mode 100644 index a49e06b..0000000 --- a/excelalchemy/types/value/date_range.py +++ /dev/null @@ -1,169 +0,0 @@ -import logging -from datetime import datetime -from typing import Any - -import pendulum - -# pyright: reportPrivateImportUsage=false -from pendulum import DateTime -from pydantic import BaseModel - -from excelalchemy.const import DATE_FORMAT_TO_PYTHON_MAPPING -from excelalchemy.const import MILLISECOND_TO_SECOND -from excelalchemy.const import DataRangeOption -from excelalchemy.types.abstract import ComplexABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import Key - - -class _DateRangeImpl(BaseModel): - start: datetime | None - end: datetime | None - - -class DateRange(ComplexABCValueType): - start: datetime | None - end: datetime | None - - __name__ = '日期范围' - - @classmethod - def parse_obj(cls, obj: Any) -> 'DateRange': - impl = _DateRangeImpl.parse_obj(obj) - self = cls(impl.start, impl.end) - return self - - def __init__(self, start: datetime | None, end: datetime | None): - # pyright: reportUnknownMemberType=false - # trick, BaseMode.dict() 会得到时间戳,而不是 datetime 对象,这是预期的行为 - _start = int(start.timestamp() * MILLISECOND_TO_SECOND) if start else None - _end = int(end.timestamp() * MILLISECOND_TO_SECOND) if end else None - super().__init__(start=_start, end=_end) - self.start = start - self.end = end - - @classmethod - def model_items(cls) -> list[tuple[Key, FieldMetaInfo]]: - return [ - (Key('start'), FieldMetaInfo(label='开始日期')), - (Key('end'), FieldMetaInfo(label='结束日期')), - ] - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - if field_meta.date_format is None: - raise RuntimeError('日期格式未定义') - - return '\n'.join( - [ - field_meta.comment_required, - field_meta.comment_date_format, - f'提示:开始日期不得晚于结束日期{field_meta.hint or ""}', - ] - ) - - @classmethod - def serialize(cls, value: dict[str, str] | Any, field_meta: FieldMetaInfo) -> dict[str, DateTime | None] | Any: - match value: - case dict(): - try: - start_str, end_str = value.get('start'), value.get('end') - # pyright: reportGeneralTypeIssues=false - # pyright: reportUnknownArgumentType=false - start_time = ( - pendulum.parse(start_str).replace( # type: ignore - tzinfo=field_meta.timezone, - ) - if start_str - else None - ) - end_time = ( - pendulum.parse(end_str).replace( # type: ignore - tzinfo=field_meta.timezone, - ) - if end_str - else None - ) - - return {'start': start_time, 'end': end_time} - except Exception as e: - logging.warning('Could not parse value %s for field %s. Reason: %s', value, cls.__name__, e) - return value - case datetime(): - return value - case str(): - try: - datetime_value = pendulum.parse(value).replace(tzinfo=field_meta.timezone) # type: ignore - except Exception as e: - logging.warning('Could not parse value %s for field %s. Reason: %s', value, cls.__name__, e) - return value - return datetime_value - case _: - return value - - @classmethod - def __validate__( - cls, - value: dict[str, DateTime | None] | Any, - field_meta: FieldMetaInfo, - ) -> 'DateRange': - try: - parsed = DateRange.parse_obj(value) - parsed.start = parsed.start.replace(tzinfo=field_meta.timezone) if parsed.start else parsed.start - parsed.end = parsed.end.replace(tzinfo=field_meta.timezone) if parsed.end else parsed.end - except Exception as exc: - raise ValueError('无法识别的输入') from exc - - errors: list[str] = [] - now = datetime.now(tz=field_meta.timezone) - - if parsed.start and parsed.end and parsed.start > parsed.end: - errors.append('开始日期不得晚于结束日期') - - match field_meta.date_range_option: - case DataRangeOption.PRE: - if (parsed.start and parsed.start > now) or (parsed.end and parsed.end > now): - errors.append('需早于当前时间(含当前时间)') - case DataRangeOption.NEXT: - if (parsed.start and parsed.start < now) or (parsed.end and parsed.end < now): - errors.append('需晚于当前时间(含当前时间)') - case DataRangeOption.NONE | None: - ... # do nothing - - if errors: - raise ValueError(*errors) - else: - return parsed - - @classmethod - def deserialize(cls, value: dict[str, str] | str | Any | None, field_meta: FieldMetaInfo) -> str: - if value is None or value == '': - return '' - date_format = field_meta.must_date_format - py_date_format = DATE_FORMAT_TO_PYTHON_MAPPING[date_format] - - if isinstance(value, str): - return value - - if isinstance(value, datetime): - return value.strftime(py_date_format) - - if isinstance(value, dict): - return cls.__deserialize__dict(py_date_format, value) - - logging.warning('%s 反序列化失败,返回原值', cls.__name__) - return value if value is not None else '' - - @classmethod - def __deserialize__dict(cls, py_date_format: str, value: dict[str, Any]) -> str: - start, end = value['start'], value['end'] - if isinstance(start, datetime): - start = start.strftime(py_date_format) - elif isinstance(start, (int, float)): - start = datetime.fromtimestamp(start / MILLISECOND_TO_SECOND).strftime(py_date_format) - - if isinstance(end, datetime): - end = end.strftime(py_date_format) - elif isinstance(end, (int, float)): - end = datetime.fromtimestamp(end / MILLISECOND_TO_SECOND).strftime(py_date_format) - return start + ' - ' + end diff --git a/excelalchemy/types/value/email.py b/excelalchemy/types/value/email.py deleted file mode 100644 index 8eb800d..0000000 --- a/excelalchemy/types/value/email.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Any - -from pydantic import EmailStr - -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.string import String - - -class Email(String): - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: - # Try to parse the value as a string - try: - parsed = str(value) - except Exception as exc: - raise ValueError('请输入正确的邮箱') from exc - - # Validate the parsed string as an email address - try: - EmailStr.validate(parsed) - except Exception as exc: - raise ValueError('请输入正确的邮箱') from exc - - # Return the parsed string if validation succeeds - return parsed diff --git a/excelalchemy/types/value/money.py b/excelalchemy/types/value/money.py deleted file mode 100644 index 61f7b6b..0000000 --- a/excelalchemy/types/value/money.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Any - -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.number import Number - - -class Money(Number): - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> float | int: - field_meta.fraction_digits = 2 - return super().__validate__(value, field_meta) diff --git a/excelalchemy/types/value/multi_checkbox.py b/excelalchemy/types/value/multi_checkbox.py deleted file mode 100644 index b598680..0000000 --- a/excelalchemy/types/value/multi_checkbox.py +++ /dev/null @@ -1,71 +0,0 @@ -import logging -from typing import Any -from typing import cast - -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.exc import ProgrammaticError -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import OptionId - - -class MultiCheckbox(ABCValueType, list[str]): - __name__ = '复选框组' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return '\n'.join( - [ - field_meta.comment_required, - field_meta.comment_options, - '单/多选:多选', - field_meta.comment_hint, - ] - ) - - @classmethod - def serialize(cls, value: str | Any, field_meta: FieldMetaInfo) -> list[str] | str: - # If the value is a list, convert all items to strings and strip whitespace - if isinstance(value, list): - return [str(item).strip() for item in cast(list[Any], value)] - - # If the value is a string, split it into a list using MULTI_CHECKBOX_SEPARATOR and strip whitespace - if isinstance(value, str): - return [item.strip() for item in value.split(MULTI_CHECKBOX_SEPARATOR)] - - # If the value is of an unsupported type, log a warning and return the original value - logging.warning('ValueType 类型 <%s> 无法解析 Excel 输入, 返回原值:%s', cls.__name__, value) - return value - - @classmethod - def __validate__(cls, value: list[str] | Any, field_meta: FieldMetaInfo) -> list[str]: # OptionId - if not isinstance(value, list): - raise ValueError('选项不存在,请参照表头的注释填写') - - if field_meta.options is None: - raise ProgrammaticError(f'options cannot be None when validate {cls.__name__}') - - if not field_meta.options: # empty - logging.warning('类型【%s】的字段【%s】的选项为空, 将返回原值', cls.__name__, field_meta.label) - return value - - if len(value) != len(set(value)): - raise ValueError('选项有重复') - - result, errors = field_meta.exchange_names_to_option_ids_with_errors(value) - - if errors: - raise ValueError(*errors) - else: - return result - - @classmethod - def deserialize(cls, value: str | list[OptionId] | None, field_meta: FieldMetaInfo) -> str: - match value: - case None | '': - return '' - case str(): - return value - case list(): - option_names = field_meta.exchange_option_ids_to_names(value) - return f'{MULTI_CHECKBOX_SEPARATOR}'.join(option_names) diff --git a/excelalchemy/types/value/number.py b/excelalchemy/types/value/number.py deleted file mode 100644 index 233ffcb..0000000 --- a/excelalchemy/types/value/number.py +++ /dev/null @@ -1,139 +0,0 @@ -import logging -from decimal import ROUND_DOWN -from decimal import Context -from decimal import Decimal -from decimal import InvalidOperation -from typing import Any - -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo - - -def canonicalize_decimal(value: Decimal, digits_limit: int | None) -> Decimal: - """将 Decimal 转换为指定精度的 Decimal""" - # pyright: reportGeneralTypeIssues=false - if digits_limit is not None and abs(value.as_tuple().exponent) != digits_limit: # type: ignore[arg-type] - try: - value = Decimal(value).quantize( - Decimal(f'0.{"0" * digits_limit}'), - context=Context(rounding=ROUND_DOWN), - ) - except InvalidOperation as e: - logging.warning('精度设置的过小,导致精度丢失,%s', e) - return value - - -def transform_decimal(value: Decimal | int | float | None) -> float | int | None: - """将 Decimal 转换为 float 或 int""" - if value is None: - return None - - if isinstance(value, (int, float)): - return value - - if not isinstance(value, Decimal): # pyright: reportUnnecessaryIsInstance=false - raise TypeError(f'Expected Decimal, got {type(value)}') - - if value.as_tuple().exponent == 0: - return int(value) - else: - return float(value) - - -class Number(Decimal, ABCValueType): - __name__ = '数值输入' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return '\n'.join( - [ - field_meta.comment_required, - '格式:数值', - field_meta.comment_fraction_digits, - f'可输入范围:{cls.__get_range_description__(field_meta)}', - field_meta.comment_unit, - ] - ) - - @classmethod - def serialize(cls, value: str | int | float | None, field_meta: FieldMetaInfo) -> Decimal | Any: - if isinstance(value, str): - value = value.strip() - try: - return transform_decimal(Decimal(value)) # type: ignore[arg-type] - except Exception as exc: - logging.warning('ValueType 类型 <%s> 无法解析 Excel 输入, 返回原值:%s, 原因: %s', cls.__name__, value, exc) - return str(value) if value is not None else '' - - @classmethod - def deserialize(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: - if value is None or value == '': - return '' - - try: - return str(transform_decimal(Decimal(value))) - except Exception as exc: - logging.warning('ValueType 类型 <%s> 无法解析 Excel 输入, 返回原值:%s, 原因: %s', cls.__name__, value, exc) - return str(value) - - @classmethod - def __get_range_description__(cls, field_meta: FieldMetaInfo) -> str: # type: ignore[return] - match (field_meta.importer_le, field_meta.importer_ge): - case (None, None): - return '无限制' - case (_, None): - return f'≤ {field_meta.importer_le}' - case (None, _): - return f'≥ {field_meta.importer_ge}' - case (le, ge): - return f'{ge}~{le}' - - @staticmethod - def __maybe_decimal__(value: Any) -> Decimal | None: - # 如果输入不是 Decimal 类型,尝试转换。 - if isinstance(value, Decimal): - return value - - try: - parsed = Decimal(str(value)) - except Exception as exc: - raise ValueError('无效输入,请输入数字。') from exc - - return parsed - - @staticmethod - def __check_range__(value: Decimal | float | int, field_meta: FieldMetaInfo) -> list[str]: - errors: list[str] = [] - - # 从 field_meta 对象中获取导入者上限和下限值。 - importer_le = field_meta.importer_le or Decimal('Infinity') - importer_ge = field_meta.importer_ge or Decimal('-Infinity') - - # 确保解析后的 decimal 在接受范围内。 - if not importer_ge <= value <= importer_le: - if field_meta.importer_le and field_meta.importer_ge: - errors.append(f'请输入在 {field_meta.importer_ge} 和 {field_meta.importer_le} 之间的数字。') - elif field_meta.importer_le: - errors.append(f'请输入在 -∞ 和 {field_meta.importer_le} 之间的数字。') - elif field_meta.importer_ge: - errors.append(f'请输入在 {field_meta.importer_ge} 和 +∞ 之间的数字。') - else: - pass - - return errors - - @classmethod - def __validate__(cls, value: Decimal | Any, field_meta: FieldMetaInfo) -> float | int: - # 如果输入不是 Decimal 类型,尝试转换。 - parsed = cls.__maybe_decimal__(value) - if parsed is None: - raise ValueError('无效输入,请输入数字。') - # 初始化一个错误信息列表。 - errors: list[str] = cls.__check_range__(value, field_meta) - if errors: - raise ValueError(*errors) - parsed = canonicalize_decimal(parsed, field_meta.fraction_digits) - value = transform_decimal(parsed) - if value is None: - raise ValueError('无效输入,请输入数字。') - return value diff --git a/excelalchemy/types/value/number_range.py b/excelalchemy/types/value/number_range.py deleted file mode 100644 index 34e4708..0000000 --- a/excelalchemy/types/value/number_range.py +++ /dev/null @@ -1,100 +0,0 @@ -import logging -from decimal import Decimal -from typing import Any - -from excelalchemy.types.abstract import ComplexABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import Key -from excelalchemy.types.value.number import Number -from excelalchemy.types.value.number import canonicalize_decimal -from excelalchemy.types.value.number import transform_decimal - - -class NumberRange(ComplexABCValueType): - start: float | int | None - end: float | int | None - - __name__ = '数值范围' - - def __init__(self, start: Decimal | int | float | None, end: Decimal | int | float | None): - # pyright: reportUnknownMemberType=false - # trick: for dict call to get the correct value - super().__init__(start=transform_decimal(start), end=transform_decimal(end)) - self.start = transform_decimal(start) - self.end = transform_decimal(end) - - @classmethod - def model_items(cls) -> list[tuple[Key, FieldMetaInfo]]: - return [ - (Key('start'), FieldMetaInfo(label='最小值')), - (Key('end'), FieldMetaInfo(label='最大值')), - ] - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return Number.comment(field_meta) - - @classmethod - def serialize(cls, value: dict[str, str] | str | Any, field_meta: FieldMetaInfo) -> Any: - # Strip leading/trailing whitespace from a string value - if isinstance(value, str): - value = value.strip() - - # Return the given value if it is already a NumberRange object - if isinstance(value, NumberRange): - return value - - # Attempt to create a new NumberRange object from a dictionary - try: - # pyright: reportGeneralTypeIssues=false - start, end = Decimal(value['start']), Decimal(value['end']) # type: ignore[index] - return NumberRange(start, end) - except (KeyError, TypeError, ValueError) as exc: - logging.warning('%s 类型无法解析 Excel 输入,返回原值 %s。原因:%s', cls.__name__, value, exc) - - # Return the original value if parsing fails - return value - - @classmethod - def deserialize(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: - if value is None or value == '': - return '' - try: - return str(transform_decimal(canonicalize_decimal(Decimal(value), field_meta.fraction_digits))) - except Exception as exc: - logging.warning('ValueType 类型 <%s> 无法解析 Excel 输入, 返回原值:%s, 原因: %s', cls.__name__, value, exc) - return str(value) - - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> 'NumberRange': - parsed = cls.__maybe_number_range__(value, field_meta) - errors: list[str] = [] - if parsed.start is not None and parsed.end is not None and parsed.start > parsed.end: - errors.append('最小值不能大于最大值') - - if parsed.start is not None: - errors.extend(Number.__check_range__(parsed.start, field_meta)) - if parsed.end is not None: - errors.extend(Number.__check_range__(parsed.end, field_meta)) - - if errors: - raise ValueError(*errors) - else: - return parsed - - @staticmethod - def __maybe_number_range__(value: dict[str, Decimal] | Any, field_meta: FieldMetaInfo) -> 'NumberRange': - if isinstance(value, NumberRange): - start = canonicalize_decimal(Decimal(str(value.start)), field_meta.fraction_digits) - end = canonicalize_decimal(Decimal(str(value.end)), field_meta.fraction_digits) - return NumberRange(start, end) - - if isinstance(value, dict): - try: - value['start'] = canonicalize_decimal(Decimal(value['start']), field_meta.fraction_digits) - value['end'] = canonicalize_decimal(Decimal(value['end']), field_meta.fraction_digits) - return NumberRange(value['start'], value['end']) - except Exception as exc: - raise ValueError('请输入数字') from exc - - raise ValueError('请输入符合格式的数字') diff --git a/excelalchemy/types/value/organization.py b/excelalchemy/types/value/organization.py deleted file mode 100644 index c30e4c0..0000000 --- a/excelalchemy/types/value/organization.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -from typing import Any - -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.multi_checkbox import MultiCheckbox -from excelalchemy.types.value.radio import Radio - - -class SingleOrganization(Radio): - __name__ = '组织单选' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - required_str = '必填' if field_meta.required else '非必填' - extra_hint = field_meta.hint or "需按照组织架构树填写组织完整路径,例如 'XX公司/一级部门/二级部门'." - return f"""必填性:{required_str}\n提示:{extra_hint}""" - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - if isinstance(value, str): - return value.strip() - return value - - @classmethod - def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - try: - return field_meta.options_id_map[value.strip()].name - except KeyError: - logging.warning('无法找到组织 %s 的选项, 返回原值', value) - - return value if value is not None else '' - - -class MultiOrganization(MultiCheckbox): - __name__ = '组织多选' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return '\n'.join( - [ - field_meta.comment_required, - f'提示:{field_meta.hint or "需按照组织架构树填写组织完整路径,如“XX公司/一级部门/二级部门”,多选时,选项之间用“、”连接"}', - ] - ) - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - return super().serialize(value, field_meta) - - @classmethod - def deserialize(cls, value: str | list[str] | None | Any, field_meta: FieldMetaInfo) -> str | Any: - if value is None or value == '': - return '' - - if isinstance(value, str): - return value - - if isinstance(value, list): - option_names = field_meta.exchange_option_ids_to_names(value) - return MULTI_CHECKBOX_SEPARATOR.join(map(str, option_names)) - - logging.warning('%s 反序列化失败', cls.__name__) - return value - - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> list[str]: - return super().__validate__(value, field_meta) diff --git a/excelalchemy/types/value/phone_number.py b/excelalchemy/types/value/phone_number.py deleted file mode 100644 index 2e2db42..0000000 --- a/excelalchemy/types/value/phone_number.py +++ /dev/null @@ -1,18 +0,0 @@ -import re -from typing import Any - -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.string import String - -PHONE_NUMBER_PATTERN = re.compile(r'^((0\d{2,3}-\d{7,8})|(1[3456789]\d{9}))$') - - -class PhoneNumber(String): - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: - parsed = str(value) - - if not PHONE_NUMBER_PATTERN.match(parsed): - raise ValueError('请输入正确的手机号') - - return parsed diff --git a/excelalchemy/types/value/radio.py b/excelalchemy/types/value/radio.py deleted file mode 100644 index 681636f..0000000 --- a/excelalchemy/types/value/radio.py +++ /dev/null @@ -1,62 +0,0 @@ -import logging -from typing import Any - -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.exc import ProgrammaticError -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import OptionId - - -class Radio(ABCValueType, str): - __name__ = '单选框组' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - if not field_meta.options: - logging.error('%s 类型的字段 %s 必须设置 options', cls.__name__, field_meta.label) - - return '\n'.join([field_meta.comment_required, field_meta.comment_options, '单/多选:单选', field_meta.comment_hint]) - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> str: - return str(value).strip() - - @classmethod - def deserialize(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: - if value is None or value == '': - return '' - - try: - return field_meta.options_id_map[value.strip()].name - except Exception as exc: - logging.warning( - '类型【%s】无法为【%s】找到【%s】的选项, 返回原值, 原因 %s', - cls.__name__, - field_meta.label, - value, - exc, - ) - return value if value is not None else '' - - @classmethod - def __validate__(cls, value: str, field_meta: FieldMetaInfo) -> OptionId | str: # return Option.id - if MULTI_CHECKBOX_SEPARATOR in value: - raise ValueError('多选不支持') - - parsed = value.strip() - - if field_meta.options is None: - raise ProgrammaticError('当验证【RADIO / MULTI_CHECKBOX / SELECT】类型字段时,选项不得为空!') - - if not field_meta.options: # empty - logging.warning('%s 类型字段"%s"的选项为空,将返回原值', cls.__name__, field_meta.label) - return parsed - - if parsed in field_meta.options_id_map: - return parsed - - if parsed not in field_meta.options_name_map: - raise ValueError('选项不存在,请参照字段注释填写') - - return field_meta.options_name_map[parsed].id diff --git a/excelalchemy/types/value/staff.py b/excelalchemy/types/value/staff.py deleted file mode 100644 index a248f6a..0000000 --- a/excelalchemy/types/value/staff.py +++ /dev/null @@ -1,70 +0,0 @@ -import logging -from typing import Any - -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import OptionId -from excelalchemy.types.value.multi_checkbox import MultiCheckbox -from excelalchemy.types.value.radio import Radio - - -class SingleStaff(Radio): - __name__ = '人员单选' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - required = '必填' if field_meta.required else '非必填' - extra_hint = field_meta.hint or '请输入人员姓名和工号,如“张三/001”' - return f"""必填性:{required} \n提示:{extra_hint}""" - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - if isinstance(value, str): - return value.strip() - return value - - @classmethod - def deserialize(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: - if value is None or value == '': - return '' - try: - return field_meta.options_id_map[value.strip()].name - except KeyError: - logging.warning('类型【%s】无法为【%s】找到【%s】的选项, 返回原值', cls.__name__, field_meta.label, value) - return value if value is not None else '' - - -class MultiStaff(MultiCheckbox): - __name__ = '人员多选' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return '\n'.join( - [ - field_meta.comment_required, - f'提示:{field_meta.hint or "请输入人员姓名和工号,如“张三/001”,多选时,选项之间用“、”连接"}', - ] - ) - - @classmethod - def serialize(cls, value: str | list[str] | Any, field_meta: FieldMetaInfo) -> Any: - return super().serialize(value, field_meta) - - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> list[str]: - return super().__validate__(value, field_meta) - - @classmethod - def deserialize(cls, value: str | list[OptionId] | Any, field_meta: FieldMetaInfo) -> Any: - if isinstance(value, str): - return value - - if isinstance(value, list): - if len(value) != len(set(value)): - raise ValueError('选项有重复') - - option_names = field_meta.exchange_option_ids_to_names(value) - return f'{MULTI_CHECKBOX_SEPARATOR}'.join(option_names) - - logging.warning('%s 反序列化失败', cls.__name__) - return value diff --git a/excelalchemy/types/value/tree.py b/excelalchemy/types/value/tree.py deleted file mode 100644 index 15659f8..0000000 --- a/excelalchemy/types/value/tree.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -from typing import Any - -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.multi_checkbox import MultiCheckbox -from excelalchemy.types.value.radio import Radio - - -class SingleTreeNode(Radio): - __name__ = '树形单选' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - return '\n'.join( - [ - field_meta.comment_required, - f'提示:{field_meta.hint or "需按照组织架构树填写组织完整路径,如“XX公司/一级部门/二级部门”,多选时,选项之间用“、”连接"}', - ] - ) - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - if isinstance(value, str): - return value.strip() - return value - - @classmethod - def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - try: - return field_meta.options_id_map[value.strip()].name - except KeyError: - logging.warning('无法找到树结点 %s 的选项, 返回原值', value) - - return value if value is not None else '' - - -class MultiTreeNode(MultiCheckbox): - __name__ = '树形多选' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - required = '必填' if field_meta.required else '非必填' - extra_hint = field_meta.hint or '请输入完整路径(包含根节点),层级之间用“/”连接,如“一级/二级/选项1”;多选时,选项之间用“,”连接' - return f"""必填性:{required} \n提示:{extra_hint}""" - - @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - return super().serialize(value, field_meta) - - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> list[str]: - return super().__validate__(value, field_meta) diff --git a/excelalchemy/types/value/url.py b/excelalchemy/types/value/url.py deleted file mode 100644 index fd4a60a..0000000 --- a/excelalchemy/types/value/url.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Any - -from pydantic import BaseModel -from pydantic import HttpUrl - -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.string import String - - -class HttpUrlValidator(BaseModel): - url: HttpUrl - - -class Url(String): - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: - parsed = str(value) - errors: list[str] = [] - - try: - HttpUrlValidator.parse_obj({'url': parsed}) - except Exception: - errors.append('请输入正确的网址') - - if errors: - raise ValueError(*errors) - else: - return parsed diff --git a/excelalchemy/util/file.py b/excelalchemy/util/file.py deleted file mode 100644 index 2fbfa80..0000000 --- a/excelalchemy/util/file.py +++ /dev/null @@ -1,95 +0,0 @@ -import base64 -import io -from datetime import timedelta -from tempfile import TemporaryFile -from typing import IO -from typing import Any - -import pandas -from minio import Minio -from urllib3.response import HTTPResponse - -from excelalchemy.const import UNIQUE_HEADER_CONNECTOR - -EXCEL_PREFIX = 'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64' - - -def add_excel_prefix(content: str) -> str: - """Add Excel prefix for base64 content string.""" - - return f'{EXCEL_PREFIX},{content}' - - -def remove_excel_prefix(content: str) -> str: - """Remove Excel prefixes for base64 content string.""" - return content.lstrip(f'{EXCEL_PREFIX},') - - -def construct_file_like_object(response: HTTPResponse) -> IO[bytes]: - """Construct a file like object from HTTPResponse. - - You must close the file after you finished using it. - """ - tmp = TemporaryFile() - tmp.write(response.read()) - tmp.seek(0) - return tmp - - -def read_file_from_minio_object( - client: Minio, - bucket_name: str, - filename: str, -) -> IO[bytes]: - """ "Read file content by object.""" - # pyright: reportUnknownMemberType=false - response: HTTPResponse = client.get_object(bucket_name, filename) - return construct_file_like_object(response) - - -def upload_file_from_minio_object( - client: Minio, # pyright: reportUnknownParameterType=false - bucket_name: str, - filename: str, - content: str, - expires: int, -) -> str: - """把文件上传到minio""" - - data = base64.b64decode(content) - # pyright: reportUnknownMemberType=false - client.put_object(bucket_name, filename, io.BytesIO(data), len(data)) - return client.presigned_get_object( # pyright: reportUnknownMemberType=false - # pyright: reportUnknownVariableType=false - bucket_name, - filename, - expires=timedelta(seconds=expires), - ) - - -def flatten(data: dict[str, Any], level: list[Any] | None = None) -> dict[str, Any]: - """平铺嵌套的字典 - - >>> flatten( {'a': {'b': {'c': 12}}}) # dotted path expansion - {'a.b.c': 12} - """ - tmp_dict = {} - # pyright: reportGeneralTypeIssues=false - level = level or [] - for key, val in data.items(): - if isinstance(val, dict): - # pyright: reportUnknownArgumentType=false - tmp_dict.update(flatten(val, level + [key])) - else: - tmp_dict[f'{UNIQUE_HEADER_CONNECTOR}'.join(level + [key])] = val - return tmp_dict - - -def value_is_nan(value: Any) -> bool: - """判断 value 是否是 NaN""" - is_nan = pandas.isna(value) - if isinstance(is_nan, bool) and is_nan: - return True - if isinstance(value, list) and any(is_nan): # type: ignore[arg-type] - return True - return False diff --git a/files/portfolio-import-input-en.xlsx b/files/portfolio-import-input-en.xlsx new file mode 100644 index 0000000..87e1eae Binary files /dev/null and b/files/portfolio-import-input-en.xlsx differ diff --git a/files/portfolio-import-result-en.xlsx b/files/portfolio-import-result-en.xlsx new file mode 100644 index 0000000..614f519 Binary files /dev/null and b/files/portfolio-import-result-en.xlsx differ diff --git a/files/portfolio-template-en.xlsx b/files/portfolio-template-en.xlsx new file mode 100644 index 0000000..5eb80e7 Binary files /dev/null and b/files/portfolio-template-en.xlsx differ diff --git a/images/portfolio-import-input-en.png b/images/portfolio-import-input-en.png new file mode 100644 index 0000000..0f216ea Binary files /dev/null and b/images/portfolio-import-input-en.png differ diff --git a/images/portfolio-import-result-en.png b/images/portfolio-import-result-en.png new file mode 100644 index 0000000..0805ce8 Binary files /dev/null and b/images/portfolio-import-result-en.png differ diff --git a/images/portfolio-template-en.png b/images/portfolio-template-en.png new file mode 100644 index 0000000..7b44b03 Binary files /dev/null and b/images/portfolio-template-en.png differ diff --git a/pyproject.toml b/pyproject.toml index b2243f2..26e085e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,44 +1,61 @@ [build-system] -requires = ['flit_core >=3.2,<4'] +requires = ['flit_core >=3.12,<4'] build-backend = 'flit_core.buildapi' [project] name = 'ExcelAlchemy' -authors = [{ name = '何睿', email = 'hrui835@gmail.com' }] +description = 'Schema-driven Python library for typed Excel import/export workflows with Pydantic and locale-aware workbooks.' +authors = [{ name = 'Ray' }] readme = 'README.md' license = { file = 'LICENSE' } -classifiers = ['License :: OSI Approved :: MIT License'] -dynamic = ['version', 'description'] -requires-python = '>=3.10' +keywords = ['excel', 'openpyxl', 'pydantic', 'minio', 'schema'] +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', + 'Topic :: Office/Business :: Financial :: Spreadsheet', + 'Topic :: Software Development :: Libraries :: Python Modules', +] +dynamic = ['version'] +requires-python = '>=3.12' dependencies = [ - 'pandas >=2.0.0, <2.1.0', - 'minio >=7.0.0, <8', - 'pydantic[email] >=1.9, <2', - 'openpyxl >=3.0.10, <4', - 'pendulum >=2.1.2, <3', + 'pydantic[email] >=2.12, <3', + 'openpyxl >=3.1.5, <4', + 'pendulum >=3.2.0, <4', ] [tool.flit.module] name = 'excelalchemy' [project.urls] -Home = 'https://github.com/SundayWindy/excelalchemy' +Home = 'https://github.com/RayCarterLab/ExcelAlchemy' +Repository = 'https://github.com/RayCarterLab/ExcelAlchemy' +Documentation = 'https://github.com/RayCarterLab/ExcelAlchemy#readme' +Issues = 'https://github.com/RayCarterLab/ExcelAlchemy/issues' [project.optional-dependencies] +minio = [ + 'minio >=7.2.20, <8', +] development = [ - 'pandas-stubs', - 'black', - 'isort', - 'mypy', - 'pylint', + 'minio >=7.2.20, <8', 'pre-commit', - 'pyright==1.1.299', + 'pyright==1.1.408', 'pytest', 'coverage', 'pytest-cov', + 'ruff', ] [tool.pyright] +include = ['src/excelalchemy', 'tests'] exclude = [ '.venv', 'venv', @@ -46,81 +63,93 @@ exclude = [ '**/.mypy_cache', '**/__pycache__', '**/.pytest_cache', + 'src/excelalchemy/types/field.py', ] -ignore = ['pandas'] enableTypeIgnoreComments = false -reportUnusedFunction = false -typeCheckingMode = 'strict' -reportUnusedImport = false -reportMissingTypeStubs = false -reportUnknownVariableType = false - - -extension-pkg-whitelist = ['pydantic', 'pendulum'] - -[tool.pylint.basic] -attr-rgx = '^[_a-z][a-z0-9_]*$' # snake_case -variable-rgx = '^[_a-z][a-z0-9_]*$' # snake_case -argument-rgx = '^[_a-z][a-z0-9_]*$' # snake_case -class-rgx = '^(_?[A-Z][a-zA-Z0-9]*)*$' -method-rgx = '^[_a-z][a-z0-9_]*$' # snake_case - - -[tool.pylint.'MESSAGES CONTROL'] -disable = [ - 'missing-module-docstring', - 'missing-function-docstring', - 'missing-class-docstring', - 'too-many-instance-attributes', - 'too-many-arguments', - 'too-few-public-methods', - 'too-many-public-methods', - 'no-else-return', - 'no-else-raise', - 'fixme', - 'duplicate-code', - 'redefined-builtin', - 'broad-except', - 'abstract-class-instantiated' +reportAbstractUsage = false +reportAttributeAccessIssue = false +reportCallIssue = false +reportPrivateImportUsage = false +reportRedeclaration = false +strict = [ + 'src/excelalchemy/_primitives/constants.py', + 'src/excelalchemy/_primitives/deprecation.py', + 'src/excelalchemy/_primitives/header_models.py', + 'src/excelalchemy/_primitives/identity.py', + 'src/excelalchemy/_primitives/payloads.py', + 'src/excelalchemy/artifacts.py', + 'src/excelalchemy/codecs/base.py', + 'src/excelalchemy/codecs/boolean.py', + 'src/excelalchemy/codecs/date.py', + 'src/excelalchemy/codecs/date_range.py', + 'src/excelalchemy/codecs/email.py', + 'src/excelalchemy/codecs/money.py', + 'src/excelalchemy/codecs/multi_checkbox.py', + 'src/excelalchemy/codecs/number.py', + 'src/excelalchemy/codecs/number_range.py', + 'src/excelalchemy/codecs/organization.py', + 'src/excelalchemy/codecs/phone_number.py', + 'src/excelalchemy/codecs/radio.py', + 'src/excelalchemy/codecs/staff.py', + 'src/excelalchemy/codecs/string.py', + 'src/excelalchemy/codecs/tree.py', + 'src/excelalchemy/codecs/url.py', + 'src/excelalchemy/config.py', + 'src/excelalchemy/core/alchemy.py', + 'src/excelalchemy/core/abstract.py', + 'src/excelalchemy/core/executor.py', + 'src/excelalchemy/core/rendering.py', + 'src/excelalchemy/core/schema.py', + 'src/excelalchemy/core/storage.py', + 'src/excelalchemy/core/storage_minio.py', + 'src/excelalchemy/core/storage_protocol.py', + 'src/excelalchemy/core/table.py', + 'src/excelalchemy/core/writer.py', + 'src/excelalchemy/exceptions.py', + 'src/excelalchemy/core/headers.py', + 'src/excelalchemy/core/rows.py', + 'src/excelalchemy/helper/pydantic.py', + 'src/excelalchemy/i18n/messages.py', + 'src/excelalchemy/metadata.py', + 'src/excelalchemy/results.py', + 'src/excelalchemy/util/convertor.py', + 'src/excelalchemy/util/file.py', ] +typeCheckingMode = 'basic' - -[tool.pylint.'MASTER'] -jobs = 4 -score = false -ignore-paths = [ - '.git/', - 'venv/', - '.venv/', - '.mypy_cache/', - '__pycache__/', - '.pytest_cache/', - 'tests/', - 'dist/', -] -extension-pkg-whitelist = [ - 'pydantic', - 'pandas', - 'pendulum', - -] - - -[tool.black] +[tool.ruff] line-length = 120 -skip-string-normalization = true - - -[tool.pylint.'FORMAT'] -max-line-length = 120 - -[tool.isort] -skip_gitignore = true -profile = 'black' -line_length = 120 -indent = ' ' -no_lines_before = 'LOCALFOLDER' -force_single_line = true - -[tool.mypy] -ignore_missing_imports = true +target-version = 'py312' +src = ['src', 'tests'] +extend-exclude = ['files'] + +[tool.ruff.lint] +select = ['E', 'F', 'I', 'UP', 'B', 'SIM', 'C4', 'RUF', 'PERF'] +ignore = ['E501', 'RUF001', 'RUF002', 'RUF003'] + +[tool.ruff.lint.per-file-ignores] +'**/__init__.py' = ['F401'] +'src/excelalchemy/const.py' = ['RUF100'] +'src/excelalchemy/exc.py' = ['E402', 'RUF100'] +'src/excelalchemy/header_models.py' = ['E402', 'RUF100'] +'src/excelalchemy/identity.py' = ['E402', 'RUF100'] +'src/excelalchemy/types/*.py' = ['E402', 'RUF100'] +'src/excelalchemy/types/**/*.py' = ['E402', 'RUF100'] + +[tool.ruff.format] +quote-style = 'preserve' +indent-style = 'space' +line-ending = 'auto' + +[tool.pytest.ini_options] +addopts = ['--import-mode=importlib'] +testpaths = ['tests'] + +[tool.coverage.run] +branch = true +source = ['excelalchemy'] + +[tool.coverage.report] +fail_under = 85 +skip_covered = true +show_missing = true diff --git a/scripts/generate_portfolio_assets.py b/scripts/generate_portfolio_assets.py new file mode 100644 index 0000000..aa2a07f --- /dev/null +++ b/scripts/generate_portfolio_assets.py @@ -0,0 +1,168 @@ +"""Generate English Excel assets for portfolio screenshots.""" + +from __future__ import annotations + +import asyncio +import base64 +import shutil +from pathlib import Path +from typing import Annotated + +from openpyxl import load_workbook +from pydantic import BaseModel, Field + +from excelalchemy import ( + Boolean, + Date, + DateFormat, + Email, + ExcelAlchemy, + ExcelMeta, + FieldMeta, + ImporterConfig, + Number, + NumberRange, + Option, + OptionId, + Radio, + String, +) +from excelalchemy._primitives.identity import UrlStr +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.core.table import WorksheetTable +from excelalchemy.util.file import remove_excel_prefix + +ROOT = Path(__file__).resolve().parents[1] +FILES_DIR = ROOT / 'files' +FILES_DIR.mkdir(exist_ok=True) +SHEET_NAME = 'Sheet1' + +TEMPLATE_PATH = FILES_DIR / 'portfolio-template-en.xlsx' +INPUT_PATH = FILES_DIR / 'portfolio-import-input-en.xlsx' +RESULT_PATH = FILES_DIR / 'portfolio-import-result-en.xlsx' + + +def _load_table(path: Path, *, skiprows: int, sheet_name: str) -> WorksheetTable: + workbook = load_workbook(path, data_only=True) + try: + worksheet = workbook[sheet_name] + rows = [ + [None if value is None else str(value) for value in row] + for row in worksheet.iter_rows( + min_row=skiprows + 1, + max_row=worksheet.max_row, + max_col=worksheet.max_column, + values_only=True, + ) + ] + + while rows and all(value is None for value in rows[-1]): + rows.pop() + + return WorksheetTable(rows=rows) + finally: + workbook.close() + + +class LocalPortfolioStorage(ExcelStorage): + """Minimal local storage used to render screenshot assets on disk.""" + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + return _load_table(FILES_DIR / input_excel_name, skiprows=skiprows, sheet_name=sheet_name) + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + content = base64.b64decode(remove_excel_prefix(content_with_prefix)) + output_path = FILES_DIR / output_name + output_path.write_bytes(content) + return UrlStr(str(output_path)) + + +TEAM_OPTIONS = [ + Option(id=OptionId('eng'), name='Engineering'), + Option(id=OptionId('ops'), name='Operations'), + Option(id=OptionId('sales'), name='Sales'), +] + + +class TemplateScreenshotImporter(BaseModel): + full_name: String = FieldMeta(label='Full name', order=1, hint='Use the employee full name') + age: Annotated[Number, Field(ge=18, le=65), ExcelMeta(label='Age', order=2, unit='years')] + work_email: Email = FieldMeta(label='Work email', order=3, hint='Use a company email address') + start_date: Date = FieldMeta(label='Start date', order=4, date_format=DateFormat.DAY) + is_active: Boolean = FieldMeta(label='Status', order=5, hint='Yes for active employees, No otherwise') + team: Radio = FieldMeta(label='Team', order=6, options=TEAM_OPTIONS) + salary_band: NumberRange = FieldMeta(label='Salary band', order=7, unit='USD') + + +async def _creator(data: dict[str, object], context: None) -> dict[str, object]: + return data + + +def _build_template_workbook() -> None: + alchemy = ExcelAlchemy( + ImporterConfig( + TemplateScreenshotImporter, + creator=_creator, + storage=LocalPortfolioStorage(), + locale='en', + ) + ) + artifact = alchemy.download_template_artifact( + sample_data=[ + { + 'full_name': 'Avery Stone', + 'age': 29, + 'work_email': 'avery.stone@example.com', + 'start_date': '2024-02-12', + 'is_active': True, + 'team': 'eng', + 'salary_band': {'start': 90000, 'end': 120000}, + } + ], + filename=TEMPLATE_PATH.name, + ) + TEMPLATE_PATH.write_bytes(artifact.as_bytes()) + + +def _build_invalid_input_workbook() -> None: + shutil.copyfile(TEMPLATE_PATH, INPUT_PATH) + workbook = load_workbook(INPUT_PATH) + worksheet = workbook[SHEET_NAME] + + worksheet['A4'] = 'Taylor' + worksheet['B4'] = '17' + worksheet['C4'] = 'not-an-email' + worksheet['D4'] = '2024-13-40' + worksheet['E4'] = 'Maybe' + worksheet['F4'] = 'Finance' + worksheet['G4'] = '150000' + worksheet['H4'] = '120000' + + workbook.save(INPUT_PATH) + workbook.close() + + +def _build_result_workbook() -> None: + alchemy = ExcelAlchemy( + ImporterConfig( + TemplateScreenshotImporter, + creator=_creator, + storage=LocalPortfolioStorage(), + locale='en', + ) + ) + asyncio.run(alchemy.import_data(INPUT_PATH.name, RESULT_PATH.name)) + + +def main() -> None: + _build_template_workbook() + _build_invalid_input_workbook() + _build_result_workbook() + + print(f'Generated template: {TEMPLATE_PATH}') + print(f'Generated input: {INPUT_PATH}') + print(f'Generated result: {RESULT_PATH}') + + +if __name__ == '__main__': + main() diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py new file mode 100644 index 0000000..c727830 --- /dev/null +++ b/src/excelalchemy/__init__.py @@ -0,0 +1,126 @@ +"""A Python Library for Reading and Writing Excel Files""" + +__version__ = '2.0.0' +from excelalchemy._primitives.constants import CharacterSet, DataRangeOption, DateFormat, Option +from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning +from excelalchemy._primitives.identity import ( + Base64Str, + ColumnIndex, + DataUrlStr, + Key, + Label, + OptionId, + RowIndex, + UniqueKey, + UniqueLabel, + UrlStr, +) +from excelalchemy.artifacts import ExcelArtifact +from excelalchemy.codecs.base import CompositeExcelFieldCodec, ExcelFieldCodec +from excelalchemy.codecs.boolean import Boolean, BooleanCodec +from excelalchemy.codecs.date import Date, DateCodec +from excelalchemy.codecs.date_range import DateRange, DateRangeCodec +from excelalchemy.codecs.email import Email, EmailCodec +from excelalchemy.codecs.money import Money, MoneyCodec +from excelalchemy.codecs.multi_checkbox import MultiCheckbox, MultiChoiceCodec +from excelalchemy.codecs.number import Number, NumberCodec +from excelalchemy.codecs.number_range import NumberRange, NumberRangeCodec +from excelalchemy.codecs.organization import ( + MultiOrganization, + MultiOrganizationCodec, + SingleOrganization, + SingleOrganizationCodec, +) +from excelalchemy.codecs.phone_number import PhoneNumber, PhoneNumberCodec +from excelalchemy.codecs.radio import Radio, SingleChoiceCodec +from excelalchemy.codecs.staff import MultiStaff, MultiStaffCodec, SingleStaff, SingleStaffCodec +from excelalchemy.codecs.string import String, StringCodec +from excelalchemy.codecs.tree import ( + MultiTreeNode, + MultiTreeNodeCodec, + SingleTreeNode, + SingleTreeNodeCodec, +) +from excelalchemy.codecs.url import Url, UrlCodec +from excelalchemy.config import ExporterConfig, ImporterConfig, ImportMode +from excelalchemy.core.alchemy import ExcelAlchemy +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError, ProgrammaticError +from excelalchemy.helper.pydantic import extract_pydantic_model +from excelalchemy.metadata import ExcelMeta, FieldMeta, PatchFieldMeta +from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult, ValidateRowResult +from excelalchemy.util.file import flatten + +__all__ = [ + 'Base64Str', + 'Boolean', + 'BooleanCodec', + 'ColumnIndex', + 'CompositeExcelFieldCodec', + 'ConfigError', + 'DataRangeOption', + 'DataUrlStr', + 'Date', + 'DateCodec', + 'DateFormat', + 'DateRange', + 'DateRangeCodec', + 'Email', + 'EmailCodec', + 'ExcelAlchemy', + 'ExcelAlchemyDeprecationWarning', + 'ExcelArtifact', + 'ExcelCellError', + 'ExcelFieldCodec', + 'ExcelMeta', + 'ExcelRowError', + 'ExcelStorage', + 'ExporterConfig', + 'FieldMeta', + 'ImportMode', + 'ImportResult', + 'ImporterConfig', + 'Key', + 'Label', + 'Money', + 'MoneyCodec', + 'MultiCheckbox', + 'MultiChoiceCodec', + 'MultiOrganization', + 'MultiOrganizationCodec', + 'MultiStaff', + 'MultiStaffCodec', + 'MultiTreeNode', + 'MultiTreeNodeCodec', + 'Number', + 'NumberCodec', + 'NumberRange', + 'NumberRangeCodec', + 'Option', + 'OptionId', + 'PatchFieldMeta', + 'PhoneNumber', + 'PhoneNumberCodec', + 'ProgrammaticError', + 'Radio', + 'RowIndex', + 'SingleChoiceCodec', + 'SingleOrganization', + 'SingleOrganizationCodec', + 'SingleStaff', + 'SingleStaffCodec', + 'SingleTreeNode', + 'SingleTreeNodeCodec', + 'String', + 'StringCodec', + 'UniqueKey', + 'UniqueLabel', + 'Url', + 'UrlCodec', + 'UrlStr', + 'ValidateHeaderResult', + 'ValidateResult', + 'ValidateRowResult', + 'extract_pydantic_model', + 'flatten', +] diff --git a/src/excelalchemy/_primitives/__init__.py b/src/excelalchemy/_primitives/__init__.py new file mode 100644 index 0000000..74a73a3 --- /dev/null +++ b/src/excelalchemy/_primitives/__init__.py @@ -0,0 +1 @@ +"""Private primitive building blocks used by ExcelAlchemy internals.""" diff --git a/src/excelalchemy/_primitives/constants.py b/src/excelalchemy/_primitives/constants.py new file mode 100644 index 0000000..ae8a478 --- /dev/null +++ b/src/excelalchemy/_primitives/constants.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from excelalchemy._primitives.identity import Key, Label, OptionId +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg + +HEADER_HINT = dmsg(MessageKey.HEADER_HINT, locale='zh-CN') + +EXCEL_COMMENT_FORMAT = {'height': 100, 'width': 300, 'font_size': 7} +CHARACTER_WIDTH = 1.3 +DEFAULT_SHEET_NAME = 'Sheet1' +# Connector used when flattening merged workbook headers. +UNIQUE_HEADER_CONNECTOR: str = '·' + +# Result workbook status column. +RESULT_COLUMN_LABEL: Label = Label(dmsg(MessageKey.RESULT_COLUMN_LABEL, locale='zh-CN')) +RESULT_COLUMN_KEY: Key = Key('__result__') + +# Result workbook reason column. +REASON_COLUMN_LABEL: Label = Label(dmsg(MessageKey.REASON_COLUMN_LABEL, locale='zh-CN')) +REASON_COLUMN_KEY: Key = Key('__reason__') + +BACKGROUND_REQUIRED_COLOR = 'FDAFB5' +BACKGROUND_ERROR_COLOR = 'FEC100' +FONT_READ_COLOR = 'FF0000' + +# Display separator used for multi-choice workbook cells. +MULTI_CHECKBOX_SEPARATOR = ',' + +FIELD_DATA_KEY = Key('fieldData') + +# Millisecond to second conversion factor. +MILLISECOND_TO_SECOND = 1000 + +# Soft option-count limit used for warning logs. +MAX_OPTIONS_COUNT = 100 + +DEFAULT_FIELD_META_ORDER = -1 +type DictStrAny = dict[str, Any] +type DictAny = dict[Any, Any] +type SetStr = set[str] +type ListStr = list[str] +type IntStr = int | str + + +class CharacterSet(StrEnum): + CHINESE = 'CHINESE' + NUMBER = 'NUMBER' + LOWERCASE_LETTERS = 'LOWERCASE_LETTERS' + UPPERCASE_LETTERS = 'UPPERCASE_LETTERS' + SPECIAL_SYMBOLS = 'SPECIAL_SYMBOLS' + + +class DateFormat(StrEnum): + YEAR = 'YEAR' + MONTH = 'MONTH' + DAY = 'DAY' + MINUTE = 'MINUTE' + + +class DataRangeOption(StrEnum): + NONE = 'NONE' + PRE = 'PRE' + NEXT = 'NEXT' + + +DATE_FORMAT_TO_PYTHON_MAPPING = { + DateFormat.YEAR: '%Y', + DateFormat.MONTH: '%Y-%m', + DateFormat.DAY: '%Y-%m-%d', + DateFormat.MINUTE: '%Y-%m-%d %H:%M', +} +DATE_FORMAT_TO_HINT_MAPPING = { + DateFormat.YEAR: 'yyyy', + DateFormat.MONTH: 'yyyy/mm', + DateFormat.DAY: 'yyyy/mm/dd', + DateFormat.MINUTE: 'yyyy/mm/dd hh:mm', +} +DATA_RANGE_OPTION_TO_CHINESE = { + DataRangeOption.PRE: dmsg(MessageKey.DATE_RANGE_OPTION_PRE_DISPLAY, locale='zh-CN'), + DataRangeOption.NEXT: dmsg(MessageKey.DATE_RANGE_OPTION_NEXT_DISPLAY, locale='zh-CN'), + DataRangeOption.NONE: dmsg(MessageKey.DATE_RANGE_OPTION_NONE_DISPLAY, locale='zh-CN'), +} + + +@dataclass +class Option: + # For user's usage, the name is the most important symbol + id: OptionId + name: str diff --git a/src/excelalchemy/_primitives/deprecation.py b/src/excelalchemy/_primitives/deprecation.py new file mode 100644 index 0000000..c655b5e --- /dev/null +++ b/src/excelalchemy/_primitives/deprecation.py @@ -0,0 +1,22 @@ +"""Deprecation helpers for public compatibility layers.""" + +from __future__ import annotations + +import warnings + +DEPRECATION_REMOVAL_VERSION = '3.0' + + +class ExcelAlchemyDeprecationWarning(FutureWarning): + """Warning emitted for deprecated public APIs that still have a compatibility shim.""" + + +def warn_compat_import(import_path: str, replacement: str) -> None: + warnings.warn( + ( + f'`{import_path}` is deprecated and will be removed in ExcelAlchemy ' + f'{DEPRECATION_REMOVAL_VERSION}. Import from `{replacement}` instead.' + ), + category=ExcelAlchemyDeprecationWarning, + stacklevel=2, + ) diff --git a/src/excelalchemy/_primitives/header_models.py b/src/excelalchemy/_primitives/header_models.py new file mode 100644 index 0000000..c152b1d --- /dev/null +++ b/src/excelalchemy/_primitives/header_models.py @@ -0,0 +1,27 @@ +"""Internal workbook header models.""" + +from pydantic import BaseModel +from pydantic.fields import Field + +from excelalchemy._primitives.constants import UNIQUE_HEADER_CONNECTOR +from excelalchemy._primitives.identity import Label, UniqueLabel + + +class ExcelHeader(BaseModel): + """Normalized workbook header extracted from user input.""" + + label: Label = Field(description='Workbook header label.') + parent_label: Label = Field( + description='Parent workbook header label. Falls back to the label itself for flat headers.' + ) + offset: int = Field(default=0, description='Child-column offset under a merged parent header.') + + @property + def unique_label(self) -> UniqueLabel: + """Return the fully qualified workbook header label.""" + label = ( + f'{self.parent_label}{UNIQUE_HEADER_CONNECTOR}{self.label}' + if self.parent_label != self.label + else self.label + ) + return UniqueLabel(label) diff --git a/src/excelalchemy/_primitives/identity.py b/src/excelalchemy/_primitives/identity.py new file mode 100644 index 0000000..e768f59 --- /dev/null +++ b/src/excelalchemy/_primitives/identity.py @@ -0,0 +1,66 @@ +"""Internal typed primitives used across the ExcelAlchemy core layer.""" + +from typing import Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema + + +class _StringIdentity(str): + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + return core_schema.no_info_after_validator_function(cls, core_schema.str_schema()) + + +class _IntegerIdentity(int): + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + return core_schema.no_info_after_validator_function(cls, core_schema.int_schema()) + + +class Label(_StringIdentity): + """Workbook header label.""" + + +class UniqueLabel(Label): + """Fully qualified workbook header label.""" + + +class Key(_StringIdentity): + """Schema key used by the Python model.""" + + +class UniqueKey(Key): + """Fully qualified schema key.""" + + +class RowIndex(_IntegerIdentity): + """Zero-based workbook row index.""" + + +class ColumnIndex(_IntegerIdentity): + """Zero-based workbook column index.""" + + +class OptionId(_StringIdentity): + """Selection option identifier.""" + + +class DataUrlStr(_StringIdentity): + """Data URL string.""" + + +class Base64Str(DataUrlStr): + """Deprecated compatibility alias for the legacy data URL string return type.""" + + +class UrlStr(_StringIdentity): + """Generic URL string.""" diff --git a/src/excelalchemy/_primitives/payloads.py b/src/excelalchemy/_primitives/payloads.py new file mode 100644 index 0000000..eb38e26 --- /dev/null +++ b/src/excelalchemy/_primitives/payloads.py @@ -0,0 +1,17 @@ +"""Typed row payload shapes shared across config and core import/export flows.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping + +type FlatRowPayload = dict[str, object] +type NestedRowPayload = dict[str, object] +type AggregatedRowPayload = dict[str, object | NestedRowPayload] +type ModelRowPayload = dict[str, object] +type ExportRowPayload = dict[str, object] +type RowPayloadLike = Mapping[str, object] + +type ImportContext[ContextT] = ContextT | None +type DataConverter = Callable[[ModelRowPayload], ModelRowPayload] +type DmlCallback[ContextT] = Callable[[ModelRowPayload, ImportContext[ContextT]], Awaitable[object]] +type ExistenceCheckCallback[ContextT] = Callable[[ModelRowPayload, ImportContext[ContextT]], Awaitable[bool]] diff --git a/src/excelalchemy/artifacts.py b/src/excelalchemy/artifacts.py new file mode 100644 index 0000000..a664953 --- /dev/null +++ b/src/excelalchemy/artifacts.py @@ -0,0 +1,38 @@ +"""Structured workbook payloads for download and integration workflows.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, replace + +from excelalchemy._primitives.identity import DataUrlStr +from excelalchemy.util.file import EXCEL_MEDIA_TYPE, add_excel_prefix, remove_excel_prefix + + +@dataclass(slots=True, frozen=True) +class ExcelArtifact: + """Structured Excel payload that can be consumed as bytes, base64, or a data URL.""" + + content: bytes + filename: str + media_type: str = EXCEL_MEDIA_TYPE + + @classmethod + def from_data_url(cls, data_url: str, *, filename: str, media_type: str = EXCEL_MEDIA_TYPE) -> ExcelArtifact: + return cls( + content=base64.b64decode(remove_excel_prefix(data_url)), + filename=filename, + media_type=media_type, + ) + + def with_filename(self, filename: str) -> ExcelArtifact: + return replace(self, filename=filename) + + def as_bytes(self) -> bytes: + return self.content + + def as_base64(self) -> str: + return base64.b64encode(self.content).decode('ascii') + + def as_data_url(self) -> DataUrlStr: + return DataUrlStr(add_excel_prefix(self.as_base64())) diff --git a/src/excelalchemy/codecs/__init__.py b/src/excelalchemy/codecs/__init__.py new file mode 100644 index 0000000..348fd33 --- /dev/null +++ b/src/excelalchemy/codecs/__init__.py @@ -0,0 +1,14 @@ +"""Registry helpers for choice-oriented Excel field codecs.""" + +from excelalchemy.codecs.base import ExcelFieldCodec + +EXCEL_CHOICE_CODECS: dict[type[ExcelFieldCodec], type[ExcelFieldCodec]] = {} + + +def excel_choice_codec(codec: type[ExcelFieldCodec]) -> type[ExcelFieldCodec]: + EXCEL_CHOICE_CODECS[codec] = codec + return codec + + +EXCEL_CHOICE_VALUE_TYPE = EXCEL_CHOICE_CODECS +excel_choice = excel_choice_codec diff --git a/src/excelalchemy/codecs/base.py b/src/excelalchemy/codecs/base.py new file mode 100644 index 0000000..0f2faa5 --- /dev/null +++ b/src/excelalchemy/codecs/base.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema + +from excelalchemy._primitives.identity import Key + +if TYPE_CHECKING: + from excelalchemy.metadata import FieldMetaInfo + + +class ExcelFieldCodec(ABC): + """Excel-facing field adapter responsible for comments, parsing, formatting, and normalization.""" + + @classmethod + @abstractmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + """Return the header comment rendered into the workbook template.""" + + @classmethod + @abstractmethod + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> Any: # value is always not None + """Parse workbook input into the intermediate Python value consumed by the import pipeline.""" + + @classmethod + @abstractmethod + def format_display_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + """Format a raw worksheet value back into a user-recognizable display value.""" + + @classmethod + @abstractmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + """Validate and normalize parsed input before handing it to the Pydantic layer.""" + + @classmethod + def comment(cls, field_meta: FieldMetaInfo) -> str: + """Backward-compatible alias for build_comment().""" + return cls.build_comment(field_meta) + + @classmethod + def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + """Backward-compatible alias for parse_input().""" + return cls.parse_input(value, field_meta) + + @classmethod + def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + """Backward-compatible alias for format_display_value().""" + return cls.format_display_value(value, field_meta) + + @classmethod + def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + """Backward-compatible alias for normalize_import_value().""" + return cls.normalize_import_value(value, field_meta) + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + # ExcelAlchemy runs metadata-aware validation in its adapter layer. + # Pydantic only needs a permissive schema here so model classes can be built in v2. + return core_schema.any_schema() + + +class CompositeExcelFieldCodec(ExcelFieldCodec, dict[str, object]): + """Excel codec for fields that expand into multiple worksheet columns.""" + + @classmethod + @abstractmethod + def column_items(cls) -> list[tuple[Key, FieldMetaInfo]]: + """Return the schema keys and metadata for each expanded worksheet column.""" + + @classmethod + def model_items(cls) -> list[tuple[Key, FieldMetaInfo]]: + """Backward-compatible alias for column_items().""" + return cls.column_items() + + +class SystemReserved(ExcelFieldCodec): + __name__ = 'SystemReserved' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return '' + + @classmethod + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + return value + + @classmethod + def format_display_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + return value + + @classmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + return value + + +class Undefined(ExcelFieldCodec): + __name__ = 'Undefined' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return '' + + @classmethod + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + return value + + @classmethod + def format_display_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + return value + + @classmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + return value + + +ABCValueType = ExcelFieldCodec +ComplexABCValueType = CompositeExcelFieldCodec +SystemFieldCodec = SystemReserved +UndefinedFieldCodec = Undefined diff --git a/src/excelalchemy/codecs/boolean.py b/src/excelalchemy/codecs/boolean.py new file mode 100644 index 0000000..cde48ea --- /dev/null +++ b/src/excelalchemy/codecs/boolean.py @@ -0,0 +1,94 @@ +import logging +from typing import Any + +from excelalchemy.codecs import excel_choice_codec +from excelalchemy.codecs.base import ExcelFieldCodec +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +@excel_choice_codec +class Boolean(ExcelFieldCodec): + __name__ = 'Boolean' + + @staticmethod + def _true_display() -> str: + return dmsg(MessageKey.BOOLEAN_TRUE_DISPLAY) + + @staticmethod + def _false_display() -> str: + return dmsg(MessageKey.BOOLEAN_FALSE_DISPLAY) + + @classmethod + def _true_values(cls) -> set[str]: + return {cls._true_display(), '是'} + + @classmethod + def _false_values(cls) -> set[str]: + return {cls._false_display(), '否'} + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return '\n'.join( + [ + field_meta.comment_required, + field_meta.comment_hint, + ] + ) + + @classmethod + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> str: + return str(value).strip() + + @classmethod + def format_display_value(cls, value: bool | str | None | Any, field_meta: FieldMetaInfo) -> str: + if value is None or value == '': + return cls._false_display() + + if isinstance(value, bool): + return cls._true_display() if value else cls._false_display() + + elif isinstance(value, str): + value = value.strip() + if value in cls._true_values(): + return cls._true_display() + 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) + return value + else: + logging.warning( + 'Type %s could not deserialize %s for field %s; returning the default value %s', + cls.__name__, + value, + field_meta.label, + cls._false_display(), + ) + + return cls._true_display() if str(value) in cls._true_values() else cls._false_display() + + @classmethod + def normalize_import_value(cls, value: str | bool | Any, field_meta: FieldMetaInfo) -> bool: + if isinstance(value, bool): + return value + + value_str = str(value).strip() + + if value_str in cls._true_values(): + return True + if value_str in cls._false_values(): + return False + + raise ValueError( + msg( + MessageKey.BOOLEAN_ENTER_YES_OR_NO, + true_value=cls._true_display(), + false_value=cls._false_display(), + ) + ) + + +BooleanCodec = Boolean diff --git a/excelalchemy/types/value/date.py b/src/excelalchemy/codecs/date.py similarity index 52% rename from excelalchemy/types/value/date.py rename to src/excelalchemy/codecs/date.py index 57ddfef..7bd07ba 100644 --- a/excelalchemy/types/value/date.py +++ b/src/excelalchemy/codecs/date.py @@ -1,26 +1,25 @@ import logging from datetime import datetime -from typing import Any -from typing import cast +from typing import Any, cast import pendulum from pendulum import DateTime -from excelalchemy.const import DATE_FORMAT_TO_HINT_MAPPING -from excelalchemy.const import MILLISECOND_TO_SECOND -from excelalchemy.const import DataRangeOption -from excelalchemy.exc import ConfigError -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo +from excelalchemy._primitives.constants import DATE_FORMAT_TO_HINT_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption +from excelalchemy.codecs.base import ExcelFieldCodec +from excelalchemy.exceptions import ConfigError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo -class Date(ABCValueType, datetime): - __name__ = '日期选择' +class Date(ExcelFieldCodec, datetime): + __name__ = 'Date' @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: + def build_comment(cls, field_meta: FieldMetaInfo) -> str: if not field_meta.date_format: - raise ConfigError('日期格式未定义') + raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) return '\n'.join( [ field_meta.comment_required, @@ -31,28 +30,35 @@ def comment(cls, field_meta: FieldMetaInfo) -> str: ) @classmethod - def serialize(cls, value: str | DateTime | Any, field_meta: FieldMetaInfo) -> datetime | Any: + def parse_input(cls, value: str | DateTime | Any, field_meta: FieldMetaInfo) -> datetime | Any: if isinstance(value, DateTime): - logging.info('类型【%s】无需序列化: %s, 返回原值 %s ', cls.__name__, field_meta.label, value) + logging.info( + 'Codec %s received a parsed datetime for %s; returning it unchanged: %s', + cls.__name__, + field_meta.label, + value, + ) return value if not field_meta.date_format: - raise ConfigError('日期格式未定义') + raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) value = str(value).strip() try: - # pyright: reportPrivateImportUsage=false - # pyright: reportUnknownMemberType=false - # pyright: reportGeneralTypeIssues=false - v = value.replace('/', '-') # pendulum 不支持 / 作为日期分隔符 + v = value.replace('/', '-') # pendulum does not accept "/" as a date separator here. dt: DateTime = cast(DateTime, pendulum.parse(v)) return dt.replace(tzinfo=field_meta.timezone) except Exception as exc: - logging.warning('ValueType 类型 <%s> 无法解析 Excel 输入,返回原值:%s,原因:%s', cls.__name__, value, exc) + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) return value @classmethod - def deserialize(cls, value: str | datetime | None | Any, field_meta: FieldMetaInfo) -> str: + def format_display_value(cls, value: str | datetime | None | Any, field_meta: FieldMetaInfo) -> str: match value: case None | '': return '' @@ -66,12 +72,14 @@ def deserialize(cls, value: str | datetime | None | Any, field_meta: FieldMetaIn return str(value) if value is not None else '' @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> int: + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> int: if field_meta.date_format is None: - raise ConfigError('日期格式未定义') + raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) if not isinstance(value, datetime): - raise ValueError(f'请输入格式为{DATE_FORMAT_TO_HINT_MAPPING[field_meta.date_format]}的日期') + raise ValueError( + msg(MessageKey.ENTER_DATE_FORMAT, date_format=DATE_FORMAT_TO_HINT_MAPPING[field_meta.date_format]) + ) parsed = cls._parse_date(value, field_meta) errors = cls._validate_date_range(parsed, field_meta) @@ -84,7 +92,8 @@ def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> int: @staticmethod def _parse_date(v: datetime, field_meta: FieldMetaInfo) -> datetime: format_ = field_meta.python_date_format - parsed = pendulum.parse(v.strftime(format_)).replace(tzinfo=field_meta.timezone) # type: ignore + parsed = datetime.strptime(v.strftime(format_), format_) + parsed = parsed.replace(tzinfo=field_meta.timezone) return parsed @staticmethod @@ -95,11 +104,14 @@ def _validate_date_range(parsed: datetime, field_meta: FieldMetaInfo) -> list[st match field_meta.date_range_option: case DataRangeOption.PRE: if parsed > now: - errors.append('需早于当前时间(含当前时间)') + errors.append(msg(MessageKey.DATE_MUST_BE_EARLIER_THAN_NOW)) case DataRangeOption.NEXT: if parsed < now: - errors.append('需晚于当前时间(含当前时间)') + errors.append(msg(MessageKey.DATE_MUST_BE_LATER_THAN_NOW)) case DataRangeOption.NONE | None: ... return errors + + +DateCodec = Date diff --git a/src/excelalchemy/codecs/date_range.py b/src/excelalchemy/codecs/date_range.py new file mode 100644 index 0000000..2ec264a --- /dev/null +++ b/src/excelalchemy/codecs/date_range.py @@ -0,0 +1,189 @@ +import logging +from collections.abc import Mapping +from datetime import datetime +from typing import Any, cast + +import pendulum +from pendulum import DateTime +from pydantic import BaseModel + +from excelalchemy._primitives.constants import DATE_FORMAT_TO_PYTHON_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption +from excelalchemy._primitives.identity import Key +from excelalchemy.codecs.base import CompositeExcelFieldCodec +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +class _DateRangeImpl(BaseModel): + start: datetime | None + end: datetime | None + + +class DateRange(CompositeExcelFieldCodec): + start: datetime | None + end: datetime | None + + __name__ = 'DateRange' + + @classmethod + def model_validate(cls, obj: Any) -> 'DateRange': + impl = _DateRangeImpl.model_validate(obj) + self = cls(impl.start, impl.end) + return self + + def __init__(self, start: datetime | None, end: datetime | None): + # Pydantic model dumps intentionally store timestamps rather than datetime objects here. + _start = int(start.timestamp() * MILLISECOND_TO_SECOND) if start else None + _end = int(end.timestamp() * MILLISECOND_TO_SECOND) if end else None + super().__init__(start=_start, end=_end) + self.start = start + self.end = end + + @classmethod + def column_items(cls) -> list[tuple[Key, FieldMetaInfo]]: + return [ + (Key('start'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_START_DATE))), + (Key('end'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_END_DATE))), + ] + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + if field_meta.date_format is None: + raise RuntimeError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) + + return '\n'.join( + [ + field_meta.comment_required, + field_meta.comment_date_format, + dmsg(MessageKey.COMMENT_DATE_RANGE_START_NOT_AFTER_END, extra_hint=field_meta.hint or ''), + ] + ) + + @classmethod + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: + mapping = cls._coerce_mapping(value) + if mapping is not None: + try: + return { + 'start': cls._parse_optional_datetime(mapping.get('start'), field_meta), + 'end': cls._parse_optional_datetime(mapping.get('end'), field_meta), + } + except Exception as exc: + logging.warning('Could not parse value %s for field %s. Reason: %s', value, cls.__name__, exc) + return value + + if isinstance(value, datetime): + return value + + if isinstance(value, str): + try: + return cls._parse_datetime_text(value, field_meta) + except Exception as exc: + logging.warning('Could not parse value %s for field %s. Reason: %s', value, cls.__name__, exc) + return value + + return value + + @classmethod + def normalize_import_value( + cls, + value: object, + field_meta: FieldMetaInfo, + ) -> 'DateRange': + try: + parsed = DateRange.model_validate(value) + parsed.start = pendulum.instance(parsed.start, tz=field_meta.timezone) if parsed.start else None + parsed.end = pendulum.instance(parsed.end, tz=field_meta.timezone) if parsed.end else None + except Exception as exc: + raise ValueError(msg(MessageKey.INVALID_INPUT)) from exc + + errors: list[str] = [] + now = datetime.now(tz=field_meta.timezone) + + if parsed.start and parsed.end and parsed.start > parsed.end: + errors.append(msg(MessageKey.DATE_RANGE_START_AFTER_END)) + + match field_meta.date_range_option: + case DataRangeOption.PRE: + if (parsed.start and parsed.start > now) or (parsed.end and parsed.end > now): + errors.append(msg(MessageKey.DATE_MUST_BE_EARLIER_THAN_NOW)) + case DataRangeOption.NEXT: + if (parsed.start and parsed.start < now) or (parsed.end and parsed.end < now): + errors.append(msg(MessageKey.DATE_MUST_BE_LATER_THAN_NOW)) + case DataRangeOption.NONE | None: + ... # do nothing + + if errors: + raise ValueError(*errors) + else: + return parsed + + @classmethod + def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) -> str: + if value is None or value == '': + return '' + date_format = field_meta.must_date_format + py_date_format = DATE_FORMAT_TO_PYTHON_MAPPING[date_format] + + if isinstance(value, str): + return value + + if isinstance(value, datetime): + return value.strftime(py_date_format) + + mapping = cls._coerce_mapping(value) + 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__) + return str(value) + + @classmethod + def __deserialize__dict(cls, py_date_format: str, value: Mapping[str, object]) -> str: + start = cls._format_boundary(value['start'], py_date_format) + end = cls._format_boundary(value['end'], py_date_format) + return start + ' - ' + end + + @staticmethod + def _format_boundary(value: object, py_date_format: str) -> str: + start = value + if isinstance(start, datetime): + start = start.strftime(py_date_format) + elif isinstance(start, (int, float)): + start = datetime.fromtimestamp(start / MILLISECOND_TO_SECOND).strftime(py_date_format) + return str(start) + + @staticmethod + def _coerce_mapping(value: object) -> Mapping[str, object] | None: + if not isinstance(value, Mapping): + return None + + raw_mapping = cast(Mapping[object, object], value) + mapping: dict[str, object] = {} + for key, item in raw_mapping.items(): + if not isinstance(key, str): + return None + mapping[key] = item + return mapping + + @staticmethod + def _parse_optional_datetime(value: object, field_meta: FieldMetaInfo) -> DateTime | None: + if value is None or value == '': + return None + if not isinstance(value, str): + raise TypeError(f'Expected a string date value, got {type(value)}') + return DateRange._parse_datetime_text(value, field_meta) + + @staticmethod + def _parse_datetime_text(value: str, field_meta: FieldMetaInfo) -> DateTime: + parsed = pendulum.parse(value) + if isinstance(parsed, DateTime): + return parsed.replace(tzinfo=field_meta.timezone) + if isinstance(parsed, datetime): + return pendulum.instance(parsed).replace(tzinfo=field_meta.timezone) + raise ValueError(msg(MessageKey.INVALID_INPUT)) + + +DateRangeCodec = DateRange diff --git a/src/excelalchemy/codecs/email.py b/src/excelalchemy/codecs/email.py new file mode 100644 index 0000000..ff642a3 --- /dev/null +++ b/src/excelalchemy/codecs/email.py @@ -0,0 +1,32 @@ +from typing import ClassVar + +from pydantic import EmailStr, TypeAdapter + +from excelalchemy.codecs.string import String +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +class Email(String): + _validator: ClassVar[TypeAdapter[EmailStr]] = TypeAdapter(EmailStr) + + @classmethod + def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> str: + # Try to parse the value as a string + try: + parsed = str(value) + except Exception as exc: + raise ValueError(msg(MessageKey.VALID_EMAIL_REQUIRED)) from exc + + # Validate the parsed string as an email address + try: + cls._validator.validate_python(parsed) + except Exception as exc: + raise ValueError(msg(MessageKey.VALID_EMAIL_REQUIRED)) from exc + + # Return the parsed string if validation succeeds + return parsed + + +EmailCodec = Email diff --git a/src/excelalchemy/codecs/money.py b/src/excelalchemy/codecs/money.py new file mode 100644 index 0000000..ac43eee --- /dev/null +++ b/src/excelalchemy/codecs/money.py @@ -0,0 +1,29 @@ +from typing import Any, ClassVar + +from excelalchemy.codecs.number import Number +from excelalchemy.metadata import FieldMetaInfo + + +class Money(Number): + MONEY_FRACTION_DIGITS: ClassVar[int] = 2 + + @classmethod + def _money_field_meta(cls, field_meta: FieldMetaInfo) -> FieldMetaInfo: + money_field_meta = field_meta.clone() + money_field_meta.fraction_digits = cls.MONEY_FRACTION_DIGITS + return money_field_meta + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return super().build_comment(cls._money_field_meta(field_meta)) + + @classmethod + def format_display_value(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: + return super().format_display_value(value, cls._money_field_meta(field_meta)) + + @classmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> float | int: + return super().normalize_import_value(value, cls._money_field_meta(field_meta)) + + +MoneyCodec = Money diff --git a/src/excelalchemy/codecs/multi_checkbox.py b/src/excelalchemy/codecs/multi_checkbox.py new file mode 100644 index 0000000..d9054a4 --- /dev/null +++ b/src/excelalchemy/codecs/multi_checkbox.py @@ -0,0 +1,82 @@ +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.exceptions import ProgrammaticError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +class MultiCheckbox(ExcelFieldCodec, list[str]): + __name__ = 'MultiChoice' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return '\n'.join( + [ + field_meta.comment_required, + field_meta.comment_options, + dmsg(MessageKey.COMMENT_SELECTION_MODE, value=dmsg(MessageKey.COMMENT_SELECTION_VALUE_MULTI)), + field_meta.comment_hint, + ] + ) + + @classmethod + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> list[str] | object: + if isinstance(value, list): + items = cast(list[object], value) + return [str(item).strip() for item in items] + + 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 + ) + return value + + @classmethod + def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> list[str]: # OptionId + if not isinstance(value, list): + raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT)) + + items = cast(list[object], value) + parsed = [str(item).strip() for item in items] + + if field_meta.options is None: + raise ProgrammaticError(msg(MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_VALUE_TYPE, value_type=cls.__name__)) + + if not field_meta.options: # empty + logging.warning( + 'Field %s of type %s has no options; returning the original value', field_meta.label, cls.__name__ + ) + return parsed + + if len(parsed) != len(set(parsed)): + raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) + + result, errors = field_meta.exchange_names_to_option_ids_with_errors(parsed) + + if errors: + raise ValueError(*errors) + else: + return result + + @classmethod + def format_display_value(cls, value: str | list[OptionId] | None, field_meta: FieldMetaInfo) -> str: + match value: + case None | '': + return '' + case str(): + return value + case list(): + option_ids = [OptionId(option_id) for option_id in value] + option_names = field_meta.exchange_option_ids_to_names(option_ids) + return f'{MULTI_CHECKBOX_SEPARATOR}'.join(option_names) + + +MultiChoiceCodec = MultiCheckbox diff --git a/src/excelalchemy/codecs/number.py b/src/excelalchemy/codecs/number.py new file mode 100644 index 0000000..36f151e --- /dev/null +++ b/src/excelalchemy/codecs/number.py @@ -0,0 +1,154 @@ +import logging +from decimal import ROUND_DOWN, Context, Decimal, InvalidOperation +from typing import Any + +from excelalchemy.codecs.base import ExcelFieldCodec +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +def canonicalize_decimal(value: Decimal, digits_limit: int | None) -> Decimal: + """Quantize a Decimal to the configured precision when needed.""" + exponent = value.as_tuple().exponent + if digits_limit is not None and isinstance(exponent, int) and abs(exponent) != digits_limit: + try: + value = Decimal(value).quantize( + Decimal(f'0.{"0" * digits_limit}'), + context=Context(rounding=ROUND_DOWN), + ) + except InvalidOperation as e: + logging.warning('fraction_digits is too small and causes precision loss: %s', e) + return value + + +def transform_decimal(value: Decimal | int | float | None) -> float | int | None: + """Convert a Decimal into an int or float for workbook-facing output.""" + if value is None: + return None + + if isinstance(value, (int, float)): + return value + + if value.as_tuple().exponent == 0: + return int(value) + else: + return float(value) + + +class Number(Decimal, ExcelFieldCodec): + __name__ = 'Number' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return '\n'.join( + [ + field_meta.comment_required, + dmsg(MessageKey.COMMENT_NUMBER_FORMAT), + field_meta.comment_fraction_digits, + dmsg(MessageKey.COMMENT_NUMBER_INPUT_RANGE, value=cls.__get_range_description__(field_meta)), + field_meta.comment_unit, + ] + ) + + @classmethod + def parse_input(cls, value: str | int | float | None, field_meta: FieldMetaInfo) -> Decimal | Any: + if isinstance(value, str): + value = value.strip() + if value is None: + return '' + try: + return transform_decimal(Decimal(str(value))) + except Exception as exc: + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) + return str(value) + + @classmethod + def format_display_value(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: + if value is None or value == '': + return '' + + try: + return str(transform_decimal(Decimal(value))) + except Exception as exc: + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) + return str(value) + + @classmethod + def __get_range_description__(cls, field_meta: FieldMetaInfo) -> str: # type: ignore[return] + match (field_meta.importer_le, field_meta.importer_ge): + case (None, None): + return dmsg(MessageKey.DATE_RANGE_OPTION_NONE_DISPLAY) + case (_, None): + return f'≤ {field_meta.importer_le}' + case (None, _): + return f'≥ {field_meta.importer_ge}' + case (le, ge): + return f'{ge}~{le}' + + @staticmethod + def __maybe_decimal__(value: Any) -> Decimal | None: + # Convert non-Decimal input through Decimal for validation. + if isinstance(value, Decimal): + return value + + try: + parsed = Decimal(str(value)) + except Exception as exc: + raise ValueError(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) from exc + + return parsed + + @staticmethod + def __check_range__(value: Decimal | float | int, field_meta: FieldMetaInfo) -> list[str]: + errors: list[str] = [] + + # Read the configured importer bounds from field metadata. + importer_le = field_meta.importer_le or Decimal('Infinity') + importer_ge = field_meta.importer_ge or Decimal('-Infinity') + + # Ensure the parsed decimal stays within the accepted range. + if not importer_ge <= value <= importer_le: + if field_meta.importer_le and field_meta.importer_ge: + errors.append( + msg( + MessageKey.NUMBER_BETWEEN_MIN_AND_MAX, + minimum=field_meta.importer_ge, + maximum=field_meta.importer_le, + ) + ) + elif field_meta.importer_le: + errors.append(msg(MessageKey.NUMBER_BETWEEN_NEG_INF_AND_MAX, maximum=field_meta.importer_le)) + elif field_meta.importer_ge: + errors.append(msg(MessageKey.NUMBER_BETWEEN_MIN_AND_POS_INF, minimum=field_meta.importer_ge)) + + return errors + + @classmethod + def normalize_import_value(cls, value: Decimal | Any, field_meta: FieldMetaInfo) -> float | int: + # Convert non-Decimal input before range validation. + parsed = cls.__maybe_decimal__(value) + if parsed is None: + raise ValueError(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) + errors: list[str] = cls.__check_range__(parsed, field_meta) + if errors: + raise ValueError(*errors) + parsed = canonicalize_decimal(parsed, field_meta.fraction_digits) + value = transform_decimal(parsed) + if value is None: + raise ValueError(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) + return value + + +NumberCodec = Number diff --git a/src/excelalchemy/codecs/number_range.py b/src/excelalchemy/codecs/number_range.py new file mode 100644 index 0000000..1f2fd64 --- /dev/null +++ b/src/excelalchemy/codecs/number_range.py @@ -0,0 +1,141 @@ +import logging +from collections.abc import Mapping +from decimal import Decimal +from typing import cast + +from excelalchemy._primitives.identity import Key +from excelalchemy.codecs.base import CompositeExcelFieldCodec +from excelalchemy.codecs.number import Number, canonicalize_decimal, transform_decimal +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +class NumberRange(CompositeExcelFieldCodec): + start: float | int | None + end: float | int | None + + __name__ = 'NumberRange' + + def __init__(self, start: Decimal | int | float | None, end: Decimal | int | float | None): + # Keep dict-like behavior while preserving normalized start/end attributes. + super().__init__(start=transform_decimal(start), end=transform_decimal(end)) + self.start = transform_decimal(start) + self.end = transform_decimal(end) + + @classmethod + def column_items(cls) -> list[tuple[Key, FieldMetaInfo]]: + return [ + (Key('start'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_MINIMUM_VALUE))), + (Key('end'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_MAXIMUM_VALUE))), + ] + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return Number.build_comment(field_meta) + + @classmethod + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: + if isinstance(value, str): + value = value.strip() + + if isinstance(value, NumberRange): + return value + + mapping = cls._coerce_mapping(value) + if mapping is not None: + try: + start = cls._parse_decimal_boundary(mapping['start']) + end = cls._parse_decimal_boundary(mapping['end']) + return NumberRange(start, end) + except (KeyError, TypeError, ValueError) as exc: + logging.warning( + '%s could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) + return value + + @classmethod + def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) -> str: + if value is None or value == '': + return '' + try: + parsed = cls._parse_decimal_boundary(value) + if parsed is None: + return '' + return str(transform_decimal(canonicalize_decimal(parsed, field_meta.fraction_digits))) + except Exception as exc: + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) + return str(value) + + @classmethod + def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> 'NumberRange': + parsed = cls.__maybe_number_range__(value, field_meta) + errors: list[str] = [] + if parsed.start is not None and parsed.end is not None and parsed.start > parsed.end: + errors.append(msg(MessageKey.NUMBER_RANGE_MIN_GREATER_THAN_MAX)) + + if parsed.start is not None: + errors.extend(Number.__check_range__(parsed.start, field_meta)) + if parsed.end is not None: + errors.extend(Number.__check_range__(parsed.end, field_meta)) + + if errors: + raise ValueError(*errors) + else: + return parsed + + @staticmethod + def __maybe_number_range__(value: object, field_meta: FieldMetaInfo) -> 'NumberRange': + if isinstance(value, NumberRange): + start = NumberRange._canonicalize_boundary(value.start, field_meta) + end = NumberRange._canonicalize_boundary(value.end, field_meta) + return NumberRange(start, end) + + mapping = NumberRange._coerce_mapping(value) + if mapping is not None: + try: + start = NumberRange._canonicalize_boundary(mapping['start'], field_meta) + end = NumberRange._canonicalize_boundary(mapping['end'], field_meta) + return NumberRange(start, end) + except Exception as exc: + raise ValueError(msg(MessageKey.ENTER_NUMBER)) from exc + + raise ValueError(msg(MessageKey.ENTER_NUMBER_EXPECTED_FORMAT)) + + @staticmethod + def _coerce_mapping(value: object) -> Mapping[str, object] | None: + if not isinstance(value, Mapping): + return None + + raw_mapping = cast(Mapping[object, object], value) + mapping: dict[str, object] = {} + for key, item in raw_mapping.items(): + if not isinstance(key, str): + return None + mapping[key] = item + return mapping + + @staticmethod + def _parse_decimal_boundary(value: object) -> Decimal | None: + if value is None or value == '': + return None + return Decimal(str(value)) + + @staticmethod + def _canonicalize_boundary(value: object, field_meta: FieldMetaInfo) -> Decimal | None: + parsed = NumberRange._parse_decimal_boundary(value) + if parsed is None: + return None + return canonicalize_decimal(parsed, field_meta.fraction_digits) + + +NumberRangeCodec = NumberRange diff --git a/src/excelalchemy/codecs/organization.py b/src/excelalchemy/codecs/organization.py new file mode 100644 index 0000000..66db89f --- /dev/null +++ b/src/excelalchemy/codecs/organization.py @@ -0,0 +1,83 @@ +import logging +from typing import cast + +from excelalchemy._primitives.constants import MULTI_CHECKBOX_SEPARATOR +from excelalchemy._primitives.identity import OptionId +from excelalchemy.codecs.multi_checkbox import MultiCheckbox +from excelalchemy.codecs.radio import Radio +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.metadata import FieldMetaInfo + + +class SingleOrganization(Radio): + __name__ = 'SingleOrganization' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + extra_hint = field_meta.hint or dmsg(MessageKey.SINGLE_ORGANIZATION_HINT) + value_key = ( + MessageKey.COMMENT_REQUIRED_VALUE_REQUIRED + if field_meta.required + else MessageKey.COMMENT_REQUIRED_VALUE_OPTIONAL + ) + return '\n'.join( + [dmsg(MessageKey.COMMENT_REQUIRED, value=dmsg(value_key)), dmsg(MessageKey.COMMENT_HINT, value=extra_hint)] + ) + + @classmethod + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> str: + return super().parse_input(value, field_meta) + + @classmethod + def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: + if not isinstance(value, str): + return '' if value is None else str(value) + try: + return field_meta.options_id_map[OptionId(value.strip())].name + except KeyError: + logging.warning('Could not resolve organization option %s; returning the original value', value) + + return value + + +class MultiOrganization(MultiCheckbox): + __name__ = 'MultiOrganization' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return '\n'.join( + [ + field_meta.comment_required, + dmsg(MessageKey.COMMENT_HINT, value=field_meta.hint or dmsg(MessageKey.MULTI_ORGANIZATION_HINT)), + ] + ) + + @classmethod + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: + return super().parse_input(value, field_meta) + + @classmethod + def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) -> str: + if value is None or value == '': + return '' + + if isinstance(value, str): + return value + + if isinstance(value, list): + items = cast(list[object], value) + option_ids = [OptionId(option_id) for option_id in items] + option_names = field_meta.exchange_option_ids_to_names(option_ids) + return MULTI_CHECKBOX_SEPARATOR.join(map(str, option_names)) + + logging.warning('%s could not be deserialized; returning the original value', cls.__name__) + return str(value) + + @classmethod + def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> list[str]: + return super().normalize_import_value(value, field_meta) + + +SingleOrganizationCodec = SingleOrganization +MultiOrganizationCodec = MultiOrganization diff --git a/src/excelalchemy/codecs/phone_number.py b/src/excelalchemy/codecs/phone_number.py new file mode 100644 index 0000000..f8309af --- /dev/null +++ b/src/excelalchemy/codecs/phone_number.py @@ -0,0 +1,23 @@ +import re +from typing import Any + +from excelalchemy.codecs.string import String +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + +PHONE_NUMBER_PATTERN = re.compile(r'^((0\d{2,3}-\d{7,8})|(1[3456789]\d{9}))$') + + +class PhoneNumber(String): + @classmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> str: + parsed = str(value) + + if not PHONE_NUMBER_PATTERN.match(parsed): + raise ValueError(msg(MessageKey.VALID_PHONE_NUMBER_REQUIRED)) + + return parsed + + +PhoneNumberCodec = PhoneNumber diff --git a/src/excelalchemy/codecs/radio.py b/src/excelalchemy/codecs/radio.py new file mode 100644 index 0000000..c6ad935 --- /dev/null +++ b/src/excelalchemy/codecs/radio.py @@ -0,0 +1,77 @@ +import logging +from typing import Any + +from excelalchemy._primitives.constants import MULTI_CHECKBOX_SEPARATOR +from excelalchemy._primitives.identity import OptionId +from excelalchemy.codecs.base import ExcelFieldCodec +from excelalchemy.exceptions import ProgrammaticError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +class Radio(ExcelFieldCodec, str): + __name__ = 'SingleChoice' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + if not field_meta.options: + logging.error('Field %s of type %s must define options', field_meta.label, cls.__name__) + + return '\n'.join( + [ + field_meta.comment_required, + field_meta.comment_options, + dmsg(MessageKey.COMMENT_SELECTION_MODE, value=dmsg(MessageKey.COMMENT_SELECTION_VALUE_SINGLE)), + field_meta.comment_hint, + ] + ) + + @classmethod + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> str: + return str(value).strip() + + @classmethod + def format_display_value(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: + if value is None or value == '': + return '' + + try: + return field_meta.options_id_map[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, + field_meta.label, + exc, + ) + return value if value is not None else '' + + @classmethod + def normalize_import_value(cls, value: str, field_meta: FieldMetaInfo) -> OptionId | str: # return Option.id + if MULTI_CHECKBOX_SEPARATOR in value: + raise ValueError(msg(MessageKey.MULTIPLE_SELECTIONS_NOT_SUPPORTED)) + + parsed = value.strip() + + if field_meta.options is None: + raise ProgrammaticError(msg(MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_SELECTION_FIELDS)) + + if not field_meta.options: # empty + logging.warning( + 'Field %s of type %s has no options; returning the original value', field_meta.label, cls.__name__ + ) + return parsed + + if parsed in field_meta.options_id_map: + return parsed + + if parsed not in field_meta.options_name_map: + raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_FIELD_COMMENT)) + + return field_meta.options_name_map[parsed].id + + +SingleChoiceCodec = Radio diff --git a/src/excelalchemy/codecs/staff.py b/src/excelalchemy/codecs/staff.py new file mode 100644 index 0000000..71e2dc9 --- /dev/null +++ b/src/excelalchemy/codecs/staff.py @@ -0,0 +1,88 @@ +import logging +from typing import cast + +from excelalchemy._primitives.constants import MULTI_CHECKBOX_SEPARATOR +from excelalchemy._primitives.identity import OptionId +from excelalchemy.codecs.multi_checkbox import MultiCheckbox +from excelalchemy.codecs.radio import Radio +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +class SingleStaff(Radio): + __name__ = 'SingleStaff' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + extra_hint = field_meta.hint or dmsg(MessageKey.SINGLE_STAFF_HINT) + value_key = ( + MessageKey.COMMENT_REQUIRED_VALUE_REQUIRED + if field_meta.required + else MessageKey.COMMENT_REQUIRED_VALUE_OPTIONAL + ) + return f'{dmsg(MessageKey.COMMENT_REQUIRED, value=dmsg(value_key))} \n{dmsg(MessageKey.COMMENT_HINT, value=extra_hint)}' + + @classmethod + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> str: + return super().parse_input(value, field_meta) + + @classmethod + def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) -> str: + if value is None or value == '': + return '' + if not isinstance(value, str): + return str(value) + try: + return field_meta.options_id_map[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, + field_meta.label, + ) + return value + + +class MultiStaff(MultiCheckbox): + __name__ = 'MultiStaff' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return '\n'.join( + [ + field_meta.comment_required, + dmsg(MessageKey.COMMENT_HINT, value=field_meta.hint or dmsg(MessageKey.MULTI_STAFF_HINT)), + ] + ) + + @classmethod + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: + return super().parse_input(value, field_meta) + + @classmethod + def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> list[str]: + return super().normalize_import_value(value, field_meta) + + @classmethod + def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: + if isinstance(value, str): + return value + + if isinstance(value, list): + items = cast(list[object], value) + option_ids = [OptionId(option_id) for option_id in items] + if len(option_ids) != len(set(option_ids)): + raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) + + option_names = field_meta.exchange_option_ids_to_names(option_ids) + return f'{MULTI_CHECKBOX_SEPARATOR}'.join(option_names) + + logging.warning('%s could not be deserialized', cls.__name__) + return str(value) + + +SingleStaffCodec = SingleStaff +MultiStaffCodec = MultiStaff diff --git a/excelalchemy/types/value/string.py b/src/excelalchemy/codecs/string.py similarity index 63% rename from excelalchemy/types/value/string.py rename to src/excelalchemy/codecs/string.py index c8fe740..ced9e3d 100644 --- a/excelalchemy/types/value/string.py +++ b/src/excelalchemy/codecs/string.py @@ -1,11 +1,15 @@ from typing import Any -from excelalchemy.const import CharacterSet -from excelalchemy.exc import ProgrammaticError -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo +from excelalchemy._primitives.constants import CharacterSet +from excelalchemy.codecs.base import ExcelFieldCodec +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo -SPECIAL_SYMBOLS = set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~"。?!,、;:‘’“”()《》〈〉【】〔〕{}⦅⦆〖〗〘〙〚〛〜〝〞〟〰–—‘‛“”„‟…‧﹏.') +SPECIAL_SYMBOLS = set( + '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~"。?!,、;:‘’“”()《》〈〉【】〔〕{}⦅⦆〖〗〘〙〚〛〜〝〞〟〰–—‘‛“”„‟…‧﹏.' +) def _is_chinese_character(character: str) -> bool: @@ -64,49 +68,49 @@ def _is_special_symbols(character: str) -> bool: CharacterSet.SPECIAL_SYMBOLS: _is_special_symbols, } -_CHARACTER_SET_TO_NAME = { - CharacterSet.CHINESE: '中文字符', - CharacterSet.NUMBER: '数字', - CharacterSet.LOWERCASE_LETTERS: '小写字母', - CharacterSet.UPPERCASE_LETTERS: '大写字母', - CharacterSet.SPECIAL_SYMBOLS: '特殊符号', +_CHARACTER_SET_TO_MESSAGE_KEY = { + CharacterSet.CHINESE: MessageKey.CHARACTER_SET_NAME_CHINESE, + CharacterSet.NUMBER: MessageKey.CHARACTER_SET_NAME_NUMBER, + CharacterSet.LOWERCASE_LETTERS: MessageKey.CHARACTER_SET_NAME_LOWERCASE, + CharacterSet.UPPERCASE_LETTERS: MessageKey.CHARACTER_SET_NAME_UPPERCASE, + CharacterSet.SPECIAL_SYMBOLS: MessageKey.CHARACTER_SET_NAME_SPECIAL, } def _format_character_set_names(cs: set[CharacterSet]) -> str: - return '、'.join(_CHARACTER_SET_TO_NAME[c] for c in cs) + ordered = sorted(cs, key=lambda item: item.value) + return ', '.join(msg(_CHARACTER_SET_TO_MESSAGE_KEY[c]) for c in ordered) -class String(str, ABCValueType): +class String(str, ExcelFieldCodec): @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: + def build_comment(cls, field_meta: FieldMetaInfo) -> str: return '\n'.join( [ field_meta.comment_unique, field_meta.comment_required, field_meta.comment_max_length, - '可输入内容:中文、数字、大写字母、小写字母、符号', + dmsg(MessageKey.COMMENT_STRING_ALLOWED_CONTENT), field_meta.comment_hint, ] ) @classmethod - def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> str: + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> str: return str(value).strip() @classmethod - def deserialize(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: + def format_display_value(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: return str(value).strip() if value is not None else '' # mccabe-complexity: 12 @classmethod - def __validate__(cls, value: str, field_meta: FieldMetaInfo) -> str: + def normalize_import_value(cls, value: str, field_meta: FieldMetaInfo) -> str: parsed = str(value) errors: list[str] = [] - if field_meta.importer_max_length is not None: - if len(parsed) > field_meta.importer_max_length: - errors.append(f'最长为{field_meta.importer_max_length}个字') + if field_meta.importer_max_length is not None and len(parsed) > field_meta.importer_max_length: + errors.append(msg(MessageKey.MAX_LENGTH_CHARACTERS, max_length=field_meta.importer_max_length)) errors.extend(cls.__check_character_set__(parsed, field_meta)) @@ -118,12 +122,17 @@ def __validate__(cls, value: str, field_meta: FieldMetaInfo) -> str: @classmethod def __check_character_set__(cls, value: str, field_meta: FieldMetaInfo) -> list[str]: errors: list[str] = [] - if field_meta.character_set is None: - raise ProgrammaticError('character_set 未设置') - for single_character in value: if not any(_CHARACTER_SET_TO_VALIDATOR[cs](single_character) for cs in field_meta.character_set): - errors.append(f'仅允许输入{_format_character_set_names(field_meta.character_set)}') + errors.append( + msg( + MessageKey.ONLY_CHARACTER_SET_ALLOWED, + character_set_names=_format_character_set_names(field_meta.character_set), + ) + ) break return errors + + +StringCodec = String diff --git a/src/excelalchemy/codecs/tree.py b/src/excelalchemy/codecs/tree.py new file mode 100644 index 0000000..f88a4ae --- /dev/null +++ b/src/excelalchemy/codecs/tree.py @@ -0,0 +1,64 @@ +import logging +from typing import Any + +from excelalchemy.codecs.multi_checkbox import MultiCheckbox +from excelalchemy.codecs.radio import Radio +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.metadata import FieldMetaInfo + + +class SingleTreeNode(Radio): + __name__ = 'SingleTreeNode' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return '\n'.join( + [ + field_meta.comment_required, + dmsg(MessageKey.COMMENT_HINT, value=field_meta.hint or dmsg(MessageKey.SINGLE_TREE_HINT)), + ] + ) + + @classmethod + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + if isinstance(value, str): + return value.strip() + return value + + @classmethod + def format_display_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + try: + return field_meta.options_id_map[value.strip()].name + except KeyError: + logging.warning('Could not resolve tree option %s; returning the original value', value) + + return value if value is not None else '' + + +class MultiTreeNode(MultiCheckbox): + __name__ = 'MultiTreeNode' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + extra_hint = field_meta.hint or dmsg(MessageKey.MULTI_TREE_HINT) + value_key = ( + MessageKey.COMMENT_REQUIRED_VALUE_REQUIRED + if field_meta.required + else MessageKey.COMMENT_REQUIRED_VALUE_OPTIONAL + ) + return '\n'.join( + [dmsg(MessageKey.COMMENT_REQUIRED, value=dmsg(value_key)), dmsg(MessageKey.COMMENT_HINT, value=extra_hint)] + ) + + @classmethod + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + return super().parse_input(value, field_meta) + + @classmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> list[str]: + return super().normalize_import_value(value, field_meta) + + +SingleTreeNodeCodec = SingleTreeNode +MultiTreeNodeCodec = MultiTreeNode diff --git a/src/excelalchemy/codecs/url.py b/src/excelalchemy/codecs/url.py new file mode 100644 index 0000000..d466201 --- /dev/null +++ b/src/excelalchemy/codecs/url.py @@ -0,0 +1,30 @@ +from typing import Any + +from pydantic import HttpUrl, TypeAdapter + +from excelalchemy.codecs.string import String +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +class Url(String): + _validator = TypeAdapter(HttpUrl) + + @classmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> str: + parsed = str(value) + errors: list[str] = [] + + try: + cls._validator.validate_python(parsed) + except Exception: + errors.append(msg(MessageKey.VALID_URL_REQUIRED)) + + if errors: + raise ValueError(*errors) + else: + return parsed + + +UrlCodec = Url diff --git a/src/excelalchemy/config.py b/src/excelalchemy/config.py new file mode 100644 index 0000000..e3ae1be --- /dev/null +++ b/src/excelalchemy/config.py @@ -0,0 +1,125 @@ +"""Configuration objects used to instantiate the ExcelAlchemy facade.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING, Self + +from pydantic import BaseModel + +from excelalchemy._primitives.payloads import DataConverter, DmlCallback, ExistenceCheckCallback, ImportContext +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.exceptions import ConfigError +from excelalchemy.helper.pydantic import get_model_field_names +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.util.convertor import export_data_converter, import_data_converter + +if TYPE_CHECKING: + from minio import Minio + + +class ExcelMode(StrEnum): + """Top-level Excel workflow mode.""" + + IMPORT = 'IMPORT' + EXPORT = 'EXPORT' + + +class ImportMode(StrEnum): + CREATE = 'CREATE' + UPDATE = 'UPDATE' + CREATE_OR_UPDATE = 'CREATE_OR_UPDATE' + + +@dataclass(slots=True) +class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateModelT: BaseModel]: + create_importer_model: type[ImporterCreateModelT] | None = None + update_importer_model: type[ImporterUpdateModelT] | None = None + + # The converter receives schema keys rather than workbook labels. + data_converter: DataConverter | None = import_data_converter + creator: DmlCallback[ContextT] | None = None + updater: DmlCallback[ContextT] | None = None + + context: ImportContext[ContextT] = None + is_data_exist: ExistenceCheckCallback[ContextT] | None = None + exec_formatter: Callable[[Exception], str] = str + + import_mode: ImportMode = ImportMode.CREATE + + storage: ExcelStorage | None = None + minio: Minio | None = None + bucket_name: str = 'excel' + url_expires: int = 3600 + locale: str = 'zh-CN' + + sheet_name: str = 'Sheet1' + + def validate_model(self) -> Self: + if self.import_mode not in ImportMode.__members__.values(): + raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) + + match self.import_mode: + case ImportMode.CREATE: + self._validate_create() + case ImportMode.UPDATE: + self._validate_update() + case ImportMode.CREATE_OR_UPDATE: + self._validate_create_or_update() + + return self + + def _validate_create(self) -> None: + if self.import_mode != ImportMode.CREATE: + raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) + if not self.create_importer_model: + raise ConfigError(msg(MessageKey.CREATE_IMPORTER_MODEL_REQUIRED_CREATE)) + + def _validate_update(self) -> None: + if self.import_mode != ImportMode.UPDATE: + raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) + if not self.update_importer_model: + raise ConfigError(msg(MessageKey.UPDATE_IMPORTER_MODEL_REQUIRED_UPDATE)) + + def _validate_create_or_update(self) -> None: + if self.import_mode != ImportMode.CREATE_OR_UPDATE: + raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) + + if not self.create_importer_model: + raise ConfigError(msg(MessageKey.CREATE_IMPORTER_MODEL_REQUIRED_CREATE_OR_UPDATE)) + if not self.update_importer_model: + raise ConfigError(msg(MessageKey.UPDATE_IMPORTER_MODEL_REQUIRED_CREATE_OR_UPDATE)) + if not self.is_data_exist: + raise ConfigError(msg(MessageKey.IS_DATA_EXIST_REQUIRED_CREATE_OR_UPDATE)) + # Create and update models must expose the same schema keys. + if get_model_field_names(self.create_importer_model) != get_model_field_names(self.update_importer_model): + raise ConfigError(msg(MessageKey.IMPORTER_MODELS_FIELD_NAMES_MUST_MATCH)) + + def __post_init__(self) -> None: + self.validate_model() + + +@dataclass(slots=True) +class ExporterConfig[ExporterModelT: BaseModel]: + exporter_model: type[ExporterModelT] + # The converter receives schema keys rather than workbook labels. + data_converter: DataConverter | None = export_data_converter + + storage: ExcelStorage | None = None + minio: Minio | None = None + bucket_name: str = 'excel' + url_expires: int = 3600 + locale: str = 'zh-CN' + + sheet_name: str = 'Sheet1' + + def validate_model(self) -> Self: + if not self.exporter_model: + raise ValueError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_EMPTY)) + return self + + def __post_init__(self) -> None: + self.validate_model() diff --git a/src/excelalchemy/const.py b/src/excelalchemy/const.py new file mode 100644 index 0000000..afc3ef8 --- /dev/null +++ b/src/excelalchemy/const.py @@ -0,0 +1,3 @@ +"""Compatibility re-exports for lower-level constant definitions.""" + +from excelalchemy._primitives.constants import * # noqa: F403 diff --git a/excelalchemy/core/__init__.py b/src/excelalchemy/core/__init__.py similarity index 100% rename from excelalchemy/core/__init__.py rename to src/excelalchemy/core/__init__.py diff --git a/src/excelalchemy/core/abstract.py b/src/excelalchemy/core/abstract.py new file mode 100644 index 0000000..924a7d1 --- /dev/null +++ b/src/excelalchemy/core/abstract.py @@ -0,0 +1,59 @@ +from abc import ABC, abstractmethod +from collections.abc import Sequence + +from pydantic import BaseModel + +from excelalchemy._primitives.identity import DataUrlStr, UrlStr +from excelalchemy._primitives.payloads import ExportRowPayload +from excelalchemy.artifacts import ExcelArtifact +from excelalchemy.results import ImportResult + + +class ABCExcelAlchemy[ + ContextT, + ImporterCreateModelT: BaseModel, + ImporterUpdateModelT: BaseModel, + CreateModelT: BaseModel, + UpdateModelT: BaseModel, + ExporterModelT: BaseModel, +](ABC): + @abstractmethod + def download_template(self, sample_data: list[ExportRowPayload] | None = None) -> DataUrlStr: + """Render an import template and return it as a data URL.""" + + @abstractmethod + def download_template_artifact( + self, + sample_data: list[ExportRowPayload] | None = None, + *, + filename: str = 'template.xlsx', + ) -> ExcelArtifact: + """Render an import template and return a structured Excel artifact.""" + + @abstractmethod + async def import_data(self, input_excel_name: str, output_excel_name: str) -> ImportResult: + """Import workbook data and return a structured result.""" + + @abstractmethod + def export(self, data: list[ExportRowPayload], keys: Sequence[str] | None = None) -> DataUrlStr: + """Export rows and return the workbook as a data URL.""" + + @abstractmethod + def export_artifact( + self, + data: list[ExportRowPayload], + keys: Sequence[str] | None = None, + *, + filename: str = 'export.xlsx', + ) -> ExcelArtifact: + """Export rows and return a structured Excel artifact.""" + + @abstractmethod + def export_upload( + self, output_name: str, data: list[ExportRowPayload], keys: Sequence[str] | None = None + ) -> UrlStr: + """Export rows and upload the workbook through the configured storage backend.""" + + @abstractmethod + def add_context(self, context: ContextT) -> None: + """Attach runtime context used by importer callbacks.""" diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py new file mode 100644 index 0000000..21382cb --- /dev/null +++ b/src/excelalchemy/core/alchemy.py @@ -0,0 +1,457 @@ +import logging +from collections.abc import Iterable, Sequence +from functools import cached_property +from typing import cast + +from pydantic import BaseModel + +from excelalchemy._primitives.constants import ( + REASON_COLUMN_KEY, + RESULT_COLUMN_KEY, +) +from excelalchemy._primitives.header_models import ExcelHeader +from excelalchemy._primitives.identity import DataUrlStr, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr +from excelalchemy._primitives.payloads import DataConverter, ExportRowPayload, FlatRowPayload, ModelRowPayload +from excelalchemy.artifacts import ExcelArtifact +from excelalchemy.codecs.base import SystemReserved +from excelalchemy.config import ExcelMode, ExporterConfig, ImporterConfig, ImportMode +from excelalchemy.core.abstract import ABCExcelAlchemy +from excelalchemy.core.executor import ImportExecutor +from excelalchemy.core.headers import ExcelHeaderParser, ExcelHeaderValidator +from excelalchemy.core.rendering import ExcelRenderer +from excelalchemy.core.rows import ImportIssueTracker, RowAggregator +from excelalchemy.core.schema import ExcelSchemaLayout +from excelalchemy.core.storage import build_storage_gateway +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.core.table import WorksheetTable +from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.helper.pydantic import get_model_field_names +from excelalchemy.i18n.messages import MessageKey, use_display_locale +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo +from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult +from excelalchemy.util.file import flatten + +HEADER_HINT_LINE_COUNT = 1 + +RESULT_COLUMN = FieldMetaInfo(label=dmsg(MessageKey.RESULT_COLUMN_LABEL, locale='zh-CN')) +RESULT_COLUMN.parent_label = RESULT_COLUMN.label +RESULT_COLUMN.key = RESULT_COLUMN.parent_key = RESULT_COLUMN_KEY +RESULT_COLUMN.excel_codec = SystemReserved + +REASON_COLUMN = FieldMetaInfo(label=dmsg(MessageKey.REASON_COLUMN_LABEL, locale='zh-CN')) +REASON_COLUMN.parent_label = REASON_COLUMN.label +REASON_COLUMN.key = REASON_COLUMN.parent_key = REASON_COLUMN_KEY +REASON_COLUMN.excel_codec = SystemReserved + + +class ExcelAlchemy[ + ContextT, + ImporterCreateModelT: BaseModel, + ImporterUpdateModelT: BaseModel, + CreateModelT: BaseModel, + UpdateModelT: BaseModel, + ExporterModelT: BaseModel, +]( + ABCExcelAlchemy[ + ContextT, + ImporterCreateModelT, + ImporterUpdateModelT, + CreateModelT, + UpdateModelT, + ExporterModelT, + ] +): + def __init__( + self, + config: ImporterConfig[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | ExporterConfig[ExporterModelT], + ): + self.df = WorksheetTable() + self.header_df = WorksheetTable() + self.config = config + self.context: ContextT | None = None + self.locale = getattr(config, 'locale', 'zh-CN') + self.__state_df_has_been_loaded__ = False + + self.import_result_field_meta = self._build_import_result_field_meta() + self.import_result_label_to_field_meta = { + field_meta.unique_label: field_meta for field_meta in self.import_result_field_meta + } + + self._header_parser = ExcelHeaderParser() + self._header_validator = ExcelHeaderValidator() + self._renderer = ExcelRenderer() + self._storage_gateway: ExcelStorage = build_storage_gateway(config) + self._layout: ExcelSchemaLayout + self._issue_tracker: ImportIssueTracker | None = None + self._row_aggregator: RowAggregator | None = None + self._executor: ImportExecutor[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | None = None + + self.__init_from_config__() + + def __init_from_config__(self) -> None: + self.context = getattr(self.config, 'context', None) + model = self.__get_importer_model__() + with use_display_locale(self.locale): + self._layout = ExcelSchemaLayout.from_model(model) + self.__sync_layout_state__() + + self._issue_tracker = ImportIssueTracker(self._layout, self.import_result_field_meta) + self.cell_errors = self._issue_tracker.cell_errors + self.row_errors = self._issue_tracker.row_errors + + if isinstance(self.config, ImporterConfig): + self._row_aggregator = RowAggregator(self._layout, self.config.import_mode) + self._executor = ImportExecutor(self.config, self._issue_tracker, lambda: self.context) + + def __sync_layout_state__(self) -> None: + self.field_metas = self._layout.field_metas + self.unique_label_to_field_meta = self._layout.unique_label_to_field_meta + self.parent_label_to_field_metas = self._layout.parent_label_to_field_metas + self.parent_key_to_field_metas = self._layout.parent_key_to_field_metas + self.unique_key_to_field_meta = self._layout.unique_key_to_field_meta + self.ordered_field_meta = self._layout.ordered_field_meta + + def _reset_import_runtime_state(self) -> None: + self.df = WorksheetTable() + self.header_df = WorksheetTable() + self.__state_df_has_been_loaded__ = False + runtime_state = vars(self) + runtime_state.pop('input_excel_has_merged_header', None) + runtime_state.pop('input_excel_headers', None) + + self._issue_tracker = ImportIssueTracker(self._layout, self.import_result_field_meta) + self.cell_errors = self._issue_tracker.cell_errors + self.row_errors = self._issue_tracker.row_errors + + if isinstance(self.config, ImporterConfig): + self._executor = ImportExecutor(self.config, self._issue_tracker, lambda: self.context) + + def __get_importer_model__(self) -> type[ImporterCreateModelT] | type[ImporterUpdateModelT] | type[ExporterModelT]: + importer_model = None + if self.excel_mode == ExcelMode.IMPORT: + if not isinstance(self.config, ImporterConfig): + raise ConfigError(msg(MessageKey.IMPORT_MODE_CONFIG_REQUIRED, config_name=ImporterConfig.__name__)) + if self.config.import_mode in (ImportMode.CREATE, ImportMode.CREATE_OR_UPDATE): + importer_model = self.config.create_importer_model # type: ignore[assignment] + elif self.config.import_mode == ImportMode.UPDATE: + importer_model = self.config.update_importer_model # type: ignore[assignment] + elif self.excel_mode == ExcelMode.EXPORT: + if not isinstance(self.config, ExporterConfig): + raise ConfigError(msg(MessageKey.EXPORT_MODE_CONFIG_REQUIRED, config_name=ExporterConfig.__name__)) + importer_model = self.config.exporter_model # type: ignore[assignment] + + if importer_model is None: + raise ConfigError(msg(MessageKey.NO_IMPORTER_OR_EXPORTER_MODEL_CONFIGURED)) + return importer_model + + def download_template(self, sample_data: list[ExportRowPayload] | None = None) -> DataUrlStr: + if self.excel_mode != ExcelMode.IMPORT: + raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_METHOD)) + + with use_display_locale(self.locale): + keys = self._select_output_excel_keys() + has_merged_header = self.has_merged_header(keys) + if has_merged_header: + df = self._export_with_merged_header(sample_data, keys) + else: + df = self._export_with_simple_header(sample_data, keys) + return self._renderer.render_template( + df, self.unique_label_to_field_meta, has_merged_header=has_merged_header + ) + + def download_template_artifact( + self, + sample_data: list[ExportRowPayload] | None = None, + *, + filename: str = 'template.xlsx', + ) -> ExcelArtifact: + return ExcelArtifact.from_data_url(self.download_template(sample_data), filename=filename) + + async def import_data(self, input_excel_name: str, output_excel_name: str) -> ImportResult: + assert isinstance(self.config, ImporterConfig) + if self.excel_mode != ExcelMode.IMPORT: + raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_METHOD)) + + self._reset_import_runtime_state() + assert self._executor is not None + + with use_display_locale(self.locale): + validate_header = self._validate_header(input_excel_name) + if not validate_header.is_valid: + return ImportResult.from_validate_header_result(validate_header) + + self.df = self.df.iloc[1:] + self._set_columns(self.df) + self.df = self.df.reset_index(drop=True) + + all_success, success_count, fail_count = True, 0, 0 + for table_row_index in range(self.extra_header_count_on_import, len(self.df)): + row = self.df.row_at(table_row_index) + aggregate_data = self._aggregate_data(cast(FlatRowPayload, row.to_dict())) + success = await self._executor.execute(RowIndex(table_row_index), aggregate_data, self.df) + all_success = all_success and success + success_count, fail_count = ( + (success_count + 1, fail_count) if success else (success_count, fail_count + 1) + ) + + url = None + if not all_success: + self._add_result_column() + content_with_prefix = self._render_import_result_excel() + url = self._upload_file(output_excel_name, content_with_prefix) + + return ImportResult( + result=(ValidateResult.DATA_INVALID, ValidateResult.SUCCESS)[int(all_success)], + url=url, + success_count=success_count, + fail_count=fail_count, + ) + + def export(self, data: list[ExportRowPayload], keys: Sequence[str] | None = None) -> DataUrlStr: + with use_display_locale(self.locale): + df, has_merged_header = self._gen_export_df(data, keys) + return self._renderer.render_data( + df, + field_meta_mapping=self.unique_label_to_field_meta, + has_merged_header=has_merged_header, + errors={}, + ) + + def export_artifact( + self, + data: list[ExportRowPayload], + keys: Sequence[str] | None = None, + *, + filename: str = 'export.xlsx', + ) -> ExcelArtifact: + return ExcelArtifact.from_data_url(self.export(data, keys), filename=filename) + + def export_upload( + self, output_name: str, data: list[ExportRowPayload], keys: Sequence[str] | None = None + ) -> UrlStr: + return self._upload_file(output_name, self.export(data, keys)) + + def add_context(self, context: ContextT) -> None: + if self.context is not None: + logging.warning('An existing conversion context is being replaced') + self.context = context + + @cached_property + def input_excel_has_merged_header(self) -> bool: + if not self.__state_df_has_been_loaded__: + raise ConfigError(msg(MessageKey.WORKSHEET_TABLE_NOT_LOADED)) + return self._header_parser.has_merged_header(self.header_df) + + @cached_property + def input_excel_headers(self) -> list[ExcelHeader]: + if not self.__state_df_has_been_loaded__: + raise ConfigError(msg(MessageKey.WORKSHEET_TABLE_NOT_LOADED)) + return self._header_parser.extract(self.header_df) + + @property + def excel_mode(self) -> ExcelMode: + if isinstance(self.config, ImporterConfig): + return ExcelMode.IMPORT + return ExcelMode.EXPORT + + @property + def extra_header_count_on_import(self) -> int: + if self.excel_mode != ExcelMode.IMPORT: + raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_PROPERTY)) + + for input_excel_label in self.input_excel_headers: + if input_excel_label.label != input_excel_label.parent_label: + return 1 + return 0 + + @property + def exporter_model(self) -> type[ExporterModelT]: + if isinstance(self.config, ImporterConfig): + if self.config.create_importer_model and self.config.update_importer_model: + raise ConfigError(msg(MessageKey.EXPORTER_MODEL_INFERENCE_CONFLICT)) + if self.config.create_importer_model: + logging.info('Inferring exporter_model from create_importer_model') + return cast(type[ExporterModelT], self.config.create_importer_model) + if self.config.update_importer_model: + logging.info('Inferring exporter_model from update_importer_model') + return cast(type[ExporterModelT], self.config.update_importer_model) + raise ConfigError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_INFERRED)) + + return self.config.exporter_model + + def has_merged_header(self, selected_keys: list[UniqueKey]) -> bool: + return self._layout.has_merged_header(selected_keys) + + def get_output_parent_excel_headers(self, selected_keys: list[UniqueKey] | None = None) -> list[UniqueLabel]: + return self._layout.get_output_parent_excel_headers(selected_keys) + + def get_output_child_excel_headers(self, selected_keys: list[UniqueKey] | None = None) -> list[Label]: + return self._layout.get_output_child_excel_headers(selected_keys) + + 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') + + input_keys = ( + list(keys) + if keys is not None + else [ + str(field_meta.parent_key) + for field_meta in self.ordered_field_meta + if field_meta.parent_key is not None + ] + ) + 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 + ) + + selected_keys = self._select_output_excel_keys(list(set(input_keys).intersection(set(model_keys)))) + has_merged_header = self.has_merged_header(selected_keys) + if has_merged_header: + df = self._export_with_merged_header(data, selected_keys, self.config.data_converter) + else: + df = self._export_with_simple_header(data, selected_keys, self.config.data_converter) + return df, has_merged_header + + def _validate_header(self, input_excel_name: str) -> ValidateHeaderResult: + if self.excel_mode != ExcelMode.IMPORT: + raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_METHOD)) + assert isinstance(self.config, ImporterConfig) + self._read_dataframe(input_excel_name) + return self._header_validator.validate(self.input_excel_headers, self._layout, self.config.import_mode) + + def _render_import_result_excel(self) -> DataUrlStr: + return self._renderer.render_data( + self.df, + field_meta_mapping=self.import_result_label_to_field_meta | self.unique_label_to_field_meta, + has_merged_header=self.input_excel_has_merged_header, + errors=self.cell_errors, + ) + + def _upload_file(self, output_name: str, content_with_prefix: DataUrlStr) -> UrlStr: + return self._storage_gateway.upload_excel(output_name, content_with_prefix) + + def _order_errors(self, errors: list[ExcelRowError | ExcelCellError]) -> Iterable[ExcelCellError | ExcelRowError]: + return self._layout.order_errors(errors) + + def _set_columns(self, df: WorksheetTable) -> WorksheetTable: + return self._header_parser.apply_columns(df, self.input_excel_headers, self.get_output_parent_excel_headers()) + + def _select_output_excel_keys(self, keys: Sequence[str] | None = None) -> list[UniqueKey]: + return self._layout.select_output_excel_keys(keys) + + def _read_dataframe(self, input_excel_name: str) -> WorksheetTable: + assert isinstance(self.config, ImporterConfig) + if not self.__state_df_has_been_loaded__: + df = self._storage_gateway.read_excel_table( + input_excel_name, + skiprows=HEADER_HINT_LINE_COUNT, + sheet_name=self.config.sheet_name, + ) + self.df = df + self.header_df = df.head(2) + self.__state_df_has_been_loaded__ = True + return self.df + + def _generate_export_df( + self, + records: list[ExportRowPayload] | None, + selected_keys: list[UniqueKey], + data_converter: DataConverter | None = None, + ) -> WorksheetTable: + rows: list[dict[UniqueLabel, object]] = [] + records = records or [] + for record in records: + row: dict[UniqueLabel, object] = {} + record = data_converter(record) if data_converter else record + for key, value in flatten(record).items(): + if key not in selected_keys: + continue + field_meta = self.unique_key_to_field_meta[UniqueKey(key)] + row[field_meta.unique_label] = field_meta.excel_codec.format_display_value(value, field_meta) + rows.append(row) + + return WorksheetTable(columns=self.get_output_parent_excel_headers(selected_keys), rows=rows) + + def _export_with_merged_header( + self, + records: list[ExportRowPayload] | None, + selected_keys: list[UniqueKey], + data_converter: DataConverter | None = None, + ) -> WorksheetTable: + data_df = self._generate_export_df(records, selected_keys, data_converter) + return data_df.with_prepended_rows([self.get_output_child_excel_headers(selected_keys)]) + + def _export_with_simple_header( + self, + records: list[ExportRowPayload] | None, + selected_keys: list[UniqueKey], + data_converter: DataConverter | None = None, + ) -> WorksheetTable: + return self._generate_export_df(records, selected_keys, data_converter) + + def _add_result_column(self): + assert self._issue_tracker is not None + self._issue_tracker.add_result_columns( + self.df, + result_unique_label=self.import_result_field_meta[0].unique_label, + reason_unique_label=self.import_result_field_meta[1].unique_label, + extra_header_count_on_import=self.extra_header_count_on_import, + ) + return self + + def _aggregate_data(self, row_data: FlatRowPayload) -> ModelRowPayload: + assert self._row_aggregator is not None + return self._row_aggregator.aggregate(row_data) + + def _register_row_error( + self, + row_index: RowIndex, + error: ExcelRowError | ExcelCellError | list[ExcelRowError | ExcelCellError] | list[ExcelCellError], + ): + assert self._issue_tracker is not None + self._issue_tracker.register_row_error(row_index, error) + + def _register_cell_errors(self, row_index: RowIndex, errors: list[ExcelCellError]): + assert self._issue_tracker is not None + self._issue_tracker.register_cell_errors(row_index, errors, self.df) + return self + + def _build_import_result_field_meta(self) -> list[FieldMetaInfo]: + result_column = FieldMetaInfo(label=dmsg(MessageKey.RESULT_COLUMN_LABEL, locale=self.locale)) + result_column.parent_label = result_column.label + result_column.key = result_column.parent_key = RESULT_COLUMN_KEY + result_column.excel_codec = SystemReserved + + reason_column = FieldMetaInfo(label=dmsg(MessageKey.REASON_COLUMN_LABEL, locale=self.locale)) + reason_column.parent_label = reason_column.label + reason_column.key = reason_column.parent_key = REASON_COLUMN_KEY + reason_column.excel_codec = SystemReserved + + return [result_column, reason_column] + + def _excel_has_merged_header(self) -> bool: + return self._header_parser.has_merged_header(self.header_df) + + def _extract_header(self) -> list[ExcelHeader]: + return self._header_parser.extract(self.header_df) + + def _extract_simple_header(self) -> list[ExcelHeader]: + return self._header_parser.extract_simple(self.header_df) + + def _extract_merged_header(self) -> list[ExcelHeader]: + return self._header_parser.extract_merged(self.header_df) + + def __setattr__(self, key: str, value: object): + if key == 'config' and hasattr(self, 'config'): + raise ValueError(msg(MessageKey.CONFIG_ALREADY_INITIALIZED, class_name=self.__class__.__name__)) + object.__setattr__(self, key, value) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}(config={self.config!r})' diff --git a/src/excelalchemy/core/executor.py b/src/excelalchemy/core/executor.py new file mode 100644 index 0000000..e175138 --- /dev/null +++ b/src/excelalchemy/core/executor.py @@ -0,0 +1,117 @@ +"""Import execution helpers for create, update, and upsert flows.""" + +from collections.abc import Callable + +from pydantic import BaseModel + +from excelalchemy._primitives.identity import RowIndex +from excelalchemy._primitives.payloads import DataConverter, DmlCallback, ImportContext, ModelRowPayload +from excelalchemy.config import ImporterConfig, ImportMode +from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.helper.pydantic import instantiate_pydantic_model +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg + +from .rows import ImportIssueTracker +from .table import WorksheetTable + + +class ImportExecutor[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateModelT: BaseModel]: + """Execute import-side DML while keeping validation and error mapping isolated.""" + + def __init__( + self, + config: ImporterConfig[ContextT, ImporterCreateModelT, ImporterUpdateModelT], + issue_tracker: ImportIssueTracker, + get_context: Callable[[], ImportContext[ContextT]], + ): + self.config = config + self.issue_tracker = issue_tracker + self.get_context = get_context + + async def execute(self, row_index: RowIndex, data: ModelRowPayload, df: WorksheetTable) -> bool: + """Dispatch one aggregated row to the configured import mode handler.""" + match self.config.import_mode: + case ImportMode.CREATE: + return await self._create(row_index, data, df) + case ImportMode.UPDATE: + return await self._update(row_index, data, df) + case ImportMode.CREATE_OR_UPDATE: + return await self._create_or_update(row_index, data, df) + raise ConfigError(msg(MessageKey.UNSUPPORTED_IMPORT_MODE, import_mode=self.config.import_mode)) + + async def _create(self, row_index: RowIndex, data: ModelRowPayload, df: WorksheetTable) -> bool: + if self.config.creator is None: + raise ConfigError(msg(MessageKey.CREATOR_NOT_CONFIGURED)) + if self.config.create_importer_model is None: + raise ConfigError(msg(MessageKey.CREATE_IMPORTER_MODEL_NOT_CONFIGURED)) + return await self._invoke_dml( + row_index, + data, + df, + self.config.create_importer_model, + self.config.creator, + self.config.data_converter, + self.config.exec_formatter, + ) + + async def _update(self, row_index: RowIndex, data: ModelRowPayload, df: WorksheetTable) -> bool: + if self.config.updater is None: + raise ConfigError(msg(MessageKey.UPDATER_NOT_CONFIGURED)) + if self.config.update_importer_model is None: + raise ConfigError(msg(MessageKey.UPDATE_IMPORTER_MODEL_NOT_CONFIGURED)) + return await self._invoke_dml( + row_index, + data, + df, + self.config.update_importer_model, + self.config.updater, + self.config.data_converter, + self.config.exec_formatter, + ) + + async def _create_or_update(self, row_index: RowIndex, data: ModelRowPayload, df: WorksheetTable) -> bool: + if self.config.is_data_exist is None: + raise ConfigError(msg(MessageKey.IS_DATA_EXIST_NOT_CONFIGURED)) + + converted_data = self.config.data_converter(dict(data)) if self.config.data_converter else data + is_data_exist = await self.config.is_data_exist(converted_data, self.get_context()) + if is_data_exist: + return await self._update(row_index, data, df) + return await self._create(row_index, data, df) + + async def _invoke_dml( + self, + row_index: RowIndex, + data: ModelRowPayload, + df: WorksheetTable, + importer_model: type[BaseModel], + dml_func: DmlCallback[ContextT], + data_converter: DataConverter | None, + exec_formatter: Callable[[Exception], str], + ) -> bool: + """Validate one row payload and call the user-supplied DML function.""" + importer_instance_or_errors = instantiate_pydantic_model(data, importer_model) + if isinstance(importer_instance_or_errors, list): + validation_errors = importer_instance_or_errors + cell_errors = [error for error in validation_errors if isinstance(error, ExcelCellError)] + self.issue_tracker.register_row_error(row_index, validation_errors) + if cell_errors: + self.issue_tracker.register_cell_errors(row_index, cell_errors, df) + return False + + converted_data = importer_instance_or_errors.model_dump(exclude_unset=True) + if data_converter is not None: + converted_data = data_converter(converted_data) + + try: + await dml_func(converted_data, self.get_context()) + except ExcelCellError as error: + self.issue_tracker.register_row_error(row_index, error) + self.issue_tracker.register_cell_errors(row_index, [error], df) + return False + except Exception as error: + self.issue_tracker.register_row_error(row_index, ExcelRowError(exec_formatter(error))) + return False + + return True diff --git a/src/excelalchemy/core/headers.py b/src/excelalchemy/core/headers.py new file mode 100644 index 0000000..a2e0f0c --- /dev/null +++ b/src/excelalchemy/core/headers.py @@ -0,0 +1,149 @@ +"""Header parsing and validation helpers for import workbooks.""" + +from collections.abc import Container, Sequence +from typing import cast + +from excelalchemy._primitives.header_models import ExcelHeader +from excelalchemy._primitives.identity import Label, UniqueLabel +from excelalchemy.config import ImportMode +from excelalchemy.core.table import WorksheetTable +from excelalchemy.exceptions import ConfigError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.results import ValidateHeaderResult +from excelalchemy.util.file import value_is_nan + +from .schema import ExcelSchemaLayout + + +class ExcelHeaderParser: + """Parse raw worksheet header rows into normalized header objects.""" + + def has_merged_header(self, header_df: WorksheetTable) -> bool: + """Detect whether the workbook uses a merged two-row header.""" + return any(value_is_nan(value) for value in header_df.iloc[0].tolist()) or any( + header_df.iloc[0].str.startswith('Unnamed') + ) + + def extract(self, header_df: WorksheetTable) -> list[ExcelHeader]: + """Parse either a simple header row or a merged header block.""" + if self.has_merged_header(header_df): + return self._extract_merged(header_df) + return self._extract_simple(header_df) + + def extract_simple(self, header_df: WorksheetTable) -> list[ExcelHeader]: + """Parse one simple header row without merged-header detection.""" + return self._extract_simple(header_df) + + def extract_merged(self, header_df: WorksheetTable) -> list[ExcelHeader]: + """Parse a two-row merged header block without auto-detection.""" + return self._extract_merged(header_df) + + def _extract_simple(self, header_df: WorksheetTable) -> list[ExcelHeader]: + return [ExcelHeader(label=Label(col), parent_label=Label(col)) for col in header_df.iloc[0].tolist()] + + def _extract_merged(self, header_df: WorksheetTable) -> list[ExcelHeader]: + headers: list[ExcelHeader] = [] + last_header: str | None = None + next_offset = 1 + + for column_index, value in header_df.iloc[0].items(): + parent_value = value + child_value = header_df.iloc[1][column_index] + if value_is_nan(parent_value) or (isinstance(parent_value, str) and parent_value.startswith('Unnamed')): + if value_is_nan(child_value): + raise ValueError(msg(MessageKey.INVALID_MERGED_HEADER_CHILD_EMPTY)) + current_header = ExcelHeader( + label=Label(child_value), + parent_label=Label(last_header), + offset=next_offset, + ) + next_offset += 1 + else: + if value_is_nan(child_value): + child_value = parent_value + current_header = ExcelHeader(label=Label(child_value), parent_label=Label(value)) + last_header, next_offset = str(value), 1 + headers.append(current_header) + + return headers + + def apply_columns( + self, + df: WorksheetTable, + headers: list[ExcelHeader], + allowed_labels: list[UniqueLabel], + ) -> WorksheetTable: + """Assign normalized unique labels as worksheet table columns.""" + columns: list[UniqueLabel] = [] + for header in headers: + if header.unique_label not in allowed_labels: + raise ConfigError(msg(MessageKey.UNSUPPORTED_COLUMN_NAME, unique_label=header.unique_label)) + columns.append(header.unique_label) + + df.columns = columns + return df + + +class ExcelHeaderValidator: + """Validate parsed headers against one schema layout.""" + + def validate( + self, + headers: list[ExcelHeader], + layout: ExcelSchemaLayout, + import_mode: ImportMode, + ) -> ValidateHeaderResult: + """Return the full header validation result consumed by the facade.""" + required_labels = [field_meta.unique_label for field_meta in layout.ordered_field_meta if field_meta.required] + primary_labels = [ + field_meta.unique_label for field_meta in layout.ordered_field_meta if field_meta.is_primary_key + ] + schema_labels = [field_meta.unique_label for field_meta in layout.ordered_field_meta] + input_labels = [header.unique_label for header in headers] + + visited: set[Label] = set() + duplicated: list[Label] = [] + for label in input_labels: + if label in visited: + duplicated.append(label) + else: + visited.add(label) + + schema_label_set = set(schema_labels) + input_label_set = set(input_labels) + unrecognized = [Label(label) for label in self._ordered_difference(input_labels, schema_label_set)] + + missing_primary: list[Label] = [] + if import_mode == ImportMode.UPDATE: + missing_primary = self._ordered_missing(primary_labels, input_label_set) + missing_required = self._ordered_missing(required_labels, input_label_set, excluded=set(missing_primary)) + + return ValidateHeaderResult( + unrecognized=unrecognized, + duplicated=duplicated, + missing_required=missing_required, + missing_primary=missing_primary, + is_valid=not (missing_required or unrecognized or duplicated or missing_primary), + ) + + @staticmethod + def _ordered_difference[T](values: Sequence[T], allowed: Container[T]) -> list[T]: + seen: set[Label] = set() + result: list[T] = [] + for value in values: + if value in allowed or value in seen: + continue + seen.add(cast(Label, value)) + result.append(value) + return result + + @staticmethod + def _ordered_missing[T]( + expected: Sequence[T], + actual: Container[T], + *, + excluded: Container[T] | None = None, + ) -> list[T]: + excluded = excluded or set() + return [value for value in expected if value not in actual and value not in excluded] diff --git a/src/excelalchemy/core/rendering.py b/src/excelalchemy/core/rendering.py new file mode 100644 index 0000000..86fbfb6 --- /dev/null +++ b/src/excelalchemy/core/rendering.py @@ -0,0 +1,35 @@ +"""High-level rendering helpers built on top of the low-level writer module.""" + +from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, RowIndex, UniqueLabel +from excelalchemy.core.table import WorksheetTable +from excelalchemy.core.writer import render_data_excel, render_merged_header_excel, render_simple_header_excel +from excelalchemy.exceptions import ExcelCellError +from excelalchemy.metadata import FieldMetaInfo + + +class ExcelRenderer: + """Render templates and result workbooks for the facade layer.""" + + def render_template( + self, df: WorksheetTable, field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], *, has_merged_header: bool + ) -> DataUrlStr: + """Render a template workbook with either a simple or merged header layout.""" + if has_merged_header: + return render_merged_header_excel(df, field_meta_mapping) + return render_simple_header_excel(df, field_meta_mapping) + + def render_data( + self, + df: WorksheetTable, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, + has_merged_header: bool, + errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]] | None = None, + ) -> DataUrlStr: + """Render a data workbook and optionally annotate cell-level import errors.""" + return render_data_excel( + df, + errors=errors or {}, + field_meta_mapping=field_meta_mapping, + has_merged_header=has_merged_header, + ) diff --git a/src/excelalchemy/core/rows.py b/src/excelalchemy/core/rows.py new file mode 100644 index 0000000..d5da170 --- /dev/null +++ b/src/excelalchemy/core/rows.py @@ -0,0 +1,144 @@ +"""Row aggregation and import issue tracking helpers.""" + +from collections import defaultdict +from collections.abc import Iterator +from typing import cast + +from excelalchemy._primitives.identity import ColumnIndex, Key, RowIndex, UniqueLabel +from excelalchemy._primitives.payloads import AggregatedRowPayload, ModelRowPayload, RowPayloadLike +from excelalchemy.config import ImportMode +from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo +from excelalchemy.results import ValidateRowResult +from excelalchemy.util.file import value_is_nan + +from .schema import ExcelSchemaLayout +from .table import WorksheetTable + + +class RowAggregator: + """Group flattened worksheet cells back into model-shaped payloads.""" + + def __init__(self, layout: ExcelSchemaLayout, import_mode: ImportMode): + self.layout = layout + self.import_mode = import_mode + + def aggregate(self, row_data: RowPayloadLike) -> ModelRowPayload: + """Aggregate one worksheet row into a serializer-ready payload.""" + return self._serialize(self._aggregate(row_data)) + + def _aggregate(self, row_data: RowPayloadLike) -> AggregatedRowPayload: + aggregated: AggregatedRowPayload = {} + for unique_label_raw, value in row_data.items(): + unique_label = UniqueLabel(unique_label_raw) + field_meta = self.layout.unique_label_to_field_meta[unique_label] + + if field_meta.key is None or field_meta.parent_key is None: + raise ConfigError( + msg(MessageKey.FIELD_META_RUNTIME_KEY_MISSING, field_meta_type=type(field_meta).__name__) + ) + + if value_is_nan(value): + if self.import_mode in {ImportMode.UPDATE, ImportMode.CREATE_OR_UPDATE}: + value = None + else: + continue + + if field_meta.parent_key == field_meta.key: + aggregated[str(field_meta.key)] = value + else: + parent_key = str(field_meta.parent_key) + child_key = str(field_meta.key) + nested = aggregated.setdefault(parent_key, {}) + if not isinstance(nested, dict): + raise TypeError(f'Expected nested payload mapping for {parent_key!r}, got {type(nested)}') + nested[child_key] = value + return aggregated + + def _serialize(self, aggregated: AggregatedRowPayload) -> ModelRowPayload: + serialized: ModelRowPayload = {} + for parent_key, value in aggregated.items(): + field_metas = self.layout.parent_key_to_field_metas[Key(parent_key)] + codec_field = field_metas[0] + if value is None: + serialized[parent_key] = None + else: + serialized[parent_key] = codec_field.excel_codec.parse_input(value, codec_field) + return serialized + + +class ImportIssueTracker: + """Keep row and cell level import issues in workbook coordinates.""" + + def __init__(self, layout: ExcelSchemaLayout, import_result_field_meta: list[FieldMetaInfo]): + self.layout = layout + self.import_result_field_meta = import_result_field_meta + self.cell_errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]] = {} + self.row_errors: dict[RowIndex, list[ExcelRowError | ExcelCellError]] = defaultdict(list) + + def register_row_error( + self, + row_index: RowIndex, + error: ExcelRowError | ExcelCellError | list[ExcelRowError | ExcelCellError] | list[ExcelCellError], + ) -> None: + """Record one row-level issue or a batch of issues for the same row.""" + if isinstance(error, list): + self.row_errors[row_index].extend(error) + else: + self.row_errors[row_index].append(error) + + def register_cell_errors(self, row_index: RowIndex, errors: list[ExcelCellError], df: WorksheetTable) -> None: + """Map cell errors from schema labels to rendered workbook coordinates.""" + for error in errors: + for index in self._column_indices(df, error.unique_label): + column_index = cast(ColumnIndex, index + len(self.import_result_field_meta)) + self.cell_errors.setdefault(row_index, {}).setdefault(column_index, []).append(error) + + def add_result_columns( + self, + df: WorksheetTable, + *, + result_unique_label: UniqueLabel, + reason_unique_label: UniqueLabel, + extra_header_count_on_import: int, + ) -> None: + """Insert result and reason columns into the rendered import result workbook.""" + result: list[str] = [] + reason: list[str] = [] + + for index in df.index[extra_header_count_on_import:]: + row_errors = self.row_errors.get(RowIndex(index)) + if not row_errors: + result.append(str(ValidateRowResult.SUCCESS)) + reason.append('') + continue + + result.append(str(ValidateRowResult.FAIL)) + numbered_reasons = [ + f'{idx}、{error!s}' for idx, error in enumerate(self.layout.order_errors(row_errors), start=1) + ] + reason.append('\n'.join(numbered_reasons)) + + if extra_header_count_on_import == 1: + result = [str(result_unique_label), *result] + reason = [str(reason_unique_label), *reason] + + df.insert(loc=0, column=reason_unique_label, value=reason) + df.insert(loc=0, column=result_unique_label, value=result) + + def _column_indices(self, df: WorksheetTable, unique_label: UniqueLabel) -> Iterator[ColumnIndex]: + if unique_label not in self.layout.unique_label_to_field_meta: + if unique_label not in self.layout.parent_label_to_field_metas: + raise ValueError(msg(MessageKey.FIELD_NOT_FOUND, unique_label=unique_label)) + + for field_meta in self.layout.parent_label_to_field_metas[unique_label]: + yield from self._single_column_index(df, field_meta.unique_label) + return + + yield from self._single_column_index(df, unique_label) + + @staticmethod + def _single_column_index(df: WorksheetTable, unique_label: UniqueLabel) -> Iterator[ColumnIndex]: + yield ColumnIndex(df.columns.get_loc(unique_label)) diff --git a/src/excelalchemy/core/schema.py b/src/excelalchemy/core/schema.py new file mode 100644 index 0000000..97c5018 --- /dev/null +++ b/src/excelalchemy/core/schema.py @@ -0,0 +1,138 @@ +"""Schema layout helpers used by the ExcelAlchemy facade.""" + +import itertools +from collections import defaultdict +from collections.abc import Iterable, Sequence +from decimal import Decimal +from itertools import chain +from typing import cast + +from pydantic import BaseModel + +from excelalchemy._primitives.constants import DEFAULT_FIELD_META_ORDER +from excelalchemy._primitives.identity import Key, Label, UniqueKey, UniqueLabel +from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.helper.pydantic import extract_pydantic_model +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo + + +class ExcelSchemaLayout: + """Capture the flattened Excel-facing layout derived from one model.""" + + def __init__(self, field_metas: list[FieldMetaInfo]): + self.field_metas = field_metas + self._check_field_meta_order(field_metas) + if not field_metas: + raise ConfigError(msg(MessageKey.NO_FIELD_METADATA_EXTRACTED)) + + self.ordered_field_meta = self._sort_field_meta(field_metas) + self.unique_label_to_field_meta: dict[UniqueLabel, FieldMetaInfo] = {} + self.parent_label_to_field_metas: dict[Label, list[FieldMetaInfo]] = {} + self.parent_key_to_field_metas: dict[Key, list[FieldMetaInfo]] = {} + self.unique_key_to_field_meta: dict[UniqueKey, FieldMetaInfo] = {} + self._build_indexes() + + @classmethod + def from_model(cls, model: type[BaseModel]) -> 'ExcelSchemaLayout': + """Build a layout from a model and validate its field ordering contract.""" + field_metas = extract_pydantic_model(model) + if not field_metas: + raise ConfigError(msg(MessageKey.NO_FIELD_METADATA_EXTRACTED_FROM_MODEL, model_name=model.__name__)) + return cls(field_metas) + + def _build_indexes(self) -> None: + for field_meta in self.ordered_field_meta: + if field_meta.parent_label is None: + raise ConfigError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) + if field_meta.parent_key is None: + raise ConfigError(msg(MessageKey.PARENT_KEY_EMPTY_RUNTIME)) + + self.parent_label_to_field_metas.setdefault(field_meta.parent_label, []).append(field_meta) + self.parent_key_to_field_metas.setdefault(field_meta.parent_key, []).append(field_meta) + self.unique_key_to_field_meta[field_meta.unique_key] = field_meta + self.unique_label_to_field_meta[field_meta.unique_label] = field_meta + + @staticmethod + def _check_field_meta_order(field_metas: list[FieldMetaInfo]) -> None: + order_to_field_meta: dict[int, set[Label]] = defaultdict(set) + for field_meta in field_metas: + assert field_meta.parent_label is not None + order_to_field_meta[field_meta.order].add(field_meta.parent_label) + duplicate_order = [v for k, v in order_to_field_meta.items() if len(v) > 1 and k != DEFAULT_FIELD_META_ORDER] + if duplicate_order: + raise ConfigError( + msg( + MessageKey.DUPLICATE_FIELD_ORDER_DEFINITIONS, + duplicate_order=list(itertools.chain.from_iterable(duplicate_order)), + ) + ) + + @classmethod + def _sort_field_meta(cls, field_metas: list[FieldMetaInfo]) -> list[FieldMetaInfo]: + orders: dict[Label, int] = {} + for idx, field_meta in enumerate(field_metas): + assert field_meta.parent_label is not None + if field_meta.order == DEFAULT_FIELD_META_ORDER: + orders[field_meta.parent_label] = idx + else: + orders[field_meta.parent_label] = field_meta.order + + return sorted( + field_metas, + key=lambda x: ( + orders.get(cast(Label, x.parent_label), Decimal('Infinity')), + x.offset, + ), + ) + + def has_merged_header(self, selected_keys: list[UniqueKey]) -> bool: + """Return whether the selected keys need a two-row merged header.""" + return any( + self.unique_key_to_field_meta[key].label != self.unique_key_to_field_meta[key].parent_label + for key in selected_keys + ) + + def get_output_parent_excel_headers(self, selected_keys: list[UniqueKey] | None = None) -> list[UniqueLabel]: + """Return the flattened header row used as worksheet table columns.""" + if not selected_keys: + return [field_meta.unique_label for field_meta in self.ordered_field_meta] + return [self.unique_key_to_field_meta[key].unique_label for key in selected_keys] + + def get_output_child_excel_headers(self, selected_keys: list[UniqueKey] | None = None) -> list[Label]: + """Return the child labels used in the second header row for merged exports.""" + if not selected_keys: + return [field_meta.label for field_meta in self.ordered_field_meta] + return [self.unique_key_to_field_meta[key].label for key in selected_keys] + + def select_output_excel_keys(self, keys: Sequence[str] | None = None) -> list[UniqueKey]: + """Expand parent keys into concrete flattened keys while preserving layout order.""" + if not keys: + return [field_meta.unique_key for field_meta in self.ordered_field_meta] + + selected_field_meta: list[FieldMetaInfo] = [] + for requested_key in keys: + unique_key = UniqueKey(requested_key) + parent_key = Key(requested_key) + if unique_key in self.unique_key_to_field_meta: + selected_field_meta.append(self.unique_key_to_field_meta[unique_key]) + elif parent_key in self.parent_key_to_field_metas: + selected_field_meta.extend(self.parent_key_to_field_metas[parent_key]) + else: + raise ValueError(msg(MessageKey.INVALID_KEY, **{'key': requested_key})) + + return [field_meta.unique_key for field_meta in self._sort_field_meta(selected_field_meta)] + + def order_errors(self, errors: list[ExcelRowError | ExcelCellError]) -> Iterable[ExcelCellError | ExcelRowError]: + """Sort cell errors by schema order and keep row-level errors at the end.""" + unique_label_to_index = {field_meta.unique_label: idx for idx, field_meta in enumerate(self.ordered_field_meta)} + row_errors: list[ExcelRowError] = [] + cell_errors: list[ExcelCellError] = [] + for error in errors: + if isinstance(error, ExcelRowError): + row_errors.append(error) + else: + cell_errors.append(error) + cell_errors.sort(key=lambda error: unique_label_to_index.get(error.unique_label, Decimal('Infinity'))) + return chain(cell_errors, row_errors) diff --git a/src/excelalchemy/core/storage.py b/src/excelalchemy/core/storage.py new file mode 100644 index 0000000..26c189d --- /dev/null +++ b/src/excelalchemy/core/storage.py @@ -0,0 +1,54 @@ +"""Storage factory for resolving ExcelAlchemy storage strategies.""" + +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +from excelalchemy.config import ExporterConfig, ImporterConfig +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.exceptions import ConfigError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg + +if TYPE_CHECKING: + from excelalchemy.core.storage_minio import MinioStorageGateway + + +class MissingStorageGateway(ExcelStorage): + """Fallback storage used when no concrete backend has been configured.""" + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str): + raise ConfigError(msg(MessageKey.NO_STORAGE_BACKEND_CONFIGURED)) + + def upload_excel(self, output_name: str, content_with_prefix: str): + raise ConfigError(msg(MessageKey.NO_STORAGE_BACKEND_CONFIGURED)) + + +def build_storage_gateway[ + ContextT, + ImporterCreateModelT: BaseModel, + ImporterUpdateModelT: BaseModel, + ExporterModelT: BaseModel, +]( + config: ImporterConfig[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | ExporterConfig[ExporterModelT], +) -> ExcelStorage: + """Build the default storage strategy for one ExcelAlchemy config.""" + storage = getattr(config, 'storage', None) + if storage is not None: + return storage + if getattr(config, 'minio', None) is not None: + from excelalchemy.core.storage_minio import MinioStorageGateway + + return MinioStorageGateway(config) + return MissingStorageGateway() + + +def __getattr__(name: str) -> Any: + if name == 'MinioStorageGateway': + from excelalchemy.core.storage_minio import MinioStorageGateway + + return MinioStorageGateway + raise AttributeError(name) + + +__all__ = ['ExcelStorage', 'MinioStorageGateway', 'MissingStorageGateway', 'build_storage_gateway'] diff --git a/src/excelalchemy/core/storage_minio.py b/src/excelalchemy/core/storage_minio.py new file mode 100644 index 0000000..61900b8 --- /dev/null +++ b/src/excelalchemy/core/storage_minio.py @@ -0,0 +1,127 @@ +"""Minio-backed Excel storage implementation.""" + +import base64 +import io +from datetime import timedelta +from typing import IO, BinaryIO, cast + +from minio import Minio +from openpyxl import load_workbook +from openpyxl.worksheet.worksheet import Worksheet +from pydantic import BaseModel +from urllib3.response import BaseHTTPResponse + +from excelalchemy._primitives.identity import UrlStr +from excelalchemy.config import ExporterConfig, ImporterConfig +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.core.table import WorksheetTable +from excelalchemy.exceptions import ConfigError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.util.file import remove_excel_prefix + + +class MinioStorageGateway(ExcelStorage): + """Excel storage strategy backed by a Minio-compatible object store.""" + + def __init__[ + ContextT, + ImporterCreateModelT: BaseModel, + ImporterUpdateModelT: BaseModel, + ExporterModelT: BaseModel, + ]( + self, + config: ImporterConfig[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | ExporterConfig[ExporterModelT], + ): + self.config = config + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + """Read one workbook object from Minio into a worksheet table.""" + if self.config.minio is None: + raise ConfigError(msg(MessageKey.MINIO_CLIENT_NOT_CONFIGURED)) + + file_object = self._read_file_object( + self.config.minio, + self.config.bucket_name, + input_excel_name, + ) + + try: + file_object.seek(0) + workbook = load_workbook(cast(BinaryIO, file_object), data_only=True) + try: + if sheet_name not in workbook.sheetnames: + raise ValueError(msg(MessageKey.WORKSHEET_NOT_FOUND, sheet_name=sheet_name)) + worksheet = workbook[sheet_name] + return self._worksheet_to_table(worksheet, skiprows=skiprows) + finally: + workbook.close() + finally: + cast(BinaryIO, file_object).close() + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + """Upload one rendered workbook and return its signed URL.""" + if self.config.minio is None: + raise ConfigError(msg(MessageKey.MINIO_CLIENT_NOT_CONFIGURED)) + url = self._upload_file_object( + self.config.minio, + self.config.bucket_name, + output_name, + remove_excel_prefix(content_with_prefix), + self.config.url_expires, + ) + return UrlStr(url) + + def read_excel_dataframe(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + """Backward-compatible alias for the worksheet-table reader.""" + return self.read_excel_table(input_excel_name, skiprows=skiprows, sheet_name=sheet_name) + + def _worksheet_to_table(self, worksheet: Worksheet, *, skiprows: int) -> WorksheetTable: + rows = [ + [self._normalize_cell_value(value) for value in row] + for row in worksheet.iter_rows( + min_row=skiprows + 1, + max_row=worksheet.max_row, + max_col=worksheet.max_column, + values_only=True, + ) + ] + + while rows and all(value is None for value in rows[-1]): + rows.pop() + + return WorksheetTable(rows=rows) + + @staticmethod + def _normalize_cell_value(value: object) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + return str(value) + + @staticmethod + def _construct_file_like_object(response: BaseHTTPResponse) -> IO[bytes]: + """Construct a file-like object from an object storage response.""" + return io.BytesIO(response.read()) + + @classmethod + def _read_file_object(cls, client: Minio, bucket_name: str, filename: str) -> IO[bytes]: + response: BaseHTTPResponse = client.get_object(bucket_name, filename) + return cls._construct_file_like_object(response) + + @staticmethod + def _upload_file_object( + client: Minio, + bucket_name: str, + filename: str, + content: str, + expires: int, + ) -> str: + data = base64.b64decode(content) + client.put_object(bucket_name, filename, io.BytesIO(data), len(data)) + return client.presigned_get_object( + bucket_name, + filename, + expires=timedelta(seconds=expires), + ) diff --git a/src/excelalchemy/core/storage_protocol.py b/src/excelalchemy/core/storage_protocol.py new file mode 100644 index 0000000..c258900 --- /dev/null +++ b/src/excelalchemy/core/storage_protocol.py @@ -0,0 +1,19 @@ +"""Storage protocol for reading and uploading Excel workbooks.""" + +from typing import Protocol, runtime_checkable + +from excelalchemy._primitives.identity import UrlStr +from excelalchemy.core.table import WorksheetTable + + +@runtime_checkable +class ExcelStorage(Protocol): + """Minimal workbook storage contract used by ExcelAlchemy.""" + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + """Read one workbook object into a worksheet table.""" + ... + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + """Upload one rendered workbook and return its URL.""" + ... diff --git a/src/excelalchemy/core/table.py b/src/excelalchemy/core/table.py new file mode 100644 index 0000000..5b4bd09 --- /dev/null +++ b/src/excelalchemy/core/table.py @@ -0,0 +1,174 @@ +"""Lightweight worksheet table abstraction used by the core import/export flow.""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping, Sequence +from dataclasses import dataclass +from typing import cast, overload + +type WorksheetValue = object +type WorksheetColumn = object + + +@dataclass(frozen=True) +class _WorksheetStringAccessor: + values: list[WorksheetValue] + + def startswith(self, prefix: str) -> list[bool]: + return [isinstance(value, str) and value.startswith(prefix) for value in self.values] + + +class WorksheetColumns(list[WorksheetColumn]): + """List-like column container with the small API surface used by the core layer.""" + + def get_loc(self, value: WorksheetColumn) -> int: + try: + return self.index(value) + except ValueError as exc: + raise KeyError(value) from exc + + +class WorksheetRow: + """Row view used by header parsing and row iteration.""" + + def __init__(self, index: Iterable[WorksheetColumn], values: list[WorksheetValue]): + self._index = list(index) + self._values = values + + @property + def str(self) -> _WorksheetStringAccessor: + return _WorksheetStringAccessor(self._values) + + def items(self) -> Iterator[tuple[WorksheetColumn, WorksheetValue]]: + return iter(zip(self._index, self._values, strict=True)) + + def tolist(self) -> list[WorksheetValue]: + return list(self._values) + + def to_dict(self) -> dict[WorksheetColumn, WorksheetValue]: + return dict(zip(self._index, self._values, strict=True)) + + def __getitem__(self, key: int | WorksheetColumn) -> WorksheetValue: + if isinstance(key, int): + return self._values[key] + return self.to_dict()[key] + + +class _WorksheetILoc: + def __init__(self, table: WorksheetTable): + self._table = table + + @overload + def __getitem__(self, key: tuple[int, int]) -> WorksheetValue: ... + + @overload + def __getitem__(self, key: slice) -> WorksheetTable: ... + + @overload + def __getitem__(self, key: int) -> WorksheetRow: ... + + def __getitem__(self, key: slice | int | tuple[int, int]) -> WorksheetTable | WorksheetRow | WorksheetValue: + if isinstance(key, tuple): + row_index, column_index = key + return self._table.cell_at(row_index, column_index) + if isinstance(key, slice): + return self._table.slice_rows(key) + return self._table.row_at(key) + + +class WorksheetTable: + """A minimal internal 2D table API for ExcelAlchemy's workbook pipeline. + + It intentionally implements only the small subset of table operations that the + core import/export flow needs. It is not intended to behave like a pandas + drop-in replacement. + """ + + def __init__( + self, + columns: Iterable[WorksheetColumn] | None = None, + rows: Iterable[Iterable[WorksheetValue] | Mapping[WorksheetColumn, WorksheetValue]] | None = None, + ): + self._columns = WorksheetColumns(list(columns or [])) + self._rows = [self._normalize_row(row) for row in (rows or [])] + + def _normalize_row( + self, + row: Iterable[WorksheetValue] | Mapping[WorksheetColumn, WorksheetValue], + ) -> list[WorksheetValue]: + if isinstance(row, Mapping): + if not self._columns: + self._columns = WorksheetColumns(list(row.keys())) + row_mapping = cast(Mapping[WorksheetColumn, WorksheetValue], row) + return [row_mapping.get(column, None) for column in self._columns] + + values = list(row) + if not self._columns: + self._columns = WorksheetColumns(list(range(len(values)))) + + if len(values) < len(self._columns): + return values + [None] * (len(self._columns) - len(values)) + if len(values) > len(self._columns): + return values[: len(self._columns)] + return values + + @property + def columns(self) -> WorksheetColumns: + return self._columns + + @columns.setter + def columns(self, value: Iterable[WorksheetColumn]) -> None: + self._columns = WorksheetColumns(list(value)) + + @property + def iloc(self) -> _WorksheetILoc: + return _WorksheetILoc(self) + + @property + def shape(self) -> tuple[int, int]: + return len(self._rows), len(self._columns) + + @property + def index(self) -> range: + return range(len(self._rows)) + + def head(self, count: int) -> WorksheetTable: + return WorksheetTable(columns=self.columns, rows=self._rows[:count]) + + def row_at(self, row_index: int) -> WorksheetRow: + return WorksheetRow(self.columns, self._rows[row_index]) + + def cell_at(self, row_index: int, column_index: int) -> WorksheetValue: + return self._rows[row_index][column_index] + + def slice_rows(self, row_slice: slice) -> WorksheetTable: + return WorksheetTable(columns=self.columns, rows=self._rows[row_slice]) + + def reset_index(self, *, drop: bool = False) -> WorksheetTable: + """Return the same rows with a fresh positional index. + + The method only supports ``drop=True`` because ``WorksheetTable`` keeps a + simple implicit positional index and does not model pandas-style index + columns. + """ + if not drop: + raise NotImplementedError('WorksheetTable only supports reset_index(drop=True)') + return WorksheetTable(columns=self.columns, rows=self._rows) + + def iterrows(self) -> Iterator[tuple[int, WorksheetRow]]: + for row_index, row in enumerate(self._rows): + yield row_index, WorksheetRow(self.columns, row) + + def with_prepended_rows( + self, + rows: Iterable[Iterable[WorksheetValue] | Mapping[WorksheetColumn, WorksheetValue]], + ) -> WorksheetTable: + return WorksheetTable(columns=self.columns, rows=[*rows, *self._rows]) + + def insert(self, *, loc: int, column: WorksheetColumn, value: Sequence[WorksheetValue]) -> None: + self._columns.insert(loc, column) + for row, cell_value in zip(self._rows, value, strict=True): + row.insert(loc, cell_value) + + def __len__(self) -> int: + return len(self._rows) diff --git a/src/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py new file mode 100644 index 0000000..f9e3e41 --- /dev/null +++ b/src/excelalchemy/core/writer.py @@ -0,0 +1,443 @@ +"""Render Excel workbooks with openpyxl only.""" + +import base64 +import io +from collections import defaultdict +from math import ceil +from typing import Any, BinaryIO, cast + +from openpyxl import Workbook +from openpyxl.cell.cell import Cell +from openpyxl.comments import Comment +from openpyxl.styles import Alignment, Font, PatternFill, numbers +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.worksheet import Worksheet + +from excelalchemy._primitives.constants import ( + BACKGROUND_ERROR_COLOR, + BACKGROUND_REQUIRED_COLOR, + CHARACTER_WIDTH, + DEFAULT_SHEET_NAME, + FONT_READ_COLOR, +) +from excelalchemy._primitives.identity import ColumnIndex, DataUrlStr, Label, RowIndex, UniqueLabel +from excelalchemy.core.table import WorksheetTable +from excelalchemy.exceptions import ExcelCellError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo +from excelalchemy.results import ValidateRowResult +from excelalchemy.util.file import add_excel_prefix, value_is_nan + +OPENPYXL_EXCEL_INDEX_START_AT = 1 + +HEADER_HINT_ROW_INDEX = 1 +HEADER_HINT_COL_INDEX = 1 +HEADER_HINT_LINE_COUNT = 1 + +SIMPLE_HEADER_ROW_COUNT = 1 +MERGE_HEADER_ROW_COUNT = 2 + + +def _get_file(file: BinaryIO | None = None) -> BinaryIO: + """Return the writable buffer used to build the workbook payload.""" + return cast(BinaryIO, file or io.BytesIO()) + + +def _create_workbook(sheet_name: str) -> tuple[Workbook, Worksheet]: + workbook = Workbook() + worksheet = workbook.active + assert worksheet is not None + worksheet.title = sheet_name + return workbook, worksheet + + +def _worksheet_cell(worksheet: Worksheet, *, row: int, column: int) -> Cell: + return cast(Cell, worksheet.cell(row=row, column=column)) + + +def _encode_workbook(workbook: Workbook, file: BinaryIO, *, close_file: bool) -> DataUrlStr: + workbook.save(file) + file.seek(0) + content = base64.b64encode(file.read()).decode() + if close_file: + file.close() + return DataUrlStr(add_excel_prefix(content)) + + +def _build_comment(field_meta: FieldMetaInfo) -> Comment | None: + comment_text = field_meta.excel_codec.build_comment(field_meta) + if not comment_text: + return None + + return Comment( + text=comment_text, + author='https://github.com/RayCarterLab/ExcelAlchemy', + height=sum(ceil(len(line) / 20) for line in comment_text.splitlines()) * 28, + width=300, + ) + + +def _style_header_cell(cell: Cell, field_meta: FieldMetaInfo) -> None: + comment = _build_comment(field_meta) + if comment is not None: + cell.comment = comment + if field_meta.required: + cell.fill = PatternFill(start_color=BACKGROUND_REQUIRED_COLOR, fill_type='solid') + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + cell.number_format = numbers.FORMAT_TEXT + + +def _style_child_header_cell(cell: Cell) -> None: + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + cell.number_format = numbers.FORMAT_TEXT + + +def _write_header_hint(worksheet: Worksheet, *, column_count: int) -> None: + cell = _worksheet_cell(worksheet, row=HEADER_HINT_ROW_INDEX, column=HEADER_HINT_COL_INDEX) + cell.value = dmsg(MessageKey.HEADER_HINT) + cell.font = Font(size=16) + cell.alignment = Alignment(wrap_text=True) + worksheet.merge_cells( + start_row=HEADER_HINT_ROW_INDEX, + start_column=HEADER_HINT_COL_INDEX, + end_row=HEADER_HINT_ROW_INDEX, + end_column=max(column_count, HEADER_HINT_COL_INDEX), + ) + worksheet.row_dimensions[HEADER_HINT_ROW_INDEX].height = 120 + + +def _write_simple_header( + worksheet: Worksheet, + df: WorksheetTable, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, + column_write_offset: int = 0, + row_write_offset: int = 0, +) -> None: + header_row_index = row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT + + for openpyxl_col_index, column in enumerate( + df.columns[column_write_offset:], + start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, + ): + field_meta = field_meta_mapping[cast(UniqueLabel, column)] + cell = _worksheet_cell(worksheet, row=header_row_index, column=openpyxl_col_index) + cell.value = str(column) + _style_header_cell(cell, field_meta) + + +def _write_vertically_merged_header( + worksheet: Worksheet, + df: WorksheetTable, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, + start_row: int, + column_write_offset: int, +) -> None: + for openpyxl_col_index, column in enumerate( + df.columns[column_write_offset:], + start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, + ): + field_meta = field_meta_mapping[cast(UniqueLabel, column)] + if field_meta.label == field_meta.parent_label: + worksheet.merge_cells( + start_row=start_row, + start_column=openpyxl_col_index, + end_row=start_row + 1, + end_column=openpyxl_col_index, + ) + + +def _write_horizontally_merged_header( + worksheet: Worksheet, + df: WorksheetTable, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, + start_row: int, + column_write_offset: int, +) -> None: + counter: dict[Label, int] = defaultdict(int) + for field_meta in field_meta_mapping.values(): + if field_meta.parent_label is None: + raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) + counter[field_meta.parent_label] += 1 + + for openpyxl_col_index, column in enumerate( + df.columns[column_write_offset:], + start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, + ): + field_meta = field_meta_mapping[cast(UniqueLabel, column)] + if field_meta.parent_label is None: + raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) + if field_meta.label != field_meta.parent_label and field_meta.offset == 0: + cell = _worksheet_cell(worksheet, row=start_row, column=openpyxl_col_index) + cell.value = str(field_meta.parent_label) + cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + worksheet.merge_cells( + start_row=start_row, + start_column=openpyxl_col_index, + end_row=start_row, + end_column=openpyxl_col_index + counter[field_meta.parent_label] - 1, + ) + + +def _write_merged_header( + worksheet: Worksheet, + df: WorksheetTable, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, + column_write_offset: int = 0, + row_write_offset: int = 0, +) -> None: + _write_simple_header( + worksheet, + df, + field_meta_mapping, + column_write_offset=column_write_offset, + row_write_offset=row_write_offset, + ) + + child_row_index = row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT + 1 + child_headers = df.iloc[0].tolist() + for column_index, child_value in enumerate(child_headers, start=OPENPYXL_EXCEL_INDEX_START_AT): + cell = _worksheet_cell(worksheet, row=child_row_index, column=column_index + column_write_offset) + cell.value = str(child_value) + _style_child_header_cell(cell) + + start_row = row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT + _write_vertically_merged_header( + worksheet, + df, + field_meta_mapping, + start_row=start_row, + column_write_offset=column_write_offset, + ) + _write_horizontally_merged_header( + worksheet, + df, + field_meta_mapping, + start_row=start_row, + column_write_offset=column_write_offset, + ) + + +def _get_parsed_value( + df: WorksheetTable, + row_index: int, + col_index: int, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], +) -> str: + cell_value: str | Any | None = df.iloc[row_index, col_index] + + if value_is_nan(cell_value): + return '' + + col_label = cast(UniqueLabel, df.columns[col_index]) + if col_label not in field_meta_mapping: + return str(cell_value) + + field_meta = field_meta_mapping[col_label] + parsed_value = field_meta.excel_codec.format_display_value(cell_value, field_meta) + return str(parsed_value) + + +def _mark_error( + worksheet: Worksheet, + errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]], + *, + column_write_offset: int, + row_write_offset: int, +) -> None: + for row_index, cols in errors.items(): + for col_index, exceptions in cols.items(): + if not exceptions: + continue + + openpyxl_col_index = col_index + column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT + openpyxl_row_index = row_index + row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT + cell = _worksheet_cell(worksheet, row=openpyxl_row_index, column=openpyxl_col_index) + cell.fill = PatternFill( + start_color=BACKGROUND_ERROR_COLOR, + end_color=BACKGROUND_ERROR_COLOR, + fill_type='solid', + ) + cell.alignment = Alignment(wrap_text=True) + + +def _write_value( + df: WorksheetTable, + worksheet: Worksheet, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, + table_data_start_index: int, + column_write_offset: int, + row_write_offset: int, +) -> None: + col_width_mapping: dict[ColumnIndex, float] = defaultdict(float) + + for row_index in range(table_data_start_index, df.shape[0]): + for column_index in range(df.shape[1]): + openpyxl_col_index = column_index + column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT + openpyxl_row_index = row_index + row_write_offset + OPENPYXL_EXCEL_INDEX_START_AT + + cell = _worksheet_cell(worksheet, row=openpyxl_row_index, column=openpyxl_col_index) + cell.value = _get_parsed_value(df, row_index, column_index, field_meta_mapping) + cell.number_format = numbers.FORMAT_TEXT + cell.alignment = Alignment(horizontal='left', vertical='center', wrap_text=True) + + if dmsg(MessageKey.RESULT_COLUMN_LABEL) == df.columns[column_index] and cell.value == str( + ValidateRowResult.FAIL + ): + cell.font = Font(color=FONT_READ_COLOR) + + col_width_mapping[ColumnIndex(openpyxl_col_index)] = max( + col_width_mapping[ColumnIndex(openpyxl_col_index)], + max(len(str(part)) for part in str(cell.value).split('\n')), + len(str(df.columns[column_index])), + ) + + for openpyxl_col_index, width in col_width_mapping.items(): + worksheet.column_dimensions[get_column_letter(openpyxl_col_index)].width = round( + (width + 4) * CHARACTER_WIDTH, 2 + ) + + +def _write_value_mark_error( + worksheet: Worksheet, + df: WorksheetTable, + errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]], + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, + row_write_offset: int = 0, + column_write_offset: int = 0, + table_data_start_index: int = 0, +) -> None: + _mark_error( + worksheet, + errors, + column_write_offset=column_write_offset, + row_write_offset=row_write_offset, + ) + _write_value( + df, + worksheet, + field_meta_mapping, + table_data_start_index=table_data_start_index, + row_write_offset=row_write_offset, + column_write_offset=column_write_offset, + ) + + +def render_simple_header_excel( + df: WorksheetTable, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + sheet_name: str = DEFAULT_SHEET_NAME, + file: BinaryIO | None = None, + close_file: bool = True, + column_write_offset: int = 0, +) -> DataUrlStr: + if file is None: + close_file = True + + tmp = _get_file(file) + workbook, worksheet = _create_workbook(sheet_name) + _write_header_hint(worksheet, column_count=len(df.columns)) + _write_simple_header( + worksheet, + df, + field_meta_mapping, + column_write_offset=column_write_offset, + row_write_offset=HEADER_HINT_LINE_COUNT, + ) + _write_value( + df, + worksheet, + field_meta_mapping, + table_data_start_index=0, + column_write_offset=column_write_offset, + row_write_offset=HEADER_HINT_LINE_COUNT + SIMPLE_HEADER_ROW_COUNT, + ) + + return _encode_workbook(workbook, tmp, close_file=close_file) + + +def render_merged_header_excel( + df: WorksheetTable, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + sheet_name: str = DEFAULT_SHEET_NAME, + file: BinaryIO | None = None, + close_file: bool = True, + column_write_offset: int = 0, +) -> DataUrlStr: + if file is None: + close_file = True + + tmp = _get_file(file) + workbook, worksheet = _create_workbook(sheet_name) + _write_header_hint(worksheet, column_count=len(df.columns)) + _write_merged_header( + worksheet, + df, + field_meta_mapping, + column_write_offset=column_write_offset, + row_write_offset=HEADER_HINT_LINE_COUNT, + ) + _write_value( + df, + worksheet, + field_meta_mapping, + table_data_start_index=1, + column_write_offset=column_write_offset, + row_write_offset=HEADER_HINT_LINE_COUNT + SIMPLE_HEADER_ROW_COUNT, + ) + + return _encode_workbook(workbook, tmp, close_file=close_file) + + +def render_data_excel( + df: WorksheetTable, + errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]], + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + sheet_name: str = DEFAULT_SHEET_NAME, + file: BinaryIO | None = None, + close_file: bool = True, + has_merged_header: bool = False, +) -> DataUrlStr: + if file is None: + close_file = True + + tmp = _get_file(file) + workbook, worksheet = _create_workbook(sheet_name) + _write_header_hint(worksheet, column_count=len(df.columns)) + + if has_merged_header: + table_data_start_index = 1 + _write_merged_header( + worksheet, + df, + field_meta_mapping, + row_write_offset=HEADER_HINT_LINE_COUNT, + ) + else: + table_data_start_index = 0 + _write_simple_header( + worksheet, + df, + field_meta_mapping, + row_write_offset=HEADER_HINT_LINE_COUNT, + ) + + _write_value_mark_error( + worksheet, + df, + errors, + field_meta_mapping, + row_write_offset=HEADER_HINT_LINE_COUNT + SIMPLE_HEADER_ROW_COUNT, + table_data_start_index=table_data_start_index, + ) + + return _encode_workbook(workbook, tmp, close_file=close_file) diff --git a/src/excelalchemy/exc.py b/src/excelalchemy/exc.py new file mode 100644 index 0000000..dc49e19 --- /dev/null +++ b/src/excelalchemy/exc.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.exc``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.exc', 'excelalchemy.exceptions') + +from excelalchemy.exceptions import * # noqa: F403 diff --git a/excelalchemy/exc.py b/src/excelalchemy/exceptions.py similarity index 66% rename from excelalchemy/exc.py rename to src/excelalchemy/exceptions.py index cc067b7..741a2ff 100644 --- a/excelalchemy/exc.py +++ b/src/excelalchemy/exceptions.py @@ -1,14 +1,17 @@ +"""Public exception types raised by ExcelAlchemy.""" + from typing import Any -from excelalchemy.const import UNIQUE_HEADER_CONNECTOR -from excelalchemy.types.identity import Label -from excelalchemy.types.identity import UniqueLabel +from excelalchemy._primitives.constants import UNIQUE_HEADER_CONNECTOR +from excelalchemy._primitives.identity import Label, UniqueLabel +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg class ExcelCellError(Exception): - """Excel 单元格错误""" + """Cell-level import error tied to a specific workbook header.""" - message = '导入 Excel 发生错误' + message = msg(MessageKey.EXCEL_IMPORT_ERROR) label: Label parent_label: Label | None detail: dict[str, Any] @@ -30,7 +33,7 @@ def __init__( def __str__(self) -> str: return f'【{self.label}】{self.message}' - def __repr__(self): + def __repr__(self) -> str: return f"{type(self).__name__}(label=Label('{self.label}'), message='{self.message}')" def __eq__(self, other: object) -> bool: @@ -49,13 +52,13 @@ def unique_label(self) -> UniqueLabel: def _validate(self) -> None: if not self.label: - raise ValueError('label 不能为空') + raise ValueError(msg(MessageKey.LABEL_CANNOT_BE_EMPTY)) class ExcelRowError(Exception): - """Excel 整行发生导入错误""" + """Row-level import error not tied to a single workbook cell.""" - message = '导入 Excel 发生行错误' + message = msg(MessageKey.EXCEL_ROW_IMPORT_ERROR) def __init__( self, @@ -66,16 +69,14 @@ def __init__( self.message = message or self.message self.detail = kwargs or {} - def __str__(self): + def __str__(self) -> str: return self.message - def __repr__(self): + def __repr__(self) -> str: return f"{type(self).__name__}(message='{self.message}')" -class ProgrammaticError(Exception): - ... +class ProgrammaticError(Exception): ... -class ConfigError(Exception): - ... +class ConfigError(Exception): ... diff --git a/src/excelalchemy/header_models.py b/src/excelalchemy/header_models.py new file mode 100644 index 0000000..aaf239e --- /dev/null +++ b/src/excelalchemy/header_models.py @@ -0,0 +1,10 @@ +"""Compatibility shim for ``excelalchemy.header_models``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import( + 'excelalchemy.header_models', + 'ExcelAlchemy internals only; avoid importing header models directly', +) + +from excelalchemy._primitives.header_models import * # noqa: F403 diff --git a/excelalchemy/helper/__init__.py b/src/excelalchemy/helper/__init__.py similarity index 100% rename from excelalchemy/helper/__init__.py rename to src/excelalchemy/helper/__init__.py diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py new file mode 100644 index 0000000..fdc10e1 --- /dev/null +++ b/src/excelalchemy/helper/pydantic.py @@ -0,0 +1,248 @@ +from collections.abc import Generator, Iterable, Mapping +from dataclasses import dataclass +from types import UnionType +from typing import Any, Union, cast, get_args, get_origin + +from pydantic import BaseModel, ValidationError +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined + +from excelalchemy._primitives.identity import Key +from excelalchemy.codecs.base import CompositeExcelFieldCodec, ExcelFieldCodec +from excelalchemy.exceptions import ExcelCellError, ExcelRowError, ProgrammaticError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg +from excelalchemy.metadata import FieldMetaInfo, extract_declared_field_metadata + + +@dataclass(frozen=True) +class PydanticFieldAdapter: + """Provide a stable view over one Pydantic field.""" + + name: str + raw_field: FieldInfo + + @property + def annotation(self) -> Any: + return self.raw_field.annotation + + @property + def excel_codec(self) -> type[Any]: + annotation = self.annotation + origin = get_origin(annotation) + if origin in (UnionType, Union): + args = [arg for arg in get_args(annotation) if arg is not type(None)] + if len(args) != 1: + raise ProgrammaticError(msg(MessageKey.UNSUPPORTED_FIELD_TYPE_DECLARATION, annotation=annotation)) + return cast(type[Any], args[0]) + + return cast(type[Any], annotation) + + @property + def value_type(self) -> type[Any]: + """Backward-compatible alias for excel_codec.""" + return self.excel_codec + + @property + def allows_none(self) -> bool: + return any(arg is type(None) for arg in get_args(self.annotation)) + + @property + def required(self) -> bool: + declared = self.declared_metadata + + if declared.is_primary_key or declared.unique: + return True + if declared.required is not None: + return declared.required + if self.raw_field.default is not PydanticUndefined or self.raw_field.default_factory is not None: + return False + return not self.allows_none + + @property + def declared_metadata(self) -> FieldMetaInfo: + return extract_declared_field_metadata(self.raw_field) + + def runtime_metadata(self) -> FieldMetaInfo: + declared = self.declared_metadata + return declared.bind_runtime( + required=self.required, + excel_codec=cast(type[ExcelFieldCodec], self.excel_codec), + parent_label=declared.label, + parent_key=Key(self.name), + key=Key(self.name), + offset=0, + ) + + def validate_value(self, raw_value: Any) -> Any: + if raw_value is None: + if self.allows_none and not self.required: + return None + raise ValueError(msg(MessageKey.THIS_FIELD_IS_REQUIRED)) + + return self.excel_codec.normalize_import_value(raw_value, self.declared_metadata) + + +@dataclass(frozen=True) +class PydanticModelAdapter: + """Expose a small, version-friendly API over a Pydantic model class.""" + + model: type[BaseModel] + + def fields(self) -> Iterable[PydanticFieldAdapter]: + return ( + PydanticFieldAdapter(name=name, raw_field=field_info) + for name, field_info in self.model.model_fields.items() + ) + + def field(self, name: str) -> PydanticFieldAdapter: + return PydanticFieldAdapter(name=name, raw_field=self.model.model_fields[name]) + + def field_names(self) -> list[str]: + return list(self.model.model_fields.keys()) + + +def extract_pydantic_model( + model: type[BaseModel] | None, +) -> list[FieldMetaInfo]: + """Extract Excel field metadata from a Pydantic model declaration.""" + if model is None: + raise RuntimeError(msg(MessageKey.MODEL_CANNOT_BE_NONE)) + return list(_extract_pydantic_model(PydanticModelAdapter(model))) + + +def get_model_field_names(model: type[BaseModel]) -> list[str]: + return PydanticModelAdapter(model).field_names() + + +def instantiate_pydantic_model[ModelT: BaseModel]( + data: Mapping[str, Any], + model: type[ModelT], +) -> ModelT | list[ExcelCellError | ExcelRowError]: + """Instantiate a Pydantic model and return mapped Excel errors when validation fails.""" + model_adapter = PydanticModelAdapter(model) + normalized_data: dict[str, Any] = {} + errors: list[ExcelCellError | ExcelRowError] = [] + failed_fields: set[str] = set() + + for field_adapter in model_adapter.fields(): + raw_value = data.get(field_adapter.name, PydanticUndefined) + if raw_value is PydanticUndefined: + continue + + try: + normalized_data[field_adapter.name] = field_adapter.validate_value(raw_value) + except ProgrammaticError: + raise + except Exception as exc: + failed_fields.add(field_adapter.name) + _handle_error(errors, exc, field_adapter.declared_metadata) + + model_instance_or_errors = _model_validate(normalized_data, model, model_adapter, failed_fields) + if isinstance(model_instance_or_errors, list): + return [*errors, *model_instance_or_errors] + + if errors: + return errors + + return model_instance_or_errors + + +def _extract_pydantic_model(model: PydanticModelAdapter) -> Generator[FieldMetaInfo, None, None]: + for field_adapter in model.fields(): + declared_metadata = field_adapter.declared_metadata + excel_codec = field_adapter.excel_codec + + if issubclass(excel_codec, CompositeExcelFieldCodec): + for offset, (key, sub_field_info) in enumerate(excel_codec.column_items()): + inherited = sub_field_info.inherited_from(declared_metadata) + yield inherited.bind_runtime( + required=field_adapter.required, + excel_codec=cast(type[ExcelFieldCodec], excel_codec), + parent_label=declared_metadata.label, + parent_key=Key(field_adapter.name), + key=key, + offset=offset, + ) + + elif issubclass(excel_codec, ExcelFieldCodec): + yield field_adapter.runtime_metadata() + + else: + raise ProgrammaticError(msg(MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED, value_type=excel_codec)) + + +def _handle_error( + error_container: list[ExcelCellError | ExcelRowError], + exc: Exception, + field_def: FieldMetaInfo, +) -> None: + messages = [str(arg) for arg in exc.args if str(arg)] or [str(exc) or msg(MessageKey.INVALID_INPUT)] + error_container.extend( + ExcelCellError( + label=field_def.label, + message=message, + ) + for message in messages + ) + + +def _model_validate[ModelT: BaseModel]( + data: dict[str, Any], + model: type[ModelT], + model_adapter: PydanticModelAdapter, + failed_fields: set[str], +) -> ModelT | list[ExcelCellError | ExcelRowError]: + try: + return model.model_validate(data) + except ValidationError as exc: + return _map_validation_error(exc, model_adapter, failed_fields) + + +def _map_validation_error( + exc: ValidationError, + model_adapter: PydanticModelAdapter, + failed_fields: set[str], +) -> list[ExcelCellError | ExcelRowError]: + mapped: list[ExcelCellError | ExcelRowError] = [] + for error in exc.errors(): + loc = error.get('loc', ()) + if not loc: + mapped.append(ExcelRowError(str(error['msg']))) + continue + + field_name = loc[0] + if not isinstance(field_name, str): + mapped.append(ExcelRowError(str(error['msg']))) + continue + if field_name in failed_fields: + continue + + field_adapter = model_adapter.field(field_name) + message = str(error['msg']) + if len(loc) > 1 and isinstance(loc[1], str): + mapped.append(_nested_excel_error(field_adapter, loc[1], message)) + continue + + mapped.append(ExcelCellError(label=field_adapter.declared_metadata.label, message=message)) + + return mapped + + +def _nested_excel_error( + field_adapter: PydanticFieldAdapter, + child_key: str, + message: str, +) -> ExcelCellError: + declared_metadata = field_adapter.declared_metadata + excel_codec = field_adapter.excel_codec + if issubclass(excel_codec, CompositeExcelFieldCodec): + for key, sub_field_info in excel_codec.column_items(): + if key == child_key: + return ExcelCellError( + label=sub_field_info.label, + parent_label=declared_metadata.label, + message=message, + ) + + return ExcelCellError(label=declared_metadata.label, message=message) diff --git a/src/excelalchemy/i18n/__init__.py b/src/excelalchemy/i18n/__init__.py new file mode 100644 index 0000000..a229bb6 --- /dev/null +++ b/src/excelalchemy/i18n/__init__.py @@ -0,0 +1,23 @@ +from excelalchemy.i18n.messages import ( + DEFAULT_LOCALE, + DISPLAY_DEFAULT_LOCALE, + SUPPORTED_DISPLAY_LOCALES, + SUPPORTED_RUNTIME_LOCALES, + MessageKey, + display_message, + get_display_locale, + message, + use_display_locale, +) + +__all__ = [ + 'DEFAULT_LOCALE', + 'DISPLAY_DEFAULT_LOCALE', + 'SUPPORTED_DISPLAY_LOCALES', + 'SUPPORTED_RUNTIME_LOCALES', + 'MessageKey', + 'display_message', + 'get_display_locale', + 'message', + 'use_display_locale', +] diff --git a/src/excelalchemy/i18n/messages.py b/src/excelalchemy/i18n/messages.py new file mode 100644 index 0000000..4182f61 --- /dev/null +++ b/src/excelalchemy/i18n/messages.py @@ -0,0 +1,388 @@ +from contextlib import contextmanager +from contextvars import ContextVar +from enum import StrEnum +from typing import Final + + +class MessageKey(StrEnum): + EXCEL_IMPORT_ERROR = 'excel_import_error' + EXCEL_ROW_IMPORT_ERROR = 'excel_row_import_error' + LABEL_CANNOT_BE_EMPTY = 'label_cannot_be_empty' + MODEL_CANNOT_BE_NONE = 'model_cannot_be_none' + UNSUPPORTED_FIELD_TYPE_DECLARATION = 'unsupported_field_type_declaration' + THIS_FIELD_IS_REQUIRED = 'this_field_is_required' + VALUE_TYPE_DECLARATION_UNSUPPORTED = 'value_type_declaration_unsupported' + INVALID_INPUT = 'invalid_input' + INVALID_IMPORT_MODE = 'invalid_import_mode' + CREATE_IMPORTER_MODEL_REQUIRED_CREATE = 'create_importer_model_required_create' + UPDATE_IMPORTER_MODEL_REQUIRED_UPDATE = 'update_importer_model_required_update' + CREATE_IMPORTER_MODEL_REQUIRED_CREATE_OR_UPDATE = 'create_importer_model_required_create_or_update' + UPDATE_IMPORTER_MODEL_REQUIRED_CREATE_OR_UPDATE = 'update_importer_model_required_create_or_update' + IS_DATA_EXIST_REQUIRED_CREATE_OR_UPDATE = 'is_data_exist_required_create_or_update' + IMPORTER_MODELS_FIELD_NAMES_MUST_MATCH = 'importer_models_field_names_must_match' + EXPORTER_MODEL_CANNOT_BE_EMPTY = 'exporter_model_cannot_be_empty' + IMPORT_MODE_CONFIG_REQUIRED = 'import_mode_config_required' + EXPORT_MODE_CONFIG_REQUIRED = 'export_mode_config_required' + NO_IMPORTER_OR_EXPORTER_MODEL_CONFIGURED = 'no_importer_or_exporter_model_configured' + IMPORT_MODE_ONLY_METHOD = 'import_mode_only_method' + IMPORT_MODE_ONLY_PROPERTY = 'import_mode_only_property' + WORKSHEET_TABLE_NOT_LOADED = 'worksheet_table_not_loaded' + EXPORTER_MODEL_INFERENCE_CONFLICT = 'exporter_model_inference_conflict' + EXPORTER_MODEL_CANNOT_BE_INFERRED = 'exporter_model_cannot_be_inferred' + CONFIG_ALREADY_INITIALIZED = 'config_already_initialized' + UNSUPPORTED_IMPORT_MODE = 'unsupported_import_mode' + CREATOR_NOT_CONFIGURED = 'creator_not_configured' + CREATE_IMPORTER_MODEL_NOT_CONFIGURED = 'create_importer_model_not_configured' + UPDATER_NOT_CONFIGURED = 'updater_not_configured' + UPDATE_IMPORTER_MODEL_NOT_CONFIGURED = 'update_importer_model_not_configured' + IS_DATA_EXIST_NOT_CONFIGURED = 'is_data_exist_not_configured' + INVALID_MERGED_HEADER_CHILD_EMPTY = 'invalid_merged_header_child_empty' + UNSUPPORTED_COLUMN_NAME = 'unsupported_column_name' + FIELD_META_RUNTIME_KEY_MISSING = 'field_meta_runtime_key_missing' + FIELD_NOT_FOUND = 'field_not_found' + COLUMN_NOT_FOUND = 'column_not_found' + NO_FIELD_METADATA_EXTRACTED = 'no_field_metadata_extracted' + NO_FIELD_METADATA_EXTRACTED_FROM_MODEL = 'no_field_metadata_extracted_from_model' + PARENT_LABEL_EMPTY_RUNTIME = 'parent_label_empty_runtime' + PARENT_KEY_EMPTY_RUNTIME = 'parent_key_empty_runtime' + KEY_EMPTY_RUNTIME = 'key_empty_runtime' + DUPLICATE_FIELD_ORDER_DEFINITIONS = 'duplicate_field_order_definitions' + INVALID_KEY = 'invalid_key' + NO_STORAGE_BACKEND_CONFIGURED = 'no_storage_backend_configured' + MINIO_CLIENT_NOT_CONFIGURED = 'minio_client_not_configured' + WORKSHEET_NOT_FOUND = 'worksheet_not_found' + PRIMARY_KEY_MUST_BE_UNIQUE = 'primary_key_must_be_unique' + PRIMARY_KEY_AND_UNIQUE_MUST_BE_REQUIRED = 'primary_key_and_unique_must_be_required' + OPTION_NOT_FOUND_HEADER_COMMENT = 'option_not_found_header_comment' + OPTION_NOT_FOUND_FIELD_COMMENT = 'option_not_found_field_comment' + DATE_FORMAT_EMPTY_RUNTIME = 'date_format_empty_runtime' + FIELD_DEFINITIONS_MUST_USE_FIELDMETA = 'field_definitions_must_use_fieldmeta' + FRACTION_DIGITS_MUST_BE_INTEGER = 'fraction_digits_must_be_integer' + DATE_FORMAT_NOT_CONFIGURED = 'date_format_not_configured' + ENTER_DATE_FORMAT = 'enter_date_format' + DATE_MUST_BE_EARLIER_THAN_NOW = 'date_must_be_earlier_than_now' + DATE_MUST_BE_LATER_THAN_NOW = 'date_must_be_later_than_now' + DATE_RANGE_START_AFTER_END = 'date_range_start_after_end' + VALID_EMAIL_REQUIRED = 'valid_email_required' + INVALID_NUMBER_ENTER_NUMBER = 'invalid_number_enter_number' + NUMBER_BETWEEN_MIN_AND_MAX = 'number_between_min_and_max' + NUMBER_BETWEEN_NEG_INF_AND_MAX = 'number_between_neg_inf_and_max' + NUMBER_BETWEEN_MIN_AND_POS_INF = 'number_between_min_and_pos_inf' + NUMBER_RANGE_MIN_GREATER_THAN_MAX = 'number_range_min_greater_than_max' + ENTER_NUMBER = 'enter_number' + ENTER_NUMBER_EXPECTED_FORMAT = 'enter_number_expected_format' + VALID_URL_REQUIRED = 'valid_url_required' + VALID_PHONE_NUMBER_REQUIRED = 'valid_phone_number_required' + MULTIPLE_SELECTIONS_NOT_SUPPORTED = 'multiple_selections_not_supported' + OPTIONS_CANNOT_BE_NONE_FOR_SELECTION_FIELDS = 'options_cannot_be_none_for_selection_fields' + OPTIONS_CANNOT_BE_NONE_FOR_VALUE_TYPE = 'options_cannot_be_none_for_value_type' + OPTIONS_CONTAIN_DUPLICATES = 'options_contain_duplicates' + CHARACTER_SET_NOT_CONFIGURED = 'character_set_not_configured' + MAX_LENGTH_CHARACTERS = 'max_length_characters' + ONLY_CHARACTER_SET_ALLOWED = 'only_character_set_allowed' + IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION = 'import_result_only_for_invalid_header_validation' + BOOLEAN_ENTER_YES_OR_NO = 'boolean_enter_yes_or_no' + BOOLEAN_TRUE_DISPLAY = 'boolean_true_display' + BOOLEAN_FALSE_DISPLAY = 'boolean_false_display' + CHARACTER_SET_NAME_CHINESE = 'character_set_name_chinese' + CHARACTER_SET_NAME_NUMBER = 'character_set_name_number' + CHARACTER_SET_NAME_LOWERCASE = 'character_set_name_lowercase' + CHARACTER_SET_NAME_UPPERCASE = 'character_set_name_uppercase' + CHARACTER_SET_NAME_SPECIAL = 'character_set_name_special' + HEADER_HINT = 'header_hint' + RESULT_COLUMN_LABEL = 'result_column_label' + REASON_COLUMN_LABEL = 'reason_column_label' + VALIDATE_ROW_SUCCESS = 'validate_row_success' + VALIDATE_ROW_FAIL = 'validate_row_fail' + COMMENT_REQUIRED = 'comment_required' + COMMENT_DATE_FORMAT = 'comment_date_format' + COMMENT_DATE_RANGE_OPTION = 'comment_date_range_option' + COMMENT_HINT = 'comment_hint' + COMMENT_OPTIONS = 'comment_options' + COMMENT_FRACTION_DIGITS = 'comment_fraction_digits' + COMMENT_UNIT = 'comment_unit' + COMMENT_UNIQUE = 'comment_unique' + COMMENT_MAX_LENGTH = 'comment_max_length' + COMMENT_NUMBER_FORMAT = 'comment_number_format' + COMMENT_NUMBER_INPUT_RANGE = 'comment_number_input_range' + COMMENT_STRING_ALLOWED_CONTENT = 'comment_string_allowed_content' + COMMENT_SELECTION_MODE = 'comment_selection_mode' + COMMENT_REQUIRED_VALUE_REQUIRED = 'comment_required_value_required' + COMMENT_REQUIRED_VALUE_OPTIONAL = 'comment_required_value_optional' + COMMENT_UNIQUE_VALUE_UNIQUE = 'comment_unique_value_unique' + COMMENT_UNIQUE_VALUE_NON_UNIQUE = 'comment_unique_value_non_unique' + COMMENT_SELECTION_VALUE_SINGLE = 'comment_selection_value_single' + COMMENT_SELECTION_VALUE_MULTI = 'comment_selection_value_multi' + COMMENT_UNIT_VALUE_NONE = 'comment_unit_value_none' + COMMENT_MAX_LENGTH_VALUE_UNLIMITED = 'comment_max_length_value_unlimited' + COMMENT_DATE_RANGE_START_NOT_AFTER_END = 'comment_date_range_start_not_after_end' + DATE_RANGE_OPTION_PRE_DISPLAY = 'date_range_option_pre_display' + DATE_RANGE_OPTION_NEXT_DISPLAY = 'date_range_option_next_display' + DATE_RANGE_OPTION_NONE_DISPLAY = 'date_range_option_none_display' + SINGLE_ORGANIZATION_HINT = 'single_organization_hint' + MULTI_ORGANIZATION_HINT = 'multi_organization_hint' + SINGLE_STAFF_HINT = 'single_staff_hint' + MULTI_STAFF_HINT = 'multi_staff_hint' + SINGLE_TREE_HINT = 'single_tree_hint' + MULTI_TREE_HINT = 'multi_tree_hint' + LABEL_START_DATE = 'label_start_date' + LABEL_END_DATE = 'label_end_date' + LABEL_MINIMUM_VALUE = 'label_minimum_value' + LABEL_MAXIMUM_VALUE = 'label_maximum_value' + + +DEFAULT_LOCALE: Final[str] = 'en' +DISPLAY_DEFAULT_LOCALE: Final[str] = 'zh-CN' +SUPPORTED_RUNTIME_LOCALES: Final[tuple[str, ...]] = (DEFAULT_LOCALE,) +SUPPORTED_DISPLAY_LOCALES: Final[tuple[str, ...]] = (DISPLAY_DEFAULT_LOCALE, 'en') +_current_display_locale: ContextVar[str] = ContextVar('excelalchemy_display_locale', default=DISPLAY_DEFAULT_LOCALE) + +MESSAGES: Final[dict[str, dict[MessageKey, str]]] = { + 'en': { + MessageKey.EXCEL_IMPORT_ERROR: 'Excel import error', + MessageKey.EXCEL_ROW_IMPORT_ERROR: 'Excel row import error', + MessageKey.LABEL_CANNOT_BE_EMPTY: 'label cannot be empty', + MessageKey.MODEL_CANNOT_BE_NONE: 'model cannot be None', + MessageKey.UNSUPPORTED_FIELD_TYPE_DECLARATION: 'Unsupported field type declaration: {annotation}', + MessageKey.THIS_FIELD_IS_REQUIRED: 'This field is required', + MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED: ( + 'Field definitions must use an ExcelFieldCodec or CompositeExcelFieldCodec subclass; {value_type} is not supported' + ), + MessageKey.INVALID_INPUT: 'Invalid input', + MessageKey.INVALID_IMPORT_MODE: 'Invalid import mode: {import_mode}', + MessageKey.CREATE_IMPORTER_MODEL_REQUIRED_CREATE: 'create_importer_model is required in CREATE mode', + MessageKey.UPDATE_IMPORTER_MODEL_REQUIRED_UPDATE: 'update_importer_model is required in UPDATE mode', + MessageKey.CREATE_IMPORTER_MODEL_REQUIRED_CREATE_OR_UPDATE: ( + 'create_importer_model is required in CREATE_OR_UPDATE mode' + ), + MessageKey.UPDATE_IMPORTER_MODEL_REQUIRED_CREATE_OR_UPDATE: ( + 'update_importer_model is required in CREATE_OR_UPDATE mode' + ), + MessageKey.IS_DATA_EXIST_REQUIRED_CREATE_OR_UPDATE: 'is_data_exist is required in CREATE_OR_UPDATE mode', + MessageKey.IMPORTER_MODELS_FIELD_NAMES_MUST_MATCH: ( + 'create and update importer models must define the same field names' + ), + MessageKey.EXPORTER_MODEL_CANNOT_BE_EMPTY: 'exporter_model cannot be empty', + MessageKey.IMPORT_MODE_CONFIG_REQUIRED: 'Import mode requires an {config_name} instance', + MessageKey.EXPORT_MODE_CONFIG_REQUIRED: 'Export mode requires an {config_name} instance', + MessageKey.NO_IMPORTER_OR_EXPORTER_MODEL_CONFIGURED: 'No importer or exporter model is configured', + MessageKey.IMPORT_MODE_ONLY_METHOD: 'This method is only available in import mode', + MessageKey.IMPORT_MODE_ONLY_PROPERTY: 'This property is only available in import mode', + MessageKey.WORKSHEET_TABLE_NOT_LOADED: 'The worksheet table must be loaded before accessing this property', + MessageKey.EXPORTER_MODEL_INFERENCE_CONFLICT: ( + 'Cannot infer exporter_model when both importer models are configured' + ), + MessageKey.EXPORTER_MODEL_CANNOT_BE_INFERRED: ( + 'Could not infer exporter_model; please configure it explicitly' + ), + MessageKey.CONFIG_ALREADY_INITIALIZED: ( + '{class_name} has already been initialized; config cannot be reassigned' + ), + MessageKey.UNSUPPORTED_IMPORT_MODE: 'Unsupported import mode: {import_mode}', + MessageKey.CREATOR_NOT_CONFIGURED: 'creator is not configured', + MessageKey.CREATE_IMPORTER_MODEL_NOT_CONFIGURED: 'create_importer_model is not configured', + MessageKey.UPDATER_NOT_CONFIGURED: 'updater is not configured', + MessageKey.UPDATE_IMPORTER_MODEL_NOT_CONFIGURED: 'update_importer_model is not configured', + MessageKey.IS_DATA_EXIST_NOT_CONFIGURED: 'is_data_exist is not configured', + MessageKey.INVALID_MERGED_HEADER_CHILD_EMPTY: 'Invalid merged header: child header cannot be empty', + MessageKey.UNSUPPORTED_COLUMN_NAME: 'Unsupported column name: {unique_label}', + MessageKey.FIELD_META_RUNTIME_KEY_MISSING: '{field_meta_type} is missing runtime key/parent_key', + MessageKey.FIELD_NOT_FOUND: 'Could not find a field for {unique_label}', + MessageKey.COLUMN_NOT_FOUND: ( + 'Could not find a column for {unique_label}; the codec definition may be invalid' + ), + MessageKey.NO_FIELD_METADATA_EXTRACTED: ( + 'No field metadata was extracted; check whether the model defines any fields' + ), + MessageKey.NO_FIELD_METADATA_EXTRACTED_FROM_MODEL: ( + 'No field metadata was extracted from model {model_name}; check its field definitions' + ), + MessageKey.PARENT_LABEL_EMPTY_RUNTIME: 'parent_label cannot be empty at runtime', + MessageKey.PARENT_KEY_EMPTY_RUNTIME: 'parent_key cannot be empty at runtime', + MessageKey.KEY_EMPTY_RUNTIME: 'key cannot be empty at runtime', + MessageKey.DUPLICATE_FIELD_ORDER_DEFINITIONS: ('Duplicate field order definitions found: {duplicate_order}'), + MessageKey.INVALID_KEY: 'Invalid key: {key}', + MessageKey.NO_STORAGE_BACKEND_CONFIGURED: ( + 'No storage backend is configured; pass storage=... or install and configure ExcelAlchemy[minio]' + ), + MessageKey.MINIO_CLIENT_NOT_CONFIGURED: 'minio client is not configured', + MessageKey.WORKSHEET_NOT_FOUND: 'Worksheet named {sheet_name!r} not found', + MessageKey.PRIMARY_KEY_MUST_BE_UNIQUE: 'Primary key fields must be unique', + MessageKey.PRIMARY_KEY_AND_UNIQUE_MUST_BE_REQUIRED: ('Primary key and unique fields must be required'), + MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT: ('Option not found; check the header comment for valid values'), + MessageKey.OPTION_NOT_FOUND_FIELD_COMMENT: ('Option not found; check the field comment for valid values'), + MessageKey.DATE_FORMAT_EMPTY_RUNTIME: 'date_format cannot be empty at runtime', + MessageKey.FIELD_DEFINITIONS_MUST_USE_FIELDMETA: ( + 'Field definitions must be created with FieldMeta or Annotated[..., ExcelMeta(...)]' + ), + MessageKey.FRACTION_DIGITS_MUST_BE_INTEGER: 'fraction_digits must be an integer', + MessageKey.DATE_FORMAT_NOT_CONFIGURED: 'date_format is not configured', + MessageKey.ENTER_DATE_FORMAT: 'Enter a date in {date_format} format', + MessageKey.DATE_MUST_BE_EARLIER_THAN_NOW: 'The value must be earlier than or equal to the current time', + MessageKey.DATE_MUST_BE_LATER_THAN_NOW: 'The value must be later than or equal to the current time', + MessageKey.DATE_RANGE_START_AFTER_END: 'The start date cannot be later than the end date', + MessageKey.VALID_EMAIL_REQUIRED: 'Enter a valid email address', + MessageKey.INVALID_NUMBER_ENTER_NUMBER: 'Invalid input; enter a number.', + MessageKey.NUMBER_BETWEEN_MIN_AND_MAX: 'Enter a number between {minimum} and {maximum}.', + MessageKey.NUMBER_BETWEEN_NEG_INF_AND_MAX: 'Enter a number between -∞ and {maximum}.', + MessageKey.NUMBER_BETWEEN_MIN_AND_POS_INF: 'Enter a number between {minimum} and +∞.', + MessageKey.NUMBER_RANGE_MIN_GREATER_THAN_MAX: 'The minimum value cannot be greater than the maximum value', + MessageKey.ENTER_NUMBER: 'Enter a number', + MessageKey.ENTER_NUMBER_EXPECTED_FORMAT: 'Enter a number in the expected format', + MessageKey.VALID_URL_REQUIRED: 'Enter a valid URL', + MessageKey.VALID_PHONE_NUMBER_REQUIRED: 'Enter a valid phone number', + MessageKey.MULTIPLE_SELECTIONS_NOT_SUPPORTED: 'Multiple selections are not supported', + MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_SELECTION_FIELDS: ( + 'options cannot be None when validating RADIO / MULTI_CHECKBOX / SELECT fields' + ), + MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_VALUE_TYPE: 'options cannot be None when validating codec {value_type}', + MessageKey.OPTIONS_CONTAIN_DUPLICATES: 'Options contain duplicates', + MessageKey.CHARACTER_SET_NOT_CONFIGURED: 'character_set is not configured', + MessageKey.MAX_LENGTH_CHARACTERS: 'The maximum length is {max_length} characters', + MessageKey.ONLY_CHARACTER_SET_ALLOWED: 'Only {character_set_names} are allowed', + MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION: ( + 'ImportResult can only be built from an invalid header validation result' + ), + MessageKey.BOOLEAN_ENTER_YES_OR_NO: 'Enter "{true_value}" or "{false_value}"', + MessageKey.BOOLEAN_TRUE_DISPLAY: 'Yes', + MessageKey.BOOLEAN_FALSE_DISPLAY: 'No', + MessageKey.CHARACTER_SET_NAME_CHINESE: 'Chinese characters', + MessageKey.CHARACTER_SET_NAME_NUMBER: 'numbers', + MessageKey.CHARACTER_SET_NAME_LOWERCASE: 'lowercase letters', + MessageKey.CHARACTER_SET_NAME_UPPERCASE: 'uppercase letters', + MessageKey.CHARACTER_SET_NAME_SPECIAL: 'symbols', + MessageKey.HEADER_HINT: ( + 'Import instructions:\n' + '1. Review the header comments before filling in data to avoid import failures.\n' + '2. Some columns may be read-only and generated by system rules; they are shown for export only and ignored on import.\n' + '3. Columns with a red background are required and must be filled according to the header comment.\n' + '4. Do not change the cell format of any column to avoid validation failures.\n' + '5. Remove the sample rows before importing.' + ), + MessageKey.RESULT_COLUMN_LABEL: 'Validation result\nDelete this column before re-uploading', + MessageKey.REASON_COLUMN_LABEL: 'Failure reason\nDelete this column before re-uploading', + MessageKey.VALIDATE_ROW_SUCCESS: 'Validation passed', + MessageKey.VALIDATE_ROW_FAIL: 'Validation failed', + MessageKey.COMMENT_REQUIRED: 'Required: {value}', + MessageKey.COMMENT_DATE_FORMAT: 'Format: date ({value})', + MessageKey.COMMENT_DATE_RANGE_OPTION: 'Range: {value}', + MessageKey.COMMENT_HINT: 'Hint: {value}', + MessageKey.COMMENT_OPTIONS: 'Options: {value}', + MessageKey.COMMENT_FRACTION_DIGITS: 'Fraction digits: {value}', + MessageKey.COMMENT_UNIT: 'Unit: {value}', + MessageKey.COMMENT_UNIQUE: 'Uniqueness: {value}', + MessageKey.COMMENT_MAX_LENGTH: 'Max length: {value}', + MessageKey.COMMENT_NUMBER_FORMAT: 'Format: number', + MessageKey.COMMENT_NUMBER_INPUT_RANGE: 'Allowed range: {value}', + MessageKey.COMMENT_STRING_ALLOWED_CONTENT: 'Allowed content: Chinese characters, numbers, uppercase letters, lowercase letters, symbols', + MessageKey.COMMENT_SELECTION_MODE: 'Selection mode: {value}', + MessageKey.COMMENT_REQUIRED_VALUE_REQUIRED: 'required', + MessageKey.COMMENT_REQUIRED_VALUE_OPTIONAL: 'optional', + MessageKey.COMMENT_UNIQUE_VALUE_UNIQUE: 'unique', + MessageKey.COMMENT_UNIQUE_VALUE_NON_UNIQUE: 'not unique', + MessageKey.COMMENT_SELECTION_VALUE_SINGLE: 'single', + MessageKey.COMMENT_SELECTION_VALUE_MULTI: 'multiple', + MessageKey.COMMENT_UNIT_VALUE_NONE: 'none', + MessageKey.COMMENT_MAX_LENGTH_VALUE_UNLIMITED: 'unlimited', + MessageKey.COMMENT_DATE_RANGE_START_NOT_AFTER_END: 'Hint: the start date cannot be later than the end date{extra_hint}', + MessageKey.DATE_RANGE_OPTION_PRE_DISPLAY: 'earlier than the current time', + MessageKey.DATE_RANGE_OPTION_NEXT_DISPLAY: 'later than the current time', + MessageKey.DATE_RANGE_OPTION_NONE_DISPLAY: 'unlimited', + MessageKey.SINGLE_ORGANIZATION_HINT: "Enter the full organization path, for example 'Company/Department/Sub-department'.", + MessageKey.MULTI_ORGANIZATION_HINT: ( + "Enter the full organization path, for example 'Company/Department/Sub-department'. " + 'Use "、" to separate multiple selections.' + ), + MessageKey.SINGLE_STAFF_HINT: 'Enter the staff name and employee ID, for example "Zhang San/001".', + MessageKey.MULTI_STAFF_HINT: ( + 'Enter the staff name and employee ID, for example "Zhang San/001". ' + 'Use "、" to separate multiple selections.' + ), + MessageKey.SINGLE_TREE_HINT: ('Enter the full tree path, for example "Company/Department/Sub-department".'), + MessageKey.MULTI_TREE_HINT: ( + 'Enter the full path including the root node. Use "/" between levels, for example ' + '"Level 1/Level 2/Option 1". Use "," to separate multiple selections.' + ), + MessageKey.LABEL_START_DATE: 'Start date', + MessageKey.LABEL_END_DATE: 'End date', + MessageKey.LABEL_MINIMUM_VALUE: 'Minimum value', + MessageKey.LABEL_MAXIMUM_VALUE: 'Maximum value', + }, + 'zh-CN': { + MessageKey.HEADER_HINT: ( + '\n导入填写须知:\n' + '1、填写数据时,请注意查看字段名称上的注释,避免导入失败。\n' + '2、表格中可能包含部分只读字段,可能是根据系统规则自动生成或是在编辑时禁止被修改,仅用于导出时查看,导入时不生效。\n' + '3、字段名称背景是红色的为必填字段,导入时必须根据注释的提示填写好内容。\n' + '4、请不要随意修改列的单元格格式,避免模板校验不通过。\n' + '5、导入前请删除示例数据。\n' + ), + MessageKey.RESULT_COLUMN_LABEL: '校验结果\n重新上传前请删除此列', + MessageKey.REASON_COLUMN_LABEL: '失败原因\n重新上传前请删除此列', + MessageKey.VALIDATE_ROW_SUCCESS: '校验通过', + MessageKey.VALIDATE_ROW_FAIL: '校验不通过', + MessageKey.COMMENT_REQUIRED: '必填性:{value}', + MessageKey.COMMENT_DATE_FORMAT: '格式:日期({value})', + MessageKey.COMMENT_DATE_RANGE_OPTION: '范围:{value}', + MessageKey.COMMENT_HINT: '提示:{value}', + MessageKey.COMMENT_OPTIONS: '选项:{value}', + MessageKey.COMMENT_FRACTION_DIGITS: '小数位数:{value}', + MessageKey.COMMENT_UNIT: '单位:{value}', + MessageKey.COMMENT_UNIQUE: '唯一性:{value}', + MessageKey.COMMENT_MAX_LENGTH: '最大长度:{value}', + MessageKey.COMMENT_NUMBER_FORMAT: '格式:数值', + MessageKey.COMMENT_NUMBER_INPUT_RANGE: '可输入范围:{value}', + MessageKey.COMMENT_STRING_ALLOWED_CONTENT: '可输入内容:中文、数字、大写字母、小写字母、符号', + MessageKey.COMMENT_SELECTION_MODE: '单/多选:{value}', + MessageKey.COMMENT_REQUIRED_VALUE_REQUIRED: '必填', + MessageKey.COMMENT_REQUIRED_VALUE_OPTIONAL: '选填', + MessageKey.COMMENT_UNIQUE_VALUE_UNIQUE: '唯一', + MessageKey.COMMENT_UNIQUE_VALUE_NON_UNIQUE: '非唯一', + MessageKey.COMMENT_SELECTION_VALUE_SINGLE: '单选', + MessageKey.COMMENT_SELECTION_VALUE_MULTI: '多选', + MessageKey.COMMENT_UNIT_VALUE_NONE: '无', + MessageKey.COMMENT_MAX_LENGTH_VALUE_UNLIMITED: '无限制', + MessageKey.COMMENT_DATE_RANGE_START_NOT_AFTER_END: '提示:开始日期不得晚于结束日期{extra_hint}', + MessageKey.DATE_RANGE_OPTION_PRE_DISPLAY: '早于当前时间', + MessageKey.DATE_RANGE_OPTION_NEXT_DISPLAY: '晚于当前时间', + MessageKey.DATE_RANGE_OPTION_NONE_DISPLAY: '无限制', + MessageKey.BOOLEAN_TRUE_DISPLAY: '是', + MessageKey.BOOLEAN_FALSE_DISPLAY: '否', + MessageKey.SINGLE_ORGANIZATION_HINT: "需按照组织架构树填写组织完整路径,例如 'XX公司/一级部门/二级部门'.", + MessageKey.MULTI_ORGANIZATION_HINT: '需按照组织架构树填写组织完整路径,如“XX公司/一级部门/二级部门”,多选时,选项之间用“、”连接', + MessageKey.SINGLE_STAFF_HINT: '请输入人员姓名和工号,如“张三/001”', + MessageKey.MULTI_STAFF_HINT: '请输入人员姓名和工号,如“张三/001”,多选时,选项之间用“、”连接', + MessageKey.SINGLE_TREE_HINT: '需按照组织架构树填写组织完整路径,如“XX公司/一级部门/二级部门”,多选时,选项之间用“、”连接', + MessageKey.MULTI_TREE_HINT: '请输入完整路径(包含根节点),层级之间用“/”连接,如“一级/二级/选项1”;多选时,选项之间用“,”连接', + MessageKey.LABEL_START_DATE: '开始日期', + MessageKey.LABEL_END_DATE: '结束日期', + MessageKey.LABEL_MINIMUM_VALUE: '最小值', + MessageKey.LABEL_MAXIMUM_VALUE: '最大值', + }, +} + + +def message(key: MessageKey, locale: str = DEFAULT_LOCALE, **kwargs: object) -> str: + locale_messages = MESSAGES.get(locale, MESSAGES[DEFAULT_LOCALE]) + template = locale_messages.get(key) or MESSAGES[DEFAULT_LOCALE][key] + return template.format(**kwargs) + + +def get_display_locale() -> str: + return _current_display_locale.get() + + +@contextmanager +def use_display_locale(locale: str): + token = _current_display_locale.set(locale) + try: + yield + finally: + _current_display_locale.reset(token) + + +def display_message(key: MessageKey, locale: str | None = None, **kwargs: object) -> str: + effective_locale = locale or get_display_locale() + locale_messages = MESSAGES.get(effective_locale, MESSAGES[DISPLAY_DEFAULT_LOCALE]) + template = locale_messages.get(key) or MESSAGES[DISPLAY_DEFAULT_LOCALE][key] + return template.format(**kwargs) diff --git a/src/excelalchemy/identity.py b/src/excelalchemy/identity.py new file mode 100644 index 0000000..ae63dc8 --- /dev/null +++ b/src/excelalchemy/identity.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.identity``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.identity', 'the excelalchemy package root') + +from excelalchemy._primitives.identity import * # noqa: F403 diff --git a/src/excelalchemy/metadata.py b/src/excelalchemy/metadata.py new file mode 100644 index 0000000..3856b18 --- /dev/null +++ b/src/excelalchemy/metadata.py @@ -0,0 +1,621 @@ +"""Excel metadata definitions decoupled from Pydantic internals.""" + +import copy +import datetime +import logging +from collections.abc import Callable, Mapping, Set +from functools import cached_property +from typing import Any, Self, cast + +from pydantic import BaseModel, Field +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined + +from excelalchemy._primitives.constants import ( + DATE_FORMAT_TO_HINT_MAPPING, + DATE_FORMAT_TO_PYTHON_MAPPING, + DEFAULT_FIELD_META_ORDER, + MAX_OPTIONS_COUNT, + MULTI_CHECKBOX_SEPARATOR, + UNIQUE_HEADER_CONNECTOR, + CharacterSet, + DataRangeOption, + DateFormat, + IntStr, + Option, +) +from excelalchemy._primitives.identity import Key, Label, OptionId, UniqueKey, UniqueLabel +from excelalchemy.codecs.base import ExcelFieldCodec, UndefinedFieldCodec +from excelalchemy.exceptions import ConfigError, ProgrammaticError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg + +EXCEL_FIELD_METADATA_KEY = 'excelalchemy_metadata' +type FieldDefaultFactory = Callable[[], object] +type FieldIncludeExclude = Set[IntStr] | bool | None + + +class PatchFieldMeta(BaseModel): + unique: bool | None = False # Workbook hint only. Runtime uniqueness is enforced elsewhere. + is_primary_key: bool | None = False # Workbook hint only. Runtime primary-key behavior is configured separately. + hint: str | None = None # Workbook-facing help text rendered into header comments. + options: list[Option] | None = None + + +class FieldMetaInfo: + """Excel field metadata independent from any validation backend.""" + + def __init__( + self, + *, + label: str | Label, + is_primary_key: bool = False, + unique: bool = False, + ignore_import: bool = False, + required: bool | None = None, + order: int = DEFAULT_FIELD_META_ORDER, + character_set: set[CharacterSet] | None = None, + fraction_digits: int | None = None, + timezone: datetime.timezone | None = None, + date_format: DateFormat | None = None, + date_range_option: DataRangeOption | None = None, + options: list[Option] | None = None, + unit: str | None = None, + hint: str | None = None, + ge: float | None = None, + le: float | None = None, + max_digits: int | None = None, + decimal_places: int | None = None, + min_items: int | None = None, + max_items: int | None = None, + unique_items: bool | None = None, + min_length: int | None = None, + max_length: int | None = None, + ) -> None: + self.label = Label(label) + self.is_primary_key = is_primary_key + self.parent_label: Label | None = None + + self.key: Key | None = None + self.parent_key: Key | None = None + + self.offset = DEFAULT_FIELD_META_ORDER + self._excel_codec: type[ExcelFieldCodec] = UndefinedFieldCodec + self.unique = unique or is_primary_key + + self.required = required + self.ignore_import = ignore_import + self.order = order + + self.character_set = character_set or set(CharacterSet) + self.fraction_digits = fraction_digits + self.timezone = timezone or datetime.timezone(datetime.timedelta(hours=8), 'CST') + self.date_format = date_format + self.date_range_option = date_range_option + self.options = options + self.unit = unit + self.hint = hint + + self.importer_ge = ge + self.importer_le = le + self.importer_max_digits = max_digits + self.importer_decimal_places = decimal_places + self.importer_min_length = min_length + self.importer_max_length = max_length + self.importer_min_items = min_items + self.importer_max_items = max_items + self.importer_unique_items = unique_items + + def clone(self) -> Self: + return copy.copy(self) + + def inherited_from(self, parent: Self) -> Self: + runtime = self.clone() + runtime.order = parent.order + runtime.character_set = runtime.character_set or parent.character_set + runtime.fraction_digits = runtime.fraction_digits or parent.fraction_digits + runtime.timezone = runtime.timezone or parent.timezone + runtime.date_format = runtime.date_format or parent.date_format + runtime.date_range_option = runtime.date_range_option or parent.date_range_option + runtime.unit = runtime.unit or parent.unit + return runtime + + def bind_runtime( + self, + *, + required: bool, + excel_codec: type[ExcelFieldCodec], + parent_label: Label, + parent_key: Key, + key: Key, + offset: int, + ) -> Self: + runtime = self.clone() + runtime.required = required + runtime.excel_codec = excel_codec + runtime.parent_label = parent_label + runtime.parent_key = parent_key + runtime.key = key + runtime.offset = offset + return runtime + + @property + def excel_codec(self) -> type[ExcelFieldCodec]: + return self._excel_codec + + @excel_codec.setter + def excel_codec(self, value: type[ExcelFieldCodec]) -> None: + self._excel_codec = value + + @property + def value_type(self) -> type[ExcelFieldCodec]: + """Backward-compatible alias for excel_codec.""" + return self.excel_codec + + @value_type.setter + def value_type(self, value: type[ExcelFieldCodec]) -> None: + self.excel_codec = value + + def set_is_primary_key(self, is_primary_key: bool | None) -> None: + if is_primary_key is None: + return + self.is_primary_key = is_primary_key + if self.is_primary_key: + self.unique = True + self.required = True + + def set_unique(self, unique: bool | None) -> None: + if unique is None: + return + self.unique = unique + if self.unique: + self.required = True + + def validate_state(self) -> None: + if self.is_primary_key and not self.unique: + raise ValueError(msg(MessageKey.PRIMARY_KEY_MUST_BE_UNIQUE)) + if (self.is_primary_key or self.unique) and self.required is False: + raise ValueError(msg(MessageKey.PRIMARY_KEY_AND_UNIQUE_MUST_BE_REQUIRED)) + + def exchange_option_ids_to_names(self, option_ids: list[str] | list[OptionId]) -> list[str]: + option_names: list[str] = [] + + for option_id in option_ids: + option_id = OptionId(option_id) + try: + option_names.append(self.options_id_map[option_id].name) + except KeyError: + logging.warning('Could not find option id %s; returning the original value', option_id) + option_names.append(option_id) + + return option_names + + def exchange_names_to_option_ids_with_errors(self, names: list[str]) -> tuple[list[str], list[str]]: + errors: list[str] = [] + result: list[str] = [] + for name in names: + option = self.options_name_map.get(name) + if option is None: + errors.append(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT)) + else: + result.append(option.id) + return result, errors + + @property + def unique_label(self) -> UniqueLabel: + if self.parent_label is None: + raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) + label = ( + f'{self.parent_label}{UNIQUE_HEADER_CONNECTOR}{self.label}' + if self.parent_label != self.label + else self.label + ) + return UniqueLabel(label) + + @property + def unique_key(self) -> UniqueKey: + if self.parent_key is None: + raise RuntimeError(msg(MessageKey.PARENT_KEY_EMPTY_RUNTIME)) + if self.key is None: + raise RuntimeError(msg(MessageKey.KEY_EMPTY_RUNTIME)) + key = f'{self.parent_key}{UNIQUE_HEADER_CONNECTOR}{self.key}' if self.parent_key != self.key else self.key + return UniqueKey(key) + + @cached_property + def options_id_map(self) -> 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', + self.label, + len(self.options), + ) + return {option.id: option for option in self.options} + + @cached_property + def options_name_map(self) -> 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', + self.label, + len(self.options), + ) + return {option.name: option for option in self.options} + + @property + def comment_required(self) -> str: + value_key = ( + MessageKey.COMMENT_REQUIRED_VALUE_REQUIRED if self.required else MessageKey.COMMENT_REQUIRED_VALUE_OPTIONAL + ) + return dmsg(MessageKey.COMMENT_REQUIRED, value=dmsg(value_key)) + + @property + def comment_date_format(self) -> str: + if self.date_format is None: + return '' + return dmsg(MessageKey.COMMENT_DATE_FORMAT, value=DATE_FORMAT_TO_HINT_MAPPING[self.date_format]) + + @property + def comment_date_range_option(self) -> str: + if self.date_range_option is None: + return dmsg(MessageKey.COMMENT_DATE_RANGE_OPTION, value=dmsg(MessageKey.DATE_RANGE_OPTION_NONE_DISPLAY)) + option_mapping = { + DataRangeOption.PRE: MessageKey.DATE_RANGE_OPTION_PRE_DISPLAY, + DataRangeOption.NEXT: MessageKey.DATE_RANGE_OPTION_NEXT_DISPLAY, + DataRangeOption.NONE: MessageKey.DATE_RANGE_OPTION_NONE_DISPLAY, + } + return dmsg(MessageKey.COMMENT_DATE_RANGE_OPTION, value=dmsg(option_mapping[self.date_range_option])) + + @property + def comment_hint(self) -> str: + if self.hint is None: + return '' + return dmsg(MessageKey.COMMENT_HINT, value=self.hint) + + @property + def comment_options(self) -> str: + if self.options is None: + return '' + return dmsg(MessageKey.COMMENT_OPTIONS, value=MULTI_CHECKBOX_SEPARATOR.join(x.name for x in self.options)) + + @property + def comment_fraction_digits(self) -> str: + return dmsg(MessageKey.COMMENT_FRACTION_DIGITS, value=self.fraction_digits or 0) + + @property + def comment_unit(self) -> str: + return dmsg(MessageKey.COMMENT_UNIT, value=self.unit or dmsg(MessageKey.COMMENT_UNIT_VALUE_NONE)) + + @property + def comment_unique(self) -> str: + value_key = ( + MessageKey.COMMENT_UNIQUE_VALUE_UNIQUE if self.unique else MessageKey.COMMENT_UNIQUE_VALUE_NON_UNIQUE + ) + return dmsg(MessageKey.COMMENT_UNIQUE, value=dmsg(value_key)) + + @property + def comment_max_length(self) -> str: + return dmsg( + MessageKey.COMMENT_MAX_LENGTH, + value=self.importer_max_length or dmsg(MessageKey.COMMENT_MAX_LENGTH_VALUE_UNLIMITED), + ) + + @property + def must_date_format(self) -> DateFormat: + if self.date_format is None: + raise ConfigError(msg(MessageKey.DATE_FORMAT_EMPTY_RUNTIME)) + return self.date_format + + @property + def python_date_format(self) -> str: + return DATE_FORMAT_TO_PYTHON_MAPPING[self.must_date_format] + + def __repr__(self) -> str: + return ( + f'FieldMeta(label={self.label!r}, ' + f'order={self.order!r}, ' + f'excel_codec={self.excel_codec.__name__!r}, ' + f'required={self.required!r}, ' + f'unique={self.unique!r}, ' + f'comment_required={self.comment_required!r}, ' + f'comment_unique={self.comment_unique!r})' + ) + + __str__ = __repr__ + + +def extract_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: + metadata = _resolve_declared_field_metadata(field_info) + return _overlay_pydantic_field_constraints(metadata.clone(), field_info) + + +def _resolve_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: + for item in field_info.metadata: + if isinstance(item, FieldMetaInfo): + return item + + if isinstance(field_info.default, FieldMetaInfo): + raise ProgrammaticError( + 'Annotated fields must place ExcelMeta(...) inside Annotated metadata; ' + 'use `field: Annotated[T, Field(...), ExcelMeta(...)]`' + ) + + json_schema_extra = field_info.json_schema_extra + if not isinstance(json_schema_extra, Mapping): + raise ProgrammaticError(msg(MessageKey.FIELD_DEFINITIONS_MUST_USE_FIELDMETA)) + + json_schema_mapping = cast(Mapping[str, object], json_schema_extra) + metadata = json_schema_mapping.get(EXCEL_FIELD_METADATA_KEY) + if not isinstance(metadata, FieldMetaInfo): + raise ProgrammaticError(msg(MessageKey.FIELD_DEFINITIONS_MUST_USE_FIELDMETA)) + return metadata + + +def _overlay_pydantic_field_constraints(metadata: FieldMetaInfo, field_info: FieldInfo) -> FieldMetaInfo: + for item in field_info.metadata: + if isinstance(item, FieldMetaInfo): + continue + + ge = getattr(item, 'ge', None) + if ge is not None: + metadata.importer_ge = ge + + le = getattr(item, 'le', None) + if le is not None: + metadata.importer_le = le + + max_digits = getattr(item, 'max_digits', None) + if max_digits is not None: + metadata.importer_max_digits = max_digits + + decimal_places = getattr(item, 'decimal_places', None) + if decimal_places is not None: + metadata.importer_decimal_places = decimal_places + + min_length = getattr(item, 'min_length', None) + if min_length is not None: + metadata.importer_min_length = min_length + metadata.importer_min_items = min_length + + max_length = getattr(item, 'max_length', None) + if max_length is not None: + metadata.importer_max_length = max_length + metadata.importer_max_items = max_length + + unique_items = getattr(item, 'unique_items', None) + if unique_items is not None: + metadata.importer_unique_items = unique_items + + return metadata + + +def _build_excel_metadata( + *, + label: str | Label, + is_primary_key: bool = False, + unique: bool = False, + ignore_import: bool = False, + required: bool | None = None, + order: int = DEFAULT_FIELD_META_ORDER, + character_set: set[CharacterSet] | None = None, + fraction_digits: int | None = None, + timezone: datetime.timezone | None = None, + date_format: DateFormat | None = None, + date_range_option: DataRangeOption | None = None, + options: list[Option] | None = None, + unit: str | None = None, + hint: str | None = None, + ge: float | None = None, + le: float | None = None, + max_digits: int | None = None, + decimal_places: int | None = None, + min_items: int | None = None, + max_items: int | None = None, + unique_items: bool | None = None, + min_length: int | None = None, + max_length: int | None = None, +) -> FieldMetaInfo: + return FieldMetaInfo( + label=label, + is_primary_key=is_primary_key, + unique=unique, + ignore_import=ignore_import, + required=required, + order=order, + character_set=character_set, + fraction_digits=fraction_digits, + timezone=timezone, + date_format=date_format, + date_range_option=date_range_option, + options=options, + unit=unit, + hint=hint, + ge=ge, + le=le, + max_digits=max_digits, + decimal_places=decimal_places, + min_items=min_items, + max_items=max_items, + unique_items=unique_items, + min_length=min_length, + max_length=max_length, + ) + + +def ExcelMeta( + *, + label: str, + is_primary_key: bool = False, + unique: bool = False, + ignore_import: bool = False, + required: bool | None = None, + order: int = DEFAULT_FIELD_META_ORDER, + character_set: set[CharacterSet] | None = None, + fraction_digits: int | None = None, + timezone: datetime.timezone | None = None, + date_format: DateFormat | None = None, + date_range_option: DataRangeOption | None = None, + options: list[Option] | None = None, + unit: str | None = None, + hint: str | None = None, + ge: float | None = None, + le: float | None = None, + max_digits: int | None = None, + decimal_places: int | None = None, + min_items: int | None = None, + max_items: int | None = None, + unique_items: bool | None = None, + min_length: int | None = None, + max_length: int | None = None, +) -> FieldMetaInfo: + """Excel-specific metadata for use with Annotated[..., Field(...), ExcelMeta(...)].""" + return _build_excel_metadata( + label=label, + is_primary_key=is_primary_key, + unique=unique, + ignore_import=ignore_import, + required=required, + order=order, + character_set=character_set, + fraction_digits=fraction_digits, + timezone=timezone, + date_format=date_format, + date_range_option=date_range_option, + options=options, + unit=unit, + hint=hint, + ge=ge, + le=le, + max_digits=max_digits, + decimal_places=decimal_places, + min_items=min_items, + max_items=max_items, + unique_items=unique_items, + min_length=min_length, + max_length=max_length, + ) + + +# pylint: disable=invalid-name +# pylint: disable=too-many-locals +def FieldMeta( + default: object = PydanticUndefined, + *, + label: str, + is_primary_key: bool = False, + unique: bool = False, + ignore_import: bool = False, + required: bool | None = None, + order: int = DEFAULT_FIELD_META_ORDER, + character_set: set[CharacterSet] | None = None, + fraction_digits: int | None = None, + timezone: datetime.timezone | None = None, + date_format: DateFormat | None = None, + date_range_option: DataRangeOption | None = None, + options: list[Option] | None = None, + unit: str | None = None, + hint: str | None = None, + default_factory: FieldDefaultFactory | None = None, + alias: str | None = None, + title: str | None = None, + description: str | None = None, + exclude: FieldIncludeExclude = None, + include: FieldIncludeExclude = None, + const: bool | None = None, + ge: float | None = None, + le: float | None = None, + multiple_of: float | None = None, + allow_inf_nan: bool | None = None, + max_digits: int | None = None, + decimal_places: int | None = None, + min_items: int | None = None, + max_items: int | None = None, + unique_items: bool | None = None, + min_length: int | None = None, + max_length: int | None = None, + allow_mutation: bool | None = True, + regex: str | None = None, + discriminator: str | None = None, + repr: bool = True, + **extra: object, +) -> Any: + metadata = _build_excel_metadata( + label=label, + is_primary_key=is_primary_key, + unique=unique, + ignore_import=ignore_import, + required=required, + order=order, + character_set=character_set, + fraction_digits=fraction_digits, + timezone=timezone, + date_format=date_format, + date_range_option=date_range_option, + options=options, + unit=unit, + hint=hint, + ge=ge, + le=le, + max_digits=max_digits, + decimal_places=decimal_places, + min_items=min_items, + max_items=max_items, + unique_items=unique_items, + min_length=min_length, + max_length=max_length, + ) + + json_schema_extra: dict[str, Any] = {EXCEL_FIELD_METADATA_KEY: metadata} | extra + if include is not None: + json_schema_extra['include'] = include + if const is not None: + json_schema_extra['const'] = const + if min_items is not None: + json_schema_extra['min_items'] = min_items + if max_items is not None: + json_schema_extra['max_items'] = max_items + if unique_items is not None: + json_schema_extra['unique_items'] = unique_items + + field_kwargs: dict[str, Any] = { + 'repr': repr, + 'json_schema_extra': json_schema_extra, + } + if default_factory is not None: + field_kwargs['default_factory'] = default_factory + if alias is not None: + field_kwargs['alias'] = alias + if title is not None: + field_kwargs['title'] = title + if description is not None: + field_kwargs['description'] = description + if isinstance(exclude, bool): + field_kwargs['exclude'] = exclude + if ge is not None: + field_kwargs['ge'] = ge + if le is not None: + field_kwargs['le'] = le + if multiple_of is not None: + field_kwargs['multiple_of'] = multiple_of + if allow_inf_nan is not None: + field_kwargs['allow_inf_nan'] = allow_inf_nan + if max_digits is not None: + field_kwargs['max_digits'] = max_digits + if decimal_places is not None: + field_kwargs['decimal_places'] = decimal_places + if min_length is not None: + field_kwargs['min_length'] = min_length + if max_length is not None: + field_kwargs['max_length'] = max_length + if regex is not None: + field_kwargs['pattern'] = regex + if discriminator is not None: + field_kwargs['discriminator'] = discriminator + if allow_mutation is not None and allow_mutation is not True: + field_kwargs['frozen'] = not allow_mutation + + return Field(default, **field_kwargs) diff --git a/excelalchemy/py.typed b/src/excelalchemy/py.typed similarity index 100% rename from excelalchemy/py.typed rename to src/excelalchemy/py.typed diff --git a/src/excelalchemy/results.py b/src/excelalchemy/results.py new file mode 100644 index 0000000..e41795a --- /dev/null +++ b/src/excelalchemy/results.py @@ -0,0 +1,91 @@ +"""Import result models for ExcelAlchemy workflows.""" + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + +from excelalchemy._primitives.identity import Label +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg +from excelalchemy.i18n.messages import message as msg + + +def _empty_labels() -> list[Label]: + return [] + + +class ValidateRowResult(StrEnum): + """Per-row validation status.""" + + SUCCESS = 'SUCCESS' + FAIL = 'FAIL' + + def __str__(self) -> str: + if self is ValidateRowResult.SUCCESS: + return dmsg(MessageKey.VALIDATE_ROW_SUCCESS) + return dmsg(MessageKey.VALIDATE_ROW_FAIL) + + +class ValidateHeaderResult(BaseModel): + """Header validation result.""" + + missing_required: list[Label] = Field(description='Required headers missing from the workbook.') + missing_primary: list[Label] = Field(description='Primary-key headers missing from the workbook.') + unrecognized: list[Label] = Field(description='Headers present in the workbook but unknown to the schema.') + duplicated: list[Label] = Field(description='Headers that appear more than once in the workbook.') + is_valid: bool = Field(default=True, description='Whether header validation succeeded.') + + @property + def is_required_missing(self) -> bool: + """Return whether any required headers are missing.""" + return bool(self.missing_required) + + +class ValidateResult(StrEnum): + """High-level import result type.""" + + HEADER_INVALID = 'HEADER_INVALID' + DATA_INVALID = 'DATA_INVALID' + SUCCESS = 'SUCCESS' + + +class ImportResult(BaseModel): + """Structured result returned from an import run.""" + + model_config = ConfigDict(extra='allow') + + result: ValidateResult = Field(description='Overall import result.') + + is_required_missing: bool = Field(default=False, description='Whether required headers are missing.') + missing_required: list[Label] = Field( + default_factory=_empty_labels, description='Required headers missing from the workbook.' + ) + missing_primary: list[Label] = Field( + default_factory=_empty_labels, description='Primary-key headers missing from the workbook.' + ) + unrecognized: list[Label] = Field( + default_factory=_empty_labels, description='Headers present in the workbook but unknown to the schema.' + ) + duplicated: list[Label] = Field( + default_factory=_empty_labels, description='Headers that appear more than once in the workbook.' + ) + + url: str | None = Field( + default=None, description='Download URL for the import result workbook when one is produced.' + ) + 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.') + + @classmethod + def from_validate_header_result(cls, result: ValidateHeaderResult) -> 'ImportResult': + """Build an import result from a failed header-validation result.""" + if result.is_valid: + raise RuntimeError(msg(MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION)) + return cls( + result=ValidateResult.HEADER_INVALID, + is_required_missing=result.is_required_missing, + missing_primary=result.missing_primary, + unrecognized=result.unrecognized, + duplicated=result.duplicated, + missing_required=result.missing_required, + ) diff --git a/src/excelalchemy/types/__init__.py b/src/excelalchemy/types/__init__.py new file mode 100644 index 0000000..957b903 --- /dev/null +++ b/src/excelalchemy/types/__init__.py @@ -0,0 +1,15 @@ +"""Compatibility re-exports for the pre-refactor ``excelalchemy.types`` namespace.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import( + 'excelalchemy.types', + 'excelalchemy.metadata, excelalchemy.results, excelalchemy.config, excelalchemy.codecs, and the excelalchemy package root', +) + +from excelalchemy._primitives.header_models import * # noqa: F403 +from excelalchemy._primitives.identity import * # noqa: F403 +from excelalchemy.codecs.base import * # noqa: F403 +from excelalchemy.config import * # noqa: F403 +from excelalchemy.metadata import * # noqa: F403 +from excelalchemy.results import * # noqa: F403 diff --git a/src/excelalchemy/types/abstract.py b/src/excelalchemy/types/abstract.py new file mode 100644 index 0000000..e0a3f83 --- /dev/null +++ b/src/excelalchemy/types/abstract.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.abstract``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.abstract', 'excelalchemy.codecs.base') + +from excelalchemy.codecs.base import * # noqa: F403 diff --git a/src/excelalchemy/types/alchemy.py b/src/excelalchemy/types/alchemy.py new file mode 100644 index 0000000..6368a5a --- /dev/null +++ b/src/excelalchemy/types/alchemy.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.alchemy``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.alchemy', 'excelalchemy.config') + +from excelalchemy.config import * # noqa: F403 diff --git a/src/excelalchemy/types/field.py b/src/excelalchemy/types/field.py new file mode 100644 index 0000000..c6db574 --- /dev/null +++ b/src/excelalchemy/types/field.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.field``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.field', 'excelalchemy.metadata') + +from excelalchemy.metadata import * # noqa: F403 diff --git a/src/excelalchemy/types/header.py b/src/excelalchemy/types/header.py new file mode 100644 index 0000000..870a56f --- /dev/null +++ b/src/excelalchemy/types/header.py @@ -0,0 +1,10 @@ +"""Compatibility shim for ``excelalchemy.types.header``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import( + 'excelalchemy.types.header', + 'ExcelAlchemy internals only; avoid importing header models directly', +) + +from excelalchemy._primitives.header_models import * # noqa: F403 diff --git a/src/excelalchemy/types/identity.py b/src/excelalchemy/types/identity.py new file mode 100644 index 0000000..863ce85 --- /dev/null +++ b/src/excelalchemy/types/identity.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.identity``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.identity', 'the excelalchemy package root') + +from excelalchemy._primitives.identity import * # noqa: F403 diff --git a/src/excelalchemy/types/result.py b/src/excelalchemy/types/result.py new file mode 100644 index 0000000..582c1f1 --- /dev/null +++ b/src/excelalchemy/types/result.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.result``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.result', 'excelalchemy.results') + +from excelalchemy.results import * # noqa: F403 diff --git a/src/excelalchemy/types/value/__init__.py b/src/excelalchemy/types/value/__init__.py new file mode 100644 index 0000000..3073ac1 --- /dev/null +++ b/src/excelalchemy/types/value/__init__.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value', 'excelalchemy.codecs') + +from excelalchemy.codecs import * # noqa: F403 diff --git a/src/excelalchemy/types/value/boolean.py b/src/excelalchemy/types/value/boolean.py new file mode 100644 index 0000000..894a930 --- /dev/null +++ b/src/excelalchemy/types/value/boolean.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.boolean``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.boolean', 'excelalchemy.codecs.boolean') + +from excelalchemy.codecs.boolean import * # noqa: F403 diff --git a/src/excelalchemy/types/value/date.py b/src/excelalchemy/types/value/date.py new file mode 100644 index 0000000..0bd18a8 --- /dev/null +++ b/src/excelalchemy/types/value/date.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.date``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.date', 'excelalchemy.codecs.date') + +from excelalchemy.codecs.date import * # noqa: F403 diff --git a/src/excelalchemy/types/value/date_range.py b/src/excelalchemy/types/value/date_range.py new file mode 100644 index 0000000..9165370 --- /dev/null +++ b/src/excelalchemy/types/value/date_range.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.date_range``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.date_range', 'excelalchemy.codecs.date_range') + +from excelalchemy.codecs.date_range import * # noqa: F403 diff --git a/src/excelalchemy/types/value/email.py b/src/excelalchemy/types/value/email.py new file mode 100644 index 0000000..722a84d --- /dev/null +++ b/src/excelalchemy/types/value/email.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.email``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.email', 'excelalchemy.codecs.email') + +from excelalchemy.codecs.email import * # noqa: F403 diff --git a/src/excelalchemy/types/value/money.py b/src/excelalchemy/types/value/money.py new file mode 100644 index 0000000..22a53c3 --- /dev/null +++ b/src/excelalchemy/types/value/money.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.money``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.money', 'excelalchemy.codecs.money') + +from excelalchemy.codecs.money import * # noqa: F403 diff --git a/src/excelalchemy/types/value/multi_checkbox.py b/src/excelalchemy/types/value/multi_checkbox.py new file mode 100644 index 0000000..b8a3e58 --- /dev/null +++ b/src/excelalchemy/types/value/multi_checkbox.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.multi_checkbox``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.multi_checkbox', 'excelalchemy.codecs.multi_checkbox') + +from excelalchemy.codecs.multi_checkbox import * # noqa: F403 diff --git a/src/excelalchemy/types/value/number.py b/src/excelalchemy/types/value/number.py new file mode 100644 index 0000000..f8ebc65 --- /dev/null +++ b/src/excelalchemy/types/value/number.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.number``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.number', 'excelalchemy.codecs.number') + +from excelalchemy.codecs.number import * # noqa: F403 diff --git a/src/excelalchemy/types/value/number_range.py b/src/excelalchemy/types/value/number_range.py new file mode 100644 index 0000000..e2c6cb3 --- /dev/null +++ b/src/excelalchemy/types/value/number_range.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.number_range``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.number_range', 'excelalchemy.codecs.number_range') + +from excelalchemy.codecs.number_range import * # noqa: F403 diff --git a/src/excelalchemy/types/value/organization.py b/src/excelalchemy/types/value/organization.py new file mode 100644 index 0000000..7608111 --- /dev/null +++ b/src/excelalchemy/types/value/organization.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.organization``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.organization', 'excelalchemy.codecs.organization') + +from excelalchemy.codecs.organization import * # noqa: F403 diff --git a/src/excelalchemy/types/value/phone_number.py b/src/excelalchemy/types/value/phone_number.py new file mode 100644 index 0000000..3c9f0e4 --- /dev/null +++ b/src/excelalchemy/types/value/phone_number.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.phone_number``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.phone_number', 'excelalchemy.codecs.phone_number') + +from excelalchemy.codecs.phone_number import * # noqa: F403 diff --git a/src/excelalchemy/types/value/radio.py b/src/excelalchemy/types/value/radio.py new file mode 100644 index 0000000..1436261 --- /dev/null +++ b/src/excelalchemy/types/value/radio.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.radio``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.radio', 'excelalchemy.codecs.radio') + +from excelalchemy.codecs.radio import * # noqa: F403 diff --git a/src/excelalchemy/types/value/staff.py b/src/excelalchemy/types/value/staff.py new file mode 100644 index 0000000..0752b28 --- /dev/null +++ b/src/excelalchemy/types/value/staff.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.staff``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.staff', 'excelalchemy.codecs.staff') + +from excelalchemy.codecs.staff import * # noqa: F403 diff --git a/src/excelalchemy/types/value/string.py b/src/excelalchemy/types/value/string.py new file mode 100644 index 0000000..86f1f29 --- /dev/null +++ b/src/excelalchemy/types/value/string.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.string``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.string', 'excelalchemy.codecs.string') + +from excelalchemy.codecs.string import * # noqa: F403 diff --git a/src/excelalchemy/types/value/tree.py b/src/excelalchemy/types/value/tree.py new file mode 100644 index 0000000..efef912 --- /dev/null +++ b/src/excelalchemy/types/value/tree.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.tree``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.tree', 'excelalchemy.codecs.tree') + +from excelalchemy.codecs.tree import * # noqa: F403 diff --git a/src/excelalchemy/types/value/url.py b/src/excelalchemy/types/value/url.py new file mode 100644 index 0000000..123975d --- /dev/null +++ b/src/excelalchemy/types/value/url.py @@ -0,0 +1,7 @@ +"""Compatibility shim for ``excelalchemy.types.value.url``.""" + +from excelalchemy._primitives.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.types.value.url', 'excelalchemy.codecs.url') + +from excelalchemy.codecs.url import * # noqa: F403 diff --git a/excelalchemy/types/__init__.py b/src/excelalchemy/util/__init__.py similarity index 100% rename from excelalchemy/types/__init__.py rename to src/excelalchemy/util/__init__.py diff --git a/excelalchemy/util/convertor.py b/src/excelalchemy/util/convertor.py similarity index 52% rename from excelalchemy/util/convertor.py rename to src/excelalchemy/util/convertor.py index 62157c0..79d6de6 100644 --- a/excelalchemy/util/convertor.py +++ b/src/excelalchemy/util/convertor.py @@ -1,21 +1,22 @@ import re -from typing import Any +from typing import cast -from excelalchemy.const import FIELD_DATA_KEY -from excelalchemy.types.identity import Key +from excelalchemy._primitives.constants import FIELD_DATA_KEY +from excelalchemy._primitives.identity import Key +from excelalchemy._primitives.payloads import ModelRowPayload -def import_data_converter(data: dict[str, Any]) -> dict[str, Any]: # noqa: C901 +def import_data_converter(data: ModelRowPayload) -> ModelRowPayload: # _to_snake_case - result: dict[str, Any] = {} + result: ModelRowPayload = {} for k, v in data.items(): snake_keys = [_to_snake_case(key) for key in k.split('.')] _nested_set(result, snake_keys, v) return result -def export_data_converter(data: dict[str, Any], to_camel: bool = False) -> dict[str, Any]: # noqa: C901 - result: dict[str, Any] = {} +def export_data_converter(data: ModelRowPayload, to_camel: bool = False) -> ModelRowPayload: + result: ModelRowPayload = {} for k, v in data.items(): camel_key = _to_camel_case(k) if to_camel else _to_snake_case(k) if camel_key != FIELD_DATA_KEY: @@ -23,8 +24,10 @@ def export_data_converter(data: dict[str, Any], to_camel: bool = False) -> dict[ continue if not v: continue + if not isinstance(v, dict): + raise TypeError(f'Expected fieldData payload to be a mapping, got {type(v)}') - for field_key, field_value in v.items(): + for field_key, field_value in cast(ModelRowPayload, v).items(): result[Key(f'{camel_key}.{field_key}')] = field_value return result @@ -41,7 +44,10 @@ def _to_camel_case(snake_str: str) -> str: return components[0] + ''.join(x.capitalize() if x else '_' for x in components[1:]) -def _nested_set(obj: dict[str, Any], keys: list[str], value: Any) -> None: +def _nested_set(obj: ModelRowPayload, keys: list[str], value: object) -> None: for key in keys[:-1]: - obj = obj.setdefault(key, {}) + nested = obj.setdefault(key, {}) + if not isinstance(nested, dict): + raise TypeError(f'Expected nested mapping at {key!r}, got {type(nested)}') + obj = cast(ModelRowPayload, nested) obj[keys[-1]] = value diff --git a/src/excelalchemy/util/file.py b/src/excelalchemy/util/file.py new file mode 100644 index 0000000..536c9cc --- /dev/null +++ b/src/excelalchemy/util/file.py @@ -0,0 +1,52 @@ +import math +from collections.abc import Mapping, Sequence +from typing import Any, cast + +from excelalchemy._primitives.constants import UNIQUE_HEADER_CONNECTOR + +EXCEL_MEDIA_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +EXCEL_PREFIX = f'data:{EXCEL_MEDIA_TYPE};base64' + + +def add_excel_prefix(content: str) -> str: + """Add Excel prefix for base64 content string.""" + + return f'{EXCEL_PREFIX},{content}' + + +def remove_excel_prefix(content: str) -> str: + """Remove Excel prefixes for base64 content string.""" + prefix = f'{EXCEL_PREFIX},' + return content.removeprefix(prefix) + + +def flatten(data: Mapping[str, object], level: list[str] | None = None) -> dict[str, object]: + """Flatten a nested mapping into unique-header paths. + + >>> flatten( {'a': {'b': {'c': 12}}}) # dotted path expansion + {'a.b.c': 12} + """ + tmp_dict: dict[str, object] = {} + level = level or [] + for key, val in data.items(): + if isinstance(val, Mapping): + nested = cast(Mapping[str, object], val) + tmp_dict.update(flatten(nested, [*level, key])) + else: + tmp_dict[f'{UNIQUE_HEADER_CONNECTOR}'.join([*level, key])] = val + return tmp_dict + + +def value_is_nan(value: Any) -> bool: + """Return whether a worksheet value should be treated as empty or NaN.""" + if value is None: + return True + + if isinstance(value, float) and math.isnan(value): + return True + + if isinstance(value, Sequence) and not isinstance(value, str): + items = cast(Sequence[object], value) + return any(value_is_nan(item) for item in items) + + return False diff --git a/tests/__init__.py b/tests/__init__.py index a001d73..8276f49 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,35 +1,3 @@ -from typing import Any -from typing import cast -from unittest import IsolatedAsyncioTestCase +from tests.support import BaseTestCase, FileRegistry, InMemoryExcelStorage, LocalMockMinio, local_minio -from minio import Minio -from pydantic import BaseModel - -from excelalchemy import ColumnIndex -from excelalchemy import ExcelAlchemy -from excelalchemy import ImporterConfig -from excelalchemy import RowIndex -from tests.mock_minio import LocalMockMinio -from tests.mock_minio import local_minio - - -class BaseTestCase(IsolatedAsyncioTestCase): - minio = local_minio - first_data_row: RowIndex = 0 - first_data_col: ColumnIndex = 2 - - @staticmethod - async def fake_creator(data: dict[str, Any], context: dict[str, Any] | None) -> dict[str, Any]: - return data - - def build_alchemy( - self, - importer: type[BaseModel], - ) -> ExcelAlchemy: - return ExcelAlchemy( - ImporterConfig( - importer, - creator=self.fake_creator, - minio=cast(Minio, self.minio), - ), - ) +__all__ = ['BaseTestCase', 'FileRegistry', 'InMemoryExcelStorage', 'LocalMockMinio', 'local_minio'] diff --git a/excelalchemy/util/__init__.py b/tests/contracts/__init__.py similarity index 100% rename from excelalchemy/util/__init__.py rename to tests/contracts/__init__.py diff --git a/tests/contracts/test_core_components_contract.py b/tests/contracts/test_core_components_contract.py new file mode 100644 index 0000000..b52010c --- /dev/null +++ b/tests/contracts/test_core_components_contract.py @@ -0,0 +1,87 @@ +from pydantic import BaseModel + +from excelalchemy import DateFormat, DateRange, ExcelCellError, FieldMeta, Key, Label, RowIndex +from excelalchemy.config import ImportMode +from excelalchemy.core.alchemy import REASON_COLUMN, RESULT_COLUMN +from excelalchemy.core.headers import ExcelHeaderParser, ExcelHeaderValidator +from excelalchemy.core.rows import ImportIssueTracker, RowAggregator +from excelalchemy.core.schema import ExcelSchemaLayout +from excelalchemy.core.table import WorksheetTable +from tests.support.contract_models import MergedContractImporter, SimpleContractImporter + + +class TestCoreComponentContracts: + def test_schema_layout_expands_composite_parent_keys_in_layout_order(self): + layout = ExcelSchemaLayout.from_model(MergedContractImporter) + + selected = layout.select_output_excel_keys([Key('salary')]) + + assert selected == ['salary·start', 'salary·end'] + assert layout.get_output_parent_excel_headers(selected) == ['工资·最小值', '工资·最大值'] + assert layout.get_output_child_excel_headers(selected) == ['最小值', '最大值'] + assert layout.has_merged_header(selected) is True + + def test_header_parser_and_validator_accept_generated_simple_headers_as_contract(self): + layout = ExcelSchemaLayout.from_model(SimpleContractImporter) + header_df = WorksheetTable(rows=[layout.get_output_parent_excel_headers()]) + parser = ExcelHeaderParser() + validator = ExcelHeaderValidator() + + headers = parser.extract(header_df) + result = validator.validate(headers, layout, ImportMode.CREATE) + + assert [header.unique_label for header in headers] == layout.get_output_parent_excel_headers() + assert result.is_valid is True + + def test_header_validator_accepts_merged_headers_with_repeated_child_labels_under_different_parents(self): + class DualRangeImporter(BaseModel): + stay_range: DateRange = FieldMeta(label='停留时间', order=1, date_format=DateFormat.DAY) + travel_range: DateRange = FieldMeta(label='出行时间', order=2, date_format=DateFormat.DAY) + + layout = ExcelSchemaLayout.from_model(DualRangeImporter) + header_df = WorksheetTable( + rows=[['停留时间', None, '出行时间', None], ['开始日期', '结束日期', '开始日期', '结束日期']] + ) + parser = ExcelHeaderParser() + validator = ExcelHeaderValidator() + + headers = parser.extract(header_df) + result = validator.validate(headers, layout, ImportMode.CREATE) + + assert [header.unique_label for header in headers] == [ + '停留时间·开始日期', + '停留时间·结束日期', + '出行时间·开始日期', + '出行时间·结束日期', + ] + assert result.duplicated == [] + assert result.unrecognized == [] + assert result.missing_required == [] + assert result.is_valid is True + + def test_row_aggregator_groups_composite_cells_back_into_parent_payload(self): + layout = ExcelSchemaLayout.from_model(MergedContractImporter) + aggregator = RowAggregator(layout, ImportMode.CREATE) + + row_data = { + '工资·最小值': '1000', + '工资·最大值': '2000', + } + + assert aggregator.aggregate(row_data) == { + 'salary': {'start': 1000, 'end': 2000}, + } + + def test_issue_tracker_offsets_cell_errors_after_result_columns(self): + layout = ExcelSchemaLayout.from_model(SimpleContractImporter) + tracker = ImportIssueTracker(layout, [RESULT_COLUMN, REASON_COLUMN]) + df = WorksheetTable(columns=['姓名'], rows=[['张三']]) + error = ExcelCellError(label=Label('姓名'), message='Simulated failure') + + tracker.register_cell_errors(RowIndex(0), [error], df) + + assert tracker.cell_errors == { + RowIndex(0): { + 2: [error], + } + } diff --git a/tests/contracts/test_export_contract.py b/tests/contracts/test_export_contract.py new file mode 100644 index 0000000..7dcfa43 --- /dev/null +++ b/tests/contracts/test_export_contract.py @@ -0,0 +1,76 @@ +from typing import cast + +from minio import Minio + +from excelalchemy import ExcelAlchemy, ExporterConfig +from tests.support import BaseTestCase, decode_prefixed_excel_to_workbook, load_binary_excel_to_workbook +from tests.support.contract_models import ( + MergedContractImporter, + SimpleContractImporter, + sample_merged_export_row, + sample_simple_export_row, +) + + +class TestExportContracts(BaseTestCase): + async def test_export_returns_prefixed_base64_payload(self): + alchemy = ExcelAlchemy(ExporterConfig(SimpleContractImporter, minio=cast(Minio, self.minio))) + + content = alchemy.export([sample_simple_export_row()]) + + assert content.startswith('data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,') + + async def test_export_artifact_returns_binary_excel_payload(self): + alchemy = ExcelAlchemy(ExporterConfig(SimpleContractImporter, minio=cast(Minio, self.minio))) + + artifact = alchemy.export_artifact([sample_simple_export_row()], filename='people-export.xlsx') + workbook = load_binary_excel_to_workbook(artifact.as_bytes()) + worksheet = workbook['Sheet1'] + + assert artifact.filename == 'people-export.xlsx' + assert artifact.media_type == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + assert artifact.as_bytes().startswith(b'PK') + assert artifact.as_data_url().startswith( + 'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,' + ) + assert worksheet['A2'].value == '年龄' + assert worksheet['A3'].value == '18' + + async def test_export_returns_only_selected_columns_when_keys_are_provided(self): + alchemy = ExcelAlchemy(ExporterConfig(SimpleContractImporter, minio=cast(Minio, self.minio))) + + workbook = decode_prefixed_excel_to_workbook(alchemy.export([sample_simple_export_row()], keys=['name', 'age'])) + worksheet = workbook['Sheet1'] + + assert worksheet['A2'].value == '年龄' + assert worksheet['B2'].value == '姓名' + assert worksheet.max_column == 2 + assert worksheet['A3'].value == '18' + assert worksheet['B3'].value == '张三' + + async def test_export_preserves_parent_and_child_headers_for_merged_layout(self): + alchemy = ExcelAlchemy(ExporterConfig(MergedContractImporter, minio=cast(Minio, self.minio))) + + workbook = decode_prefixed_excel_to_workbook(alchemy.export([sample_merged_export_row()])) + worksheet = workbook['Sheet1'] + + second_row = [worksheet.cell(row=2, column=index).value for index in range(1, worksheet.max_column + 1)] + third_row = [worksheet.cell(row=3, column=index).value for index in range(1, worksheet.max_column + 1)] + + assert '最大停留日期' in second_row + assert '工资' in second_row + assert '开始日期' in third_row + assert '结束日期' in third_row + assert '最小值' in third_row + assert '最大值' in third_row + + async def test_export_returns_user_visible_values_for_complex_value_types(self): + alchemy = ExcelAlchemy(ExporterConfig(MergedContractImporter, minio=cast(Minio, self.minio))) + + workbook = decode_prefixed_excel_to_workbook(alchemy.export([sample_merged_export_row()])) + worksheet = workbook['Sheet1'] + + assert worksheet['D4'].value == '是' + assert worksheet['O4'].value == '选项1' + assert worksheet['R4'].value == '2020-01-01' + assert worksheet['S4'].value == '2021-01-02' diff --git a/tests/contracts/test_import_contract.py b/tests/contracts/test_import_contract.py new file mode 100644 index 0000000..dd22ec1 --- /dev/null +++ b/tests/contracts/test_import_contract.py @@ -0,0 +1,175 @@ +import io +from typing import cast + +from minio import Minio +from openpyxl import load_workbook + +from excelalchemy import ExcelAlchemy, ImporterConfig, ValidateResult +from excelalchemy.const import BACKGROUND_ERROR_COLOR, REASON_COLUMN_LABEL, RESULT_COLUMN_LABEL +from tests.support import BaseTestCase, FileRegistry, get_fill_color, load_binary_excel_to_workbook +from tests.support.contract_models import MergedContractImporter, SimpleContractImporter, creator, failing_creator + + +class TestImportContracts(BaseTestCase): + async def test_import_data_returns_success_result_for_valid_workbook(self): + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + result = await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT, + output_excel_name='contract-success.xlsx', + ) + + assert result.result == ValidateResult.SUCCESS + assert result.success_count == 1 + assert result.fail_count == 0 + assert result.url is None + + async def test_import_data_returns_header_invalid_result_for_invalid_header(self): + output_name = 'contract-header-invalid.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + result = await alchemy.import_data( + input_excel_name=FileRegistry.TEST_HEADER_INVALID_INPUT, + output_excel_name=output_name, + ) + + assert result.result == ValidateResult.HEADER_INVALID + assert set(result.unrecognized) == {'不存在的表头'} + assert '年龄' in set(result.missing_required) + assert output_name not in self.minio.storage + + async def test_import_data_reloads_workbook_state_on_each_run(self): + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + first_result = await alchemy.import_data( + input_excel_name=FileRegistry.TEST_HEADER_INVALID_INPUT, + output_excel_name='contract-first-header-invalid.xlsx', + ) + second_result = await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT, + output_excel_name='contract-second-success.xlsx', + ) + + assert first_result.result == ValidateResult.HEADER_INVALID + assert second_result.result == ValidateResult.SUCCESS + assert second_result.success_count == 1 + assert second_result.fail_count == 0 + assert second_result.url is None + + async def test_import_data_uploads_result_workbook_for_invalid_rows(self): + output_name = 'contract-data-invalid.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + result = await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR, + output_excel_name=output_name, + ) + + assert result.result == ValidateResult.DATA_INVALID + assert result.success_count == 0 + assert result.fail_count == 1 + assert result.url == f'excel/{output_name}' + assert output_name in self.minio.storage + + async def test_import_result_workbook_returns_result_and_reason_columns(self): + output_name = 'contract-data-invalid-columns.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR, + output_excel_name=output_name, + ) + workbook = load_binary_excel_to_workbook(self.minio.storage[output_name]['data'].getvalue()) + worksheet = workbook['Sheet1'] + + assert worksheet['A2'].value == RESULT_COLUMN_LABEL + assert worksheet['B2'].value == REASON_COLUMN_LABEL + assert worksheet['A3'].value == '校验不通过' + assert isinstance(worksheet['B3'].value, str) + assert worksheet['B3'].value.startswith('1、') + assert '【出生日期】' in worksheet['B3'].value + + async def test_import_result_workbook_marks_failed_cells_in_red(self): + output_name = 'contract-data-invalid-colors.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR, + output_excel_name=output_name, + ) + workbook = load_binary_excel_to_workbook(self.minio.storage[output_name]['data'].getvalue()) + worksheet = workbook['Sheet1'] + row_colors = [get_fill_color(cell) for cell in worksheet[3]] + + assert BACKGROUND_ERROR_COLOR in row_colors + + async def test_import_result_workbook_marks_business_cell_errors_in_red(self): + output_name = 'contract-data-invalid-business-cell.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy( + ImporterConfig(SimpleContractImporter, creator=failing_creator, minio=cast(Minio, self.minio)) + ) + + await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT, + output_excel_name=output_name, + ) + workbook = load_binary_excel_to_workbook(self.minio.storage[output_name]['data'].getvalue()) + worksheet = workbook['Sheet1'] + + assert worksheet['B3'].value == '1、【姓名】Simulated failure' + assert get_fill_color(worksheet['D3']) == BACKGROUND_ERROR_COLOR + + async def test_import_result_workbook_supports_english_display_locale(self): + output_name = 'contract-data-invalid-english.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy( + ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio), locale='en') + ) + + await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR, + output_excel_name=output_name, + ) + workbook = load_binary_excel_to_workbook(self.minio.storage[output_name]['data'].getvalue()) + worksheet = workbook['Sheet1'] + + assert worksheet['A2'].value == 'Validation result\nDelete this column before re-uploading' + assert worksheet['B2'].value == 'Failure reason\nDelete this column before re-uploading' + assert worksheet['A3'].value == 'Validation failed' + + async def test_import_result_workbook_marks_merged_header_failures_on_the_correct_data_row(self): + input_name = 'contract-merged-invalid-input.xlsx' + output_name = 'contract-merged-invalid-output.xlsx' + self.minio.storage.pop(output_name, None) + + source_content = self.minio.storage[FileRegistry.TEST_IMPORT_WITH_MERGE_HEADER]['data'].getvalue() + workbook = load_workbook(io.BytesIO(source_content)) + worksheet = workbook['Sheet1'] + worksheet['E4'] = 'not-a-date' + + buffer = io.BytesIO() + workbook.save(buffer) + workbook.close() + buffer.seek(0) + self.minio.put_object(self.minio.bucket_name, input_name, buffer, len(buffer.getvalue())) + + alchemy = ExcelAlchemy(ImporterConfig(MergedContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + result = await alchemy.import_data( + input_excel_name=input_name, + output_excel_name=output_name, + ) + + assert result.result == ValidateResult.DATA_INVALID + + result_workbook = load_binary_excel_to_workbook(self.minio.storage[output_name]['data'].getvalue()) + result_worksheet = result_workbook['Sheet1'] + + assert result_worksheet['A4'].value == '校验不通过' + assert isinstance(result_worksheet['B4'].value, str) + assert '【出生日期】' in result_worksheet['B4'].value diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py new file mode 100644 index 0000000..2efd3c5 --- /dev/null +++ b/tests/contracts/test_pydantic_contract.py @@ -0,0 +1,147 @@ +from typing import Annotated + +from pydantic import BaseModel, Field, field_validator, model_validator + +from excelalchemy import ( + DateFormat, + DateRange, + Email, + ExcelCellError, + ExcelFieldCodec, + ExcelMeta, + ExcelRowError, + FieldMeta, + Label, +) +from excelalchemy.helper.pydantic import extract_pydantic_model, instantiate_pydantic_model +from excelalchemy.metadata import FieldMetaInfo, extract_declared_field_metadata + + +class ContractPydanticModel(BaseModel): + email: Email = FieldMeta(label='邮箱', order=1) + stay_range: DateRange = FieldMeta(label='停留时间', order=2, date_format=DateFormat.DAY) + + +class TestPydanticContracts: + def test_fieldmeta_keeps_excel_metadata_outside_pydantic_fieldinfo_subclass(self): + raw_field_info = ContractPydanticModel.model_fields['email'] + + assert not isinstance(raw_field_info, FieldMetaInfo) + assert extract_declared_field_metadata(raw_field_info).label == Label('邮箱') + + def test_extract_pydantic_model_preserves_excel_metadata_shape(self): + metas = extract_pydantic_model(ContractPydanticModel) + + assert [meta.unique_label for meta in metas] == ['邮箱', '停留时间·开始日期', '停留时间·结束日期'] + assert [meta.parent_key for meta in metas] == ['email', 'stay_range', 'stay_range'] + assert [meta.key for meta in metas] == ['email', 'start', 'end'] + assert [meta.offset for meta in metas] == [0, 0, 1] + assert metas[0].required is True + + def test_instantiate_pydantic_model_maps_validation_errors_to_excel_cell_errors(self): + result = instantiate_pydantic_model({'email': 'not-an-email'}, ContractPydanticModel) + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0].label == Label('邮箱') + assert result[1].label == Label('停留时间') + + def test_instantiate_pydantic_model_applies_field_constraints_and_field_validators(self): + class FieldValidatedModel(BaseModel): + name: Email = FieldMeta(label='邮箱', order=1, min_length=20) + + @field_validator('name') + @classmethod + def must_use_company_domain(cls, value: str) -> str: + if not value.endswith('@example.com'): + raise ValueError('must use the company domain') + return value + + too_short = instantiate_pydantic_model({'name': 'a@b.co'}, FieldValidatedModel) + wrong_domain = instantiate_pydantic_model({'name': 'long-enough-address@openai.com'}, FieldValidatedModel) + + assert isinstance(too_short, list) + assert too_short == [ + ExcelCellError(label=Label('邮箱'), message='Value should have at least 20 items after validation, not 6') + ] + + assert isinstance(wrong_domain, list) + assert wrong_domain == [ExcelCellError(label=Label('邮箱'), message='Value error, must use the company domain')] + + def test_instantiate_pydantic_model_maps_model_validators_to_row_errors(self): + class ModelValidatedContract(BaseModel): + email: Email = FieldMeta(label='邮箱', order=1) + stay_range: DateRange = FieldMeta(label='停留时间', order=2, date_format=DateFormat.DAY) + + @model_validator(mode='after') + def reject_combination(self): + raise ValueError('combination invalid') + + result = instantiate_pydantic_model( + { + 'email': 'noreply@example.com', + 'stay_range': DateRange.model_validate({'start': '2024-01-01', 'end': '2024-01-02'}), + }, + ModelValidatedContract, + ) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], ExcelRowError) + assert str(result[0]) == 'Value error, combination invalid' + + def test_custom_excel_field_codec_can_define_new_style_extension_surface(self): + class UppercaseTextCodec(str, ExcelFieldCodec): + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + return f'Normalize {field_meta.label} to uppercase' + + @classmethod + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> str: + return str(value).strip() + + @classmethod + def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: + return '' if value is None else str(value) + + @classmethod + def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> str: + return str(value).upper() + + class CodecContractModel(BaseModel): + name: UppercaseTextCodec = FieldMeta(label='名称', order=1) + + metas = extract_pydantic_model(CodecContractModel) + result = instantiate_pydantic_model({'name': 'alice'}, CodecContractModel) + + assert metas[0].excel_codec is UppercaseTextCodec + assert metas[0].value_type is UppercaseTextCodec + assert isinstance(result, CodecContractModel) + assert result.name == 'ALICE' + + def test_annotated_excel_meta_supports_explicit_pydantic_v2_style_declarations(self): + class AnnotatedContractModel(BaseModel): + email: Annotated[Email, Field(min_length=20), ExcelMeta(label='邮箱', order=1)] + stay_range: Annotated[ + DateRange, + ExcelMeta(label='停留时间', order=2, date_format=DateFormat.DAY), + ] + + raw_field_info = AnnotatedContractModel.model_fields['email'] + declared_metadata = extract_declared_field_metadata(raw_field_info) + metas = extract_pydantic_model(AnnotatedContractModel) + result = instantiate_pydantic_model( + { + 'email': 'a@b.co', + 'stay_range': {'start': '2024-01-01', 'end': '2024-01-02'}, + }, + AnnotatedContractModel, + ) + + assert declared_metadata.label == Label('邮箱') + assert declared_metadata.importer_min_length == 20 + assert [meta.unique_label for meta in metas] == ['邮箱', '停留时间·开始日期', '停留时间·结束日期'] + assert isinstance(result, list) + assert result == [ + ExcelCellError(label=Label('邮箱'), message='Value should have at least 20 items after validation, not 6') + ] diff --git a/tests/contracts/test_result_contract.py b/tests/contracts/test_result_contract.py new file mode 100644 index 0000000..3bd5462 --- /dev/null +++ b/tests/contracts/test_result_contract.py @@ -0,0 +1,46 @@ +from excelalchemy import Label, ValidateResult +from excelalchemy.results import ImportResult, ValidateHeaderResult + + +class TestResultContracts: + def test_validate_header_result_returns_true_when_required_fields_are_missing(self): + result = ValidateHeaderResult( + missing_required=[Label('年龄')], + missing_primary=[], + unrecognized=[], + duplicated=[], + is_valid=False, + ) + + assert result.is_required_missing is True + + def test_import_result_from_validate_header_result_maps_all_header_fields(self): + validate_header = ValidateHeaderResult( + missing_required=[Label('年龄')], + missing_primary=[Label('邮箱')], + unrecognized=[Label('未知列')], + duplicated=[Label('姓名')], + is_valid=False, + ) + + result = ImportResult.from_validate_header_result(validate_header) + + assert result.result == ValidateResult.HEADER_INVALID + assert result.is_required_missing is True + assert result.missing_required == [Label('年龄')] + assert result.missing_primary == [Label('邮箱')] + assert result.unrecognized == [Label('未知列')] + assert result.duplicated == [Label('姓名')] + assert result.url is None + + def test_import_result_returns_success_defaults_for_success_case(self): + result = ImportResult(result=ValidateResult.SUCCESS, success_count=1) + + assert result.result == ValidateResult.SUCCESS + assert result.success_count == 1 + assert result.fail_count == 0 + assert result.url is None + assert result.missing_required == [] + assert result.missing_primary == [] + assert result.unrecognized == [] + assert result.duplicated == [] diff --git a/tests/contracts/test_storage_contract.py b/tests/contracts/test_storage_contract.py new file mode 100644 index 0000000..a01f679 --- /dev/null +++ b/tests/contracts/test_storage_contract.py @@ -0,0 +1,166 @@ +import io +from typing import cast + +from minio import Minio +from openpyxl import Workbook + +from excelalchemy import ConfigError, ExcelAlchemy, ExporterConfig, ImporterConfig, ValidateResult +from excelalchemy.core.storage import MissingStorageGateway, build_storage_gateway +from excelalchemy.core.storage_minio import MinioStorageGateway +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.core.table import WorksheetTable +from tests.support import BaseTestCase, FileRegistry, InMemoryExcelStorage +from tests.support.contract_models import SimpleContractImporter, creator, sample_simple_export_row + + +class TestStorageContracts(BaseTestCase): + def _build_storage_gateway(self) -> ExcelStorage: + config = ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio)) + return build_storage_gateway(config) + + async def test_default_storage_gateway_conforms_to_excel_storage_protocol(self): + gateway = self._build_storage_gateway() + + assert isinstance(gateway, ExcelStorage) + assert isinstance(gateway, MinioStorageGateway) + + async def test_missing_storage_gateway_is_used_when_no_backend_is_configured(self): + config = ImporterConfig(SimpleContractImporter, creator=creator) + gateway = build_storage_gateway(config) + + assert isinstance(gateway, MissingStorageGateway) + + async def test_template_generation_does_not_require_storage_backend(self): + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator)) + + template = alchemy.download_template([sample_simple_export_row()]) + + assert template.startswith('data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,') + + async def test_export_upload_without_storage_backend_raises_clear_error(self): + alchemy = ExcelAlchemy(ExporterConfig(SimpleContractImporter)) + + with self.assertRaises(ConfigError) as cm: + alchemy.export_upload('missing-storage.xlsx', [sample_simple_export_row()]) + + self.assertEqual( + str(cm.exception), + 'No storage backend is configured; pass storage=... or install and configure ExcelAlchemy[minio]', + ) + + async def test_explicit_storage_is_preferred_over_legacy_minio_settings(self): + input_name = FileRegistry.TEST_SIMPLE_IMPORT + input_bytes = self.minio.storage[input_name]['data'].getvalue() + storage = InMemoryExcelStorage({input_name: input_bytes}) + config = ImporterConfig( + SimpleContractImporter, + creator=creator, + storage=storage, + minio=cast(Minio, self.minio), + ) + gateway = build_storage_gateway(config) + + assert gateway is storage + + alchemy = ExcelAlchemy(config) + result = await alchemy.import_data( + input_excel_name=input_name, + output_excel_name='storage-preferred.xlsx', + ) + + assert result.result == ValidateResult.SUCCESS + assert 'storage-preferred.xlsx' not in self.minio.storage + + async def test_export_upload_supports_explicit_custom_storage(self): + storage = InMemoryExcelStorage() + output_name = 'contract-export-memory.xlsx' + alchemy = ExcelAlchemy(ExporterConfig(SimpleContractImporter, storage=storage)) + + url = alchemy.export_upload(output_name, [sample_simple_export_row()]) + + assert url == f'memory://{output_name}' + assert output_name in storage.uploaded + assert storage.uploaded[output_name].startswith(b'PK') + + async def test_import_failure_upload_supports_explicit_custom_storage(self): + input_name = FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR + input_bytes = self.minio.storage[input_name]['data'].getvalue() + output_name = 'contract-import-memory.xlsx' + storage = InMemoryExcelStorage({input_name: input_bytes}) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, storage=storage)) + + result = await alchemy.import_data(input_excel_name=input_name, output_excel_name=output_name) + + assert result.result == ValidateResult.DATA_INVALID + assert result.url == f'memory://{output_name}' + assert output_name in storage.uploaded + assert storage.uploaded[output_name].startswith(b'PK') + + async def test_export_upload_stores_generated_workbook_in_minio(self): + output_name = 'contract-export-upload.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy(ExporterConfig(SimpleContractImporter, minio=cast(Minio, self.minio))) + + url = alchemy.export_upload(output_name, [sample_simple_export_row()]) + + assert url == f'excel/{output_name}' + assert output_name in self.minio.storage + assert self.minio.storage[output_name]['bucket_name'] == 'excel' + assert self.minio.storage[output_name]['data'].getvalue().startswith(b'PK') + + async def test_import_failure_upload_uses_requested_output_excel_name(self): + output_name = 'contract-import-upload.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + await alchemy.import_data( + input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR, + output_excel_name=output_name, + ) + + assert output_name in self.minio.storage + assert self.minio.storage[output_name]['filename'] == output_name + + async def test_uploaded_payload_remains_binary_excel_content_without_prefix(self): + output_name = 'contract-upload-bytes.xlsx' + self.minio.storage.pop(output_name, None) + alchemy = ExcelAlchemy(ExporterConfig(SimpleContractImporter, minio=cast(Minio, self.minio))) + + alchemy.export_upload(output_name, [sample_simple_export_row()]) + payload = self.minio.storage[output_name]['data'].getvalue() + + assert payload.startswith(b'PK') + assert not payload.startswith(b'data:application') + + async def test_storage_reader_returns_worksheet_table_for_simple_import_workbook(self): + gateway = self._build_storage_gateway() + + table = gateway.read_excel_table(FileRegistry.TEST_SIMPLE_IMPORT, skiprows=1, sheet_name='Sheet1') + + assert isinstance(table, WorksheetTable) + assert table.shape == (2, 17) + assert table.iloc[0].tolist()[:3] == ['年龄', '姓名', '地址'] + assert table.iloc[1].tolist()[:3] == ['18', '张三', '北京市'] + + async def test_storage_reader_preserves_empty_cells_from_merged_headers(self): + gateway = self._build_storage_gateway() + workbook = Workbook() + worksheet = workbook.active + assert worksheet is not None + worksheet.title = 'Sheet1' + worksheet.append(['HEADER_HINT', None]) + worksheet.append(['日期范围', None]) + worksheet.append(['开始日期', '结束日期']) + worksheet.append(['2024-01-01', '2024-01-31']) + worksheet.merge_cells('A2:B2') + + file_object = io.BytesIO() + workbook.save(file_object) + payload = file_object.getvalue() + input_name = 'contract-merged-reader.xlsx' + self.minio.put_object(self.minio.bucket_name, input_name, io.BytesIO(payload), len(payload)) + + table = gateway.read_excel_table(input_name, skiprows=1, sheet_name='Sheet1') + + assert table.iloc[0].tolist() == ['日期范围', None] + assert table.iloc[1].tolist() == ['开始日期', '结束日期'] diff --git a/tests/contracts/test_template_contract.py b/tests/contracts/test_template_contract.py new file mode 100644 index 0000000..b05dd05 --- /dev/null +++ b/tests/contracts/test_template_contract.py @@ -0,0 +1,104 @@ +from typing import cast + +from minio import Minio + +from excelalchemy import ExcelAlchemy, ImporterConfig +from excelalchemy.const import BACKGROUND_REQUIRED_COLOR, HEADER_HINT +from tests.support import ( + BaseTestCase, + decode_prefixed_excel_to_workbook, + get_fill_color, + list_data_validations, + list_merge_ranges, + load_binary_excel_to_workbook, +) +from tests.support.contract_models import MergedContractImporter, SimpleContractImporter, creator + + +class TestTemplateContracts(BaseTestCase): + async def test_download_template_returns_prefixed_base64_payload(self): + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + content = alchemy.download_template() + + assert content.startswith('data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,') + + async def test_download_template_artifact_returns_binary_excel_payload(self): + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + artifact = alchemy.download_template_artifact(filename='people-template.xlsx') + workbook = load_binary_excel_to_workbook(artifact.as_bytes()) + worksheet = workbook['Sheet1'] + + assert artifact.filename == 'people-template.xlsx' + assert artifact.media_type == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + assert artifact.as_bytes().startswith(b'PK') + assert artifact.as_data_url().startswith( + 'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,' + ) + assert worksheet['A2'].value == '年龄' + + async def test_download_template_returns_sample_rows_with_user_visible_values(self): + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + workbook = decode_prefixed_excel_to_workbook( + alchemy.download_template([{'age': 18, 'name': '张三', 'radio': '选项1'}]) + ) + worksheet = workbook['Sheet1'] + + assert worksheet['A1'].value == HEADER_HINT + assert worksheet['A3'].value == '18' + assert worksheet['B3'].value == '张三' + assert worksheet['O3'].value == '选项1' + + async def test_download_template_returns_simple_header_with_required_fill_and_comment(self): + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + workbook = decode_prefixed_excel_to_workbook(alchemy.download_template()) + worksheet = workbook['Sheet1'] + + assert worksheet['A2'].value == '年龄' + assert get_fill_color(worksheet['A2']) == BACKGROUND_REQUIRED_COLOR + assert worksheet['A2'].comment is not None + assert '必填性:必填' in worksheet['A2'].comment.text + + async def test_download_template_returns_merged_header_with_expected_merge_ranges(self): + alchemy = ExcelAlchemy(ImporterConfig(MergedContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + workbook = decode_prefixed_excel_to_workbook(alchemy.download_template()) + worksheet = workbook['Sheet1'] + merge_ranges = list_merge_ranges(worksheet) + second_row = [worksheet.cell(row=2, column=index).value for index in range(1, worksheet.max_column + 1)] + third_row = [worksheet.cell(row=3, column=index).value for index in range(1, worksheet.max_column + 1)] + + assert worksheet['A1'].value == HEADER_HINT + assert '最大停留日期' in second_row + assert '工资' in second_row + assert '开始日期' in third_row + assert '结束日期' in third_row + assert '最小值' in third_row + assert '最大值' in third_row + assert 'A2:A3' in merge_ranges + assert 'R2:S2' in merge_ranges + assert 'T2:U2' in merge_ranges + + async def test_download_template_returns_workbook_without_excel_data_validation(self): + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) + + workbook = decode_prefixed_excel_to_workbook(alchemy.download_template()) + worksheet = workbook['Sheet1'] + validations = list_data_validations(worksheet) + + assert validations == [] + + async def test_download_template_supports_english_display_locale(self): + alchemy = ExcelAlchemy( + ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio), locale='en') + ) + + workbook = decode_prefixed_excel_to_workbook(alchemy.download_template()) + worksheet = workbook['Sheet1'] + + assert worksheet['A1'].value.startswith('Import instructions:') + assert worksheet['A2'].comment is not None + assert 'Required: required' in worksheet['A2'].comment.text diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_import.py b/tests/integration/test_excelalchemy_workflows.py similarity index 69% rename from tests/test_import.py rename to tests/integration/test_excelalchemy_workflows.py index 4a81a5f..0fb86f1 100644 --- a/tests/test_import.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -1,51 +1,48 @@ -import asyncio import datetime import random -from typing import Any -from typing import cast +from typing import Annotated, Any, cast from minio import Minio -from pydantic import BaseModel - -from excelalchemy import Boolean -from excelalchemy import ColumnIndex -from excelalchemy import ConfigError -from excelalchemy import Date -from excelalchemy import DateFormat -from excelalchemy import DateRange -from excelalchemy import Email -from excelalchemy import ExcelAlchemy -from excelalchemy import ExcelCellError -from excelalchemy import ExporterConfig -from excelalchemy import FieldMeta -from excelalchemy import ImporterConfig -from excelalchemy import ImportMode -from excelalchemy import Label -from excelalchemy import Money -from excelalchemy import MultiCheckbox -from excelalchemy import MultiOrganization -from excelalchemy import MultiStaff -from excelalchemy import MultiTreeNode -from excelalchemy import Number -from excelalchemy import NumberRange -from excelalchemy import Option -from excelalchemy import OptionId -from excelalchemy import PhoneNumber -from excelalchemy import ProgrammaticError -from excelalchemy import Radio -from excelalchemy import RowIndex -from excelalchemy import SingleOrganization -from excelalchemy import SingleStaff -from excelalchemy import SingleTreeNode -from excelalchemy import String -from excelalchemy import UniqueKey -from excelalchemy import Url -from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry - - -class TestImport(BaseTestCase): +from pydantic import BaseModel, Field + +from excelalchemy import ( + Boolean, + ConfigError, + Date, + DateFormat, + DateRange, + Email, + ExcelAlchemy, + ExcelCellError, + ExcelMeta, + ExporterConfig, + FieldMeta, + ImporterConfig, + ImportMode, + Label, + Money, + MultiCheckbox, + MultiOrganization, + MultiStaff, + MultiTreeNode, + Number, + NumberRange, + Option, + OptionId, + PhoneNumber, + ProgrammaticError, + Radio, + SingleOrganization, + SingleStaff, + SingleTreeNode, + String, + Url, + ValidateResult, +) +from tests.support import BaseTestCase, FileRegistry + + +class TestExcelAlchemyIntegrationWorkflows(BaseTestCase): class NoMergeHeaderImporter(BaseModel): age: Number = FieldMeta(label='年龄', order=1) name: String = FieldMeta(label='姓名', order=2) @@ -227,7 +224,7 @@ async def is_data_exist(data: dict[str, Any], context: dict[str, Any] | None) -> context = {} return random.choices([True, False], weights=[0.5, 0.5])[0] - async def test_simple_import_on_creator(self): + async def test_import_create_mode_returns_success_for_valid_simple_workbook(self): """Test import excel with no merged header""" config = ImporterConfig(self.NoMergeHeaderImporter, creator=self.creator, minio=cast(Minio, self.minio)) alchemy = ExcelAlchemy(config) @@ -243,7 +240,7 @@ async def test_simple_import_on_creator(self): assert result.success_count == 1 assert result.url is None - async def test_simple_import_on_update(self): + async def test_import_update_mode_returns_success_for_valid_simple_workbook(self): """Test import excel with no merged header""" self.assertRaises(ConfigError, ImporterConfig, self.NoMergeHeaderImporter, import_mode=ImportMode.UPDATE) config = ImporterConfig( @@ -263,7 +260,7 @@ async def test_simple_import_on_update(self): assert result.success_count == 1 assert result.url is None - async def test_simple_import_on_create_or_update(self): + async def test_import_create_or_update_mode_returns_success_for_valid_simple_workbook(self): """Test import excel with no merged header""" self.assertRaises( ConfigError, @@ -294,7 +291,7 @@ async def test_simple_import_on_create_or_update(self): assert result.success_count == 1 assert result.url is None - async def test_no_merge_header_import_with_errors(self): + async def test_import_records_cell_errors_for_invalid_simple_workbook(self): """Test import excel with no merged header""" config = ImporterConfig(self.NoMergeHeaderImporter, creator=self.creator, minio=cast(Minio, self.minio)) alchemy = ExcelAlchemy(config) @@ -310,22 +307,54 @@ async def test_no_merge_header_import_with_errors(self): assert alchemy.cell_errors == { 0: { - 6: [ExcelCellError(label=Label('出生日期'), message='请输入格式为yyyy的日期')], - 7: [ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱')], - 18: [ExcelCellError(label=Label('网址'), message='请输入正确的网址')], - 9: [ExcelCellError(label=Label('爱好'), message='选项不存在,请参照表头的注释填写')], - 10: [ExcelCellError(label=Label('公司'), message='选项不存在,请参照表头的注释填写')], - 11: [ExcelCellError(label=Label('经理'), message='选项不存在,请参照表头的注释填写')], - 12: [ExcelCellError(label=Label('部门'), message='选项不存在,请参照表头的注释填写')], - 17: [ExcelCellError(label=Label('团队'), message='选项不存在,请参照字段注释填写')], - 13: [ExcelCellError(label=Label('电话'), message='请输入正确的手机号')], - 14: [ExcelCellError(label=Label('单选'), message='选项不存在,请参照字段注释填写')], - 15: [ExcelCellError(label=Label('老板'), message='选项不存在,请参照字段注释填写')], - 16: [ExcelCellError(label=Label('领导'), message='选项不存在,请参照字段注释填写')], + 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')], + 9: [ + ExcelCellError( + label=Label('爱好'), message='Option not found; check the header comment for valid values' + ) + ], + 10: [ + ExcelCellError( + label=Label('公司'), message='Option not found; check the header comment for valid values' + ) + ], + 11: [ + ExcelCellError( + label=Label('经理'), message='Option not found; check the header comment for valid values' + ) + ], + 12: [ + ExcelCellError( + label=Label('部门'), message='Option not found; check the header comment for valid values' + ) + ], + 17: [ + ExcelCellError( + label=Label('团队'), message='Option not found; check the field comment for valid values' + ) + ], + 13: [ExcelCellError(label=Label('电话'), message='Enter a valid phone number')], + 14: [ + ExcelCellError( + label=Label('单选'), message='Option not found; check the field comment for valid values' + ) + ], + 15: [ + ExcelCellError( + label=Label('老板'), message='Option not found; check the field comment for valid values' + ) + ], + 16: [ + ExcelCellError( + label=Label('领导'), message='Option not found; check the field comment for valid values' + ) + ], } } - async def test_no_merge_header_export(self): + async def test_export_returns_simple_header_dataframe_for_flat_model(self): config = ExporterConfig(self.NoMergeHeaderImporter, minio=cast(Minio, self.minio)) alchemy = ExcelAlchemy(config) data = [ @@ -334,8 +363,8 @@ async def test_no_merge_header_export(self): 'name': '张三', 'address': '北京市朝阳区', 'is_active': True, - 'birth_date': datetime.datetime.utcnow(), - 'email': 'norepy@icloud.com', + 'birth_date': datetime.datetime.now(datetime.UTC), + 'email': 'noreply@example.com', 'price': 100.0, 'web': 'https://www.baidu.com', 'hobby': '篮球', @@ -359,7 +388,7 @@ async def test_no_merge_header_export(self): assert df.shape == (1, 17) assert df.iloc[0, 0] == '18' - async def test_duplicate_order(self): + async def test_duplicate_field_order_raises_config_error(self): class DuplicateOrderImporter(self.NoMergeHeaderImporter): max_stay_date: DateRange = FieldMeta(label='最大停留日期', order=7, date_format=DateFormat.YEAR) salary: NumberRange = FieldMeta(label='工资', order=14) @@ -367,7 +396,7 @@ class DuplicateOrderImporter(self.NoMergeHeaderImporter): config = ExporterConfig(DuplicateOrderImporter, minio=cast(Minio, self.minio)) self.assertRaises(ConfigError, ExcelAlchemy, config) - async def test_export_with_merged_header(self): + async def test_export_detects_merged_header_layout_for_composite_fields(self): config = ExporterConfig(self.MergeHeaderImporter, minio=cast(Minio, self.minio)) alchemy = ExcelAlchemy(config) data = [ @@ -376,8 +405,8 @@ async def test_export_with_merged_header(self): 'name': '张三', 'address': '北京市朝阳区', 'is_active': True, - 'birth_date': datetime.datetime.utcnow(), - 'email': 'norepy@icloud.com', + 'birth_date': datetime.datetime.now(datetime.UTC), + 'email': 'noreply@example.com', 'price': 100.0, 'web': 'https://www.baidu.com', 'hobby': '篮球', @@ -396,10 +425,10 @@ async def test_export_with_merged_header(self): result = alchemy.export(data) assert result is not None - df, has_merged_header = alchemy._gen_export_df(data) + _, has_merged_header = alchemy._gen_export_df(data) assert has_merged_header is True - async def test_import_with_merge_header(self): + async def test_import_returns_success_for_merged_header_workbook(self): config = ImporterConfig(self.MergeHeaderImporter, creator=self.creator, minio=cast(Minio, self.minio)) alchemy = ExcelAlchemy(config) @@ -412,39 +441,72 @@ async def test_import_with_merge_header(self): assert result.success_count == 1 assert result.url is None - async def test_empty_config_model(self): - class EmptyCModel(BaseModel): - ... + async def test_empty_importer_model_raises_config_error(self): + class EmptyCModel(BaseModel): ... config = ImporterConfig(EmptyCModel, creator=self.creator, minio=cast(Minio, self.minio)) with self.assertRaises(ConfigError) as cm: ExcelAlchemy(config) - self.assertEqual(str(cm.exception), '没有从模型 EmptyCModel 中提取到字段元数据,请检查模型是否定义了字段') + self.assertEqual( + str(cm.exception), + 'No field metadata was extracted from model EmptyCModel; check its field definitions', + ) - async def test_empty_field_meta(self): + async def test_non_fieldmeta_definition_raises_programmatic_error(self): class EmptyFieldMetaModel(BaseModel): name: str config = ImporterConfig(EmptyFieldMetaModel, creator=self.creator, minio=cast(Minio, self.minio)) with self.assertRaises(ProgrammaticError) as cm: ExcelAlchemy(config) - self.assertEqual(str(cm.exception), '字段定义必须是 FieldMeta 的实例') + self.assertEqual( + str(cm.exception), + 'Field definitions must be created with FieldMeta or Annotated[..., ExcelMeta(...)]', + ) + + async def test_misplaced_excelmeta_default_raises_helpful_programmatic_error(self): + class MisplacedAnnotatedExcelMetaModel(BaseModel): + name: Annotated[str, Field(min_length=3)] = cast(str, ExcelMeta(label='Name', order=1)) + + config = ImporterConfig(MisplacedAnnotatedExcelMetaModel, creator=self.creator, minio=cast(Minio, self.minio)) + + with self.assertRaises(ProgrammaticError) as cm: + ExcelAlchemy(config) + + self.assertEqual( + str(cm.exception), + 'Annotated fields must place ExcelMeta(...) inside Annotated metadata; ' + 'use `field: Annotated[T, Field(...), ExcelMeta(...)]`', + ) + + async def test_annotated_excel_meta_definition_can_build_template(self): + class AnnotatedImporter(BaseModel): + email: Annotated[Email, Field(min_length=10), ExcelMeta(label='邮箱', order=1)] + + config = ImporterConfig(AnnotatedImporter, creator=self.creator, minio=cast(Minio, self.minio)) + alchemy = ExcelAlchemy(config) + + template = alchemy.download_template() + + self.assertTrue( + template.startswith('data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,') + ) - async def test_not_importer_config(self): + async def test_passing_non_config_object_raises_config_error(self): class NotImporterConfigModel(BaseModel): name: str = FieldMeta(label='姓名') with self.assertRaises(ConfigError) as cm: - ExcelAlchemy(NotImporterConfigModel) + ExcelAlchemy(cast(Any, NotImporterConfigModel)) - self.assertEqual(str(cm.exception), '导出模式的配置类必须是 ExporterConfig') + self.assertEqual(str(cm.exception), 'Export mode requires an ExporterConfig instance') - async def test_download_template_on_export(self): + async def test_download_template_in_export_mode_raises_config_error(self): config = ExporterConfig(self.MergeHeaderImporter, minio=cast(Minio, self.minio)) alchemy = ExcelAlchemy(config) with self.assertRaises(ConfigError) as cm: alchemy.download_template() - self.assertEqual(str(cm.exception), '只支持导入模式调用此方法') + self.assertEqual(str(cm.exception), 'This method is only available in import mode') diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..a179356 --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1,28 @@ +from tests.support.base import BaseTestCase +from tests.support.mock_minio import LocalMockMinio, local_minio +from tests.support.registry import FileRegistry +from tests.support.storage import InMemoryExcelStorage +from tests.support.workbook import ( + decode_prefixed_excel_to_workbook, + get_fill_color, + get_font_color, + list_data_validations, + list_merge_ranges, + load_binary_excel_to_workbook, + worksheet_matrix, +) + +__all__ = [ + 'BaseTestCase', + 'FileRegistry', + 'InMemoryExcelStorage', + 'LocalMockMinio', + 'decode_prefixed_excel_to_workbook', + 'get_fill_color', + 'get_font_color', + 'list_data_validations', + 'list_merge_ranges', + 'load_binary_excel_to_workbook', + 'local_minio', + 'worksheet_matrix', +] diff --git a/tests/support/base.py b/tests/support/base.py new file mode 100644 index 0000000..8272cb9 --- /dev/null +++ b/tests/support/base.py @@ -0,0 +1,30 @@ +from typing import Any, cast +from unittest import IsolatedAsyncioTestCase + +from minio import Minio +from pydantic import BaseModel + +from excelalchemy import ColumnIndex, ExcelAlchemy, ImporterConfig, RowIndex +from tests.support.mock_minio import local_minio + + +class BaseTestCase(IsolatedAsyncioTestCase): + minio = local_minio + first_data_row: RowIndex = RowIndex(0) + first_data_col: ColumnIndex = ColumnIndex(2) + + @staticmethod + async def fake_creator(data: dict[str, Any], context: dict[str, Any] | None) -> dict[str, Any]: + return data + + def build_alchemy( + self, + importer: type[BaseModel], + ) -> ExcelAlchemy: + return ExcelAlchemy( + ImporterConfig( + importer, + creator=self.fake_creator, + minio=cast(Minio, self.minio), + ), + ) diff --git a/tests/support/contract_models.py b/tests/support/contract_models.py new file mode 100644 index 0000000..46703a6 --- /dev/null +++ b/tests/support/contract_models.py @@ -0,0 +1,139 @@ +import datetime +from typing import Any + +from pydantic import BaseModel + +from excelalchemy import ( + Boolean, + Date, + DateFormat, + DateRange, + Email, + ExcelCellError, + FieldMeta, + Label, + Money, + MultiCheckbox, + MultiOrganization, + MultiStaff, + MultiTreeNode, + Number, + NumberRange, + Option, + OptionId, + PhoneNumber, + Radio, + SingleOrganization, + SingleStaff, + SingleTreeNode, + String, + Url, +) + +COMMON_OPTIONS = [ + Option(id=OptionId('1'), name='选项1'), + Option(id=OptionId('2'), name='选项2'), + Option(id=OptionId('3'), name='选项3'), +] + +ORGANIZATION_OPTIONS = [ + Option(id=OptionId('1'), name='腾讯'), + Option(id=OptionId('2'), name='阿里巴巴'), + Option(id=OptionId('3'), name='百度'), +] + +STAFF_OPTIONS = [ + Option(id=OptionId('1'), name='张三'), + Option(id=OptionId('2'), name='李四'), + Option(id=OptionId('3'), name='王五'), +] + +TREE_OPTIONS = [ + Option(id=OptionId('1'), name='研发部'), + Option(id=OptionId('2'), name='市场部'), + Option(id=OptionId('3'), name='销售部'), +] + +BOSS_OPTIONS = [ + Option(id=OptionId('1'), name='马云'), + Option(id=OptionId('2'), name='马化腾'), + Option(id=OptionId('3'), name='李彦宏'), +] + + +class SimpleContractImporter(BaseModel): + age: Number = FieldMeta(label='年龄', order=1) + name: String = FieldMeta(label='姓名', order=2) + address: String | None = FieldMeta(label='地址', order=4) + is_active: Boolean = FieldMeta(label='是否启用', order=5) + birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.YEAR) + email: Email = FieldMeta(label='邮箱', order=7) + price: Money = FieldMeta(label='价格', order=8) + web: Url = FieldMeta(label='网址', order=9) + hobby: MultiCheckbox = FieldMeta( + label='爱好', + order=10, + options=[ + Option(id=OptionId('1'), name='篮球'), + Option(id=OptionId('2'), name='足球'), + Option(id=OptionId('3'), name='乒乓球'), + ], + ) + company: MultiOrganization = FieldMeta(label='公司', order=11, options=ORGANIZATION_OPTIONS) + manager: MultiStaff = FieldMeta(label='经理', order=12, options=STAFF_OPTIONS) + department: MultiTreeNode = FieldMeta(label='部门', order=13, options=TREE_OPTIONS) + team: SingleTreeNode = FieldMeta(label='团队', order=14, options=TREE_OPTIONS) + phone: PhoneNumber = FieldMeta(label='电话', order=15) + radio: Radio = FieldMeta(label='单选', order=16, options=COMMON_OPTIONS) + boss: SingleOrganization = FieldMeta(label='老板', order=17, options=BOSS_OPTIONS) + leader: SingleStaff = FieldMeta(label='领导', order=18, options=STAFF_OPTIONS) + + +class MergedContractImporter(SimpleContractImporter): + max_stay_date: DateRange = FieldMeta(label='最大停留日期', order=19, date_format=DateFormat.YEAR) + salary: NumberRange = FieldMeta(label='工资', order=20) + + +def sample_simple_export_row() -> dict[str, Any]: + return { + 'age': 18, + 'name': '张三', + 'address': '北京市朝阳区', + 'is_active': True, + 'birth_date': datetime.datetime(2021, 1, 1), + 'email': 'noreply@example.com', + 'price': 100, + 'web': 'https://www.baidu.com', + 'hobby': ['1', '2'], + 'company': ['1', '2'], + 'manager': ['1', '2'], + 'department': ['1', '2'], + 'team': '1', + 'phone': '13800138000', + 'radio': '1', + 'boss': '1', + 'leader': '1', + } + + +def sample_merged_export_row() -> dict[str, Any]: + return sample_simple_export_row() | { + 'max_stay_date': {'start': '2020-01-01', 'end': '2021-01-02'}, + 'salary': {'start': 1000, 'end': 2000}, + } + + +async def creator(data: dict[str, Any], context: dict[str, Any] | None) -> dict[str, Any]: + if context: + data['company_id'] = context.get('company_id') + return data + + +async def updater(data: dict[str, Any], context: dict[str, Any] | None) -> dict[str, Any]: + if context: + data['company_id'] = context.get('company_id') + return data + + +async def failing_creator(data: dict[str, Any], context: dict[str, Any] | None) -> dict[str, Any]: + raise ExcelCellError(label=Label('姓名'), message='Simulated failure') diff --git a/tests/mock_minio.py b/tests/support/mock_minio.py similarity index 53% rename from tests/mock_minio.py rename to tests/support/mock_minio.py index 11fc1b2..c380ecd 100644 --- a/tests/mock_minio.py +++ b/tests/support/mock_minio.py @@ -1,22 +1,24 @@ import io +import os from copy import copy from pathlib import Path from tempfile import NamedTemporaryFile -from typing import Any +from typing import Any, ClassVar -import pandas +from openpyxl import Workbook, load_workbook +from openpyxl.worksheet.cell_range import CellRange from excelalchemy.const import HEADER_HINT -from tests.registry import FileRegistry +from tests.support.registry import FileRegistry class LocalMockMinio: """有合并表头的内容直接使用文件""" - storage: dict[str, Any] = {} + storage: ClassVar[dict[str, Any]] = {} bucket_name: str = 'test' - mock_excel_data: dict[str, Any] = { + mock_excel_data: ClassVar[dict[str, Any]] = { FileRegistry.TEST_HEADER_INVALID_INPUT: [ { '不存在的表头': '是', @@ -52,7 +54,7 @@ class LocalMockMinio: ], FileRegistry.TEST_EMAIL_CORRECT_FORMAT: [ { - '邮箱': 'excelalchemy@163.com', + '邮箱': 'person@example.com', }, ], FileRegistry.TEST_SIMPLE_IMPORT: [ @@ -62,13 +64,13 @@ class LocalMockMinio: '地址': '北京市', '是否启用': '是', '出生日期': '2021-01-01', - '邮箱': 'noreply@icloud.com', + '邮箱': 'noreply@example.com', '价格': 100.0, '爱好': '篮球', '公司': '阿里巴巴', '经理': '李四', '部门': '研发部', - '电话': '13223658966', + '电话': '13800138000', '单选': '选项1', '老板': '马云', '领导': '张三', @@ -106,28 +108,81 @@ def __init__(self): automatically add HEADER_HINT to first row """ for filename, data in self.mock_excel_data.items(): - if isinstance(data, str): - df = pandas.read_excel(Path(__file__).parent / Path(data)) - else: - df = pandas.DataFrame(data) - - f = NamedTemporaryFile(suffix='.xlsx') - original_header = df.columns - df.columns = range(len(df.columns)) - header_row = pandas.Series(original_header, index=df.columns) - df = pandas.concat([header_row.to_frame().T, df], ignore_index=True) - - df.loc[-1] = 0 - df.index = df.index + 1 - df = df.sort_index() - df.iat[0, 0] = HEADER_HINT - - df.to_excel(f.name, index=False, header=False, engine='openpyxl') - f.seek(0) - data = io.BytesIO(f.read()) - f.seek(0) - length = len(f.read()) - self.put_object(self.bucket_name, filename, data, length, f) + with NamedTemporaryFile(suffix='.xlsx', delete=False) as temporary_file: + temporary_filename = temporary_file.name + + workbook = self._build_workbook(data) + workbook.save(temporary_filename) + workbook.close() + + with open(temporary_filename, 'rb') as rf: + file_bytes = rf.read() + + data = io.BytesIO(file_bytes) + length = len(file_bytes) + self.put_object(self.bucket_name, filename, data, length, temporary_filename) + + def _build_workbook(self, data: str | list[dict[str, Any]]): + if isinstance(data, str): + source_workbook = load_workbook(Path(__file__).resolve().parent.parent / Path(data.lstrip('./'))) + source_worksheet = source_workbook['Sheet1'] + rows = [list(row) for row in source_worksheet.iter_rows(values_only=True)] + trimmed_width = self._trimmed_width(rows) + + workbook = Workbook() + worksheet = workbook.active + assert worksheet is not None + worksheet.title = source_worksheet.title + worksheet.cell(row=1, column=1, value=HEADER_HINT) + + for row_index, row in enumerate(rows, start=2): + for column_index, value in enumerate(row[:trimmed_width], start=1): + worksheet.cell(row=row_index, column=column_index, value=value) + + for merged_range in source_worksheet.merged_cells.ranges: + if merged_range.min_col > trimmed_width: + continue + shifted_range = CellRange( + min_col=merged_range.min_col, + max_col=min(merged_range.max_col, trimmed_width), + min_row=merged_range.min_row + 1, + max_row=merged_range.max_row + 1, + ) + worksheet.merge_cells(str(shifted_range)) + + source_workbook.close() + return workbook + + workbook = Workbook() + worksheet = workbook.active + assert worksheet is not None + worksheet.title = 'Sheet1' + worksheet.cell(row=1, column=1, value=HEADER_HINT) + + if not data: + return workbook + + headers = list(data[0].keys()) + for column_index, header in enumerate(headers, start=1): + worksheet.cell(row=2, column=column_index, value=header) + + for row_index, row in enumerate(data, start=3): + for column_index, header in enumerate(headers, start=1): + worksheet.cell(row=row_index, column=column_index, value=row.get(header)) + + return workbook + + @staticmethod + def _trimmed_width(rows: list[list[Any]]) -> int: + if not rows: + return 0 + + width = max(len(row) for row in rows) + while width > 0: + if any(len(row) >= width and row[width - 1] is not None for row in rows): + return width + width -= 1 + return 0 def put_object(self, bucket_name: str, filename: str, data: io.BytesIO, length: int, file: Any = None) -> None: self.storage[filename] = { @@ -147,8 +202,9 @@ def get_object(self, bucket_name: str, filename: str) -> io.BytesIO: return copy(self.storage[filename]['data']) # use copy to avoid close(), so it can be read multiple times def __del__(self): - for filename, data in self.storage.items(): - data['file'].close() if data['file'] else None + for data in self.storage.values(): + if isinstance(data['file'], str) and os.path.exists(data['file']): + os.remove(data['file']) local_minio = LocalMockMinio() diff --git a/tests/registry.py b/tests/support/registry.py similarity index 93% rename from tests/registry.py rename to tests/support/registry.py index d23666d..8d369af 100644 --- a/tests/registry.py +++ b/tests/support/registry.py @@ -1,7 +1,7 @@ -from enum import Enum +from enum import StrEnum -class FileRegistry(str, Enum): +class FileRegistry(StrEnum): TEST_HEADER_INVALID_INPUT = 'test_header_invalid_input' TEST_BOOLEAN_INPUT = 'test_boolean_input' diff --git a/tests/support/storage.py b/tests/support/storage.py new file mode 100644 index 0000000..5a94e3e --- /dev/null +++ b/tests/support/storage.py @@ -0,0 +1,42 @@ +import io +from base64 import b64decode + +from openpyxl import load_workbook + +from excelalchemy import UrlStr +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.core.table import WorksheetTable + + +class InMemoryExcelStorage(ExcelStorage): + """Simple in-memory storage used to exercise the storage protocol.""" + + def __init__(self, fixtures: dict[str, bytes] | None = None): + self.fixtures = fixtures or {} + self.uploaded: dict[str, bytes] = {} + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + workbook = load_workbook(io.BytesIO(self.fixtures[input_excel_name]), data_only=True) + try: + worksheet = workbook[sheet_name] + rows = [ + [None if value is None else str(value) for value in row] + for row in worksheet.iter_rows( + min_row=skiprows + 1, + max_row=worksheet.max_row, + max_col=worksheet.max_column, + values_only=True, + ) + ] + finally: + workbook.close() + + while rows and all(value is None for value in rows[-1]): + rows.pop() + + return WorksheetTable(rows=rows) + + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + _, payload = content_with_prefix.split(',', 1) + self.uploaded[output_name] = b64decode(payload) + return UrlStr(f'memory://{output_name}') diff --git a/tests/support/workbook.py b/tests/support/workbook.py new file mode 100644 index 0000000..10cc37c --- /dev/null +++ b/tests/support/workbook.py @@ -0,0 +1,61 @@ +import base64 +import io +from typing import Any + +from openpyxl import load_workbook +from openpyxl.cell.cell import Cell, MergedCell +from openpyxl.workbook.workbook import Workbook +from openpyxl.worksheet.worksheet import Worksheet + + +def decode_prefixed_excel_to_workbook(content: str) -> Workbook: + _, payload = content.split(',', 1) + return load_workbook(io.BytesIO(base64.b64decode(payload))) + + +def load_binary_excel_to_workbook(content: bytes) -> Workbook: + return load_workbook(io.BytesIO(content)) + + +def worksheet_matrix( + worksheet: Worksheet, + min_row: int, + max_row: int, + min_col: int, + max_col: int, +) -> list[list[Any]]: + return [ + [worksheet.cell(row=row_index, column=column_index).value for column_index in range(min_col, max_col + 1)] + for row_index in range(min_row, max_row + 1) + ] + + +def list_merge_ranges(worksheet: Worksheet) -> list[str]: + return sorted(str(cell_range) for cell_range in worksheet.merged_cells.ranges) + + +def list_data_validations(worksheet: Worksheet) -> list[tuple[str | None, str]]: + return [(validation.formula1, str(validation.sqref)) for validation in worksheet.data_validations.dataValidation] + + +def get_fill_color(cell: Cell | MergedCell) -> str | None: + color = cell.fill.start_color.rgb or cell.fill.fgColor.rgb or cell.fill.start_color.index + return _normalize_color(color) + + +def get_font_color(cell: Cell) -> str | None: + if cell.font.color is None: + return None + color = cell.font.color.rgb or cell.font.color.indexed or cell.font.color.theme + return _normalize_color(color) + + +def _normalize_color(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, int): + return str(value) + if isinstance(value, str): + upper = value.upper() + return upper[-6:] if len(upper) == 8 else upper + return str(value) diff --git a/tests/test_exception.py b/tests/test_exception.py deleted file mode 100644 index 7e31db8..0000000 --- a/tests/test_exception.py +++ /dev/null @@ -1,62 +0,0 @@ -from excelalchemy import ExcelCellError -from excelalchemy import Label -from excelalchemy.exc import ExcelRowError -from tests import BaseTestCase - - -class TestException(BaseTestCase): - async def test_equal(self): - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - exc2 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - assert exc1 == exc2 - - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - exc2 = ExcelCellError(label=Label('邮箱1'), message='请输入正确的邮箱') - - assert exc1 != exc2 - - async def test_repr(self): - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - assert repr(exc1) == "ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱')" - - async def test_str(self): - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - assert str(exc1) == '【邮箱】请输入正确的邮箱' - - async def test_wrong_init(self): - self.assertRaises(ValueError, ExcelCellError, label=Label(''), message='请输入正确的邮箱') - - async def test_unique_label(self): - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - assert exc1.unique_label == '邮箱' - - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱', parent_label=Label('父')) - assert exc1.unique_label == '父·邮箱' - - async def test_eq(self): - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - exc2 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - assert exc1 == exc2 - - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - exc2 = ExcelCellError(label=Label('邮箱1'), message='请输入正确的邮箱') - assert exc1 != exc2 - - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - other = 'other' - - assert exc1 != other - assert other != exc1 - - async def test_row_error(self): - exc1 = ExcelRowError(message='导入 Excel 发生行错误') - assert exc1.message == '导入 Excel 发生行错误' - - exc1 = ExcelRowError(message='请输入正确的邮箱') - assert exc1.message == '请输入正确的邮箱' - - exc1 = ExcelRowError(message='请输入正确的邮箱') - assert str(exc1) == '请输入正确的邮箱' - - exc1 = ExcelRowError(message='请输入正确的邮箱') - assert repr(exc1) == "ExcelRowError(message='请输入正确的邮箱')" diff --git a/tests/test_value_type/test_money.py b/tests/test_value_type/test_money.py deleted file mode 100644 index efc6523..0000000 --- a/tests/test_value_type/test_money.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import cast - -from pydantic import BaseModel - -from excelalchemy import FieldMeta -from excelalchemy import Money -from tests import BaseTestCase - - -class TestMoney(BaseTestCase): - async def test_validate(self): - class Importer(BaseModel): - money: Money = FieldMeta(label='金额', order=1) - - alchemy = self.build_alchemy(Importer) - field = alchemy.ordered_field_meta[0] - - field.value_type = cast(Money, field.value_type) - self.assertRaises(ValueError, field.value_type.__validate__, 'ddd', field) - - assert field.value_type.__validate__(1.23, field) == 1.23 - assert field.value_type.__validate__(1.234, field) == 1.23 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/codecs/__init__.py b/tests/unit/codecs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_value_type/test_boolean.py b/tests/unit/codecs/test_boolean_codec.py similarity index 57% rename from tests/test_value_type/test_boolean.py rename to tests/unit/codecs/test_boolean_codec.py index 3aa0275..9bf2893 100644 --- a/tests/test_value_type/test_boolean.py +++ b/tests/unit/codecs/test_boolean_codec.py @@ -1,14 +1,12 @@ from pydantic import BaseModel -from excelalchemy import Boolean -from excelalchemy import FieldMeta -from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry +from excelalchemy import Boolean, FieldMeta, ValidateResult +from excelalchemy.i18n.messages import use_display_locale +from tests.support import BaseTestCase, FileRegistry -class TestBoolean(BaseTestCase): - async def test_boolean(self): +class TestBooleanValueType(BaseTestCase): + async def test_import_accepts_recognized_boolean_cell_value(self): """测试导入时,布尔值正确读取""" class Importer(BaseModel): @@ -20,7 +18,7 @@ class Importer(BaseModel): ) assert result.result == ValidateResult.SUCCESS, '导入失败' - async def test_boolean_deserialize(self): + async def test_deserialize_maps_supported_boolean_inputs_to_display_values(self): class Importer(BaseModel): is_active: Boolean = FieldMeta(label='是否启用', order=1) @@ -35,7 +33,7 @@ class Importer(BaseModel): assert field.value_type.deserialize('', field) == '否' assert field.value_type.deserialize(1, field) == '否' - async def test_validate(self): + async def test_validate_accepts_only_yes_or_no_inputs(self): class Importer(BaseModel): is_active: Boolean = FieldMeta(label='是否启用', order=1) @@ -48,3 +46,21 @@ class Importer(BaseModel): self.assertRaises(ValueError, field.value_type.__validate__, '任何无法识别的值', field) self.assertRaises(ValueError, field.value_type.__validate__, '', field) + + async def test_boolean_display_values_follow_english_locale(self): + class Importer(BaseModel): + is_active: Boolean = FieldMeta(label='是否启用', order=1) + + alchemy = self.build_alchemy(Importer) + field = alchemy.ordered_field_meta[0] + + with use_display_locale('en'): + assert field.value_type.deserialize(None, field) == 'No' + assert field.value_type.deserialize(True, field) == 'Yes' + assert field.value_type.deserialize(False, field) == 'No' + assert field.value_type.deserialize('Yes', field) == 'Yes' + assert field.value_type.deserialize('No', field) == 'No' + assert field.value_type.__validate__('Yes', field) + assert field.value_type.__validate__('No', field) is False + assert field.value_type.__validate__('是', field) + assert field.value_type.__validate__('否', field) is False diff --git a/tests/test_value_type/test_date.py b/tests/unit/codecs/test_date_codec.py similarity index 82% rename from tests/test_value_type/test_date.py rename to tests/unit/codecs/test_date_codec.py index 93881e5..4440caf 100644 --- a/tests/test_value_type/test_date.py +++ b/tests/unit/codecs/test_date_codec.py @@ -2,26 +2,26 @@ from typing import cast from minio import Minio -from pendulum import DateTime -from pendulum import today +from pendulum import DateTime, today from pendulum.tz.timezone import Timezone from pydantic import BaseModel -from excelalchemy import ConfigError -from excelalchemy import DataRangeOption -from excelalchemy import Date -from excelalchemy import DateFormat -from excelalchemy import ExcelAlchemy -from excelalchemy import ExcelCellError -from excelalchemy import FieldMeta -from excelalchemy import ImporterConfig -from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry - - -class TestDate(BaseTestCase): - async def test_date_no_format(self): +from excelalchemy import ( + ConfigError, + DataRangeOption, + Date, + DateFormat, + ExcelAlchemy, + ExcelCellError, + FieldMeta, + ImporterConfig, + ValidateResult, +) +from tests.support import BaseTestCase, FileRegistry + + +class TestDateValueType(BaseTestCase): + async def test_download_and_import_require_explicit_date_format(self): """测试导入时,日期格式未指定""" class Importer(BaseModel): @@ -36,7 +36,7 @@ class Importer(BaseModel): with self.assertRaises(ConfigError): await alchemy.import_data(input_excel_name=FileRegistry.TEST_DATE_INPUT, output_excel_name='result.xlsx') - async def test_date_wrong_range(self): + async def test_import_rejects_dates_that_do_not_match_month_format(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.MONTH) @@ -50,11 +50,13 @@ class Importer(BaseModel): error = alchemy.cell_errors[self.first_data_row][self.first_data_col][0] assert isinstance(error, ExcelCellError) assert error.label == '出生日期' - assert error.message == '请输入格式为yyyy/mm的日期' # may be more accurate to say "请输入格式为yyyy/mm的日期,如2021/01" - assert repr(error) == "ExcelCellError(label=Label('出生日期'), message='请输入格式为yyyy/mm的日期')" - assert str(error) == '【出生日期】请输入格式为yyyy/mm的日期' + assert ( + error.message == 'Enter a date in yyyy/mm format' + ) # may be more accurate to say 'Enter a date in yyyy/mm format, e.g. 2021/01' + assert repr(error) == "ExcelCellError(label=Label('出生日期'), message='Enter a date in yyyy/mm format')" + assert str(error) == '【出生日期】Enter a date in yyyy/mm format' - async def test_date_wrong_format(self): + async def test_import_rejects_dates_that_do_not_match_day_format(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.DAY) @@ -68,9 +70,9 @@ class Importer(BaseModel): error = alchemy.cell_errors[self.first_data_row][self.first_data_col][0] assert isinstance(error, ExcelCellError) assert error.label == '出生日期' - assert error.message == '请输入格式为yyyy/mm/dd的日期' + assert error.message == 'Enter a date in yyyy/mm/dd format' - async def test_date_serialize(self): + async def test_serialize_parses_supported_date_inputs(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.DAY) @@ -89,7 +91,7 @@ class Importer(BaseModel): DateTime(2022, 2, 2, 12, 12, 12, tzinfo=Timezone('Asia/Shanghai')), field ) == DateTime(2022, 2, 2, 12, 12, 12, tzinfo=Timezone('Asia/Shanghai')) - async def test_deserialize(self): + async def test_deserialize_formats_supported_runtime_values(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.DAY) @@ -107,7 +109,7 @@ class Importer(BaseModel): == '2022-02-02' ) - async def test_validate_day(self): + async def test_validate_day_format_normalizes_to_day_timestamp(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.DAY) @@ -121,7 +123,7 @@ class Importer(BaseModel): == 1643731200000 ) - async def test_validate_month(self): + async def test_validate_month_format_normalizes_to_month_timestamp(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.MONTH) @@ -135,7 +137,7 @@ class Importer(BaseModel): == 1643644800000 ) - async def test_validate_year(self): + async def test_validate_year_format_normalizes_to_year_timestamp(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.YEAR) @@ -151,7 +153,7 @@ class Importer(BaseModel): field.date_format = None self.assertRaises(ConfigError, field.value_type.__validate__, '2022-02-02', field) - async def test_validate_minute(self): + async def test_validate_minute_format_normalizes_to_minute_timestamp(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.MINUTE) @@ -165,7 +167,7 @@ class Importer(BaseModel): == 1643775120000 ) - async def test_daterange_option(self): + async def test_validate_respects_date_range_option_constraints(self): class Importer(BaseModel): birth_date: Date = FieldMeta(label='出生日期', order=6, date_format=DateFormat.DAY) diff --git a/tests/test_value_type/test_daterange.py b/tests/unit/codecs/test_date_range_codec.py similarity index 87% rename from tests/test_value_type/test_daterange.py rename to tests/unit/codecs/test_date_range_codec.py index 6bd236b..b3fdd52 100644 --- a/tests/test_value_type/test_daterange.py +++ b/tests/unit/codecs/test_date_range_codec.py @@ -1,21 +1,13 @@ -from datetime import timedelta - -from pendulum import DateTime -from pendulum import today +from pendulum import DateTime, today from pendulum.tz.timezone import Timezone from pydantic import BaseModel -from excelalchemy import DataRangeOption -from excelalchemy import DateFormat -from excelalchemy import DateRange -from excelalchemy import FieldMeta -from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry +from excelalchemy import DataRangeOption, DateFormat, DateRange, FieldMeta, ValidateResult +from tests.support import BaseTestCase, FileRegistry -class TestDateRange(BaseTestCase): - async def test_daterange(self): +class TestDateRangeValueType(BaseTestCase): + async def test_import_accepts_valid_date_range_workbook(self): class Importer(BaseModel): date_range: DateRange = FieldMeta(label='日期范围', order=1) @@ -25,11 +17,11 @@ class Importer(BaseModel): ) assert result.result == ValidateResult.SUCCESS, '导入失败' - async def test_daterange_missing_before(self): + async def test_import_returns_header_invalid_when_merged_header_loses_trailing_child(self): """对于合并的表头,如果后面缺失 日期范围 | (这里合并了表头)| 开始日期 | (这里缺了一个值)| - DataFrame 不会读到第一行第二列的值,因此 ExcelAlchemy 不会认为有合并得表头 + worksheet reader 不会读到第一行第二列的值,因此 ExcelAlchemy 不会认为有合并得表头 """ class Importer(BaseModel): @@ -42,14 +34,14 @@ class Importer(BaseModel): # ExcelAlchemy 任务需要的表头是 DateRange.model_items(开始日期,结束日期) # 但是 Excel 读到的表头是 日期范围 assert result.result == ValidateResult.HEADER_INVALID, '导入失败' - assert sorted(result.missing_required) == sorted(['开始日期', '结束日期']) + assert sorted(result.missing_required) == sorted(['日期范围·开始日期', '日期范围·结束日期']) assert result.unrecognized == ['日期范围'] - async def test_test_date_range_missing_input_after(self): + async def test_import_returns_header_invalid_when_merged_header_loses_leading_child(self): """对于合并的表头,如果前面缺失 日期范围 | (这里合并了表头)| (这里缺了一个值) | 开始日期 | - DataFrame 能正确读到四个值,因此 ExcelAlchemy 会认为有合并得表头 + worksheet reader 能正确读到四个值,因此 ExcelAlchemy 会认为有合并得表头 """ class Importer(BaseModel): @@ -60,10 +52,10 @@ class Importer(BaseModel): input_excel_name=FileRegistry.TEST_DATE_RANGE_MISSING_INPUT_AFTER, output_excel_name='result.xlsx' ) assert result.result == ValidateResult.HEADER_INVALID, '导入失败' - assert sorted(result.missing_required) == sorted(['结束日期']) + assert sorted(result.missing_required) == sorted(['日期范围·结束日期']) assert result.unrecognized == ['日期范围'] - async def test_daterange_value_type(self): + async def test_date_range_value_type_exposes_comment_and_boundaries(self): class Importer(BaseModel): date_range: DateRange = FieldMeta(label='日期范围', order=1, date_format=DateFormat.DAY) @@ -79,7 +71,7 @@ class Importer(BaseModel): assert value_type.end == DateTime(2023, 2, 2, 12, 12, 12, tzinfo=Timezone('Asia/Shanghai')) assert value_type.comment(field) == '必填性:必填\n格式:日期(yyyy/mm/dd)\n提示:开始日期不得晚于结束日期' - async def test_serialize(self): + async def test_serialize_parses_supported_date_range_inputs(self): class Importer(BaseModel): date_range: DateRange = FieldMeta(label='日期范围', order=1, date_format=DateFormat.DAY) @@ -116,7 +108,7 @@ class Importer(BaseModel): assert value_type.serialize('不能解析的值', field) == '不能解析的值' - async def test_validate(self): + async def test_validate_rejects_invalid_date_range_boundaries_and_constraints(self): class Importer(BaseModel): date_range: DateRange = FieldMeta(label='日期范围', order=1, date_format=DateFormat.DAY) @@ -198,7 +190,7 @@ class Importer(BaseModel): 'end': 1675267200000, } - async def test_deserialize(self): + async def test_deserialize_formats_supported_date_range_outputs(self): class Importer(BaseModel): date_range: DateRange = FieldMeta(label='日期范围', order=1, date_format=DateFormat.DAY) diff --git a/tests/test_value_type/test_email.py b/tests/unit/codecs/test_email_codec.py similarity index 70% rename from tests/test_value_type/test_email.py rename to tests/unit/codecs/test_email_codec.py index d408752..ca03fc9 100644 --- a/tests/test_value_type/test_email.py +++ b/tests/unit/codecs/test_email_codec.py @@ -1,18 +1,11 @@ from pydantic import BaseModel -from excelalchemy import ColumnIndex -from excelalchemy import Email -from excelalchemy import ExcelCellError -from excelalchemy import FieldMeta -from excelalchemy import Label -from excelalchemy import RowIndex -from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry - - -class TestEmail(BaseTestCase): - async def test_email_wrong_format(self): +from excelalchemy import ColumnIndex, Email, ExcelCellError, FieldMeta, Label, RowIndex, ValidateResult +from tests.support import BaseTestCase, FileRegistry + + +class TestEmailValueType(BaseTestCase): + async def test_import_rejects_invalid_email_value(self): class Importer(BaseModel): email: Email = FieldMeta(label='邮箱', order=1) @@ -23,9 +16,11 @@ class Importer(BaseModel): assert result.result == ValidateResult.DATA_INVALID, '导入失败' assert result.fail_count == 1 row, col, first_error = RowIndex(0), ColumnIndex(2), 0 - assert alchemy.cell_errors[row][col][first_error] == ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') + assert alchemy.cell_errors[row][col][first_error] == ExcelCellError( + label=Label('邮箱'), message='Enter a valid email address' + ) - async def test_email_correct_format(self): + async def test_import_accepts_valid_email_value(self): class Importer(BaseModel): email: Email = FieldMeta(label='邮箱', order=1) @@ -37,7 +32,7 @@ class Importer(BaseModel): assert result.fail_count == 0 assert result.success_count == 1 - async def test_validate(self): + async def test_validate_accepts_well_formed_email_addresses(self): class Importer(BaseModel): email: Email = FieldMeta(label='邮箱', order=1) diff --git a/tests/unit/codecs/test_money_codec.py b/tests/unit/codecs/test_money_codec.py new file mode 100644 index 0000000..e4c446b --- /dev/null +++ b/tests/unit/codecs/test_money_codec.py @@ -0,0 +1,32 @@ +from typing import cast + +from pydantic import BaseModel + +from excelalchemy import FieldMeta, Money +from tests.support import BaseTestCase + + +class TestMoneyValueType(BaseTestCase): + async def test_validate_normalizes_money_inputs_and_rejects_invalid_values(self): + class Importer(BaseModel): + money: Money = FieldMeta(label='金额', order=1) + + alchemy = self.build_alchemy(Importer) + field = alchemy.ordered_field_meta[0] + + field.value_type = cast(Money, field.value_type) + self.assertRaises(ValueError, field.value_type.__validate__, 'ddd', field) + + assert field.value_type.__validate__(1.23, field) == 1.23 + assert field.value_type.__validate__(1.234, field) == 1.23 + assert field.fraction_digits is None + + async def test_money_comment_uses_fixed_two_fraction_digits_without_mutating_field_metadata(self): + class Importer(BaseModel): + money: Money = FieldMeta(label='金额', order=1) + + alchemy = self.build_alchemy(Importer) + field = alchemy.ordered_field_meta[0] + + assert field.value_type.comment(field) == '必填性:必填\n格式:数值\n小数位数:2\n可输入范围:无限制\n单位:无' + assert field.fraction_digits is None diff --git a/tests/test_value_type/test_multi_checkbox.py b/tests/unit/codecs/test_multi_checkbox_codec.py similarity index 85% rename from tests/test_value_type/test_multi_checkbox.py rename to tests/unit/codecs/test_multi_checkbox_codec.py index f454eac..d14b5e1 100644 --- a/tests/test_value_type/test_multi_checkbox.py +++ b/tests/unit/codecs/test_multi_checkbox_codec.py @@ -2,17 +2,13 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import MultiCheckbox -from excelalchemy import OptionId -from excelalchemy import ProgrammaticError -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.const import Option -from tests import BaseTestCase +from excelalchemy import FieldMeta, MultiCheckbox, OptionId, ProgrammaticError +from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR, Option +from tests.support import BaseTestCase -class TestMultiCheckbox(BaseTestCase): - async def test_comment(self): +class TestMultiCheckboxValueType(BaseTestCase): + async def test_comment_describes_multi_select_behavior(self): class Importer(BaseModel): multi_checkbox: MultiCheckbox = FieldMeta(label='多选框', order=1) @@ -22,7 +18,7 @@ class Importer(BaseModel): assert field.value_type.comment(field) == '必填性:必填\n\n单/多选:多选\n' - async def test_serialize(self): + async def test_serialize_splits_multi_select_inputs_into_lists(self): class Importer(BaseModel): multi_checkbox: MultiCheckbox = FieldMeta(label='多选框', order=1) @@ -36,7 +32,7 @@ class Importer(BaseModel): assert field.value_type.serialize(None, field) is None assert field.value_type.serialize('', field) == [''] - async def test_validate(self): + async def test_validate_rejects_unknown_or_duplicate_multi_select_options(self): class Importer(BaseModel): multi_checkbox: MultiCheckbox = FieldMeta( label='多选框', @@ -61,7 +57,7 @@ class Importer(BaseModel): field.options = None self.assertRaises(ProgrammaticError, field.value_type.__validate__, ['a', 'b'], field) - async def test_deserialize(self): + async def test_deserialize_maps_multi_select_option_ids_to_display_names(self): class Importer(BaseModel): multi_checkbox: MultiCheckbox = FieldMeta( label='多选框', diff --git a/tests/test_value_type/test_multi_organization.py b/tests/unit/codecs/test_multi_organization_codec.py similarity index 56% rename from tests/test_value_type/test_multi_organization.py rename to tests/unit/codecs/test_multi_organization_codec.py index 28de68d..b11028c 100644 --- a/tests/test_value_type/test_multi_organization.py +++ b/tests/unit/codecs/test_multi_organization_codec.py @@ -2,15 +2,12 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import MultiOrganization -from excelalchemy import Option -from excelalchemy import OptionId -from tests import BaseTestCase +from excelalchemy import FieldMeta, MultiOrganization, Option, OptionId +from tests.support import BaseTestCase -class TestMultiOrganization(BaseTestCase): - async def test_comment(self): +class TestMultiOrganizationValueType(BaseTestCase): + async def test_comment_describes_multi_organization_input(self): class Importer(BaseModel): multi_organization: MultiOrganization = FieldMeta(label='多选组织', order=1) @@ -18,9 +15,12 @@ class Importer(BaseModel): field = alchemy.ordered_field_meta[0] field.value_type = cast(MultiOrganization, field.value_type) - assert field.value_type.comment(field) == '必填性:必填\n提示:需按照组织架构树填写组织完整路径,如“XX公司/一级部门/二级部门”,多选时,选项之间用“、”连接' + assert ( + field.value_type.comment(field) + == '必填性:必填\n提示:需按照组织架构树填写组织完整路径,如“XX公司/一级部门/二级部门”,多选时,选项之间用“、”连接' + ) - async def test_deserialize(self): + async def test_deserialize_maps_organization_ids_to_display_names(self): class Importer(BaseModel): multi_organization: MultiOrganization = FieldMeta( label='多选组织', @@ -35,6 +35,9 @@ class Importer(BaseModel): field = alchemy.ordered_field_meta[0] field.value_type = cast(MultiOrganization, field.value_type) - assert field.value_type.deserialize('XX公司/一级部门/二级部门、XX公司/一级部门/三级部门', field) == 'XX公司/一级部门/二级部门、XX公司/一级部门/三级部门' + assert ( + field.value_type.deserialize('XX公司/一级部门/二级部门、XX公司/一级部门/三级部门', field) + == 'XX公司/一级部门/二级部门、XX公司/一级部门/三级部门' + ) assert field.value_type.deserialize([1, 2], field) == '一级部门,三级部门' assert field.value_type.deserialize([1, 2, 3], field) == '一级部门,三级部门,3' diff --git a/tests/test_value_type/test_multi_staff.py b/tests/unit/codecs/test_multi_staff_codec.py similarity index 73% rename from tests/test_value_type/test_multi_staff.py rename to tests/unit/codecs/test_multi_staff_codec.py index 72af2cd..90443ab 100644 --- a/tests/test_value_type/test_multi_staff.py +++ b/tests/unit/codecs/test_multi_staff_codec.py @@ -2,15 +2,12 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import MultiStaff -from excelalchemy import Option -from excelalchemy import OptionId -from tests import BaseTestCase +from excelalchemy import FieldMeta, MultiStaff, Option, OptionId +from tests.support import BaseTestCase -class TestMultiStaff(BaseTestCase): - async def test_comment(self): +class TestMultiStaffValueType(BaseTestCase): + async def test_comment_describes_multi_staff_input(self): class Importer(BaseModel): staff: MultiStaff = FieldMeta( label='员工', @@ -20,9 +17,12 @@ class Importer(BaseModel): field = alchemy.ordered_field_meta[0] field.value_type = cast(MultiStaff, field.value_type) - assert field.value_type.comment(field) == '必填性:必填\n提示:请输入人员姓名和工号,如“张三/001”,多选时,选项之间用“、”连接' + assert ( + field.value_type.comment(field) + == '必填性:必填\n提示:请输入人员姓名和工号,如“张三/001”,多选时,选项之间用“、”连接' + ) - async def test_serialize(self): + async def test_serialize_splits_multi_staff_input_into_values(self): class Importer(BaseModel): staff: MultiStaff = FieldMeta( label='员工', @@ -39,7 +39,7 @@ class Importer(BaseModel): assert field.value_type.serialize('张三/001、李四/002', field) == ['张三/001、李四/002'] assert field.value_type.serialize('1,2', field) == ['1,2'] - async def test_deserialize(self): + async def test_deserialize_maps_staff_ids_to_display_names(self): class Importer(BaseModel): staff: MultiStaff = FieldMeta( label='员工', diff --git a/tests/test_value_type/test_number.py b/tests/unit/codecs/test_number_codec.py similarity index 90% rename from tests/test_value_type/test_number.py rename to tests/unit/codecs/test_number_codec.py index dacfe30..ecaee09 100644 --- a/tests/test_value_type/test_number.py +++ b/tests/unit/codecs/test_number_codec.py @@ -3,13 +3,12 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Number -from tests import BaseTestCase +from excelalchemy import FieldMeta, Number +from tests.support import BaseTestCase -class TestNumber(BaseTestCase): - async def test_comment(self): +class TestNumberValueType(BaseTestCase): + async def test_comment_reflects_fraction_digits_and_range_constraints(self): class Importer(BaseModel): number: Number = FieldMeta(label='数字', order=1, comment='数字') @@ -31,7 +30,7 @@ class Importer(BaseModel): field.importer_ge = 1 assert field.value_type.comment(field) == '必填性:必填\n格式:数值\n小数位数:2\n可输入范围:≥ 1\n单位:无' - async def test_serialize(self): + async def test_serialize_preserves_numeric_inputs_before_validation(self): class Importer(BaseModel): number: Number = FieldMeta(label='数字', order=1) @@ -48,7 +47,7 @@ class Importer(BaseModel): assert field.value_type.serialize(1.236, field) == 1.236 assert field.value_type.serialize(1.2345, field) == 1.2345 - async def test_deserialize(self): + async def test_deserialize_stringifies_numeric_inputs_for_excel_display(self): class Importer(BaseModel): number: Number = FieldMeta(label='数字', order=1) @@ -66,7 +65,7 @@ class Importer(BaseModel): assert field.value_type.deserialize(1.236, field) == '1.236' assert field.value_type.deserialize(1.2345, field) == '1.2345' - async def test_validate(self): + async def test_validate_enforces_numeric_ranges_and_precision(self): class Importer(BaseModel): number: Number = FieldMeta(label='数字', order=1) diff --git a/tests/test_value_type/test_number_range.py b/tests/unit/codecs/test_number_range_codec.py similarity index 86% rename from tests/test_value_type/test_number_range.py rename to tests/unit/codecs/test_number_range_codec.py index 2c37196..aa67aae 100644 --- a/tests/test_value_type/test_number_range.py +++ b/tests/unit/codecs/test_number_range_codec.py @@ -2,13 +2,12 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import NumberRange -from tests import BaseTestCase +from excelalchemy import FieldMeta, NumberRange +from tests.support import BaseTestCase -class TestNumberRange(BaseTestCase): - def test_comment(self): +class TestNumberRangeValueType(BaseTestCase): + def test_comment_reuses_number_comment_contract(self): class Importer(BaseModel): number: NumberRange = FieldMeta(label='数字', order=1, comment='数字') @@ -19,7 +18,7 @@ class Importer(BaseModel): assert field.value_type.comment(field) == '必填性:必填\n格式:数值\n小数位数:0\n可输入范围:无限制\n单位:无' assert len(field.value_type.model_items()) == 2 - async def test_serialize(self): + async def test_serialize_parses_number_range_inputs(self): class Importer(BaseModel): number: NumberRange = FieldMeta(label='数字', order=1) @@ -46,7 +45,7 @@ class Importer(BaseModel): 'end': 1.23, } - async def test_deserialize(self): + async def test_deserialize_stringifies_number_range_boundaries(self): class Importer(BaseModel): number: NumberRange = FieldMeta(label='数字', order=1) @@ -59,7 +58,7 @@ class Importer(BaseModel): field.fraction_digits = 2 assert field.value_type.deserialize(1.2345, field) == '1.23' - async def test_validate(self): + async def test_validate_enforces_number_range_order_and_constraints(self): class Importer(BaseModel): number: NumberRange = FieldMeta(label='数字', order=1) diff --git a/tests/test_value_type/test_phone_number.py b/tests/unit/codecs/test_phone_number_codec.py similarity index 74% rename from tests/test_value_type/test_phone_number.py rename to tests/unit/codecs/test_phone_number_codec.py index 760559b..94879e5 100644 --- a/tests/test_value_type/test_phone_number.py +++ b/tests/unit/codecs/test_phone_number_codec.py @@ -2,13 +2,12 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import PhoneNumber -from tests import BaseTestCase +from excelalchemy import FieldMeta, PhoneNumber +from tests.support import BaseTestCase -class TestPhoneNumber(BaseTestCase): - async def test_validate(self): +class TestPhoneNumberValueType(BaseTestCase): + async def test_validate_accepts_mobile_numbers_only(self): class Importer(BaseModel): phone_number: PhoneNumber = FieldMeta(label='手机号', order=1) diff --git a/tests/test_value_type/test_radio.py b/tests/unit/codecs/test_radio_codec.py similarity index 89% rename from tests/test_value_type/test_radio.py rename to tests/unit/codecs/test_radio_codec.py index ccb6176..51372d2 100644 --- a/tests/test_value_type/test_radio.py +++ b/tests/unit/codecs/test_radio_codec.py @@ -2,17 +2,13 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Option -from excelalchemy import OptionId -from excelalchemy import ProgrammaticError -from excelalchemy import Radio +from excelalchemy import FieldMeta, Option, OptionId, ProgrammaticError, Radio from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from tests import BaseTestCase +from tests.support import BaseTestCase -class TestRadio(BaseTestCase): - async def test_comment(self): +class TestRadioValueType(BaseTestCase): + async def test_comment_describes_single_select_behavior(self): class Importer(BaseModel): radio: Radio = FieldMeta( label='单选框组', @@ -32,7 +28,7 @@ class Importer(BaseModel): field.options = None assert field.value_type.comment(field) == '必填性:必填\n\n单/多选:单选\n' - async def test_serialize(self): + async def test_serialize_stringifies_option_values(self): class Importer(BaseModel): radio: Radio = FieldMeta( label='单选框组', @@ -50,7 +46,7 @@ class Importer(BaseModel): assert field.value_type.serialize(1, field) == '1' assert field.value_type.serialize(2, field) == '2' - async def test_deserialize(self): + async def test_deserialize_maps_option_ids_to_display_names(self): class Importer(BaseModel): radio: Radio = FieldMeta( label='单选框组', @@ -73,7 +69,7 @@ class Importer(BaseModel): assert field.value_type.deserialize('选项2', field) == '选项2' assert field.value_type.deserialize('选项3', field) == '选项3' - async def test_validate(self): + async def test_validate_accepts_known_options_and_rejects_invalid_inputs(self): class Importer(BaseModel): radio: Radio = FieldMeta( label='单选框组', diff --git a/tests/test_value_type/test_single_organization.py b/tests/unit/codecs/test_single_organization_codec.py similarity index 71% rename from tests/test_value_type/test_single_organization.py rename to tests/unit/codecs/test_single_organization_codec.py index 955c89e..82e53c9 100644 --- a/tests/test_value_type/test_single_organization.py +++ b/tests/unit/codecs/test_single_organization_codec.py @@ -2,15 +2,12 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Option -from excelalchemy import OptionId -from excelalchemy import SingleOrganization -from tests import BaseTestCase +from excelalchemy import FieldMeta, Option, OptionId, SingleOrganization +from tests.support import BaseTestCase -class TestSingleOrganization(BaseTestCase): - async def test_comment(self): +class TestSingleOrganizationValueType(BaseTestCase): + async def test_comment_describes_single_organization_input(self): class Importer(BaseModel): single_organization: SingleOrganization = FieldMeta(label='单选组织', order=1) @@ -18,9 +15,12 @@ class Importer(BaseModel): field = alchemy.ordered_field_meta[0] field.value_type = cast(SingleOrganization, field.value_type) - assert field.value_type.comment(field) == "必填性:必填\n提示:需按照组织架构树填写组织完整路径,例如 'XX公司/一级部门/二级部门'." + assert ( + field.value_type.comment(field) + == "必填性:必填\n提示:需按照组织架构树填写组织完整路径,例如 'XX公司/一级部门/二级部门'." + ) - async def test_serialize(self): + async def test_serialize_strips_single_organization_input(self): class Importer(BaseModel): single_organization: SingleOrganization = FieldMeta(label='单选组织', order=1) @@ -30,7 +30,7 @@ class Importer(BaseModel): assert field.value_type.serialize('XX公司/一级部门/二级部门', field) == 'XX公司/一级部门/二级部门' - async def test_deserialize(self): + async def test_deserialize_maps_single_organization_id_to_display_name(self): class Importer(BaseModel): single_organization: SingleOrganization = FieldMeta( label='单选组织', diff --git a/tests/test_value_type/test_single_staff.py b/tests/unit/codecs/test_single_staff_codec.py similarity index 83% rename from tests/test_value_type/test_single_staff.py rename to tests/unit/codecs/test_single_staff_codec.py index f2bc72a..f5c7b99 100644 --- a/tests/test_value_type/test_single_staff.py +++ b/tests/unit/codecs/test_single_staff_codec.py @@ -2,15 +2,12 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Option -from excelalchemy import OptionId -from excelalchemy import SingleStaff -from tests import BaseTestCase +from excelalchemy import FieldMeta, Option, OptionId, SingleStaff +from tests.support import BaseTestCase -class TestSingleStaff(BaseTestCase): - async def test_comment(self): +class TestSingleStaffValueType(BaseTestCase): + async def test_comment_describes_single_staff_input(self): class Importer(BaseModel): staff: SingleStaff = FieldMeta(label='员工', comment='员工') @@ -20,7 +17,7 @@ class Importer(BaseModel): assert field.value_type.comment(field) == '必填性:必填 \n提示:请输入人员姓名和工号,如“张三/001”' - async def test_serialize(self): + async def test_serialize_strips_single_staff_input(self): class Importer(BaseModel): staff: SingleStaff = FieldMeta( label='员工', @@ -37,7 +34,7 @@ class Importer(BaseModel): assert field.value_type.serialize('张三/001', field) == '张三/001' assert field.value_type.serialize(OptionId(1), field) == '1' - async def test_deserialize(self): + async def test_deserialize_maps_single_staff_id_to_display_name(self): class Importer(BaseModel): staff: SingleStaff = FieldMeta( label='员工', @@ -54,7 +51,7 @@ class Importer(BaseModel): assert field.value_type.deserialize('张三/001', field) == '张三/001' assert field.value_type.deserialize('1', field) == '张三/001' - async def test_validate(self): + async def test_validate_accepts_known_staff_options_and_rejects_invalid_inputs(self): class Importer(BaseModel): staff: SingleStaff = FieldMeta( label='员工', diff --git a/tests/test_value_type/test_url.py b/tests/unit/codecs/test_url_codec.py similarity index 71% rename from tests/test_value_type/test_url.py rename to tests/unit/codecs/test_url_codec.py index cb41ffe..45971fe 100644 --- a/tests/test_value_type/test_url.py +++ b/tests/unit/codecs/test_url_codec.py @@ -2,13 +2,12 @@ from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Url -from tests import BaseTestCase +from excelalchemy import FieldMeta, Url +from tests.support import BaseTestCase -class TestUrl(BaseTestCase): - async def test_comment(self): +class TestUrlValueType(BaseTestCase): + async def test_comment_describes_url_input(self): class Importer(BaseModel): url: Url = FieldMeta(label='url', order=1) @@ -16,9 +15,12 @@ class Importer(BaseModel): field = alchemy.ordered_field_meta[0] field.value_type = cast(Url, field.value_type) - assert field.value_type.comment(field) == '唯一性:非唯一\n必填性:必填\n最大长度:无限制\n可输入内容:中文、数字、大写字母、小写字母、符号\n' + assert ( + field.value_type.comment(field) + == '唯一性:非唯一\n必填性:必填\n最大长度:无限制\n可输入内容:中文、数字、大写字母、小写字母、符号\n' + ) - async def test_serialize(self): + async def test_serialize_strips_url_input(self): class Importer(BaseModel): url: Url = FieldMeta(label='url', order=1) @@ -28,7 +30,7 @@ class Importer(BaseModel): assert field.value_type.serialize('http://www.baidu.com', field) == 'http://www.baidu.com' - async def test_deserialize(self): + async def test_deserialize_returns_user_visible_url_values(self): class Importer(BaseModel): url: Url = FieldMeta(label='url', order=1) @@ -39,7 +41,7 @@ class Importer(BaseModel): assert field.value_type.deserialize('http://www.baidu.com', field) == 'http://www.baidu.com' assert field.value_type.deserialize('1', field) == '1' - async def test_validate(self): + async def test_validate_accepts_well_formed_urls(self): class Importer(BaseModel): url: Url = FieldMeta(label='url', order=1) diff --git a/tests/test_util.py b/tests/unit/test_converters_and_schema_extraction.py similarity index 58% rename from tests/test_util.py rename to tests/unit/test_converters_and_schema_extraction.py index 37e8f21..72db7b3 100644 --- a/tests/test_util.py +++ b/tests/unit/test_converters_and_schema_extraction.py @@ -2,26 +2,31 @@ from pydantic import BaseModel -from excelalchemy import ExcelAlchemy -from excelalchemy import FieldMeta -from excelalchemy import ImporterConfig -from excelalchemy import String -from excelalchemy import extract_pydantic_model -from excelalchemy.util.convertor import export_data_converter -from excelalchemy.util.convertor import import_data_converter +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, String, extract_pydantic_model +from excelalchemy.util.convertor import export_data_converter, import_data_converter -class TestUtil(IsolatedAsyncioTestCase): +class TestConvertersAndSchemaExtraction(IsolatedAsyncioTestCase): class Importer(BaseModel): name: String = FieldMeta(label='名称', order=1) address: String | None = FieldMeta(label='地址', order=3) - def test_template(self): + def test_download_template_returns_excel_payload(self): alchemy = ExcelAlchemy(ImporterConfig(self.Importer)) template = alchemy.download_template() assert template is not None and len(template) > 100 - def test_extract_pydantic_model(self): + def test_download_template_artifact_returns_bytes_and_data_url_views(self): + alchemy = ExcelAlchemy(ImporterConfig(self.Importer)) + artifact = alchemy.download_template_artifact(filename='schema-template.xlsx') + + assert artifact.filename == 'schema-template.xlsx' + assert artifact.as_bytes().startswith(b'PK') + assert artifact.as_data_url().startswith( + 'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,' + ) + + def test_extract_pydantic_model_returns_field_metadata(self): field_metas = extract_pydantic_model(self.Importer) self.assertIsNotNone(field_metas) assert len(field_metas) == 2 @@ -29,19 +34,19 @@ def test_extract_pydantic_model(self): assert field_metas[1].label == '地址' @classmethod - def test_import_data_converter(cls): + def test_import_data_converter_normalizes_keys_to_snake_case(cls): input_data = {'Name': 'name', 'Address': 'address', 'FieldData': {'ID': 'id', 'Name': 'name'}} expected = {'name': 'name', 'address': 'address', 'field_data': {'ID': 'id', 'Name': 'name'}} assert import_data_converter(input_data) == expected @classmethod - def test_export_data_converter(cls): + def test_export_data_converter_flattens_field_data_keys(cls): input_data = {'name': 'name', 'Age': None, 'address': 'address', 'field_data': {'ID': 'id', 'Name': 'name'}} expected = {'address': 'address', 'age': None, 'field_data': {'ID': 'id', 'Name': 'name'}, 'name': 'name'} assert export_data_converter(input_data) == expected @classmethod - def test_export_data_converter_to_camel_case(cls): + def test_export_data_converter_preserves_camel_case_when_requested(cls): input_data = {'name': 'name', 'address': 'address', 'field_data': {'ID': 'id', 'Name': 'name'}} expected = {'address': 'address', 'fieldData.ID': 'id', 'fieldData.Name': 'name', 'name': 'name'} assert export_data_converter(input_data, to_camel=True) == expected diff --git a/tests/unit/test_deprecation_policy.py b/tests/unit/test_deprecation_policy.py new file mode 100644 index 0000000..ae03e91 --- /dev/null +++ b/tests/unit/test_deprecation_policy.py @@ -0,0 +1,69 @@ +import importlib +import sys +import warnings + +from excelalchemy import ExcelAlchemyDeprecationWarning + + +def import_compat_module(module_name: str) -> list[warnings.WarningMessage]: + compat_prefixes = ( + 'excelalchemy.types', + 'excelalchemy.exc', + 'excelalchemy.identity', + 'excelalchemy.header_models', + ) + compat_modules = [ + loaded_name + for loaded_name in sys.modules + if any(loaded_name == prefix or loaded_name.startswith(f'{prefix}.') for prefix in compat_prefixes) + ] + for loaded_name in compat_modules: + sys.modules.pop(loaded_name, None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always', ExcelAlchemyDeprecationWarning) + importlib.import_module(module_name) + + return caught + + +class TestDeprecationPolicy: + def test_package_level_compat_namespace_emits_explicit_deprecation_warning(self): + warnings_seen = import_compat_module('excelalchemy.types') + + assert any( + isinstance(warning.message, ExcelAlchemyDeprecationWarning) + and '`excelalchemy.types` is deprecated' in str(warning.message) + and 'ExcelAlchemy 3.0' in str(warning.message) + for warning in warnings_seen + ) + + def test_leaf_compat_module_points_to_replacement_import_path(self): + warnings_seen = import_compat_module('excelalchemy.types.value.string') + + assert any( + isinstance(warning.message, ExcelAlchemyDeprecationWarning) + and '`excelalchemy.types.value.string` is deprecated' in str(warning.message) + and '`excelalchemy.codecs.string`' in str(warning.message) + for warning in warnings_seen + ) + + def test_legacy_exc_module_points_to_public_exceptions_module(self): + warnings_seen = import_compat_module('excelalchemy.exc') + + assert any( + isinstance(warning.message, ExcelAlchemyDeprecationWarning) + and '`excelalchemy.exc` is deprecated' in str(warning.message) + and '`excelalchemy.exceptions`' in str(warning.message) + for warning in warnings_seen + ) + + def test_legacy_identity_module_points_to_package_root(self): + warnings_seen = import_compat_module('excelalchemy.identity') + + assert any( + isinstance(warning.message, ExcelAlchemyDeprecationWarning) + and '`excelalchemy.identity` is deprecated' in str(warning.message) + and '`the excelalchemy package root`' in str(warning.message) + for warning in warnings_seen + ) diff --git a/tests/unit/test_excel_exceptions.py b/tests/unit/test_excel_exceptions.py new file mode 100644 index 0000000..5131479 --- /dev/null +++ b/tests/unit/test_excel_exceptions.py @@ -0,0 +1,60 @@ +from excelalchemy import ExcelCellError, ExcelRowError, Label +from tests.support import BaseTestCase + + +class TestExcelExceptions(BaseTestCase): + async def test_excel_cell_errors_compare_equal_when_message_and_label_match(self): + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + exc2 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + assert exc1 == exc2 + + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + exc2 = ExcelCellError(label=Label('邮箱1'), message='Enter a valid email address') + + assert exc1 != exc2 + + async def test_excel_cell_error_repr_includes_label_and_message(self): + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + assert repr(exc1) == "ExcelCellError(label=Label('邮箱'), message='Enter a valid email address')" + + async def test_excel_cell_error_str_prefixes_label(self): + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + assert str(exc1) == '【邮箱】Enter a valid email address' + + async def test_excel_cell_error_requires_non_empty_label(self): + self.assertRaises(ValueError, ExcelCellError, label=Label(''), message='Enter a valid email address') + + async def test_excel_cell_error_builds_unique_label_from_parent_when_present(self): + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + assert exc1.unique_label == '邮箱' + + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address', parent_label=Label('父')) + assert exc1.unique_label == '父·邮箱' + + async def test_excel_cell_error_supports_equality_and_inequality_operations(self): + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + exc2 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + assert exc1 == exc2 + + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + exc2 = ExcelCellError(label=Label('邮箱1'), message='Enter a valid email address') + assert exc1 != exc2 + + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') + other = 'other' + + assert exc1 != other + assert other != exc1 + + async def test_excel_row_error_preserves_message_in_string_representations(self): + exc1 = ExcelRowError(message='Excel row import error') + assert exc1.message == 'Excel row import error' + + exc1 = ExcelRowError(message='Enter a valid email address') + assert exc1.message == 'Enter a valid email address' + + exc1 = ExcelRowError(message='Enter a valid email address') + assert str(exc1) == 'Enter a valid email address' + + exc1 = ExcelRowError(message='Enter a valid email address') + assert repr(exc1) == "ExcelRowError(message='Enter a valid email address')" diff --git a/tests/test_field_meta.py b/tests/unit/test_field_metadata.py similarity index 80% rename from tests/test_field_meta.py rename to tests/unit/test_field_metadata.py index 96feb35..5f9d080 100644 --- a/tests/test_field_meta.py +++ b/tests/unit/test_field_metadata.py @@ -1,20 +1,26 @@ -from pydantic import BaseModel - -from excelalchemy import ConfigError -from excelalchemy import DataRangeOption -from excelalchemy import Date -from excelalchemy import DateFormat -from excelalchemy import Email -from excelalchemy import FieldMeta -from excelalchemy import Number -from excelalchemy import Option -from excelalchemy import OptionId -from excelalchemy import Radio -from tests import BaseTestCase - - -class TestFieldMeta(BaseTestCase): - async def test_set_is_primary_key(self): +from typing import Annotated + +from pydantic import BaseModel, Field + +from excelalchemy import ( + ConfigError, + DataRangeOption, + Date, + DateFormat, + Email, + EmailCodec, + ExcelMeta, + FieldMeta, + Number, + Option, + OptionId, + Radio, +) +from tests.support import BaseTestCase + + +class TestFieldMetadata(BaseTestCase): + async def test_set_is_primary_key_marks_field_as_required_and_unique(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -39,7 +45,7 @@ class Importer(BaseModel): assert alchemy.ordered_field_meta[0].is_primary_key assert alchemy.ordered_field_meta[0].required and alchemy.ordered_field_meta[0].unique - async def test_set_unique(self): + async def test_set_unique_marks_field_as_required(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -64,7 +70,7 @@ class Importer(BaseModel): assert alchemy.ordered_field_meta[0].unique assert alchemy.ordered_field_meta[0].required - async def test_validate_state(self): + async def test_validate_state_accepts_consistent_field_configuration(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -97,9 +103,9 @@ class Importer(BaseModel): alchemy = self.build_alchemy(Importer) assert alchemy.ordered_field_meta[0].exchange_names_to_option_ids_with_errors( [OptionId('男'), OptionId('不存在')] - ) == (['male'], ['选项不存在,请参照表头的注释填写']) + ) == (['male'], ['Option not found; check the header comment for valid values']) - async def test_unique_label(self): + async def test_unique_label_uses_parent_label_for_nested_fields(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -117,7 +123,7 @@ class Importer(BaseModel): alchemy.ordered_field_meta[1].parent_label = '父' assert alchemy.ordered_field_meta[1].unique_label == '父·邮箱2' - async def test_unique_key(self): + async def test_unique_key_uses_parent_key_for_nested_fields(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -135,7 +141,7 @@ class Importer(BaseModel): alchemy.ordered_field_meta[1].parent_key = 'parent' assert alchemy.ordered_field_meta[1].unique_key == 'parent·email2' - async def test_options_id_map(self): + async def test_options_id_map_indexes_options_by_id(self): class Importer(BaseModel): sex: Radio = FieldMeta( label='邮箱', @@ -149,7 +155,7 @@ class Importer(BaseModel): 'female': Option(id=OptionId('female'), name='女'), } - async def test_options_name_map(self): + async def test_options_name_map_indexes_options_by_name(self): class Importer(BaseModel): sex: Radio = FieldMeta( label='邮箱', @@ -163,7 +169,7 @@ class Importer(BaseModel): '女': Option(id=OptionId('female'), name='女'), } - async def test_comment_required(self): + async def test_comment_required_reflects_required_flag(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -179,7 +185,7 @@ class Importer(BaseModel): assert alchemy.ordered_field_meta[0].comment_required == '必填性:必填' assert alchemy.ordered_field_meta[1].comment_required == '必填性:选填' - async def test_comment_date_format(self): + async def test_comment_date_format_uses_configured_date_pattern(self): class Importer(BaseModel): date: Date = FieldMeta(label='日期', order=1, date_format=DateFormat.DAY) date2: Date = FieldMeta(label='日期', order=2, date_format=DateFormat.MONTH) @@ -188,7 +194,7 @@ class Importer(BaseModel): assert alchemy.ordered_field_meta[0].comment_date_format == '格式:日期(yyyy/mm/dd)' assert alchemy.ordered_field_meta[1].comment_date_format == '格式:日期(yyyy/mm)' - async def test_comment_date_range_option(self): + async def test_comment_date_range_option_reflects_range_constraint(self): class Importer(BaseModel): ne: Date = FieldMeta( label='日期', @@ -211,7 +217,7 @@ class Importer(BaseModel): assert alchemy.ordered_field_meta[1].comment_date_range_option == '范围:无限制' assert alchemy.ordered_field_meta[2].comment_date_range_option == '范围:早于当前时间' - async def test_comment_hint(self): + async def test_comment_hint_returns_configured_hint(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -222,7 +228,7 @@ class Importer(BaseModel): alchemy = self.build_alchemy(Importer) assert alchemy.ordered_field_meta[0].comment_hint == '提示:请输入邮箱' - async def test_comment_options(self): + async def test_comment_options_lists_available_option_names(self): class Importer(BaseModel): sex: Radio = FieldMeta( label='邮箱', @@ -233,7 +239,7 @@ class Importer(BaseModel): alchemy = self.build_alchemy(Importer) assert alchemy.ordered_field_meta[0].comment_options == '选项:男,女' - async def test_comment_fraction_digits(self): + async def test_comment_fraction_digits_reflects_numeric_precision(self): class Importer(BaseModel): decimal: Number = FieldMeta( label='邮箱', @@ -244,7 +250,7 @@ class Importer(BaseModel): alchemy = self.build_alchemy(Importer) assert alchemy.ordered_field_meta[0].comment_fraction_digits == '小数位数:2' - async def test_comment_unit(self): + async def test_comment_unit_reflects_configured_unit(self): class Importer(BaseModel): decimal: Number = FieldMeta( label='邮箱', @@ -255,7 +261,7 @@ class Importer(BaseModel): alchemy = self.build_alchemy(Importer) assert alchemy.ordered_field_meta[0].comment_unit == '单位:元' - async def test_comment_unique(self): + async def test_comment_unique_reflects_unique_constraint(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -266,7 +272,7 @@ class Importer(BaseModel): alchemy = self.build_alchemy(Importer) assert alchemy.ordered_field_meta[0].comment_unique == '唯一性:唯一' - async def test_comment_max_length(self): + async def test_comment_max_length_reflects_string_length_limit(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -277,7 +283,7 @@ class Importer(BaseModel): alchemy = self.build_alchemy(Importer) assert alchemy.ordered_field_meta[0].comment_max_length == '最大长度:10' - async def test_must_date_format(self): + async def test_must_date_format_returns_configured_format_or_raises(self): class Importer(BaseModel): date: Date = FieldMeta(label='日期', order=1, date_format=DateFormat.DAY) date2: Date = FieldMeta( @@ -291,7 +297,7 @@ class Importer(BaseModel): with self.assertRaises(ConfigError): alchemy.ordered_field_meta[1].must_date_format # noqa - async def test_python_date_format(self): + async def test_python_date_format_maps_enum_to_strftime_pattern(self): class Importer(BaseModel): date: Date = FieldMeta(label='日期', order=1, date_format=DateFormat.DAY) date2: Date = FieldMeta( @@ -305,7 +311,7 @@ class Importer(BaseModel): with self.assertRaises(ConfigError): alchemy.ordered_field_meta[1].python_date_format # noqa - async def test_repr(self): + async def test_repr_summarizes_field_metadata_state(self): class Importer(BaseModel): email: Email = FieldMeta( label='邮箱', @@ -314,7 +320,20 @@ class Importer(BaseModel): ) alchemy = self.build_alchemy(Importer) + assert alchemy.ordered_field_meta[0].excel_codec is Email + assert alchemy.ordered_field_meta[0].value_type is EmailCodec assert repr(alchemy.ordered_field_meta[0]) == ( - "FieldMeta(label='邮箱', order=1, value_type='Email', required=True, " + "FieldMeta(label='邮箱', order=1, excel_codec='Email', required=True, " "unique=True, comment_required='必填性:必填', comment_unique='唯一性:唯一')" ) + + async def test_excelmeta_supports_annotated_field_declarations(self): + class Importer(BaseModel): + email: Annotated[Email, Field(max_length=10), ExcelMeta(label='邮箱', order=1)] + + alchemy = self.build_alchemy(Importer) + field_meta = alchemy.ordered_field_meta[0] + + assert field_meta.label == '邮箱' + assert field_meta.excel_codec is Email + assert field_meta.comment_max_length == '最大长度:10' diff --git a/tests/unit/test_file_utils.py b/tests/unit/test_file_utils.py new file mode 100644 index 0000000..94ef2c9 --- /dev/null +++ b/tests/unit/test_file_utils.py @@ -0,0 +1,13 @@ +from excelalchemy.util.file import EXCEL_PREFIX, remove_excel_prefix + + +class TestFileUtils: + def test_remove_excel_prefix_strips_only_the_exact_prefix(self): + prefixed_content = f'{EXCEL_PREFIX},data:payload' + + assert remove_excel_prefix(prefixed_content) == 'data:payload' + + def test_remove_excel_prefix_leaves_unprefixed_content_unchanged(self): + content = 'data:payload' + + assert remove_excel_prefix(content) == content diff --git a/tests/unit/test_i18n_messages.py b/tests/unit/test_i18n_messages.py new file mode 100644 index 0000000..376572f --- /dev/null +++ b/tests/unit/test_i18n_messages.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel + +from excelalchemy import Date, DateFormat, FieldMeta +from excelalchemy.i18n.messages import ( + DISPLAY_DEFAULT_LOCALE, + SUPPORTED_DISPLAY_LOCALES, + SUPPORTED_RUNTIME_LOCALES, + MessageKey, + display_message, + message, + use_display_locale, +) +from excelalchemy.metadata import extract_declared_field_metadata +from excelalchemy.results import ValidateRowResult + + +class TestI18nMessages: + def test_message_formats_templates(self): + assert message(MessageKey.ENTER_DATE_FORMAT, date_format='yyyy/mm/dd') == 'Enter a date in yyyy/mm/dd format' + + def test_message_falls_back_to_default_locale(self): + assert ( + message(MessageKey.NO_STORAGE_BACKEND_CONFIGURED, locale='zh-CN') + == 'No storage backend is configured; pass storage=... or install and configure ExcelAlchemy[minio]' + ) + + def test_display_message_uses_context_locale(self): + with use_display_locale('en'): + assert ( + display_message(MessageKey.RESULT_COLUMN_LABEL) + == 'Validation result\nDelete this column before re-uploading' + ) + assert str(ValidateRowResult.FAIL) == 'Validation failed' + + def test_public_locale_policy_constants_are_stable(self): + assert SUPPORTED_RUNTIME_LOCALES == ('en',) + assert SUPPORTED_DISPLAY_LOCALES == ('zh-CN', 'en') + assert DISPLAY_DEFAULT_LOCALE == 'zh-CN' + + def test_comment_strings_switch_with_display_locale(self): + class Importer(BaseModel): + birth_date: Date = FieldMeta(label='Birth date', order=1, date_format=DateFormat.DAY) + + field = extract_declared_field_metadata(Importer.model_fields['birth_date']) + field.required = True + with use_display_locale('en'): + assert field.comment_required == 'Required: required' + assert field.comment_date_format == 'Format: date (yyyy/mm/dd)' diff --git a/typings/.gitkeep b/typings/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8b72bba --- /dev/null +++ b/uv.lock @@ -0,0 +1,806 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "excelalchemy" +source = { editable = "." } +dependencies = [ + { name = "openpyxl" }, + { name = "pendulum" }, + { name = "pydantic", extra = ["email"] }, +] + +[package.optional-dependencies] +development = [ + { name = "coverage" }, + { name = "minio" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +minio = [ + { name = "minio" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", marker = "extra == 'development'" }, + { name = "minio", marker = "extra == 'development'", specifier = ">=7.2.20,<8" }, + { name = "minio", marker = "extra == 'minio'", specifier = ">=7.2.20,<8" }, + { name = "openpyxl", specifier = ">=3.1.5,<4" }, + { name = "pendulum", specifier = ">=3.2.0,<4" }, + { name = "pre-commit", marker = "extra == 'development'" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.12,<3" }, + { name = "pyright", marker = "extra == 'development'", specifier = "==1.1.408" }, + { name = "pytest", marker = "extra == 'development'" }, + { name = "pytest-cov", marker = "extra == 'development'" }, + { name = "ruff", marker = "extra == 'development'" }, +] +provides-extras = ["development", "minio"] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pendulum" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/56/dd0ea9f97d25a0763cda09e2217563b45714786118d8c68b0b745395d6eb/pendulum-3.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bf0b489def51202a39a2a665dcc4162d5e46934a740fe4c4fe3068979610156c", size = 337830, upload-time = "2026-01-30T11:21:08.298Z" }, + { url = "https://files.pythonhosted.org/packages/cf/98/83d62899bf7226fc12396de4bc1fb2b5da27e451c7c60790043aaf8b4731/pendulum-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:937a529aa302efa18dcf25e53834964a87ffb2df8f80e3669ab7757a6126beaf", size = 327574, upload-time = "2026-01-30T11:21:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/76/fa/ff2aa992b23f0543c709b1a3f3f9ed760ec71fd02c8bb01f93bf008b52e4/pendulum-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85c7689defc65c4dc29bf257f7cca55d210fabb455de9476e1748d2ab2ae80d7", size = 339891, upload-time = "2026-01-30T11:21:11.089Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4e/25b4fa11d19503d50d7b52d7ef943c0f20fd54422aaeb9e38f588c815c50/pendulum-3.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e216e5a412563ea2ecf5de467dcf3d02717947fcdabe6811d5ee360726b02b", size = 373726, upload-time = "2026-01-30T11:21:12.493Z" }, + { url = "https://files.pythonhosted.org/packages/4f/30/0acad6396c4e74e5c689aa4f0b0c49e2ecdcfce368e7b5bf35ca1c0fc61a/pendulum-3.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a2af22eeec438fbaac72bb7fba783e0950a514fba980d9a32db394b51afccec", size = 379827, upload-time = "2026-01-30T11:21:14.08Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f7/e6a2fdf2a23d59b4b48b8fa89e8d4bf2dd371aea2c6ba8fcecec20a4acb9/pendulum-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3159cceb54f5aa8b85b141c7f0ce3fac8bdd1ffdc7c79e67dca9133eac7c4d11", size = 348921, upload-time = "2026-01-30T11:21:15.816Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f2/c15fa7f9ad4e181aa469b6040b574988bd108ccdf4ae509ad224f9e4db44/pendulum-3.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c39ea5e9ffa20ea8bae986d00e0908bd537c8468b71d6b6503ab0b4c3d76e0ea", size = 517188, upload-time = "2026-01-30T11:21:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/5f80b12ee88ec26e930c3a5a602608a63c29cf60c81a0eb066d583772550/pendulum-3.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5afc753e570cce1f44197676371f68953f7d4f022303d141bb09f804d5fe6d7", size = 561833, upload-time = "2026-01-30T11:21:19.232Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/1ac481626cb63db751f6281e294661947c1f0321ebe5d1c532a3b51a8006/pendulum-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:fd55c12560816d9122ca2142d9e428f32c0c083bf77719320b1767539c7a3a3b", size = 258725, upload-time = "2026-01-30T11:21:20.558Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/50b0398d7d027eb70a3e1e336de7b6e599c6b74431cb7d3863287e1292bb/pendulum-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:faef52a7ed99729f0838353b956f3fabf6c550c062db247e9e2fc2b48fcb9457", size = 253089, upload-time = "2026-01-30T11:21:22.497Z" }, + { url = "https://files.pythonhosted.org/packages/27/8c/400c8b8dbd7524424f3d9902ded64741e82e5e321d1aabbd68ade89e71cf/pendulum-3.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:addb0512f919fe5b70c8ee534ee71c775630d3efe567ea5763d92acff857cfc3", size = 337820, upload-time = "2026-01-30T11:21:24.305Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7c16f26cc55d9206d71da294ce6857d0da381e26bc9e0c2a069424c2b173/pendulum-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3aaa50342dc174acebdc21089315012e63789353957b39ac83cac9f9fc8d1075", size = 327551, upload-time = "2026-01-30T11:21:25.747Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/f36ec5d56d55104232380fdbf84ff53cc05607574af3cbdc8a43991ac8a7/pendulum-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:927e9c9ab52ff68e71b76dd410e5f1cd78f5ea6e7f0a9f5eb549aea16a4d5354", size = 339894, upload-time = "2026-01-30T11:21:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/b9a1e546519c3a92d5bc17787cea925e06a20def2ae344fa136d2fc40338/pendulum-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:249d18f5543c9f43aba3bd77b34864ec8cf6f64edbead405f442e23c94fce63d", size = 373766, upload-time = "2026-01-30T11:21:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a6/6471ab87ae2260594501f071586a765fc894817043b7d2d4b04e2eff4f31/pendulum-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c644cc15eec5fb02291f0f193195156780fd5a0affd7a349592403826d1a35e", size = 379837, upload-time = "2026-01-30T11:21:30.637Z" }, + { url = "https://files.pythonhosted.org/packages/0d/79/0ba0c14e862388f7b822626e6e989163c23bebe7f96de5ec4b207cbe7c3d/pendulum-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:063ab61af953bb56ad5bc8e131fd0431c915ed766d90ccecd7549c8090b51004", size = 348904, upload-time = "2026-01-30T11:21:32.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/34/df922c7c0b12719589d4954bfa5bdca9e02bcde220f5c5c1838a87118960/pendulum-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:26a3ae26c9dd70a4256f1c2f51addc43641813574c0db6ce5664f9861cd93621", size = 517173, upload-time = "2026-01-30T11:21:34.428Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/3b9e061eeee97b72a47c1434ee03f6d85f0284d9285d92b12b0fff2d19ac/pendulum-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2b10d91dc00f424444a42f47c69e6b3bfd79376f330179dc06bc342184b35f9a", size = 561744, upload-time = "2026-01-30T11:21:35.861Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7e/f12fdb6070b7975c1fcfa5685dbe4ab73c788878a71f4d1d7e3c87979e37/pendulum-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:63070ff03e30a57b16c8e793ee27da8dac4123c1d6e0cf74c460ce9ee8a64aa4", size = 258746, upload-time = "2026-01-30T11:21:37.782Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/5abd872056357f069ae34a9b24a75ac58e79092d16201d779a8dd31386bb/pendulum-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c8dde63e2796b62070a49ce813ce200aba9186130307f04ec78affcf6c2e8122", size = 253028, upload-time = "2026-01-30T11:21:39.381Z" }, + { url = "https://files.pythonhosted.org/packages/82/99/5b9cc823862450910bcb2c7cdc6884c0939b268639146d30e4a4f55eb1f1/pendulum-3.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c17ac069e88c5a1e930a5ae0ef17357a14b9cc5a28abadda74eaa8106d241c8e", size = 338281, upload-time = "2026-01-30T11:21:40.812Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3a/64a35260f6ac36c0ad50eeb5f1a465b98b0d7603f79a5c2077c41326d639/pendulum-3.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1fbb540edecb21f8244aebfb05a1f2333ddc6c7819378c099d4a61cc91ae93c", size = 328030, upload-time = "2026-01-30T11:21:42.778Z" }, + { url = "https://files.pythonhosted.org/packages/da/6b/1140e09310035a2afb05bb90a2b8fbda9d3222e03b92de9533123afe6b65/pendulum-3.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8c67fb9a1fe8fc1adae2cc01b0c292b268c12475b4609ff4aed71c9dd367b4d", size = 340206, upload-time = "2026-01-30T11:21:44.148Z" }, + { url = "https://files.pythonhosted.org/packages/52/4a/a493de56cbc24a64b21ac6ba98513a9ec5c67daa3dba325e39a8e53f30d8/pendulum-3.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:baa9a66c980defda6cfe1275103a94b22e90d83ebd7a84cc961cee6cbd25a244", size = 373976, upload-time = "2026-01-30T11:21:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/f083c4fd1a161d4ab218680cc906338c541497b3098373f2241f58c429cb/pendulum-3.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef8f783fa7a14973b0596d8af2a5b2d90858a55030e9b4c6885eb4284b88314f", size = 380075, upload-time = "2026-01-30T11:21:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/333a0fcb33bf15eb879a46a11ce6300c1698a141e689665fe430783ff8d6/pendulum-3.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7d2e9bfb065727d8676e7ada3793b47a24349500a5e9637404355e482c822be", size = 349026, upload-time = "2026-01-30T11:21:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/dfb526ec0cba1e7cd6a5e4f4dd64a6ada7428d1449c54b15f7b295f6e122/pendulum-3.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:55d7ba6bb74171c3ee409bf30076ee3a259a3c2bb147ac87ebb76aaa3cf5d3a2", size = 517395, upload-time = "2026-01-30T11:21:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/c9/37/b4f2b5f1200351c4869b8b46ad5c21019e3dbe0417f5867ae969fad7b5fe/pendulum-3.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:a50d8cf42f06d3d8c3f8bb2a7ac47fa93b5145e69de6a7209be6a47afdd9cf76", size = 561926, upload-time = "2026-01-30T11:21:51.698Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9e/567376582da58f5fe8e4f579db2bcfbf243cf619a5825bdf1023ad1436b3/pendulum-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e5bbb92b155cd5018b3cf70ee49ed3b9c94398caaaa7ed97fe41e5bb5a968418", size = 258817, upload-time = "2026-01-30T11:21:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/dfffd7eb50d67fa821cd4d92cf71575ead6162930202bc40dfcedf78c38c/pendulum-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:d53134418e04335c3029a32e9341cccc9b085a28744fb5ee4e6a8f5039363b1a", size = 253292, upload-time = "2026-01-30T11:21:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +]