From d2a791d653a525373bb646b2c3ac1348127e2ad6 Mon Sep 17 00:00:00 2001 From: ruicore Date: Fri, 20 Mar 2026 19:12:19 +0800 Subject: [PATCH 01/27] =?UTF-8?q?feat(PR-01):=20=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E4=B8=8E=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/__init__.py | 39 +---- tests/contracts/__init__.py | 1 + tests/contracts/test_export_contract.py | 60 ++++++++ tests/contracts/test_import_contract.py | 106 ++++++++++++++ tests/contracts/test_pydantic_contract.py | 33 +++++ tests/contracts/test_result_contract.py | 48 +++++++ tests/contracts/test_storage_contract.py | 52 +++++++ tests/contracts/test_template_contract.py | 88 ++++++++++++ tests/integration/__init__.py | 1 + .../test_excelalchemy_workflows.py} | 30 ++-- tests/support/__init__.py | 25 ++++ tests/support/base.py | 34 +++++ tests/support/contract_models.py | 136 ++++++++++++++++++ tests/{ => support}/mock_minio.py | 24 ++-- tests/{ => support}/registry.py | 0 tests/support/workbook.py | 61 ++++++++ tests/unit/__init__.py | 1 + .../test_converters_and_schema_extraction.py} | 12 +- .../test_excel_exceptions.py} | 18 +-- .../test_field_metadata.py} | 42 +++--- tests/unit/value_types/__init__.py | 1 + .../value_types/test_boolean_value_type.py} | 12 +- .../test_date_range_value_type.py} | 20 +-- .../value_types/test_date_value_type.py} | 26 ++-- .../value_types/test_email_value_type.py} | 12 +- .../value_types/test_money_value_type.py} | 6 +- .../test_multi_checkbox_value_type.py} | 12 +- .../test_multi_organization_value_type.py} | 8 +- .../test_multi_staff_value_type.py} | 10 +- .../test_number_range_value_type.py} | 12 +- .../value_types/test_number_value_type.py} | 12 +- .../test_phone_number_value_type.py} | 6 +- .../value_types/test_radio_value_type.py} | 12 +- .../test_single_organization_value_type.py} | 10 +- .../test_single_staff_value_type.py} | 12 +- .../value_types/test_url_value_type.py} | 12 +- 36 files changed, 809 insertions(+), 185 deletions(-) create mode 100644 tests/contracts/__init__.py create mode 100644 tests/contracts/test_export_contract.py create mode 100644 tests/contracts/test_import_contract.py create mode 100644 tests/contracts/test_pydantic_contract.py create mode 100644 tests/contracts/test_result_contract.py create mode 100644 tests/contracts/test_storage_contract.py create mode 100644 tests/contracts/test_template_contract.py create mode 100644 tests/integration/__init__.py rename tests/{test_import.py => integration/test_excelalchemy_workflows.py} (93%) create mode 100644 tests/support/__init__.py create mode 100644 tests/support/base.py create mode 100644 tests/support/contract_models.py rename tests/{ => support}/mock_minio.py (88%) rename tests/{ => support}/registry.py (100%) create mode 100644 tests/support/workbook.py create mode 100644 tests/unit/__init__.py rename tests/{test_util.py => unit/test_converters_and_schema_extraction.py} (82%) rename tests/{test_exception.py => unit/test_excel_exceptions.py} (79%) rename tests/{test_field_meta.py => unit/test_field_metadata.py} (88%) create mode 100644 tests/unit/value_types/__init__.py rename tests/{test_value_type/test_boolean.py => unit/value_types/test_boolean_value_type.py} (85%) rename tests/{test_value_type/test_daterange.py => unit/value_types/test_date_range_value_type.py} (92%) rename tests/{test_value_type/test_date.py => unit/value_types/test_date_value_type.py} (89%) rename tests/{test_value_type/test_email.py => unit/value_types/test_email_value_type.py} (83%) rename tests/{test_value_type/test_money.py => unit/value_types/test_money_value_type.py} (78%) rename tests/{test_value_type/test_multi_checkbox.py => unit/value_types/test_multi_checkbox_value_type.py} (89%) rename tests/{test_value_type/test_multi_organization.py => unit/value_types/test_multi_organization_value_type.py} (87%) rename tests/{test_value_type/test_multi_staff.py => unit/value_types/test_multi_staff_value_type.py} (86%) rename tests/{test_value_type/test_number_range.py => unit/value_types/test_number_range_value_type.py} (88%) rename tests/{test_value_type/test_number.py => unit/value_types/test_number_value_type.py} (91%) rename tests/{test_value_type/test_phone_number.py => unit/value_types/test_phone_number_value_type.py} (81%) rename tests/{test_value_type/test_radio.py => unit/value_types/test_radio_value_type.py} (91%) rename tests/{test_value_type/test_single_organization.py => unit/value_types/test_single_organization_value_type.py} (85%) rename tests/{test_value_type/test_single_staff.py => unit/value_types/test_single_staff_value_type.py} (86%) rename tests/{test_value_type/test_url.py => unit/value_types/test_url_value_type.py} (84%) diff --git a/tests/__init__.py b/tests/__init__.py index a001d73..a507c1d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,35 +1,6 @@ -from typing import Any -from typing import cast -from unittest import IsolatedAsyncioTestCase +from tests.support import BaseTestCase +from tests.support import FileRegistry +from tests.support import LocalMockMinio +from tests.support import 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', 'LocalMockMinio', 'local_minio'] diff --git a/tests/contracts/__init__.py b/tests/contracts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/contracts/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/contracts/test_export_contract.py b/tests/contracts/test_export_contract.py new file mode 100644 index 0000000..bb0ed9f --- /dev/null +++ b/tests/contracts/test_export_contract.py @@ -0,0 +1,60 @@ +from typing import cast + +from minio import Minio + +from excelalchemy import ExcelAlchemy +from excelalchemy import ExporterConfig +from tests.support import BaseTestCase +from tests.support import decode_prefixed_excel_to_workbook +from tests.support.contract_models import MergedContractImporter +from tests.support.contract_models import SimpleContractImporter +from tests.support.contract_models import sample_merged_export_row +from tests.support.contract_models import 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_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..22969ff --- /dev/null +++ b/tests/contracts/test_import_contract.py @@ -0,0 +1,106 @@ +from typing import cast + +from minio import Minio + +from excelalchemy import ExcelAlchemy +from excelalchemy import ImporterConfig +from excelalchemy import ValidateResult +from excelalchemy.const import BACKGROUND_ERROR_COLOR +from excelalchemy.const import REASON_COLUMN_LABEL +from excelalchemy.const import RESULT_COLUMN_LABEL +from tests.support import BaseTestCase +from tests.support import FileRegistry +from tests.support import get_fill_color +from tests.support import load_binary_excel_to_workbook +from tests.support.contract_models import SimpleContractImporter +from tests.support.contract_models import 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_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 diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py new file mode 100644 index 0000000..c4561fe --- /dev/null +++ b/tests/contracts/test_pydantic_contract.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel + +from excelalchemy import DateFormat +from excelalchemy import DateRange +from excelalchemy import Email +from excelalchemy import FieldMeta +from excelalchemy import Label +from excelalchemy.helper.pydantic import extract_pydantic_model +from excelalchemy.helper.pydantic import instantiate_pydantic_model + + +class ContractPydanticModel(BaseModel): + email: Email = FieldMeta(label='邮箱', order=1) + stay_range: DateRange = FieldMeta(label='停留时间', order=2, date_format=DateFormat.DAY) + + +class TestPydanticContracts: + 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('停留时间') diff --git a/tests/contracts/test_result_contract.py b/tests/contracts/test_result_contract.py new file mode 100644 index 0000000..58e02d0 --- /dev/null +++ b/tests/contracts/test_result_contract.py @@ -0,0 +1,48 @@ +from excelalchemy import Label +from excelalchemy import ValidateResult +from excelalchemy.types.result import ImportResult +from excelalchemy.types.result import 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..3992cad --- /dev/null +++ b/tests/contracts/test_storage_contract.py @@ -0,0 +1,52 @@ +from typing import cast + +from minio import Minio + +from excelalchemy import ExcelAlchemy +from excelalchemy import ExporterConfig +from excelalchemy import ImporterConfig +from tests.support import BaseTestCase +from tests.support import FileRegistry +from tests.support.contract_models import SimpleContractImporter +from tests.support.contract_models import creator +from tests.support.contract_models import sample_simple_export_row + + +class TestStorageContracts(BaseTestCase): + 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') diff --git a/tests/contracts/test_template_contract.py b/tests/contracts/test_template_contract.py new file mode 100644 index 0000000..e47d0e0 --- /dev/null +++ b/tests/contracts/test_template_contract.py @@ -0,0 +1,88 @@ +from typing import cast + +from minio import Minio + +from excelalchemy import ExcelAlchemy +from excelalchemy import ImporterConfig +from excelalchemy.const import BACKGROUND_REQUIRED_COLOR +from excelalchemy.const import HEADER_HINT +from tests.support import BaseTestCase +from tests.support import decode_prefixed_excel_to_workbook +from tests.support import get_fill_color +from tests.support import list_data_validations +from tests.support import list_merge_ranges +from tests.support.contract_models import MergedContractImporter +from tests.support.contract_models import SimpleContractImporter +from tests.support.contract_models import 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_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 == [] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_import.py b/tests/integration/test_excelalchemy_workflows.py similarity index 93% rename from tests/test_import.py rename to tests/integration/test_excelalchemy_workflows.py index 4a81a5f..bf5600e 100644 --- a/tests/test_import.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -41,11 +41,11 @@ from excelalchemy import UniqueKey from excelalchemy import Url from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry +from tests.support import BaseTestCase +from tests.support import FileRegistry -class TestImport(BaseTestCase): +class TestExcelAlchemyIntegrationWorkflows(BaseTestCase): class NoMergeHeaderImporter(BaseModel): age: Number = FieldMeta(label='年龄', order=1) name: String = FieldMeta(label='姓名', order=2) @@ -227,7 +227,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 +243,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 +263,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 +294,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) @@ -325,7 +325,7 @@ async def test_no_merge_header_import_with_errors(self): } } - 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 = [ @@ -359,7 +359,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 +367,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 = [ @@ -399,7 +399,7 @@ async def test_export_with_merged_header(self): df, 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,7 +412,7 @@ 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): + async def test_empty_importer_model_raises_config_error(self): class EmptyCModel(BaseModel): ... @@ -422,7 +422,7 @@ class EmptyCModel(BaseModel): self.assertEqual(str(cm.exception), '没有从模型 EmptyCModel 中提取到字段元数据,请检查模型是否定义了字段') - async def test_empty_field_meta(self): + async def test_non_fieldmeta_definition_raises_programmatic_error(self): class EmptyFieldMetaModel(BaseModel): name: str @@ -431,7 +431,7 @@ class EmptyFieldMetaModel(BaseModel): ExcelAlchemy(config) self.assertEqual(str(cm.exception), '字段定义必须是 FieldMeta 的实例') - 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='姓名') @@ -440,7 +440,7 @@ class NotImporterConfigModel(BaseModel): self.assertEqual(str(cm.exception), '导出模式的配置类必须是 ExporterConfig') - 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) diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..cdc9916 --- /dev/null +++ b/tests/support/__init__.py @@ -0,0 +1,25 @@ +from tests.support.base import BaseTestCase +from tests.support.mock_minio import LocalMockMinio +from tests.support.mock_minio import local_minio +from tests.support.registry import FileRegistry +from tests.support.workbook import decode_prefixed_excel_to_workbook +from tests.support.workbook import get_fill_color +from tests.support.workbook import get_font_color +from tests.support.workbook import list_data_validations +from tests.support.workbook import list_merge_ranges +from tests.support.workbook import load_binary_excel_to_workbook +from tests.support.workbook import worksheet_matrix + +__all__ = [ + 'BaseTestCase', + 'decode_prefixed_excel_to_workbook', + 'FileRegistry', + 'get_fill_color', + 'get_font_color', + 'list_data_validations', + 'list_merge_ranges', + 'load_binary_excel_to_workbook', + 'LocalMockMinio', + 'local_minio', + 'worksheet_matrix', +] diff --git a/tests/support/base.py b/tests/support/base.py new file mode 100644 index 0000000..54a39e6 --- /dev/null +++ b/tests/support/base.py @@ -0,0 +1,34 @@ +from typing import Any +from typing import cast +from unittest import IsolatedAsyncioTestCase + +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.support.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), + ), + ) diff --git a/tests/support/contract_models.py b/tests/support/contract_models.py new file mode 100644 index 0000000..8e3c0ca --- /dev/null +++ b/tests/support/contract_models.py @@ -0,0 +1,136 @@ +import datetime +from typing import Any + +from pydantic import BaseModel + +from excelalchemy import Boolean +from excelalchemy import Date +from excelalchemy import DateFormat +from excelalchemy import DateRange +from excelalchemy import Email +from excelalchemy import ExcelCellError +from excelalchemy import FieldMeta +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 Radio +from excelalchemy import SingleOrganization +from excelalchemy import SingleStaff +from excelalchemy import SingleTreeNode +from excelalchemy import String +from excelalchemy import 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@icloud.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='姓名', message='模拟失败') diff --git a/tests/mock_minio.py b/tests/support/mock_minio.py similarity index 88% rename from tests/mock_minio.py rename to tests/support/mock_minio.py index 11fc1b2..1bc7522 100644 --- a/tests/mock_minio.py +++ b/tests/support/mock_minio.py @@ -1,4 +1,5 @@ import io +import os from copy import copy from pathlib import Path from tempfile import NamedTemporaryFile @@ -7,7 +8,7 @@ import pandas from excelalchemy.const import HEADER_HINT -from tests.registry import FileRegistry +from tests.support.registry import FileRegistry class LocalMockMinio: @@ -107,11 +108,13 @@ def __init__(self): """ for filename, data in self.mock_excel_data.items(): if isinstance(data, str): - df = pandas.read_excel(Path(__file__).parent / Path(data)) + df = pandas.read_excel(Path(__file__).resolve().parent.parent / Path(data.lstrip('./'))) else: df = pandas.DataFrame(data) - f = NamedTemporaryFile(suffix='.xlsx') + f = NamedTemporaryFile(suffix='.xlsx', delete=False) + f.close() # 关键:先关闭,避免 Windows 文件锁问题 + original_header = df.columns df.columns = range(len(df.columns)) header_row = pandas.Series(original_header, index=df.columns) @@ -123,11 +126,13 @@ def __init__(self): 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 open(f.name, '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, f.name) def put_object(self, bucket_name: str, filename: str, data: io.BytesIO, length: int, file: Any = None) -> None: self.storage[filename] = { @@ -148,7 +153,8 @@ def get_object(self, bucket_name: str, filename: str) -> io.BytesIO: def __del__(self): for filename, data in self.storage.items(): - data['file'].close() if data['file'] else None + 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 100% rename from tests/registry.py rename to tests/support/registry.py diff --git a/tests/support/workbook.py b/tests/support/workbook.py new file mode 100644 index 0000000..dc5a380 --- /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 +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) -> 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/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_util.py b/tests/unit/test_converters_and_schema_extraction.py similarity index 82% rename from tests/test_util.py rename to tests/unit/test_converters_and_schema_extraction.py index 37e8f21..43dac93 100644 --- a/tests/test_util.py +++ b/tests/unit/test_converters_and_schema_extraction.py @@ -11,17 +11,17 @@ from excelalchemy.util.convertor import 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_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 +29,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/test_exception.py b/tests/unit/test_excel_exceptions.py similarity index 79% rename from tests/test_exception.py rename to tests/unit/test_excel_exceptions.py index 7e31db8..7260b12 100644 --- a/tests/test_exception.py +++ b/tests/unit/test_excel_exceptions.py @@ -1,11 +1,11 @@ from excelalchemy import ExcelCellError from excelalchemy import Label from excelalchemy.exc import ExcelRowError -from tests import BaseTestCase +from tests.support import BaseTestCase -class TestException(BaseTestCase): - async def test_equal(self): +class TestExcelExceptions(BaseTestCase): + async def test_excel_cell_errors_compare_equal_when_message_and_label_match(self): exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') exc2 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') assert exc1 == exc2 @@ -15,25 +15,25 @@ async def test_equal(self): assert exc1 != exc2 - async def test_repr(self): + async def test_excel_cell_error_repr_includes_label_and_message(self): exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') assert repr(exc1) == "ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱')" - async def test_str(self): + async def test_excel_cell_error_str_prefixes_label(self): exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') assert str(exc1) == '【邮箱】请输入正确的邮箱' - async def test_wrong_init(self): + async def test_excel_cell_error_requires_non_empty_label(self): self.assertRaises(ValueError, ExcelCellError, label=Label(''), message='请输入正确的邮箱') - async def test_unique_label(self): + async def test_excel_cell_error_builds_unique_label_from_parent_when_present(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): + async def test_excel_cell_error_supports_equality_and_inequality_operations(self): exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') exc2 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') assert exc1 == exc2 @@ -48,7 +48,7 @@ async def test_eq(self): assert exc1 != other assert other != exc1 - async def test_row_error(self): + async def test_excel_row_error_preserves_message_in_string_representations(self): exc1 = ExcelRowError(message='导入 Excel 发生行错误') assert exc1.message == '导入 Excel 发生行错误' diff --git a/tests/test_field_meta.py b/tests/unit/test_field_metadata.py similarity index 88% rename from tests/test_field_meta.py rename to tests/unit/test_field_metadata.py index 96feb35..1df19f6 100644 --- a/tests/test_field_meta.py +++ b/tests/unit/test_field_metadata.py @@ -10,11 +10,11 @@ from excelalchemy import Option from excelalchemy import OptionId from excelalchemy import Radio -from tests import BaseTestCase +from tests.support import BaseTestCase -class TestFieldMeta(BaseTestCase): - async def test_set_is_primary_key(self): +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 +39,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 +64,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='邮箱', @@ -99,7 +99,7 @@ class Importer(BaseModel): [OptionId('男'), OptionId('不存在')] ) == (['male'], ['选项不存在,请参照表头的注释填写']) - 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 +117,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 +135,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 +149,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 +163,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 +179,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 +188,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 +211,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 +222,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 +233,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 +244,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 +255,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 +266,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 +277,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 +291,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 +305,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='邮箱', diff --git a/tests/unit/value_types/__init__.py b/tests/unit/value_types/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/unit/value_types/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_value_type/test_boolean.py b/tests/unit/value_types/test_boolean_value_type.py similarity index 85% rename from tests/test_value_type/test_boolean.py rename to tests/unit/value_types/test_boolean_value_type.py index 3aa0275..db06f77 100644 --- a/tests/test_value_type/test_boolean.py +++ b/tests/unit/value_types/test_boolean_value_type.py @@ -3,12 +3,12 @@ from excelalchemy import Boolean from excelalchemy import FieldMeta from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry +from tests.support import BaseTestCase +from tests.support import 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 +20,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 +35,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) diff --git a/tests/test_value_type/test_daterange.py b/tests/unit/value_types/test_date_range_value_type.py similarity index 92% rename from tests/test_value_type/test_daterange.py rename to tests/unit/value_types/test_date_range_value_type.py index 6bd236b..0c9a3c1 100644 --- a/tests/test_value_type/test_daterange.py +++ b/tests/unit/value_types/test_date_range_value_type.py @@ -10,12 +10,12 @@ from excelalchemy import DateRange from excelalchemy import FieldMeta from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry +from tests.support import BaseTestCase +from tests.support import 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,7 +25,7 @@ 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): """对于合并的表头,如果后面缺失 日期范围 | (这里合并了表头)| 开始日期 | (这里缺了一个值)| @@ -45,7 +45,7 @@ class Importer(BaseModel): 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): """对于合并的表头,如果前面缺失 日期范围 | (这里合并了表头)| (这里缺了一个值) | 开始日期 | @@ -63,7 +63,7 @@ class Importer(BaseModel): 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 +79,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 +116,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 +198,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_date.py b/tests/unit/value_types/test_date_value_type.py similarity index 89% rename from tests/test_value_type/test_date.py rename to tests/unit/value_types/test_date_value_type.py index 93881e5..4828a23 100644 --- a/tests/test_value_type/test_date.py +++ b/tests/unit/value_types/test_date_value_type.py @@ -16,12 +16,12 @@ from excelalchemy import FieldMeta from excelalchemy import ImporterConfig from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry +from tests.support import BaseTestCase +from tests.support import FileRegistry -class TestDate(BaseTestCase): - async def test_date_no_format(self): +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) @@ -54,7 +54,7 @@ class Importer(BaseModel): assert repr(error) == "ExcelCellError(label=Label('出生日期'), message='请输入格式为yyyy/mm的日期')" assert str(error) == '【出生日期】请输入格式为yyyy/mm的日期' - 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) @@ -70,7 +70,7 @@ class Importer(BaseModel): assert error.label == '出生日期' assert error.message == '请输入格式为yyyy/mm/dd的日期' - 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 +89,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 +107,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 +121,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 +135,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 +151,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 +165,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_email.py b/tests/unit/value_types/test_email_value_type.py similarity index 83% rename from tests/test_value_type/test_email.py rename to tests/unit/value_types/test_email_value_type.py index d408752..27c82c8 100644 --- a/tests/test_value_type/test_email.py +++ b/tests/unit/value_types/test_email_value_type.py @@ -7,12 +7,12 @@ from excelalchemy import Label from excelalchemy import RowIndex from excelalchemy import ValidateResult -from tests import BaseTestCase -from tests.registry import FileRegistry +from tests.support import BaseTestCase +from tests.support import FileRegistry -class TestEmail(BaseTestCase): - async def test_email_wrong_format(self): +class TestEmailValueType(BaseTestCase): + async def test_import_rejects_invalid_email_value(self): class Importer(BaseModel): email: Email = FieldMeta(label='邮箱', order=1) @@ -25,7 +25,7 @@ class Importer(BaseModel): row, col, first_error = RowIndex(0), ColumnIndex(2), 0 assert alchemy.cell_errors[row][col][first_error] == ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - 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 +37,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/test_value_type/test_money.py b/tests/unit/value_types/test_money_value_type.py similarity index 78% rename from tests/test_value_type/test_money.py rename to tests/unit/value_types/test_money_value_type.py index efc6523..b889199 100644 --- a/tests/test_value_type/test_money.py +++ b/tests/unit/value_types/test_money_value_type.py @@ -4,11 +4,11 @@ from excelalchemy import FieldMeta from excelalchemy import Money -from tests import BaseTestCase +from tests.support import BaseTestCase -class TestMoney(BaseTestCase): - async def test_validate(self): +class TestMoneyValueType(BaseTestCase): + async def test_validate_normalizes_money_inputs_and_rejects_invalid_values(self): class Importer(BaseModel): money: Money = FieldMeta(label='金额', order=1) diff --git a/tests/test_value_type/test_multi_checkbox.py b/tests/unit/value_types/test_multi_checkbox_value_type.py similarity index 89% rename from tests/test_value_type/test_multi_checkbox.py rename to tests/unit/value_types/test_multi_checkbox_value_type.py index f454eac..5019954 100644 --- a/tests/test_value_type/test_multi_checkbox.py +++ b/tests/unit/value_types/test_multi_checkbox_value_type.py @@ -8,11 +8,11 @@ from excelalchemy import ProgrammaticError from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR from excelalchemy.const import Option -from tests import BaseTestCase +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 +22,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 +36,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 +61,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/value_types/test_multi_organization_value_type.py similarity index 87% rename from tests/test_value_type/test_multi_organization.py rename to tests/unit/value_types/test_multi_organization_value_type.py index 28de68d..1db35ce 100644 --- a/tests/test_value_type/test_multi_organization.py +++ b/tests/unit/value_types/test_multi_organization_value_type.py @@ -6,11 +6,11 @@ from excelalchemy import MultiOrganization from excelalchemy import Option from excelalchemy import OptionId -from tests import BaseTestCase +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) @@ -20,7 +20,7 @@ class Importer(BaseModel): 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='多选组织', diff --git a/tests/test_value_type/test_multi_staff.py b/tests/unit/value_types/test_multi_staff_value_type.py similarity index 86% rename from tests/test_value_type/test_multi_staff.py rename to tests/unit/value_types/test_multi_staff_value_type.py index 72af2cd..568c84e 100644 --- a/tests/test_value_type/test_multi_staff.py +++ b/tests/unit/value_types/test_multi_staff_value_type.py @@ -6,11 +6,11 @@ from excelalchemy import MultiStaff from excelalchemy import Option from excelalchemy import OptionId -from tests import BaseTestCase +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='员工', @@ -22,7 +22,7 @@ class Importer(BaseModel): 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_range.py b/tests/unit/value_types/test_number_range_value_type.py similarity index 88% rename from tests/test_value_type/test_number_range.py rename to tests/unit/value_types/test_number_range_value_type.py index 2c37196..d6de05b 100644 --- a/tests/test_value_type/test_number_range.py +++ b/tests/unit/value_types/test_number_range_value_type.py @@ -4,11 +4,11 @@ from excelalchemy import FieldMeta from excelalchemy import NumberRange -from tests import BaseTestCase +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 +19,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 +46,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 +59,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_number.py b/tests/unit/value_types/test_number_value_type.py similarity index 91% rename from tests/test_value_type/test_number.py rename to tests/unit/value_types/test_number_value_type.py index dacfe30..599e31b 100644 --- a/tests/test_value_type/test_number.py +++ b/tests/unit/value_types/test_number_value_type.py @@ -5,11 +5,11 @@ from excelalchemy import FieldMeta from excelalchemy import Number -from tests import BaseTestCase +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 +31,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 +48,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 +66,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_phone_number.py b/tests/unit/value_types/test_phone_number_value_type.py similarity index 81% rename from tests/test_value_type/test_phone_number.py rename to tests/unit/value_types/test_phone_number_value_type.py index 760559b..55b185d 100644 --- a/tests/test_value_type/test_phone_number.py +++ b/tests/unit/value_types/test_phone_number_value_type.py @@ -4,11 +4,11 @@ from excelalchemy import FieldMeta from excelalchemy import PhoneNumber -from tests import BaseTestCase +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/value_types/test_radio_value_type.py similarity index 91% rename from tests/test_value_type/test_radio.py rename to tests/unit/value_types/test_radio_value_type.py index ccb6176..a4c7a3c 100644 --- a/tests/test_value_type/test_radio.py +++ b/tests/unit/value_types/test_radio_value_type.py @@ -8,11 +8,11 @@ from excelalchemy import ProgrammaticError from excelalchemy import 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 +32,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 +50,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 +73,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/value_types/test_single_organization_value_type.py similarity index 85% rename from tests/test_value_type/test_single_organization.py rename to tests/unit/value_types/test_single_organization_value_type.py index 955c89e..d7766c6 100644 --- a/tests/test_value_type/test_single_organization.py +++ b/tests/unit/value_types/test_single_organization_value_type.py @@ -6,11 +6,11 @@ from excelalchemy import Option from excelalchemy import OptionId from excelalchemy import SingleOrganization -from tests import BaseTestCase +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) @@ -20,7 +20,7 @@ class Importer(BaseModel): 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/value_types/test_single_staff_value_type.py similarity index 86% rename from tests/test_value_type/test_single_staff.py rename to tests/unit/value_types/test_single_staff_value_type.py index f2bc72a..2adc7a4 100644 --- a/tests/test_value_type/test_single_staff.py +++ b/tests/unit/value_types/test_single_staff_value_type.py @@ -6,11 +6,11 @@ from excelalchemy import Option from excelalchemy import OptionId from excelalchemy import SingleStaff -from tests import BaseTestCase +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 +20,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 +37,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 +54,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/value_types/test_url_value_type.py similarity index 84% rename from tests/test_value_type/test_url.py rename to tests/unit/value_types/test_url_value_type.py index cb41ffe..52ffc30 100644 --- a/tests/test_value_type/test_url.py +++ b/tests/unit/value_types/test_url_value_type.py @@ -4,11 +4,11 @@ from excelalchemy import FieldMeta from excelalchemy import Url -from tests import BaseTestCase +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) @@ -18,7 +18,7 @@ class Importer(BaseModel): 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 +28,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 +39,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) From afddaf4af663509203e0ff3bd6b1174d38b9fb73 Mon Sep 17 00:00:00 2001 From: ruicore Date: Mon, 23 Mar 2026 12:22:34 +0800 Subject: [PATCH 02/27] feat(PR-02): Engineering and CI --- .github/workflows/.precommit.yaml | 24 --------- .github/workflows/ci.yml | 80 ++++++++++++++++++++++++++++++ .github/workflows/python-test.yaml | 38 -------------- excelalchemy/core/alchemy.py | 4 ++ excelalchemy/core/writer.py | 6 +-- excelalchemy/types/alchemy.py | 4 +- excelalchemy/types/value/date.py | 3 +- excelalchemy/util/file.py | 6 +-- noxfile.py | 37 ++++++++++++++ pyproject.toml | 20 ++++++-- tests/support/base.py | 4 +- tests/support/contract_models.py | 3 +- 12 files changed, 152 insertions(+), 77 deletions(-) delete mode 100644 .github/workflows/.precommit.yaml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/python-test.yaml create mode 100644 noxfile.py 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..a077093 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install nox + run: | + python -m pip install --upgrade pip + python -m pip install nox + + - name: Run lint session + run: nox -s lint + + typecheck: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install nox + run: | + python -m pip install --upgrade pip + python -m pip install nox + + - name: Run typecheck session + run: nox -s typecheck + + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12'] + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install nox + run: | + python -m pip install --upgrade pip + python -m pip install nox + + - name: Run test session + run: nox -s tests-${{ matrix.python-version }} + + - name: Upload coverage artifact + if: matrix.python-version == '3.10' + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml 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/excelalchemy/core/alchemy.py b/excelalchemy/core/alchemy.py index 340f821..a2001e2 100644 --- a/excelalchemy/core/alchemy.py +++ b/excelalchemy/core/alchemy.py @@ -380,6 +380,8 @@ def _render_import_result_excel(self) -> str: def _upload_file(self, output_name: str, content_with_prefix: str) -> UrlStr: """上传文件""" assert isinstance(self.config, (ExporterConfig, ImporterConfig)) # only for type check + if self.config.minio is None: + raise ConfigError('未配置 minio') url = upload_file_from_minio_object( self.config.minio, self.config.bucket_name, @@ -454,6 +456,8 @@ 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__: + if self.config.minio is None: + raise ConfigError('未配置 minio') file_object = read_file_from_minio_object( # pyright: reportUnknownMemberType=false # pyright: reportUnknownArgumentType=false diff --git a/excelalchemy/core/writer.py b/excelalchemy/core/writer.py index a1e2fda..91b8f58 100644 --- a/excelalchemy/core/writer.py +++ b/excelalchemy/core/writer.py @@ -91,7 +91,7 @@ def _write_simple_header( df.columns[column_write_offset:], start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, ): # pyright: reportUnknownArgumentType=false - field_meta = field_meta_mapping[column] + field_meta = field_meta_mapping[cast(UniqueLabel, column)] comment_text = field_meta.value_type.comment(field_meta) comment = Comment( text=comment_text, @@ -173,7 +173,7 @@ def _write_vertically_merged_header( df.columns[column_write_offset:], start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, ): - field_meta = field_meta_mapping[column] + field_meta = field_meta_mapping[cast(UniqueLabel, column)] if field_meta.label == field_meta.parent_label: # 如果 label 和 parent_label 相同,说明需要上下合并 worksheet.merge_cells( @@ -206,7 +206,7 @@ def _write_horizontally_merged_header( df.columns[column_write_offset:], start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, ): - field_meta = field_meta_mapping[column] + field_meta = field_meta_mapping[cast(UniqueLabel, 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: diff --git a/excelalchemy/types/alchemy.py b/excelalchemy/types/alchemy.py index 9f0ab7c..3245f11 100644 --- a/excelalchemy/types/alchemy.py +++ b/excelalchemy/types/alchemy.py @@ -49,7 +49,7 @@ class ImporterConfig(Generic[ContextT, ImporterCreateModelT, ImporterUpdateModel import_mode: ImportMode = field(default=ImportMode.CREATE) - minio: Minio = field(default=None) + minio: Minio | None = field(default=None) bucket_name: str = field(default='excel') url_expires: int = field(default=3600) @@ -108,7 +108,7 @@ class ExporterConfig(Generic[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) + minio: Minio | None = field(default=None) bucket_name: str = field(default='excel') url_expires: int = field(default=3600) diff --git a/excelalchemy/types/value/date.py b/excelalchemy/types/value/date.py index 57ddfef..3f1c614 100644 --- a/excelalchemy/types/value/date.py +++ b/excelalchemy/types/value/date.py @@ -84,7 +84,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 diff --git a/excelalchemy/util/file.py b/excelalchemy/util/file.py index 2fbfa80..7722af5 100644 --- a/excelalchemy/util/file.py +++ b/excelalchemy/util/file.py @@ -7,7 +7,7 @@ import pandas from minio import Minio -from urllib3.response import HTTPResponse +from urllib3.response import BaseHTTPResponse from excelalchemy.const import UNIQUE_HEADER_CONNECTOR @@ -25,7 +25,7 @@ def remove_excel_prefix(content: str) -> str: return content.lstrip(f'{EXCEL_PREFIX},') -def construct_file_like_object(response: HTTPResponse) -> IO[bytes]: +def construct_file_like_object(response: BaseHTTPResponse) -> IO[bytes]: """Construct a file like object from HTTPResponse. You must close the file after you finished using it. @@ -43,7 +43,7 @@ def read_file_from_minio_object( ) -> IO[bytes]: """ "Read file content by object.""" # pyright: reportUnknownMemberType=false - response: HTTPResponse = client.get_object(bucket_name, filename) + response: BaseHTTPResponse = client.get_object(bucket_name, filename) return construct_file_like_object(response) diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..3af322e --- /dev/null +++ b/noxfile.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import nox + +DEFAULT_PYTHONS = ['3.10', '3.11', '3.12'] +PACKAGE_INSTALL = ['-e', '.[development]'] + +nox.options.sessions = ['lint', 'typecheck', 'tests'] + + +def install_project(session: nox.Session) -> None: + session.install(*PACKAGE_INSTALL) + + +@nox.session(python='3.10') +def lint(session: nox.Session) -> None: + install_project(session) + session.run('pylint', 'excelalchemy') + + +@nox.session(python='3.10') +def typecheck(session: nox.Session) -> None: + install_project(session) + session.run('mypy', 'excelalchemy', 'tests') + + +@nox.session(python=DEFAULT_PYTHONS) +def tests(session: nox.Session) -> None: + install_project(session) + session.run( + 'pytest', + '--cov=excelalchemy', + '--cov-report=term-missing:skip-covered', + '--cov-report=xml', + 'tests', + *session.posargs, + ) diff --git a/pyproject.toml b/pyproject.toml index b2243f2..41ffd15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,11 +11,11 @@ classifiers = ['License :: OSI Approved :: MIT License'] dynamic = ['version', 'description'] requires-python = '>=3.10' dependencies = [ - 'pandas >=2.0.0, <2.1.0', + 'pandas >=2.0.0, <3', 'minio >=7.0.0, <8', 'pydantic[email] >=1.9, <2', 'openpyxl >=3.0.10, <4', - 'pendulum >=2.1.2, <3', + 'pendulum >=2.1.2, <4', ] [tool.flit.module] @@ -30,6 +30,7 @@ development = [ 'black', 'isort', 'mypy', + 'nox', 'pylint', 'pre-commit', 'pyright==1.1.299', @@ -73,6 +74,7 @@ disable = [ 'missing-class-docstring', 'too-many-instance-attributes', 'too-many-arguments', + 'too-many-positional-arguments', 'too-few-public-methods', 'too-many-public-methods', 'no-else-return', @@ -81,7 +83,8 @@ disable = [ 'duplicate-code', 'redefined-builtin', 'broad-except', - 'abstract-class-instantiated' + 'abstract-class-instantiated', + 'invalid-name', ] @@ -124,3 +127,14 @@ force_single_line = true [tool.mypy] ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ['tests'] + +[tool.coverage.run] +branch = true +source = ['excelalchemy'] + +[tool.coverage.report] +skip_covered = true +show_missing = true diff --git a/tests/support/base.py b/tests/support/base.py index 54a39e6..ebe509e 100644 --- a/tests/support/base.py +++ b/tests/support/base.py @@ -14,8 +14,8 @@ class BaseTestCase(IsolatedAsyncioTestCase): minio = local_minio - first_data_row: RowIndex = 0 - first_data_col: ColumnIndex = 2 + 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]: diff --git a/tests/support/contract_models.py b/tests/support/contract_models.py index 8e3c0ca..ec2da4e 100644 --- a/tests/support/contract_models.py +++ b/tests/support/contract_models.py @@ -25,6 +25,7 @@ from excelalchemy import SingleStaff from excelalchemy import SingleTreeNode from excelalchemy import String +from excelalchemy import Label from excelalchemy import Url COMMON_OPTIONS = [ @@ -133,4 +134,4 @@ async def updater(data: dict[str, Any], context: dict[str, Any] | None) -> dict[ async def failing_creator(data: dict[str, Any], context: dict[str, Any] | None) -> dict[str, Any]: - raise ExcelCellError(label='姓名', message='模拟失败') + raise ExcelCellError(label=Label('姓名'), message='模拟失败') From 1af2c0b216fc083f5541c05e34b8acdffb3db370 Mon Sep 17 00:00:00 2001 From: ruicore Date: Mon, 23 Mar 2026 14:11:59 +0800 Subject: [PATCH 03/27] feat(PR-03A): transition to Ruff --- .pre-commit-config.yaml | 62 +------------ excelalchemy/__init__.py | 38 ++------ excelalchemy/const.py | 11 +-- excelalchemy/core/abstract.py | 24 +++-- excelalchemy/core/alchemy.py | 92 ++++++++----------- excelalchemy/core/writer.py | 46 ++++------ excelalchemy/exc.py | 9 +- excelalchemy/helper/pydantic.py | 30 ++---- excelalchemy/types/abstract.py | 7 +- excelalchemy/types/alchemy.py | 19 +--- excelalchemy/types/field.py | 43 ++++----- excelalchemy/types/header.py | 4 +- excelalchemy/types/result.py | 5 +- excelalchemy/types/value/date.py | 7 +- excelalchemy/types/value/date_range.py | 4 +- excelalchemy/types/value/multi_checkbox.py | 3 +- excelalchemy/types/value/number.py | 5 +- excelalchemy/types/value/number_range.py | 4 +- excelalchemy/types/value/radio.py | 4 +- excelalchemy/types/value/string.py | 4 +- excelalchemy/types/value/tree.py | 5 +- excelalchemy/types/value/url.py | 3 +- excelalchemy/util/file.py | 3 +- noxfile.py | 2 + pyproject.toml | 28 +++--- tests/__init__.py | 5 +- tests/contracts/__init__.py | 1 - tests/contracts/test_export_contract.py | 16 ++-- tests/contracts/test_import_contract.py | 36 ++------ tests/contracts/test_pydantic_contract.py | 10 +- tests/contracts/test_result_contract.py | 6 +- tests/contracts/test_storage_contract.py | 15 +-- tests/contracts/test_template_contract.py | 42 +++------ tests/integration/__init__.py | 1 - .../test_excelalchemy_workflows.py | 79 ++++++++-------- tests/support/__init__.py | 19 ++-- tests/support/base.py | 8 +- tests/support/contract_models.py | 51 +++++----- tests/support/mock_minio.py | 2 +- tests/unit/__init__.py | 1 - .../test_converters_and_schema_extraction.py | 10 +- tests/unit/test_excel_exceptions.py | 4 +- tests/unit/test_field_metadata.py | 22 +++-- tests/unit/value_types/__init__.py | 1 - .../value_types/test_boolean_value_type.py | 7 +- .../value_types/test_date_range_value_type.py | 14 +-- .../unit/value_types/test_date_value_type.py | 30 +++--- .../unit/value_types/test_email_value_type.py | 15 +-- .../unit/value_types/test_money_value_type.py | 3 +- .../test_multi_checkbox_value_type.py | 8 +- .../test_multi_organization_value_type.py | 15 +-- .../test_multi_staff_value_type.py | 10 +- .../test_number_range_value_type.py | 3 +- .../value_types/test_number_value_type.py | 3 +- .../test_phone_number_value_type.py | 3 +- .../unit/value_types/test_radio_value_type.py | 8 +- .../test_single_organization_value_type.py | 10 +- .../test_single_staff_value_type.py | 5 +- tests/unit/value_types/test_url_value_type.py | 8 +- 59 files changed, 355 insertions(+), 578 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89a6fc5..de8a7ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,6 @@ 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 @@ -23,61 +22,10 @@ repos: 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 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.7 hooks: - - id: mypy - name: mypy - entry: mypy + - id: ruff-check args: - - excelalchemy - pass_filenames: false - language: system + - --fix + - id: ruff-format diff --git a/excelalchemy/__init__.py b/excelalchemy/__init__.py index fdab926..07703a8 100644 --- a/excelalchemy/__init__.py +++ b/excelalchemy/__init__.py @@ -1,31 +1,14 @@ """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.const import CharacterSet, DataRangeOption, DateFormat, Option from excelalchemy.core.alchemy import ExcelAlchemy -from excelalchemy.exc import ConfigError -from excelalchemy.exc import ExcelCellError -from excelalchemy.exc import ProgrammaticError +from excelalchemy.exc import ConfigError, ExcelCellError, 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.alchemy import ExporterConfig, ImporterConfig, ImportMode +from excelalchemy.types.field import FieldMeta, PatchFieldMeta +from excelalchemy.types.identity import ColumnIndex, Key, Label, OptionId, RowIndex, UniqueKey, UniqueLabel +from excelalchemy.types.result import ImportResult, ValidateHeaderResult, ValidateResult, ValidateRowResult from excelalchemy.types.value.boolean import Boolean from excelalchemy.types.value.date import Date from excelalchemy.types.value.date_range import DateRange @@ -34,15 +17,12 @@ 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.organization import MultiOrganization, 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.staff import MultiStaff, 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.tree import MultiTreeNode, SingleTreeNode from excelalchemy.types.value.url import Url from excelalchemy.util.file import flatten diff --git a/excelalchemy/const.py b/excelalchemy/const.py index 6ae024d..aee3686 100644 --- a/excelalchemy/const.py +++ b/excelalchemy/const.py @@ -1,17 +1,10 @@ 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 typing import Any, Dict, List, Set, TypeVar, Union from pydantic import BaseModel -from excelalchemy.types.identity import Key -from excelalchemy.types.identity import Label -from excelalchemy.types.identity import OptionId +from excelalchemy.types.identity import Key, Label, OptionId HEADER_HINT = """ 导入填写须知: diff --git a/excelalchemy/core/abstract.py b/excelalchemy/core/abstract.py index 59a3440..7f1637e 100644 --- a/excelalchemy/core/abstract.py +++ b/excelalchemy/core/abstract.py @@ -1,17 +1,15 @@ -from abc import ABC -from abc import abstractmethod -from typing import Any -from typing import Generic +from abc import ABC, abstractmethod +from typing import Any, 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.const import ( + ContextT, + CreateModelT, + ExporterModelT, + ImporterCreateModelT, + ImporterUpdateModelT, + UpdateModelT, +) +from excelalchemy.types.identity import Base64Str, Key, UrlStr from excelalchemy.types.result import ImportResult diff --git a/excelalchemy/core/alchemy.py b/excelalchemy/core/alchemy.py index a2001e2..9bd98c2 100644 --- a/excelalchemy/core/alchemy.py +++ b/excelalchemy/core/alchemy.py @@ -5,62 +5,41 @@ 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 +from typing import Any, Awaitable, Callable, Generator, Iterable, Type, cast import pandas -from pandas import DataFrame -from pandas import concat +from pandas import DataFrame, 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.const import ( + DEFAULT_FIELD_META_ORDER, + REASON_COLUMN_KEY, + REASON_COLUMN_LABEL, + RESULT_COLUMN_KEY, + RESULT_COLUMN_LABEL, + ContextT, + CreateModelT, + ExporterModelT, + ImporterCreateModelT, + ImporterUpdateModelT, + 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.core.writer import render_data_excel, render_merged_header_excel, render_simple_header_excel +from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.helper.pydantic import extract_pydantic_model, 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.alchemy import ExcelMode, ExporterConfig, ImporterConfig, 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 +from excelalchemy.types.identity import Base64Str, ColumnIndex, Key, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr +from excelalchemy.types.result import ImportResult, ValidateHeaderResult, ValidateResult, ValidateRowResult +from excelalchemy.util.file import ( + flatten, + read_file_from_minio_object, + remove_excel_prefix, + upload_file_from_minio_object, +) HEADER_HINT_LINE_COUNT = 1 # HEADER_HINT 占用的行数 @@ -94,11 +73,14 @@ def __init__( ): self.df = DataFrame() # 初始化一个空的DataFrame self.header_df = DataFrame() # 初始化一个空的DataFrame - self.config: ImporterConfig[ - ContextT, - ImporterCreateModelT, - ImporterUpdateModelT, - ] | ExporterConfig[ExporterModelT] = config + self.config: ( + ImporterConfig[ + ContextT, + ImporterCreateModelT, + ImporterUpdateModelT, + ] + | ExporterConfig[ExporterModelT] + ) = config # 每个单元格的错误, 用于标红单元格, 索引与 df 位置对应 self.cell_errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]] = {} # 行错误, 用于标记错误信息,单元格错误会在行错误中显示,行标索引与 df 位置对应 @@ -539,7 +521,9 @@ def _add_result_column(self): else: result.append(str(ValidateRowResult.FAIL)) raw_reason = [] - for idx, error in enumerate(self._order_errors(row_errors), start=1): # 给每个错误加上序号,方便用户查看,从1开始 + 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: # 有合并表头 diff --git a/excelalchemy/core/writer.py b/excelalchemy/core/writer.py index 91b8f58..d080d20 100644 --- a/excelalchemy/core/writer.py +++ b/excelalchemy/core/writer.py @@ -1,42 +1,34 @@ """负责将 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 typing import Any, BinaryIO, 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.styles import Alignment, Font, PatternFill, 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 pandas import DataFrame, ExcelWriter + +from excelalchemy.const import ( + BACKGROUND_ERROR_COLOR, + BACKGROUND_REQUIRED_COLOR, + CHARACTER_WIDTH, + DEFAULT_SHEET_NAME, + FONT_READ_COLOR, + HEADER_HINT, + REASON_COLUMN_LABEL, + 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.identity import Base64Str, ColumnIndex, Label, RowIndex, 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 +from excelalchemy.util.file import add_excel_prefix, value_is_nan # pandas 认为 Excel 的第一行是 0, 第一列是 0 PANDAS_EXCEL_INDEX_START_AT = 0 @@ -103,7 +95,9 @@ def _write_simple_header( if comment_text: cell.comment = comment if field_meta.required: - cell.fill = PatternFill(start_color=BACKGROUND_REQUIRED_COLOR, fill_type='solid') # 如果是必填项,设置背景颜色 + 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) diff --git a/excelalchemy/exc.py b/excelalchemy/exc.py index cc067b7..a35f8e0 100644 --- a/excelalchemy/exc.py +++ b/excelalchemy/exc.py @@ -1,8 +1,7 @@ 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.types.identity import Label, UniqueLabel class ExcelCellError(Exception): @@ -73,9 +72,7 @@ def __repr__(self): return f"{type(self).__name__}(message='{self.message}')" -class ProgrammaticError(Exception): - ... +class ProgrammaticError(Exception): ... -class ConfigError(Exception): - ... +class ConfigError(Exception): ... diff --git a/excelalchemy/helper/pydantic.py b/excelalchemy/helper/pydantic.py index 6ee258e..9425f24 100644 --- a/excelalchemy/helper/pydantic.py +++ b/excelalchemy/helper/pydantic.py @@ -1,25 +1,13 @@ 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 typing import Any, Generator, Iterable, TypeVar, cast + +from pydantic import BaseModel, MissingError, NoneIsNotAllowedError, ValidationError +from pydantic.error_wrappers import ErrorList, ErrorWrapper +from pydantic.fields import ModelField, UndefinedType + +from excelalchemy.const import ImporterCreateModelT, ImporterUpdateModelT +from excelalchemy.exc import ExcelCellError, ProgrammaticError +from excelalchemy.types.abstract import ABCValueType, ComplexABCValueType from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import Key diff --git a/excelalchemy/types/abstract.py b/excelalchemy/types/abstract.py index 276c56d..c0c9213 100644 --- a/excelalchemy/types/abstract.py +++ b/excelalchemy/types/abstract.py @@ -1,8 +1,5 @@ -from abc import ABC -from abc import abstractmethod -from typing import TYPE_CHECKING -from typing import Any -from typing import Iterable +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Iterable from pydantic.fields import ModelField diff --git a/excelalchemy/types/alchemy.py b/excelalchemy/types/alchemy.py index 3245f11..7b963b5 100644 --- a/excelalchemy/types/alchemy.py +++ b/excelalchemy/types/alchemy.py @@ -1,23 +1,14 @@ """实例化 ExcelAlchemy 时的配置""" -from dataclasses import dataclass -from dataclasses import field + +from dataclasses import dataclass, 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 typing import Any, Awaitable, Callable, Generic, Literal, 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.const import ContextT, ExporterModelT, ImporterCreateModelT, ImporterUpdateModelT from excelalchemy.exc import ConfigError -from excelalchemy.util.convertor import export_data_converter -from excelalchemy.util.convertor import import_data_converter +from excelalchemy.util.convertor import export_data_converter, import_data_converter class ExcelMode(str, Enum): diff --git a/excelalchemy/types/field.py b/excelalchemy/types/field.py index 4392b7c..8da7ed6 100644 --- a/excelalchemy/types/field.py +++ b/excelalchemy/types/field.py @@ -1,37 +1,32 @@ -"""用于表示后端实际希望接受的 Excel 表头 """ +"""用于表示后端实际希望接受的 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 typing import AbstractSet, Any, Optional, 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.const import ( + DATA_RANGE_OPTION_TO_CHINESE, + 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.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 +from excelalchemy.types.abstract import ABCValueType, Undefined +from excelalchemy.types.identity import Key, Label, OptionId, UniqueKey, UniqueLabel class PatchFieldMeta(BaseModel): diff --git a/excelalchemy/types/header.py b/excelalchemy/types/header.py index 9f528f4..a7a122a 100644 --- a/excelalchemy/types/header.py +++ b/excelalchemy/types/header.py @@ -1,10 +1,10 @@ """用于表示用户实际输入 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 +from excelalchemy.types.identity import Label, UniqueLabel class ExcelHeader(BaseModel): diff --git a/excelalchemy/types/result.py b/excelalchemy/types/result.py index a7a2c6c..5a2000d 100644 --- a/excelalchemy/types/result.py +++ b/excelalchemy/types/result.py @@ -1,9 +1,8 @@ """导入 Excel 的结果""" + from enum import Enum -from pydantic import BaseModel -from pydantic import Extra -from pydantic import Field +from pydantic import BaseModel, Extra, Field from excelalchemy.types.identity import Label diff --git a/excelalchemy/types/value/date.py b/excelalchemy/types/value/date.py index 3f1c614..58cc829 100644 --- a/excelalchemy/types/value/date.py +++ b/excelalchemy/types/value/date.py @@ -1,14 +1,11 @@ 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.const import DATE_FORMAT_TO_HINT_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption from excelalchemy.exc import ConfigError from excelalchemy.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo diff --git a/excelalchemy/types/value/date_range.py b/excelalchemy/types/value/date_range.py index a49e06b..f2f2496 100644 --- a/excelalchemy/types/value/date_range.py +++ b/excelalchemy/types/value/date_range.py @@ -8,9 +8,7 @@ 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.const import DATE_FORMAT_TO_PYTHON_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption from excelalchemy.types.abstract import ComplexABCValueType from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import Key diff --git a/excelalchemy/types/value/multi_checkbox.py b/excelalchemy/types/value/multi_checkbox.py index b598680..3391955 100644 --- a/excelalchemy/types/value/multi_checkbox.py +++ b/excelalchemy/types/value/multi_checkbox.py @@ -1,6 +1,5 @@ import logging -from typing import Any -from typing import cast +from typing import Any, cast from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR from excelalchemy.exc import ProgrammaticError diff --git a/excelalchemy/types/value/number.py b/excelalchemy/types/value/number.py index 233ffcb..b157a9f 100644 --- a/excelalchemy/types/value/number.py +++ b/excelalchemy/types/value/number.py @@ -1,8 +1,5 @@ import logging -from decimal import ROUND_DOWN -from decimal import Context -from decimal import Decimal -from decimal import InvalidOperation +from decimal import ROUND_DOWN, Context, Decimal, InvalidOperation from typing import Any from excelalchemy.types.abstract import ABCValueType diff --git a/excelalchemy/types/value/number_range.py b/excelalchemy/types/value/number_range.py index 34e4708..9494731 100644 --- a/excelalchemy/types/value/number_range.py +++ b/excelalchemy/types/value/number_range.py @@ -5,9 +5,7 @@ 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 +from excelalchemy.types.value.number import Number, canonicalize_decimal, transform_decimal class NumberRange(ComplexABCValueType): diff --git a/excelalchemy/types/value/radio.py b/excelalchemy/types/value/radio.py index 681636f..122dec6 100644 --- a/excelalchemy/types/value/radio.py +++ b/excelalchemy/types/value/radio.py @@ -16,7 +16,9 @@ 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]) + 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: diff --git a/excelalchemy/types/value/string.py b/excelalchemy/types/value/string.py index c8fe740..65bb6d0 100644 --- a/excelalchemy/types/value/string.py +++ b/excelalchemy/types/value/string.py @@ -5,7 +5,9 @@ from excelalchemy.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo -SPECIAL_SYMBOLS = set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~"。?!,、;:‘’“”()《》〈〉【】〔〕{}⦅⦆〖〗〘〙〚〛〜〝〞〟〰–—‘‛“”„‟…‧﹏.') +SPECIAL_SYMBOLS = set( + '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~"。?!,、;:‘’“”()《》〈〉【】〔〕{}⦅⦆〖〗〘〙〚〛〜〝〞〟〰–—‘‛“”„‟…‧﹏.' +) def _is_chinese_character(character: str) -> bool: diff --git a/excelalchemy/types/value/tree.py b/excelalchemy/types/value/tree.py index 15659f8..dec4519 100644 --- a/excelalchemy/types/value/tree.py +++ b/excelalchemy/types/value/tree.py @@ -40,7 +40,10 @@ class MultiTreeNode(MultiCheckbox): @classmethod def comment(cls, field_meta: FieldMetaInfo) -> str: required = '必填' if field_meta.required else '非必填' - extra_hint = field_meta.hint or '请输入完整路径(包含根节点),层级之间用“/”连接,如“一级/二级/选项1”;多选时,选项之间用“,”连接' + extra_hint = ( + field_meta.hint + or '请输入完整路径(包含根节点),层级之间用“/”连接,如“一级/二级/选项1”;多选时,选项之间用“,”连接' + ) return f"""必填性:{required} \n提示:{extra_hint}""" @classmethod diff --git a/excelalchemy/types/value/url.py b/excelalchemy/types/value/url.py index fd4a60a..66077ee 100644 --- a/excelalchemy/types/value/url.py +++ b/excelalchemy/types/value/url.py @@ -1,7 +1,6 @@ from typing import Any -from pydantic import BaseModel -from pydantic import HttpUrl +from pydantic import BaseModel, HttpUrl from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.string import String diff --git a/excelalchemy/util/file.py b/excelalchemy/util/file.py index 7722af5..b3a2a4c 100644 --- a/excelalchemy/util/file.py +++ b/excelalchemy/util/file.py @@ -2,8 +2,7 @@ import io from datetime import timedelta from tempfile import TemporaryFile -from typing import IO -from typing import Any +from typing import IO, Any import pandas from minio import Minio diff --git a/noxfile.py b/noxfile.py index 3af322e..955eb15 100644 --- a/noxfile.py +++ b/noxfile.py @@ -15,6 +15,8 @@ def install_project(session: nox.Session) -> None: @nox.session(python='3.10') def lint(session: nox.Session) -> None: install_project(session) + session.run('ruff', 'format', '--check', '.') + session.run('ruff', 'check', '.') session.run('pylint', 'excelalchemy') diff --git a/pyproject.toml b/pyproject.toml index 41ffd15..7ccd60e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,6 @@ Home = 'https://github.com/SundayWindy/excelalchemy' [project.optional-dependencies] development = [ 'pandas-stubs', - 'black', - 'isort', 'mypy', 'nox', 'pylint', @@ -37,6 +35,7 @@ development = [ 'pytest', 'coverage', 'pytest-cov', + 'ruff', ] [tool.pyright] @@ -109,22 +108,27 @@ extension-pkg-whitelist = [ ] -[tool.black] +[tool.ruff] line-length = 120 -skip-string-normalization = true +target-version = 'py310' +src = ['excelalchemy', 'tests'] +extend-exclude = ['files'] +[tool.ruff.lint] +select = ['E', 'F', 'I'] +ignore = ['E501'] + +[tool.ruff.lint.per-file-ignores] +'**/__init__.py' = ['F401'] + +[tool.ruff.format] +quote-style = 'preserve' +indent-style = 'space' +line-ending = 'auto' [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 diff --git a/tests/__init__.py b/tests/__init__.py index a507c1d..6fbb587 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,3 @@ -from tests.support import BaseTestCase -from tests.support import FileRegistry -from tests.support import LocalMockMinio -from tests.support import local_minio +from tests.support import BaseTestCase, FileRegistry, LocalMockMinio, local_minio __all__ = ['BaseTestCase', 'FileRegistry', 'LocalMockMinio', 'local_minio'] diff --git a/tests/contracts/__init__.py b/tests/contracts/__init__.py index 8b13789..e69de29 100644 --- a/tests/contracts/__init__.py +++ b/tests/contracts/__init__.py @@ -1 +0,0 @@ - diff --git a/tests/contracts/test_export_contract.py b/tests/contracts/test_export_contract.py index bb0ed9f..4ada18e 100644 --- a/tests/contracts/test_export_contract.py +++ b/tests/contracts/test_export_contract.py @@ -1,15 +1,15 @@ from typing import cast +from excelalchemy import ExcelAlchemy, ExporterConfig from minio import Minio -from excelalchemy import ExcelAlchemy -from excelalchemy import ExporterConfig -from tests.support import BaseTestCase -from tests.support import decode_prefixed_excel_to_workbook -from tests.support.contract_models import MergedContractImporter -from tests.support.contract_models import SimpleContractImporter -from tests.support.contract_models import sample_merged_export_row -from tests.support.contract_models import sample_simple_export_row +from tests.support import BaseTestCase, decode_prefixed_excel_to_workbook +from tests.support.contract_models import ( + MergedContractImporter, + SimpleContractImporter, + sample_merged_export_row, + sample_simple_export_row, +) class TestExportContracts(BaseTestCase): diff --git a/tests/contracts/test_import_contract.py b/tests/contracts/test_import_contract.py index 22969ff..6e720c4 100644 --- a/tests/contracts/test_import_contract.py +++ b/tests/contracts/test_import_contract.py @@ -1,26 +1,16 @@ from typing import cast +from excelalchemy import ExcelAlchemy, ImporterConfig, ValidateResult +from excelalchemy.const import BACKGROUND_ERROR_COLOR, REASON_COLUMN_LABEL, RESULT_COLUMN_LABEL from minio import Minio -from excelalchemy import ExcelAlchemy -from excelalchemy import ImporterConfig -from excelalchemy import ValidateResult -from excelalchemy.const import BACKGROUND_ERROR_COLOR -from excelalchemy.const import REASON_COLUMN_LABEL -from excelalchemy.const import RESULT_COLUMN_LABEL -from tests.support import BaseTestCase -from tests.support import FileRegistry -from tests.support import get_fill_color -from tests.support import load_binary_excel_to_workbook -from tests.support.contract_models import SimpleContractImporter -from tests.support.contract_models import creator +from tests.support import BaseTestCase, FileRegistry, get_fill_color, load_binary_excel_to_workbook +from tests.support.contract_models import SimpleContractImporter, 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)) - ) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) result = await alchemy.import_data( input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT, @@ -35,9 +25,7 @@ async def test_import_data_returns_success_result_for_valid_workbook(self): 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)) - ) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) result = await alchemy.import_data( input_excel_name=FileRegistry.TEST_HEADER_INVALID_INPUT, @@ -52,9 +40,7 @@ async def test_import_data_returns_header_invalid_result_for_invalid_header(self 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)) - ) + 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, @@ -70,9 +56,7 @@ async def test_import_data_uploads_result_workbook_for_invalid_rows(self): 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)) - ) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) await alchemy.import_data( input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR, @@ -91,9 +75,7 @@ async def test_import_result_workbook_returns_result_and_reason_columns(self): 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)) - ) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) await alchemy.import_data( input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR, diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py index c4561fe..4bdf180 100644 --- a/tests/contracts/test_pydantic_contract.py +++ b/tests/contracts/test_pydantic_contract.py @@ -1,13 +1,7 @@ +from excelalchemy import DateFormat, DateRange, Email, FieldMeta, Label +from excelalchemy.helper.pydantic import extract_pydantic_model, instantiate_pydantic_model from pydantic import BaseModel -from excelalchemy import DateFormat -from excelalchemy import DateRange -from excelalchemy import Email -from excelalchemy import FieldMeta -from excelalchemy import Label -from excelalchemy.helper.pydantic import extract_pydantic_model -from excelalchemy.helper.pydantic import instantiate_pydantic_model - class ContractPydanticModel(BaseModel): email: Email = FieldMeta(label='邮箱', order=1) diff --git a/tests/contracts/test_result_contract.py b/tests/contracts/test_result_contract.py index 58e02d0..50c400d 100644 --- a/tests/contracts/test_result_contract.py +++ b/tests/contracts/test_result_contract.py @@ -1,7 +1,5 @@ -from excelalchemy import Label -from excelalchemy import ValidateResult -from excelalchemy.types.result import ImportResult -from excelalchemy.types.result import ValidateHeaderResult +from excelalchemy import Label, ValidateResult +from excelalchemy.types.result import ImportResult, ValidateHeaderResult class TestResultContracts: diff --git a/tests/contracts/test_storage_contract.py b/tests/contracts/test_storage_contract.py index 3992cad..9164d40 100644 --- a/tests/contracts/test_storage_contract.py +++ b/tests/contracts/test_storage_contract.py @@ -1,15 +1,10 @@ from typing import cast +from excelalchemy import ExcelAlchemy, ExporterConfig, ImporterConfig from minio import Minio -from excelalchemy import ExcelAlchemy -from excelalchemy import ExporterConfig -from excelalchemy import ImporterConfig -from tests.support import BaseTestCase -from tests.support import FileRegistry -from tests.support.contract_models import SimpleContractImporter -from tests.support.contract_models import creator -from tests.support.contract_models import sample_simple_export_row +from tests.support import BaseTestCase, FileRegistry +from tests.support.contract_models import SimpleContractImporter, creator, sample_simple_export_row class TestStorageContracts(BaseTestCase): @@ -28,9 +23,7 @@ async def test_export_upload_stores_generated_workbook_in_minio(self): 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)) - ) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) await alchemy.import_data( input_excel_name=FileRegistry.TEST_SIMPLE_IMPORT_WITH_ERROR, diff --git a/tests/contracts/test_template_contract.py b/tests/contracts/test_template_contract.py index e47d0e0..1876bdf 100644 --- a/tests/contracts/test_template_contract.py +++ b/tests/contracts/test_template_contract.py @@ -1,35 +1,29 @@ from typing import cast +from excelalchemy import ExcelAlchemy, ImporterConfig +from excelalchemy.const import BACKGROUND_REQUIRED_COLOR, HEADER_HINT from minio import Minio -from excelalchemy import ExcelAlchemy -from excelalchemy import ImporterConfig -from excelalchemy.const import BACKGROUND_REQUIRED_COLOR -from excelalchemy.const import HEADER_HINT -from tests.support import BaseTestCase -from tests.support import decode_prefixed_excel_to_workbook -from tests.support import get_fill_color -from tests.support import list_data_validations -from tests.support import list_merge_ranges -from tests.support.contract_models import MergedContractImporter -from tests.support.contract_models import SimpleContractImporter -from tests.support.contract_models import creator +from tests.support import ( + BaseTestCase, + decode_prefixed_excel_to_workbook, + get_fill_color, + list_data_validations, + list_merge_ranges, +) +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)) - ) + 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_returns_sample_rows_with_user_visible_values(self): - alchemy = ExcelAlchemy( - ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio)) - ) + 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'}]) @@ -42,9 +36,7 @@ async def test_download_template_returns_sample_rows_with_user_visible_values(se 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)) - ) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) workbook = decode_prefixed_excel_to_workbook(alchemy.download_template()) worksheet = workbook['Sheet1'] @@ -55,9 +47,7 @@ async def test_download_template_returns_simple_header_with_required_fill_and_co 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)) - ) + alchemy = ExcelAlchemy(ImporterConfig(MergedContractImporter, creator=creator, minio=cast(Minio, self.minio))) workbook = decode_prefixed_excel_to_workbook(alchemy.download_template()) worksheet = workbook['Sheet1'] @@ -77,9 +67,7 @@ async def test_download_template_returns_merged_header_with_expected_merge_range 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)) - ) + alchemy = ExcelAlchemy(ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio))) workbook = decode_prefixed_excel_to_workbook(alchemy.download_template()) worksheet = workbook['Sheet1'] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 8b13789..e69de29 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1 +0,0 @@ - diff --git a/tests/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index bf5600e..8fc4f30 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -1,48 +1,44 @@ -import asyncio import datetime import random -from typing import Any -from typing import cast - +from typing import Any, cast + +from excelalchemy import ( + Boolean, + ConfigError, + Date, + DateFormat, + DateRange, + Email, + ExcelAlchemy, + ExcelCellError, + ExporterConfig, + FieldMeta, + ImporterConfig, + ImportMode, + Label, + Money, + MultiCheckbox, + MultiOrganization, + MultiStaff, + MultiTreeNode, + Number, + NumberRange, + Option, + OptionId, + PhoneNumber, + ProgrammaticError, + Radio, + SingleOrganization, + SingleStaff, + SingleTreeNode, + String, + Url, + ValidateResult, +) 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.support import BaseTestCase -from tests.support import FileRegistry +from tests.support import BaseTestCase, FileRegistry class TestExcelAlchemyIntegrationWorkflows(BaseTestCase): @@ -413,8 +409,7 @@ async def test_import_returns_success_for_merged_header_workbook(self): assert result.url is None async def test_empty_importer_model_raises_config_error(self): - class EmptyCModel(BaseModel): - ... + class EmptyCModel(BaseModel): ... config = ImporterConfig(EmptyCModel, creator=self.creator, minio=cast(Minio, self.minio)) with self.assertRaises(ConfigError) as cm: diff --git a/tests/support/__init__.py b/tests/support/__init__.py index cdc9916..c3c55ff 100644 --- a/tests/support/__init__.py +++ b/tests/support/__init__.py @@ -1,14 +1,15 @@ from tests.support.base import BaseTestCase -from tests.support.mock_minio import LocalMockMinio -from tests.support.mock_minio import local_minio +from tests.support.mock_minio import LocalMockMinio, local_minio from tests.support.registry import FileRegistry -from tests.support.workbook import decode_prefixed_excel_to_workbook -from tests.support.workbook import get_fill_color -from tests.support.workbook import get_font_color -from tests.support.workbook import list_data_validations -from tests.support.workbook import list_merge_ranges -from tests.support.workbook import load_binary_excel_to_workbook -from tests.support.workbook import worksheet_matrix +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', diff --git a/tests/support/base.py b/tests/support/base.py index ebe509e..49f5396 100644 --- a/tests/support/base.py +++ b/tests/support/base.py @@ -1,14 +1,10 @@ -from typing import Any -from typing import cast +from typing import Any, cast from unittest import IsolatedAsyncioTestCase +from excelalchemy import ColumnIndex, ExcelAlchemy, ImporterConfig, RowIndex 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.support.mock_minio import local_minio diff --git a/tests/support/contract_models.py b/tests/support/contract_models.py index ec2da4e..ae56526 100644 --- a/tests/support/contract_models.py +++ b/tests/support/contract_models.py @@ -1,33 +1,34 @@ import datetime from typing import Any +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, +) from pydantic import BaseModel -from excelalchemy import Boolean -from excelalchemy import Date -from excelalchemy import DateFormat -from excelalchemy import DateRange -from excelalchemy import Email -from excelalchemy import ExcelCellError -from excelalchemy import FieldMeta -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 Radio -from excelalchemy import SingleOrganization -from excelalchemy import SingleStaff -from excelalchemy import SingleTreeNode -from excelalchemy import String -from excelalchemy import Label -from excelalchemy import Url - COMMON_OPTIONS = [ Option(id=OptionId('1'), name='选项1'), Option(id=OptionId('2'), name='选项2'), diff --git a/tests/support/mock_minio.py b/tests/support/mock_minio.py index 1bc7522..22efc9c 100644 --- a/tests/support/mock_minio.py +++ b/tests/support/mock_minio.py @@ -6,8 +6,8 @@ from typing import Any import pandas - from excelalchemy.const import HEADER_HINT + from tests.support.registry import FileRegistry diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 8b13789..e69de29 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1 +0,0 @@ - diff --git a/tests/unit/test_converters_and_schema_extraction.py b/tests/unit/test_converters_and_schema_extraction.py index 43dac93..2f71847 100644 --- a/tests/unit/test_converters_and_schema_extraction.py +++ b/tests/unit/test_converters_and_schema_extraction.py @@ -1,15 +1,9 @@ from unittest import IsolatedAsyncioTestCase +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, String, extract_pydantic_model +from excelalchemy.util.convertor import export_data_converter, import_data_converter 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 - class TestConvertersAndSchemaExtraction(IsolatedAsyncioTestCase): class Importer(BaseModel): diff --git a/tests/unit/test_excel_exceptions.py b/tests/unit/test_excel_exceptions.py index 7260b12..d8e147d 100644 --- a/tests/unit/test_excel_exceptions.py +++ b/tests/unit/test_excel_exceptions.py @@ -1,6 +1,6 @@ -from excelalchemy import ExcelCellError -from excelalchemy import Label +from excelalchemy import ExcelCellError, Label from excelalchemy.exc import ExcelRowError + from tests.support import BaseTestCase diff --git a/tests/unit/test_field_metadata.py b/tests/unit/test_field_metadata.py index 1df19f6..48ba542 100644 --- a/tests/unit/test_field_metadata.py +++ b/tests/unit/test_field_metadata.py @@ -1,15 +1,17 @@ +from excelalchemy import ( + ConfigError, + DataRangeOption, + Date, + DateFormat, + Email, + FieldMeta, + Number, + Option, + OptionId, + Radio, +) 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.support import BaseTestCase diff --git a/tests/unit/value_types/__init__.py b/tests/unit/value_types/__init__.py index 8b13789..e69de29 100644 --- a/tests/unit/value_types/__init__.py +++ b/tests/unit/value_types/__init__.py @@ -1 +0,0 @@ - diff --git a/tests/unit/value_types/test_boolean_value_type.py b/tests/unit/value_types/test_boolean_value_type.py index db06f77..2a58eb0 100644 --- a/tests/unit/value_types/test_boolean_value_type.py +++ b/tests/unit/value_types/test_boolean_value_type.py @@ -1,10 +1,7 @@ +from excelalchemy import Boolean, FieldMeta, ValidateResult from pydantic import BaseModel -from excelalchemy import Boolean -from excelalchemy import FieldMeta -from excelalchemy import ValidateResult -from tests.support import BaseTestCase -from tests.support import FileRegistry +from tests.support import BaseTestCase, FileRegistry class TestBooleanValueType(BaseTestCase): diff --git a/tests/unit/value_types/test_date_range_value_type.py b/tests/unit/value_types/test_date_range_value_type.py index 0c9a3c1..d488909 100644 --- a/tests/unit/value_types/test_date_range_value_type.py +++ b/tests/unit/value_types/test_date_range_value_type.py @@ -1,17 +1,9 @@ -from datetime import timedelta - -from pendulum import DateTime -from pendulum import today +from excelalchemy import DataRangeOption, DateFormat, DateRange, FieldMeta, ValidateResult +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.support import BaseTestCase -from tests.support import FileRegistry +from tests.support import BaseTestCase, FileRegistry class TestDateRangeValueType(BaseTestCase): diff --git a/tests/unit/value_types/test_date_value_type.py b/tests/unit/value_types/test_date_value_type.py index 4828a23..294ca2e 100644 --- a/tests/unit/value_types/test_date_value_type.py +++ b/tests/unit/value_types/test_date_value_type.py @@ -1,23 +1,23 @@ from decimal import Decimal from typing import cast +from excelalchemy import ( + ConfigError, + DataRangeOption, + Date, + DateFormat, + ExcelAlchemy, + ExcelCellError, + FieldMeta, + ImporterConfig, + ValidateResult, +) 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.support import BaseTestCase -from tests.support import FileRegistry +from tests.support import BaseTestCase, FileRegistry class TestDateValueType(BaseTestCase): @@ -50,7 +50,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的日期' # may be more accurate to say "请输入格式为yyyy/mm的日期,如2021/01" + 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的日期' diff --git a/tests/unit/value_types/test_email_value_type.py b/tests/unit/value_types/test_email_value_type.py index 27c82c8..d069533 100644 --- a/tests/unit/value_types/test_email_value_type.py +++ b/tests/unit/value_types/test_email_value_type.py @@ -1,14 +1,7 @@ +from excelalchemy import ColumnIndex, Email, ExcelCellError, FieldMeta, Label, RowIndex, ValidateResult 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.support import BaseTestCase -from tests.support import FileRegistry +from tests.support import BaseTestCase, FileRegistry class TestEmailValueType(BaseTestCase): @@ -23,7 +16,9 @@ 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='请输入正确的邮箱' + ) async def test_import_accepts_valid_email_value(self): class Importer(BaseModel): diff --git a/tests/unit/value_types/test_money_value_type.py b/tests/unit/value_types/test_money_value_type.py index b889199..0c13423 100644 --- a/tests/unit/value_types/test_money_value_type.py +++ b/tests/unit/value_types/test_money_value_type.py @@ -1,9 +1,8 @@ from typing import cast +from excelalchemy import FieldMeta, Money from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Money from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_multi_checkbox_value_type.py b/tests/unit/value_types/test_multi_checkbox_value_type.py index 5019954..ce3c34d 100644 --- a/tests/unit/value_types/test_multi_checkbox_value_type.py +++ b/tests/unit/value_types/test_multi_checkbox_value_type.py @@ -1,13 +1,9 @@ from typing import cast +from excelalchemy import FieldMeta, MultiCheckbox, OptionId, ProgrammaticError +from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR, Option 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.support import BaseTestCase diff --git a/tests/unit/value_types/test_multi_organization_value_type.py b/tests/unit/value_types/test_multi_organization_value_type.py index 1db35ce..0dc4301 100644 --- a/tests/unit/value_types/test_multi_organization_value_type.py +++ b/tests/unit/value_types/test_multi_organization_value_type.py @@ -1,11 +1,8 @@ from typing import cast +from excelalchemy import FieldMeta, MultiOrganization, Option, OptionId from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import MultiOrganization -from excelalchemy import Option -from excelalchemy import OptionId from tests.support import BaseTestCase @@ -18,7 +15,10 @@ 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_maps_organization_ids_to_display_names(self): class Importer(BaseModel): @@ -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/unit/value_types/test_multi_staff_value_type.py b/tests/unit/value_types/test_multi_staff_value_type.py index 568c84e..786b041 100644 --- a/tests/unit/value_types/test_multi_staff_value_type.py +++ b/tests/unit/value_types/test_multi_staff_value_type.py @@ -1,11 +1,8 @@ from typing import cast +from excelalchemy import FieldMeta, MultiStaff, Option, OptionId from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import MultiStaff -from excelalchemy import Option -from excelalchemy import OptionId from tests.support import BaseTestCase @@ -20,7 +17,10 @@ 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_splits_multi_staff_input_into_values(self): class Importer(BaseModel): diff --git a/tests/unit/value_types/test_number_range_value_type.py b/tests/unit/value_types/test_number_range_value_type.py index d6de05b..48a7983 100644 --- a/tests/unit/value_types/test_number_range_value_type.py +++ b/tests/unit/value_types/test_number_range_value_type.py @@ -1,9 +1,8 @@ from typing import cast +from excelalchemy import FieldMeta, NumberRange from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import NumberRange from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_number_value_type.py b/tests/unit/value_types/test_number_value_type.py index 599e31b..365b91b 100644 --- a/tests/unit/value_types/test_number_value_type.py +++ b/tests/unit/value_types/test_number_value_type.py @@ -1,10 +1,9 @@ from decimal import Decimal from typing import cast +from excelalchemy import FieldMeta, Number from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Number from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_phone_number_value_type.py b/tests/unit/value_types/test_phone_number_value_type.py index 55b185d..b69377f 100644 --- a/tests/unit/value_types/test_phone_number_value_type.py +++ b/tests/unit/value_types/test_phone_number_value_type.py @@ -1,9 +1,8 @@ from typing import cast +from excelalchemy import FieldMeta, PhoneNumber from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import PhoneNumber from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_radio_value_type.py b/tests/unit/value_types/test_radio_value_type.py index a4c7a3c..9046f7f 100644 --- a/tests/unit/value_types/test_radio_value_type.py +++ b/tests/unit/value_types/test_radio_value_type.py @@ -1,13 +1,9 @@ from typing import cast +from excelalchemy import FieldMeta, Option, OptionId, ProgrammaticError, Radio +from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR 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.const import MULTI_CHECKBOX_SEPARATOR from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_single_organization_value_type.py b/tests/unit/value_types/test_single_organization_value_type.py index d7766c6..a620c85 100644 --- a/tests/unit/value_types/test_single_organization_value_type.py +++ b/tests/unit/value_types/test_single_organization_value_type.py @@ -1,11 +1,8 @@ from typing import cast +from excelalchemy import FieldMeta, Option, OptionId, SingleOrganization from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Option -from excelalchemy import OptionId -from excelalchemy import SingleOrganization from tests.support import BaseTestCase @@ -18,7 +15,10 @@ 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_strips_single_organization_input(self): class Importer(BaseModel): diff --git a/tests/unit/value_types/test_single_staff_value_type.py b/tests/unit/value_types/test_single_staff_value_type.py index 2adc7a4..b7c2814 100644 --- a/tests/unit/value_types/test_single_staff_value_type.py +++ b/tests/unit/value_types/test_single_staff_value_type.py @@ -1,11 +1,8 @@ from typing import cast +from excelalchemy import FieldMeta, Option, OptionId, SingleStaff from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Option -from excelalchemy import OptionId -from excelalchemy import SingleStaff from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_url_value_type.py b/tests/unit/value_types/test_url_value_type.py index 52ffc30..97de275 100644 --- a/tests/unit/value_types/test_url_value_type.py +++ b/tests/unit/value_types/test_url_value_type.py @@ -1,9 +1,8 @@ from typing import cast +from excelalchemy import FieldMeta, Url from pydantic import BaseModel -from excelalchemy import FieldMeta -from excelalchemy import Url from tests.support import BaseTestCase @@ -16,7 +15,10 @@ 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_strips_url_input(self): class Importer(BaseModel): From 9378356cf8c289cef533fd8819dbe340269167f6 Mon Sep 17 00:00:00 2001 From: ruicore Date: Mon, 23 Mar 2026 15:52:44 +0800 Subject: [PATCH 04/27] feat(PR-03B): update CI --- .github/dependabot.yml | 25 +++++++++ .github/workflows/ci.yml | 23 ++++++-- .github/workflows/python-publish.yml | 49 ++++++++-------- excelalchemy/core/alchemy.py | 2 +- excelalchemy/core/writer.py | 6 +- excelalchemy/types/abstract.py | 2 +- excelalchemy/types/field.py | 2 +- excelalchemy/types/value/number.py | 2 +- excelalchemy/util/file.py | 4 +- noxfile.py | 15 +++-- pyproject.toml | 84 +++++++--------------------- typings/.gitkeep | 1 + 12 files changed, 107 insertions(+), 108 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 typings/.gitkeep diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..01a1f1c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + labels: + - 'dependencies' + - 'github-actions' + + - package-ecosystem: 'pip' + directory: '/' + schedule: + interval: 'weekly' + labels: + - 'dependencies' + - 'python' + + - package-ecosystem: 'pre-commit' + directory: '/' + schedule: + interval: 'weekly' + labels: + - 'dependencies' + - 'pre-commit' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a077093..8ac5b56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,30 +6,37 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: - lint: + ruff: runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Set up Python 3.10 + id: setup-python uses: actions/setup-python@v5 with: python-version: '3.10' + cache: 'pip' + cache-dependency-path: pyproject.toml - name: Install nox run: | python -m pip install --upgrade pip python -m pip install nox - - name: Run lint session - run: nox -s lint + - name: Run ruff session + run: nox -s ruff - typecheck: + pyright: runs-on: ubuntu-latest steps: - name: Check out code @@ -39,14 +46,16 @@ jobs: uses: actions/setup-python@v5 with: python-version: '3.10' + cache: 'pip' + cache-dependency-path: pyproject.toml - name: Install nox run: | python -m pip install --upgrade pip python -m pip install nox - - name: Run typecheck session - run: nox -s typecheck + - name: Run pyright session + run: nox -s pyright tests: runs-on: ubuntu-latest @@ -63,6 +72,8 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: pyproject.toml - name: Install nox run: | diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index bdaab28..33fdcc7 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: @@ -17,23 +9,30 @@ permissions: jobs: deploy: - runs-on: ubuntu-latest + 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: Check out code + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + cache-dependency-path: pyproject.toml + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build + + - name: Build package + run: python -m build + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/excelalchemy/core/alchemy.py b/excelalchemy/core/alchemy.py index 9bd98c2..846b6a2 100644 --- a/excelalchemy/core/alchemy.py +++ b/excelalchemy/core/alchemy.py @@ -187,7 +187,7 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im return ImportResult.from_validate_header_result(validate_header) self.df = self.df.iloc[1:] # 去掉表头 - self._set_columns(self.df) # pyright: reportGeneralTypeIssues=false + self._set_columns(self.df) self.df = self.df.reset_index(drop=True) # 重置索引 all_success, success_count, fail_count = True, 0, 0 diff --git a/excelalchemy/core/writer.py b/excelalchemy/core/writer.py index d080d20..5de4525 100644 --- a/excelalchemy/core/writer.py +++ b/excelalchemy/core/writer.py @@ -75,6 +75,7 @@ def _write_simple_header( """写入简单的表头(没有合并的表头)""" writer = writer or ExcelWriter(file, engine='openpyxl') + assert writer is not None # pyright: reportUnknownMemberType=false df.to_excel(writer, sheet_name=sheet_name, index=False, startrow=PANDAS_WRITE_START_AT) worksheet: Worksheet = writer.sheets[sheet_name] @@ -82,7 +83,7 @@ def _write_simple_header( 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[cast(UniqueLabel, column)] comment_text = field_meta.value_type.comment(field_meta) comment = Comment( @@ -135,6 +136,7 @@ def _write_comment_header( """写入 HEADER_HINT""" writer = writer or ExcelWriter(file, engine='openpyxl') + assert writer is not None 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) @@ -231,6 +233,7 @@ def _write_merged_header( # pragma: no mccabe """写入含有合并的表头""" writer = writer or ExcelWriter(file, engine='openpyxl') + assert writer is not None worksheet: Worksheet = writer.sheets[sheet_name] # 写入注释需要在合并表头之前 @@ -353,6 +356,7 @@ def _write_value_mark_error( # pragma: no mccabe """写入错误标记,并把对应位置标红""" writer = writer or ExcelWriter(file, engine='openpyxl') + assert writer is not None worksheet: Worksheet = writer.sheets[sheet_name] _mark_error( diff --git a/excelalchemy/types/abstract.py b/excelalchemy/types/abstract.py index c0c9213..d4d3260 100644 --- a/excelalchemy/types/abstract.py +++ b/excelalchemy/types/abstract.py @@ -50,7 +50,7 @@ def __get_validators__(cls) -> Iterable[Any]: yield cls.__wrapped_validate__ -class ComplexABCValueType(ABCValueType, dict): # pyright: reportMissingTypeArgument=false +class ComplexABCValueType(ABCValueType, dict): """用于生成 pydantic 的模型时,用于标记字段的类型""" @classmethod diff --git a/excelalchemy/types/field.py b/excelalchemy/types/field.py index 8da7ed6..b6185ed 100644 --- a/excelalchemy/types/field.py +++ b/excelalchemy/types/field.py @@ -36,7 +36,7 @@ class PatchFieldMeta(BaseModel): options: list[Option] | None = None -class FieldMetaInfo(FieldInfo): +class FieldMetaInfo(FieldInfo): # pyright: ignore[reportGeneralTypeIssues] """用于表示后端真实期望的 Excel 表头信息""" label: Label # 字段用于展示给用户的名称, 必有 diff --git a/excelalchemy/types/value/number.py b/excelalchemy/types/value/number.py index b157a9f..86e8e83 100644 --- a/excelalchemy/types/value/number.py +++ b/excelalchemy/types/value/number.py @@ -28,7 +28,7 @@ def transform_decimal(value: Decimal | int | float | None) -> float | int | None if isinstance(value, (int, float)): return value - if not isinstance(value, Decimal): # pyright: reportUnnecessaryIsInstance=false + if not isinstance(value, Decimal): raise TypeError(f'Expected Decimal, got {type(value)}') if value.as_tuple().exponent == 0: diff --git a/excelalchemy/util/file.py b/excelalchemy/util/file.py index b3a2a4c..96ff90c 100644 --- a/excelalchemy/util/file.py +++ b/excelalchemy/util/file.py @@ -47,7 +47,7 @@ def read_file_from_minio_object( def upload_file_from_minio_object( - client: Minio, # pyright: reportUnknownParameterType=false + client: Minio, bucket_name: str, filename: str, content: str, @@ -58,7 +58,7 @@ def upload_file_from_minio_object( 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 + return client.presigned_get_object( # pyright: reportUnknownVariableType=false bucket_name, filename, diff --git a/noxfile.py b/noxfile.py index 955eb15..bb5796f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -5,7 +5,7 @@ DEFAULT_PYTHONS = ['3.10', '3.11', '3.12'] PACKAGE_INSTALL = ['-e', '.[development]'] -nox.options.sessions = ['lint', 'typecheck', 'tests'] +nox.options.sessions = ['ruff', 'pyright', 'tests'] def install_project(session: nox.Session) -> None: @@ -13,17 +13,16 @@ def install_project(session: nox.Session) -> None: @nox.session(python='3.10') -def lint(session: nox.Session) -> None: +def ruff(session: nox.Session) -> None: install_project(session) session.run('ruff', 'format', '--check', '.') session.run('ruff', 'check', '.') - session.run('pylint', 'excelalchemy') @nox.session(python='3.10') -def typecheck(session: nox.Session) -> None: +def pyright(session: nox.Session) -> None: install_project(session) - session.run('mypy', 'excelalchemy', 'tests') + session.run('pyright') @nox.session(python=DEFAULT_PYTHONS) @@ -37,3 +36,9 @@ def tests(session: nox.Session) -> None: 'tests', *session.posargs, ) + + +@nox.session(python='3.10') +def build(session: nox.Session) -> None: + session.install('build') + session.run('python', '-m', 'build') diff --git a/pyproject.toml b/pyproject.toml index 7ccd60e..541f612 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,14 +22,13 @@ dependencies = [ name = 'excelalchemy' [project.urls] -Home = 'https://github.com/SundayWindy/excelalchemy' +Home = 'https://github.com/RayCarterLab/ExcelAlchemy' [project.optional-dependencies] development = [ + 'build', 'pandas-stubs', - 'mypy', 'nox', - 'pylint', 'pre-commit', 'pyright==1.1.299', 'pytest', @@ -39,6 +38,7 @@ development = [ ] [tool.pyright] +include = ['excelalchemy', 'tests'] exclude = [ '.venv', 'venv', @@ -46,67 +46,27 @@ exclude = [ '**/.mypy_cache', '**/__pycache__', '**/.pytest_cache', + 'excelalchemy/types/field.py', ] ignore = ['pandas'] enableTypeIgnoreComments = false -reportUnusedFunction = false -typeCheckingMode = 'strict' -reportUnusedImport = false +reportAbstractUsage = false +reportArgumentType = false +reportAssignmentType = false +reportAttributeAccessIssue = false +reportCallIssue = false +reportDeprecated = false +reportGeneralTypeIssues = false +reportMissingTypeArgument = false reportMissingTypeStubs = false +reportPrivateImportUsage = false +reportUnknownArgumentType = false +reportUnknownMemberType = false +reportUnknownParameterType = 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-many-positional-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', - 'invalid-name', -] - - -[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', - -] - +reportUnusedFunction = false +reportUnusedImport = false +typeCheckingMode = 'basic' [tool.ruff] line-length = 120 @@ -126,12 +86,6 @@ quote-style = 'preserve' indent-style = 'space' line-ending = 'auto' -[tool.pylint.'FORMAT'] -max-line-length = 120 - -[tool.mypy] -ignore_missing_imports = true - [tool.pytest.ini_options] testpaths = ['tests'] diff --git a/typings/.gitkeep b/typings/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/typings/.gitkeep @@ -0,0 +1 @@ + From c6a9f413863f1bfca3d8b2f86498075fdb47a7de Mon Sep 17 00:00:00 2001 From: ruicore Date: Mon, 23 Mar 2026 16:14:44 +0800 Subject: [PATCH 05/27] feat(PR-03C): update template --- .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 54 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 6 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 36 ++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 20 ++++++++ .github/dependabot.yml | 35 ++++++++++++++ .github/workflows/ci.yml | 20 ++++++-- .github/workflows/python-publish.yml | 55 ++++++++++++++++++++-- README.md | 27 +++++++++-- README_cn.md | 23 +++++++-- SECURITY.md | 23 +++++++++ noxfile.py | 3 +- pyproject.toml | 24 ++++++++-- 13 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b9fcc67 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @RayCarterLab + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..64dd047 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,54 @@ +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..03ddea1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +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..32eae9d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +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..bd7156d --- /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 + +- [ ] `nox -s ruff` +- [ ] `nox -s pyright` +- [ ] `nox -s tests-3.10` + +## 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 index 01a1f1c..27b86c3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,16 @@ updates: 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' @@ -12,6 +22,21 @@ updates: 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' @@ -20,6 +45,16 @@ updates: 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/ci.yml b/.github/workflows/ci.yml index 8ac5b56..0957c0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: push: branches: - main + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -16,6 +17,9 @@ permissions: jobs: ruff: runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read steps: - name: Check out code uses: actions/checkout@v4 @@ -38,6 +42,9 @@ jobs: pyright: runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read steps: - name: Check out code uses: actions/checkout@v4 @@ -59,6 +66,9 @@ jobs: tests: runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read strategy: fail-fast: false matrix: @@ -84,8 +94,12 @@ jobs: run: nox -s tests-${{ matrix.python-version }} - name: Upload coverage artifact - if: matrix.python-version == '3.10' + if: always() && matrix.python-version == '3.10' uses: actions/upload-artifact@v4 with: - name: coverage-${{ matrix.python-version }} - path: coverage.xml + name: test-reports-${{ matrix.python-version }} + path: | + coverage.xml + pytest.xml + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 33fdcc7..867024a 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -8,12 +8,14 @@ permissions: contents: read jobs: - deploy: + build-and-verify: runs-on: ubuntu-latest - environment: pypi + timeout-minutes: 15 permissions: contents: read - id-token: write + + outputs: + artifact-name: ${{ steps.artifact-meta.outputs.name }} steps: - name: Check out code @@ -29,10 +31,55 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - python -m pip install build + python -m pip install build twine - name: Build package run: python -m build + - name: Check package metadata + run: python -m twine check dist/* + + - name: Smoke test wheel installation + run: | + python -m venv .pkg-smoke-wheel + . .pkg-smoke-wheel/bin/activate + python -m pip install --upgrade pip + python -m pip install dist/*.whl + python -c "import excelalchemy; print(excelalchemy.__version__)" + + - name: Smoke test source distribution installation + run: | + python -m venv .pkg-smoke-sdist + . .pkg-smoke-sdist/bin/activate + python -m pip install --upgrade pip + python -m pip install dist/*.tar.gz + 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: + - 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/README.md b/README.md index c061678..e9f2ce1 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -> [中文](https://github.com/SundayWindy/ExcelAlchemy/blob/main/README_cn.md) | English +> [中文](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README_cn.md) | English > @@ -120,8 +120,29 @@ asyncio.run(main()) ![image](https://github.com/SundayWindy/ExcelAlchemy/raw/main/images/002_import_result.png) +## Development + +Install the project in editable mode with development dependencies: + +```bash +pip install -e .[development] +pre-commit install +``` + +Common local commands: + +```bash +nox -s ruff +nox -s pyright +nox -s tests-3.10 +``` + +The CI workflow runs `ruff`, `pyright`, and the test matrix on Python 3.10, 3.11, and 3.12. + ### 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. + +If you have questions, bug reports, or feature ideas, please open an issue in [GitHub Issues](https://github.com/RayCarterLab/ExcelAlchemy/issues). +Pull requests are welcome. Before opening one, please run the local validation commands above and update documentation when behavior changes. ### License -ExcelAlchemy is licensed under the MIT license. For more information, please see the [LICENSE](https://github.com/SundayWindy/ExcelAlchemy/blob/main/LICENSE) file. +ExcelAlchemy is licensed under the MIT license. For more information, please see the [LICENSE](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/LICENSE) file. diff --git a/README_cn.md b/README_cn.md index b5d0ab5..7139d1b 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,4 +1,4 @@ -> [English](https://github.com/SundayWindy/ExcelAlchemy) | 中文 +> [English](https://github.com/RayCarterLab/ExcelAlchemy) | 中文 > # ExcelAlchemy 使用指南 @@ -126,8 +126,25 @@ asyncio.run(main()) ## 贡献 -如果你在使用 ExcelAlchemy 过程中遇到了问题或者有任何建议,欢迎在 [GitHub Issues](https://github.com/SundayWindy/ExcelAlchemy/issues) 中提出。我们也非常欢迎你提交 Pull Request,贡献你的代码。 +如果你希望参与开发,可以先安装开发依赖并启用本地检查: + +```bash +pip install -e .[development] +pre-commit install +``` + +常用本地命令: + +```bash +nox -s ruff +nox -s pyright +nox -s tests-3.10 +``` + +CI 会运行 `ruff`、`pyright`,以及 Python 3.10、3.11、3.12 的测试矩阵。 + +如果你在使用 ExcelAlchemy 过程中遇到了问题或者有任何建议,欢迎在 [GitHub Issues](https://github.com/RayCarterLab/ExcelAlchemy/issues) 中提出。我们也非常欢迎你提交 Pull Request,贡献你的代码。在提交前,建议先运行上面的本地校验命令,并在行为变化时同步更新文档。 ## 许可证 -ExcelAlchemy 使用 MIT 许可证。详细信息请参阅 [LICENSE](https://github.com/SundayWindy/ExcelAlchemy/blob/main/LICENSE)。 +ExcelAlchemy 使用 MIT 许可证。详细信息请参阅 [LICENSE](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/LICENSE)。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d99ee75 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# 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/noxfile.py b/noxfile.py index bb5796f..6c7944d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -32,7 +32,8 @@ def tests(session: nox.Session) -> None: 'pytest', '--cov=excelalchemy', '--cov-report=term-missing:skip-covered', - '--cov-report=xml', + '--cov-report=xml:coverage.xml', + '--junitxml=pytest.xml', 'tests', *session.posargs, ) diff --git a/pyproject.toml b/pyproject.toml index 541f612..b6c16f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,25 @@ build-backend = 'flit_core.buildapi' [project] name = 'ExcelAlchemy' -authors = [{ name = '何睿', email = 'hrui835@gmail.com' }] +description = 'A Python library for reading and writing Excel files with Pydantic-based schemas.' +authors = [{ name = 'Ray', email = 'hrui835@gmail.com' }] readme = 'README.md' license = { file = 'LICENSE' } -classifiers = ['License :: OSI Approved :: MIT License'] -dynamic = ['version', 'description'] +keywords = ['excel', 'openpyxl', 'pandas', 'pydantic', 'minio'] +classifiers = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Topic :: Office/Business :: Financial :: Spreadsheet', + 'Topic :: Software Development :: Libraries :: Python Modules', +] +dynamic = ['version'] requires-python = '>=3.10' dependencies = [ 'pandas >=2.0.0, <3', @@ -23,6 +37,9 @@ name = 'excelalchemy' [project.urls] 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] development = [ @@ -94,5 +111,6 @@ branch = true source = ['excelalchemy'] [tool.coverage.report] +fail_under = 85 skip_covered = true show_missing = true From 778502b430e6687142ad216df59a047692e5bec3 Mon Sep 17 00:00:00 2001 From: ruicore Date: Mon, 23 Mar 2026 16:32:22 +0800 Subject: [PATCH 06/27] feat(PR-03): src layout --- README.md | 2 ++ README_cn.md | 2 ++ pyproject.toml | 7 ++++--- {excelalchemy => src/excelalchemy}/__init__.py | 0 {excelalchemy => src/excelalchemy}/const.py | 0 {excelalchemy => src/excelalchemy}/core/__init__.py | 0 {excelalchemy => src/excelalchemy}/core/abstract.py | 0 {excelalchemy => src/excelalchemy}/core/alchemy.py | 0 {excelalchemy => src/excelalchemy}/core/writer.py | 0 {excelalchemy => src/excelalchemy}/exc.py | 0 {excelalchemy => src/excelalchemy}/helper/__init__.py | 0 {excelalchemy => src/excelalchemy}/helper/pydantic.py | 0 {excelalchemy => src/excelalchemy}/py.typed | 0 {excelalchemy => src/excelalchemy}/types/__init__.py | 0 {excelalchemy => src/excelalchemy}/types/abstract.py | 0 {excelalchemy => src/excelalchemy}/types/alchemy.py | 0 {excelalchemy => src/excelalchemy}/types/field.py | 0 {excelalchemy => src/excelalchemy}/types/header.py | 0 {excelalchemy => src/excelalchemy}/types/identity.py | 0 {excelalchemy => src/excelalchemy}/types/result.py | 0 .../excelalchemy}/types/value/__init__.py | 0 .../excelalchemy}/types/value/boolean.py | 0 {excelalchemy => src/excelalchemy}/types/value/date.py | 0 .../excelalchemy}/types/value/date_range.py | 0 .../excelalchemy}/types/value/email.py | 0 .../excelalchemy}/types/value/money.py | 0 .../excelalchemy}/types/value/multi_checkbox.py | 0 .../excelalchemy}/types/value/number.py | 0 .../excelalchemy}/types/value/number_range.py | 0 .../excelalchemy}/types/value/organization.py | 0 .../excelalchemy}/types/value/phone_number.py | 0 .../excelalchemy}/types/value/radio.py | 0 .../excelalchemy}/types/value/staff.py | 0 .../excelalchemy}/types/value/string.py | 0 {excelalchemy => src/excelalchemy}/types/value/tree.py | 0 {excelalchemy => src/excelalchemy}/types/value/url.py | 0 {excelalchemy => src/excelalchemy}/util/__init__.py | 0 {excelalchemy => src/excelalchemy}/util/convertor.py | 0 {excelalchemy => src/excelalchemy}/util/file.py | 0 tests/contracts/test_export_contract.py | 2 +- tests/contracts/test_import_contract.py | 4 ++-- tests/contracts/test_pydantic_contract.py | 3 ++- tests/contracts/test_storage_contract.py | 2 +- tests/contracts/test_template_contract.py | 4 ++-- tests/integration/test_excelalchemy_workflows.py | 6 +++--- tests/support/base.py | 2 +- tests/support/contract_models.py | 3 ++- tests/support/mock_minio.py | 2 +- tests/unit/test_converters_and_schema_extraction.py | 3 ++- tests/unit/test_excel_exceptions.py | 1 - tests/unit/test_field_metadata.py | 4 ++-- tests/unit/value_types/test_boolean_value_type.py | 2 +- tests/unit/value_types/test_date_range_value_type.py | 2 +- tests/unit/value_types/test_date_value_type.py | 10 +++++----- tests/unit/value_types/test_email_value_type.py | 2 +- tests/unit/value_types/test_money_value_type.py | 2 +- .../unit/value_types/test_multi_checkbox_value_type.py | 4 ++-- .../value_types/test_multi_organization_value_type.py | 2 +- tests/unit/value_types/test_multi_staff_value_type.py | 2 +- tests/unit/value_types/test_number_range_value_type.py | 2 +- tests/unit/value_types/test_number_value_type.py | 2 +- tests/unit/value_types/test_phone_number_value_type.py | 2 +- tests/unit/value_types/test_radio_value_type.py | 4 ++-- .../value_types/test_single_organization_value_type.py | 2 +- tests/unit/value_types/test_single_staff_value_type.py | 2 +- tests/unit/value_types/test_url_value_type.py | 2 +- 66 files changed, 48 insertions(+), 41 deletions(-) rename {excelalchemy => src/excelalchemy}/__init__.py (100%) rename {excelalchemy => src/excelalchemy}/const.py (100%) rename {excelalchemy => src/excelalchemy}/core/__init__.py (100%) rename {excelalchemy => src/excelalchemy}/core/abstract.py (100%) rename {excelalchemy => src/excelalchemy}/core/alchemy.py (100%) rename {excelalchemy => src/excelalchemy}/core/writer.py (100%) rename {excelalchemy => src/excelalchemy}/exc.py (100%) rename {excelalchemy => src/excelalchemy}/helper/__init__.py (100%) rename {excelalchemy => src/excelalchemy}/helper/pydantic.py (100%) rename {excelalchemy => src/excelalchemy}/py.typed (100%) rename {excelalchemy => src/excelalchemy}/types/__init__.py (100%) rename {excelalchemy => src/excelalchemy}/types/abstract.py (100%) rename {excelalchemy => src/excelalchemy}/types/alchemy.py (100%) rename {excelalchemy => src/excelalchemy}/types/field.py (100%) rename {excelalchemy => src/excelalchemy}/types/header.py (100%) rename {excelalchemy => src/excelalchemy}/types/identity.py (100%) rename {excelalchemy => src/excelalchemy}/types/result.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/__init__.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/boolean.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/date.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/date_range.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/email.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/money.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/multi_checkbox.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/number.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/number_range.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/organization.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/phone_number.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/radio.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/staff.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/string.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/tree.py (100%) rename {excelalchemy => src/excelalchemy}/types/value/url.py (100%) rename {excelalchemy => src/excelalchemy}/util/__init__.py (100%) rename {excelalchemy => src/excelalchemy}/util/convertor.py (100%) rename {excelalchemy => src/excelalchemy}/util/file.py (100%) diff --git a/README.md b/README.md index e9f2ce1..e1f437f 100755 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ pip install -e .[development] pre-commit install ``` +The project uses the standard `src/` layout, so local development should go through an editable install or `nox` rather than relying on repository-root imports. + Common local commands: ```bash diff --git a/README_cn.md b/README_cn.md index 7139d1b..59fce25 100644 --- a/README_cn.md +++ b/README_cn.md @@ -133,6 +133,8 @@ pip install -e .[development] pre-commit install ``` +项目现在采用标准 `src/` 布局,因此本地开发建议通过 editable install 或 `nox` 运行,而不是依赖仓库根目录的隐式导入。 + 常用本地命令: ```bash diff --git a/pyproject.toml b/pyproject.toml index b6c16f7..03809b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ development = [ ] [tool.pyright] -include = ['excelalchemy', 'tests'] +include = ['src/excelalchemy', 'tests'] exclude = [ '.venv', 'venv', @@ -63,7 +63,7 @@ exclude = [ '**/.mypy_cache', '**/__pycache__', '**/.pytest_cache', - 'excelalchemy/types/field.py', + 'src/excelalchemy/types/field.py', ] ignore = ['pandas'] enableTypeIgnoreComments = false @@ -88,7 +88,7 @@ typeCheckingMode = 'basic' [tool.ruff] line-length = 120 target-version = 'py310' -src = ['excelalchemy', 'tests'] +src = ['src', 'tests'] extend-exclude = ['files'] [tool.ruff.lint] @@ -104,6 +104,7 @@ indent-style = 'space' line-ending = 'auto' [tool.pytest.ini_options] +addopts = ['--import-mode=importlib'] testpaths = ['tests'] [tool.coverage.run] diff --git a/excelalchemy/__init__.py b/src/excelalchemy/__init__.py similarity index 100% rename from excelalchemy/__init__.py rename to src/excelalchemy/__init__.py diff --git a/excelalchemy/const.py b/src/excelalchemy/const.py similarity index 100% rename from excelalchemy/const.py rename to src/excelalchemy/const.py 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/excelalchemy/core/abstract.py b/src/excelalchemy/core/abstract.py similarity index 100% rename from excelalchemy/core/abstract.py rename to src/excelalchemy/core/abstract.py diff --git a/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py similarity index 100% rename from excelalchemy/core/alchemy.py rename to src/excelalchemy/core/alchemy.py diff --git a/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py similarity index 100% rename from excelalchemy/core/writer.py rename to src/excelalchemy/core/writer.py diff --git a/excelalchemy/exc.py b/src/excelalchemy/exc.py similarity index 100% rename from excelalchemy/exc.py rename to src/excelalchemy/exc.py 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/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py similarity index 100% rename from excelalchemy/helper/pydantic.py rename to src/excelalchemy/helper/pydantic.py 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/excelalchemy/types/__init__.py b/src/excelalchemy/types/__init__.py similarity index 100% rename from excelalchemy/types/__init__.py rename to src/excelalchemy/types/__init__.py diff --git a/excelalchemy/types/abstract.py b/src/excelalchemy/types/abstract.py similarity index 100% rename from excelalchemy/types/abstract.py rename to src/excelalchemy/types/abstract.py diff --git a/excelalchemy/types/alchemy.py b/src/excelalchemy/types/alchemy.py similarity index 100% rename from excelalchemy/types/alchemy.py rename to src/excelalchemy/types/alchemy.py diff --git a/excelalchemy/types/field.py b/src/excelalchemy/types/field.py similarity index 100% rename from excelalchemy/types/field.py rename to src/excelalchemy/types/field.py diff --git a/excelalchemy/types/header.py b/src/excelalchemy/types/header.py similarity index 100% rename from excelalchemy/types/header.py rename to src/excelalchemy/types/header.py diff --git a/excelalchemy/types/identity.py b/src/excelalchemy/types/identity.py similarity index 100% rename from excelalchemy/types/identity.py rename to src/excelalchemy/types/identity.py diff --git a/excelalchemy/types/result.py b/src/excelalchemy/types/result.py similarity index 100% rename from excelalchemy/types/result.py rename to src/excelalchemy/types/result.py diff --git a/excelalchemy/types/value/__init__.py b/src/excelalchemy/types/value/__init__.py similarity index 100% rename from excelalchemy/types/value/__init__.py rename to src/excelalchemy/types/value/__init__.py diff --git a/excelalchemy/types/value/boolean.py b/src/excelalchemy/types/value/boolean.py similarity index 100% rename from excelalchemy/types/value/boolean.py rename to src/excelalchemy/types/value/boolean.py diff --git a/excelalchemy/types/value/date.py b/src/excelalchemy/types/value/date.py similarity index 100% rename from excelalchemy/types/value/date.py rename to src/excelalchemy/types/value/date.py diff --git a/excelalchemy/types/value/date_range.py b/src/excelalchemy/types/value/date_range.py similarity index 100% rename from excelalchemy/types/value/date_range.py rename to src/excelalchemy/types/value/date_range.py diff --git a/excelalchemy/types/value/email.py b/src/excelalchemy/types/value/email.py similarity index 100% rename from excelalchemy/types/value/email.py rename to src/excelalchemy/types/value/email.py diff --git a/excelalchemy/types/value/money.py b/src/excelalchemy/types/value/money.py similarity index 100% rename from excelalchemy/types/value/money.py rename to src/excelalchemy/types/value/money.py diff --git a/excelalchemy/types/value/multi_checkbox.py b/src/excelalchemy/types/value/multi_checkbox.py similarity index 100% rename from excelalchemy/types/value/multi_checkbox.py rename to src/excelalchemy/types/value/multi_checkbox.py diff --git a/excelalchemy/types/value/number.py b/src/excelalchemy/types/value/number.py similarity index 100% rename from excelalchemy/types/value/number.py rename to src/excelalchemy/types/value/number.py diff --git a/excelalchemy/types/value/number_range.py b/src/excelalchemy/types/value/number_range.py similarity index 100% rename from excelalchemy/types/value/number_range.py rename to src/excelalchemy/types/value/number_range.py diff --git a/excelalchemy/types/value/organization.py b/src/excelalchemy/types/value/organization.py similarity index 100% rename from excelalchemy/types/value/organization.py rename to src/excelalchemy/types/value/organization.py diff --git a/excelalchemy/types/value/phone_number.py b/src/excelalchemy/types/value/phone_number.py similarity index 100% rename from excelalchemy/types/value/phone_number.py rename to src/excelalchemy/types/value/phone_number.py diff --git a/excelalchemy/types/value/radio.py b/src/excelalchemy/types/value/radio.py similarity index 100% rename from excelalchemy/types/value/radio.py rename to src/excelalchemy/types/value/radio.py diff --git a/excelalchemy/types/value/staff.py b/src/excelalchemy/types/value/staff.py similarity index 100% rename from excelalchemy/types/value/staff.py rename to src/excelalchemy/types/value/staff.py diff --git a/excelalchemy/types/value/string.py b/src/excelalchemy/types/value/string.py similarity index 100% rename from excelalchemy/types/value/string.py rename to src/excelalchemy/types/value/string.py diff --git a/excelalchemy/types/value/tree.py b/src/excelalchemy/types/value/tree.py similarity index 100% rename from excelalchemy/types/value/tree.py rename to src/excelalchemy/types/value/tree.py diff --git a/excelalchemy/types/value/url.py b/src/excelalchemy/types/value/url.py similarity index 100% rename from excelalchemy/types/value/url.py rename to src/excelalchemy/types/value/url.py diff --git a/excelalchemy/util/__init__.py b/src/excelalchemy/util/__init__.py similarity index 100% rename from excelalchemy/util/__init__.py rename to src/excelalchemy/util/__init__.py diff --git a/excelalchemy/util/convertor.py b/src/excelalchemy/util/convertor.py similarity index 100% rename from excelalchemy/util/convertor.py rename to src/excelalchemy/util/convertor.py diff --git a/excelalchemy/util/file.py b/src/excelalchemy/util/file.py similarity index 100% rename from excelalchemy/util/file.py rename to src/excelalchemy/util/file.py diff --git a/tests/contracts/test_export_contract.py b/tests/contracts/test_export_contract.py index 4ada18e..422ca37 100644 --- a/tests/contracts/test_export_contract.py +++ b/tests/contracts/test_export_contract.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import ExcelAlchemy, ExporterConfig from minio import Minio +from excelalchemy import ExcelAlchemy, ExporterConfig from tests.support import BaseTestCase, decode_prefixed_excel_to_workbook from tests.support.contract_models import ( MergedContractImporter, diff --git a/tests/contracts/test_import_contract.py b/tests/contracts/test_import_contract.py index 6e720c4..0f969ca 100644 --- a/tests/contracts/test_import_contract.py +++ b/tests/contracts/test_import_contract.py @@ -1,9 +1,9 @@ from typing import cast -from excelalchemy import ExcelAlchemy, ImporterConfig, ValidateResult -from excelalchemy.const import BACKGROUND_ERROR_COLOR, REASON_COLUMN_LABEL, RESULT_COLUMN_LABEL from minio import Minio +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 SimpleContractImporter, creator diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py index 4bdf180..dc2b904 100644 --- a/tests/contracts/test_pydantic_contract.py +++ b/tests/contracts/test_pydantic_contract.py @@ -1,6 +1,7 @@ +from pydantic import BaseModel + from excelalchemy import DateFormat, DateRange, Email, FieldMeta, Label from excelalchemy.helper.pydantic import extract_pydantic_model, instantiate_pydantic_model -from pydantic import BaseModel class ContractPydanticModel(BaseModel): diff --git a/tests/contracts/test_storage_contract.py b/tests/contracts/test_storage_contract.py index 9164d40..d249a82 100644 --- a/tests/contracts/test_storage_contract.py +++ b/tests/contracts/test_storage_contract.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import ExcelAlchemy, ExporterConfig, ImporterConfig from minio import Minio +from excelalchemy import ExcelAlchemy, ExporterConfig, ImporterConfig from tests.support import BaseTestCase, FileRegistry from tests.support.contract_models import SimpleContractImporter, creator, sample_simple_export_row diff --git a/tests/contracts/test_template_contract.py b/tests/contracts/test_template_contract.py index 1876bdf..ec6250f 100644 --- a/tests/contracts/test_template_contract.py +++ b/tests/contracts/test_template_contract.py @@ -1,9 +1,9 @@ from typing import cast -from excelalchemy import ExcelAlchemy, ImporterConfig -from excelalchemy.const import BACKGROUND_REQUIRED_COLOR, HEADER_HINT 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, diff --git a/tests/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index 8fc4f30..2c50aed 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -2,6 +2,9 @@ import random from typing import Any, cast +from minio import Minio +from pydantic import BaseModel + from excelalchemy import ( Boolean, ConfigError, @@ -35,9 +38,6 @@ Url, ValidateResult, ) -from minio import Minio -from pydantic import BaseModel - from tests.support import BaseTestCase, FileRegistry diff --git a/tests/support/base.py b/tests/support/base.py index 49f5396..8272cb9 100644 --- a/tests/support/base.py +++ b/tests/support/base.py @@ -1,10 +1,10 @@ from typing import Any, cast from unittest import IsolatedAsyncioTestCase -from excelalchemy import ColumnIndex, ExcelAlchemy, ImporterConfig, RowIndex from minio import Minio from pydantic import BaseModel +from excelalchemy import ColumnIndex, ExcelAlchemy, ImporterConfig, RowIndex from tests.support.mock_minio import local_minio diff --git a/tests/support/contract_models.py b/tests/support/contract_models.py index ae56526..a0adc6c 100644 --- a/tests/support/contract_models.py +++ b/tests/support/contract_models.py @@ -1,6 +1,8 @@ import datetime from typing import Any +from pydantic import BaseModel + from excelalchemy import ( Boolean, Date, @@ -27,7 +29,6 @@ String, Url, ) -from pydantic import BaseModel COMMON_OPTIONS = [ Option(id=OptionId('1'), name='选项1'), diff --git a/tests/support/mock_minio.py b/tests/support/mock_minio.py index 22efc9c..1bc7522 100644 --- a/tests/support/mock_minio.py +++ b/tests/support/mock_minio.py @@ -6,8 +6,8 @@ from typing import Any import pandas -from excelalchemy.const import HEADER_HINT +from excelalchemy.const import HEADER_HINT from tests.support.registry import FileRegistry diff --git a/tests/unit/test_converters_and_schema_extraction.py b/tests/unit/test_converters_and_schema_extraction.py index 2f71847..0131315 100644 --- a/tests/unit/test_converters_and_schema_extraction.py +++ b/tests/unit/test_converters_and_schema_extraction.py @@ -1,8 +1,9 @@ from unittest import IsolatedAsyncioTestCase +from pydantic import BaseModel + from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, String, extract_pydantic_model from excelalchemy.util.convertor import export_data_converter, import_data_converter -from pydantic import BaseModel class TestConvertersAndSchemaExtraction(IsolatedAsyncioTestCase): diff --git a/tests/unit/test_excel_exceptions.py b/tests/unit/test_excel_exceptions.py index d8e147d..c258792 100644 --- a/tests/unit/test_excel_exceptions.py +++ b/tests/unit/test_excel_exceptions.py @@ -1,6 +1,5 @@ from excelalchemy import ExcelCellError, Label from excelalchemy.exc import ExcelRowError - from tests.support import BaseTestCase diff --git a/tests/unit/test_field_metadata.py b/tests/unit/test_field_metadata.py index 48ba542..f0ef026 100644 --- a/tests/unit/test_field_metadata.py +++ b/tests/unit/test_field_metadata.py @@ -1,3 +1,5 @@ +from pydantic import BaseModel + from excelalchemy import ( ConfigError, DataRangeOption, @@ -10,8 +12,6 @@ OptionId, Radio, ) -from pydantic import BaseModel - from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_boolean_value_type.py b/tests/unit/value_types/test_boolean_value_type.py index 2a58eb0..3a4dd99 100644 --- a/tests/unit/value_types/test_boolean_value_type.py +++ b/tests/unit/value_types/test_boolean_value_type.py @@ -1,6 +1,6 @@ -from excelalchemy import Boolean, FieldMeta, ValidateResult from pydantic import BaseModel +from excelalchemy import Boolean, FieldMeta, ValidateResult from tests.support import BaseTestCase, FileRegistry diff --git a/tests/unit/value_types/test_date_range_value_type.py b/tests/unit/value_types/test_date_range_value_type.py index d488909..3650558 100644 --- a/tests/unit/value_types/test_date_range_value_type.py +++ b/tests/unit/value_types/test_date_range_value_type.py @@ -1,8 +1,8 @@ -from excelalchemy import DataRangeOption, DateFormat, DateRange, FieldMeta, ValidateResult from pendulum import DateTime, today from pendulum.tz.timezone import Timezone from pydantic import BaseModel +from excelalchemy import DataRangeOption, DateFormat, DateRange, FieldMeta, ValidateResult from tests.support import BaseTestCase, FileRegistry diff --git a/tests/unit/value_types/test_date_value_type.py b/tests/unit/value_types/test_date_value_type.py index 294ca2e..bdfe4ea 100644 --- a/tests/unit/value_types/test_date_value_type.py +++ b/tests/unit/value_types/test_date_value_type.py @@ -1,6 +1,11 @@ from decimal import Decimal from typing import cast +from minio import Minio +from pendulum import DateTime, today +from pendulum.tz.timezone import Timezone +from pydantic import BaseModel + from excelalchemy import ( ConfigError, DataRangeOption, @@ -12,11 +17,6 @@ ImporterConfig, ValidateResult, ) -from minio import Minio -from pendulum import DateTime, today -from pendulum.tz.timezone import Timezone -from pydantic import BaseModel - from tests.support import BaseTestCase, FileRegistry diff --git a/tests/unit/value_types/test_email_value_type.py b/tests/unit/value_types/test_email_value_type.py index d069533..7a7bb28 100644 --- a/tests/unit/value_types/test_email_value_type.py +++ b/tests/unit/value_types/test_email_value_type.py @@ -1,6 +1,6 @@ -from excelalchemy import ColumnIndex, Email, ExcelCellError, FieldMeta, Label, RowIndex, ValidateResult from pydantic import BaseModel +from excelalchemy import ColumnIndex, Email, ExcelCellError, FieldMeta, Label, RowIndex, ValidateResult from tests.support import BaseTestCase, FileRegistry diff --git a/tests/unit/value_types/test_money_value_type.py b/tests/unit/value_types/test_money_value_type.py index 0c13423..a13fea3 100644 --- a/tests/unit/value_types/test_money_value_type.py +++ b/tests/unit/value_types/test_money_value_type.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import FieldMeta, Money from pydantic import BaseModel +from excelalchemy import FieldMeta, Money from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_multi_checkbox_value_type.py b/tests/unit/value_types/test_multi_checkbox_value_type.py index ce3c34d..d14b5e1 100644 --- a/tests/unit/value_types/test_multi_checkbox_value_type.py +++ b/tests/unit/value_types/test_multi_checkbox_value_type.py @@ -1,9 +1,9 @@ from typing import cast -from excelalchemy import FieldMeta, MultiCheckbox, OptionId, ProgrammaticError -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR, Option from pydantic import BaseModel +from excelalchemy import FieldMeta, MultiCheckbox, OptionId, ProgrammaticError +from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR, Option from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_multi_organization_value_type.py b/tests/unit/value_types/test_multi_organization_value_type.py index 0dc4301..b11028c 100644 --- a/tests/unit/value_types/test_multi_organization_value_type.py +++ b/tests/unit/value_types/test_multi_organization_value_type.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import FieldMeta, MultiOrganization, Option, OptionId from pydantic import BaseModel +from excelalchemy import FieldMeta, MultiOrganization, Option, OptionId from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_multi_staff_value_type.py b/tests/unit/value_types/test_multi_staff_value_type.py index 786b041..90443ab 100644 --- a/tests/unit/value_types/test_multi_staff_value_type.py +++ b/tests/unit/value_types/test_multi_staff_value_type.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import FieldMeta, MultiStaff, Option, OptionId from pydantic import BaseModel +from excelalchemy import FieldMeta, MultiStaff, Option, OptionId from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_number_range_value_type.py b/tests/unit/value_types/test_number_range_value_type.py index 48a7983..aa67aae 100644 --- a/tests/unit/value_types/test_number_range_value_type.py +++ b/tests/unit/value_types/test_number_range_value_type.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import FieldMeta, NumberRange from pydantic import BaseModel +from excelalchemy import FieldMeta, NumberRange from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_number_value_type.py b/tests/unit/value_types/test_number_value_type.py index 365b91b..ecaee09 100644 --- a/tests/unit/value_types/test_number_value_type.py +++ b/tests/unit/value_types/test_number_value_type.py @@ -1,9 +1,9 @@ from decimal import Decimal from typing import cast -from excelalchemy import FieldMeta, Number from pydantic import BaseModel +from excelalchemy import FieldMeta, Number from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_phone_number_value_type.py b/tests/unit/value_types/test_phone_number_value_type.py index b69377f..94879e5 100644 --- a/tests/unit/value_types/test_phone_number_value_type.py +++ b/tests/unit/value_types/test_phone_number_value_type.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import FieldMeta, PhoneNumber from pydantic import BaseModel +from excelalchemy import FieldMeta, PhoneNumber from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_radio_value_type.py b/tests/unit/value_types/test_radio_value_type.py index 9046f7f..51372d2 100644 --- a/tests/unit/value_types/test_radio_value_type.py +++ b/tests/unit/value_types/test_radio_value_type.py @@ -1,9 +1,9 @@ from typing import cast -from excelalchemy import FieldMeta, Option, OptionId, ProgrammaticError, Radio -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR from pydantic import BaseModel +from excelalchemy import FieldMeta, Option, OptionId, ProgrammaticError, Radio +from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_single_organization_value_type.py b/tests/unit/value_types/test_single_organization_value_type.py index a620c85..82e53c9 100644 --- a/tests/unit/value_types/test_single_organization_value_type.py +++ b/tests/unit/value_types/test_single_organization_value_type.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import FieldMeta, Option, OptionId, SingleOrganization from pydantic import BaseModel +from excelalchemy import FieldMeta, Option, OptionId, SingleOrganization from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_single_staff_value_type.py b/tests/unit/value_types/test_single_staff_value_type.py index b7c2814..f5c7b99 100644 --- a/tests/unit/value_types/test_single_staff_value_type.py +++ b/tests/unit/value_types/test_single_staff_value_type.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import FieldMeta, Option, OptionId, SingleStaff from pydantic import BaseModel +from excelalchemy import FieldMeta, Option, OptionId, SingleStaff from tests.support import BaseTestCase diff --git a/tests/unit/value_types/test_url_value_type.py b/tests/unit/value_types/test_url_value_type.py index 97de275..45971fe 100644 --- a/tests/unit/value_types/test_url_value_type.py +++ b/tests/unit/value_types/test_url_value_type.py @@ -1,8 +1,8 @@ from typing import cast -from excelalchemy import FieldMeta, Url from pydantic import BaseModel +from excelalchemy import FieldMeta, Url from tests.support import BaseTestCase From 7c9c8d84c0830d02fc5c7b241d97bc3935c0f4c4 Mon Sep 17 00:00:00 2001 From: ruicore Date: Mon, 23 Mar 2026 16:59:42 +0800 Subject: [PATCH 07/27] feat(PR-04): add Pydantic Adaptor --- src/excelalchemy/core/alchemy.py | 4 +- src/excelalchemy/helper/pydantic.py | 158 ++++++++-------- src/excelalchemy/types/abstract.py | 10 +- src/excelalchemy/types/alchemy.py | 3 +- src/excelalchemy/types/field.py | 214 +++++++++------------- tests/contracts/test_pydantic_contract.py | 7 + 6 files changed, 193 insertions(+), 203 deletions(-) diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 846b6a2..ad926ce 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -27,7 +27,7 @@ from excelalchemy.core.abstract import ABCExcelAlchemy from excelalchemy.core.writer import render_data_excel, render_merged_header_excel, render_simple_header_excel from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError -from excelalchemy.helper.pydantic import extract_pydantic_model, instantiate_pydantic_model +from excelalchemy.helper.pydantic import extract_pydantic_model, get_model_field_names, instantiate_pydantic_model from excelalchemy.types.abstract import SystemReserved from excelalchemy.types.alchemy import ExcelMode, ExporterConfig, ImporterConfig, ImportMode from excelalchemy.types.field import FieldMetaInfo @@ -305,7 +305,7 @@ def _gen_export_df(self, data: list[dict[str, Any]], keys: list[Key] | None = No 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()) + model_keys = cast(list[Key], get_model_field_names(self.exporter_model)) if unrecognized := (set(input_keys) - set(model_keys)): logging.warning('导出的列 {%s} 不在模型 {%s} 中', unrecognized, model_keys) diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index 9425f24..d6a1e9c 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from dataclasses import dataclass from typing import Any, Generator, Iterable, TypeVar, cast from pydantic import BaseModel, MissingError, NoneIsNotAllowedError, ValidationError @@ -8,33 +9,79 @@ from excelalchemy.const import ImporterCreateModelT, ImporterUpdateModelT from excelalchemy.exc import ExcelCellError, ProgrammaticError from excelalchemy.types.abstract import ABCValueType, ComplexABCValueType -from excelalchemy.types.field import FieldMetaInfo +from excelalchemy.types.field import FieldMetaInfo, extract_declared_field_metadata from excelalchemy.types.identity import Key ModelT = TypeVar('ModelT', bound=BaseModel) +@dataclass(frozen=True) +class PydanticFieldAdapter: + raw_field: ModelField + + @property + def name(self) -> str: + return self.raw_field.name + + @property + def value_type(self) -> type[Any]: + return self.raw_field.type_ + + @property + def required(self) -> bool: + if isinstance(self.raw_field.required, UndefinedType): + return False + return bool(self.raw_field.required) + + @property + def declared_metadata(self) -> FieldMetaInfo: + return extract_declared_field_metadata(self.raw_field.field_info) + + def runtime_metadata(self) -> FieldMetaInfo: + declared = self.declared_metadata + return declared.bind_runtime( + required=self.required, + value_type=cast(type[ABCValueType], self.value_type), + parent_label=declared.label, + parent_key=Key(self.name), + key=Key(self.name), + offset=0, + ) + + +@dataclass(frozen=True) + class PydanticModelAdapter: + model: type[BaseModel] + + def fields(self) -> Iterable[PydanticFieldAdapter]: + return (PydanticFieldAdapter(field) for field in self.model.__fields__.values()) + + def field(self, name: str) -> PydanticFieldAdapter: + return PydanticFieldAdapter(self.model.__fields__[name]) + + def field_names(self) -> list[str]: + return list(self.model.__fields__.keys()) + + def extract_pydantic_model( model: type[ImporterCreateModelT] | type[ImporterUpdateModelT] | type[BaseModel] | None, ) -> list[FieldMetaInfo]: - """根据 Pydantic 模型提取 Excel 表头信息 - 包含是否必填、值类型、注释等信息 - """ + """根据 Pydantic 模型提取 Excel 表头信息.""" if model is None: raise RuntimeError('模型不能为空') - return list(_extract_pydantic_model(model)) + 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( # noqa: C901 data: dict[Key, Any], model: type[ModelT], ) -> ModelT | list[ExcelCellError]: - """实例化 Pydantic 模型, 并返回错误. - - 若实例化成功, 则返回实例化后的模型, 错误信息为 None - 若实例化失败, 则模型返回 None, 错误信息为 ExcelImportError 列表 - 若无法取得FieldMeta, 则raise ProgrammaticError - """ + """实例化 Pydantic 模型, 并返回错误.""" + model_adapter = PydanticModelAdapter(model) try: result: ModelT | list[ExcelCellError] = model.parse_obj(data) except ValidationError as wrapped_error: @@ -45,20 +92,20 @@ def instantiate_pydantic_model( # noqa: C901 result = [] - for loc, e in locations_and_errors: + for loc, error_wrapper 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) + leaf_field_def = model_adapter.field(leaf).declared_metadata + _handle_error(result, error_wrapper.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) + parent_field = model_adapter.field(parent) + parent_field_def = parent_field.declared_metadata + nested_model = cast(type[BaseModel], parent_field.value_type) + leaf_field_def = PydanticModelAdapter(nested_model).field(leaf).declared_metadata + _handle_error(result, error_wrapper.exc, parent_field_def, leaf_field_def) if len(result) == 0: raise ProgrammaticError('实例化模型失败, 但错误信息为空') from wrapped_error @@ -66,53 +113,28 @@ def instantiate_pydantic_model( # noqa: C901 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 +def _extract_pydantic_model(model: PydanticModelAdapter) -> Generator[FieldMetaInfo, None, None]: + for field_adapter in model.fields(): + declared_metadata = field_adapter.declared_metadata + value_type = field_adapter.value_type + + if issubclass(value_type, ComplexABCValueType): + for offset, (key, sub_field_info) in enumerate(value_type.model_items()): + inherited = sub_field_info.inherited_from(declared_metadata) + yield inherited.bind_runtime( + required=field_adapter.required, + value_type=cast(type[ABCValueType], value_type), + parent_label=declared_metadata.label, + parent_key=Key(field_adapter.name), + key=key, + offset=offset, + ) - elif issubclass(type_, ABCValueType): - field_info = _complete_field_info(field_info, model_field) - yield field_info + elif issubclass(value_type, ABCValueType): + yield field_adapter.runtime_metadata() 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 + raise ProgrammaticError(f'字段定义必须是 ValueType 的子类, 或 ComplexValueType 的子类, 不支持 {value_type}') def _handle_error( @@ -120,7 +142,7 @@ def _handle_error( exc: Exception, parent_field_def: FieldMetaInfo | None, leaf_field_def: FieldMetaInfo, -): +) -> None: match exc: case NoneIsNotAllowedError() | MissingError(): error_container.append( @@ -163,14 +185,6 @@ def _flatten_errors( 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') diff --git a/src/excelalchemy/types/abstract.py b/src/excelalchemy/types/abstract.py index d4d3260..4c21825 100644 --- a/src/excelalchemy/types/abstract.py +++ b/src/excelalchemy/types/abstract.py @@ -1,8 +1,6 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Iterable -from pydantic.fields import ModelField - from excelalchemy.types.identity import Key if TYPE_CHECKING: @@ -36,9 +34,11 @@ 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] + def __wrapped_validate__(cls, value: Any, field: Any) -> Any: + # Delay the import to avoid a hard dependency on Pydantic internals at module import time. + from excelalchemy.types.field import extract_declared_field_metadata + + return cls.__validate__(value, extract_declared_field_metadata(field.field_info)) @classmethod @abstractmethod diff --git a/src/excelalchemy/types/alchemy.py b/src/excelalchemy/types/alchemy.py index 7b963b5..89f186d 100644 --- a/src/excelalchemy/types/alchemy.py +++ b/src/excelalchemy/types/alchemy.py @@ -8,6 +8,7 @@ from excelalchemy.const import ContextT, ExporterModelT, ImporterCreateModelT, ImporterUpdateModelT from excelalchemy.exc import ConfigError +from excelalchemy.helper.pydantic import get_model_field_names from excelalchemy.util.convertor import export_data_converter, import_data_converter @@ -86,7 +87,7 @@ def _validate_create_or_update(self): if not self.is_data_exist: raise ConfigError('当选择【创建或更新模式】时,数据存在判断函数不能为空') # 创建模型和更新模型的字段必须一致 - if self.create_importer_model.__fields__.keys() != self.update_importer_model.__fields__.keys(): + if get_model_field_names(self.create_importer_model) != get_model_field_names(self.update_importer_model): raise ConfigError('创建模型和更新模型的字段名称必须一致') def __post_init__(self): diff --git a/src/excelalchemy/types/field.py b/src/excelalchemy/types/field.py index b6185ed..46ad7c9 100644 --- a/src/excelalchemy/types/field.py +++ b/src/excelalchemy/types/field.py @@ -1,11 +1,12 @@ -"""用于表示后端实际希望接受的 Excel 表头""" +"""Excel metadata definitions decoupled from Pydantic internals.""" +import copy import datetime import logging from functools import cached_property from typing import AbstractSet, Any, Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, Field from pydantic.fields import FieldInfo from pydantic.fields import Undefined as PydanticUndefined from pydantic.typing import NoArgAnyCallable @@ -24,10 +25,12 @@ IntStr, Option, ) -from excelalchemy.exc import ConfigError +from excelalchemy.exc import ConfigError, ProgrammaticError from excelalchemy.types.abstract import ABCValueType, Undefined from excelalchemy.types.identity import Key, Label, OptionId, UniqueKey, UniqueLabel +EXCEL_FIELD_METADATA_KEY = 'excelalchemy_metadata' + class PatchFieldMeta(BaseModel): unique: bool | None = False # 当前列是否唯一,不用于校验,用于渲染 Excel 表头的注释 @@ -36,84 +39,28 @@ class PatchFieldMeta(BaseModel): options: list[Option] | None = None -class FieldMetaInfo(FieldInfo): # pyright: ignore[reportGeneralTypeIssues] - """用于表示后端真实期望的 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, 可选 +class FieldMetaInfo: + """Excel field metadata independent from any validation backend.""" - # pylint: disable=too-many-locals def __init__( self, - default: Any = Undefined, *, - # 导入模块增加的字段·必填 - label: str, - # 是否为主键(产品定义的关键列) + label: str | Label, is_primary_key: bool = False, - # 导入模块增加的字段·从 pydantic 模型中获取 unique: bool = False, ignore_import: bool = False, + required: bool | None = 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, @@ -121,45 +68,19 @@ def __init__( 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.parent_label: Label | None = None + + self.key: Key | None = None + self.parent_key: Key | None = None + + self.offset = DEFAULT_FIELD_META_ORDER + self.value_type: type[ABCValueType] = Undefined + self.unique = unique or is_primary_key + + self.required = required self.ignore_import = ignore_import self.order = order @@ -172,8 +93,48 @@ def __init__( self.unit = unit self.hint = hint - # 下列属性从 pydantic 配置中获取,不允许手动设置 - self.required = False + 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) -> 'FieldMetaInfo': + return copy.copy(self) + + def inherited_from(self, parent: 'FieldMetaInfo') -> 'FieldMetaInfo': + 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, + value_type: type[ABCValueType], + parent_label: Label, + parent_key: Key, + key: Key, + offset: int, + ) -> 'FieldMetaInfo': + runtime = self.clone() + runtime.required = required + runtime.value_type = value_type + runtime.parent_label = parent_label + runtime.parent_key = parent_key + runtime.key = key + runtime.offset = offset + return runtime def set_is_primary_key(self, is_primary_key: bool | None) -> None: if is_primary_key is None: @@ -235,6 +196,8 @@ def unique_label(self) -> UniqueLabel: def unique_key(self) -> UniqueKey: if self.parent_key is None: raise RuntimeError('运行时 parent_key 不能为空') + if self.key is None: + raise RuntimeError('运行时 key 不能为空') key = f'{self.parent_key}{UNIQUE_HEADER_CONNECTOR}{self.key}' if self.parent_key != self.key else self.key return UniqueKey(key) @@ -316,7 +279,7 @@ def must_date_format(self) -> DateFormat: def python_date_format(self) -> str: return DATE_FORMAT_TO_PYTHON_MAPPING[self.must_date_format] - def __repr__(self): + def __repr__(self) -> str: return ( f'FieldMeta(label={self.label!r}, ' f'order={self.order!r}, ' @@ -330,32 +293,31 @@ def __repr__(self): __str__ = __repr__ +def extract_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: + metadata = field_info.extra.get(EXCEL_FIELD_METADATA_KEY) + if not isinstance(metadata, FieldMetaInfo): + raise ProgrammaticError('字段定义必须是 FieldMeta 的实例') + return metadata + + # 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, @@ -379,12 +341,12 @@ def FieldMeta( discriminator: str | None = None, repr: bool = True, **extra: Any, -) -> Any: # return any to ignore the annotation type +) -> Any: # pyright: reportUnnecessaryIsInstance=false if fraction_digits is not None and not isinstance(fraction_digits, int): raise ValueError('fraction_digits 必须是整数') - return FieldMetaInfo( - default=default, + + metadata = FieldMetaInfo( label=label, is_primary_key=is_primary_key, unique=unique, @@ -398,17 +360,8 @@ def FieldMeta( 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, @@ -416,9 +369,24 @@ def FieldMeta( unique_items=unique_items, min_length=min_length, max_length=max_length, + ) + + return Field( + 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, + **({EXCEL_FIELD_METADATA_KEY: metadata} | extra), ) diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py index dc2b904..466ff6b 100644 --- a/tests/contracts/test_pydantic_contract.py +++ b/tests/contracts/test_pydantic_contract.py @@ -2,6 +2,7 @@ from excelalchemy import DateFormat, DateRange, Email, FieldMeta, Label from excelalchemy.helper.pydantic import extract_pydantic_model, instantiate_pydantic_model +from excelalchemy.types.field import FieldMetaInfo, extract_declared_field_metadata class ContractPydanticModel(BaseModel): @@ -10,6 +11,12 @@ class ContractPydanticModel(BaseModel): class TestPydanticContracts: + def test_fieldmeta_keeps_excel_metadata_outside_pydantic_fieldinfo_subclass(self): + raw_field_info = ContractPydanticModel.__fields__['email'].field_info + + 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) From 4dcffc3c3efbd8d808b9009ee1ea4fded8d3764d Mon Sep 17 00:00:00 2001 From: ruicore Date: Mon, 23 Mar 2026 17:53:25 +0800 Subject: [PATCH 08/27] feat(PR-05): update pydantic V2 --- README.md | 4 +- README_cn.md | 4 +- pyproject.toml | 2 +- src/excelalchemy/core/alchemy.py | 4 +- src/excelalchemy/helper/pydantic.py | 181 ++++++++++----------- src/excelalchemy/types/abstract.py | 22 +-- src/excelalchemy/types/field.py | 83 +++++++--- src/excelalchemy/types/identity.py | 39 ++++- src/excelalchemy/types/result.py | 7 +- src/excelalchemy/types/value/date_range.py | 6 +- src/excelalchemy/types/value/email.py | 6 +- src/excelalchemy/types/value/url.py | 10 +- tests/contracts/test_pydantic_contract.py | 2 +- 13 files changed, 206 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index e1f437f..69348e5 100755 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ class Importer(BaseModel): def data_converter(data: dict[str, Any]) -> dict[str, Any]: - """Custom data converter, here you can modify the result of Importer.dict()""" + """Custom data converter, here you can modify the result of Importer.model_dump()""" data['age'] = data['age'] + 1 data['name'] = {"phone": data['phone']} return data @@ -112,7 +112,7 @@ asyncio.run(main()) * 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. * 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. +* In the above example, we define a `data_converter` function, which is used to modify the result of `Importer.model_dump().` 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. diff --git a/README_cn.md b/README_cn.md index 59fce25..e53c9c3 100644 --- a/README_cn.md +++ b/README_cn.md @@ -83,7 +83,7 @@ class Importer(BaseModel): def data_converter(data: dict[str, Any]) -> dict[str, Any]: - """自定义数据转换器, 在这里,你可以对 Importer.dict() 的结果进行转换""" + """自定义数据转换器, 在这里,你可以对 Importer.model_dump() 的结果进行转换""" data['age'] = data['age'] + 1 data['name'] = {"phone": data['phone']} return data @@ -115,7 +115,7 @@ asyncio.run(main()) * 导入功能的文件基于 Minio,因此在使用该功能前,你需要先安装 Minio,并且在 Minio 中创建一个 bucket,用于存放 Excel 文件。 * 导入的 Excel 文件,必须是从 `download_template` 方法生成的 Excel 文件,否则会产生解析错误。 -* 上面的示例代码中,我们定义了一个 `data_converter` 函数,该函数用于对 `Importer.dict()` 的结果进行转换,最终返回的结果将会作为 `create_func` 函数的参数。当然,此函数是可选的,如果你不需要对数据进行转换,可以不定义该函数。 +* 上面的示例代码中,我们定义了一个 `data_converter` 函数,该函数用于对 `Importer.model_dump()` 的结果进行转换,最终返回的结果将会作为 `create_func` 函数的参数。当然,此函数是可选的,如果你不需要对数据进行转换,可以不定义该函数。 * `create_func` 函数用于创建数据,该函数的参数为 `data_converter` 函数的返回值,`context` 为 `None`,你可以在该函数中对数据进行创建,例如,你可以将数据存入数据库中。 * `import_data` 方法的参数 `input_excel_name` 为 Excel 文件在 Minio 中的名称,`output_excel_name` 为解析结果 Excel 文件在 Minio 中的名称,该文件包含所有输入的数据,如果某条数据解析失败,则在该条数据的第一列中会有错误信息,并且会讲产生错误的单元格标红。 * 返回 ImportResult 类型的结果,您可以在代码中查看该类的定义,该类包含了解析结果的所有信息,例如,成功导入的数据条数、失败的数据条数、失败的数据等。 diff --git a/pyproject.toml b/pyproject.toml index 03809b1..3139e32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ requires-python = '>=3.10' dependencies = [ 'pandas >=2.0.0, <3', 'minio >=7.0.0, <8', - 'pydantic[email] >=1.9, <2', + 'pydantic[email] >=2, <3', 'openpyxl >=3.0.10, <4', 'pendulum >=2.1.2, <4', ] diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index ad926ce..d6849ad 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -605,9 +605,9 @@ async def __caller_impl__( # 第二步: 调用 creator/updater, 可能产生错误 importer_instance = importer_instance_or_errors if data_converter is not None: - converted_data = data_converter(importer_instance.dict(exclude_unset=True)) + converted_data = data_converter(importer_instance.model_dump(exclude_unset=True)) else: - converted_data = importer_instance.dict(exclude_unset=True) + converted_data = importer_instance.model_dump(exclude_unset=True) try: await dml_func(converted_data, self.context) except ExcelCellError as e: diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index d6a1e9c..6ffb831 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -1,10 +1,9 @@ -from collections.abc import Sequence from dataclasses import dataclass -from typing import Any, Generator, Iterable, TypeVar, cast +from types import UnionType +from typing import Any, Generator, Iterable, TypeVar, cast, get_args, get_origin -from pydantic import BaseModel, MissingError, NoneIsNotAllowedError, ValidationError -from pydantic.error_wrappers import ErrorList, ErrorWrapper -from pydantic.fields import ModelField, UndefinedType +from pydantic import BaseModel +from pydantic.fields import FieldInfo, PydanticUndefined from excelalchemy.const import ImporterCreateModelT, ImporterUpdateModelT from excelalchemy.exc import ExcelCellError, ProgrammaticError @@ -17,25 +16,46 @@ @dataclass(frozen=True) class PydanticFieldAdapter: - raw_field: ModelField + name: str + raw_field: FieldInfo @property - def name(self) -> str: - return self.raw_field.name + def annotation(self) -> Any: + return self.raw_field.annotation @property def value_type(self) -> type[Any]: - return self.raw_field.type_ + annotation = self.annotation + origin = get_origin(annotation) + if origin in (UnionType, getattr(__import__('typing'), 'Union')): + args = [arg for arg in get_args(annotation) if arg is not type(None)] + if len(args) != 1: + raise ProgrammaticError(f'不支持的字段类型定义: {annotation}') + return cast(type[Any], args[0]) + + return cast(type[Any], annotation) + + @property + def allows_none(self) -> bool: + return any(arg is type(None) for arg in get_args(self.annotation)) @property def required(self) -> bool: - if isinstance(self.raw_field.required, UndefinedType): + 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 + if self.allows_none: return False - return bool(self.raw_field.required) + return True @property def declared_metadata(self) -> FieldMetaInfo: - return extract_declared_field_metadata(self.raw_field.field_info) + return extract_declared_field_metadata(self.raw_field) def runtime_metadata(self) -> FieldMetaInfo: declared = self.declared_metadata @@ -48,19 +68,30 @@ def runtime_metadata(self) -> FieldMetaInfo: 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('必填项缺失') + + return self.value_type.__validate__(raw_value, self.declared_metadata) + @dataclass(frozen=True) - class PydanticModelAdapter: +class PydanticModelAdapter: model: type[BaseModel] def fields(self) -> Iterable[PydanticFieldAdapter]: - return (PydanticFieldAdapter(field) for field in self.model.__fields__.values()) + 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(self.model.__fields__[name]) + return PydanticFieldAdapter(name=name, raw_field=self.model.model_fields[name]) def field_names(self) -> list[str]: - return list(self.model.__fields__.keys()) + return list(self.model.model_fields.keys()) def extract_pydantic_model( @@ -82,35 +113,33 @@ def instantiate_pydantic_model( # noqa: C901 ) -> ModelT | list[ExcelCellError]: """实例化 Pydantic 模型, 并返回错误.""" model_adapter = PydanticModelAdapter(model) - 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, error_wrapper in locations_and_errors: - attr_path = _validate_error_loc(loc) - - match attr_path: - case (leaf,): - leaf_field_def = model_adapter.field(leaf).declared_metadata - _handle_error(result, error_wrapper.exc, None, leaf_field_def) - - case (parent, leaf): - parent_field = model_adapter.field(parent) - parent_field_def = parent_field.declared_metadata - nested_model = cast(type[BaseModel], parent_field.value_type) - leaf_field_def = PydanticModelAdapter(nested_model).field(leaf).declared_metadata - _handle_error(result, error_wrapper.exc, parent_field_def, leaf_field_def) - - if len(result) == 0: - raise ProgrammaticError('实例化模型失败, 但错误信息为空') from wrapped_error - - return result + validated_data: dict[str, Any] = {} + errors: list[ExcelCellError] = [] + + for field_adapter in model_adapter.fields(): + raw_value = data.get(Key(field_adapter.name), PydanticUndefined) + if raw_value is PydanticUndefined: + if field_adapter.required: + errors.append(ExcelCellError(label=field_adapter.declared_metadata.label, message='必填项缺失')) + continue + + try: + validated_data[field_adapter.name] = field_adapter.validate_value(raw_value) + except ProgrammaticError: + raise + except Exception as exc: + _handle_error(errors, exc, field_adapter.declared_metadata) + + if errors: + return errors + + return cast( + ModelT, + model.model_construct( + _fields_set=set(validated_data.keys()), + **validated_data, + ), + ) def _extract_pydantic_model(model: PydanticModelAdapter) -> Generator[FieldMetaInfo, None, None]: @@ -140,57 +169,13 @@ def _extract_pydantic_model(model: PydanticModelAdapter) -> Generator[FieldMetaI def _handle_error( error_container: list[ExcelCellError], exc: Exception, - parent_field_def: FieldMetaInfo | None, - leaf_field_def: FieldMetaInfo, + field_def: FieldMetaInfo, ) -> None: - 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_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) + messages = [str(arg) for arg in exc.args if str(arg)] or [str(exc) or '无效输入'] + error_container.extend( + ExcelCellError( + label=field_def.label, + message=message, + ) + for message in messages + ) diff --git a/src/excelalchemy/types/abstract.py b/src/excelalchemy/types/abstract.py index 4c21825..e756d3a 100644 --- a/src/excelalchemy/types/abstract.py +++ b/src/excelalchemy/types/abstract.py @@ -1,5 +1,8 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Iterable +from typing import TYPE_CHECKING, Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema from excelalchemy.types.identity import Key @@ -33,21 +36,20 @@ def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: # value is al def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: """用于把 pandas 读取的 Excel 之后的数据,转回用户可识别的数据, 处理聚合之前的数据""" - @classmethod - def __wrapped_validate__(cls, value: Any, field: Any) -> Any: - # Delay the import to avoid a hard dependency on Pydantic internals at module import time. - from excelalchemy.types.field import extract_declared_field_metadata - - return cls.__validate__(value, extract_declared_field_metadata(field.field_info)) - @classmethod @abstractmethod def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> Any: """验证用户输入的值是否符合约束. 接收 serialize 后的值""" @classmethod - def __get_validators__(cls) -> Iterable[Any]: - yield cls.__wrapped_validate__ + 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 ComplexABCValueType(ABCValueType, dict): diff --git a/src/excelalchemy/types/field.py b/src/excelalchemy/types/field.py index 46ad7c9..fb159dd 100644 --- a/src/excelalchemy/types/field.py +++ b/src/excelalchemy/types/field.py @@ -4,12 +4,10 @@ import datetime import logging from functools import cached_property -from typing import AbstractSet, Any, Optional, Union +from typing import AbstractSet, Any, Callable, Optional, Union from pydantic import BaseModel, Field -from pydantic.fields import FieldInfo -from pydantic.fields import Undefined as PydanticUndefined -from pydantic.typing import NoArgAnyCallable +from pydantic.fields import FieldInfo, PydanticUndefined from excelalchemy.const import ( DATA_RANGE_OPTION_TO_CHINESE, @@ -49,7 +47,7 @@ def __init__( is_primary_key: bool = False, unique: bool = False, ignore_import: bool = False, - required: bool | None = False, + required: bool | None = None, order: int = DEFAULT_FIELD_META_ORDER, character_set: set[CharacterSet] | None = None, fraction_digits: int | None = None, @@ -294,7 +292,7 @@ def __repr__(self) -> str: def extract_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: - metadata = field_info.extra.get(EXCEL_FIELD_METADATA_KEY) + metadata = (field_info.json_schema_extra or {}).get(EXCEL_FIELD_METADATA_KEY) if not isinstance(metadata, FieldMetaInfo): raise ProgrammaticError('字段定义必须是 FieldMeta 的实例') return metadata @@ -309,6 +307,7 @@ def FieldMeta( 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, @@ -318,7 +317,7 @@ def FieldMeta( options: list[Option] | None = None, unit: str | None = None, hint: str | None = None, - default_factory: Optional[NoArgAnyCallable] = None, + default_factory: Optional[Callable[[], Any]] = None, alias: str | None = None, title: str | None = None, description: str | None = None, @@ -351,6 +350,7 @@ def FieldMeta( is_primary_key=is_primary_key, unique=unique, ignore_import=ignore_import, + required=required, order=order, character_set=character_set, fraction_digits=fraction_digits, @@ -371,22 +371,53 @@ def FieldMeta( max_length=max_length, ) - return Field( - 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, - **({EXCEL_FIELD_METADATA_KEY: metadata} | extra), - ) + json_schema_extra = {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/src/excelalchemy/types/identity.py b/src/excelalchemy/types/identity.py index 98f83df..b46e221 100644 --- a/src/excelalchemy/types/identity.py +++ b/src/excelalchemy/types/identity.py @@ -1,7 +1,32 @@ """定义了一些用于标识的类型""" +from typing import Any -class Label(str): +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): """Excel 的列名""" @@ -9,7 +34,7 @@ class UniqueLabel(Label): """Excel 唯一的列名""" -class Key(str): +class Key(_StringIdentity): """Python 模型的键名""" @@ -17,21 +42,21 @@ class UniqueKey(Key): """Python 模型唯一的键名""" -class RowIndex(int): +class RowIndex(_IntegerIdentity): """Excel 的行索引, 从 0 开始""" -class ColumnIndex(int): +class ColumnIndex(_IntegerIdentity): """Excel 的列索引, 从 0 开始""" -class OptionId(str): +class OptionId(_StringIdentity): """选项 ID""" -class Base64Str(str): +class Base64Str(_StringIdentity): """Base64 编码的字符串""" -class UrlStr(str): +class UrlStr(_StringIdentity): """URL 字符串""" diff --git a/src/excelalchemy/types/result.py b/src/excelalchemy/types/result.py index 5a2000d..d50674d 100644 --- a/src/excelalchemy/types/result.py +++ b/src/excelalchemy/types/result.py @@ -2,7 +2,7 @@ from enum import Enum -from pydantic import BaseModel, Extra, Field +from pydantic import BaseModel, ConfigDict, Field from excelalchemy.types.identity import Label @@ -43,6 +43,8 @@ class ValidateResult(str, Enum): class ImportResult(BaseModel): """导入数据结果""" + model_config = ConfigDict(extra='allow') + result: ValidateResult = Field(description='导入结果') is_required_missing: bool = Field(default=False, description='是否缺失必填表头') @@ -55,9 +57,6 @@ class ImportResult(BaseModel): 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': """从校验表头结果构造导入结果""" diff --git a/src/excelalchemy/types/value/date_range.py b/src/excelalchemy/types/value/date_range.py index f2f2496..2e52c7c 100644 --- a/src/excelalchemy/types/value/date_range.py +++ b/src/excelalchemy/types/value/date_range.py @@ -26,8 +26,8 @@ class DateRange(ComplexABCValueType): __name__ = '日期范围' @classmethod - def parse_obj(cls, obj: Any) -> 'DateRange': - impl = _DateRangeImpl.parse_obj(obj) + def model_validate(cls, obj: Any) -> 'DateRange': + impl = _DateRangeImpl.model_validate(obj) self = cls(impl.start, impl.end) return self @@ -106,7 +106,7 @@ def __validate__( field_meta: FieldMetaInfo, ) -> 'DateRange': try: - parsed = DateRange.parse_obj(value) + parsed = DateRange.model_validate(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: diff --git a/src/excelalchemy/types/value/email.py b/src/excelalchemy/types/value/email.py index 8eb800d..541414b 100644 --- a/src/excelalchemy/types/value/email.py +++ b/src/excelalchemy/types/value/email.py @@ -1,12 +1,14 @@ from typing import Any -from pydantic import EmailStr +from pydantic import EmailStr, TypeAdapter from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.string import String class Email(String): + _validator = TypeAdapter(EmailStr) + @classmethod def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: # Try to parse the value as a string @@ -17,7 +19,7 @@ def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: # Validate the parsed string as an email address try: - EmailStr.validate(parsed) + cls._validator.validate_python(parsed) except Exception as exc: raise ValueError('请输入正确的邮箱') from exc diff --git a/src/excelalchemy/types/value/url.py b/src/excelalchemy/types/value/url.py index 66077ee..c14d685 100644 --- a/src/excelalchemy/types/value/url.py +++ b/src/excelalchemy/types/value/url.py @@ -1,23 +1,21 @@ from typing import Any -from pydantic import BaseModel, HttpUrl +from pydantic import HttpUrl, TypeAdapter from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.string import String -class HttpUrlValidator(BaseModel): - url: HttpUrl - - class Url(String): + _validator = TypeAdapter(HttpUrl) + @classmethod def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: parsed = str(value) errors: list[str] = [] try: - HttpUrlValidator.parse_obj({'url': parsed}) + cls._validator.validate_python(parsed) except Exception: errors.append('请输入正确的网址') diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py index 466ff6b..965acb4 100644 --- a/tests/contracts/test_pydantic_contract.py +++ b/tests/contracts/test_pydantic_contract.py @@ -12,7 +12,7 @@ class ContractPydanticModel(BaseModel): class TestPydanticContracts: def test_fieldmeta_keeps_excel_metadata_outside_pydantic_fieldinfo_subclass(self): - raw_field_info = ContractPydanticModel.__fields__['email'].field_info + raw_field_info = ContractPydanticModel.model_fields['email'] assert not isinstance(raw_field_info, FieldMetaInfo) assert extract_declared_field_metadata(raw_field_info).label == Label('邮箱') From 69fdde328f863b8c614fea93d202349268f7f801 Mon Sep 17 00:00:00 2001 From: ruicore Date: Mon, 23 Mar 2026 18:40:50 +0800 Subject: [PATCH 09/27] feat(PR-06): update python version to 3.12 --- .github/workflows/ci.yml | 12 +++++----- .github/workflows/python-publish.yml | 4 ++-- README.md | 7 ++++-- README_cn.md | 7 ++++-- noxfile.py | 10 ++++---- pyproject.toml | 23 +++++++++++-------- src/excelalchemy/core/alchemy.py | 2 -- src/excelalchemy/core/writer.py | 2 -- src/excelalchemy/types/abstract.py | 1 - src/excelalchemy/types/field.py | 1 - src/excelalchemy/types/value/date.py | 3 --- src/excelalchemy/types/value/date_range.py | 5 ---- src/excelalchemy/types/value/number.py | 1 - src/excelalchemy/types/value/number_range.py | 2 -- src/excelalchemy/util/file.py | 5 ---- .../test_excelalchemy_workflows.py | 4 ++-- 16 files changed, 39 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0957c0c..d5f0ff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,11 +24,11 @@ jobs: - name: Check out code uses: actions/checkout@v4 - - name: Set up Python 3.10 + - name: Set up Python 3.14 id: setup-python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.14' cache: 'pip' cache-dependency-path: pyproject.toml @@ -49,10 +49,10 @@ jobs: - name: Check out code uses: actions/checkout@v4 - - name: Set up Python 3.10 + - name: Set up Python 3.14 uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.14' cache: 'pip' cache-dependency-path: pyproject.toml @@ -72,7 +72,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.12', '3.13', '3.14'] steps: - name: Check out code @@ -94,7 +94,7 @@ jobs: run: nox -s tests-${{ matrix.python-version }} - name: Upload coverage artifact - if: always() && matrix.python-version == '3.10' + if: always() && matrix.python-version == '3.14' uses: actions/upload-artifact@v4 with: name: test-reports-${{ matrix.python-version }} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 867024a..f09e2f9 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -21,10 +21,10 @@ jobs: - name: Check out code uses: actions/checkout@v4 - - name: Set up Python 3.10 + - name: Set up Python 3.14 uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.14' cache: 'pip' cache-dependency-path: pyproject.toml diff --git a/README.md b/README.md index 69348e5..e284ff9 100755 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ Use pip to install: pip install ExcelAlchemy ``` +ExcelAlchemy currently supports Python 3.12 through 3.14. +Python 3.14 is the primary supported version, and new behavior or dependency updates may be optimized for it first. + ## Usage ### Generate Excel template from Pydantic class @@ -136,10 +139,10 @@ Common local commands: ```bash nox -s ruff nox -s pyright -nox -s tests-3.10 +nox -s tests-3.12 ``` -The CI workflow runs `ruff`, `pyright`, and the test matrix on Python 3.10, 3.11, and 3.12. +The CI workflow runs `ruff`, `pyright`, and the test matrix on Python 3.12, 3.13, and 3.14. ### Contributing diff --git a/README_cn.md b/README_cn.md index e53c9c3..a64bd82 100644 --- a/README_cn.md +++ b/README_cn.md @@ -14,6 +14,9 @@ ExcelAlchemy 是一个用于从 Minio 下载 Excel 文件,解析用户输入 pip install ExcelAlchemy ``` +ExcelAlchemy 当前支持 Python 3.12 到 3.14。 +其中 Python 3.14 是主支持版本,新的行为优化和依赖升级会优先围绕 3.14 进行。 + ## 使用方法 ### 从 Pydantic 类生成 Excel 模板 @@ -140,10 +143,10 @@ pre-commit install ```bash nox -s ruff nox -s pyright -nox -s tests-3.10 +nox -s tests-3.12 ``` -CI 会运行 `ruff`、`pyright`,以及 Python 3.10、3.11、3.12 的测试矩阵。 +CI 会运行 `ruff`、`pyright`,以及 Python 3.12、3.13、3.14 的测试矩阵。 如果你在使用 ExcelAlchemy 过程中遇到了问题或者有任何建议,欢迎在 [GitHub Issues](https://github.com/RayCarterLab/ExcelAlchemy/issues) 中提出。我们也非常欢迎你提交 Pull Request,贡献你的代码。在提交前,建议先运行上面的本地校验命令,并在行为变化时同步更新文档。 diff --git a/noxfile.py b/noxfile.py index 6c7944d..9963e21 100644 --- a/noxfile.py +++ b/noxfile.py @@ -2,24 +2,26 @@ import nox -DEFAULT_PYTHONS = ['3.10', '3.11', '3.12'] +DEFAULT_PYTHONS = ['3.12', '3.13', '3.14'] +MAIN_PYTHON = '3.14' PACKAGE_INSTALL = ['-e', '.[development]'] nox.options.sessions = ['ruff', 'pyright', 'tests'] +nox.options.error_on_missing_interpreters = False def install_project(session: nox.Session) -> None: session.install(*PACKAGE_INSTALL) -@nox.session(python='3.10') +@nox.session(python=MAIN_PYTHON) def ruff(session: nox.Session) -> None: install_project(session) session.run('ruff', 'format', '--check', '.') session.run('ruff', 'check', '.') -@nox.session(python='3.10') +@nox.session(python=MAIN_PYTHON) def pyright(session: nox.Session) -> None: install_project(session) session.run('pyright') @@ -39,7 +41,7 @@ def tests(session: nox.Session) -> None: ) -@nox.session(python='3.10') +@nox.session(python=MAIN_PYTHON) def build(session: nox.Session) -> None: session.install('build') session.run('python', '-m', 'build') diff --git a/pyproject.toml b/pyproject.toml index 3139e32..4c490b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,20 +16,21 @@ classifiers = [ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', + '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.10' +requires-python = '>=3.12' dependencies = [ - 'pandas >=2.0.0, <3', - 'minio >=7.0.0, <8', - 'pydantic[email] >=2, <3', - 'openpyxl >=3.0.10, <4', - 'pendulum >=2.1.2, <4', + 'pandas >=2.3.3, <4', + 'minio >=7.2.20, <8', + 'pydantic[email] >=2.12, <3', + 'openpyxl >=3.1.5, <4', + 'pendulum >=3.2.0, <4', ] [tool.flit.module] @@ -47,7 +48,7 @@ development = [ 'pandas-stubs', 'nox', 'pre-commit', - 'pyright==1.1.299', + 'pyright==1.1.408', 'pytest', 'coverage', 'pytest-cov', @@ -74,9 +75,11 @@ reportAttributeAccessIssue = false reportCallIssue = false reportDeprecated = false reportGeneralTypeIssues = false +reportImportCycles = false reportMissingTypeArgument = false reportMissingTypeStubs = false reportPrivateImportUsage = false +reportRedeclaration = false reportUnknownArgumentType = false reportUnknownMemberType = false reportUnknownParameterType = false @@ -87,7 +90,7 @@ typeCheckingMode = 'basic' [tool.ruff] line-length = 120 -target-version = 'py310' +target-version = 'py312' src = ['src', 'tests'] extend-exclude = ['files'] diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index d6849ad..9eee855 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -441,8 +441,6 @@ def _read_dataframe(self, input_excel_name: str) -> pandas.DataFrame: if self.config.minio is None: raise ConfigError('未配置 minio') file_object = read_file_from_minio_object( - # pyright: reportUnknownMemberType=false - # pyright: reportUnknownArgumentType=false self.config.minio, self.config.bucket_name, input_excel_name, diff --git a/src/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py index 5de4525..e291839 100644 --- a/src/excelalchemy/core/writer.py +++ b/src/excelalchemy/core/writer.py @@ -76,7 +76,6 @@ def _write_simple_header( writer = writer or ExcelWriter(file, engine='openpyxl') assert writer is not None - # pyright: reportUnknownMemberType=false df.to_excel(writer, sheet_name=sheet_name, index=False, startrow=PANDAS_WRITE_START_AT) worksheet: Worksheet = writer.sheets[sheet_name] @@ -149,7 +148,6 @@ def _write_comment_header( 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: diff --git a/src/excelalchemy/types/abstract.py b/src/excelalchemy/types/abstract.py index e756d3a..983dcbc 100644 --- a/src/excelalchemy/types/abstract.py +++ b/src/excelalchemy/types/abstract.py @@ -7,7 +7,6 @@ from excelalchemy.types.identity import Key if TYPE_CHECKING: - # pyright: reportImportCycles=false from excelalchemy.types.field import FieldMetaInfo else: FieldMetaInfo = Any diff --git a/src/excelalchemy/types/field.py b/src/excelalchemy/types/field.py index fb159dd..77bf1ca 100644 --- a/src/excelalchemy/types/field.py +++ b/src/excelalchemy/types/field.py @@ -341,7 +341,6 @@ def FieldMeta( repr: bool = True, **extra: Any, ) -> Any: - # pyright: reportUnnecessaryIsInstance=false if fraction_digits is not None and not isinstance(fraction_digits, int): raise ValueError('fraction_digits 必须是整数') diff --git a/src/excelalchemy/types/value/date.py b/src/excelalchemy/types/value/date.py index 58cc829..993fb7c 100644 --- a/src/excelalchemy/types/value/date.py +++ b/src/excelalchemy/types/value/date.py @@ -38,9 +38,6 @@ def serialize(cls, value: str | DateTime | Any, field_meta: FieldMetaInfo) -> da value = str(value).strip() try: - # pyright: reportPrivateImportUsage=false - # pyright: reportUnknownMemberType=false - # pyright: reportGeneralTypeIssues=false v = value.replace('/', '-') # pendulum 不支持 / 作为日期分隔符 dt: DateTime = cast(DateTime, pendulum.parse(v)) return dt.replace(tzinfo=field_meta.timezone) diff --git a/src/excelalchemy/types/value/date_range.py b/src/excelalchemy/types/value/date_range.py index 2e52c7c..8f100a8 100644 --- a/src/excelalchemy/types/value/date_range.py +++ b/src/excelalchemy/types/value/date_range.py @@ -3,8 +3,6 @@ from typing import Any import pendulum - -# pyright: reportPrivateImportUsage=false from pendulum import DateTime from pydantic import BaseModel @@ -32,7 +30,6 @@ def model_validate(cls, obj: Any) -> 'DateRange': 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 @@ -66,8 +63,6 @@ def serialize(cls, value: dict[str, str] | Any, field_meta: FieldMetaInfo) -> di 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, diff --git a/src/excelalchemy/types/value/number.py b/src/excelalchemy/types/value/number.py index 86e8e83..90403d6 100644 --- a/src/excelalchemy/types/value/number.py +++ b/src/excelalchemy/types/value/number.py @@ -8,7 +8,6 @@ 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( diff --git a/src/excelalchemy/types/value/number_range.py b/src/excelalchemy/types/value/number_range.py index 9494731..f749374 100644 --- a/src/excelalchemy/types/value/number_range.py +++ b/src/excelalchemy/types/value/number_range.py @@ -15,7 +15,6 @@ class NumberRange(ComplexABCValueType): __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) @@ -44,7 +43,6 @@ def serialize(cls, value: dict[str, str] | str | Any, field_meta: FieldMetaInfo) # 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: diff --git a/src/excelalchemy/util/file.py b/src/excelalchemy/util/file.py index 96ff90c..20e37a1 100644 --- a/src/excelalchemy/util/file.py +++ b/src/excelalchemy/util/file.py @@ -41,7 +41,6 @@ def read_file_from_minio_object( filename: str, ) -> IO[bytes]: """ "Read file content by object.""" - # pyright: reportUnknownMemberType=false response: BaseHTTPResponse = client.get_object(bucket_name, filename) return construct_file_like_object(response) @@ -56,10 +55,8 @@ def upload_file_from_minio_object( """把文件上传到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: reportUnknownVariableType=false bucket_name, filename, expires=timedelta(seconds=expires), @@ -73,11 +70,9 @@ def flatten(data: dict[str, Any], level: list[Any] | None = None) -> dict[str, A {'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 diff --git a/tests/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index 2c50aed..4f0f753 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -330,7 +330,7 @@ async def test_export_returns_simple_header_dataframe_for_flat_model(self): 'name': '张三', 'address': '北京市朝阳区', 'is_active': True, - 'birth_date': datetime.datetime.utcnow(), + 'birth_date': datetime.datetime.now(datetime.UTC), 'email': 'norepy@icloud.com', 'price': 100.0, 'web': 'https://www.baidu.com', @@ -372,7 +372,7 @@ async def test_export_detects_merged_header_layout_for_composite_fields(self): 'name': '张三', 'address': '北京市朝阳区', 'is_active': True, - 'birth_date': datetime.datetime.utcnow(), + 'birth_date': datetime.datetime.now(datetime.UTC), 'email': 'norepy@icloud.com', 'price': 100.0, 'web': 'https://www.baidu.com', From 4b01359da0fa0b3b8f2cf571ce01a631b95f24f2 Mon Sep 17 00:00:00 2001 From: ruicore Date: Tue, 24 Mar 2026 09:40:33 +0800 Subject: [PATCH 10/27] feat(PR-07): update structure and syntax --- src/excelalchemy/const.py | 20 +- src/excelalchemy/core/abstract.py | 31 +- src/excelalchemy/core/alchemy.py | 626 ++++-------------- src/excelalchemy/core/executor.py | 112 ++++ src/excelalchemy/core/headers.py | 103 +++ src/excelalchemy/core/rendering.py | 38 ++ src/excelalchemy/core/rows.py | 138 ++++ src/excelalchemy/core/schema.py | 128 ++++ src/excelalchemy/core/storage.py | 55 ++ src/excelalchemy/helper/pydantic.py | 13 +- src/excelalchemy/types/alchemy.py | 14 +- src/excelalchemy/types/field.py | 8 +- .../test_core_components_contract.py | 61 ++ 13 files changed, 788 insertions(+), 559 deletions(-) create mode 100644 src/excelalchemy/core/executor.py create mode 100644 src/excelalchemy/core/headers.py create mode 100644 src/excelalchemy/core/rendering.py create mode 100644 src/excelalchemy/core/rows.py create mode 100644 src/excelalchemy/core/schema.py create mode 100644 src/excelalchemy/core/storage.py create mode 100644 tests/contracts/test_core_components_contract.py diff --git a/src/excelalchemy/const.py b/src/excelalchemy/const.py index aee3686..e83c11e 100644 --- a/src/excelalchemy/const.py +++ b/src/excelalchemy/const.py @@ -1,8 +1,6 @@ from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, List, Set, TypeVar, Union - -from pydantic import BaseModel +from typing import Any from excelalchemy.types.identity import Key, Label, OptionId @@ -45,17 +43,11 @@ 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) +type DictStrAny = dict[str, Any] +type DictAny = dict[Any, Any] +type SetStr = set[str] +type ListStr = list[str] +type IntStr = int | str class CharacterSet(str, Enum): diff --git a/src/excelalchemy/core/abstract.py b/src/excelalchemy/core/abstract.py index 7f1637e..a1ca0af 100644 --- a/src/excelalchemy/core/abstract.py +++ b/src/excelalchemy/core/abstract.py @@ -1,29 +1,20 @@ from abc import ABC, abstractmethod -from typing import Any, Generic +from typing import Any + +from pydantic import BaseModel -from excelalchemy.const import ( - ContextT, - CreateModelT, - ExporterModelT, - ImporterCreateModelT, - ImporterUpdateModelT, - UpdateModelT, -) from excelalchemy.types.identity import Base64Str, Key, UrlStr from excelalchemy.types.result import ImportResult -class ABCExcelAlchemy( - ABC, - Generic[ - ContextT, - ImporterCreateModelT, - ImporterUpdateModelT, - CreateModelT, - UpdateModelT, - ExporterModelT, - ], -): +class ABCExcelAlchemy[ + ContextT, + ImporterCreateModelT: BaseModel, + ImporterUpdateModelT: BaseModel, + CreateModelT: BaseModel, + UpdateModelT: BaseModel, + ExporterModelT: BaseModel, +](ABC): @abstractmethod def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> str: """下载导入模版, Excel 字段顺序与定义的导出模型一致""" diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 9eee855..5a100c3 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -1,63 +1,54 @@ -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, Awaitable, Callable, Generator, Iterable, Type, cast +from typing import Any, Callable, Iterable, cast -import pandas from pandas import DataFrame, concat from pydantic import BaseModel from excelalchemy.const import ( - DEFAULT_FIELD_META_ORDER, REASON_COLUMN_KEY, REASON_COLUMN_LABEL, RESULT_COLUMN_KEY, RESULT_COLUMN_LABEL, - ContextT, - CreateModelT, - ExporterModelT, - ImporterCreateModelT, - ImporterUpdateModelT, - UpdateModelT, ) from excelalchemy.core.abstract import ABCExcelAlchemy -from excelalchemy.core.writer import render_data_excel, render_merged_header_excel, render_simple_header_excel +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 MinioStorageGateway from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError -from excelalchemy.helper.pydantic import extract_pydantic_model, get_model_field_names, instantiate_pydantic_model +from excelalchemy.helper.pydantic import get_model_field_names from excelalchemy.types.abstract import SystemReserved from excelalchemy.types.alchemy import ExcelMode, ExporterConfig, ImporterConfig, ImportMode from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.header import ExcelHeader -from excelalchemy.types.identity import Base64Str, ColumnIndex, Key, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr -from excelalchemy.types.result import ImportResult, ValidateHeaderResult, ValidateResult, ValidateRowResult -from excelalchemy.util.file import ( - flatten, - read_file_from_minio_object, - remove_excel_prefix, - upload_file_from_minio_object, -) +from excelalchemy.types.identity import Base64Str, Key, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr +from excelalchemy.types.result import ImportResult, ValidateHeaderResult, ValidateResult +from excelalchemy.util.file import flatten -HEADER_HINT_LINE_COUNT = 1 # HEADER_HINT 占用的行数 +HEADER_HINT_LINE_COUNT = 1 -# 导入结果的字段元数据, 依据产品定义,占两列 -# 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( +class ExcelAlchemy[ + ContextT, + ImporterCreateModelT: BaseModel, + ImporterUpdateModelT: BaseModel, + CreateModelT: BaseModel, + UpdateModelT: BaseModel, + ExporterModelT: BaseModel, +]( ABCExcelAlchemy[ ContextT, ImporterCreateModelT, @@ -65,72 +56,55 @@ class ExcelAlchemy( 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.df = DataFrame() + self.header_df = DataFrame() + self.config = config + self.context: ContextT | None = None + self.__state_df_has_been_loaded__ = False + 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 + self.import_result_label_to_field_meta = { + field_meta.unique_label: field_meta for field_meta 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._header_parser = ExcelHeaderParser() + self._header_validator = ExcelHeaderValidator() + self._renderer = ExcelRenderer() + self._storage_gateway = MinioStorageGateway(config) + self._layout: ExcelSchemaLayout + self._issue_tracker: ImportIssueTracker | None = None + self._row_aggregator: RowAggregator | None = None + self._executor: ImportExecutor[ContextT] | None = None - 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 + model = self.__get_importer_model__() + 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 __get_importer_model__(self) -> type[ImporterCreateModelT] | type[ImporterUpdateModelT] | type[ExporterModelT]: importer_model = None @@ -141,7 +115,6 @@ def __get_importer_model__(self) -> type[ImporterCreateModelT] | type[ImporterUp 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__}') @@ -149,51 +122,38 @@ def __get_importer_model__(self) -> type[ImporterCreateModelT] | type[ImporterUp 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) + return self._renderer.render_template(df, self.unique_label_to_field_meta, has_merged_header=has_merged_header) async def import_data(self, input_excel_name: str, output_excel_name: str) -> ImportResult: - """导入数据""" - assert isinstance(self.config, ImporterConfig) # only for type check + assert isinstance(self.config, ImporterConfig) + assert self._executor is not None if self.excel_mode != ExcelMode.IMPORT: raise ConfigError('只支持导入模式调用此方法') - validate_header = self._validate_header(input_excel_name) # 验证表头 + 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.df = self.df.iloc[1:] self._set_columns(self.df) - self.df = self.df.reset_index(drop=True) # 重置索引 + 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) + success = await self._executor.execute(cast(RowIndex, pandas_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) @@ -202,6 +162,7 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im 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, @@ -210,253 +171,130 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im ) 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( + return self._renderer.render_data( df, - errors={}, # 数据导出没有错误 field_meta_mapping=self.unique_label_to_field_meta, has_merged_header=has_merged_header, + errors={}, ) 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) + 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('已经存在旧的转换模型上下文, 旧的上下文将被替换, 请确认此操作符合预期') - 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() + return self._header_parser.has_merged_header(self.header_df) @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() + 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: - # 执行导入时,预期额外的表头行数, 有合并单元格为 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]: + 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) + 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) + 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 + return self._layout.has_merged_header(selected_keys) 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] + 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]: - """导出的 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] + return self._layout.get_output_child_excel_headers(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])) + input_keys = keys or list( + filter(None, [cast(Key | None, field_meta.parent_key) for field_meta in self.ordered_field_meta]) + ) model_keys = cast(list[Key], get_model_field_names(self.exporter_model)) 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) + 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('只支持导入模式调用此方法') - assert isinstance(self.config, ImporterConfig) # only for type hint, not for runtime + assert isinstance(self.config, ImporterConfig) 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), - ) + return self._header_validator.validate(self.input_excel_headers, self._layout, self.config.import_mode) def _render_import_result_excel(self) -> str: - """执行导入后,渲染数据""" - content_with_prefix = render_data_excel( + return self._renderer.render_data( 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, + errors=self.cell_errors, ) - 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 - if self.config.minio is None: - raise ConfigError('未配置 minio') - 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) + return self._storage_gateway.upload_excel(output_name, content_with_prefix) 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) + return self._layout.order_errors(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 + return self._header_parser.apply_columns(df, self.input_excel_headers, self.get_output_parent_excel_headers()) 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, - ), - ) + return self._layout.select_output_excel_keys(keys) - def _read_dataframe(self, input_excel_name: str) -> pandas.DataFrame: - """读取 DataFrame""" - assert isinstance(self.config, ImporterConfig) # only for type check + def _read_dataframe(self, input_excel_name: str) -> DataFrame: + assert isinstance(self.config, ImporterConfig) if not self.__state_df_has_been_loaded__: - if self.config.minio is None: - raise ConfigError('未配置 minio') - file_object = read_file_from_minio_object( - self.config.minio, - self.config.bucket_name, + df = self._storage_gateway.read_excel_dataframe( input_excel_name, - ) - - df: DataFrame = pandas.read_excel( - cast(PathLike[str], file_object), # cast to cheat type check + skiprows=HEADER_HINT_LINE_COUNT, 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.header_df = df.head(2) self.__state_df_has_been_loaded__ = True return self.df @@ -466,21 +304,19 @@ def _generate_export_df( selected_keys: list[UniqueKey], data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = None, ) -> DataFrame: - """生成导出的 DataFrame""" - rst = [] - if records is None: - records = [] + rows = [] + records = records or [] 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] + 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) + rows.append(row) - return DataFrame(columns=self.get_output_parent_excel_headers(selected_keys), data=rst) + return DataFrame(columns=self.get_output_parent_excel_headers(selected_keys), data=rows) def _export_with_merged_header( self, @@ -488,12 +324,9 @@ def _export_with_merged_header( 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 + header_df = DataFrame(columns=data_df.columns, data=[self.get_output_child_excel_headers(selected_keys)]) + return concat([header_df, data_df], ignore_index=True) def _export_with_simple_header( self, @@ -501,269 +334,46 @@ def _export_with_simple_header( 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, + assert self._issue_tracker is not None + self._issue_tracker.add_result_columns( + self.df, + result_unique_label=RESULT_COLUMN.unique_label, + reason_unique_label=REASON_COLUMN.unique_label, + extra_header_count_on_import=self.extra_header_count_on_import, ) - - 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.model_dump(exclude_unset=True)) - else: - converted_data = importer_instance.model_dump(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) + return self 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 定义不正确') + 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], ): - """注册行错误""" - if isinstance(error, list): - self.row_errors[row_index].extend(error) - else: - self.row_errors[row_index].append(error) + 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]): - """注册单元格错误""" - 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) + assert self._issue_tracker is not None + self._issue_tracker.register_cell_errors(row_index, errors, self.df) 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')) + return self._header_parser.has_merged_header(self.header_df) def _extract_header(self) -> list[ExcelHeader]: - """提取表头信息""" - if self._excel_has_merged_header(): - return self._extract_merged_header() - else: - return self._extract_simple_header() + return self._header_parser.extract(self.header_df) 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()] + return self._header_parser._extract_simple(self.header_df) 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 + return self._header_parser._extract_merged(self.header_df) def __setattr__(self, key: str, value: Any): if key == 'config' and hasattr(self, 'config'): diff --git a/src/excelalchemy/core/executor.py b/src/excelalchemy/core/executor.py new file mode 100644 index 0000000..3f5dbd6 --- /dev/null +++ b/src/excelalchemy/core/executor.py @@ -0,0 +1,112 @@ +"""Import execution helpers for create, update, and upsert flows.""" + +from typing import Any, Awaitable, Callable + +from pandas import DataFrame +from pydantic import BaseModel + +from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.helper.pydantic import instantiate_pydantic_model +from excelalchemy.types.alchemy import ImporterConfig, ImportMode +from excelalchemy.types.identity import Key, RowIndex + +from .rows import ImportIssueTracker + + +class ImportExecutor[ContextT]: + """Execute import-side DML while keeping validation and error mapping isolated.""" + + def __init__( + self, + config: ImporterConfig[Any, Any, Any], + issue_tracker: ImportIssueTracker, + get_context: Callable[[], ContextT | None], + ): + self.config = config + self.issue_tracker = issue_tracker + self.get_context = get_context + + async def execute(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame) -> 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(f'不支持的导入模式: {self.config.import_mode}') + + async def _create(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame) -> bool: + 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._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: dict[Key, Any], df: DataFrame) -> bool: + 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._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: dict[Key, Any], df: DataFrame) -> bool: + if self.config.is_data_exist is None: + raise ConfigError('未配置 is_data_exists') + + 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: dict[Key, Any], + df: DataFrame, + 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: + """Validate one row payload and call the user-supplied DML function.""" + 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.issue_tracker.register_row_error(row_index, errors) + self.issue_tracker.register_cell_errors(row_index, errors, df) + return False + + importer_instance = importer_instance_or_errors + converted_data = importer_instance.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) + 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..62529ec --- /dev/null +++ b/src/excelalchemy/core/headers.py @@ -0,0 +1,103 @@ +"""Header parsing and validation helpers for import workbooks.""" + +import pandas +from pandas import DataFrame + +from excelalchemy.exc import ConfigError +from excelalchemy.types.alchemy import ImportMode +from excelalchemy.types.header import ExcelHeader +from excelalchemy.types.identity import Label, UniqueLabel +from excelalchemy.types.result import ValidateHeaderResult + +from .schema import ExcelSchemaLayout + + +class ExcelHeaderParser: + """Parse raw worksheet header rows into normalized header objects.""" + + def has_merged_header(self, header_df: DataFrame) -> bool: + """Detect whether the workbook uses a merged two-row header.""" + return any(pandas.isna(header_df.iloc[0])) or any(header_df.iloc[0].str.startswith('Unnamed')) + + def extract(self, header_df: DataFrame) -> 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: DataFrame) -> 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: DataFrame) -> 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] # 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 apply_columns(self, df: DataFrame, headers: list[ExcelHeader], allowed_labels: list[UniqueLabel]) -> DataFrame: + """Assign normalized unique labels as DataFrame columns.""" + columns: list[UniqueLabel] = [] + for header in headers: + if header.unique_label not in allowed_labels: + raise ConfigError(f'不支持的列名: {header.unique_label}') + columns.append(header.unique_label) + + df.columns = columns # type: ignore[assignment] + 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.label for field_meta in layout.ordered_field_meta if field_meta.required] + primary_labels = [field_meta.label for field_meta in layout.ordered_field_meta if field_meta.is_primary_key] + input_labels = [header.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) + unrecognized = list(set(input_labels) - set(field_meta.label for field_meta in layout.ordered_field_meta)) + + missing_primary: list[Label] = [] + if 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), + ) diff --git a/src/excelalchemy/core/rendering.py b/src/excelalchemy/core/rendering.py new file mode 100644 index 0000000..63fdca2 --- /dev/null +++ b/src/excelalchemy/core/rendering.py @@ -0,0 +1,38 @@ +"""High-level rendering helpers built on top of the low-level writer module.""" + +from typing import cast + +from pandas import DataFrame + +from excelalchemy.core.writer import render_data_excel, render_merged_header_excel, render_simple_header_excel +from excelalchemy.exc import ExcelCellError +from excelalchemy.types.field import FieldMetaInfo +from excelalchemy.types.identity import Base64Str, ColumnIndex, RowIndex, UniqueLabel + + +class ExcelRenderer: + """Render templates and result workbooks for the facade layer.""" + + def render_template( + self, df: DataFrame, field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], *, has_merged_header: bool + ) -> Base64Str: + """Render a template workbook with either a simple or merged header layout.""" + if has_merged_header: + return cast(Base64Str, render_merged_header_excel(df, field_meta_mapping)) + return cast(Base64Str, render_simple_header_excel(df, field_meta_mapping)) + + def render_data( + self, + df: DataFrame, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, + has_merged_header: bool, + errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]] | None = None, + ) -> Base64Str: + """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..5d10a7a --- /dev/null +++ b/src/excelalchemy/core/rows.py @@ -0,0 +1,138 @@ +"""Row aggregation and import issue tracking helpers.""" + +from collections import defaultdict +from typing import Any, cast + +import pandas +from pandas import DataFrame + +from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.types.alchemy import ImportMode +from excelalchemy.types.field import FieldMetaInfo +from excelalchemy.types.identity import ColumnIndex, Key, RowIndex, UniqueLabel +from excelalchemy.types.result import ValidateRowResult + +from .schema import ExcelSchemaLayout + + +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: dict[UniqueLabel, Any]) -> dict[Key, Any]: + """Aggregate one worksheet row into a serializer-ready payload.""" + return self._serialize(self._aggregate(row_data)) + + def _aggregate(self, row_data: dict[UniqueLabel, Any]) -> dict[Key, Any]: + aggregated: dict[Key, Any] = {} + for unique_label, value in row_data.items(): + 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(f'{type(field_meta).__name__} 未配置 key/parent_key') + + if pandas.isna(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[field_meta.key] = value + else: + aggregated.setdefault(field_meta.parent_key, {}) + aggregated[field_meta.parent_key][field_meta.key] = value + return aggregated + + def _serialize(self, aggregated: dict[Key, Any]) -> dict[Key, Any]: + serialized: dict[Key, Any] = {} + for parent_key, value in aggregated.items(): + field_metas = self.layout.parent_key_to_field_metas[parent_key] + validator = field_metas[0] + if value is None: + serialized[parent_key] = None + else: + serialized[parent_key] = validator.value_type.serialize(value, validator) + 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: DataFrame) -> 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: DataFrame, + *, + 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(index) + if not row_errors: + result.append(str(ValidateRowResult.SUCCESS)) + reason.append('') + continue + + result.append(str(ValidateRowResult.FAIL)) + numbered_reasons = [ + f'{idx}、{str(error)}' 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: DataFrame, unique_label: UniqueLabel): + 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(f'找不到 {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: DataFrame, unique_label: UniqueLabel): + index = df.columns.get_loc(unique_label) + if isinstance(index, int): + yield ColumnIndex(index) + return + raise ValueError(f'找不到 {unique_label} 对应的列, 推测是 value_type 定义不正确') diff --git a/src/excelalchemy/core/schema.py b/src/excelalchemy/core/schema.py new file mode 100644 index 0000000..485b9b6 --- /dev/null +++ b/src/excelalchemy/core/schema.py @@ -0,0 +1,128 @@ +"""Schema layout helpers used by the ExcelAlchemy facade.""" + +import itertools +from collections import defaultdict +from decimal import Decimal +from itertools import chain +from typing import Iterable, cast + +from pydantic import BaseModel + +from excelalchemy.const import DEFAULT_FIELD_META_ORDER +from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.helper.pydantic import extract_pydantic_model +from excelalchemy.types.field import FieldMetaInfo +from excelalchemy.types.identity import Key, Label, UniqueKey, UniqueLabel + + +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('没有提取到字段元数据,请检查模型是否定义了字段') + + 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(f'没有从模型 {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('父标签不能为空') + 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 + + @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(f'字段顺序定义有重复:{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 DataFrame 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: list[Key] | 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 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 [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..02ada7b --- /dev/null +++ b/src/excelalchemy/core/storage.py @@ -0,0 +1,55 @@ +"""Storage adapters used by ExcelAlchemy to read and upload workbooks.""" + +from os import PathLike +from typing import BinaryIO, cast + +import pandas +from pandas import DataFrame + +from excelalchemy.exc import ConfigError +from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig +from excelalchemy.types.identity import UrlStr +from excelalchemy.util.file import read_file_from_minio_object, remove_excel_prefix, upload_file_from_minio_object + + +class MinioStorageGateway: + """Small gateway around the Minio-backed workbook IO helpers.""" + + def __init__(self, config: ImporterConfig | ExporterConfig): + self.config = config + + def read_excel_dataframe(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> DataFrame: + """Read one Excel object from Minio into a DataFrame.""" + if self.config.minio is None: + raise ConfigError('未配置 minio') + + file_object = read_file_from_minio_object( + self.config.minio, + self.config.bucket_name, + input_excel_name, + ) + + try: + return pandas.read_excel( + cast(PathLike[str], file_object), + sheet_name=sheet_name, + skiprows=skiprows, + header=None, + dtype=str, + engine='openpyxl', + ) + 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('未配置 minio') + 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) diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index 6ffb831..b410cef 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -1,21 +1,20 @@ from dataclasses import dataclass from types import UnionType -from typing import Any, Generator, Iterable, TypeVar, cast, get_args, get_origin +from typing import Any, Generator, Iterable, cast, get_args, get_origin from pydantic import BaseModel from pydantic.fields import FieldInfo, PydanticUndefined -from excelalchemy.const import ImporterCreateModelT, ImporterUpdateModelT from excelalchemy.exc import ExcelCellError, ProgrammaticError from excelalchemy.types.abstract import ABCValueType, ComplexABCValueType from excelalchemy.types.field import FieldMetaInfo, extract_declared_field_metadata from excelalchemy.types.identity import Key -ModelT = TypeVar('ModelT', bound=BaseModel) - @dataclass(frozen=True) class PydanticFieldAdapter: + """Provide a stable view over one Pydantic field.""" + name: str raw_field: FieldInfo @@ -79,6 +78,8 @@ def validate_value(self, raw_value: Any) -> Any: @dataclass(frozen=True) class PydanticModelAdapter: + """Expose a small, version-friendly API over a Pydantic model class.""" + model: type[BaseModel] def fields(self) -> Iterable[PydanticFieldAdapter]: @@ -95,7 +96,7 @@ def field_names(self) -> list[str]: def extract_pydantic_model( - model: type[ImporterCreateModelT] | type[ImporterUpdateModelT] | type[BaseModel] | None, + model: type[BaseModel] | None, ) -> list[FieldMetaInfo]: """根据 Pydantic 模型提取 Excel 表头信息.""" if model is None: @@ -107,7 +108,7 @@ def get_model_field_names(model: type[BaseModel]) -> list[str]: return PydanticModelAdapter(model).field_names() -def instantiate_pydantic_model( # noqa: C901 +def instantiate_pydantic_model[ModelT: BaseModel]( # noqa: C901 data: dict[Key, Any], model: type[ModelT], ) -> ModelT | list[ExcelCellError]: diff --git a/src/excelalchemy/types/alchemy.py b/src/excelalchemy/types/alchemy.py index 89f186d..ed3f92e 100644 --- a/src/excelalchemy/types/alchemy.py +++ b/src/excelalchemy/types/alchemy.py @@ -2,11 +2,11 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Awaitable, Callable, Generic, Literal, Type +from typing import Any, Awaitable, Callable, Literal from minio import Minio +from pydantic import BaseModel -from excelalchemy.const import ContextT, ExporterModelT, ImporterCreateModelT, ImporterUpdateModelT from excelalchemy.exc import ConfigError from excelalchemy.helper.pydantic import get_model_field_names from excelalchemy.util.convertor import export_data_converter, import_data_converter @@ -26,9 +26,9 @@ class ImportMode(str, Enum): @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) +class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateModelT: BaseModel]: + 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) @@ -95,8 +95,8 @@ def __post_init__(self): @dataclass -class ExporterConfig(Generic[ExporterModelT]): - exporter_model: Type[ExporterModelT] +class ExporterConfig[ExporterModelT: BaseModel]: + 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) diff --git a/src/excelalchemy/types/field.py b/src/excelalchemy/types/field.py index 77bf1ca..3582689 100644 --- a/src/excelalchemy/types/field.py +++ b/src/excelalchemy/types/field.py @@ -4,7 +4,7 @@ import datetime import logging from functools import cached_property -from typing import AbstractSet, Any, Callable, Optional, Union +from typing import AbstractSet, Any, Callable from pydantic import BaseModel, Field from pydantic.fields import FieldInfo, PydanticUndefined @@ -317,12 +317,12 @@ def FieldMeta( options: list[Option] | None = None, unit: str | None = None, hint: str | None = None, - default_factory: Optional[Callable[[], Any]] = None, + default_factory: Callable[[], Any] | None = 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, + exclude: AbstractSet[IntStr] | Any = None, + include: AbstractSet[IntStr] | Any = None, const: bool | None = None, ge: float | None = None, le: float | None = None, diff --git a/tests/contracts/test_core_components_contract.py b/tests/contracts/test_core_components_contract.py new file mode 100644 index 0000000..f2fcb04 --- /dev/null +++ b/tests/contracts/test_core_components_contract.py @@ -0,0 +1,61 @@ +from pandas import DataFrame + +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.exc import ExcelCellError +from excelalchemy.types.alchemy import ImportMode +from excelalchemy.types.identity import Key, Label, RowIndex +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 = DataFrame([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_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 = DataFrame(columns=['姓名'], data=[['张三']]) + error = ExcelCellError(label=Label('姓名'), message='模拟失败') + + tracker.register_cell_errors(RowIndex(0), [error], df) + + assert tracker.cell_errors == { + RowIndex(0): { + 2: [error], + } + } From 8b4cd9e9063b0a1a2ab3cc62d8d3c4f6bf9ff0f7 Mon Sep 17 00:00:00 2001 From: ruicore Date: Wed, 25 Mar 2026 14:24:27 +0800 Subject: [PATCH 11/27] feat(PR-08): remove pandas --- pyproject.toml | 6 +- src/excelalchemy/core/alchemy.py | 27 +- src/excelalchemy/core/executor.py | 12 +- src/excelalchemy/core/headers.py | 34 +- src/excelalchemy/core/rendering.py | 7 +- src/excelalchemy/core/rows.py | 15 +- src/excelalchemy/core/schema.py | 2 +- src/excelalchemy/core/storage.py | 51 +- src/excelalchemy/core/table.py | 139 ++++++ src/excelalchemy/core/writer.py | 444 ++++++++---------- src/excelalchemy/types/abstract.py | 4 +- src/excelalchemy/util/file.py | 14 +- .../test_core_components_contract.py | 7 +- tests/contracts/test_storage_contract.py | 41 ++ tests/support/mock_minio.py | 84 +++- .../value_types/test_date_range_value_type.py | 4 +- 16 files changed, 540 insertions(+), 351 deletions(-) create mode 100644 src/excelalchemy/core/table.py diff --git a/pyproject.toml b/pyproject.toml index 4c490b7..e174400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = 'A Python library for reading and writing Excel files with Pydanti authors = [{ name = 'Ray', email = 'hrui835@gmail.com' }] readme = 'README.md' license = { file = 'LICENSE' } -keywords = ['excel', 'openpyxl', 'pandas', 'pydantic', 'minio'] +keywords = ['excel', 'openpyxl', 'pydantic', 'minio', 'schema'] classifiers = [ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', @@ -26,11 +26,11 @@ classifiers = [ dynamic = ['version'] requires-python = '>=3.12' dependencies = [ - 'pandas >=2.3.3, <4', 'minio >=7.2.20, <8', 'pydantic[email] >=2.12, <3', 'openpyxl >=3.1.5, <4', 'pendulum >=3.2.0, <4', + "flit>=3.12.0", ] [tool.flit.module] @@ -45,7 +45,6 @@ Issues = 'https://github.com/RayCarterLab/ExcelAlchemy/issues' [project.optional-dependencies] development = [ 'build', - 'pandas-stubs', 'nox', 'pre-commit', 'pyright==1.1.408', @@ -66,7 +65,6 @@ exclude = [ '**/.pytest_cache', 'src/excelalchemy/types/field.py', ] -ignore = ['pandas'] enableTypeIgnoreComments = false reportAbstractUsage = false reportArgumentType = false diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 5a100c3..934cd50 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -2,7 +2,6 @@ from functools import cached_property from typing import Any, Callable, Iterable, cast -from pandas import DataFrame, concat from pydantic import BaseModel from excelalchemy.const import ( @@ -18,6 +17,7 @@ from excelalchemy.core.rows import ImportIssueTracker, RowAggregator from excelalchemy.core.schema import ExcelSchemaLayout from excelalchemy.core.storage import MinioStorageGateway +from excelalchemy.core.table import WorksheetTable from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError from excelalchemy.helper.pydantic import get_model_field_names from excelalchemy.types.abstract import SystemReserved @@ -62,8 +62,8 @@ def __init__( self, config: ImporterConfig[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | ExporterConfig[ExporterModelT], ): - self.df = DataFrame() - self.header_df = DataFrame() + self.df = WorksheetTable() + self.header_df = WorksheetTable() self.config = config self.context: ContextT | None = None self.__state_df_has_been_loaded__ = False @@ -151,9 +151,9 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im 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(): + for table_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._executor.execute(cast(RowIndex, pandas_row_index), aggregate_data, self.df) + success = await self._executor.execute(cast(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) @@ -239,7 +239,7 @@ def get_output_parent_excel_headers(self, selected_keys: list[UniqueKey] | None 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[dict[str, Any]], keys: list[Key] | None = None) -> tuple[DataFrame, bool]: + def _gen_export_df(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> tuple[WorksheetTable, bool]: if self.excel_mode == ExcelMode.IMPORT: logging.info('导出模式为导入模式, 调用导出方法时自动切换为导出模式') @@ -279,13 +279,13 @@ def _upload_file(self, output_name: str, content_with_prefix: str) -> UrlStr: def _order_errors(self, errors: list[ExcelRowError | ExcelCellError]) -> Iterable[ExcelCellError | ExcelRowError]: return self._layout.order_errors(errors) - def _set_columns(self, df: DataFrame) -> DataFrame: + 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: list[Key] | None = None) -> list[UniqueKey]: return self._layout.select_output_excel_keys(keys) - def _read_dataframe(self, input_excel_name: str) -> DataFrame: + 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_dataframe( @@ -303,7 +303,7 @@ def _generate_export_df( records: list[dict[str, Any]] | None, selected_keys: list[UniqueKey], data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = None, - ) -> DataFrame: + ) -> WorksheetTable: rows = [] records = records or [] for record in records: @@ -316,24 +316,23 @@ def _generate_export_df( row[field_meta.unique_label] = field_meta.value_type.deserialize(value, field_meta) rows.append(row) - return DataFrame(columns=self.get_output_parent_excel_headers(selected_keys), data=rows) + return WorksheetTable(columns=self.get_output_parent_excel_headers(selected_keys), rows=rows) 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: + ) -> WorksheetTable: data_df = self._generate_export_df(records, selected_keys, data_converter) - header_df = DataFrame(columns=data_df.columns, data=[self.get_output_child_excel_headers(selected_keys)]) - return concat([header_df, data_df], ignore_index=True) + return data_df.with_prepended_rows([self.get_output_child_excel_headers(selected_keys)]) 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: + ) -> WorksheetTable: return self._generate_export_df(records, selected_keys, data_converter) def _add_result_column(self): diff --git a/src/excelalchemy/core/executor.py b/src/excelalchemy/core/executor.py index 3f5dbd6..6aca50b 100644 --- a/src/excelalchemy/core/executor.py +++ b/src/excelalchemy/core/executor.py @@ -2,7 +2,6 @@ from typing import Any, Awaitable, Callable -from pandas import DataFrame from pydantic import BaseModel from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError @@ -11,6 +10,7 @@ from excelalchemy.types.identity import Key, RowIndex from .rows import ImportIssueTracker +from .table import WorksheetTable class ImportExecutor[ContextT]: @@ -26,7 +26,7 @@ def __init__( self.issue_tracker = issue_tracker self.get_context = get_context - async def execute(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame) -> bool: + async def execute(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: """Dispatch one aggregated row to the configured import mode handler.""" match self.config.import_mode: case ImportMode.CREATE: @@ -37,7 +37,7 @@ async def execute(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame return await self._create_or_update(row_index, data, df) raise ConfigError(f'不支持的导入模式: {self.config.import_mode}') - async def _create(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame) -> bool: + async def _create(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: if self.config.creator is None: raise ConfigError('未配置 creator') if self.config.create_importer_model is None: @@ -52,7 +52,7 @@ async def _create(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame self.config.exec_formatter, ) - async def _update(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame) -> bool: + async def _update(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: if self.config.updater is None: raise ConfigError('未配置 updater') if self.config.update_importer_model is None: @@ -67,7 +67,7 @@ async def _update(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame self.config.exec_formatter, ) - async def _create_or_update(self, row_index: RowIndex, data: dict[Key, Any], df: DataFrame) -> bool: + async def _create_or_update(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: if self.config.is_data_exist is None: raise ConfigError('未配置 is_data_exists') @@ -81,7 +81,7 @@ async def _invoke_dml( self, row_index: RowIndex, data: dict[Key, Any], - df: DataFrame, + df: WorksheetTable, importer_model: type[BaseModel], dml_func: Callable[[dict[str, Any], ContextT | None], Awaitable[Any]], data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None, diff --git a/src/excelalchemy/core/headers.py b/src/excelalchemy/core/headers.py index 62529ec..ade28f0 100644 --- a/src/excelalchemy/core/headers.py +++ b/src/excelalchemy/core/headers.py @@ -1,13 +1,12 @@ """Header parsing and validation helpers for import workbooks.""" -import pandas -from pandas import DataFrame - +from excelalchemy.core.table import WorksheetTable from excelalchemy.exc import ConfigError from excelalchemy.types.alchemy import ImportMode from excelalchemy.types.header import ExcelHeader from excelalchemy.types.identity import Label, UniqueLabel from excelalchemy.types.result import ValidateHeaderResult +from excelalchemy.util.file import value_is_nan from .schema import ExcelSchemaLayout @@ -15,29 +14,31 @@ class ExcelHeaderParser: """Parse raw worksheet header rows into normalized header objects.""" - def has_merged_header(self, header_df: DataFrame) -> bool: + def has_merged_header(self, header_df: WorksheetTable) -> bool: """Detect whether the workbook uses a merged two-row header.""" - return any(pandas.isna(header_df.iloc[0])) or any(header_df.iloc[0].str.startswith('Unnamed')) + 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: DataFrame) -> list[ExcelHeader]: + 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: DataFrame) -> list[ExcelHeader]: + 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: DataFrame) -> list[ExcelHeader]: + 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] # type: ignore[call-overload] - if pandas.isna(parent_value) or parent_value.startswith('Unnamed'): - if pandas.isna(child_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('合并表头错误: 子表头不能为空') current_header = ExcelHeader( label=Label(child_value), @@ -46,7 +47,7 @@ def _extract_merged(self, header_df: DataFrame) -> list[ExcelHeader]: ) next_offset += 1 else: - if pandas.isna(child_value): + 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 = value, 1 @@ -54,8 +55,13 @@ def _extract_merged(self, header_df: DataFrame) -> list[ExcelHeader]: return headers - def apply_columns(self, df: DataFrame, headers: list[ExcelHeader], allowed_labels: list[UniqueLabel]) -> DataFrame: - """Assign normalized unique labels as DataFrame columns.""" + 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: diff --git a/src/excelalchemy/core/rendering.py b/src/excelalchemy/core/rendering.py index 63fdca2..41082d0 100644 --- a/src/excelalchemy/core/rendering.py +++ b/src/excelalchemy/core/rendering.py @@ -2,8 +2,7 @@ from typing import cast -from pandas import DataFrame - +from excelalchemy.core.table import WorksheetTable from excelalchemy.core.writer import render_data_excel, render_merged_header_excel, render_simple_header_excel from excelalchemy.exc import ExcelCellError from excelalchemy.types.field import FieldMetaInfo @@ -14,7 +13,7 @@ class ExcelRenderer: """Render templates and result workbooks for the facade layer.""" def render_template( - self, df: DataFrame, field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], *, has_merged_header: bool + self, df: WorksheetTable, field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], *, has_merged_header: bool ) -> Base64Str: """Render a template workbook with either a simple or merged header layout.""" if has_merged_header: @@ -23,7 +22,7 @@ def render_template( def render_data( self, - df: DataFrame, + df: WorksheetTable, field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], *, has_merged_header: bool, diff --git a/src/excelalchemy/core/rows.py b/src/excelalchemy/core/rows.py index 5d10a7a..56ae66e 100644 --- a/src/excelalchemy/core/rows.py +++ b/src/excelalchemy/core/rows.py @@ -3,16 +3,15 @@ from collections import defaultdict from typing import Any, cast -import pandas -from pandas import DataFrame - from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError from excelalchemy.types.alchemy import ImportMode from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import ColumnIndex, Key, RowIndex, UniqueLabel from excelalchemy.types.result import ValidateRowResult +from excelalchemy.util.file import value_is_nan from .schema import ExcelSchemaLayout +from .table import WorksheetTable class RowAggregator: @@ -34,7 +33,7 @@ def _aggregate(self, row_data: dict[UniqueLabel, Any]) -> dict[Key, Any]: 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 value_is_nan(value): if self.import_mode in {ImportMode.UPDATE, ImportMode.CREATE_OR_UPDATE}: value = None else: @@ -79,7 +78,7 @@ def register_row_error( else: self.row_errors[row_index].append(error) - def register_cell_errors(self, row_index: RowIndex, errors: list[ExcelCellError], df: DataFrame) -> None: + 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): @@ -88,7 +87,7 @@ def register_cell_errors(self, row_index: RowIndex, errors: list[ExcelCellError] def add_result_columns( self, - df: DataFrame, + df: WorksheetTable, *, result_unique_label: UniqueLabel, reason_unique_label: UniqueLabel, @@ -118,7 +117,7 @@ def add_result_columns( 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: DataFrame, unique_label: UniqueLabel): + def _column_indices(self, df: WorksheetTable, unique_label: UniqueLabel): 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(f'找不到 {unique_label} 对应的字段') @@ -130,7 +129,7 @@ def _column_indices(self, df: DataFrame, unique_label: UniqueLabel): yield from self._single_column_index(df, unique_label) @staticmethod - def _single_column_index(df: DataFrame, unique_label: UniqueLabel): + def _single_column_index(df: WorksheetTable, unique_label: UniqueLabel): index = df.columns.get_loc(unique_label) if isinstance(index, int): yield ColumnIndex(index) diff --git a/src/excelalchemy/core/schema.py b/src/excelalchemy/core/schema.py index 485b9b6..7ba047d 100644 --- a/src/excelalchemy/core/schema.py +++ b/src/excelalchemy/core/schema.py @@ -87,7 +87,7 @@ def has_merged_header(self, selected_keys: list[UniqueKey]) -> bool: ) def get_output_parent_excel_headers(self, selected_keys: list[UniqueKey] | None = None) -> list[UniqueLabel]: - """Return the flattened header row used as DataFrame columns.""" + """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] diff --git a/src/excelalchemy/core/storage.py b/src/excelalchemy/core/storage.py index 02ada7b..1b9cfe9 100644 --- a/src/excelalchemy/core/storage.py +++ b/src/excelalchemy/core/storage.py @@ -1,11 +1,11 @@ """Storage adapters used by ExcelAlchemy to read and upload workbooks.""" -from os import PathLike from typing import BinaryIO, cast -import pandas -from pandas import DataFrame +from openpyxl import load_workbook +from openpyxl.worksheet.worksheet import Worksheet +from excelalchemy.core.table import WorksheetTable from excelalchemy.exc import ConfigError from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig from excelalchemy.types.identity import UrlStr @@ -18,8 +18,8 @@ class MinioStorageGateway: def __init__(self, config: ImporterConfig | ExporterConfig): self.config = config - def read_excel_dataframe(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> DataFrame: - """Read one Excel object from Minio into a DataFrame.""" + def read_excel_dataframe(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('未配置 minio') @@ -30,17 +30,42 @@ def read_excel_dataframe(self, input_excel_name: str, *, skiprows: int, sheet_na ) try: - return pandas.read_excel( - cast(PathLike[str], file_object), - sheet_name=sheet_name, - skiprows=skiprows, - header=None, - dtype=str, - engine='openpyxl', - ) + file_object.seek(0) + workbook = load_workbook(cast(BinaryIO, file_object), data_only=True) + try: + if sheet_name not in workbook.sheetnames: + raise ValueError(f'Worksheet named {sheet_name!r} not found') + worksheet = workbook[sheet_name] + return self._worksheet_to_table(worksheet, skiprows=skiprows) + finally: + workbook.close() finally: cast(BinaryIO, file_object).close() + 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) + 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: diff --git a/src/excelalchemy/core/table.py b/src/excelalchemy/core/table.py new file mode 100644 index 0000000..c771443 --- /dev/null +++ b/src/excelalchemy/core/table.py @@ -0,0 +1,139 @@ +"""Lightweight worksheet table abstraction used by the core import/export flow.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Iterator, overload + + +@dataclass(frozen=True) +class _WorksheetStringAccessor: + values: list[Any] + + def startswith(self, prefix: str) -> list[bool]: + return [isinstance(value, str) and value.startswith(prefix) for value in self.values] + + +class WorksheetColumns(list[Any]): + """List-like column container with the small API surface used by the core layer.""" + + def get_loc(self, value: Any) -> 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[Any], values: list[Any]): + self._index = list(index) + self._values = values + + @property + def str(self) -> _WorksheetStringAccessor: + return _WorksheetStringAccessor(self._values) + + def items(self) -> Iterator[tuple[Any, Any]]: + return iter(zip(self._index, self._values)) + + def tolist(self) -> list[Any]: + return list(self._values) + + def to_dict(self) -> dict[Any, Any]: + return dict(zip(self._index, self._values)) + + def __getitem__(self, key: Any) -> Any: + 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]) -> Any: ... + + @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 | Any': + if isinstance(key, tuple): + row_index, column_index = key + return self._table._rows[row_index][column_index] + if isinstance(key, slice): + return WorksheetTable(columns=self._table.columns, rows=self._table._rows[key]) + return WorksheetRow(self._table.columns, self._table._rows[key]) + + +class WorksheetTable: + """A minimal 2D table API that mirrors the table features ExcelAlchemy actually uses.""" + + def __init__(self, columns: Iterable[Any] | None = None, rows: Iterable[Iterable[Any]] | 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[Any] | dict[Any, Any]) -> list[Any]: + if isinstance(row, dict): + if not self._columns: + self._columns = WorksheetColumns(list(row.keys())) + return [row.get(column) 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[Any]) -> 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 reset_index(self, *, drop: bool = False) -> 'WorksheetTable': + if not drop: + raise NotImplementedError('WorksheetTable 仅支持 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[Any]]) -> 'WorksheetTable': + return WorksheetTable(columns=self.columns, rows=[*rows, *self._rows]) + + def insert(self, *, loc: int, column: Any, value: list[Any]) -> 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 index e291839..b173225 100644 --- a/src/excelalchemy/core/writer.py +++ b/src/excelalchemy/core/writer.py @@ -1,17 +1,16 @@ -"""负责将 pandas 写入 Excel 文件""" +"""Render Excel workbooks with openpyxl only.""" import base64 +import io from collections import defaultdict from math import ceil -from tempfile import NamedTemporaryFile from typing import Any, BinaryIO, cast +from openpyxl import Workbook from openpyxl.comments import Comment from openpyxl.styles import Alignment, Font, PatternFill, numbers from openpyxl.utils import get_column_letter -from openpyxl.worksheet.datavalidation import DataValidation from openpyxl.worksheet.worksheet import Worksheet -from pandas import DataFrame, ExcelWriter from excelalchemy.const import ( BACKGROUND_ERROR_COLOR, @@ -20,124 +19,78 @@ DEFAULT_SHEET_NAME, FONT_READ_COLOR, HEADER_HINT, - REASON_COLUMN_LABEL, RESULT_COLUMN_LABEL, ) +from excelalchemy.core.table import WorksheetTable from excelalchemy.exc import ExcelCellError from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import Base64Str, ColumnIndex, Label, RowIndex, UniqueLabel from excelalchemy.types.result import ValidateRowResult -from excelalchemy.types.value import EXCEL_CHOICE_VALUE_TYPE from excelalchemy.util.file import add_excel_prefix, 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_INDEX_START_AT = 1 -# 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 +HEADER_HINT_LINE_COUNT = 1 -# 简单表头的行数 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()) + """Return the writable buffer used to build the workbook payload.""" + return cast(BinaryIO, file or io.BytesIO()) -# 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: - """写入简单的表头(没有合并的表头)""" +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 - writer = writer or ExcelWriter(file, engine='openpyxl') - assert writer is not None - 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, - ): - field_meta = field_meta_mapping[cast(UniqueLabel, 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) +def _encode_workbook(workbook: Workbook, file: BinaryIO, *, close_file: bool) -> str: + workbook.save(file) + file.seek(0) + content = base64.b64encode(file.read()).decode() if close_file: - writer.close() + file.close() + return add_excel_prefix(content) - return file +def _build_comment(field_meta: FieldMetaInfo) -> Comment | None: + comment_text = field_meta.value_type.comment(field_meta) + if not comment_text: + return None -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') - assert writer is not None - df.to_excel(writer, sheet_name=sheet_name, index=False, startrow=PANDAS_WRITE_START_AT) - worksheet: Worksheet = writer.sheets[sheet_name] + return 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, + ) + + +def _style_header_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) -> 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(row=HEADER_HINT_ROW_INDEX, column=HEADER_HINT_COL_INDEX) cell.value = HEADER_HINT cell.font = Font(size=16) @@ -146,50 +99,61 @@ def _write_comment_header( start_row=HEADER_HINT_ROW_INDEX, start_column=HEADER_HINT_COL_INDEX, end_row=HEADER_HINT_ROW_INDEX, - end_column=len(df.columns), + end_column=max(column_count, HEADER_HINT_COL_INDEX), ) worksheet.row_dimensions[HEADER_HINT_ROW_INDEX].height = 120 - if close_file: - writer.close() - return file +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(row=header_row_index, column=openpyxl_col_index) + cell.value = 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, - df: DataFrame, column_write_offset: int, - field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - worksheet: Worksheet, -): +) -> 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: - # 如果 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, + start_column=openpyxl_col_index, + end_row=start_row + 1, + end_column=openpyxl_col_index, ) - worksheet.cell( - row=start_row, - column=openpyxl_col_index + column_write_offset, - ).number_format = numbers.FORMAT_TEXT def _write_horizontally_merged_header( + worksheet: Worksheet, + df: WorksheetTable, + field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], + *, 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: @@ -204,87 +168,84 @@ def _write_horizontally_merged_header( 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 = worksheet.cell(row=start_row, column=openpyxl_col_index) 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, + start_column=openpyxl_col_index, end_row=start_row, - end_column=openpyxl_col_index + column_write_offset + counter[field_meta.parent_label] - 1, + end_column=openpyxl_col_index + counter[field_meta.parent_label] - 1, ) -def _write_merged_header( # pragma: no mccabe - df: DataFrame, +def _write_merged_header( + worksheet: Worksheet, + df: WorksheetTable, 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') - assert writer is not None - worksheet: Worksheet = writer.sheets[sheet_name] - - # 写入注释需要在合并表头之前 +) -> None: _write_simple_header( + worksheet, 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, + column_write_offset=column_write_offset, + row_write_offset=row_write_offset, ) - # 第一遍遍历,找出纵向合并的单元格 - 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() + 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(row=child_row_index, column=column_index + column_write_offset) + cell.value = child_value + _style_child_header_cell(cell) - return file + 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: DataFrame, + df: WorksheetTable, 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 + 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] - cell_value = field_meta.value_type.deserialize(cell_value, field_meta) - return str(cell_value) + field_meta = field_meta_mapping[col_label] + parsed_value = field_meta.value_type.deserialize(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: @@ -292,8 +253,6 @@ def _mark_error( 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, @@ -304,32 +263,33 @@ def _mark_error( def _write_value( - df: DataFrame, + df: WorksheetTable, worksheet: Worksheet, field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], - pands_data_start_index: int, + *, + 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(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 + + 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(row=openpyxl_row_index, column=openpyxl_col_index) - cell.value = _get_parsed_value(df, row_index_, column_index_, field_meta_mapping) + 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) + + if 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(x)) for x in str(cell.value).split('\n')), - len(str(df.columns[column_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(): @@ -338,117 +298,100 @@ def _write_value( ) -# pylint: disable=too-many-locals -def _write_value_mark_error( # pragma: no mccabe - df: DataFrame, +def _write_value_mark_error( + worksheet: Worksheet, + df: WorksheetTable, 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') - assert writer is not None - worksheet: Worksheet = writer.sheets[sheet_name] - + table_data_start_index: int = 0, +) -> None: _mark_error( - worksheet=worksheet, - errors=errors, + worksheet, + 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, + 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, ) - if close_file: - writer.close() - return file - def render_simple_header_excel( - df: DataFrame, + 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, ) -> 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) + workbook, worksheet = _create_workbook(sheet_name) + _write_header_hint(worksheet, column_count=len(df.columns)) _write_simple_header( + worksheet, df, field_meta_mapping, - tmp, - sheet_name, - column_write_offset, + column_write_offset=column_write_offset, row_write_offset=HEADER_HINT_LINE_COUNT, - writer=writer, - close_file=False, + ) + _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, ) - writer.close() - tmp.seek(0) - content = base64.b64encode(tmp.read()).decode() - if close_file: - tmp.close() - return add_excel_prefix(content) + return _encode_workbook(workbook, tmp, close_file=close_file) def render_merged_header_excel( - df: DataFrame, + 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, ) -> 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) + workbook, worksheet = _create_workbook(sheet_name) + _write_header_hint(worksheet, column_count=len(df.columns)) _write_merged_header( + worksheet, df, field_meta_mapping, - tmp, - sheet_name, - column_write_offset, + column_write_offset=column_write_offset, row_write_offset=HEADER_HINT_LINE_COUNT, - writer=writer, - close_file=False, + ) + _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, ) - writer.close() # writer 需要先 close,否则无法读取到数据 - tmp.seek(0) - content = base64.b64encode(tmp.read()).decode() - - if close_file: - tmp.close() - return add_excel_prefix(content) + return _encode_workbook(workbook, tmp, close_file=close_file) def render_data_excel( - df: DataFrame, + df: WorksheetTable, errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]], field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], sheet_name: str = DEFAULT_SHEET_NAME, @@ -460,46 +403,33 @@ def render_data_excel( close_file = True tmp = _get_file(file) - writer = ExcelWriter(tmp, engine='openpyxl') + workbook, worksheet = _create_workbook(sheet_name) + _write_header_hint(worksheet, column_count=len(df.columns)) - _write_comment_header(df, tmp, sheet_name, writer=writer, close_file=False) if has_merged_header: - pands_data_start_index = 1 + table_data_start_index = 1 _write_merged_header( + worksheet, 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 + table_data_start_index = 0 _write_simple_header( + worksheet, df, field_meta_mapping, - tmp, - sheet_name, row_write_offset=HEADER_HINT_LINE_COUNT, - writer=writer, - close_file=False, ) + _write_value_mark_error( + worksheet, 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, + row_write_offset=HEADER_HINT_LINE_COUNT + SIMPLE_HEADER_ROW_COUNT, + table_data_start_index=table_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)) + return Base64Str(_encode_workbook(workbook, tmp, close_file=close_file)) diff --git a/src/excelalchemy/types/abstract.py b/src/excelalchemy/types/abstract.py index 983dcbc..d5896ed 100644 --- a/src/excelalchemy/types/abstract.py +++ b/src/excelalchemy/types/abstract.py @@ -33,7 +33,7 @@ def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: # value is al @classmethod @abstractmethod def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - """用于把 pandas 读取的 Excel 之后的数据,转回用户可识别的数据, 处理聚合之前的数据""" + """用于把 worksheet 读入后的值转回用户可识别的数据, 处理聚合之前的数据""" @classmethod @abstractmethod @@ -70,7 +70,7 @@ def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: @classmethod @abstractmethod def deserialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - """用于把 pandas 读取的 Excel 之后的数据,转回用户可识别的数据, 处理聚合之前的数据""" + """用于把 worksheet 读入后的值转回用户可识别的数据, 处理聚合之前的数据""" @classmethod @abstractmethod diff --git a/src/excelalchemy/util/file.py b/src/excelalchemy/util/file.py index 20e37a1..78ff81a 100644 --- a/src/excelalchemy/util/file.py +++ b/src/excelalchemy/util/file.py @@ -1,10 +1,10 @@ import base64 import io +import math from datetime import timedelta from tempfile import TemporaryFile from typing import IO, Any -import pandas from minio import Minio from urllib3.response import BaseHTTPResponse @@ -80,10 +80,14 @@ def flatten(data: dict[str, Any], level: list[Any] | None = None) -> dict[str, A def value_is_nan(value: Any) -> bool: - """判断 value 是否是 NaN""" - is_nan = pandas.isna(value) - if isinstance(is_nan, bool) and is_nan: + """判断 value 是否为空单元格或 NaN。""" + if value is None: return True - if isinstance(value, list) and any(is_nan): # type: ignore[arg-type] + + if isinstance(value, float) and math.isnan(value): return True + + if isinstance(value, list | tuple): + return any(value_is_nan(item) for item in value) + return False diff --git a/tests/contracts/test_core_components_contract.py b/tests/contracts/test_core_components_contract.py index f2fcb04..8b21181 100644 --- a/tests/contracts/test_core_components_contract.py +++ b/tests/contracts/test_core_components_contract.py @@ -1,9 +1,8 @@ -from pandas import DataFrame - 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 excelalchemy.exc import ExcelCellError from excelalchemy.types.alchemy import ImportMode from excelalchemy.types.identity import Key, Label, RowIndex @@ -23,7 +22,7 @@ def test_schema_layout_expands_composite_parent_keys_in_layout_order(self): def test_header_parser_and_validator_accept_generated_simple_headers_as_contract(self): layout = ExcelSchemaLayout.from_model(SimpleContractImporter) - header_df = DataFrame([layout.get_output_parent_excel_headers()]) + header_df = WorksheetTable(rows=[layout.get_output_parent_excel_headers()]) parser = ExcelHeaderParser() validator = ExcelHeaderValidator() @@ -49,7 +48,7 @@ def test_row_aggregator_groups_composite_cells_back_into_parent_payload(self): def test_issue_tracker_offsets_cell_errors_after_result_columns(self): layout = ExcelSchemaLayout.from_model(SimpleContractImporter) tracker = ImportIssueTracker(layout, [RESULT_COLUMN, REASON_COLUMN]) - df = DataFrame(columns=['姓名'], data=[['张三']]) + df = WorksheetTable(columns=['姓名'], rows=[['张三']]) error = ExcelCellError(label=Label('姓名'), message='模拟失败') tracker.register_cell_errors(RowIndex(0), [error], df) diff --git a/tests/contracts/test_storage_contract.py b/tests/contracts/test_storage_contract.py index d249a82..02b8ccd 100644 --- a/tests/contracts/test_storage_contract.py +++ b/tests/contracts/test_storage_contract.py @@ -1,13 +1,21 @@ +import io from typing import cast from minio import Minio +from openpyxl import Workbook from excelalchemy import ExcelAlchemy, ExporterConfig, ImporterConfig +from excelalchemy.core.storage import MinioStorageGateway +from excelalchemy.core.table import WorksheetTable from tests.support import BaseTestCase, FileRegistry from tests.support.contract_models import SimpleContractImporter, creator, sample_simple_export_row class TestStorageContracts(BaseTestCase): + def _build_storage_gateway(self) -> MinioStorageGateway: + config = ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio)) + return MinioStorageGateway(config) + async def test_export_upload_stores_generated_workbook_in_minio(self): output_name = 'contract-export-upload.xlsx' self.minio.storage.pop(output_name, None) @@ -43,3 +51,36 @@ async def test_uploaded_payload_remains_binary_excel_content_without_prefix(self 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_dataframe(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_dataframe(input_name, skiprows=1, sheet_name='Sheet1') + + assert table.iloc[0].tolist() == ['日期范围', None] + assert table.iloc[1].tolist() == ['开始日期', '结束日期'] diff --git a/tests/support/mock_minio.py b/tests/support/mock_minio.py index 1bc7522..0d9b369 100644 --- a/tests/support/mock_minio.py +++ b/tests/support/mock_minio.py @@ -5,7 +5,8 @@ from tempfile import NamedTemporaryFile from typing import Any -import pandas +from openpyxl import Workbook, load_workbook +from openpyxl.worksheet.cell_range import CellRange from excelalchemy.const import HEADER_HINT from tests.support.registry import FileRegistry @@ -107,25 +108,12 @@ 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__).resolve().parent.parent / Path(data.lstrip('./'))) - else: - df = pandas.DataFrame(data) - f = NamedTemporaryFile(suffix='.xlsx', delete=False) f.close() # 关键:先关闭,避免 Windows 文件锁问题 - 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') + workbook = self._build_workbook(data) + workbook.save(f.name) + workbook.close() with open(f.name, 'rb') as rf: file_bytes = rf.read() @@ -134,6 +122,68 @@ def __init__(self): length = len(file_bytes) self.put_object(self.bucket_name, filename, data, length, f.name) + 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] = { 'bucket_name': bucket_name, diff --git a/tests/unit/value_types/test_date_range_value_type.py b/tests/unit/value_types/test_date_range_value_type.py index 3650558..a249046 100644 --- a/tests/unit/value_types/test_date_range_value_type.py +++ b/tests/unit/value_types/test_date_range_value_type.py @@ -21,7 +21,7 @@ async def test_import_returns_header_invalid_when_merged_header_loses_trailing_c """对于合并的表头,如果后面缺失 日期范围 | (这里合并了表头)| 开始日期 | (这里缺了一个值)| - DataFrame 不会读到第一行第二列的值,因此 ExcelAlchemy 不会认为有合并得表头 + worksheet reader 不会读到第一行第二列的值,因此 ExcelAlchemy 不会认为有合并得表头 """ class Importer(BaseModel): @@ -41,7 +41,7 @@ async def test_import_returns_header_invalid_when_merged_header_loses_leading_ch """对于合并的表头,如果前面缺失 日期范围 | (这里合并了表头)| (这里缺了一个值) | 开始日期 | - DataFrame 能正确读到四个值,因此 ExcelAlchemy 会认为有合并得表头 + worksheet reader 能正确读到四个值,因此 ExcelAlchemy 会认为有合并得表头 """ class Importer(BaseModel): From 6671db951ab0456a3e3419e34a5395896d88f5c7 Mon Sep 17 00:00:00 2001 From: ruicore Date: Thu, 26 Mar 2026 09:35:06 +0800 Subject: [PATCH 12/27] feat(PR-09): use uv as package manager --- .github/CODEOWNERS | 1 - .github/ISSUE_TEMPLATE/bug_report.yml | 1 - .github/ISSUE_TEMPLATE/config.yml | 1 - .github/ISSUE_TEMPLATE/feature_request.yml | 1 - .github/PULL_REQUEST_TEMPLATE.md | 8 +- .github/workflows/ci.yml | 73 +- .github/workflows/python-publish.yml | 38 +- .pre-commit-config.yaml | 12 +- README.md | 18 +- README_cn.md | 16 +- SECURITY.md | 1 - noxfile.py | 47 -- pyproject.toml | 5 +- typings/.gitkeep | 1 - uv.lock | 802 +++++++++++++++++++++ 15 files changed, 886 insertions(+), 139 deletions(-) delete mode 100644 noxfile.py create mode 100644 uv.lock diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b9fcc67..4405209 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1 @@ * @RayCarterLab - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 64dd047..2b4df5f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -51,4 +51,3 @@ body: 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 index 03ddea1..1474f11 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,4 +3,3 @@ 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 index 32eae9d..e5e3aee 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -33,4 +33,3 @@ body: 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 index bd7156d..b108882 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,13 +8,13 @@ ## Validation -- [ ] `nox -s ruff` -- [ ] `nox -s pyright` -- [ ] `nox -s tests-3.10` +- [ ] `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/workflows/ci.yml b/.github/workflows/ci.yml index d5f0ff9..58b4a1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,23 +22,27 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python 3.14 - id: setup-python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.14' - cache: 'pip' - cache-dependency-path: pyproject.toml + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock - - name: Install nox - run: | - python -m pip install --upgrade pip - python -m pip install nox + - name: Sync development environment + run: uv sync --locked --extra development - - name: Run ruff session - run: nox -s ruff + - name: Run ruff + run: | + uv run ruff format --check . + uv run ruff check . pyright: runs-on: ubuntu-latest @@ -47,22 +51,25 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python 3.14 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.14' - cache: 'pip' - cache-dependency-path: pyproject.toml + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock - - name: Install nox - run: | - python -m pip install --upgrade pip - python -m pip install nox + - name: Sync development environment + run: uv sync --locked --extra development - - name: Run pyright session - run: nox -s pyright + - name: Run pyright + run: uv run pyright tests: runs-on: ubuntu-latest @@ -76,22 +83,26 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: pyproject.toml + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock - - name: Install nox - run: | - python -m pip install --upgrade pip - python -m pip install nox + - name: Sync development environment + run: uv sync --locked --extra development - - name: Run test session - run: nox -s tests-${{ matrix.python-version }} + - 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' diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index f09e2f9..8ff684b 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -19,41 +19,37 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python 3.14 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.14' - cache: 'pip' - cache-dependency-path: pyproject.toml - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - python -m pip install build twine + - 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: python -m build + run: uv build - name: Check package metadata - run: python -m twine check dist/* + run: uvx twine check dist/* - name: Smoke test wheel installation run: | - python -m venv .pkg-smoke-wheel - . .pkg-smoke-wheel/bin/activate - python -m pip install --upgrade pip - python -m pip install dist/*.whl - python -c "import excelalchemy; print(excelalchemy.__version__)" + 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: | - python -m venv .pkg-smoke-sdist - . .pkg-smoke-sdist/bin/activate - python -m pip install --upgrade pip - python -m pip install dist/*.tar.gz - python -c "import excelalchemy; print(excelalchemy.__version__)" + 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de8a7ed..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 @@ -12,16 +12,6 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/best-doctor/pre-commit-hooks - rev: v1.0.11 - 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/astral-sh/ruff-pre-commit rev: v0.15.7 hooks: diff --git a/README.md b/README.md index e284ff9..b2c17f9 100755 --- a/README.md +++ b/README.md @@ -125,24 +125,26 @@ asyncio.run(main()) ## Development -Install the project in editable mode with development dependencies: +Install `uv`, then sync the development environment: ```bash -pip install -e .[development] -pre-commit install +uv sync --extra development +uv run pre-commit install ``` -The project uses the standard `src/` layout, so local development should go through an editable install or `nox` rather than relying on repository-root imports. +The project uses the standard `src/` layout, so local development should go through the managed `uv` environment rather than relying on repository-root imports. Common local commands: ```bash -nox -s ruff -nox -s pyright -nox -s tests-3.12 +uv run ruff format --check . +uv run ruff check . +uv run pyright +uv run pytest --cov=excelalchemy --cov-report=term-missing:skip-covered tests +uv build ``` -The CI workflow runs `ruff`, `pyright`, and the test matrix on Python 3.12, 3.13, and 3.14. +The CI workflow uses `uv` for dependency management and runs `ruff`, `pyright`, and the test matrix on Python 3.12, 3.13, and 3.14. ### Contributing diff --git a/README_cn.md b/README_cn.md index a64bd82..b135701 100644 --- a/README_cn.md +++ b/README_cn.md @@ -132,21 +132,23 @@ asyncio.run(main()) 如果你希望参与开发,可以先安装开发依赖并启用本地检查: ```bash -pip install -e .[development] -pre-commit install +uv sync --extra development +uv run pre-commit install ``` -项目现在采用标准 `src/` 布局,因此本地开发建议通过 editable install 或 `nox` 运行,而不是依赖仓库根目录的隐式导入。 +项目现在采用标准 `src/` 布局,因此本地开发建议通过 `uv` 管理的环境运行,而不是依赖仓库根目录的隐式导入。 常用本地命令: ```bash -nox -s ruff -nox -s pyright -nox -s tests-3.12 +uv run ruff format --check . +uv run ruff check . +uv run pyright +uv run pytest --cov=excelalchemy --cov-report=term-missing:skip-covered tests +uv build ``` -CI 会运行 `ruff`、`pyright`,以及 Python 3.12、3.13、3.14 的测试矩阵。 +CI 现在使用 `uv` 管理依赖,并运行 `ruff`、`pyright`,以及 Python 3.12、3.13、3.14 的测试矩阵。 如果你在使用 ExcelAlchemy 过程中遇到了问题或者有任何建议,欢迎在 [GitHub Issues](https://github.com/RayCarterLab/ExcelAlchemy/issues) 中提出。我们也非常欢迎你提交 Pull Request,贡献你的代码。在提交前,建议先运行上面的本地校验命令,并在行为变化时同步更新文档。 diff --git a/SECURITY.md b/SECURITY.md index d99ee75..82ad7ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,4 +20,3 @@ If that workflow is unavailable, contact the maintainers privately and include: - 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/noxfile.py b/noxfile.py deleted file mode 100644 index 9963e21..0000000 --- a/noxfile.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -import nox - -DEFAULT_PYTHONS = ['3.12', '3.13', '3.14'] -MAIN_PYTHON = '3.14' -PACKAGE_INSTALL = ['-e', '.[development]'] - -nox.options.sessions = ['ruff', 'pyright', 'tests'] -nox.options.error_on_missing_interpreters = False - - -def install_project(session: nox.Session) -> None: - session.install(*PACKAGE_INSTALL) - - -@nox.session(python=MAIN_PYTHON) -def ruff(session: nox.Session) -> None: - install_project(session) - session.run('ruff', 'format', '--check', '.') - session.run('ruff', 'check', '.') - - -@nox.session(python=MAIN_PYTHON) -def pyright(session: nox.Session) -> None: - install_project(session) - session.run('pyright') - - -@nox.session(python=DEFAULT_PYTHONS) -def tests(session: nox.Session) -> None: - install_project(session) - session.run( - 'pytest', - '--cov=excelalchemy', - '--cov-report=term-missing:skip-covered', - '--cov-report=xml:coverage.xml', - '--junitxml=pytest.xml', - 'tests', - *session.posargs, - ) - - -@nox.session(python=MAIN_PYTHON) -def build(session: nox.Session) -> None: - session.install('build') - session.run('python', '-m', 'build') diff --git a/pyproject.toml b/pyproject.toml index e174400..80949fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ['flit_core >=3.2,<4'] +requires = ['flit_core >=3.12,<4'] build-backend = 'flit_core.buildapi' [project] @@ -30,7 +30,6 @@ dependencies = [ 'pydantic[email] >=2.12, <3', 'openpyxl >=3.1.5, <4', 'pendulum >=3.2.0, <4', - "flit>=3.12.0", ] [tool.flit.module] @@ -44,8 +43,6 @@ Issues = 'https://github.com/RayCarterLab/ExcelAlchemy/issues' [project.optional-dependencies] development = [ - 'build', - 'nox', 'pre-commit', 'pyright==1.1.408', 'pytest', diff --git a/typings/.gitkeep b/typings/.gitkeep index 8b13789..e69de29 100644 --- a/typings/.gitkeep +++ b/typings/.gitkeep @@ -1 +0,0 @@ - diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0f6c4e3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,802 @@ +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 = "minio" }, + { name = "openpyxl" }, + { name = "pendulum" }, + { name = "pydantic", extra = ["email"] }, +] + +[package.optional-dependencies] +development = [ + { name = "coverage" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", marker = "extra == 'development'" }, + { name = "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"] + +[[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" }, +] From 958dc1eb15399b4d661f71f99d4d7e0d404f4bef Mon Sep 17 00:00:00 2001 From: ruicore Date: Fri, 27 Mar 2026 11:03:14 +0800 Subject: [PATCH 13/27] feat(PR-10): make minio optional dependency --- README.md | 105 ++++++++++++++++++- README_cn.md | 105 ++++++++++++++++++- pyproject.toml | 5 +- src/excelalchemy/__init__.py | 2 + src/excelalchemy/core/abstract.py | 2 +- src/excelalchemy/core/alchemy.py | 7 +- src/excelalchemy/core/storage.py | 93 +++++------------ src/excelalchemy/core/storage_minio.py | 120 ++++++++++++++++++++++ src/excelalchemy/core/storage_protocol.py | 19 ++++ src/excelalchemy/types/alchemy.py | 11 +- src/excelalchemy/util/file.py | 48 +-------- tests/__init__.py | 4 +- tests/contracts/test_storage_contract.py | 91 ++++++++++++++-- tests/support/__init__.py | 2 + tests/support/storage.py | 42 ++++++++ uv.lock | 10 +- 16 files changed, 527 insertions(+), 139 deletions(-) create mode 100644 src/excelalchemy/core/storage_minio.py create mode 100644 src/excelalchemy/core/storage_protocol.py create mode 100644 tests/support/storage.py diff --git a/README.md b/README.md index b2c17f9..744661a 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # 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. +ExcelAlchemy is a Python library for schema-driven Excel import and export with Pydantic models. It supports pluggable storage backends and currently ships with a built-in Minio-compatible implementation. ## Installation @@ -14,9 +14,93 @@ Use pip to install: pip install ExcelAlchemy ``` +If you want the built-in Minio backend, install the optional extra: + +```bash +pip install "ExcelAlchemy[minio]" +``` + ExcelAlchemy currently supports Python 3.12 through 3.14. Python 3.14 is the primary supported version, and new behavior or dependency updates may be optimized for it first. +## Storage backends + +ExcelAlchemy accepts any storage backend that implements the `ExcelStorage` protocol. + +- Use `storage=...` when you want a custom backend. +- The legacy `minio=..., bucket_name=..., url_expires=...` configuration still works and uses the built-in Minio implementation. +- If you use the built-in Minio backend, install `ExcelAlchemy[minio]`. + +Example custom in-memory storage: + +```python +from base64 import b64decode +from io import BytesIO +from typing import Any + +from openpyxl import load_workbook +from pydantic import BaseModel + +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, FieldMeta, ImporterConfig, Number, String +from excelalchemy.core.table import WorksheetTable +from excelalchemy.types.identity import UrlStr + + +class InMemoryExcelStorage(ExcelStorage): + def __init__(self) -> None: + self.fixtures: dict[str, bytes] = {} + self.uploaded: dict[str, bytes] = {} + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + workbook = load_workbook(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}') + + +class Importer(BaseModel): + age: Number = FieldMeta(label='Age', order=1) + name: String = FieldMeta(label='Name', order=2) + + +async def creator(data: dict[str, Any], context: None) -> Any: + return data + + +storage = InMemoryExcelStorage() + +# Template generation does not require a storage backend. +template_alchemy = ExcelAlchemy(ImporterConfig(Importer, creator=creator)) +template_base64 = template_alchemy.download_template() +print(template_base64[:40]) + +# Uploading export output uses the custom storage backend. +export_alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=storage)) +url = export_alchemy.export_upload('people.xlsx', [{'age': 18, 'name': 'Alice'}]) +print(url) # memory://people.xlsx +print(storage.uploaded['people.xlsx'][:2]) # b'PK' +``` + ## Usage ### Generate Excel template from Pydantic class @@ -65,11 +149,14 @@ In the above example, we specify a sample, which is a list of dictionaries. Each ### Parse a Pydantic class from an Excel file and create data +The recommended way to use the built-in Minio backend is to pass an explicit storage strategy: + ```python import asyncio from typing import Any from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String +from excelalchemy.core.storage import MinioStorageGateway from minio import Minio from pydantic import BaseModel @@ -95,7 +182,7 @@ async def create_func(data: dict[str, Any], context: None) -> Any: async def main(): - alchemy = ExcelAlchemy( + storage = MinioStorageGateway( ImporterConfig( create_importer_model=Importer, creator=create_func, @@ -105,6 +192,14 @@ async def main(): url_expires=3600, ) ) + alchemy = ExcelAlchemy( + ImporterConfig( + create_importer_model=Importer, + creator=create_func, + data_converter=data_converter, + storage=storage, + ) + ) result = await alchemy.import_data(input_excel_name='test.xlsx', output_excel_name="test.xlsx") print(result) @@ -112,12 +207,14 @@ async def main(): asyncio.run(main()) ``` -* 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. +* The example above uses the built-in Minio-compatible storage strategy, so you need to install Minio and create a bucket if you want to use that backend. +* If you already have your own object store, local filesystem layer, or test double, pass it as `storage=...` instead. +* The older `minio=..., bucket_name=..., url_expires=...` configuration is still supported for compatibility, but `storage=MinioStorageGateway(...)` is now the preferred form. * 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.model_dump().` 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 `input_excel_name` parameter of the `import_data()` method is the storage key used by your backend, and the `output_excel_name` parameter is where the parsing result workbook will be uploaded. 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) diff --git a/README_cn.md b/README_cn.md index b135701..cfe981c 100644 --- a/README_cn.md +++ b/README_cn.md @@ -4,7 +4,7 @@ # 📊 ExcelAlchemy -ExcelAlchemy 是一个用于从 Minio 下载 Excel 文件,解析用户输入并生成对应 Pydantic 类的 Python 库,同时也可以将 Pydantic 数据生成对应的 Excel,便于用户下载。 +ExcelAlchemy 是一个基于 Pydantic 模型进行 Excel 导入导出的 Python 库。它支持可插拔存储后端,并且当前内置了 Minio 兼容实现。 ## 安装 @@ -14,9 +14,93 @@ ExcelAlchemy 是一个用于从 Minio 下载 Excel 文件,解析用户输入 pip install ExcelAlchemy ``` +如果你要使用内置的 Minio 后端,请安装可选 extra: + +```bash +pip install "ExcelAlchemy[minio]" +``` + ExcelAlchemy 当前支持 Python 3.12 到 3.14。 其中 Python 3.14 是主支持版本,新的行为优化和依赖升级会优先围绕 3.14 进行。 +## 存储后端 + +ExcelAlchemy 可以接收任何实现了 `ExcelStorage` 协议的存储后端。 + +- 如果你要接自定义存储,请使用 `storage=...` +- 旧的 `minio=..., bucket_name=..., url_expires=...` 配置仍然可用,并会继续走内置的 Minio 实现 +- 如果你要使用内置的 Minio 后端,请安装 `ExcelAlchemy[minio]` + +一个简单的内存存储示例: + +```python +from base64 import b64decode +from io import BytesIO +from typing import Any + +from openpyxl import load_workbook +from pydantic import BaseModel + +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, FieldMeta, ImporterConfig, Number, String +from excelalchemy.core.table import WorksheetTable +from excelalchemy.types.identity import UrlStr + + +class InMemoryExcelStorage(ExcelStorage): + def __init__(self) -> None: + self.fixtures: dict[str, bytes] = {} + self.uploaded: dict[str, bytes] = {} + + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + workbook = load_workbook(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}') + + +class Importer(BaseModel): + age: Number = FieldMeta(label='年龄', order=1) + name: String = FieldMeta(label='姓名', order=2) + + +async def creator(data: dict[str, Any], context: None) -> Any: + return data + + +storage = InMemoryExcelStorage() + +# 生成模板时不需要存储后端。 +template_alchemy = ExcelAlchemy(ImporterConfig(Importer, creator=creator)) +template_base64 = template_alchemy.download_template() +print(template_base64[:40]) + +# 导出上传时会走自定义存储后端。 +export_alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=storage)) +url = export_alchemy.export_upload('people.xlsx', [{'age': 18, 'name': 'Alice'}]) +print(url) # memory://people.xlsx +print(storage.uploaded['people.xlsx'][:2]) # b'PK' +``` + ## 使用方法 ### 从 Pydantic 类生成 Excel 模板 @@ -69,11 +153,14 @@ print(base64content) ### 从 Excel 解析 Pydantic 类并创建数据 +推荐通过显式的存储策略来使用内置的 Minio 后端: + ```python import asyncio from typing import Any from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String +from excelalchemy.core.storage import MinioStorageGateway from minio import Minio from pydantic import BaseModel @@ -99,7 +186,7 @@ async def create_func(data: dict[str, Any], context: None) -> Any: async def main(): - alchemy = ExcelAlchemy( + storage = MinioStorageGateway( ImporterConfig( create_importer_model=Importer, creator=create_func, @@ -109,6 +196,14 @@ async def main(): url_expires=3600, ) ) + alchemy = ExcelAlchemy( + ImporterConfig( + create_importer_model=Importer, + creator=create_func, + data_converter=data_converter, + storage=storage, + ) + ) result = await alchemy.import_data(input_excel_name='test.xlsx', output_excel_name="test.xlsx") print(result) @@ -116,11 +211,13 @@ async def main(): asyncio.run(main()) ``` -* 导入功能的文件基于 Minio,因此在使用该功能前,你需要先安装 Minio,并且在 Minio 中创建一个 bucket,用于存放 Excel 文件。 +* 上面的示例使用了内置的 Minio 兼容存储策略,因此如果你要使用这个后端,需要先安装 Minio,并准备好 bucket。 +* 如果你已经有自己的对象存储、本地文件系统封装或测试替身,也可以直接通过 `storage=...` 传入。 +* 旧的 `minio=..., bucket_name=..., url_expires=...` 写法仍然兼容,但现在更推荐 `storage=MinioStorageGateway(...)` 这种形式。 * 导入的 Excel 文件,必须是从 `download_template` 方法生成的 Excel 文件,否则会产生解析错误。 * 上面的示例代码中,我们定义了一个 `data_converter` 函数,该函数用于对 `Importer.model_dump()` 的结果进行转换,最终返回的结果将会作为 `create_func` 函数的参数。当然,此函数是可选的,如果你不需要对数据进行转换,可以不定义该函数。 * `create_func` 函数用于创建数据,该函数的参数为 `data_converter` 函数的返回值,`context` 为 `None`,你可以在该函数中对数据进行创建,例如,你可以将数据存入数据库中。 -* `import_data` 方法的参数 `input_excel_name` 为 Excel 文件在 Minio 中的名称,`output_excel_name` 为解析结果 Excel 文件在 Minio 中的名称,该文件包含所有输入的数据,如果某条数据解析失败,则在该条数据的第一列中会有错误信息,并且会讲产生错误的单元格标红。 +* `import_data` 方法的参数 `input_excel_name` 是你的存储后端里用于读取输入文件的 key,`output_excel_name` 是解析结果工作簿回写时使用的 key。该文件包含所有输入的数据,如果某条数据解析失败,则在该条数据的第一列中会有错误信息,并且会讲产生错误的单元格标红。 * 返回 ImportResult 类型的结果,您可以在代码中查看该类的定义,该类包含了解析结果的所有信息,例如,成功导入的数据条数、失败的数据条数、失败的数据等。 一个导入结果的示例, 如图所示: diff --git a/pyproject.toml b/pyproject.toml index 80949fc..084c0a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ classifiers = [ dynamic = ['version'] requires-python = '>=3.12' dependencies = [ - 'minio >=7.2.20, <8', 'pydantic[email] >=2.12, <3', 'openpyxl >=3.1.5, <4', 'pendulum >=3.2.0, <4', @@ -42,7 +41,11 @@ Documentation = 'https://github.com/RayCarterLab/ExcelAlchemy#readme' Issues = 'https://github.com/RayCarterLab/ExcelAlchemy/issues' [project.optional-dependencies] +minio = [ + 'minio >=7.2.20, <8', +] development = [ + 'minio >=7.2.20, <8', 'pre-commit', 'pyright==1.1.408', 'pytest', diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index 07703a8..943cc63 100644 --- a/src/excelalchemy/__init__.py +++ b/src/excelalchemy/__init__.py @@ -3,6 +3,7 @@ __version__ = '1.1.0' from excelalchemy.const import CharacterSet, DataRangeOption, DateFormat, Option from excelalchemy.core.alchemy import ExcelAlchemy +from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.exc import ConfigError, ExcelCellError, ProgrammaticError from excelalchemy.helper.pydantic import extract_pydantic_model from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig, ImportMode @@ -34,6 +35,7 @@ 'DateRange', 'DataRangeOption', 'Email', + 'ExcelStorage', 'ExcelAlchemy', 'ExcelCellError', 'ExporterConfig', diff --git a/src/excelalchemy/core/abstract.py b/src/excelalchemy/core/abstract.py index a1ca0af..d2390f4 100644 --- a/src/excelalchemy/core/abstract.py +++ b/src/excelalchemy/core/abstract.py @@ -29,7 +29,7 @@ def export(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> B @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/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 934cd50..7670e68 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -16,7 +16,8 @@ from excelalchemy.core.rendering import ExcelRenderer from excelalchemy.core.rows import ImportIssueTracker, RowAggregator from excelalchemy.core.schema import ExcelSchemaLayout -from excelalchemy.core.storage import MinioStorageGateway +from excelalchemy.core.storage import build_storage_gateway +from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.core.table import WorksheetTable from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError from excelalchemy.helper.pydantic import get_model_field_names @@ -76,7 +77,7 @@ def __init__( self._header_parser = ExcelHeaderParser() self._header_validator = ExcelHeaderValidator() self._renderer = ExcelRenderer() - self._storage_gateway = MinioStorageGateway(config) + self._storage_gateway: ExcelStorage = build_storage_gateway(config) self._layout: ExcelSchemaLayout self._issue_tracker: ImportIssueTracker | None = None self._row_aggregator: RowAggregator | None = None @@ -288,7 +289,7 @@ def _select_output_excel_keys(self, keys: list[Key] | None = None) -> list[Uniqu 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_dataframe( + df = self._storage_gateway.read_excel_table( input_excel_name, skiprows=HEADER_HINT_LINE_COUNT, sheet_name=self.config.sheet_name, diff --git a/src/excelalchemy/core/storage.py b/src/excelalchemy/core/storage.py index 1b9cfe9..8410872 100644 --- a/src/excelalchemy/core/storage.py +++ b/src/excelalchemy/core/storage.py @@ -1,80 +1,43 @@ -"""Storage adapters used by ExcelAlchemy to read and upload workbooks.""" +"""Storage factory for resolving ExcelAlchemy storage strategies.""" -from typing import BinaryIO, cast +from typing import TYPE_CHECKING, Any -from openpyxl import load_workbook -from openpyxl.worksheet.worksheet import Worksheet - -from excelalchemy.core.table import WorksheetTable +from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.exc import ConfigError from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig -from excelalchemy.types.identity import UrlStr -from excelalchemy.util.file import read_file_from_minio_object, remove_excel_prefix, upload_file_from_minio_object +if TYPE_CHECKING: + from excelalchemy.core.storage_minio import MinioStorageGateway + + +class MissingStorageGateway(ExcelStorage): + """Fallback storage used when no concrete backend has been configured.""" -class MinioStorageGateway: - """Small gateway around the Minio-backed workbook IO helpers.""" + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str): + raise ConfigError('未配置存储后端,请传入 storage=... 或安装并配置 ExcelAlchemy[minio]') - def __init__(self, config: ImporterConfig | ExporterConfig): - self.config = config + def upload_excel(self, output_name: str, content_with_prefix: str): + raise ConfigError('未配置存储后端,请传入 storage=... 或安装并配置 ExcelAlchemy[minio]') - def read_excel_dataframe(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('未配置 minio') - file_object = read_file_from_minio_object( - self.config.minio, - self.config.bucket_name, - input_excel_name, - ) +def build_storage_gateway(config: ImporterConfig | ExporterConfig) -> 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 - 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(f'Worksheet named {sheet_name!r} not found') - worksheet = workbook[sheet_name] - return self._worksheet_to_table(worksheet, skiprows=skiprows) - finally: - workbook.close() - finally: - cast(BinaryIO, file_object).close() + return MinioStorageGateway(config) + return MissingStorageGateway() - 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() +def __getattr__(name: str) -> Any: + if name == 'MinioStorageGateway': + from excelalchemy.core.storage_minio import MinioStorageGateway - return WorksheetTable(rows=rows) + return MinioStorageGateway + raise AttributeError(name) - @staticmethod - def _normalize_cell_value(value: object) -> str | None: - if value is None: - return None - if isinstance(value, str): - return value - return str(value) - 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('未配置 minio') - 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) +__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..da2bbca --- /dev/null +++ b/src/excelalchemy/core/storage_minio.py @@ -0,0 +1,120 @@ +"""Minio-backed Excel storage implementation.""" + +import base64 +import io +from datetime import timedelta +from tempfile import TemporaryFile +from typing import IO, BinaryIO, cast + +from minio import Minio +from openpyxl import load_workbook +from openpyxl.worksheet.worksheet import Worksheet +from urllib3.response import BaseHTTPResponse + +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.core.table import WorksheetTable +from excelalchemy.exc import ConfigError +from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig +from excelalchemy.types.identity import UrlStr +from excelalchemy.util.file import remove_excel_prefix + + +class MinioStorageGateway(ExcelStorage): + """Excel storage strategy backed by a Minio-compatible object store.""" + + def __init__(self, config: ImporterConfig | ExporterConfig): + 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('未配置 minio') + + 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(f'Worksheet named {sheet_name!r} not found') + 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('未配置 minio') + 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.""" + tmp = TemporaryFile() + tmp.write(response.read()) + tmp.seek(0) + return tmp + + @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..f65c17d --- /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.core.table import WorksheetTable +from excelalchemy.types.identity import UrlStr + + +@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/types/alchemy.py b/src/excelalchemy/types/alchemy.py index ed3f92e..0d6157b 100644 --- a/src/excelalchemy/types/alchemy.py +++ b/src/excelalchemy/types/alchemy.py @@ -1,16 +1,21 @@ """实例化 ExcelAlchemy 时的配置""" +from __future__ import annotations + from dataclasses import dataclass, field from enum import Enum -from typing import Any, Awaitable, Callable, Literal +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal -from minio import Minio from pydantic import BaseModel +from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.exc import ConfigError from excelalchemy.helper.pydantic import get_model_field_names from excelalchemy.util.convertor import export_data_converter, import_data_converter +if TYPE_CHECKING: + from minio import Minio + class ExcelMode(str, Enum): """Excel 模式""" @@ -41,6 +46,7 @@ class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateMo import_mode: ImportMode = field(default=ImportMode.CREATE) + storage: ExcelStorage | None = field(default=None) minio: Minio | None = field(default=None) bucket_name: str = field(default='excel') url_expires: int = field(default=3600) @@ -100,6 +106,7 @@ class ExporterConfig[ExporterModelT: BaseModel]: # 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) + storage: ExcelStorage | None = field(default=None) minio: Minio | None = field(default=None) bucket_name: str = field(default='excel') url_expires: int = field(default=3600) diff --git a/src/excelalchemy/util/file.py b/src/excelalchemy/util/file.py index 78ff81a..94481a3 100644 --- a/src/excelalchemy/util/file.py +++ b/src/excelalchemy/util/file.py @@ -1,12 +1,5 @@ -import base64 -import io import math -from datetime import timedelta -from tempfile import TemporaryFile -from typing import IO, Any - -from minio import Minio -from urllib3.response import BaseHTTPResponse +from typing import Any from excelalchemy.const import UNIQUE_HEADER_CONNECTOR @@ -24,45 +17,6 @@ def remove_excel_prefix(content: str) -> str: return content.lstrip(f'{EXCEL_PREFIX},') -def construct_file_like_object(response: BaseHTTPResponse) -> 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.""" - response: BaseHTTPResponse = client.get_object(bucket_name, filename) - return construct_file_like_object(response) - - -def upload_file_from_minio_object( - client: Minio, - bucket_name: str, - filename: str, - content: str, - expires: int, -) -> str: - """把文件上传到minio""" - - 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), - ) - - def flatten(data: dict[str, Any], level: list[Any] | None = None) -> dict[str, Any]: """平铺嵌套的字典 diff --git a/tests/__init__.py b/tests/__init__.py index 6fbb587..8276f49 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,3 @@ -from tests.support import BaseTestCase, FileRegistry, LocalMockMinio, local_minio +from tests.support import BaseTestCase, FileRegistry, InMemoryExcelStorage, LocalMockMinio, local_minio -__all__ = ['BaseTestCase', 'FileRegistry', 'LocalMockMinio', 'local_minio'] +__all__ = ['BaseTestCase', 'FileRegistry', 'InMemoryExcelStorage', 'LocalMockMinio', 'local_minio'] diff --git a/tests/contracts/test_storage_contract.py b/tests/contracts/test_storage_contract.py index 02b8ccd..3c35946 100644 --- a/tests/contracts/test_storage_contract.py +++ b/tests/contracts/test_storage_contract.py @@ -4,17 +4,94 @@ from minio import Minio from openpyxl import Workbook -from excelalchemy import ExcelAlchemy, ExporterConfig, ImporterConfig -from excelalchemy.core.storage import MinioStorageGateway +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 +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) -> MinioStorageGateway: + def _build_storage_gateway(self) -> ExcelStorage: config = ImporterConfig(SimpleContractImporter, creator=creator, minio=cast(Minio, self.minio)) - return MinioStorageGateway(config) + 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), '未配置存储后端,请传入 storage=... 或安装并配置 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' @@ -55,7 +132,7 @@ async def test_uploaded_payload_remains_binary_excel_content_without_prefix(self async def test_storage_reader_returns_worksheet_table_for_simple_import_workbook(self): gateway = self._build_storage_gateway() - table = gateway.read_excel_dataframe(FileRegistry.TEST_SIMPLE_IMPORT, skiprows=1, sheet_name='Sheet1') + table = gateway.read_excel_table(FileRegistry.TEST_SIMPLE_IMPORT, skiprows=1, sheet_name='Sheet1') assert isinstance(table, WorksheetTable) assert table.shape == (2, 17) @@ -80,7 +157,7 @@ async def test_storage_reader_preserves_empty_cells_from_merged_headers(self): 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_dataframe(input_name, skiprows=1, sheet_name='Sheet1') + 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/support/__init__.py b/tests/support/__init__.py index c3c55ff..32c640f 100644 --- a/tests/support/__init__.py +++ b/tests/support/__init__.py @@ -1,6 +1,7 @@ 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, @@ -17,6 +18,7 @@ 'FileRegistry', 'get_fill_color', 'get_font_color', + 'InMemoryExcelStorage', 'list_data_validations', 'list_merge_ranges', 'load_binary_excel_to_workbook', diff --git a/tests/support/storage.py b/tests/support/storage.py new file mode 100644 index 0000000..5cd5281 --- /dev/null +++ b/tests/support/storage.py @@ -0,0 +1,42 @@ +import io +from base64 import b64decode + +from openpyxl import load_workbook + +from excelalchemy.core.storage_protocol import ExcelStorage +from excelalchemy.core.table import WorksheetTable +from excelalchemy.types.identity import UrlStr + + +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/uv.lock b/uv.lock index 0f6c4e3..8b72bba 100644 --- a/uv.lock +++ b/uv.lock @@ -270,7 +270,6 @@ wheels = [ name = "excelalchemy" source = { editable = "." } dependencies = [ - { name = "minio" }, { name = "openpyxl" }, { name = "pendulum" }, { name = "pydantic", extra = ["email"] }, @@ -279,17 +278,22 @@ dependencies = [ [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", specifier = ">=7.2.20,<8" }, + { 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'" }, @@ -299,7 +303,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'development'" }, { name = "ruff", marker = "extra == 'development'" }, ] -provides-extras = ["development"] +provides-extras = ["development", "minio"] [[package]] name = "filelock" From 637ff586a45828e6e4788e0762ad3c06847959f8 Mon Sep 17 00:00:00 2001 From: ruicore Date: Fri, 27 Mar 2026 18:30:26 +0800 Subject: [PATCH 14/27] feat(PR-11): i18n support --- README.md | 46 +++ README_cn.md | 46 +++ src/excelalchemy/core/alchemy.py | 153 +++---- src/excelalchemy/core/executor.py | 14 +- src/excelalchemy/core/headers.py | 6 +- src/excelalchemy/core/rows.py | 8 +- src/excelalchemy/core/schema.py | 19 +- src/excelalchemy/core/storage.py | 6 +- src/excelalchemy/core/storage_minio.py | 8 +- src/excelalchemy/core/table.py | 2 +- src/excelalchemy/core/writer.py | 13 +- src/excelalchemy/exc.py | 8 +- src/excelalchemy/helper/pydantic.py | 21 +- src/excelalchemy/i18n/__init__.py | 3 + src/excelalchemy/i18n/messages.py | 378 ++++++++++++++++++ src/excelalchemy/types/alchemy.py | 26 +- src/excelalchemy/types/field.py | 58 +-- src/excelalchemy/types/result.py | 13 +- src/excelalchemy/types/value/boolean.py | 8 +- src/excelalchemy/types/value/date.py | 18 +- src/excelalchemy/types/value/date_range.py | 21 +- src/excelalchemy/types/value/email.py | 6 +- .../types/value/multi_checkbox.py | 15 +- src/excelalchemy/types/value/number.py | 33 +- src/excelalchemy/types/value/number_range.py | 17 +- src/excelalchemy/types/value/organization.py | 10 +- src/excelalchemy/types/value/phone_number.py | 4 +- src/excelalchemy/types/value/radio.py | 24 +- src/excelalchemy/types/value/staff.py | 15 +- src/excelalchemy/types/value/string.py | 16 +- src/excelalchemy/types/value/tree.py | 13 +- src/excelalchemy/types/value/url.py | 4 +- .../test_core_components_contract.py | 2 +- tests/contracts/test_import_contract.py | 18 + tests/contracts/test_storage_contract.py | 5 +- tests/contracts/test_template_contract.py | 12 + .../test_excelalchemy_workflows.py | 35 +- tests/support/contract_models.py | 2 +- tests/unit/test_excel_exceptions.py | 48 +-- tests/unit/test_field_metadata.py | 2 +- tests/unit/test_i18n_messages.py | 35 ++ .../unit/value_types/test_date_value_type.py | 10 +- .../unit/value_types/test_email_value_type.py | 2 +- 43 files changed, 933 insertions(+), 270 deletions(-) create mode 100644 src/excelalchemy/i18n/__init__.py create mode 100644 src/excelalchemy/i18n/messages.py create mode 100644 tests/unit/test_i18n_messages.py diff --git a/README.md b/README.md index 744661a..d780b34 100755 --- a/README.md +++ b/README.md @@ -103,6 +103,51 @@ print(storage.uploaded['people.xlsx'][:2]) # b'PK' ## Usage +### Choose template/result language + +`locale` controls Excel-facing display text such as: + +- the header hint in row 1 +- column comments +- result workbook column titles +- row-level validation status text + +The default is `zh-CN`. If you want an English template and result workbook, pass `locale='en'`. + +```python +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String +from pydantic import BaseModel + + +class Importer(BaseModel): + age: Number = FieldMeta(label='Age', order=1) + name: String = FieldMeta(label='Name', order=2) + + +alchemy_zh = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')) +template_zh = alchemy_zh.download_template() + +alchemy_en = ExcelAlchemy(ImporterConfig(Importer, locale='en')) +template_en = alchemy_en.download_template() +``` + +The same `locale` option also applies to import result workbooks: + +```python +from excelalchemy import ExcelAlchemy, ImporterConfig + + +alchemy = ExcelAlchemy( + ImporterConfig( + Importer, + creator=create_func, + storage=storage, + locale='en', + ) +) +result = await alchemy.import_data(input_excel_name='people.xlsx', output_excel_name='people-result.xlsx') +``` + ### Generate Excel template from Pydantic class ```python @@ -210,6 +255,7 @@ asyncio.run(main()) * The example above uses the built-in Minio-compatible storage strategy, so you need to install Minio and create a bucket if you want to use that backend. * If you already have your own object store, local filesystem layer, or test double, pass it as `storage=...` instead. * The older `minio=..., bucket_name=..., url_expires=...` configuration is still supported for compatibility, but `storage=MinioStorageGateway(...)` is now the preferred form. +* You can also set `locale='en'` or `locale='zh-CN'` on `ImporterConfig(...)` to control the language used in generated templates and import result workbooks. * 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.model_dump().` 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. diff --git a/README_cn.md b/README_cn.md index cfe981c..31796b8 100644 --- a/README_cn.md +++ b/README_cn.md @@ -103,6 +103,51 @@ print(storage.uploaded['people.xlsx'][:2]) # b'PK' ## 使用方法 +### 选择模板/结果语言 + +`locale` 用来控制 Excel 展示文案,例如: + +- 第一行的填写须知 +- 表头批注 +- 导入结果工作簿里的结果列标题 +- 行级“校验通过 / 校验不通过”文本 + +默认值是 `zh-CN`。如果你希望生成英文模板和英文结果工作簿,可以传 `locale='en'`。 + +```python +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String +from pydantic import BaseModel + + +class Importer(BaseModel): + age: Number = FieldMeta(label='Age', order=1) + name: String = FieldMeta(label='Name', order=2) + + +alchemy_zh = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')) +template_zh = alchemy_zh.download_template() + +alchemy_en = ExcelAlchemy(ImporterConfig(Importer, locale='en')) +template_en = alchemy_en.download_template() +``` + +导入结果工作簿也会使用同一个 `locale`: + +```python +from excelalchemy import ExcelAlchemy, ImporterConfig + + +alchemy = ExcelAlchemy( + ImporterConfig( + Importer, + creator=create_func, + storage=storage, + locale='en', + ) +) +result = await alchemy.import_data(input_excel_name='people.xlsx', output_excel_name='people-result.xlsx') +``` + ### 从 Pydantic 类生成 Excel 模板 ```python @@ -214,6 +259,7 @@ asyncio.run(main()) * 上面的示例使用了内置的 Minio 兼容存储策略,因此如果你要使用这个后端,需要先安装 Minio,并准备好 bucket。 * 如果你已经有自己的对象存储、本地文件系统封装或测试替身,也可以直接通过 `storage=...` 传入。 * 旧的 `minio=..., bucket_name=..., url_expires=...` 写法仍然兼容,但现在更推荐 `storage=MinioStorageGateway(...)` 这种形式。 +* 你也可以在 `ImporterConfig(...)` 上设置 `locale='en'` 或 `locale='zh-CN'`,来控制生成模板和导入结果工作簿中的展示语言。 * 导入的 Excel 文件,必须是从 `download_template` 方法生成的 Excel 文件,否则会产生解析错误。 * 上面的示例代码中,我们定义了一个 `data_converter` 函数,该函数用于对 `Importer.model_dump()` 的结果进行转换,最终返回的结果将会作为 `create_func` 函数的参数。当然,此函数是可选的,如果你不需要对数据进行转换,可以不定义该函数。 * `create_func` 函数用于创建数据,该函数的参数为 `data_converter` 函数的返回值,`context` 为 `None`,你可以在该函数中对数据进行创建,例如,你可以将数据存入数据库中。 diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 7670e68..17a1e6b 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -6,9 +6,7 @@ from excelalchemy.const import ( REASON_COLUMN_KEY, - REASON_COLUMN_LABEL, RESULT_COLUMN_KEY, - RESULT_COLUMN_LABEL, ) from excelalchemy.core.abstract import ABCExcelAlchemy from excelalchemy.core.executor import ImportExecutor @@ -21,6 +19,9 @@ from excelalchemy.core.table import WorksheetTable from excelalchemy.exc 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.types.abstract import SystemReserved from excelalchemy.types.alchemy import ExcelMode, ExporterConfig, ImporterConfig, ImportMode from excelalchemy.types.field import FieldMetaInfo @@ -31,12 +32,12 @@ HEADER_HINT_LINE_COUNT = 1 -RESULT_COLUMN = FieldMetaInfo(label=RESULT_COLUMN_LABEL) +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.value_type = SystemReserved -REASON_COLUMN = FieldMetaInfo(label=REASON_COLUMN_LABEL) +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.value_type = SystemReserved @@ -67,9 +68,10 @@ def __init__( 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: list[FieldMetaInfo] = [RESULT_COLUMN, REASON_COLUMN] + 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 } @@ -88,7 +90,8 @@ def __init__( def __init_from_config__(self) -> None: self.context = getattr(self.config, 'context', None) model = self.__get_importer_model__() - self._layout = ExcelSchemaLayout.from_model(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) @@ -111,93 +114,96 @@ def __get_importer_model__(self) -> type[ImporterCreateModelT] | type[ImporterUp importer_model = None if self.excel_mode == ExcelMode.IMPORT: if not isinstance(self.config, ImporterConfig): - raise ConfigError(f'导入模式的配置类必须是 {ImporterConfig.__name__}') + 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(f'导出模式的配置类必须是 {ExporterConfig.__name__}') + 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('请检查配置类是否定义了导入模型或导出模型') + raise ConfigError(msg(MessageKey.NO_IMPORTER_OR_EXPORTER_MODEL_CONFIGURED)) return importer_model def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> str: if self.excel_mode != ExcelMode.IMPORT: - raise ConfigError('只支持导入模式调用此方法') + raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_METHOD)) - 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) + 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) async def import_data(self, input_excel_name: str, output_excel_name: str) -> ImportResult: assert isinstance(self.config, ImporterConfig) assert self._executor is not None 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) - self.df = self.df.reset_index(drop=True) - - all_success, success_count, fail_count = True, 0, 0 - for table_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._executor.execute(cast(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, - ) + raise ConfigError(msg(MessageKey.IMPORT_MODE_ONLY_METHOD)) + + 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, 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._executor.execute(cast(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[dict[str, Any]], keys: list[Key] | None = None) -> Base64Str: - 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={}, - ) + 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_upload(self, output_name: str, data: list[dict[str, Any]], keys: list[Key] | 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('已经存在旧的转换模型上下文, 旧的上下文将被替换, 请确认此操作符合预期') + 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('请保证 df 已经初始化') + 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('请保证 df 已经初始化') + raise ConfigError(msg(MessageKey.WORKSHEET_TABLE_NOT_LOADED)) return self._header_parser.extract(self.header_df) @property @@ -209,7 +215,7 @@ def excel_mode(self) -> ExcelMode: @property def extra_header_count_on_import(self) -> int: if self.excel_mode != ExcelMode.IMPORT: - raise ConfigError('只支持导入模式读取此属性') + 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: @@ -220,14 +226,14 @@ def extra_header_count_on_import(self) -> int: 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('从导入模型推断导出模型失败, 请手动设置导出模型') + raise ConfigError(msg(MessageKey.EXPORTER_MODEL_INFERENCE_CONFLICT)) if self.config.create_importer_model: - logging.info('从导入模型推断导出模型, 请确认此操作符合预期,使用的是 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('从导入模型推断导出模型, 请确认此操作符合预期,使用的是 update_importer_model') + logging.info('Inferring exporter_model from update_importer_model') return cast(type[ExporterModelT], self.config.update_importer_model) - raise ConfigError('从导入模型推断导出模型失败, 请手动设置导出模型') + raise ConfigError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_INFERRED)) return self.config.exporter_model @@ -242,14 +248,14 @@ def get_output_child_excel_headers(self, selected_keys: list[UniqueKey] | None = def _gen_export_df(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> tuple[WorksheetTable, bool]: if self.excel_mode == ExcelMode.IMPORT: - logging.info('导出模式为导入模式, 调用导出方法时自动切换为导出模式') + logging.info('Export requested while configured in import mode; continuing with exporter_model inference') input_keys = keys or list( filter(None, [cast(Key | None, field_meta.parent_key) for field_meta in self.ordered_field_meta]) ) model_keys = cast(list[Key], get_model_field_names(self.exporter_model)) if unrecognized := (set(input_keys) - set(model_keys)): - logging.warning('导出的列 {%s} 不在模型 {%s} 中', unrecognized, 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) @@ -261,7 +267,7 @@ def _gen_export_df(self, data: list[dict[str, Any]], keys: list[Key] | None = No def _validate_header(self, input_excel_name: str) -> ValidateHeaderResult: if self.excel_mode != ExcelMode.IMPORT: - raise ConfigError('只支持导入模式调用此方法') + 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) @@ -340,8 +346,8 @@ def _add_result_column(self): assert self._issue_tracker is not None self._issue_tracker.add_result_columns( self.df, - result_unique_label=RESULT_COLUMN.unique_label, - reason_unique_label=REASON_COLUMN.unique_label, + 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 @@ -363,6 +369,19 @@ def _register_cell_errors(self, row_index: RowIndex, errors: list[ExcelCellError 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.value_type = 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.value_type = SystemReserved + + return [result_column, reason_column] + def _excel_has_merged_header(self) -> bool: return self._header_parser.has_merged_header(self.header_df) @@ -377,7 +396,7 @@ def _extract_merged_header(self) -> list[ExcelHeader]: def __setattr__(self, key: str, value: Any): if key == 'config' and hasattr(self, 'config'): - raise ValueError(f'{self.__class__.__name__} 已经被实例化, config 不能被修改') + raise ValueError(msg(MessageKey.CONFIG_ALREADY_INITIALIZED, class_name=self.__class__.__name__)) object.__setattr__(self, key, value) def __repr__(self): diff --git a/src/excelalchemy/core/executor.py b/src/excelalchemy/core/executor.py index 6aca50b..30ec9fc 100644 --- a/src/excelalchemy/core/executor.py +++ b/src/excelalchemy/core/executor.py @@ -6,6 +6,8 @@ from excelalchemy.exc 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 excelalchemy.types.alchemy import ImporterConfig, ImportMode from excelalchemy.types.identity import Key, RowIndex @@ -35,13 +37,13 @@ async def execute(self, row_index: RowIndex, data: dict[Key, Any], df: Worksheet 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(f'不支持的导入模式: {self.config.import_mode}') + raise ConfigError(msg(MessageKey.UNSUPPORTED_IMPORT_MODE, import_mode=self.config.import_mode)) async def _create(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: if self.config.creator is None: - raise ConfigError('未配置 creator') + raise ConfigError(msg(MessageKey.CREATOR_NOT_CONFIGURED)) if self.config.create_importer_model is None: - raise ConfigError('未配置 create_importer_model') + raise ConfigError(msg(MessageKey.CREATE_IMPORTER_MODEL_NOT_CONFIGURED)) return await self._invoke_dml( row_index, data, @@ -54,9 +56,9 @@ async def _create(self, row_index: RowIndex, data: dict[Key, Any], df: Worksheet async def _update(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: if self.config.updater is None: - raise ConfigError('未配置 updater') + raise ConfigError(msg(MessageKey.UPDATER_NOT_CONFIGURED)) if self.config.update_importer_model is None: - raise ConfigError('未配置 update_importer_model') + raise ConfigError(msg(MessageKey.UPDATE_IMPORTER_MODEL_NOT_CONFIGURED)) return await self._invoke_dml( row_index, data, @@ -69,7 +71,7 @@ async def _update(self, row_index: RowIndex, data: dict[Key, Any], df: Worksheet async def _create_or_update(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: if self.config.is_data_exist is None: - raise ConfigError('未配置 is_data_exists') + 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()) diff --git a/src/excelalchemy/core/headers.py b/src/excelalchemy/core/headers.py index ade28f0..7d8fbf1 100644 --- a/src/excelalchemy/core/headers.py +++ b/src/excelalchemy/core/headers.py @@ -2,6 +2,8 @@ from excelalchemy.core.table import WorksheetTable from excelalchemy.exc import ConfigError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.alchemy import ImportMode from excelalchemy.types.header import ExcelHeader from excelalchemy.types.identity import Label, UniqueLabel @@ -39,7 +41,7 @@ def _extract_merged(self, header_df: WorksheetTable) -> list[ExcelHeader]: 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('合并表头错误: 子表头不能为空') + raise ValueError(msg(MessageKey.INVALID_MERGED_HEADER_CHILD_EMPTY)) current_header = ExcelHeader( label=Label(child_value), parent_label=Label(last_header), @@ -65,7 +67,7 @@ def apply_columns( columns: list[UniqueLabel] = [] for header in headers: if header.unique_label not in allowed_labels: - raise ConfigError(f'不支持的列名: {header.unique_label}') + raise ConfigError(msg(MessageKey.UNSUPPORTED_COLUMN_NAME, unique_label=header.unique_label)) columns.append(header.unique_label) df.columns = columns # type: ignore[assignment] diff --git a/src/excelalchemy/core/rows.py b/src/excelalchemy/core/rows.py index 56ae66e..a2c28de 100644 --- a/src/excelalchemy/core/rows.py +++ b/src/excelalchemy/core/rows.py @@ -4,6 +4,8 @@ from typing import Any, cast from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.alchemy import ImportMode from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import ColumnIndex, Key, RowIndex, UniqueLabel @@ -31,7 +33,7 @@ def _aggregate(self, row_data: dict[UniqueLabel, Any]) -> dict[Key, Any]: 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(f'{type(field_meta).__name__} 未配置 key/parent_key') + 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}: @@ -120,7 +122,7 @@ def add_result_columns( def _column_indices(self, df: WorksheetTable, unique_label: UniqueLabel): 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(f'找不到 {unique_label} 对应的字段') + 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) @@ -134,4 +136,4 @@ def _single_column_index(df: WorksheetTable, unique_label: UniqueLabel): if isinstance(index, int): yield ColumnIndex(index) return - raise ValueError(f'找不到 {unique_label} 对应的列, 推测是 value_type 定义不正确') + raise ValueError(msg(MessageKey.COLUMN_NOT_FOUND, unique_label=unique_label)) diff --git a/src/excelalchemy/core/schema.py b/src/excelalchemy/core/schema.py index 7ba047d..899e3fc 100644 --- a/src/excelalchemy/core/schema.py +++ b/src/excelalchemy/core/schema.py @@ -11,6 +11,8 @@ from excelalchemy.const import DEFAULT_FIELD_META_ORDER from excelalchemy.exc 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.types.field import FieldMetaInfo from excelalchemy.types.identity import Key, Label, UniqueKey, UniqueLabel @@ -22,7 +24,7 @@ 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('没有提取到字段元数据,请检查模型是否定义了字段') + 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] = {} @@ -36,15 +38,15 @@ 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(f'没有从模型 {model.__name__} 中提取到字段元数据,请检查模型是否定义了字段') + 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('父标签不能为空') + raise ConfigError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) if field_meta.parent_key is None: - raise ConfigError('父键不能为空') + 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) @@ -59,7 +61,12 @@ def _check_field_meta_order(field_metas: list[FieldMetaInfo]) -> 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(f'字段顺序定义有重复:{list(itertools.chain.from_iterable(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]: @@ -110,7 +117,7 @@ def select_output_excel_keys(self, keys: list[Key] | None = None) -> list[Unique 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}') + raise ValueError(msg(MessageKey.INVALID_KEY, key=key)) return [field_meta.unique_key for field_meta in self._sort_field_meta(selected_field_meta)] diff --git a/src/excelalchemy/core/storage.py b/src/excelalchemy/core/storage.py index 8410872..c4c093c 100644 --- a/src/excelalchemy/core/storage.py +++ b/src/excelalchemy/core/storage.py @@ -4,6 +4,8 @@ from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.exc import ConfigError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig if TYPE_CHECKING: @@ -14,10 +16,10 @@ 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('未配置存储后端,请传入 storage=... 或安装并配置 ExcelAlchemy[minio]') + raise ConfigError(msg(MessageKey.NO_STORAGE_BACKEND_CONFIGURED)) def upload_excel(self, output_name: str, content_with_prefix: str): - raise ConfigError('未配置存储后端,请传入 storage=... 或安装并配置 ExcelAlchemy[minio]') + raise ConfigError(msg(MessageKey.NO_STORAGE_BACKEND_CONFIGURED)) def build_storage_gateway(config: ImporterConfig | ExporterConfig) -> ExcelStorage: diff --git a/src/excelalchemy/core/storage_minio.py b/src/excelalchemy/core/storage_minio.py index da2bbca..97ed13b 100644 --- a/src/excelalchemy/core/storage_minio.py +++ b/src/excelalchemy/core/storage_minio.py @@ -14,6 +14,8 @@ from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.core.table import WorksheetTable from excelalchemy.exc import ConfigError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig from excelalchemy.types.identity import UrlStr from excelalchemy.util.file import remove_excel_prefix @@ -28,7 +30,7 @@ def __init__(self, config: ImporterConfig | ExporterConfig): 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('未配置 minio') + raise ConfigError(msg(MessageKey.MINIO_CLIENT_NOT_CONFIGURED)) file_object = self._read_file_object( self.config.minio, @@ -41,7 +43,7 @@ def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: workbook = load_workbook(cast(BinaryIO, file_object), data_only=True) try: if sheet_name not in workbook.sheetnames: - raise ValueError(f'Worksheet named {sheet_name!r} not found') + raise ValueError(msg(MessageKey.WORKSHEET_NOT_FOUND, sheet_name=sheet_name)) worksheet = workbook[sheet_name] return self._worksheet_to_table(worksheet, skiprows=skiprows) finally: @@ -52,7 +54,7 @@ def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: 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('未配置 minio') + raise ConfigError(msg(MessageKey.MINIO_CLIENT_NOT_CONFIGURED)) url = self._upload_file_object( self.config.minio, self.config.bucket_name, diff --git a/src/excelalchemy/core/table.py b/src/excelalchemy/core/table.py index c771443..8ba1505 100644 --- a/src/excelalchemy/core/table.py +++ b/src/excelalchemy/core/table.py @@ -120,7 +120,7 @@ def head(self, count: int) -> 'WorksheetTable': def reset_index(self, *, drop: bool = False) -> 'WorksheetTable': if not drop: - raise NotImplementedError('WorksheetTable 仅支持 reset_index(drop=True)') + raise NotImplementedError('WorksheetTable only supports reset_index(drop=True)') return WorksheetTable(columns=self.columns, rows=self._rows) def iterrows(self) -> Iterator[tuple[int, WorksheetRow]]: diff --git a/src/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py index b173225..33bc278 100644 --- a/src/excelalchemy/core/writer.py +++ b/src/excelalchemy/core/writer.py @@ -18,11 +18,12 @@ CHARACTER_WIDTH, DEFAULT_SHEET_NAME, FONT_READ_COLOR, - HEADER_HINT, - RESULT_COLUMN_LABEL, ) from excelalchemy.core.table import WorksheetTable from excelalchemy.exc 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.types.field import FieldMetaInfo from excelalchemy.types.identity import Base64Str, ColumnIndex, Label, RowIndex, UniqueLabel from excelalchemy.types.result import ValidateRowResult @@ -92,7 +93,7 @@ def _style_child_header_cell(cell) -> None: def _write_header_hint(worksheet: Worksheet, *, column_count: int) -> None: cell = worksheet.cell(row=HEADER_HINT_ROW_INDEX, column=HEADER_HINT_COL_INDEX) - cell.value = HEADER_HINT + cell.value = dmsg(MessageKey.HEADER_HINT) cell.font = Font(size=16) cell.alignment = Alignment(wrap_text=True) worksheet.merge_cells( @@ -157,7 +158,7 @@ def _write_horizontally_merged_header( 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 不能为空') + raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) counter[field_meta.parent_label] += 1 for openpyxl_col_index, column in enumerate( @@ -166,7 +167,7 @@ def _write_horizontally_merged_header( ): field_meta = field_meta_mapping[cast(UniqueLabel, column)] if field_meta.parent_label is None: - raise RuntimeError('运行时 parent_label 不能为空') + raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) if field_meta.label != field_meta.parent_label and field_meta.offset == 0: cell = worksheet.cell(row=start_row, column=openpyxl_col_index) cell.value = field_meta.parent_label @@ -283,7 +284,7 @@ def _write_value( 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): + 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( diff --git a/src/excelalchemy/exc.py b/src/excelalchemy/exc.py index a35f8e0..982e003 100644 --- a/src/excelalchemy/exc.py +++ b/src/excelalchemy/exc.py @@ -1,13 +1,15 @@ from typing import Any from excelalchemy.const import UNIQUE_HEADER_CONNECTOR +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.identity import Label, UniqueLabel class ExcelCellError(Exception): """Excel 单元格错误""" - message = '导入 Excel 发生错误' + message = msg(MessageKey.EXCEL_IMPORT_ERROR) label: Label parent_label: Label | None detail: dict[str, Any] @@ -48,13 +50,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 整行发生导入错误""" - message = '导入 Excel 发生行错误' + message = msg(MessageKey.EXCEL_ROW_IMPORT_ERROR) def __init__( self, diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index b410cef..a7612bf 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -6,6 +6,8 @@ from pydantic.fields import FieldInfo, PydanticUndefined from excelalchemy.exc import ExcelCellError, ProgrammaticError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.abstract import ABCValueType, ComplexABCValueType from excelalchemy.types.field import FieldMetaInfo, extract_declared_field_metadata from excelalchemy.types.identity import Key @@ -29,7 +31,7 @@ def value_type(self) -> type[Any]: if origin in (UnionType, getattr(__import__('typing'), 'Union')): args = [arg for arg in get_args(annotation) if arg is not type(None)] if len(args) != 1: - raise ProgrammaticError(f'不支持的字段类型定义: {annotation}') + raise ProgrammaticError(msg(MessageKey.UNSUPPORTED_FIELD_TYPE_DECLARATION, annotation=annotation)) return cast(type[Any], args[0]) return cast(type[Any], annotation) @@ -71,7 +73,7 @@ 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('必填项缺失') + raise ValueError(msg(MessageKey.THIS_FIELD_IS_REQUIRED)) return self.value_type.__validate__(raw_value, self.declared_metadata) @@ -100,7 +102,7 @@ def extract_pydantic_model( ) -> list[FieldMetaInfo]: """根据 Pydantic 模型提取 Excel 表头信息.""" if model is None: - raise RuntimeError('模型不能为空') + raise RuntimeError(msg(MessageKey.MODEL_CANNOT_BE_NONE)) return list(_extract_pydantic_model(PydanticModelAdapter(model))) @@ -121,7 +123,12 @@ def instantiate_pydantic_model[ModelT: BaseModel]( # noqa: C901 raw_value = data.get(Key(field_adapter.name), PydanticUndefined) if raw_value is PydanticUndefined: if field_adapter.required: - errors.append(ExcelCellError(label=field_adapter.declared_metadata.label, message='必填项缺失')) + errors.append( + ExcelCellError( + label=field_adapter.declared_metadata.label, + message=msg(MessageKey.THIS_FIELD_IS_REQUIRED), + ) + ) continue try: @@ -164,7 +171,9 @@ def _extract_pydantic_model(model: PydanticModelAdapter) -> Generator[FieldMetaI yield field_adapter.runtime_metadata() else: - raise ProgrammaticError(f'字段定义必须是 ValueType 的子类, 或 ComplexValueType 的子类, 不支持 {value_type}') + raise ProgrammaticError( + msg(MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED, value_type=value_type) + ) def _handle_error( @@ -172,7 +181,7 @@ def _handle_error( exc: Exception, field_def: FieldMetaInfo, ) -> None: - messages = [str(arg) for arg in exc.args if str(arg)] or [str(exc) or '无效输入'] + 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, diff --git a/src/excelalchemy/i18n/__init__.py b/src/excelalchemy/i18n/__init__.py new file mode 100644 index 0000000..9659065 --- /dev/null +++ b/src/excelalchemy/i18n/__init__.py @@ -0,0 +1,3 @@ +from excelalchemy.i18n.messages import MessageKey, display_message, get_display_locale, message, use_display_locale + +__all__ = ['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..91c46c5 --- /dev/null +++ b/src/excelalchemy/i18n/messages.py @@ -0,0 +1,378 @@ +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' + 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' +_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 a ValueType or ComplexValueType 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 value_type 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', + 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 {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 "是" or "否"', + 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.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/types/alchemy.py b/src/excelalchemy/types/alchemy.py index 0d6157b..517c572 100644 --- a/src/excelalchemy/types/alchemy.py +++ b/src/excelalchemy/types/alchemy.py @@ -11,6 +11,8 @@ from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.exc 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: @@ -50,12 +52,13 @@ class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateMo minio: Minio | None = field(default=None) bucket_name: str = field(default='excel') url_expires: int = field(default=3600) + locale: str = field(default='zh-CN') 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} 不合法') + raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) match self.import_mode: case ImportMode.CREATE: @@ -70,31 +73,31 @@ def validate_model(self): # 创建模式验证 def _validate_create(self): if self.import_mode != ImportMode.CREATE: - raise ConfigError(f'导入模式 {self.import_mode} 不合法') + raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) if not self.create_importer_model: - raise ConfigError('当选择【创建模式】时,创建模型不能为空') + raise ConfigError(msg(MessageKey.CREATE_IMPORTER_MODEL_REQUIRED_CREATE)) # 更新模式验证 def _validate_update(self): if self.import_mode != ImportMode.UPDATE: - raise ConfigError(f'导入模式 {self.import_mode} 不合法') + raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) if not self.update_importer_model: - raise ConfigError('当选择【更新模式】时,更新模型不能为空') + raise ConfigError(msg(MessageKey.UPDATE_IMPORTER_MODEL_REQUIRED_UPDATE)) # 创建或更新模式验证 def _validate_create_or_update(self): if self.import_mode != ImportMode.CREATE_OR_UPDATE: - raise ConfigError(f'导入模式 {self.import_mode} 不合法') + raise ConfigError(msg(MessageKey.INVALID_IMPORT_MODE, import_mode=self.import_mode)) if not self.create_importer_model: - raise ConfigError('当选择【创建或更新模式】时,创建模型不能为空') + raise ConfigError(msg(MessageKey.CREATE_IMPORTER_MODEL_REQUIRED_CREATE_OR_UPDATE)) if not self.update_importer_model: - raise ConfigError('当选择【创建或更新模式】时,更新模型不能为空') + raise ConfigError(msg(MessageKey.UPDATE_IMPORTER_MODEL_REQUIRED_CREATE_OR_UPDATE)) if not self.is_data_exist: - raise ConfigError('当选择【创建或更新模式】时,数据存在判断函数不能为空') + raise ConfigError(msg(MessageKey.IS_DATA_EXIST_REQUIRED_CREATE_OR_UPDATE)) # 创建模型和更新模型的字段必须一致 if get_model_field_names(self.create_importer_model) != get_model_field_names(self.update_importer_model): - raise ConfigError('创建模型和更新模型的字段名称必须一致') + raise ConfigError(msg(MessageKey.IMPORTER_MODELS_FIELD_NAMES_MUST_MATCH)) def __post_init__(self): self.validate_model() @@ -110,12 +113,13 @@ class ExporterConfig[ExporterModelT: BaseModel]: minio: Minio | None = field(default=None) bucket_name: str = field(default='excel') url_expires: int = field(default=3600) + locale: str = field(default='zh-CN') sheet_name: Literal['Sheet1'] = field(default='Sheet1') def validate_model(self): if not self.exporter_model: - raise ValueError('导出模型不能为空') + raise ValueError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_EMPTY)) return self def __post_init__(self): diff --git a/src/excelalchemy/types/field.py b/src/excelalchemy/types/field.py index 3582689..704f73c 100644 --- a/src/excelalchemy/types/field.py +++ b/src/excelalchemy/types/field.py @@ -10,7 +10,6 @@ from pydantic.fields import FieldInfo, PydanticUndefined from excelalchemy.const import ( - DATA_RANGE_OPTION_TO_CHINESE, DATE_FORMAT_TO_HINT_MAPPING, DATE_FORMAT_TO_PYTHON_MAPPING, DEFAULT_FIELD_META_ORDER, @@ -24,6 +23,9 @@ Option, ) from excelalchemy.exc 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 from excelalchemy.types.abstract import ABCValueType, Undefined from excelalchemy.types.identity import Key, Label, OptionId, UniqueKey, UniqueLabel @@ -151,9 +153,9 @@ def set_unique(self, unique: bool | None) -> None: def validate_state(self) -> None: if self.is_primary_key and not self.unique: - raise ValueError('主键必须唯一') + raise ValueError(msg(MessageKey.PRIMARY_KEY_MUST_BE_UNIQUE)) if (self.is_primary_key or self.unique) and self.required is False: - raise ValueError('主键或唯一字段必须必填') + 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] = [] @@ -163,7 +165,7 @@ def exchange_option_ids_to_names(self, option_ids: list[str] | list[OptionId]) - try: option_names.append(self.options_id_map[option_id].name) except KeyError: - logging.warning('找不到选项id %s,将返回原值', option_id) + logging.warning('Could not find option id %s; returning the original value', option_id) option_names.append(option_id) return option_names @@ -174,7 +176,7 @@ def exchange_names_to_option_ids_with_errors(self, names: list[str]) -> tuple[li for name in names: option = self.options_name_map.get(name) if option is None: - errors.append('选项不存在,请参照表头的注释填写') + errors.append(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT)) else: result.append(option.id) return result, errors @@ -182,7 +184,7 @@ def exchange_names_to_option_ids_with_errors(self, names: list[str]) -> tuple[li @property def unique_label(self) -> UniqueLabel: if self.parent_label is None: - raise RuntimeError('运行时 parent_label 不能为空') + raise RuntimeError(msg(MessageKey.PARENT_LABEL_EMPTY_RUNTIME)) label = ( f'{self.parent_label}{UNIQUE_HEADER_CONNECTOR}{self.label}' if self.parent_label != self.label @@ -193,9 +195,9 @@ def unique_label(self) -> UniqueLabel: @property def unique_key(self) -> UniqueKey: if self.parent_key is None: - raise RuntimeError('运行时 parent_key 不能为空') + raise RuntimeError(msg(MessageKey.PARENT_KEY_EMPTY_RUNTIME)) if self.key is None: - raise RuntimeError('运行时 key 不能为空') + 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) @@ -205,7 +207,7 @@ def options_id_map(self) -> dict[OptionId, Option]: return {} if len(self.options) > MAX_OPTIONS_COUNT: logging.warning( - '您为字段【%s】指定了 %s 个选项, 请考虑此数量是否合理,options 设计的本意不是为了处理大量数据', + 'Field "%s" defines %s options; please confirm that this is intentional because options are not meant for large datasets', self.label, len(self.options), ) @@ -217,7 +219,7 @@ def options_name_map(self) -> dict[str, Option]: return {} if len(self.options) > MAX_OPTIONS_COUNT: logging.warning( - '您为字段【%s】指定了 %s 个选项, 请考虑此数量是否合理,options 设计的本意不是为了处理大量数据', + 'Field "%s" defines %s options; please confirm that this is intentional because options are not meant for large datasets', self.label, len(self.options), ) @@ -225,52 +227,62 @@ def options_name_map(self) -> dict[str, Option]: @property def comment_required(self) -> str: - return f"必填性:{'必填' if self.required else '选填'}" + 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 f'格式:日期({DATE_FORMAT_TO_HINT_MAPPING[self.date_format]})' + 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 '范围:无限制' - return f'范围:{DATA_RANGE_OPTION_TO_CHINESE[self.date_range_option]}' + 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 f'提示:{self.hint}' + return dmsg(MessageKey.COMMENT_HINT, value=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)}' + 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 f'小数位数:{self.fraction_digits or 0}' + return dmsg(MessageKey.COMMENT_FRACTION_DIGITS, value=self.fraction_digits or 0) @property def comment_unit(self) -> str: - return f'单位:{self.unit or "无"}' + return dmsg(MessageKey.COMMENT_UNIT, value=self.unit or dmsg(MessageKey.COMMENT_UNIT_VALUE_NONE)) @property def comment_unique(self) -> str: - return f"唯一性:{'唯一' if self.unique else '非唯一'}" + 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 f'最大长度:{self.importer_max_length or "无限制"}' + 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('运行时 date_format 不能为空') + raise ConfigError(msg(MessageKey.DATE_FORMAT_EMPTY_RUNTIME)) return self.date_format @property @@ -294,7 +306,7 @@ def __repr__(self) -> str: def extract_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: metadata = (field_info.json_schema_extra or {}).get(EXCEL_FIELD_METADATA_KEY) if not isinstance(metadata, FieldMetaInfo): - raise ProgrammaticError('字段定义必须是 FieldMeta 的实例') + raise ProgrammaticError(msg(MessageKey.FIELD_DEFINITIONS_MUST_USE_FIELDMETA)) return metadata @@ -342,7 +354,7 @@ def FieldMeta( **extra: Any, ) -> Any: if fraction_digits is not None and not isinstance(fraction_digits, int): - raise ValueError('fraction_digits 必须是整数') + raise ValueError(msg(MessageKey.FRACTION_DIGITS_MUST_BE_INTEGER)) metadata = FieldMetaInfo( label=label, diff --git a/src/excelalchemy/types/result.py b/src/excelalchemy/types/result.py index d50674d..703f57a 100644 --- a/src/excelalchemy/types/result.py +++ b/src/excelalchemy/types/result.py @@ -4,17 +4,22 @@ from pydantic import BaseModel, ConfigDict, Field +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.types.identity import Label class ValidateRowResult(str, Enum): """导入结果""" - SUCCESS = '校验通过' - FAIL = '校验不通过' + SUCCESS = 'SUCCESS' + FAIL = 'FAIL' def __str__(self): - return self.value + if self is ValidateRowResult.SUCCESS: + return dmsg(MessageKey.VALIDATE_ROW_SUCCESS) + return dmsg(MessageKey.VALIDATE_ROW_FAIL) class ValidateHeaderResult(BaseModel): @@ -61,7 +66,7 @@ class ImportResult(BaseModel): def from_validate_header_result(cls, result: ValidateHeaderResult) -> 'ImportResult': """从校验表头结果构造导入结果""" if result.is_valid: - raise RuntimeError('只有校验表头失败时才能构造导入结果') + raise RuntimeError(msg(MessageKey.IMPORT_RESULT_ONLY_FOR_INVALID_HEADER_VALIDATION)) return cls( result=ValidateResult.HEADER_INVALID, is_required_missing=result.is_required_missing, diff --git a/src/excelalchemy/types/value/boolean.py b/src/excelalchemy/types/value/boolean.py index 24bf0b0..8cbabb9 100644 --- a/src/excelalchemy/types/value/boolean.py +++ b/src/excelalchemy/types/value/boolean.py @@ -1,6 +1,8 @@ import logging from typing import Any +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value import excel_choice @@ -34,11 +36,11 @@ def deserialize(cls, value: bool | str | None | Any, field_meta: FieldMetaInfo) elif isinstance(value, str): value = value.strip() if value not in ('是', '否'): - logging.warning('无法识别布尔值 %s, 返回原值', value) + logging.warning('Could not recognize boolean value %s; returning the original value', value) return value return value else: - logging.warning('类型【%s】无法为 %s 反序列化: %s, 返回默认值 "否" ', cls.__name__, field_meta.label, value) + logging.warning('Type %s could not deserialize %s for field %s; returning the default value "否"', cls.__name__, value, field_meta.label) return '是' if str(value) == '是' else '否' @@ -50,6 +52,6 @@ def __validate__(cls, value: str | bool | Any, field_meta: FieldMetaInfo) -> boo value_str = str(value) if value_str not in ('是', '否'): - raise ValueError('请输入“是”或“否”') + raise ValueError(msg(MessageKey.BOOLEAN_ENTER_YES_OR_NO)) return value_str == '是' diff --git a/src/excelalchemy/types/value/date.py b/src/excelalchemy/types/value/date.py index 993fb7c..b9e94a5 100644 --- a/src/excelalchemy/types/value/date.py +++ b/src/excelalchemy/types/value/date.py @@ -7,6 +7,8 @@ from excelalchemy.const import DATE_FORMAT_TO_HINT_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption from excelalchemy.exc import ConfigError +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo @@ -17,7 +19,7 @@ class Date(ABCValueType, datetime): @classmethod def 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, @@ -34,7 +36,7 @@ def serialize(cls, value: str | DateTime | Any, field_meta: FieldMetaInfo) -> da return value if not field_meta.date_format: - raise ConfigError('日期格式未定义') + raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) value = str(value).strip() try: @@ -42,7 +44,7 @@ def serialize(cls, value: str | DateTime | Any, field_meta: FieldMetaInfo) -> da 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 @@ -62,10 +64,12 @@ def deserialize(cls, value: str | datetime | None | Any, field_meta: FieldMetaIn @classmethod def __validate__(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) @@ -90,10 +94,10 @@ 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: ... diff --git a/src/excelalchemy/types/value/date_range.py b/src/excelalchemy/types/value/date_range.py index 8f100a8..1e44ad3 100644 --- a/src/excelalchemy/types/value/date_range.py +++ b/src/excelalchemy/types/value/date_range.py @@ -7,6 +7,9 @@ from pydantic import BaseModel from excelalchemy.const import DATE_FORMAT_TO_PYTHON_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption +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.types.abstract import ComplexABCValueType from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import Key @@ -40,20 +43,20 @@ def __init__(self, start: datetime | None, end: datetime | None): @classmethod def model_items(cls) -> list[tuple[Key, FieldMetaInfo]]: return [ - (Key('start'), FieldMetaInfo(label='开始日期')), - (Key('end'), FieldMetaInfo(label='结束日期')), + (Key('start'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_START_DATE))), + (Key('end'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_END_DATE))), ] @classmethod def comment(cls, field_meta: FieldMetaInfo) -> str: if field_meta.date_format is None: - raise RuntimeError('日期格式未定义') + raise RuntimeError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) return '\n'.join( [ field_meta.comment_required, field_meta.comment_date_format, - f'提示:开始日期不得晚于结束日期{field_meta.hint or ""}', + dmsg(MessageKey.COMMENT_DATE_RANGE_START_NOT_AFTER_END, extra_hint=field_meta.hint or ''), ] ) @@ -105,21 +108,21 @@ def __validate__( 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 + 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('开始日期不得晚于结束日期') + 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('需早于当前时间(含当前时间)') + 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('需晚于当前时间(含当前时间)') + errors.append(msg(MessageKey.DATE_MUST_BE_LATER_THAN_NOW)) case DataRangeOption.NONE | None: ... # do nothing @@ -144,7 +147,7 @@ def deserialize(cls, value: dict[str, str] | str | Any | None, field_meta: Field if isinstance(value, dict): return cls.__deserialize__dict(py_date_format, value) - logging.warning('%s 反序列化失败,返回原值', cls.__name__) + logging.warning('%s could not be deserialized; returning the original value', cls.__name__) return value if value is not None else '' @classmethod diff --git a/src/excelalchemy/types/value/email.py b/src/excelalchemy/types/value/email.py index 541414b..cd3b97d 100644 --- a/src/excelalchemy/types/value/email.py +++ b/src/excelalchemy/types/value/email.py @@ -2,6 +2,8 @@ from pydantic import EmailStr, TypeAdapter +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.string import String @@ -15,13 +17,13 @@ def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: try: parsed = str(value) except Exception as exc: - raise ValueError('请输入正确的邮箱') from 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('请输入正确的邮箱') from exc + raise ValueError(msg(MessageKey.VALID_EMAIL_REQUIRED)) from exc # Return the parsed string if validation succeeds return parsed diff --git a/src/excelalchemy/types/value/multi_checkbox.py b/src/excelalchemy/types/value/multi_checkbox.py index 3391955..0f1f4f6 100644 --- a/src/excelalchemy/types/value/multi_checkbox.py +++ b/src/excelalchemy/types/value/multi_checkbox.py @@ -3,6 +3,9 @@ from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR from excelalchemy.exc 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.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import OptionId @@ -17,7 +20,7 @@ def comment(cls, field_meta: FieldMetaInfo) -> str: [ field_meta.comment_required, field_meta.comment_options, - '单/多选:多选', + dmsg(MessageKey.COMMENT_SELECTION_MODE, value=dmsg(MessageKey.COMMENT_SELECTION_VALUE_MULTI)), field_meta.comment_hint, ] ) @@ -33,23 +36,23 @@ def serialize(cls, value: str | Any, field_meta: FieldMetaInfo) -> list[str] | s 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) + logging.warning('ValueType <%s> could not parse Excel input %s; returning the original value', 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('选项不存在,请参照表头的注释填写') + raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT)) if field_meta.options is None: - raise ProgrammaticError(f'options cannot be None when validate {cls.__name__}') + raise ProgrammaticError(msg(MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_VALUE_TYPE, value_type=cls.__name__)) if not field_meta.options: # empty - logging.warning('类型【%s】的字段【%s】的选项为空, 将返回原值', cls.__name__, field_meta.label) + logging.warning('Field %s of type %s has no options; returning the original value', field_meta.label, cls.__name__) return value if len(value) != len(set(value)): - raise ValueError('选项有重复') + raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) result, errors = field_meta.exchange_names_to_option_ids_with_errors(value) diff --git a/src/excelalchemy/types/value/number.py b/src/excelalchemy/types/value/number.py index 90403d6..60cd9bf 100644 --- a/src/excelalchemy/types/value/number.py +++ b/src/excelalchemy/types/value/number.py @@ -2,6 +2,9 @@ from decimal import ROUND_DOWN, Context, Decimal, InvalidOperation from typing import Any +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.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo @@ -15,7 +18,7 @@ def canonicalize_decimal(value: Decimal, digits_limit: int | None) -> Decimal: context=Context(rounding=ROUND_DOWN), ) except InvalidOperation as e: - logging.warning('精度设置的过小,导致精度丢失,%s', e) + logging.warning('fraction_digits is too small and causes precision loss: %s', e) return value @@ -44,9 +47,9 @@ def comment(cls, field_meta: FieldMetaInfo) -> str: return '\n'.join( [ field_meta.comment_required, - '格式:数值', + dmsg(MessageKey.COMMENT_NUMBER_FORMAT), field_meta.comment_fraction_digits, - f'可输入范围:{cls.__get_range_description__(field_meta)}', + dmsg(MessageKey.COMMENT_NUMBER_INPUT_RANGE, value=cls.__get_range_description__(field_meta)), field_meta.comment_unit, ] ) @@ -58,7 +61,7 @@ def serialize(cls, value: str | int | float | None, field_meta: FieldMetaInfo) - 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) + logging.warning('ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', cls.__name__, value, exc) return str(value) if value is not None else '' @classmethod @@ -69,14 +72,14 @@ def deserialize(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: try: return str(transform_decimal(Decimal(value))) 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 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 '无限制' + return dmsg(MessageKey.DATE_RANGE_OPTION_NONE_DISPLAY) case (_, None): return f'≤ {field_meta.importer_le}' case (None, _): @@ -93,7 +96,7 @@ def __maybe_decimal__(value: Any) -> Decimal | None: try: parsed = Decimal(str(value)) except Exception as exc: - raise ValueError('无效输入,请输入数字。') from exc + raise ValueError(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) from exc return parsed @@ -108,11 +111,17 @@ def __check_range__(value: Decimal | float | int, field_meta: FieldMetaInfo) -> # 确保解析后的 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} 之间的数字。') + 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(f'请输入在 -∞ 和 {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(f'请输入在 {field_meta.importer_ge} 和 +∞ 之间的数字。') + errors.append(msg(MessageKey.NUMBER_BETWEEN_MIN_AND_POS_INF, minimum=field_meta.importer_ge)) else: pass @@ -123,7 +132,7 @@ def __validate__(cls, value: Decimal | Any, field_meta: FieldMetaInfo) -> float # 如果输入不是 Decimal 类型,尝试转换。 parsed = cls.__maybe_decimal__(value) if parsed is None: - raise ValueError('无效输入,请输入数字。') + raise ValueError(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) # 初始化一个错误信息列表。 errors: list[str] = cls.__check_range__(value, field_meta) if errors: @@ -131,5 +140,5 @@ def __validate__(cls, value: Decimal | Any, field_meta: FieldMetaInfo) -> float parsed = canonicalize_decimal(parsed, field_meta.fraction_digits) value = transform_decimal(parsed) if value is None: - raise ValueError('无效输入,请输入数字。') + raise ValueError(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) return value diff --git a/src/excelalchemy/types/value/number_range.py b/src/excelalchemy/types/value/number_range.py index f749374..c8d0aed 100644 --- a/src/excelalchemy/types/value/number_range.py +++ b/src/excelalchemy/types/value/number_range.py @@ -2,6 +2,9 @@ from decimal import Decimal from typing import Any +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.types.abstract import ComplexABCValueType from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import Key @@ -23,8 +26,8 @@ def __init__(self, start: Decimal | int | float | None, end: Decimal | int | flo @classmethod def model_items(cls) -> list[tuple[Key, FieldMetaInfo]]: return [ - (Key('start'), FieldMetaInfo(label='最小值')), - (Key('end'), FieldMetaInfo(label='最大值')), + (Key('start'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_MINIMUM_VALUE))), + (Key('end'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_MAXIMUM_VALUE))), ] @classmethod @@ -46,7 +49,7 @@ def serialize(cls, value: dict[str, str] | str | Any, field_meta: FieldMetaInfo) 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) + logging.warning('%s could not parse Excel input %s; returning the original value. Reason: %s', cls.__name__, value, exc) # Return the original value if parsing fails return value @@ -58,7 +61,7 @@ def deserialize(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: 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) + logging.warning('ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', cls.__name__, value, exc) return str(value) @classmethod @@ -66,7 +69,7 @@ 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('最小值不能大于最大值') + 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)) @@ -91,6 +94,6 @@ def __maybe_number_range__(value: dict[str, Decimal] | Any, field_meta: FieldMet 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(msg(MessageKey.ENTER_NUMBER)) from exc - raise ValueError('请输入符合格式的数字') + raise ValueError(msg(MessageKey.ENTER_NUMBER_EXPECTED_FORMAT)) diff --git a/src/excelalchemy/types/value/organization.py b/src/excelalchemy/types/value/organization.py index c30e4c0..59ea944 100644 --- a/src/excelalchemy/types/value/organization.py +++ b/src/excelalchemy/types/value/organization.py @@ -2,6 +2,8 @@ from typing import Any from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.multi_checkbox import MultiCheckbox from excelalchemy.types.value.radio import Radio @@ -12,9 +14,9 @@ class SingleOrganization(Radio): @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}""" + 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 serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: @@ -40,7 +42,7 @@ def comment(cls, field_meta: FieldMetaInfo) -> str: return '\n'.join( [ field_meta.comment_required, - f'提示:{field_meta.hint or "需按照组织架构树填写组织完整路径,如“XX公司/一级部门/二级部门”,多选时,选项之间用“、”连接"}', + dmsg(MessageKey.COMMENT_HINT, value=field_meta.hint or dmsg(MessageKey.MULTI_ORGANIZATION_HINT)), ] ) diff --git a/src/excelalchemy/types/value/phone_number.py b/src/excelalchemy/types/value/phone_number.py index 2e2db42..b068626 100644 --- a/src/excelalchemy/types/value/phone_number.py +++ b/src/excelalchemy/types/value/phone_number.py @@ -1,6 +1,8 @@ import re from typing import Any +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.string import String @@ -13,6 +15,6 @@ def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: parsed = str(value) if not PHONE_NUMBER_PATTERN.match(parsed): - raise ValueError('请输入正确的手机号') + raise ValueError(msg(MessageKey.VALID_PHONE_NUMBER_REQUIRED)) return parsed diff --git a/src/excelalchemy/types/value/radio.py b/src/excelalchemy/types/value/radio.py index 122dec6..d252dfe 100644 --- a/src/excelalchemy/types/value/radio.py +++ b/src/excelalchemy/types/value/radio.py @@ -3,6 +3,9 @@ from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR from excelalchemy.exc 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.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.identity import OptionId @@ -14,10 +17,15 @@ class Radio(ABCValueType, str): @classmethod def comment(cls, field_meta: FieldMetaInfo) -> str: if not field_meta.options: - logging.error('%s 类型的字段 %s 必须设置 options', cls.__name__, field_meta.label) + 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, '单/多选:单选', field_meta.comment_hint] + [ + 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 @@ -33,10 +41,10 @@ def deserialize(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: return field_meta.options_id_map[value.strip()].name except Exception as exc: logging.warning( - '类型【%s】无法为【%s】找到【%s】的选项, 返回原值, 原因 %s', + 'Type %s could not resolve option %s for field %s; returning the original value. Reason: %s', cls.__name__, - field_meta.label, value, + field_meta.label, exc, ) return value if value is not None else '' @@ -44,21 +52,21 @@ def deserialize(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: @classmethod def __validate__(cls, value: str, field_meta: FieldMetaInfo) -> OptionId | str: # return Option.id if MULTI_CHECKBOX_SEPARATOR in value: - raise ValueError('多选不支持') + raise ValueError(msg(MessageKey.MULTIPLE_SELECTIONS_NOT_SUPPORTED)) parsed = value.strip() if field_meta.options is None: - raise ProgrammaticError('当验证【RADIO / MULTI_CHECKBOX / SELECT】类型字段时,选项不得为空!') + raise ProgrammaticError(msg(MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_SELECTION_FIELDS)) if not field_meta.options: # empty - logging.warning('%s 类型字段"%s"的选项为空,将返回原值', cls.__name__, field_meta.label) + 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('选项不存在,请参照字段注释填写') + raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_FIELD_COMMENT)) return field_meta.options_name_map[parsed].id diff --git a/src/excelalchemy/types/value/staff.py b/src/excelalchemy/types/value/staff.py index a248f6a..ae437dc 100644 --- a/src/excelalchemy/types/value/staff.py +++ b/src/excelalchemy/types/value/staff.py @@ -2,6 +2,9 @@ from typing import Any from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR +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.types.field import FieldMetaInfo from excelalchemy.types.identity import OptionId from excelalchemy.types.value.multi_checkbox import MultiCheckbox @@ -13,9 +16,9 @@ class SingleStaff(Radio): @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}""" + 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 serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: @@ -42,7 +45,7 @@ def comment(cls, field_meta: FieldMetaInfo) -> str: return '\n'.join( [ field_meta.comment_required, - f'提示:{field_meta.hint or "请输入人员姓名和工号,如“张三/001”,多选时,选项之间用“、”连接"}', + dmsg(MessageKey.COMMENT_HINT, value=field_meta.hint or dmsg(MessageKey.MULTI_STAFF_HINT)), ] ) @@ -61,10 +64,10 @@ def deserialize(cls, value: str | list[OptionId] | Any, field_meta: FieldMetaInf if isinstance(value, list): if len(value) != len(set(value)): - raise ValueError('选项有重复') + raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) option_names = field_meta.exchange_option_ids_to_names(value) return f'{MULTI_CHECKBOX_SEPARATOR}'.join(option_names) - logging.warning('%s 反序列化失败', cls.__name__) + logging.warning('%s could not be deserialized', cls.__name__) return value diff --git a/src/excelalchemy/types/value/string.py b/src/excelalchemy/types/value/string.py index 65bb6d0..085dd2b 100644 --- a/src/excelalchemy/types/value/string.py +++ b/src/excelalchemy/types/value/string.py @@ -2,6 +2,9 @@ from excelalchemy.const import CharacterSet from excelalchemy.exc 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.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo @@ -87,7 +90,7 @@ def comment(cls, field_meta: FieldMetaInfo) -> str: field_meta.comment_unique, field_meta.comment_required, field_meta.comment_max_length, - '可输入内容:中文、数字、大写字母、小写字母、符号', + dmsg(MessageKey.COMMENT_STRING_ALLOWED_CONTENT), field_meta.comment_hint, ] ) @@ -108,7 +111,7 @@ def __validate__(cls, value: str, field_meta: FieldMetaInfo) -> 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}个字') + errors.append(msg(MessageKey.MAX_LENGTH_CHARACTERS, max_length=field_meta.importer_max_length)) errors.extend(cls.__check_character_set__(parsed, field_meta)) @@ -121,11 +124,16 @@ def __validate__(cls, value: str, field_meta: FieldMetaInfo) -> str: 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 未设置') + raise ProgrammaticError(msg(MessageKey.CHARACTER_SET_NOT_CONFIGURED)) 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 diff --git a/src/excelalchemy/types/value/tree.py b/src/excelalchemy/types/value/tree.py index dec4519..b985f78 100644 --- a/src/excelalchemy/types/value/tree.py +++ b/src/excelalchemy/types/value/tree.py @@ -1,6 +1,8 @@ import logging from typing import Any +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.multi_checkbox import MultiCheckbox from excelalchemy.types.value.radio import Radio @@ -14,7 +16,7 @@ def comment(cls, field_meta: FieldMetaInfo) -> str: return '\n'.join( [ field_meta.comment_required, - f'提示:{field_meta.hint or "需按照组织架构树填写组织完整路径,如“XX公司/一级部门/二级部门”,多选时,选项之间用“、”连接"}', + dmsg(MessageKey.COMMENT_HINT, value=field_meta.hint or dmsg(MessageKey.SINGLE_TREE_HINT)), ] ) @@ -39,12 +41,9 @@ class MultiTreeNode(MultiCheckbox): @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}""" + 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 serialize(cls, value: Any, field_meta: FieldMetaInfo) -> Any: diff --git a/src/excelalchemy/types/value/url.py b/src/excelalchemy/types/value/url.py index c14d685..0054734 100644 --- a/src/excelalchemy/types/value/url.py +++ b/src/excelalchemy/types/value/url.py @@ -2,6 +2,8 @@ from pydantic import HttpUrl, TypeAdapter +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.string import String @@ -17,7 +19,7 @@ def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> str: try: cls._validator.validate_python(parsed) except Exception: - errors.append('请输入正确的网址') + errors.append(msg(MessageKey.VALID_URL_REQUIRED)) if errors: raise ValueError(*errors) diff --git a/tests/contracts/test_core_components_contract.py b/tests/contracts/test_core_components_contract.py index 8b21181..c7b1faf 100644 --- a/tests/contracts/test_core_components_contract.py +++ b/tests/contracts/test_core_components_contract.py @@ -49,7 +49,7 @@ 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='模拟失败') + error = ExcelCellError(label=Label('姓名'), message='Simulated failure') tracker.register_cell_errors(RowIndex(0), [error], df) diff --git a/tests/contracts/test_import_contract.py b/tests/contracts/test_import_contract.py index 0f969ca..8e9b98f 100644 --- a/tests/contracts/test_import_contract.py +++ b/tests/contracts/test_import_contract.py @@ -86,3 +86,21 @@ async def test_import_result_workbook_marks_failed_cells_in_red(self): row_colors = [get_fill_color(cell) for cell in worksheet[3]] assert BACKGROUND_ERROR_COLOR in row_colors + + 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' diff --git a/tests/contracts/test_storage_contract.py b/tests/contracts/test_storage_contract.py index 3c35946..a01f679 100644 --- a/tests/contracts/test_storage_contract.py +++ b/tests/contracts/test_storage_contract.py @@ -43,7 +43,10 @@ async def test_export_upload_without_storage_backend_raises_clear_error(self): with self.assertRaises(ConfigError) as cm: alchemy.export_upload('missing-storage.xlsx', [sample_simple_export_row()]) - self.assertEqual(str(cm.exception), '未配置存储后端,请传入 storage=... 或安装并配置 ExcelAlchemy[minio]') + 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 diff --git a/tests/contracts/test_template_contract.py b/tests/contracts/test_template_contract.py index ec6250f..082896c 100644 --- a/tests/contracts/test_template_contract.py +++ b/tests/contracts/test_template_contract.py @@ -74,3 +74,15 @@ async def test_download_template_returns_workbook_without_excel_data_validation( 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/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index 4f0f753..46ab765 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -306,18 +306,18 @@ async def test_import_records_cell_errors_for_invalid_simple_workbook(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')], } } @@ -415,7 +415,10 @@ class EmptyCModel(BaseModel): ... 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_non_fieldmeta_definition_raises_programmatic_error(self): class EmptyFieldMetaModel(BaseModel): @@ -424,7 +427,7 @@ class EmptyFieldMetaModel(BaseModel): 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') async def test_passing_non_config_object_raises_config_error(self): class NotImporterConfigModel(BaseModel): @@ -433,7 +436,7 @@ class NotImporterConfigModel(BaseModel): with self.assertRaises(ConfigError) as cm: ExcelAlchemy(NotImporterConfigModel) - self.assertEqual(str(cm.exception), '导出模式的配置类必须是 ExporterConfig') + self.assertEqual(str(cm.exception), 'Export mode requires an ExporterConfig instance') async def test_download_template_in_export_mode_raises_config_error(self): config = ExporterConfig(self.MergeHeaderImporter, minio=cast(Minio, self.minio)) @@ -442,4 +445,4 @@ async def test_download_template_in_export_mode_raises_config_error(self): 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/contract_models.py b/tests/support/contract_models.py index a0adc6c..2303af8 100644 --- a/tests/support/contract_models.py +++ b/tests/support/contract_models.py @@ -136,4 +136,4 @@ async def updater(data: dict[str, Any], context: dict[str, Any] | None) -> dict[ async def failing_creator(data: dict[str, Any], context: dict[str, Any] | None) -> dict[str, Any]: - raise ExcelCellError(label=Label('姓名'), message='模拟失败') + raise ExcelCellError(label=Label('姓名'), message='Simulated failure') diff --git a/tests/unit/test_excel_exceptions.py b/tests/unit/test_excel_exceptions.py index c258792..9ecc43e 100644 --- a/tests/unit/test_excel_exceptions.py +++ b/tests/unit/test_excel_exceptions.py @@ -5,57 +5,57 @@ class TestExcelExceptions(BaseTestCase): async def test_excel_cell_errors_compare_equal_when_message_and_label_match(self): - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') - exc2 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') + 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='请输入正确的邮箱') - exc2 = ExcelCellError(label=Label('邮箱1'), message='请输入正确的邮箱') + 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='请输入正确的邮箱') - assert repr(exc1) == "ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱')" + 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='请输入正确的邮箱') - assert str(exc1) == '【邮箱】请输入正确的邮箱' + 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='请输入正确的邮箱') + 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='请输入正确的邮箱') + exc1 = ExcelCellError(label=Label('邮箱'), message='Enter a valid email address') assert exc1.unique_label == '邮箱' - exc1 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱', parent_label=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='请输入正确的邮箱') - exc2 = ExcelCellError(label=Label('邮箱'), message='请输入正确的邮箱') + 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='请输入正确的邮箱') - exc2 = ExcelCellError(label=Label('邮箱1'), message='请输入正确的邮箱') + 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='请输入正确的邮箱') + 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 发生行错误') - assert exc1.message == '导入 Excel 发生行错误' + exc1 = ExcelRowError(message='Excel row import error') + assert exc1.message == 'Excel row import error' - exc1 = ExcelRowError(message='请输入正确的邮箱') - assert exc1.message == '请输入正确的邮箱' + exc1 = ExcelRowError(message='Enter a valid email address') + assert exc1.message == 'Enter a valid email address' - exc1 = ExcelRowError(message='请输入正确的邮箱') - assert str(exc1) == '请输入正确的邮箱' + exc1 = ExcelRowError(message='Enter a valid email address') + assert str(exc1) == 'Enter a valid email address' - exc1 = ExcelRowError(message='请输入正确的邮箱') - assert repr(exc1) == "ExcelRowError(message='请输入正确的邮箱')" + exc1 = ExcelRowError(message='Enter a valid email address') + assert repr(exc1) == "ExcelRowError(message='Enter a valid email address')" diff --git a/tests/unit/test_field_metadata.py b/tests/unit/test_field_metadata.py index f0ef026..a4a04dc 100644 --- a/tests/unit/test_field_metadata.py +++ b/tests/unit/test_field_metadata.py @@ -99,7 +99,7 @@ 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_uses_parent_label_for_nested_fields(self): class Importer(BaseModel): diff --git a/tests/unit/test_i18n_messages.py b/tests/unit/test_i18n_messages.py new file mode 100644 index 0000000..840332a --- /dev/null +++ b/tests/unit/test_i18n_messages.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + +from excelalchemy import Date, DateFormat, FieldMeta +from excelalchemy.i18n.messages import MessageKey, display_message, message, use_display_locale +from excelalchemy.types.field import extract_declared_field_metadata +from excelalchemy.types.result 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_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/tests/unit/value_types/test_date_value_type.py b/tests/unit/value_types/test_date_value_type.py index bdfe4ea..4440caf 100644 --- a/tests/unit/value_types/test_date_value_type.py +++ b/tests/unit/value_types/test_date_value_type.py @@ -51,10 +51,10 @@ class Importer(BaseModel): 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的日期' + 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_import_rejects_dates_that_do_not_match_day_format(self): class Importer(BaseModel): @@ -70,7 +70,7 @@ 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_serialize_parses_supported_date_inputs(self): class Importer(BaseModel): diff --git a/tests/unit/value_types/test_email_value_type.py b/tests/unit/value_types/test_email_value_type.py index 7a7bb28..ca03fc9 100644 --- a/tests/unit/value_types/test_email_value_type.py +++ b/tests/unit/value_types/test_email_value_type.py @@ -17,7 +17,7 @@ class Importer(BaseModel): assert result.fail_count == 1 row, col, first_error = RowIndex(0), ColumnIndex(2), 0 assert alchemy.cell_errors[row][col][first_error] == ExcelCellError( - label=Label('邮箱'), message='请输入正确的邮箱' + label=Label('邮箱'), message='Enter a valid email address' ) async def test_import_accepts_valid_email_value(self): From 4813a2683872f72d7d5bf70dbeb0be45680346ee Mon Sep 17 00:00:00 2001 From: ruicore Date: Fri, 27 Mar 2026 18:45:12 +0800 Subject: [PATCH 15/27] feat(PR-12): update docs --- ABOUT.md | 266 ++++++++++++++++++++++++++++--- README.md | 363 +++++++++++++++++++------------------------ README_cn.md | 337 ++++++++++++++++----------------------- docs/architecture.md | 132 ++++++++++++++++ 4 files changed, 673 insertions(+), 425 deletions(-) create mode 100644 docs/architecture.md diff --git a/ABOUT.md b/ABOUT.md index 1bb0cfe..83dbce1 100644 --- a/ABOUT.md +++ b/ABOUT.md @@ -1,33 +1,255 @@ -# 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. + +## 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/README.md b/README.md index d780b34..fff5079 100755 --- a/README.md +++ b/README.md @@ -1,118 +1,128 @@ -> [中文](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/README_cn.md) | English -> +# ExcelAlchemy +[中文 README](./README_cn.md) · [About](./ABOUT.md) · [Architecture](./docs/architecture.md) -# 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 for schema-driven Excel import and export with Pydantic models. It supports pluggable storage backends and currently ships with a built-in Minio-compatible implementation. +ExcelAlchemy is a schema-driven Excel import/export library for Python. +It turns Pydantic models into Excel templates, validates spreadsheet input back into application data, and keeps the import/export workflow explicit, typed, and extensible. -## Installation +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. -Use pip to install: +## What This Project Is -``` -pip install ExcelAlchemy -``` +- 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. -If you want the built-in Minio backend, install the optional extra: +## What This Project Is Not -```bash -pip install "ExcelAlchemy[minio]" -``` +- Not a general spreadsheet analysis library. +- Not a pandas-first data wrangling tool. +- Not a GUI spreadsheet editor. +- Not a fully generic forms framework. -ExcelAlchemy currently supports Python 3.12 through 3.14. -Python 3.14 is the primary supported version, and new behavior or dependency updates may be optimized for it first. +## Why This Exists -## Storage backends +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 accepts any storage backend that implements the `ExcelStorage` protocol. +ExcelAlchemy treats Excel as a typed contract: -- Use `storage=...` when you want a custom backend. -- The legacy `minio=..., bucket_name=..., url_expires=...` configuration still works and uses the built-in Minio implementation. -- If you use the built-in Minio backend, install `ExcelAlchemy[minio]`. +- 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 -Example custom in-memory storage: +## Highlights -```python -from base64 import b64decode -from io import BytesIO -from typing import Any +- Pydantic v2-based schema extraction and validation +- Locale-aware workbook text with `locale='zh-CN' | 'en'` +- Pluggable storage via `ExcelStorage` +- No pandas runtime dependency +- Python 3.12-3.14 support, with 3.14 as the primary target +- `uv`-based development and CI workflow +- Contract tests that protect import/export behavior during refactors -from openpyxl import load_workbook -from pydantic import BaseModel +## Architecture -from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, FieldMeta, ImporterConfig, Number, String -from excelalchemy.core.table import WorksheetTable -from excelalchemy.types.identity import UrlStr +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] -class InMemoryExcelStorage(ExcelStorage): - def __init__(self) -> None: - self.fixtures: dict[str, bytes] = {} - self.uploaded: dict[str, bytes] = {} + G --> H[MinioStorageGateway] + G --> I[Custom Storage] - def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: - workbook = load_workbook(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) + B --> J[FieldMeta / FieldMetaInfo] + E --> K[Pydantic Adapter] + F --> L[i18n Display Messages] + E --> M[Runtime Error Messages] +``` - 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}') +See the full breakdown in [docs/architecture.md](./docs/architecture.md). +## Design Principles -class Importer(BaseModel): - age: Number = FieldMeta(label='Age', order=1) - name: String = FieldMeta(label='Name', order=2) +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. -async def creator(data: dict[str, Any], context: None) -> Any: - return data +## Quick Start +### Install -storage = InMemoryExcelStorage() +```bash +pip install ExcelAlchemy +``` -# Template generation does not require a storage backend. -template_alchemy = ExcelAlchemy(ImporterConfig(Importer, creator=creator)) -template_base64 = template_alchemy.download_template() -print(template_base64[:40]) +If you want the built-in Minio backend: -# Uploading export output uses the custom storage backend. -export_alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=storage)) -url = export_alchemy.export_upload('people.xlsx', [{'age': 18, 'name': 'Alice'}]) -print(url) # memory://people.xlsx -print(storage.uploaded['people.xlsx'][:2]) # b'PK' +```bash +pip install "ExcelAlchemy[minio]" ``` -## Usage +## Minimal Example + +```python +from pydantic import BaseModel + +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -### Choose template/result language -`locale` controls Excel-facing display text such as: +class Importer(BaseModel): + age: Number = FieldMeta(label='Age', order=1) + name: String = FieldMeta(label='Name', order=2) -- the header hint in row 1 + +alchemy = ExcelAlchemy(ImporterConfig(Importer, locale='en')) +template_base64 = alchemy.download_template() +``` + +## Locale-Aware Workbook Output + +`locale` affects workbook-facing display text such as: + +- header hint text - column comments - result workbook column titles -- row-level validation status text - -The default is `zh-CN`. If you want an English template and result workbook, pass `locale='en'`. +- row validation status labels ```python from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String @@ -124,19 +134,13 @@ class Importer(BaseModel): name: String = FieldMeta(label='Name', order=2) -alchemy_zh = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')) -template_zh = alchemy_zh.download_template() - -alchemy_en = ExcelAlchemy(ImporterConfig(Importer, locale='en')) -template_en = alchemy_en.download_template() +zh_template = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')).download_template() +en_template = ExcelAlchemy(ImporterConfig(Importer, locale='en')).download_template() ``` -The same `locale` option also applies to import result workbooks: +The same `locale` also controls import result workbooks: ```python -from excelalchemy import ExcelAlchemy, ImporterConfig - - alchemy = ExcelAlchemy( ImporterConfig( Importer, @@ -145,154 +149,113 @@ alchemy = ExcelAlchemy( locale='en', ) ) -result = await alchemy.import_data(input_excel_name='people.xlsx', output_excel_name='people-result.xlsx') +result = await alchemy.import_data("people.xlsx", "people-result.xlsx") ``` -### Generate Excel template from Pydantic class +## Storage Extension Point + +Storage is modeled as a protocol, not a product decision. ```python -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -from pydantic import BaseModel +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig +from excelalchemy.core.table import WorksheetTable +from excelalchemy.types.identity import UrlStr -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) +class InMemoryExcelStorage(ExcelStorage): + def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: + ... -``` -* 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: + def upload_excel(self, output_name: str, content_with_prefix: str) -> UrlStr: + ... -```python -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -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) - -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) +alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=InMemoryExcelStorage())) ``` -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 +Use the built-in Minio implementation when you want it, but the library no longer requires Minio to define its architecture. -The recommended way to use the built-in Minio backend is to pass an explicit storage strategy: +## Why These Design Choices -```python -import asyncio -from typing import Any +### Why no pandas? -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -from excelalchemy.core.storage import MinioStorageGateway -from minio import Minio -from pydantic import BaseModel +ExcelAlchemy uses `openpyxl` plus an internal `WorksheetTable` abstraction. +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 -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) +### 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: -def data_converter(data: dict[str, Any]) -> dict[str, Any]: - """Custom data converter, here you can modify the result of Importer.model_dump()""" - data['age'] = data['age'] + 1 - data['name'] = {"phone": data['phone']} - return data +- `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. -async def create_func(data: dict[str, Any], context: None) -> Any: - """Your defined creation function""" - # do something to create data - return True +### 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. -async def main(): - storage = MinioStorageGateway( - ImporterConfig( - create_importer_model=Importer, - creator=create_func, - data_converter=data_converter, - minio=Minio(endpoint=''), # reachable minio address - bucket_name='excel', - url_expires=3600, - ) - ) - alchemy = ExcelAlchemy( - ImporterConfig( - create_importer_model=Importer, - creator=create_func, - data_converter=data_converter, - storage=storage, - ) - ) - result = await alchemy.import_data(input_excel_name='test.xlsx', output_excel_name="test.xlsx") - print(result) +## Evolution +This repository intentionally records its evolution: -asyncio.run(main()) -``` +- `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 -* The example above uses the built-in Minio-compatible storage strategy, so you need to install Minio and create a bucket if you want to use that backend. -* If you already have your own object store, local filesystem layer, or test double, pass it as `storage=...` instead. -* The older `minio=..., bucket_name=..., url_expires=...` configuration is still supported for compatibility, but `storage=MinioStorageGateway(...)` is now the preferred form. -* You can also set `locale='en'` or `locale='zh-CN'` on `ImporterConfig(...)` to control the language used in generated templates and import result workbooks. +These are not incidental refactors; they are the story of the codebase. +See [ABOUT.md](./ABOUT.md) for the migration rationale behind each step. -* 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.model_dump().` 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 storage key used by your backend, and the `output_excel_name` parameter is where the parsing result workbook will be uploaded. 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) +## Pydantic v1 vs v2 +The short version: -## Development +| 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 | -Install `uv`, then sync the development environment: +More detail is documented in [ABOUT.md](./ABOUT.md). -```bash -uv sync --extra development -uv run pre-commit install -``` +## Docs Map -The project uses the standard `src/` layout, so local development should go through the managed `uv` environment rather than relying on repository-root imports. +- [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 -Common local commands: +The project uses `uv` for local development and CI. ```bash -uv run ruff format --check . +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 ``` -The CI workflow uses `uv` for dependency management and runs `ruff`, `pyright`, and the test matrix on Python 3.12, 3.13, and 3.14. - -### Contributing - -If you have questions, bug reports, or feature ideas, please open an issue in [GitHub Issues](https://github.com/RayCarterLab/ExcelAlchemy/issues). -Pull requests are welcome. Before opening one, please run the local validation commands above and update documentation when behavior changes. +## License -### License -ExcelAlchemy is licensed under the MIT license. For more information, please see the [LICENSE](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/LICENSE) file. +MIT. See [LICENSE](./LICENSE). diff --git a/README_cn.md b/README_cn.md index 31796b8..33015f1 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,118 +1,109 @@ -> [English](https://github.com/RayCarterLab/ExcelAlchemy) | 中文 -> -# ExcelAlchemy 使用指南 +# ExcelAlchemy -# 📊 ExcelAlchemy +[English README](./README.md) · [项目说明](./ABOUT.md) · [架构文档](./docs/architecture.md) -ExcelAlchemy 是一个基于 Pydantic 模型进行 Excel 导入导出的 Python 库。它支持可插拔存储后端,并且当前内置了 Minio 兼容实现。 +ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 +它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 -## 安装 +你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 -使用 pip 安装: +## 这个项目适合什么 -``` -pip install ExcelAlchemy -``` +- 需要给业务方发 Excel 模板并回收数据 +- 需要把 Excel 输入和后端模型保持一致 +- 需要在失败结果中明确指出哪一格有问题 +- 想要一个可以接自定义存储的 Excel 工作流库 -如果你要使用内置的 Minio 后端,请安装可选 extra: +## 这个项目不打算做什么 -```bash -pip install "ExcelAlchemy[minio]" -``` +- 不做通用表格分析库 +- 不做 pandas 风格的数据处理框架 +- 不做桌面表格编辑器 +- 不追求“魔法式自动推断一切” -ExcelAlchemy 当前支持 Python 3.12 到 3.14。 -其中 Python 3.14 是主支持版本,新的行为优化和依赖升级会优先围绕 3.14 进行。 +## 核心特点 -## 存储后端 +- 基于 Pydantic v2 的 schema 驱动设计 +- 支持 `locale='zh-CN' | 'en'` 的 Excel 展示文案 +- 支持可插拔存储后端 `ExcelStorage` +- 运行时不依赖 pandas +- 支持 Python 3.12-3.14,主支持版本是 3.14 +- 使用 `uv` 管理开发与 CI -ExcelAlchemy 可以接收任何实现了 `ExcelStorage` 协议的存储后端。 +## 项目定位 -- 如果你要接自定义存储,请使用 `storage=...` -- 旧的 `minio=..., bucket_name=..., url_expires=...` 配置仍然可用,并会继续走内置的 Minio 实现 -- 如果你要使用内置的 Minio 后端,请安装 `ExcelAlchemy[minio]` +这个仓库不只是“一个能用的库”,也是一个展示工程思考的作品: -一个简单的内存存储示例: +- 为什么要从 Pydantic v1 迁到 v2 +- 为什么要去掉 pandas +- 为什么要做 storage abstraction +- 为什么 facade 外面要简洁,里面要分层 +- 为什么国际化先从消息层和 workbook display text 开始 -```python -from base64 import b64decode -from io import BytesIO -from typing import Any +详细设计思路见 [ABOUT.md](./ABOUT.md)。 -from openpyxl import load_workbook -from pydantic import BaseModel +## 架构概览 -from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, FieldMeta, ImporterConfig, Number, String -from excelalchemy.core.table import WorksheetTable -from excelalchemy.types.identity import UrlStr +```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 协议] + G --> H[MinioStorageGateway] + G --> I[自定义存储实现] -class InMemoryExcelStorage(ExcelStorage): - def __init__(self) -> None: - self.fixtures: dict[str, bytes] = {} - self.uploaded: dict[str, bytes] = {} + B --> J[FieldMeta / FieldMetaInfo] + E --> K[Pydantic Adapter] + F --> L[i18n Display Messages] + E --> M[Runtime Error Messages] +``` - def read_excel_table(self, input_excel_name: str, *, skiprows: int, sheet_name: str) -> WorksheetTable: - workbook = load_workbook(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) +完整分层说明见 [docs/architecture.md](./docs/architecture.md)。 - 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}') +## 安装 +```bash +pip install ExcelAlchemy +``` -class Importer(BaseModel): - age: Number = FieldMeta(label='年龄', order=1) - name: String = FieldMeta(label='姓名', order=2) +如果你要使用内置的 Minio 后端: +```bash +pip install "ExcelAlchemy[minio]" +``` + +## 快速开始 + +```python +from pydantic import BaseModel -async def creator(data: dict[str, Any], context: None) -> Any: - return data +from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -storage = InMemoryExcelStorage() +class Importer(BaseModel): + age: Number = FieldMeta(label='年龄', order=1) + name: String = FieldMeta(label='姓名', order=2) -# 生成模板时不需要存储后端。 -template_alchemy = ExcelAlchemy(ImporterConfig(Importer, creator=creator)) -template_base64 = template_alchemy.download_template() -print(template_base64[:40]) -# 导出上传时会走自定义存储后端。 -export_alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=storage)) -url = export_alchemy.export_upload('people.xlsx', [{'age': 18, 'name': 'Alice'}]) -print(url) # memory://people.xlsx -print(storage.uploaded['people.xlsx'][:2]) # b'PK' +alchemy = ExcelAlchemy(ImporterConfig(Importer)) +template_base64 = alchemy.download_template() ``` -## 使用方法 - -### 选择模板/结果语言 +## 选择模板 / 结果语言 -`locale` 用来控制 Excel 展示文案,例如: +`locale` 会影响 Excel 里真正给用户看的文案,例如: -- 第一行的填写须知 +- 第一行填写须知 - 表头批注 -- 导入结果工作簿里的结果列标题 -- 行级“校验通过 / 校验不通过”文本 +- 结果列标题 +- “校验通过 / 校验不通过” 文本 -默认值是 `zh-CN`。如果你希望生成英文模板和英文结果工作簿,可以传 `locale='en'`。 +默认是 `zh-CN`,如果你想生成英文模板或英文结果工作簿,可以传 `locale='en'`。 ```python from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String @@ -124,19 +115,13 @@ class Importer(BaseModel): name: String = FieldMeta(label='Name', order=2) -alchemy_zh = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')) -template_zh = alchemy_zh.download_template() - -alchemy_en = ExcelAlchemy(ImporterConfig(Importer, locale='en')) -template_en = alchemy_en.download_template() +template_zh = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')).download_template() +template_en = ExcelAlchemy(ImporterConfig(Importer, locale='en')).download_template() ``` 导入结果工作簿也会使用同一个 `locale`: ```python -from excelalchemy import ExcelAlchemy, ImporterConfig - - alchemy = ExcelAlchemy( ImporterConfig( Importer, @@ -145,156 +130,102 @@ alchemy = ExcelAlchemy( locale='en', ) ) -result = await alchemy.import_data(input_excel_name='people.xlsx', output_excel_name='people-result.xlsx') +result = await alchemy.import_data("people.xlsx", "people-result.xlsx") ``` -### 从 Pydantic 类生成 Excel 模板 +## 存储扩展点 + +ExcelAlchemy 接受任何实现了 `ExcelStorage` 协议的存储后端。 ```python -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -from pydantic import BaseModel +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig +from excelalchemy.core.table import WorksheetTable +from excelalchemy.types.identity import UrlStr -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) +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(ImporterConfig(Importer)) -base64content = alchemy.download_template() -print(base64content) +alchemy = ExcelAlchemy(ExporterConfig(Importer, storage=InMemoryExcelStorage())) ``` -* 上面是一个简单的例子,从 Pydantic 类生成 Excel 模板,Excel 模版中将会有一个 Sheet,Sheet 名称为 `Sheet1`,并且会有四列,分别为 `年龄`、`名称`、`电话`、`地址`,其中 `年龄`、`名称` 为必填项,`电话`、`地址` 为可选项。 -* 返回一个 base64 编码的 Excel 字符串,可以直接在前端页面中使用 `window.open` 方法打开 Excel 文件,或者在浏览器地址栏中输入 base64content,即可下载 Excel 文件。 -* 在下载模版时,您也可以指定一些默认值,例如: -```python -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -from pydantic import BaseModel +如果你希望使用内置 Minio 实现,推荐显式传入 `storage=MinioStorageGateway(...)`,而不是再把 Minio 配置散落到门面层。 +## 为什么这样设计 -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) +### 为什么去掉 pandas +这个项目真正需要的是: -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) -``` - -* 上面的例子中,我们指定了一个 `sample`,`sample` 是一个列表,列表中的每个元素都是一个字典,字典中的键为 Pydantic 类中的字段名,值为该字段的默认值。 -* 最终下载的 Excel 文件中,`Sheet1` 中的第一行为字段名,第二行开始为默认值,如果某个字段没有默认值,则该字段为空,如图所示: -* ![image](https://github.com/SundayWindy/ExcelAlchemy/raw/main/images/001_sample_template.png) +- 读写 Excel +- 一个稳定的中间表格抽象 +- 对表头 / 行 / 错误坐标的精确控制 -### 从 Excel 解析 Pydantic 类并创建数据 +并不需要 pandas 擅长的分析能力。 +因此改成 `openpyxl + WorksheetTable` 更贴合问题域,也让安装和依赖稳定性更好。 -推荐通过显式的存储策略来使用内置的 Minio 后端: +### 为什么做 Pydantic adapter -```python -import asyncio -from typing import Any +Excel 元数据不应该深绑到 Pydantic 内部结构上。 +所以现在的分层是: -from excelalchemy import ExcelAlchemy, FieldMeta, ImporterConfig, Number, String -from excelalchemy.core.storage import MinioStorageGateway -from minio import Minio -from pydantic import BaseModel +- `FieldMetaInfo` 负责 Excel 元数据 +- `helper/pydantic.py` 只做适配 +- 真正的业务校验仍然由 ExcelAlchemy 控制 +这就是为什么 Pydantic v2 迁移可以做得比较稳。 -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.model_dump() 的结果进行转换""" - 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(): - storage = MinioStorageGateway( - ImporterConfig( - create_importer_model=Importer, - creator=create_func, - data_converter=data_converter, - minio=Minio(endpoint=''), # 可访问的 minio 地址 - bucket_name='excel', - url_expires=3600, - ) - ) - alchemy = ExcelAlchemy( - ImporterConfig( - create_importer_model=Importer, - creator=create_func, - data_converter=data_converter, - storage=storage, - ) - ) - result = await alchemy.import_data(input_excel_name='test.xlsx', output_excel_name="test.xlsx") - print(result) +### 为什么做 storage abstraction +这个项目不应该等于 Minio。 +Minio 只是一个默认实现,真正稳定的接口应该是 `ExcelStorage`。 -asyncio.run(main()) -``` +这样用户可以接: -* 上面的示例使用了内置的 Minio 兼容存储策略,因此如果你要使用这个后端,需要先安装 Minio,并准备好 bucket。 -* 如果你已经有自己的对象存储、本地文件系统封装或测试替身,也可以直接通过 `storage=...` 传入。 -* 旧的 `minio=..., bucket_name=..., url_expires=...` 写法仍然兼容,但现在更推荐 `storage=MinioStorageGateway(...)` 这种形式。 -* 你也可以在 `ImporterConfig(...)` 上设置 `locale='en'` 或 `locale='zh-CN'`,来控制生成模板和导入结果工作簿中的展示语言。 -* 导入的 Excel 文件,必须是从 `download_template` 方法生成的 Excel 文件,否则会产生解析错误。 -* 上面的示例代码中,我们定义了一个 `data_converter` 函数,该函数用于对 `Importer.model_dump()` 的结果进行转换,最终返回的结果将会作为 `create_func` 函数的参数。当然,此函数是可选的,如果你不需要对数据进行转换,可以不定义该函数。 -* `create_func` 函数用于创建数据,该函数的参数为 `data_converter` 函数的返回值,`context` 为 `None`,你可以在该函数中对数据进行创建,例如,你可以将数据存入数据库中。 -* `import_data` 方法的参数 `input_excel_name` 是你的存储后端里用于读取输入文件的 key,`output_excel_name` 是解析结果工作簿回写时使用的 key。该文件包含所有输入的数据,如果某条数据解析失败,则在该条数据的第一列中会有错误信息,并且会讲产生错误的单元格标红。 -* 返回 ImportResult 类型的结果,您可以在代码中查看该类的定义,该类包含了解析结果的所有信息,例如,成功导入的数据条数、失败的数据条数、失败的数据等。 +- 对象存储 +- 本地文件系统 +- 测试替身 +- 内存存储 -一个导入结果的示例, 如图所示: -* ![image](https://github.com/SundayWindy/ExcelAlchemy/raw/main/images/002_import_result.png) +## 演进记录 +这个仓库的价值,很大一部分来自它的演进过程: -## 贡献 +- `src/` layout 迁移 +- CI / 发布链路现代化 +- Pydantic 元数据层解耦 +- Pydantic v2 迁移 +- Python 3.12-3.14 现代化 +- 核心架构拆分 +- 去 pandas 化 +- 存储抽象化 +- 国际化基础层与 workbook locale 化 -如果你希望参与开发,可以先安装开发依赖并启用本地检查: +这些不是零碎优化,而是整套工程判断的痕迹。 -```bash -uv sync --extra development -uv run pre-commit install -``` +## 文档索引 -项目现在采用标准 `src/` 布局,因此本地开发建议通过 `uv` 管理的环境运行,而不是依赖仓库根目录的隐式导入。 +- [README.md](./README.md): 英文首页,偏作品集表达 +- [README_cn.md](./README_cn.md): 中文说明页,偏使用和理解 +- [ABOUT.md](./ABOUT.md): 设计原则、迁移记录、架构取舍 +- [docs/architecture.md](./docs/architecture.md): 组件边界与扩展点 -常用本地命令: +## 开发 ```bash -uv run ruff format --check . +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 ``` -CI 现在使用 `uv` 管理依赖,并运行 `ruff`、`pyright`,以及 Python 3.12、3.13、3.14 的测试矩阵。 - -如果你在使用 ExcelAlchemy 过程中遇到了问题或者有任何建议,欢迎在 [GitHub Issues](https://github.com/RayCarterLab/ExcelAlchemy/issues) 中提出。我们也非常欢迎你提交 Pull Request,贡献你的代码。在提交前,建议先运行上面的本地校验命令,并在行为变化时同步更新文档。 - ## 许可证 -ExcelAlchemy 使用 MIT 许可证。详细信息请参阅 [LICENSE](https://github.com/RayCarterLab/ExcelAlchemy/blob/main/LICENSE)。 +MIT。详见 [LICENSE](./LICENSE)。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7b7b5a8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,132 @@ +# 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] +``` + +## 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/types/field.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 Value Types + +Implement a new `ABCValueType` or `ComplexABCValueType` when you want custom workbook semantics. + +### 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. + +## 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. From 095804f280dbe04b251c4f0d8004f6ba2c26f91d Mon Sep 17 00:00:00 2001 From: ruicore Date: Fri, 27 Mar 2026 19:07:18 +0800 Subject: [PATCH 16/27] feat(2.0.0rc1) --- ABOUT.md | 10 +++ CHANGELOG.md | 55 ++++++++++++ MIGRATIONS.md | 89 +++++++++++++++++++ README.md | 26 +++++- README_cn.md | 25 +++++- docs/architecture.md | 15 ++++ docs/locale.md | 73 +++++++++++++++ docs/releases/2.0.0rc1.md | 80 +++++++++++++++++ src/excelalchemy/__init__.py | 2 +- src/excelalchemy/const.py | 21 ++--- src/excelalchemy/i18n/__init__.py | 24 ++++- src/excelalchemy/i18n/messages.py | 20 ++++- src/excelalchemy/types/value/boolean.py | 54 ++++++++--- src/excelalchemy/types/value/string.py | 15 ++-- tests/unit/test_i18n_messages.py | 15 +++- .../value_types/test_boolean_value_type.py | 19 ++++ 16 files changed, 506 insertions(+), 37 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 MIGRATIONS.md create mode 100644 docs/locale.md create mode 100644 docs/releases/2.0.0rc1.md diff --git a/ABOUT.md b/ABOUT.md index 83dbce1..2e993b9 100644 --- a/ABOUT.md +++ b/ABOUT.md @@ -125,6 +125,16 @@ That is why the project now separates: 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aaa31f4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# 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.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 + +### 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..0902e22 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,89 @@ +# 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.0rc1` and later `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') +``` + +## 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 the release candidate before moving to the final `2.0.0`. diff --git a/README.md b/README.md index fff5079..3b65404 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ExcelAlchemy -[中文 README](./README_cn.md) · [About](./ABOUT.md) · [Architecture](./docs/architecture.md) +[中文 README](./README_cn.md) · [About](./ABOUT.md) · [Architecture](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [Migration Notes](./MIGRATIONS.md) ExcelAlchemy is a schema-driven Excel import/export library for Python. It turns Pydantic models into Excel templates, validates spreadsheet input back into application data, and keeps the import/export workflow explicit, typed, and extensible. @@ -8,6 +8,8 @@ It turns Pydantic models into Excel templates, validates spreadsheet input back This repository is also a design artifact. It documents a series of deliberate engineering choices: `src/` layout, Pydantic v2 migration, pandas removal, pluggable storage, `uv`-based workflows, and locale-aware workbook output. +The current release track being prepared is `2.0.0rc1`, the first public release candidate for ExcelAlchemy 2.0. + ## What This Project Is - A library for building Excel workflows from typed schemas. @@ -68,6 +70,21 @@ flowchart TD 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. @@ -124,6 +141,13 @@ template_base64 = alchemy.download_template() - 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 pydantic import BaseModel diff --git a/README_cn.md b/README_cn.md index 33015f1..ddcb5ae 100644 --- a/README_cn.md +++ b/README_cn.md @@ -1,10 +1,12 @@ # ExcelAlchemy -[English README](./README.md) · [项目说明](./ABOUT.md) · [架构文档](./docs/architecture.md) +[English README](./README.md) · [项目说明](./ABOUT.md) · [架构文档](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [迁移说明](./MIGRATIONS.md) ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 +当前准备发布的版本线是 `2.0.0rc1`,也就是 ExcelAlchemy 2.0 的首个公开预发布版本。 + 你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 ## 这个项目适合什么 @@ -65,6 +67,21 @@ flowchart TD 完整分层说明见 [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 @@ -105,6 +122,12 @@ template_base64 = alchemy.download_template() 默认是 `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 from pydantic import BaseModel diff --git a/docs/architecture.md b/docs/architecture.md index 7b7b5a8..acd6761 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,6 +22,21 @@ flowchart TD 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 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.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/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index 943cc63..4279089 100644 --- a/src/excelalchemy/__init__.py +++ b/src/excelalchemy/__init__.py @@ -1,6 +1,6 @@ """A Python Library for Reading and Writing Excel Files""" -__version__ = '1.1.0' +__version__ = '2.0.0rc1' from excelalchemy.const import CharacterSet, DataRangeOption, DateFormat, Option from excelalchemy.core.alchemy import ExcelAlchemy from excelalchemy.core.storage_protocol import ExcelStorage diff --git a/src/excelalchemy/const.py b/src/excelalchemy/const.py index e83c11e..d150a9e 100644 --- a/src/excelalchemy/const.py +++ b/src/excelalchemy/const.py @@ -2,16 +2,11 @@ from enum import Enum from typing import Any +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import display_message as dmsg from excelalchemy.types.identity import Key, Label, OptionId -HEADER_HINT = """ -导入填写须知: -1、填写数据时,请注意查看字段名称上的注释,避免导入失败。 -2、表格中可能包含部分只读字段,可能是根据系统规则自动生成或是在编辑时禁止被修改,仅用于导出时查看,导入时不生效。 -3、字段名称背景是红色的为必填字段,导入时必须根据注释的提示填写好内容。 -4、请不要随意修改列的单元格格式,避免模板校验不通过。 -5、导入前请删除示例数据。 -""" +HEADER_HINT = dmsg(MessageKey.HEADER_HINT, locale='zh-CN') EXCEL_COMMENT_FORMAT = {'height': 100, 'width': 300, 'font_size': 7} CHARACTER_WIDTH = 1.3 @@ -20,11 +15,11 @@ UNIQUE_HEADER_CONNECTOR: str = '·' # 数据导出结果列 -RESULT_COLUMN_LABEL: Label = Label('校验结果\n重新上传前请删除此列') +RESULT_COLUMN_LABEL: Label = Label(dmsg(MessageKey.RESULT_COLUMN_LABEL, locale='zh-CN')) RESULT_COLUMN_KEY: Key = Key('__result__') # 数据导出原因列 -REASON_COLUMN_LABEL: Label = Label('失败原因\n重新上传前请删除此列') +REASON_COLUMN_LABEL: Label = Label(dmsg(MessageKey.REASON_COLUMN_LABEL, locale='zh-CN')) REASON_COLUMN_KEY: Key = Key('__reason__') BACKGROUND_REQUIRED_COLOR = 'FDAFB5' @@ -84,9 +79,9 @@ class DataRangeOption(str, Enum): DateFormat.MINUTE: 'yyyy/mm/dd hh:mm', } DATA_RANGE_OPTION_TO_CHINESE = { - DataRangeOption.PRE: '早于当前时间', - DataRangeOption.NEXT: '晚于当前时间', - DataRangeOption.NONE: '无限制', + 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'), } diff --git a/src/excelalchemy/i18n/__init__.py b/src/excelalchemy/i18n/__init__.py index 9659065..a229bb6 100644 --- a/src/excelalchemy/i18n/__init__.py +++ b/src/excelalchemy/i18n/__init__.py @@ -1,3 +1,23 @@ -from excelalchemy.i18n.messages import MessageKey, display_message, get_display_locale, message, use_display_locale +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__ = ['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 index 91c46c5..ea6d21e 100644 --- a/src/excelalchemy/i18n/messages.py +++ b/src/excelalchemy/i18n/messages.py @@ -82,6 +82,13 @@ class MessageKey(StrEnum): 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' @@ -126,6 +133,8 @@ class MessageKey(StrEnum): 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]]] = { @@ -240,7 +249,14 @@ class MessageKey(StrEnum): 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 "是" or "否"', + 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' @@ -338,6 +354,8 @@ class MessageKey(StrEnum): 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”', diff --git a/src/excelalchemy/types/value/boolean.py b/src/excelalchemy/types/value/boolean.py index 8cbabb9..fc0a413 100644 --- a/src/excelalchemy/types/value/boolean.py +++ b/src/excelalchemy/types/value/boolean.py @@ -2,6 +2,7 @@ from typing import Any 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.types.abstract import ABCValueType from excelalchemy.types.field import FieldMetaInfo @@ -12,6 +13,22 @@ class Boolean(ABCValueType): __name__ = '布尔' + @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 comment(cls, field_meta: FieldMetaInfo) -> str: return '\n'.join( @@ -28,30 +45,47 @@ def serialize(cls, value: Any, field_meta: FieldMetaInfo) -> str: @classmethod def deserialize(cls, value: bool | str | None | Any, field_meta: FieldMetaInfo) -> str: if value is None or value == '': - return '否' # 产品要求,空值默认为否 + return cls._false_display() if isinstance(value, bool): - return '是' if value else '否' + return cls._true_display() if value else cls._false_display() elif isinstance(value, str): value = value.strip() - if value not in ('是', '否'): + 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 - return value else: - logging.warning('Type %s could not deserialize %s for field %s; returning the default value "否"', cls.__name__, value, field_meta.label) + 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 '是' if str(value) == '是' else '否' + return cls._true_display() if str(value) in cls._true_values() else cls._false_display() @classmethod def __validate__(cls, value: str | bool | Any, field_meta: FieldMetaInfo) -> bool: if isinstance(value, bool): return value - value_str = str(value) + value_str = str(value).strip() - if value_str not in ('是', '否'): - raise ValueError(msg(MessageKey.BOOLEAN_ENTER_YES_OR_NO)) + if value_str in cls._true_values(): + return True + if value_str in cls._false_values(): + return False - return value_str == '是' + raise ValueError( + msg( + MessageKey.BOOLEAN_ENTER_YES_OR_NO, + true_value=cls._true_display(), + false_value=cls._false_display(), + ) + ) diff --git a/src/excelalchemy/types/value/string.py b/src/excelalchemy/types/value/string.py index 085dd2b..7bf70f5 100644 --- a/src/excelalchemy/types/value/string.py +++ b/src/excelalchemy/types/value/string.py @@ -69,17 +69,18 @@ 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): diff --git a/tests/unit/test_i18n_messages.py b/tests/unit/test_i18n_messages.py index 840332a..c2ad04b 100644 --- a/tests/unit/test_i18n_messages.py +++ b/tests/unit/test_i18n_messages.py @@ -1,7 +1,15 @@ from pydantic import BaseModel from excelalchemy import Date, DateFormat, FieldMeta -from excelalchemy.i18n.messages import MessageKey, display_message, message, use_display_locale +from excelalchemy.i18n.messages import ( + DISPLAY_DEFAULT_LOCALE, + SUPPORTED_DISPLAY_LOCALES, + SUPPORTED_RUNTIME_LOCALES, + MessageKey, + display_message, + message, + use_display_locale, +) from excelalchemy.types.field import extract_declared_field_metadata from excelalchemy.types.result import ValidateRowResult @@ -24,6 +32,11 @@ def test_display_message_uses_context_locale(self): 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) diff --git a/tests/unit/value_types/test_boolean_value_type.py b/tests/unit/value_types/test_boolean_value_type.py index 3a4dd99..9bf2893 100644 --- a/tests/unit/value_types/test_boolean_value_type.py +++ b/tests/unit/value_types/test_boolean_value_type.py @@ -1,6 +1,7 @@ from pydantic import BaseModel from excelalchemy import Boolean, FieldMeta, ValidateResult +from excelalchemy.i18n.messages import use_display_locale from tests.support import BaseTestCase, FileRegistry @@ -45,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 From 3c13f4fd935f7a929e2d2c5e0cec1ab8810639de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 12:06:53 +0800 Subject: [PATCH 17/27] fix(error): fix session error --- src/excelalchemy/core/alchemy.py | 18 ++++++++- src/excelalchemy/core/executor.py | 1 + src/excelalchemy/core/headers.py | 37 ++++++++++++++++--- src/excelalchemy/types/value/money.py | 21 +++++++++-- src/excelalchemy/util/file.py | 3 +- .../test_core_components_contract.py | 27 ++++++++++++++ tests/contracts/test_import_contract.py | 37 ++++++++++++++++++- tests/unit/test_file_utils.py | 13 +++++++ .../value_types/test_date_range_value_type.py | 4 +- .../unit/value_types/test_money_value_type.py | 11 ++++++ 10 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_file_utils.py diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 17a1e6b..36d8ba2 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -110,6 +110,20 @@ def __sync_layout_state__(self) -> None: 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 + self.__dict__.pop('input_excel_has_merged_header', None) + self.__dict__.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: @@ -143,10 +157,12 @@ def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> async def import_data(self, input_excel_name: str, output_excel_name: str) -> ImportResult: assert isinstance(self.config, ImporterConfig) - assert self._executor is not None 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: diff --git a/src/excelalchemy/core/executor.py b/src/excelalchemy/core/executor.py index 30ec9fc..2088684 100644 --- a/src/excelalchemy/core/executor.py +++ b/src/excelalchemy/core/executor.py @@ -106,6 +106,7 @@ async def _invoke_dml( 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))) diff --git a/src/excelalchemy/core/headers.py b/src/excelalchemy/core/headers.py index 7d8fbf1..b60cd8d 100644 --- a/src/excelalchemy/core/headers.py +++ b/src/excelalchemy/core/headers.py @@ -84,9 +84,10 @@ def validate( import_mode: ImportMode, ) -> ValidateHeaderResult: """Return the full header validation result consumed by the facade.""" - required_labels = [field_meta.label for field_meta in layout.ordered_field_meta if field_meta.required] - primary_labels = [field_meta.label for field_meta in layout.ordered_field_meta if field_meta.is_primary_key] - input_labels = [header.label for header in headers] + 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] = [] @@ -95,12 +96,15 @@ def validate( duplicated.append(label) else: visited.add(label) - unrecognized = list(set(input_labels) - set(field_meta.label for field_meta in layout.ordered_field_meta)) + + schema_label_set = set(schema_labels) + input_label_set = set(input_labels) + unrecognized = self._ordered_difference(input_labels, schema_label_set) missing_primary: list[Label] = [] if 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)) + 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, @@ -109,3 +113,24 @@ def validate( missing_primary=missing_primary, is_valid=not (missing_required or unrecognized or duplicated or missing_primary), ) + + @staticmethod + def _ordered_difference(values: list[Label], allowed: set[Label]) -> list[Label]: + seen: set[Label] = set() + result: list[Label] = [] + for value in values: + if value in allowed or value in seen: + continue + seen.add(value) + result.append(value) + return result + + @staticmethod + def _ordered_missing( + expected: list[Label], + actual: set[Label], + *, + excluded: set[Label] | None = None, + ) -> list[Label]: + 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/types/value/money.py b/src/excelalchemy/types/value/money.py index 61f7b6b..dbb5e74 100644 --- a/src/excelalchemy/types/value/money.py +++ b/src/excelalchemy/types/value/money.py @@ -1,11 +1,26 @@ -from typing import Any +from typing import Any, ClassVar from excelalchemy.types.field import FieldMetaInfo from excelalchemy.types.value.number import Number 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 comment(cls, field_meta: FieldMetaInfo) -> str: + return super().comment(cls._money_field_meta(field_meta)) + + @classmethod + def deserialize(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: + return super().deserialize(value, cls._money_field_meta(field_meta)) + @classmethod def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> float | int: - field_meta.fraction_digits = 2 - return super().__validate__(value, field_meta) + return super().__validate__(value, cls._money_field_meta(field_meta)) diff --git a/src/excelalchemy/util/file.py b/src/excelalchemy/util/file.py index 94481a3..d74914b 100644 --- a/src/excelalchemy/util/file.py +++ b/src/excelalchemy/util/file.py @@ -14,7 +14,8 @@ def add_excel_prefix(content: str) -> str: def remove_excel_prefix(content: str) -> str: """Remove Excel prefixes for base64 content string.""" - return content.lstrip(f'{EXCEL_PREFIX},') + prefix = f'{EXCEL_PREFIX},' + return content.removeprefix(prefix) def flatten(data: dict[str, Any], level: list[Any] | None = None) -> dict[str, Any]: diff --git a/tests/contracts/test_core_components_contract.py b/tests/contracts/test_core_components_contract.py index c7b1faf..5686043 100644 --- a/tests/contracts/test_core_components_contract.py +++ b/tests/contracts/test_core_components_contract.py @@ -1,3 +1,6 @@ +from pydantic import BaseModel + +from excelalchemy import DateFormat, DateRange, FieldMeta from excelalchemy.core.alchemy import REASON_COLUMN, RESULT_COLUMN from excelalchemy.core.headers import ExcelHeaderParser, ExcelHeaderValidator from excelalchemy.core.rows import ImportIssueTracker, RowAggregator @@ -32,6 +35,30 @@ def test_header_parser_and_validator_accept_generated_simple_headers_as_contract 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) diff --git a/tests/contracts/test_import_contract.py b/tests/contracts/test_import_contract.py index 8e9b98f..775d5dd 100644 --- a/tests/contracts/test_import_contract.py +++ b/tests/contracts/test_import_contract.py @@ -5,7 +5,7 @@ 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 SimpleContractImporter, creator +from tests.support.contract_models import SimpleContractImporter, creator, failing_creator class TestImportContracts(BaseTestCase): @@ -37,6 +37,24 @@ async def test_import_data_returns_header_invalid_result_for_invalid_header(self 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) @@ -87,6 +105,23 @@ async def test_import_result_workbook_marks_failed_cells_in_red(self): 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) 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/value_types/test_date_range_value_type.py b/tests/unit/value_types/test_date_range_value_type.py index a249046..b3fdd52 100644 --- a/tests/unit/value_types/test_date_range_value_type.py +++ b/tests/unit/value_types/test_date_range_value_type.py @@ -34,7 +34,7 @@ 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_import_returns_header_invalid_when_merged_header_loses_leading_child(self): @@ -52,7 +52,7 @@ 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_date_range_value_type_exposes_comment_and_boundaries(self): diff --git a/tests/unit/value_types/test_money_value_type.py b/tests/unit/value_types/test_money_value_type.py index a13fea3..e4c446b 100644 --- a/tests/unit/value_types/test_money_value_type.py +++ b/tests/unit/value_types/test_money_value_type.py @@ -19,3 +19,14 @@ class Importer(BaseModel): 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 From 6c9399e68c5973f2a78bf7d20cbc0c52a11639e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 13:13:45 +0800 Subject: [PATCH 18/27] refactor(pydantic): support validation using pydantic --- CHANGELOG.md | 2 + MIGRATIONS.md | 20 + README.md | 37 +- README_cn.md | 15 +- docs/architecture.md | 36 +- pyproject.toml | 5 + src/excelalchemy/__init__.py | 93 ++- src/excelalchemy/_internal/__init__.py | 2 + src/excelalchemy/_internal/constants.py | 92 +++ src/excelalchemy/_internal/deprecation.py | 22 + src/excelalchemy/_internal/header_models.py | 25 + src/excelalchemy/_internal/identity.py | 66 ++ src/excelalchemy/artifacts.py | 39 ++ src/excelalchemy/codecs/__init__.py | 14 + src/excelalchemy/codecs/base.py | 128 ++++ src/excelalchemy/codecs/boolean.py | 94 +++ src/excelalchemy/codecs/date.py | 107 ++++ src/excelalchemy/codecs/date_range.py | 168 +++++ src/excelalchemy/codecs/email.py | 32 + src/excelalchemy/codecs/money.py | 29 + src/excelalchemy/codecs/multi_checkbox.py | 76 +++ src/excelalchemy/codecs/number.py | 147 +++++ src/excelalchemy/codecs/number_range.py | 102 +++ src/excelalchemy/codecs/organization.py | 74 +++ src/excelalchemy/codecs/phone_number.py | 23 + src/excelalchemy/codecs/radio.py | 75 +++ src/excelalchemy/codecs/staff.py | 77 +++ src/excelalchemy/codecs/string.py | 143 +++++ src/excelalchemy/codecs/tree.py | 58 ++ src/excelalchemy/codecs/url.py | 30 + src/excelalchemy/config.py | 126 ++++ src/excelalchemy/const.py | 92 +-- src/excelalchemy/core/abstract.py | 24 +- src/excelalchemy/core/alchemy.py | 44 +- src/excelalchemy/core/executor.py | 14 +- src/excelalchemy/core/headers.py | 10 +- src/excelalchemy/core/rendering.py | 6 +- src/excelalchemy/core/rows.py | 14 +- src/excelalchemy/core/schema.py | 8 +- src/excelalchemy/core/storage.py | 4 +- src/excelalchemy/core/storage_minio.py | 6 +- src/excelalchemy/core/storage_protocol.py | 2 +- src/excelalchemy/core/writer.py | 14 +- src/excelalchemy/exc.py | 80 +-- src/excelalchemy/exceptions.py | 82 +++ src/excelalchemy/header_models.py | 11 + src/excelalchemy/helper/pydantic.py | 125 +++- src/excelalchemy/i18n/messages.py | 10 +- src/excelalchemy/identity.py | 8 + src/excelalchemy/metadata.py | 605 ++++++++++++++++++ src/excelalchemy/results.py | 77 +++ src/excelalchemy/types/__init__.py | 15 + src/excelalchemy/types/abstract.py | 119 +--- src/excelalchemy/types/alchemy.py | 127 +--- src/excelalchemy/types/field.py | 435 +------------ src/excelalchemy/types/header.py | 29 +- src/excelalchemy/types/identity.py | 63 +- src/excelalchemy/types/result.py | 78 +-- src/excelalchemy/types/value/__init__.py | 11 +- src/excelalchemy/types/value/boolean.py | 92 +-- src/excelalchemy/types/value/date.py | 105 +-- src/excelalchemy/types/value/date_range.py | 166 +---- src/excelalchemy/types/value/email.py | 30 +- src/excelalchemy/types/value/money.py | 27 +- .../types/value/multi_checkbox.py | 74 +-- src/excelalchemy/types/value/number.py | 145 +---- src/excelalchemy/types/value/number_range.py | 100 +-- src/excelalchemy/types/value/organization.py | 71 +- src/excelalchemy/types/value/phone_number.py | 21 +- src/excelalchemy/types/value/radio.py | 73 +-- src/excelalchemy/types/value/staff.py | 74 +-- src/excelalchemy/types/value/string.py | 141 +--- src/excelalchemy/types/value/tree.py | 55 +- src/excelalchemy/types/value/url.py | 28 +- src/excelalchemy/util/convertor.py | 4 +- src/excelalchemy/util/file.py | 5 +- .../test_core_components_contract.py | 6 +- tests/contracts/test_export_contract.py | 18 +- tests/contracts/test_pydantic_contract.py | 114 +++- tests/contracts/test_result_contract.py | 2 +- tests/contracts/test_template_contract.py | 16 + .../test_excelalchemy_workflows.py | 23 +- tests/support/storage.py | 2 +- .../unit/{value_types => codecs}/__init__.py | 0 .../test_boolean_codec.py} | 0 .../test_date_codec.py} | 0 .../test_date_range_codec.py} | 0 .../test_email_codec.py} | 0 .../test_money_codec.py} | 0 .../test_multi_checkbox_codec.py} | 0 .../test_multi_organization_codec.py} | 0 .../test_multi_staff_codec.py} | 0 .../test_number_codec.py} | 0 .../test_number_range_codec.py} | 0 .../test_phone_number_codec.py} | 0 .../test_radio_codec.py} | 0 .../test_single_organization_codec.py} | 0 .../test_single_staff_codec.py} | 0 .../test_url_codec.py} | 0 .../test_converters_and_schema_extraction.py | 10 + tests/unit/test_deprecation_policy.py | 69 ++ tests/unit/test_excel_exceptions.py | 3 +- tests/unit/test_field_metadata.py | 21 +- tests/unit/test_i18n_messages.py | 4 +- 104 files changed, 3283 insertions(+), 2286 deletions(-) create mode 100644 src/excelalchemy/_internal/__init__.py create mode 100644 src/excelalchemy/_internal/constants.py create mode 100644 src/excelalchemy/_internal/deprecation.py create mode 100644 src/excelalchemy/_internal/header_models.py create mode 100644 src/excelalchemy/_internal/identity.py create mode 100644 src/excelalchemy/artifacts.py create mode 100644 src/excelalchemy/codecs/__init__.py create mode 100644 src/excelalchemy/codecs/base.py create mode 100644 src/excelalchemy/codecs/boolean.py create mode 100644 src/excelalchemy/codecs/date.py create mode 100644 src/excelalchemy/codecs/date_range.py create mode 100644 src/excelalchemy/codecs/email.py create mode 100644 src/excelalchemy/codecs/money.py create mode 100644 src/excelalchemy/codecs/multi_checkbox.py create mode 100644 src/excelalchemy/codecs/number.py create mode 100644 src/excelalchemy/codecs/number_range.py create mode 100644 src/excelalchemy/codecs/organization.py create mode 100644 src/excelalchemy/codecs/phone_number.py create mode 100644 src/excelalchemy/codecs/radio.py create mode 100644 src/excelalchemy/codecs/staff.py create mode 100644 src/excelalchemy/codecs/string.py create mode 100644 src/excelalchemy/codecs/tree.py create mode 100644 src/excelalchemy/codecs/url.py create mode 100644 src/excelalchemy/config.py create mode 100644 src/excelalchemy/exceptions.py create mode 100644 src/excelalchemy/header_models.py create mode 100644 src/excelalchemy/identity.py create mode 100644 src/excelalchemy/metadata.py create mode 100644 src/excelalchemy/results.py rename tests/unit/{value_types => codecs}/__init__.py (100%) rename tests/unit/{value_types/test_boolean_value_type.py => codecs/test_boolean_codec.py} (100%) rename tests/unit/{value_types/test_date_value_type.py => codecs/test_date_codec.py} (100%) rename tests/unit/{value_types/test_date_range_value_type.py => codecs/test_date_range_codec.py} (100%) rename tests/unit/{value_types/test_email_value_type.py => codecs/test_email_codec.py} (100%) rename tests/unit/{value_types/test_money_value_type.py => codecs/test_money_codec.py} (100%) rename tests/unit/{value_types/test_multi_checkbox_value_type.py => codecs/test_multi_checkbox_codec.py} (100%) rename tests/unit/{value_types/test_multi_organization_value_type.py => codecs/test_multi_organization_codec.py} (100%) rename tests/unit/{value_types/test_multi_staff_value_type.py => codecs/test_multi_staff_codec.py} (100%) rename tests/unit/{value_types/test_number_value_type.py => codecs/test_number_codec.py} (100%) rename tests/unit/{value_types/test_number_range_value_type.py => codecs/test_number_range_codec.py} (100%) rename tests/unit/{value_types/test_phone_number_value_type.py => codecs/test_phone_number_codec.py} (100%) rename tests/unit/{value_types/test_radio_value_type.py => codecs/test_radio_codec.py} (100%) rename tests/unit/{value_types/test_single_organization_value_type.py => codecs/test_single_organization_codec.py} (100%) rename tests/unit/{value_types/test_single_staff_value_type.py => codecs/test_single_staff_codec.py} (100%) rename tests/unit/{value_types/test_url_value_type.py => codecs/test_url_codec.py} (100%) create mode 100644 tests/unit/test_deprecation_policy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa31f4..25b367c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ is intended to validate the release pipeline before the final `2.0.0` release. - 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 diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 0902e22..ee93f14 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -80,6 +80,26 @@ Example: 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+. diff --git a/README.md b/README.md index 3b65404..e92d52c 100755 --- a/README.md +++ b/README.md @@ -129,9 +129,37 @@ class Importer(BaseModel): alchemy = ExcelAlchemy(ImporterConfig(Importer, locale='en')) -template_base64 = alchemy.download_template() +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 +``` + +## Modern Annotated Example + +```python +from typing import Annotated + +from pydantic import BaseModel, Field + +from excelalchemy import Email, ExcelAlchemy, ExcelMeta, ImporterConfig + + +class Importer(BaseModel): + 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') ``` +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. + ## Locale-Aware Workbook Output `locale` affects workbook-facing display text such as: @@ -158,8 +186,8 @@ class Importer(BaseModel): name: String = FieldMeta(label='Name', order=2) -zh_template = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')).download_template() -en_template = ExcelAlchemy(ImporterConfig(Importer, locale='en')).download_template() +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: @@ -181,9 +209,8 @@ result = await alchemy.import_data("people.xlsx", "people-result.xlsx") Storage is modeled as a protocol, not a product decision. ```python -from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, UrlStr from excelalchemy.core.table import WorksheetTable -from excelalchemy.types.identity import UrlStr class InMemoryExcelStorage(ExcelStorage): diff --git a/README_cn.md b/README_cn.md index ddcb5ae..dc19f3e 100644 --- a/README_cn.md +++ b/README_cn.md @@ -108,9 +108,15 @@ class Importer(BaseModel): alchemy = ExcelAlchemy(ImporterConfig(Importer)) -template_base64 = alchemy.download_template() +template = alchemy.download_template_artifact(filename='people-template.xlsx') + +excel_bytes = template.as_bytes() +template_data_url = template.as_data_url() # 兼容旧的浏览器集成方式 ``` +浏览器下载时,优先使用 `excel_bytes` 构造 `Blob`,或者让后端直接返回二进制并带上 +`Content-Disposition: attachment`。现代浏览器对超长 `data:` URL 的顶层导航并不稳定。 + ## 选择模板 / 结果语言 `locale` 会影响 Excel 里真正给用户看的文案,例如: @@ -138,8 +144,8 @@ class Importer(BaseModel): name: String = FieldMeta(label='Name', order=2) -template_zh = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')).download_template() -template_en = ExcelAlchemy(ImporterConfig(Importer, locale='en')).download_template() +template_zh = ExcelAlchemy(ImporterConfig(Importer, locale='zh-CN')).download_template_artifact() +template_en = ExcelAlchemy(ImporterConfig(Importer, locale='en')).download_template_artifact() ``` 导入结果工作簿也会使用同一个 `locale`: @@ -161,9 +167,8 @@ result = await alchemy.import_data("people.xlsx", "people-result.xlsx") ExcelAlchemy 接受任何实现了 `ExcelStorage` 协议的存储后端。 ```python -from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig +from excelalchemy import ExcelAlchemy, ExcelStorage, ExporterConfig, UrlStr from excelalchemy.core.table import WorksheetTable -from excelalchemy.types.identity import UrlStr class InMemoryExcelStorage(ExcelStorage): diff --git a/docs/architecture.md b/docs/architecture.md index acd6761..09eda8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,7 @@ flowchart LR ### Metadata -`src/excelalchemy/types/field.py` +`src/excelalchemy/metadata.py` - owns Excel field metadata - exposes workbook comment fragments @@ -123,9 +123,17 @@ flowchart LR Implement `ExcelStorage` when you want a different backend. -### Custom Value Types +### Custom Field Codecs -Implement a new `ABCValueType` or `ComplexABCValueType` when you want custom workbook semantics. +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 @@ -135,6 +143,28 @@ Use `data_converter` when the workbook schema should not map 1:1 to backend payl 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/_internal/identity.py`: internal typed string and index wrappers used across the core layer +- `src/excelalchemy/_internal/constants.py`: internal constant and enum definitions +- `src/excelalchemy/results.py`: import/export result models +- `src/excelalchemy/_internal/header_models.py`: internal workbook header model objects +- `src/excelalchemy/_internal/deprecation.py`: internal 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: diff --git a/pyproject.toml b/pyproject.toml index 084c0a0..d704375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,11 @@ ignore = ['E501'] [tool.ruff.lint.per-file-ignores] '**/__init__.py' = ['F401'] +'src/excelalchemy/exc.py' = ['E402'] +'src/excelalchemy/header_models.py' = ['E402'] +'src/excelalchemy/identity.py' = ['E402'] +'src/excelalchemy/types/*.py' = ['E402'] +'src/excelalchemy/types/**/*.py' = ['E402'] [tool.ruff.format] quote-style = 'preserve' diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index 4279089..531bf06 100644 --- a/src/excelalchemy/__init__.py +++ b/src/excelalchemy/__init__.py @@ -1,43 +1,77 @@ """A Python Library for Reading and Writing Excel Files""" __version__ = '2.0.0rc1' -from excelalchemy.const import CharacterSet, DataRangeOption, DateFormat, Option +from excelalchemy._internal.constants import CharacterSet, DataRangeOption, DateFormat, Option +from excelalchemy._internal.deprecation import ExcelAlchemyDeprecationWarning +from excelalchemy._internal.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.exc import ConfigError, ExcelCellError, ProgrammaticError +from excelalchemy.exceptions import ConfigError, ExcelCellError, ExcelRowError, ProgrammaticError from excelalchemy.helper.pydantic import extract_pydantic_model -from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig, ImportMode -from excelalchemy.types.field import FieldMeta, PatchFieldMeta -from excelalchemy.types.identity import ColumnIndex, Key, Label, OptionId, RowIndex, UniqueKey, UniqueLabel -from excelalchemy.types.result import ImportResult, ValidateHeaderResult, ValidateResult, 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, SingleOrganization -from excelalchemy.types.value.phone_number import PhoneNumber -from excelalchemy.types.value.radio import Radio -from excelalchemy.types.value.staff import MultiStaff, SingleStaff -from excelalchemy.types.value.string import String -from excelalchemy.types.value.tree import MultiTreeNode, SingleTreeNode -from excelalchemy.types.value.url import Url +from excelalchemy.metadata import ExcelMeta, FieldMeta, PatchFieldMeta +from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult, ValidateRowResult from excelalchemy.util.file import flatten __all__ = [ 'Boolean', + 'BooleanCodec', 'ColumnIndex', + 'CompositeExcelFieldCodec', 'Date', + 'DateCodec', 'DateFormat', 'DateRange', + 'DateRangeCodec', 'DataRangeOption', 'Email', + 'EmailCodec', 'ExcelStorage', 'ExcelAlchemy', + 'ExcelArtifact', 'ExcelCellError', + 'ExcelAlchemyDeprecationWarning', + 'ExcelFieldCodec', + 'ExcelMeta', + 'ExcelRowError', 'ExporterConfig', 'FieldMeta', 'ImportMode', @@ -46,27 +80,44 @@ 'Key', 'Label', 'Money', + 'MoneyCodec', 'MultiCheckbox', + 'MultiChoiceCodec', 'MultiOrganization', + 'MultiOrganizationCodec', 'MultiStaff', + 'MultiStaffCodec', 'MultiTreeNode', + 'MultiTreeNodeCodec', 'Number', + 'NumberCodec', 'NumberRange', + 'NumberRangeCodec', 'Option', 'OptionId', 'PatchFieldMeta', + 'DataUrlStr', + 'Base64Str', 'PhoneNumber', + 'PhoneNumberCodec', 'ProgrammaticError', 'ConfigError', 'Radio', 'RowIndex', + 'SingleChoiceCodec', 'SingleOrganization', + 'SingleOrganizationCodec', 'SingleStaff', + 'SingleStaffCodec', 'SingleTreeNode', + 'SingleTreeNodeCodec', 'String', + 'StringCodec', 'UniqueKey', 'UniqueLabel', 'Url', + 'UrlStr', + 'UrlCodec', 'ValidateHeaderResult', 'ValidateResult', 'ValidateRowResult', diff --git a/src/excelalchemy/_internal/__init__.py b/src/excelalchemy/_internal/__init__.py new file mode 100644 index 0000000..b48a57f --- /dev/null +++ b/src/excelalchemy/_internal/__init__.py @@ -0,0 +1,2 @@ +"""Internal implementation helpers for ExcelAlchemy.""" + diff --git a/src/excelalchemy/_internal/constants.py b/src/excelalchemy/_internal/constants.py new file mode 100644 index 0000000..89fee28 --- /dev/null +++ b/src/excelalchemy/_internal/constants.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from excelalchemy._internal.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' +# 连接符 +UNIQUE_HEADER_CONNECTOR: str = '·' + +# 数据导出结果列 +RESULT_COLUMN_LABEL: Label = Label(dmsg(MessageKey.RESULT_COLUMN_LABEL, locale='zh-CN')) +RESULT_COLUMN_KEY: Key = Key('__result__') + +# 数据导出原因列 +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' + +# 多选分隔符 +MULTI_CHECKBOX_SEPARATOR = ',' + +FIELD_DATA_KEY = Key('fieldData') + +# 毫秒转换为秒 +MILLISECOND_TO_SECOND = 1000 + +# options 最多允许的选项数量 +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(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: 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/_internal/deprecation.py b/src/excelalchemy/_internal/deprecation.py new file mode 100644 index 0000000..c655b5e --- /dev/null +++ b/src/excelalchemy/_internal/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/_internal/header_models.py b/src/excelalchemy/_internal/header_models.py new file mode 100644 index 0000000..513b10f --- /dev/null +++ b/src/excelalchemy/_internal/header_models.py @@ -0,0 +1,25 @@ +"""Internal workbook header models.""" + +from pydantic import BaseModel +from pydantic.fields import Field + +from excelalchemy._internal.constants import UNIQUE_HEADER_CONNECTOR +from excelalchemy._internal.identity import Label, 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/src/excelalchemy/_internal/identity.py b/src/excelalchemy/_internal/identity.py new file mode 100644 index 0000000..707418e --- /dev/null +++ b/src/excelalchemy/_internal/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): + """Excel 的列名""" + + +class UniqueLabel(Label): + """Excel 唯一的列名""" + + +class Key(_StringIdentity): + """Python 模型的键名""" + + +class UniqueKey(Key): + """Python 模型唯一的键名""" + + +class RowIndex(_IntegerIdentity): + """Excel 的行索引, 从 0 开始""" + + +class ColumnIndex(_IntegerIdentity): + """Excel 的列索引, 从 0 开始""" + + +class OptionId(_StringIdentity): + """选项 ID""" + + +class DataUrlStr(_StringIdentity): + """Data URL string.""" + + +class Base64Str(DataUrlStr): + """Deprecated compatibility alias for the legacy data URL string return type.""" + + +class UrlStr(_StringIdentity): + """URL 字符串""" diff --git a/src/excelalchemy/artifacts.py b/src/excelalchemy/artifacts.py new file mode 100644 index 0000000..a4d8b9b --- /dev/null +++ b/src/excelalchemy/artifacts.py @@ -0,0 +1,39 @@ +"""Structured workbook payloads for download and integration workflows.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, replace + +from excelalchemy._internal.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..a0fcd13 --- /dev/null +++ b/src/excelalchemy/codecs/base.py @@ -0,0 +1,128 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema + +from excelalchemy._internal.identity import Key + +if TYPE_CHECKING: + from excelalchemy.metadata import FieldMetaInfo +else: + FieldMetaInfo = Any + + +class ExcelFieldCodec(ABC): + """Excel-facing field adapter responsible for comments, parsing, formatting, and normalization.""" + + @classmethod + @abstractmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + """用于渲染 Excel 表头的注释""" + + @classmethod + @abstractmethod + def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> Any: # value is always not None + """用于把用户填入 Excel 的数据,转换成后端代码入口可接收的数据 + 如果转换失败,返回原值,用户后续捕获更准确的错误 + """ + + @classmethod + @abstractmethod + def format_display_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + """用于把 worksheet 读入后的值转回用户可识别的数据, 处理聚合之前的数据""" + + @classmethod + @abstractmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + """验证用户输入的值是否符合约束. 接收 serialize 后的值""" + + @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): + """Excel codec for fields that expand into multiple worksheet columns.""" + + @classmethod + @abstractmethod + def column_items(cls) -> list[tuple[Key, FieldMetaInfo]]: + """用于获取模型的所有字段名""" + + @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..f217fe1 --- /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__ = '布尔' + + @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/src/excelalchemy/codecs/date.py b/src/excelalchemy/codecs/date.py new file mode 100644 index 0000000..541f40e --- /dev/null +++ b/src/excelalchemy/codecs/date.py @@ -0,0 +1,107 @@ +import logging +from datetime import datetime +from typing import Any, cast + +import pendulum +from pendulum import DateTime + +from excelalchemy._internal.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(ExcelFieldCodec, datetime): + __name__ = '日期选择' + + @classmethod + def build_comment(cls, field_meta: FieldMetaInfo) -> str: + if not field_meta.date_format: + raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) + return '\n'.join( + [ + field_meta.comment_required, + field_meta.comment_date_format, + field_meta.comment_date_range_option, + field_meta.comment_hint, + ] + ) + + @classmethod + 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) + return value + + if not field_meta.date_format: + raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) + + value = str(value).strip() + try: + v = value.replace('/', '-') # pendulum 不支持 / 作为日期分隔符 + dt: DateTime = cast(DateTime, pendulum.parse(v)) + return dt.replace(tzinfo=field_meta.timezone) + except Exception as exc: + logging.warning('ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', cls.__name__, value, exc) + return value + + @classmethod + def format_display_value(cls, value: str | datetime | None | Any, field_meta: FieldMetaInfo) -> str: + match value: + case None | '': + return '' + case datetime(): + return value.strftime(field_meta.python_date_format) + case int() | float(): + return datetime.fromtimestamp(int(value) / MILLISECOND_TO_SECOND).strftime( + field_meta.python_date_format + ) + case _: + return str(value) if value is not None else '' + + @classmethod + def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> int: + if field_meta.date_format is None: + raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) + + if not isinstance(value, datetime): + 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) + + if errors: + raise ValueError(*errors) + else: + return int(parsed.timestamp() * MILLISECOND_TO_SECOND) + + @staticmethod + def _parse_date(v: datetime, field_meta: FieldMetaInfo) -> datetime: + format_ = field_meta.python_date_format + parsed = datetime.strptime(v.strftime(format_), format_) + parsed = parsed.replace(tzinfo=field_meta.timezone) + return parsed + + @staticmethod + def _validate_date_range(parsed: datetime, field_meta: FieldMetaInfo) -> list[str]: + now = datetime.now(tz=field_meta.timezone) + errors: list[str] = [] + + match field_meta.date_range_option: + case DataRangeOption.PRE: + if parsed > now: + errors.append(msg(MessageKey.DATE_MUST_BE_EARLIER_THAN_NOW)) + case DataRangeOption.NEXT: + if parsed < now: + 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..eaf83fe --- /dev/null +++ b/src/excelalchemy/codecs/date_range.py @@ -0,0 +1,168 @@ +import logging +from datetime import datetime +from typing import Any + +import pendulum +from pendulum import DateTime +from pydantic import BaseModel + +from excelalchemy._internal.constants import DATE_FORMAT_TO_PYTHON_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption +from excelalchemy._internal.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__ = '日期范围' + + @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): + # 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 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: 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') + 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 normalize_import_value( + cls, + value: dict[str, DateTime | None] | Any, + field_meta: FieldMetaInfo, + ) -> 'DateRange': + try: + parsed = DateRange.model_validate(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(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: 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 could not be deserialized; returning the original value', 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 + + +DateRangeCodec = DateRange diff --git a/src/excelalchemy/codecs/email.py b/src/excelalchemy/codecs/email.py new file mode 100644 index 0000000..ea3aabc --- /dev/null +++ b/src/excelalchemy/codecs/email.py @@ -0,0 +1,32 @@ +from typing import Any + +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 = TypeAdapter(EmailStr) + + @classmethod + def normalize_import_value(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(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..e82097b --- /dev/null +++ b/src/excelalchemy/codecs/multi_checkbox.py @@ -0,0 +1,76 @@ +import logging +from typing import Any, cast + +from excelalchemy._internal.constants import MULTI_CHECKBOX_SEPARATOR +from excelalchemy._internal.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__ = '复选框组' + + @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: 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> could not parse Excel input %s; returning the original value', cls.__name__, value) + return value + + @classmethod + def normalize_import_value(cls, value: list[str] | Any, field_meta: FieldMetaInfo) -> list[str]: # OptionId + if not isinstance(value, list): + raise ValueError(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT)) + + 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 value + + if len(value) != len(set(value)): + raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) + + result, errors = field_meta.exchange_names_to_option_ids_with_errors(value) + + 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_names = field_meta.exchange_option_ids_to_names(value) + 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..0c7505e --- /dev/null +++ b/src/excelalchemy/codecs/number.py @@ -0,0 +1,147 @@ +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: + """将 Decimal 转换为指定精度的 Decimal""" + 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('fraction_digits is too small and causes precision loss: %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): + raise TypeError(f'Expected Decimal, got {type(value)}') + + if value.as_tuple().exponent == 0: + return int(value) + else: + return float(value) + + +class Number(Decimal, ExcelFieldCodec): + __name__ = '数值输入' + + @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() + try: + return transform_decimal(Decimal(value)) # type: ignore[arg-type] + 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) if value is not None else '' + + @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: + # 如果输入不是 Decimal 类型,尝试转换。 + 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] = [] + + # 从 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( + 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)) + else: + pass + + return errors + + @classmethod + def normalize_import_value(cls, value: Decimal | Any, field_meta: FieldMetaInfo) -> float | int: + # 如果输入不是 Decimal 类型,尝试转换。 + parsed = cls.__maybe_decimal__(value) + if parsed is None: + raise ValueError(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) + # 初始化一个错误信息列表。 + 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(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..ad6090f --- /dev/null +++ b/src/excelalchemy/codecs/number_range.py @@ -0,0 +1,102 @@ +import logging +from decimal import Decimal +from typing import Any + +from excelalchemy._internal.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__ = '数值范围' + + def __init__(self, start: Decimal | int | float | None, end: Decimal | int | float | None): + # 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 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: 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: + start, end = Decimal(value['start']), Decimal(value['end']) # type: ignore[index] + 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 the original value if parsing fails + return value + + @classmethod + def format_display_value(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> 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: 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(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: 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(msg(MessageKey.ENTER_NUMBER)) from exc + + raise ValueError(msg(MessageKey.ENTER_NUMBER_EXPECTED_FORMAT)) + + +NumberRangeCodec = NumberRange diff --git a/src/excelalchemy/codecs/organization.py b/src/excelalchemy/codecs/organization.py new file mode 100644 index 0000000..651956c --- /dev/null +++ b/src/excelalchemy/codecs/organization.py @@ -0,0 +1,74 @@ +import logging +from typing import Any + +from excelalchemy._internal.constants import MULTI_CHECKBOX_SEPARATOR +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__ = '组织单选' + + @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: 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('无法找到组织 %s 的选项, 返回原值', value) + + return value if value is not None else '' + + +class MultiOrganization(MultiCheckbox): + __name__ = '组织多选' + + @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: Any, field_meta: FieldMetaInfo) -> Any: + return super().parse_input(value, field_meta) + + @classmethod + def format_display_value(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 normalize_import_value(cls, value: Any, 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..6858552 --- /dev/null +++ b/src/excelalchemy/codecs/radio.py @@ -0,0 +1,75 @@ +import logging +from typing import Any + +from excelalchemy._internal.constants import MULTI_CHECKBOX_SEPARATOR +from excelalchemy._internal.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__ = '单选框组' + + @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..49a176a --- /dev/null +++ b/src/excelalchemy/codecs/staff.py @@ -0,0 +1,77 @@ +import logging +from typing import Any + +from excelalchemy._internal.constants import MULTI_CHECKBOX_SEPARATOR +from excelalchemy._internal.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__ = '人员单选' + + @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: Any, field_meta: FieldMetaInfo) -> Any: + if isinstance(value, str): + return value.strip() + return value + + @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 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 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: str | list[str] | 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) + + @classmethod + def format_display_value(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(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) + + option_names = field_meta.exchange_option_ids_to_names(value) + return f'{MULTI_CHECKBOX_SEPARATOR}'.join(option_names) + + logging.warning('%s could not be deserialized', cls.__name__) + return value + + +SingleStaffCodec = SingleStaff +MultiStaffCodec = MultiStaff diff --git a/src/excelalchemy/codecs/string.py b/src/excelalchemy/codecs/string.py new file mode 100644 index 0000000..d73bdf1 --- /dev/null +++ b/src/excelalchemy/codecs/string.py @@ -0,0 +1,143 @@ +from typing import Any + +from excelalchemy._internal.constants import CharacterSet +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 + +SPECIAL_SYMBOLS = set( + '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~"。?!,、;:‘’“”()《》〈〉【】〔〕{}⦅⦆〖〗〘〙〚〛〜〝〞〟〰–—‘‛“”„‟…‧﹏.' +) + + +def _is_chinese_character(character: str) -> bool: + # https://www.unicode.org/versions/Unicode15.0.0/ch18.pdf + # + # Table 18-1. Blocks Containing Han Ideographs + # # Block Range Comment + # CJK Unified Ideographs 4E00–9FFF Common + # CJK Unified Ideographs Extension A 3400–4DBF Rare + # CJK Unified Ideographs Extension B 20000–2A6DF Rare, historic + # CJK Unified Ideographs Extension C 2A700–2B73F Rare, historic + # CJK Unified Ideographs Extension D 2B740–2B81F Uncommon, some in current use + # CJK Unified Ideographs Extension E 2B820–2CEAF Rare, historic + # CJK Unified Ideographs Extension F 2CEB0–2EBEF Rare, historic + # CJK Unified Ideographs Extension G 30000–3134F Rare, historic + # CJK Unified Ideographs Extension H 31350–323AF Rare, historic + # CJK Compatibility Ideographs F900–FAFF Duplicates, unifiable variants, corporate characters + # CJK Compatibility Ideographs Supplement 2F800–2FA1F Unifiable variant + code_point = ord(character) + return ( + (0x4E00 <= code_point <= 0x9FFF) + or (0x3400 <= code_point <= 0x4DBF) + or (0x20000 <= code_point <= 0x2A6DF) + or (0x2A700 <= code_point <= 0x2B73F) + or (0x2B740 <= code_point <= 0x2B81F) + or (0x2B820 <= code_point <= 0x2CEAF) + or (0x2CEB0 <= code_point <= 0x2EBEF) + or (0x30000 <= code_point <= 0x3134F) + or (0x31350 <= code_point <= 0x323AF) + or (0xF900 <= code_point <= 0xFAFF) + or (0x2F800 <= code_point <= 0x2FA1F) + ) + + +def _is_number_character(character: str) -> bool: + return ord(character) in range(ord('0'), ord('9') + 1) + + +def _is_lowercase_letters(character: str) -> bool: + return ord(character) in range(ord('a'), ord('z') + 1) + + +def _is_uppercase_letters(character: str) -> bool: + return ord(character) in range(ord('A'), ord('Z') + 1) + + +def _is_special_symbols(character: str) -> bool: + return character in SPECIAL_SYMBOLS + + +_CHARACTER_SET_TO_VALIDATOR = { + CharacterSet.CHINESE: _is_chinese_character, + CharacterSet.NUMBER: _is_number_character, + CharacterSet.LOWERCASE_LETTERS: _is_lowercase_letters, + CharacterSet.UPPERCASE_LETTERS: _is_uppercase_letters, + CharacterSet.SPECIAL_SYMBOLS: _is_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: + ordered = sorted(cs, key=lambda item: item.value) + return ', '.join(msg(_CHARACTER_SET_TO_MESSAGE_KEY[c]) for c in ordered) + + +class String(str, ExcelFieldCodec): + @classmethod + 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 parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> str: + return str(value).strip() + + @classmethod + 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 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(msg(MessageKey.MAX_LENGTH_CHARACTERS, max_length=field_meta.importer_max_length)) + + errors.extend(cls.__check_character_set__(parsed, field_meta)) + + if errors: + raise ValueError(*errors) + else: + return parsed + + @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(msg(MessageKey.CHARACTER_SET_NOT_CONFIGURED)) + + for single_character in value: + if not any(_CHARACTER_SET_TO_VALIDATOR[cs](single_character) for cs in 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..4526925 --- /dev/null +++ b/src/excelalchemy/codecs/tree.py @@ -0,0 +1,58 @@ +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__ = '树形单选' + + @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('无法找到树结点 %s 的选项, 返回原值', value) + + return value if value is not None else '' + + +class MultiTreeNode(MultiCheckbox): + __name__ = '树形多选' + + @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..9da7fb0 --- /dev/null +++ b/src/excelalchemy/config.py @@ -0,0 +1,126 @@ +"""实例化 ExcelAlchemy 时的配置""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal + +from pydantic import BaseModel + +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(str, Enum): + """Excel 模式""" + + IMPORT = 'IMPORT' + EXPORT = 'EXPORT' + + +class ImportMode(str, Enum): + CREATE = 'CREATE' # 创建 + UPDATE = 'UPDATE' # 更新 + CREATE_OR_UPDATE = 'CREATE_OR_UPDATE' # 创建或更新 + + +@dataclass +class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateModelT: BaseModel]: + 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) + + storage: ExcelStorage | None = field(default=None) + minio: Minio | None = field(default=None) + bucket_name: str = field(default='excel') + url_expires: int = field(default=3600) + locale: str = field(default='zh-CN') + + sheet_name: Literal['Sheet1'] = field(default='Sheet1') + + def validate_model(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): + 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): + 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): + 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)) + # 创建模型和更新模型的字段必须一致 + 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): + self.validate_model() + + +@dataclass +class ExporterConfig[ExporterModelT: BaseModel]: + 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) + + storage: ExcelStorage | None = field(default=None) + minio: Minio | None = field(default=None) + bucket_name: str = field(default='excel') + url_expires: int = field(default=3600) + locale: str = field(default='zh-CN') + + sheet_name: Literal['Sheet1'] = field(default='Sheet1') + + def validate_model(self): + if not self.exporter_model: + raise ValueError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_EMPTY)) + return self + + def __post_init__(self): + self.validate_model() diff --git a/src/excelalchemy/const.py b/src/excelalchemy/const.py index d150a9e..e9d407f 100644 --- a/src/excelalchemy/const.py +++ b/src/excelalchemy/const.py @@ -1,92 +1,4 @@ -from dataclasses import dataclass -from enum import Enum -from typing import Any +"""Compatibility re-exports for lower-level constant definitions.""" -from excelalchemy.i18n.messages import MessageKey -from excelalchemy.i18n.messages import display_message as dmsg -from excelalchemy.types.identity import Key, Label, OptionId +from excelalchemy._internal.constants import * # noqa: F403 -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' -# 连接符 -UNIQUE_HEADER_CONNECTOR: str = '·' - -# 数据导出结果列 -RESULT_COLUMN_LABEL: Label = Label(dmsg(MessageKey.RESULT_COLUMN_LABEL, locale='zh-CN')) -RESULT_COLUMN_KEY: Key = Key('__result__') - -# 数据导出原因列 -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' - -# 多选分隔符 -MULTI_CHECKBOX_SEPARATOR = ',' - -FIELD_DATA_KEY = Key('fieldData') - -# 毫秒转换为秒 -MILLISECOND_TO_SECOND = 1000 - -# options 最多允许的选项数量 -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(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: 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/core/abstract.py b/src/excelalchemy/core/abstract.py index d2390f4..3560af0 100644 --- a/src/excelalchemy/core/abstract.py +++ b/src/excelalchemy/core/abstract.py @@ -3,8 +3,9 @@ from pydantic import BaseModel -from excelalchemy.types.identity import Base64Str, Key, UrlStr -from excelalchemy.types.result import ImportResult +from excelalchemy._internal.identity import Base64Str, Key, UrlStr +from excelalchemy.artifacts import ExcelArtifact +from excelalchemy.results import ImportResult class ABCExcelAlchemy[ @@ -19,6 +20,15 @@ class ABCExcelAlchemy[ def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> str: """下载导入模版, Excel 字段顺序与定义的导出模型一致""" + @abstractmethod + def download_template_artifact( + self, + sample_data: list[dict[str, Any]] | None = None, + *, + filename: str = 'template.xlsx', + ) -> ExcelArtifact: + """下载导入模板,返回结构化 Excel 产物。""" + @abstractmethod async def import_data(self, input_excel_name: str, output_excel_name: str) -> ImportResult: """导入数据""" @@ -27,6 +37,16 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im def export(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> Base64Str: """导出数据,返回 base64 编码的 excel 文件, 字段顺序与定义的导出模型一致""" + @abstractmethod + def export_artifact( + self, + data: list[dict[str, Any]], + keys: list[Key] | None = None, + *, + filename: str = 'export.xlsx', + ) -> ExcelArtifact: + """导出数据,返回结构化 Excel 产物。""" + @abstractmethod def export_upload(self, output_name: str, data: list[dict[str, Any]], keys: list[Key] | None = None) -> UrlStr: """导出数据, 自动将文件上传到配置的存储后端,字段顺序与定义的导出模型一致""" diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 36d8ba2..d5033be 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -4,10 +4,15 @@ from pydantic import BaseModel -from excelalchemy.const import ( +from excelalchemy._internal.constants import ( REASON_COLUMN_KEY, RESULT_COLUMN_KEY, ) +from excelalchemy._internal.header_models import ExcelHeader +from excelalchemy._internal.identity import Base64Str, Key, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr +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 @@ -17,17 +22,13 @@ from excelalchemy.core.storage import build_storage_gateway from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.core.table import WorksheetTable -from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +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.types.abstract import SystemReserved -from excelalchemy.types.alchemy import ExcelMode, ExporterConfig, ImporterConfig, ImportMode -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.header import ExcelHeader -from excelalchemy.types.identity import Base64Str, Key, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr -from excelalchemy.types.result import ImportResult, ValidateHeaderResult, ValidateResult +from excelalchemy.metadata import FieldMetaInfo +from excelalchemy.results import ImportResult, ValidateHeaderResult, ValidateResult from excelalchemy.util.file import flatten HEADER_HINT_LINE_COUNT = 1 @@ -35,12 +36,12 @@ 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.value_type = SystemReserved +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.value_type = SystemReserved +REASON_COLUMN.excel_codec = SystemReserved class ExcelAlchemy[ @@ -155,6 +156,14 @@ def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> 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[dict[str, Any]] | 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: @@ -202,6 +211,15 @@ def export(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> B errors={}, ) + def export_artifact( + self, + data: list[dict[str, Any]], + keys: list[Key] | 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[dict[str, Any]], keys: list[Key] | None = None) -> UrlStr: return self._upload_file(output_name, self.export(data, keys)) @@ -336,7 +354,7 @@ def _generate_export_df( 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) + 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) @@ -389,12 +407,12 @@ 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.value_type = SystemReserved + 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.value_type = SystemReserved + reason_column.excel_codec = SystemReserved return [result_column, reason_column] diff --git a/src/excelalchemy/core/executor.py b/src/excelalchemy/core/executor.py index 2088684..4b01694 100644 --- a/src/excelalchemy/core/executor.py +++ b/src/excelalchemy/core/executor.py @@ -4,12 +4,12 @@ from pydantic import BaseModel -from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy._internal.identity import Key, RowIndex +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 excelalchemy.types.alchemy import ImporterConfig, ImportMode -from excelalchemy.types.identity import Key, RowIndex from .rows import ImportIssueTracker from .table import WorksheetTable @@ -92,9 +92,11 @@ async def _invoke_dml( """Validate one row payload and call the user-supplied DML function.""" 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.issue_tracker.register_row_error(row_index, errors) - self.issue_tracker.register_cell_errors(row_index, errors, df) + 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 importer_instance = importer_instance_or_errors diff --git a/src/excelalchemy/core/headers.py b/src/excelalchemy/core/headers.py index b60cd8d..5666043 100644 --- a/src/excelalchemy/core/headers.py +++ b/src/excelalchemy/core/headers.py @@ -1,13 +1,13 @@ """Header parsing and validation helpers for import workbooks.""" +from excelalchemy._internal.header_models import ExcelHeader +from excelalchemy._internal.identity import Label, UniqueLabel +from excelalchemy.config import ImportMode from excelalchemy.core.table import WorksheetTable -from excelalchemy.exc import ConfigError +from excelalchemy.exceptions import ConfigError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import message as msg -from excelalchemy.types.alchemy import ImportMode -from excelalchemy.types.header import ExcelHeader -from excelalchemy.types.identity import Label, UniqueLabel -from excelalchemy.types.result import ValidateHeaderResult +from excelalchemy.results import ValidateHeaderResult from excelalchemy.util.file import value_is_nan from .schema import ExcelSchemaLayout diff --git a/src/excelalchemy/core/rendering.py b/src/excelalchemy/core/rendering.py index 41082d0..01ed1a3 100644 --- a/src/excelalchemy/core/rendering.py +++ b/src/excelalchemy/core/rendering.py @@ -2,11 +2,11 @@ from typing import cast +from excelalchemy._internal.identity import Base64Str, ColumnIndex, 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.exc import ExcelCellError -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import Base64Str, ColumnIndex, RowIndex, UniqueLabel +from excelalchemy.exceptions import ExcelCellError +from excelalchemy.metadata import FieldMetaInfo class ExcelRenderer: diff --git a/src/excelalchemy/core/rows.py b/src/excelalchemy/core/rows.py index a2c28de..91ef1cd 100644 --- a/src/excelalchemy/core/rows.py +++ b/src/excelalchemy/core/rows.py @@ -3,13 +3,13 @@ from collections import defaultdict from typing import Any, cast -from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy._internal.identity import ColumnIndex, Key, RowIndex, UniqueLabel +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.types.alchemy import ImportMode -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import ColumnIndex, Key, RowIndex, UniqueLabel -from excelalchemy.types.result import ValidateRowResult +from excelalchemy.metadata import FieldMetaInfo +from excelalchemy.results import ValidateRowResult from excelalchemy.util.file import value_is_nan from .schema import ExcelSchemaLayout @@ -52,11 +52,11 @@ def _serialize(self, aggregated: dict[Key, Any]) -> dict[Key, Any]: serialized: dict[Key, Any] = {} for parent_key, value in aggregated.items(): field_metas = self.layout.parent_key_to_field_metas[parent_key] - validator = field_metas[0] + codec_field = field_metas[0] if value is None: serialized[parent_key] = None else: - serialized[parent_key] = validator.value_type.serialize(value, validator) + serialized[parent_key] = codec_field.excel_codec.parse_input(value, codec_field) return serialized diff --git a/src/excelalchemy/core/schema.py b/src/excelalchemy/core/schema.py index 899e3fc..f400d72 100644 --- a/src/excelalchemy/core/schema.py +++ b/src/excelalchemy/core/schema.py @@ -8,13 +8,13 @@ from pydantic import BaseModel -from excelalchemy.const import DEFAULT_FIELD_META_ORDER -from excelalchemy.exc import ConfigError, ExcelCellError, ExcelRowError +from excelalchemy._internal.constants import DEFAULT_FIELD_META_ORDER +from excelalchemy._internal.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.types.field import FieldMetaInfo -from excelalchemy.types.identity import Key, Label, UniqueKey, UniqueLabel +from excelalchemy.metadata import FieldMetaInfo class ExcelSchemaLayout: diff --git a/src/excelalchemy/core/storage.py b/src/excelalchemy/core/storage.py index c4c093c..900c646 100644 --- a/src/excelalchemy/core/storage.py +++ b/src/excelalchemy/core/storage.py @@ -2,11 +2,11 @@ from typing import TYPE_CHECKING, Any +from excelalchemy.config import ExporterConfig, ImporterConfig from excelalchemy.core.storage_protocol import ExcelStorage -from excelalchemy.exc import ConfigError +from excelalchemy.exceptions import ConfigError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import message as msg -from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig if TYPE_CHECKING: from excelalchemy.core.storage_minio import MinioStorageGateway diff --git a/src/excelalchemy/core/storage_minio.py b/src/excelalchemy/core/storage_minio.py index 97ed13b..b643ee3 100644 --- a/src/excelalchemy/core/storage_minio.py +++ b/src/excelalchemy/core/storage_minio.py @@ -11,13 +11,13 @@ from openpyxl.worksheet.worksheet import Worksheet from urllib3.response import BaseHTTPResponse +from excelalchemy._internal.identity import UrlStr +from excelalchemy.config import ExporterConfig, ImporterConfig from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.core.table import WorksheetTable -from excelalchemy.exc import ConfigError +from excelalchemy.exceptions import ConfigError from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import message as msg -from excelalchemy.types.alchemy import ExporterConfig, ImporterConfig -from excelalchemy.types.identity import UrlStr from excelalchemy.util.file import remove_excel_prefix diff --git a/src/excelalchemy/core/storage_protocol.py b/src/excelalchemy/core/storage_protocol.py index f65c17d..3541ed1 100644 --- a/src/excelalchemy/core/storage_protocol.py +++ b/src/excelalchemy/core/storage_protocol.py @@ -2,8 +2,8 @@ from typing import Protocol, runtime_checkable +from excelalchemy._internal.identity import UrlStr from excelalchemy.core.table import WorksheetTable -from excelalchemy.types.identity import UrlStr @runtime_checkable diff --git a/src/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py index 33bc278..d9b0bb1 100644 --- a/src/excelalchemy/core/writer.py +++ b/src/excelalchemy/core/writer.py @@ -12,21 +12,21 @@ from openpyxl.utils import get_column_letter from openpyxl.worksheet.worksheet import Worksheet -from excelalchemy.const import ( +from excelalchemy._internal.constants import ( BACKGROUND_ERROR_COLOR, BACKGROUND_REQUIRED_COLOR, CHARACTER_WIDTH, DEFAULT_SHEET_NAME, FONT_READ_COLOR, ) +from excelalchemy._internal.identity import Base64Str, ColumnIndex, Label, RowIndex, UniqueLabel from excelalchemy.core.table import WorksheetTable -from excelalchemy.exc import ExcelCellError +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.types.field import FieldMetaInfo -from excelalchemy.types.identity import Base64Str, ColumnIndex, Label, RowIndex, UniqueLabel -from excelalchemy.types.result import ValidateRowResult +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 @@ -62,7 +62,7 @@ def _encode_workbook(workbook: Workbook, file: BinaryIO, *, close_file: bool) -> def _build_comment(field_meta: FieldMetaInfo) -> Comment | None: - comment_text = field_meta.value_type.comment(field_meta) + comment_text = field_meta.excel_codec.build_comment(field_meta) if not comment_text: return None @@ -236,7 +236,7 @@ def _get_parsed_value( return str(cell_value) field_meta = field_meta_mapping[col_label] - parsed_value = field_meta.value_type.deserialize(cell_value, field_meta) + parsed_value = field_meta.excel_codec.format_display_value(cell_value, field_meta) return str(parsed_value) diff --git a/src/excelalchemy/exc.py b/src/excelalchemy/exc.py index 982e003..ad23dad 100644 --- a/src/excelalchemy/exc.py +++ b/src/excelalchemy/exc.py @@ -1,80 +1,8 @@ -from typing import Any +"""Compatibility shim for ``excelalchemy.exc``.""" -from excelalchemy.const import UNIQUE_HEADER_CONNECTOR -from excelalchemy.i18n.messages import MessageKey -from excelalchemy.i18n.messages import message as msg -from excelalchemy.types.identity import Label, UniqueLabel +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.exc', 'excelalchemy.exceptions') -class ExcelCellError(Exception): - """Excel 单元格错误""" +from excelalchemy.exceptions import * # noqa: F403 - message = msg(MessageKey.EXCEL_IMPORT_ERROR) - label: Label - parent_label: Label | None - detail: dict[str, Any] - - def __init__( - self, - message: str, - label: Label, - parent_label: Label | None = None, - **kwargs: Any, - ): - super().__init__(message, label, parent_label) - self.message = message or self.message - self.label = label - self.parent_label = parent_label - self.detail = kwargs or {} - self._validate() - - def __str__(self) -> str: - return f'【{self.label}】{self.message}' - - def __repr__(self): - return f"{type(self).__name__}(label=Label('{self.label}'), message='{self.message}')" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, ExcelCellError): - return NotImplemented - return str(self) == str(other) - - @property - def unique_label(self) -> UniqueLabel: - label = ( - f'{self.parent_label}{UNIQUE_HEADER_CONNECTOR}{self.label}' - if (self.parent_label and self.parent_label != self.label) - else self.label - ) - return UniqueLabel(label) - - def _validate(self) -> None: - if not self.label: - raise ValueError(msg(MessageKey.LABEL_CANNOT_BE_EMPTY)) - - -class ExcelRowError(Exception): - """Excel 整行发生导入错误""" - - message = msg(MessageKey.EXCEL_ROW_IMPORT_ERROR) - - def __init__( - self, - message: str, - **kwargs: Any, - ): - super().__init__(message) - self.message = message or self.message - self.detail = kwargs or {} - - def __str__(self): - return self.message - - def __repr__(self): - return f"{type(self).__name__}(message='{self.message}')" - - -class ProgrammaticError(Exception): ... - - -class ConfigError(Exception): ... diff --git a/src/excelalchemy/exceptions.py b/src/excelalchemy/exceptions.py new file mode 100644 index 0000000..140239c --- /dev/null +++ b/src/excelalchemy/exceptions.py @@ -0,0 +1,82 @@ +"""Public exception types raised by ExcelAlchemy.""" + +from typing import Any + +from excelalchemy._internal.constants import UNIQUE_HEADER_CONNECTOR +from excelalchemy._internal.identity import Label, UniqueLabel +from excelalchemy.i18n.messages import MessageKey +from excelalchemy.i18n.messages import message as msg + + +class ExcelCellError(Exception): + """Excel 单元格错误""" + + message = msg(MessageKey.EXCEL_IMPORT_ERROR) + label: Label + parent_label: Label | None + detail: dict[str, Any] + + def __init__( + self, + message: str, + label: Label, + parent_label: Label | None = None, + **kwargs: Any, + ): + super().__init__(message, label, parent_label) + self.message = message or self.message + self.label = label + self.parent_label = parent_label + self.detail = kwargs or {} + self._validate() + + def __str__(self) -> str: + return f'【{self.label}】{self.message}' + + def __repr__(self): + return f"{type(self).__name__}(label=Label('{self.label}'), message='{self.message}')" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ExcelCellError): + return NotImplemented + return str(self) == str(other) + + @property + def unique_label(self) -> UniqueLabel: + label = ( + f'{self.parent_label}{UNIQUE_HEADER_CONNECTOR}{self.label}' + if (self.parent_label and self.parent_label != self.label) + else self.label + ) + return UniqueLabel(label) + + def _validate(self) -> None: + if not self.label: + raise ValueError(msg(MessageKey.LABEL_CANNOT_BE_EMPTY)) + + +class ExcelRowError(Exception): + """Excel 整行发生导入错误""" + + message = msg(MessageKey.EXCEL_ROW_IMPORT_ERROR) + + def __init__( + self, + message: str, + **kwargs: Any, + ): + super().__init__(message) + self.message = message or self.message + self.detail = kwargs or {} + + def __str__(self): + return self.message + + def __repr__(self): + return f"{type(self).__name__}(message='{self.message}')" + + +class ProgrammaticError(Exception): ... + + +class ConfigError(Exception): ... diff --git a/src/excelalchemy/header_models.py b/src/excelalchemy/header_models.py new file mode 100644 index 0000000..45b6b72 --- /dev/null +++ b/src/excelalchemy/header_models.py @@ -0,0 +1,11 @@ +"""Compatibility shim for ``excelalchemy.header_models``.""" + +from excelalchemy._internal.deprecation import warn_compat_import + +warn_compat_import( + 'excelalchemy.header_models', + 'ExcelAlchemy internals only; avoid importing header models directly', +) + +from excelalchemy._internal.header_models import * # noqa: F403 + diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index a7612bf..48223a9 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -2,15 +2,15 @@ from types import UnionType from typing import Any, Generator, Iterable, cast, get_args, get_origin -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from pydantic.fields import FieldInfo, PydanticUndefined -from excelalchemy.exc import ExcelCellError, ProgrammaticError +from excelalchemy._internal.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.types.abstract import ABCValueType, ComplexABCValueType -from excelalchemy.types.field import FieldMetaInfo, extract_declared_field_metadata -from excelalchemy.types.identity import Key +from excelalchemy.metadata import FieldMetaInfo, extract_declared_field_metadata @dataclass(frozen=True) @@ -25,7 +25,7 @@ def annotation(self) -> Any: return self.raw_field.annotation @property - def value_type(self) -> type[Any]: + def excel_codec(self) -> type[Any]: annotation = self.annotation origin = get_origin(annotation) if origin in (UnionType, getattr(__import__('typing'), 'Union')): @@ -36,6 +36,11 @@ def value_type(self) -> type[Any]: 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)) @@ -62,7 +67,7 @@ def runtime_metadata(self) -> FieldMetaInfo: declared = self.declared_metadata return declared.bind_runtime( required=self.required, - value_type=cast(type[ABCValueType], self.value_type), + excel_codec=cast(type[ExcelFieldCodec], self.excel_codec), parent_label=declared.label, parent_key=Key(self.name), key=Key(self.name), @@ -75,7 +80,7 @@ def validate_value(self, raw_value: Any) -> Any: return None raise ValueError(msg(MessageKey.THIS_FIELD_IS_REQUIRED)) - return self.value_type.__validate__(raw_value, self.declared_metadata) + return self.excel_codec.normalize_import_value(raw_value, self.declared_metadata) @dataclass(frozen=True) @@ -113,71 +118,64 @@ def get_model_field_names(model: type[BaseModel]) -> list[str]: def instantiate_pydantic_model[ModelT: BaseModel]( # noqa: C901 data: dict[Key, Any], model: type[ModelT], -) -> ModelT | list[ExcelCellError]: +) -> ModelT | list[ExcelCellError | ExcelRowError]: """实例化 Pydantic 模型, 并返回错误.""" model_adapter = PydanticModelAdapter(model) - validated_data: dict[str, Any] = {} - errors: list[ExcelCellError] = [] + 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(Key(field_adapter.name), PydanticUndefined) if raw_value is PydanticUndefined: - if field_adapter.required: - errors.append( - ExcelCellError( - label=field_adapter.declared_metadata.label, - message=msg(MessageKey.THIS_FIELD_IS_REQUIRED), - ) - ) continue try: - validated_data[field_adapter.name] = field_adapter.validate_value(raw_value) + 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 cast( - ModelT, - model.model_construct( - _fields_set=set(validated_data.keys()), - **validated_data, - ), - ) + 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 - value_type = field_adapter.value_type + excel_codec = field_adapter.excel_codec - if issubclass(value_type, ComplexABCValueType): - for offset, (key, sub_field_info) in enumerate(value_type.model_items()): + 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, - value_type=cast(type[ABCValueType], value_type), + excel_codec=cast(type[ExcelFieldCodec], excel_codec), parent_label=declared_metadata.label, parent_key=Key(field_adapter.name), key=key, offset=offset, ) - elif issubclass(value_type, ABCValueType): + elif issubclass(excel_codec, ExcelFieldCodec): yield field_adapter.runtime_metadata() else: raise ProgrammaticError( - msg(MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED, value_type=value_type) + msg(MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED, value_type=excel_codec) ) def _handle_error( - error_container: list[ExcelCellError], + error_container: list[ExcelCellError | ExcelRowError], exc: Exception, field_def: FieldMetaInfo, ) -> None: @@ -189,3 +187,64 @@ def _handle_error( ) 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 cast(ModelT, 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/messages.py b/src/excelalchemy/i18n/messages.py index ea6d21e..0bb468b 100644 --- a/src/excelalchemy/i18n/messages.py +++ b/src/excelalchemy/i18n/messages.py @@ -146,7 +146,7 @@ class MessageKey(StrEnum): 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 a ValueType or ComplexValueType subclass; {value_type} is not supported' + '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}', @@ -189,7 +189,7 @@ class MessageKey(StrEnum): 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 value_type definition may be invalid' + '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' @@ -220,7 +220,9 @@ class MessageKey(StrEnum): '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', + 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', @@ -241,7 +243,7 @@ class MessageKey(StrEnum): 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 {value_type}', + 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', diff --git a/src/excelalchemy/identity.py b/src/excelalchemy/identity.py new file mode 100644 index 0000000..23b0272 --- /dev/null +++ b/src/excelalchemy/identity.py @@ -0,0 +1,8 @@ +"""Compatibility shim for ``excelalchemy.identity``.""" + +from excelalchemy._internal.deprecation import warn_compat_import + +warn_compat_import('excelalchemy.identity', 'the excelalchemy package root') + +from excelalchemy._internal.identity import * # noqa: F403 + diff --git a/src/excelalchemy/metadata.py b/src/excelalchemy/metadata.py new file mode 100644 index 0000000..3405872 --- /dev/null +++ b/src/excelalchemy/metadata.py @@ -0,0 +1,605 @@ +"""Excel metadata definitions decoupled from Pydantic internals.""" + +import copy +import datetime +import logging +from functools import cached_property +from typing import AbstractSet, Any, Callable + +from pydantic import BaseModel, Field +from pydantic.fields import FieldInfo, PydanticUndefined + +from excelalchemy._internal.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._internal.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' + + +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: + """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) -> 'FieldMetaInfo': + return copy.copy(self) + + def inherited_from(self, parent: 'FieldMetaInfo') -> 'FieldMetaInfo': + 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, + ) -> 'FieldMetaInfo': + 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 + + metadata = (field_info.json_schema_extra or {}).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: + if fraction_digits is not None and not isinstance(fraction_digits, int): + raise ValueError(msg(MessageKey.FRACTION_DIGITS_MUST_BE_INTEGER)) + + 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: Any = 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: Callable[[], Any] | None = None, + alias: str | None = None, + title: str | None = None, + description: str | None = None, + exclude: AbstractSet[IntStr] | Any = None, + include: 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: + 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 = {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/src/excelalchemy/results.py b/src/excelalchemy/results.py new file mode 100644 index 0000000..567982c --- /dev/null +++ b/src/excelalchemy/results.py @@ -0,0 +1,77 @@ +"""导入 Excel 的结果""" + +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field + +from excelalchemy._internal.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 + + +class ValidateRowResult(str, Enum): + """导入结果""" + + SUCCESS = 'SUCCESS' + FAIL = 'FAIL' + + def __str__(self): + if self is ValidateRowResult.SUCCESS: + return dmsg(MessageKey.VALIDATE_ROW_SUCCESS) + return dmsg(MessageKey.VALIDATE_ROW_FAIL) + + +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): + """导入数据结果""" + + model_config = ConfigDict(extra='allow') + + 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='导入失败的数据条数') + + @classmethod + def from_validate_header_result(cls, result: ValidateHeaderResult) -> 'ImportResult': + """从校验表头结果构造导入结果""" + 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 index e69de29..7a24f22 100644 --- a/src/excelalchemy/types/__init__.py +++ b/src/excelalchemy/types/__init__.py @@ -0,0 +1,15 @@ +"""Compatibility re-exports for the pre-refactor ``excelalchemy.types`` namespace.""" + +from excelalchemy._internal.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._internal.header_models import * # noqa: F403 +from excelalchemy._internal.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 index d5896ed..94b0c38 100644 --- a/src/excelalchemy/types/abstract.py +++ b/src/excelalchemy/types/abstract.py @@ -1,118 +1,7 @@ -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any +"""Compatibility shim for ``excelalchemy.types.abstract``.""" -from pydantic import GetCoreSchemaHandler -from pydantic_core import core_schema +from excelalchemy._internal.deprecation import warn_compat_import -from excelalchemy.types.identity import Key +warn_compat_import('excelalchemy.types.abstract', 'excelalchemy.codecs.base') -if TYPE_CHECKING: - 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: - """用于把 worksheet 读入后的值转回用户可识别的数据, 处理聚合之前的数据""" - - @classmethod - @abstractmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - """验证用户输入的值是否符合约束. 接收 serialize 后的值""" - - @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 ComplexABCValueType(ABCValueType, dict): - """用于生成 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: - """用于把 worksheet 读入后的值转回用户可识别的数据, 处理聚合之前的数据""" - - @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 +from excelalchemy.codecs.base import * # noqa: F403 diff --git a/src/excelalchemy/types/alchemy.py b/src/excelalchemy/types/alchemy.py index 517c572..1aa5572 100644 --- a/src/excelalchemy/types/alchemy.py +++ b/src/excelalchemy/types/alchemy.py @@ -1,126 +1,7 @@ -"""实例化 ExcelAlchemy 时的配置""" +"""Compatibility shim for ``excelalchemy.types.alchemy``.""" -from __future__ import annotations +from excelalchemy._internal.deprecation import warn_compat_import -from dataclasses import dataclass, field -from enum import Enum -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal +warn_compat_import('excelalchemy.types.alchemy', 'excelalchemy.config') -from pydantic import BaseModel - -from excelalchemy.core.storage_protocol import ExcelStorage -from excelalchemy.exc 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(str, Enum): - """Excel 模式""" - - IMPORT = 'IMPORT' - EXPORT = 'EXPORT' - - -class ImportMode(str, Enum): - CREATE = 'CREATE' # 创建 - UPDATE = 'UPDATE' # 更新 - CREATE_OR_UPDATE = 'CREATE_OR_UPDATE' # 创建或更新 - - -@dataclass -class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateModelT: BaseModel]: - 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) - - storage: ExcelStorage | None = field(default=None) - minio: Minio | None = field(default=None) - bucket_name: str = field(default='excel') - url_expires: int = field(default=3600) - locale: str = field(default='zh-CN') - - sheet_name: Literal['Sheet1'] = field(default='Sheet1') - - def validate_model(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): - 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): - 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): - 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)) - # 创建模型和更新模型的字段必须一致 - 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): - self.validate_model() - - -@dataclass -class ExporterConfig[ExporterModelT: BaseModel]: - 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) - - storage: ExcelStorage | None = field(default=None) - minio: Minio | None = field(default=None) - bucket_name: str = field(default='excel') - url_expires: int = field(default=3600) - locale: str = field(default='zh-CN') - - sheet_name: Literal['Sheet1'] = field(default='Sheet1') - - def validate_model(self): - if not self.exporter_model: - raise ValueError(msg(MessageKey.EXPORTER_MODEL_CANNOT_BE_EMPTY)) - return self - - def __post_init__(self): - self.validate_model() +from excelalchemy.config import * # noqa: F403 diff --git a/src/excelalchemy/types/field.py b/src/excelalchemy/types/field.py index 704f73c..c4fd228 100644 --- a/src/excelalchemy/types/field.py +++ b/src/excelalchemy/types/field.py @@ -1,434 +1,7 @@ -"""Excel metadata definitions decoupled from Pydantic internals.""" +"""Compatibility shim for ``excelalchemy.types.field``.""" -import copy -import datetime -import logging -from functools import cached_property -from typing import AbstractSet, Any, Callable +from excelalchemy._internal.deprecation import warn_compat_import -from pydantic import BaseModel, Field -from pydantic.fields import FieldInfo, PydanticUndefined +warn_compat_import('excelalchemy.types.field', 'excelalchemy.metadata') -from excelalchemy.const 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.exc 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 -from excelalchemy.types.abstract import ABCValueType, Undefined -from excelalchemy.types.identity import Key, Label, OptionId, UniqueKey, UniqueLabel - -EXCEL_FIELD_METADATA_KEY = 'excelalchemy_metadata' - - -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: - """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.value_type: type[ABCValueType] = Undefined - 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) -> 'FieldMetaInfo': - return copy.copy(self) - - def inherited_from(self, parent: 'FieldMetaInfo') -> 'FieldMetaInfo': - 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, - value_type: type[ABCValueType], - parent_label: Label, - parent_key: Key, - key: Key, - offset: int, - ) -> 'FieldMetaInfo': - runtime = self.clone() - runtime.required = required - runtime.value_type = value_type - runtime.parent_label = parent_label - runtime.parent_key = parent_key - runtime.key = key - runtime.offset = offset - return runtime - - 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'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__ - - -def extract_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: - metadata = (field_info.json_schema_extra or {}).get(EXCEL_FIELD_METADATA_KEY) - if not isinstance(metadata, FieldMetaInfo): - raise ProgrammaticError(msg(MessageKey.FIELD_DEFINITIONS_MUST_USE_FIELDMETA)) - return metadata - - -# pylint: disable=invalid-name -# pylint: disable=too-many-locals -def FieldMeta( - default: Any = 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: Callable[[], Any] | None = None, - alias: str | None = None, - title: str | None = None, - description: str | None = None, - exclude: AbstractSet[IntStr] | Any = None, - include: 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: - if fraction_digits is not None and not isinstance(fraction_digits, int): - raise ValueError(msg(MessageKey.FRACTION_DIGITS_MUST_BE_INTEGER)) - - metadata = 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, - ) - - json_schema_extra = {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) +from excelalchemy.metadata import * # noqa: F403 diff --git a/src/excelalchemy/types/header.py b/src/excelalchemy/types/header.py index a7a122a..0bd9327 100644 --- a/src/excelalchemy/types/header.py +++ b/src/excelalchemy/types/header.py @@ -1,25 +1,10 @@ -"""用于表示用户实际输入 Excel 的表头""" +"""Compatibility shim for ``excelalchemy.types.header``.""" -from pydantic import BaseModel -from pydantic.fields import Field +from excelalchemy._internal.deprecation import warn_compat_import -from excelalchemy.const import UNIQUE_HEADER_CONNECTOR -from excelalchemy.types.identity import Label, UniqueLabel +warn_compat_import( + 'excelalchemy.types.header', + 'ExcelAlchemy internals only; avoid importing header models directly', +) - -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) +from excelalchemy._internal.header_models import * # noqa: F403 diff --git a/src/excelalchemy/types/identity.py b/src/excelalchemy/types/identity.py index b46e221..7fdf43e 100644 --- a/src/excelalchemy/types/identity.py +++ b/src/excelalchemy/types/identity.py @@ -1,62 +1,7 @@ -"""定义了一些用于标识的类型""" +"""Compatibility shim for ``excelalchemy.types.identity``.""" -from typing import Any +from excelalchemy._internal.deprecation import warn_compat_import -from pydantic import GetCoreSchemaHandler -from pydantic_core import core_schema +warn_compat_import('excelalchemy.types.identity', 'the excelalchemy package root') - -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): - """Excel 的列名""" - - -class UniqueLabel(Label): - """Excel 唯一的列名""" - - -class Key(_StringIdentity): - """Python 模型的键名""" - - -class UniqueKey(Key): - """Python 模型唯一的键名""" - - -class RowIndex(_IntegerIdentity): - """Excel 的行索引, 从 0 开始""" - - -class ColumnIndex(_IntegerIdentity): - """Excel 的列索引, 从 0 开始""" - - -class OptionId(_StringIdentity): - """选项 ID""" - - -class Base64Str(_StringIdentity): - """Base64 编码的字符串""" - - -class UrlStr(_StringIdentity): - """URL 字符串""" +from excelalchemy._internal.identity import * # noqa: F403 diff --git a/src/excelalchemy/types/result.py b/src/excelalchemy/types/result.py index 703f57a..5b34fd9 100644 --- a/src/excelalchemy/types/result.py +++ b/src/excelalchemy/types/result.py @@ -1,77 +1,7 @@ -"""导入 Excel 的结果""" +"""Compatibility shim for ``excelalchemy.types.result``.""" -from enum import Enum +from excelalchemy._internal.deprecation import warn_compat_import -from pydantic import BaseModel, ConfigDict, Field +warn_compat_import('excelalchemy.types.result', 'excelalchemy.results') -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.types.identity import Label - - -class ValidateRowResult(str, Enum): - """导入结果""" - - SUCCESS = 'SUCCESS' - FAIL = 'FAIL' - - def __str__(self): - if self is ValidateRowResult.SUCCESS: - return dmsg(MessageKey.VALIDATE_ROW_SUCCESS) - return dmsg(MessageKey.VALIDATE_ROW_FAIL) - - -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): - """导入数据结果""" - - model_config = ConfigDict(extra='allow') - - 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='导入失败的数据条数') - - @classmethod - def from_validate_header_result(cls, result: ValidateHeaderResult) -> 'ImportResult': - """从校验表头结果构造导入结果""" - 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, - ) +from excelalchemy.results import * # noqa: F403 diff --git a/src/excelalchemy/types/value/__init__.py b/src/excelalchemy/types/value/__init__.py index 8e513f7..dd90278 100644 --- a/src/excelalchemy/types/value/__init__.py +++ b/src/excelalchemy/types/value/__init__.py @@ -1,10 +1,7 @@ -"""ExcelAlchemy value types,用于生成 pydantic 的模型时,用于标记字段的类型""" +"""Compatibility shim for ``excelalchemy.types.value``.""" -from excelalchemy.types.abstract import ABCValueType +from excelalchemy._internal.deprecation import warn_compat_import -EXCEL_CHOICE_VALUE_TYPE: dict[type[ABCValueType], type[ABCValueType]] = {} +warn_compat_import('excelalchemy.types.value', 'excelalchemy.codecs') - -def excel_choice(value_type: type[ABCValueType]) -> type[ABCValueType]: - EXCEL_CHOICE_VALUE_TYPE[value_type] = value_type - return value_type +from excelalchemy.codecs import * # noqa: F403 diff --git a/src/excelalchemy/types/value/boolean.py b/src/excelalchemy/types/value/boolean.py index fc0a413..023e085 100644 --- a/src/excelalchemy/types/value/boolean.py +++ b/src/excelalchemy/types/value/boolean.py @@ -1,91 +1,7 @@ -import logging -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.boolean``.""" -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.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value import excel_choice +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.boolean', 'excelalchemy.codecs.boolean') -@excel_choice -class Boolean(ABCValueType): - __name__ = '布尔' - - @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 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 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 __validate__(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(), - ) - ) +from excelalchemy.codecs.boolean import * # noqa: F403 diff --git a/src/excelalchemy/types/value/date.py b/src/excelalchemy/types/value/date.py index b9e94a5..ac18266 100644 --- a/src/excelalchemy/types/value/date.py +++ b/src/excelalchemy/types/value/date.py @@ -1,104 +1,7 @@ -import logging -from datetime import datetime -from typing import Any, cast +"""Compatibility shim for ``excelalchemy.types.value.date``.""" -import pendulum -from pendulum import DateTime +from excelalchemy._internal.deprecation import warn_compat_import -from excelalchemy.const import DATE_FORMAT_TO_HINT_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption -from excelalchemy.exc import ConfigError -from excelalchemy.i18n.messages import MessageKey -from excelalchemy.i18n.messages import message as msg -from excelalchemy.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo +warn_compat_import('excelalchemy.types.value.date', 'excelalchemy.codecs.date') - -class Date(ABCValueType, datetime): - __name__ = '日期选择' - - @classmethod - def comment(cls, field_meta: FieldMetaInfo) -> str: - if not field_meta.date_format: - raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) - return '\n'.join( - [ - field_meta.comment_required, - field_meta.comment_date_format, - field_meta.comment_date_range_option, - field_meta.comment_hint, - ] - ) - - @classmethod - def serialize(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) - return value - - if not field_meta.date_format: - raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) - - value = str(value).strip() - try: - v = value.replace('/', '-') # pendulum 不支持 / 作为日期分隔符 - dt: DateTime = cast(DateTime, pendulum.parse(v)) - return dt.replace(tzinfo=field_meta.timezone) - except Exception as exc: - logging.warning('ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', cls.__name__, value, exc) - return value - - @classmethod - def deserialize(cls, value: str | datetime | None | Any, field_meta: FieldMetaInfo) -> str: - match value: - case None | '': - return '' - case datetime(): - return value.strftime(field_meta.python_date_format) - case int() | float(): - return datetime.fromtimestamp(int(value) / MILLISECOND_TO_SECOND).strftime( - field_meta.python_date_format - ) - case _: - return str(value) if value is not None else '' - - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> int: - if field_meta.date_format is None: - raise ConfigError(msg(MessageKey.DATE_FORMAT_NOT_CONFIGURED)) - - if not isinstance(value, datetime): - 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) - - if errors: - raise ValueError(*errors) - else: - return int(parsed.timestamp() * MILLISECOND_TO_SECOND) - - @staticmethod - def _parse_date(v: datetime, field_meta: FieldMetaInfo) -> datetime: - format_ = field_meta.python_date_format - parsed = datetime.strptime(v.strftime(format_), format_) - parsed = parsed.replace(tzinfo=field_meta.timezone) - return parsed - - @staticmethod - def _validate_date_range(parsed: datetime, field_meta: FieldMetaInfo) -> list[str]: - now = datetime.now(tz=field_meta.timezone) - errors: list[str] = [] - - match field_meta.date_range_option: - case DataRangeOption.PRE: - if parsed > now: - errors.append(msg(MessageKey.DATE_MUST_BE_EARLIER_THAN_NOW)) - case DataRangeOption.NEXT: - if parsed < now: - errors.append(msg(MessageKey.DATE_MUST_BE_LATER_THAN_NOW)) - case DataRangeOption.NONE | None: - ... - - return errors +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 index 1e44ad3..458e538 100644 --- a/src/excelalchemy/types/value/date_range.py +++ b/src/excelalchemy/types/value/date_range.py @@ -1,165 +1,7 @@ -import logging -from datetime import datetime -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.date_range``.""" -import pendulum -from pendulum import DateTime -from pydantic import BaseModel +from excelalchemy._internal.deprecation import warn_compat_import -from excelalchemy.const import DATE_FORMAT_TO_PYTHON_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption -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.types.abstract import ComplexABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import Key +warn_compat_import('excelalchemy.types.value.date_range', 'excelalchemy.codecs.date_range') - -class _DateRangeImpl(BaseModel): - start: datetime | None - end: datetime | None - - -class DateRange(ComplexABCValueType): - start: datetime | None - end: datetime | None - - __name__ = '日期范围' - - @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): - # 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=dmsg(MessageKey.LABEL_START_DATE))), - (Key('end'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_END_DATE))), - ] - - @classmethod - def 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 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') - 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.model_validate(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(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 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 could not be deserialized; returning the original value', 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 +from excelalchemy.codecs.date_range import * # noqa: F403 diff --git a/src/excelalchemy/types/value/email.py b/src/excelalchemy/types/value/email.py index cd3b97d..3c74342 100644 --- a/src/excelalchemy/types/value/email.py +++ b/src/excelalchemy/types/value/email.py @@ -1,29 +1,7 @@ -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.email``.""" -from pydantic import EmailStr, TypeAdapter +from excelalchemy._internal.deprecation import warn_compat_import -from excelalchemy.i18n.messages import MessageKey -from excelalchemy.i18n.messages import message as msg -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.string import String +warn_compat_import('excelalchemy.types.value.email', 'excelalchemy.codecs.email') - -class Email(String): - _validator = TypeAdapter(EmailStr) - - @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(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 +from excelalchemy.codecs.email import * # noqa: F403 diff --git a/src/excelalchemy/types/value/money.py b/src/excelalchemy/types/value/money.py index dbb5e74..0ee8b66 100644 --- a/src/excelalchemy/types/value/money.py +++ b/src/excelalchemy/types/value/money.py @@ -1,26 +1,7 @@ -from typing import Any, ClassVar +"""Compatibility shim for ``excelalchemy.types.value.money``.""" -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.number import Number +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.money', 'excelalchemy.codecs.money') -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 comment(cls, field_meta: FieldMetaInfo) -> str: - return super().comment(cls._money_field_meta(field_meta)) - - @classmethod - def deserialize(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: - return super().deserialize(value, cls._money_field_meta(field_meta)) - - @classmethod - def __validate__(cls, value: Any, field_meta: FieldMetaInfo) -> float | int: - return super().__validate__(value, cls._money_field_meta(field_meta)) +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 index 0f1f4f6..1baa1cf 100644 --- a/src/excelalchemy/types/value/multi_checkbox.py +++ b/src/excelalchemy/types/value/multi_checkbox.py @@ -1,73 +1,7 @@ -import logging -from typing import Any, cast +"""Compatibility shim for ``excelalchemy.types.value.multi_checkbox``.""" -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.exc 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.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import OptionId +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.multi_checkbox', 'excelalchemy.codecs.multi_checkbox') -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, - dmsg(MessageKey.COMMENT_SELECTION_MODE, value=dmsg(MessageKey.COMMENT_SELECTION_VALUE_MULTI)), - 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> could not parse Excel input %s; returning the original value', 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(msg(MessageKey.OPTION_NOT_FOUND_HEADER_COMMENT)) - - 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 value - - if len(value) != len(set(value)): - raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) - - 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) +from excelalchemy.codecs.multi_checkbox import * # noqa: F403 diff --git a/src/excelalchemy/types/value/number.py b/src/excelalchemy/types/value/number.py index 60cd9bf..05c0f0e 100644 --- a/src/excelalchemy/types/value/number.py +++ b/src/excelalchemy/types/value/number.py @@ -1,144 +1,7 @@ -import logging -from decimal import ROUND_DOWN, Context, Decimal, InvalidOperation -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.number``.""" -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.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.number', 'excelalchemy.codecs.number') -def canonicalize_decimal(value: Decimal, digits_limit: int | None) -> Decimal: - """将 Decimal 转换为指定精度的 Decimal""" - 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('fraction_digits is too small and causes precision loss: %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): - 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, - 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 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> could not parse Excel input %s; returning the original value. Reason: %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> 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: - # 如果输入不是 Decimal 类型,尝试转换。 - 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] = [] - - # 从 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( - 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)) - 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(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) - # 初始化一个错误信息列表。 - 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(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) - return value +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 index c8d0aed..cbc8086 100644 --- a/src/excelalchemy/types/value/number_range.py +++ b/src/excelalchemy/types/value/number_range.py @@ -1,99 +1,7 @@ -import logging -from decimal import Decimal -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.number_range``.""" -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.types.abstract import ComplexABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import Key -from excelalchemy.types.value.number import Number, canonicalize_decimal, transform_decimal +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.number_range', 'excelalchemy.codecs.number_range') -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): - # 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=dmsg(MessageKey.LABEL_MINIMUM_VALUE))), - (Key('end'), FieldMetaInfo(label=dmsg(MessageKey.LABEL_MAXIMUM_VALUE))), - ] - - @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: - start, end = Decimal(value['start']), Decimal(value['end']) # type: ignore[index] - 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 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> could not parse Excel input %s; returning the original value. Reason: %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(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: 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(msg(MessageKey.ENTER_NUMBER)) from exc - - raise ValueError(msg(MessageKey.ENTER_NUMBER_EXPECTED_FORMAT)) +from excelalchemy.codecs.number_range import * # noqa: F403 diff --git a/src/excelalchemy/types/value/organization.py b/src/excelalchemy/types/value/organization.py index 59ea944..caf047f 100644 --- a/src/excelalchemy/types/value/organization.py +++ b/src/excelalchemy/types/value/organization.py @@ -1,70 +1,7 @@ -import logging -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.organization``.""" -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.i18n.messages import MessageKey -from excelalchemy.i18n.messages import display_message as dmsg -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.multi_checkbox import MultiCheckbox -from excelalchemy.types.value.radio import Radio +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.organization', 'excelalchemy.codecs.organization') -class SingleOrganization(Radio): - __name__ = '组织单选' - - @classmethod - def 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 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, - dmsg(MessageKey.COMMENT_HINT, value=field_meta.hint or dmsg(MessageKey.MULTI_ORGANIZATION_HINT)), - ] - ) - - @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) +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 index b068626..1161171 100644 --- a/src/excelalchemy/types/value/phone_number.py +++ b/src/excelalchemy/types/value/phone_number.py @@ -1,20 +1,7 @@ -import re -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.phone_number``.""" -from excelalchemy.i18n.messages import MessageKey -from excelalchemy.i18n.messages import message as msg -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.string import String +from excelalchemy._internal.deprecation import warn_compat_import -PHONE_NUMBER_PATTERN = re.compile(r'^((0\d{2,3}-\d{7,8})|(1[3456789]\d{9}))$') +warn_compat_import('excelalchemy.types.value.phone_number', 'excelalchemy.codecs.phone_number') - -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(msg(MessageKey.VALID_PHONE_NUMBER_REQUIRED)) - - return parsed +from excelalchemy.codecs.phone_number import * # noqa: F403 diff --git a/src/excelalchemy/types/value/radio.py b/src/excelalchemy/types/value/radio.py index d252dfe..4a0585d 100644 --- a/src/excelalchemy/types/value/radio.py +++ b/src/excelalchemy/types/value/radio.py @@ -1,72 +1,7 @@ -import logging -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.radio``.""" -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -from excelalchemy.exc 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.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.identity import OptionId +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.radio', 'excelalchemy.codecs.radio') -class Radio(ABCValueType, str): - __name__ = '单选框组' - - @classmethod - def 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 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( - '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 __validate__(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 +from excelalchemy.codecs.radio import * # noqa: F403 diff --git a/src/excelalchemy/types/value/staff.py b/src/excelalchemy/types/value/staff.py index ae437dc..64795c9 100644 --- a/src/excelalchemy/types/value/staff.py +++ b/src/excelalchemy/types/value/staff.py @@ -1,73 +1,7 @@ -import logging -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.staff``.""" -from excelalchemy.const import MULTI_CHECKBOX_SEPARATOR -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.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 +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.staff', 'excelalchemy.codecs.staff') -class SingleStaff(Radio): - __name__ = '人员单选' - - @classmethod - def 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 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, - dmsg(MessageKey.COMMENT_HINT, value=field_meta.hint or dmsg(MessageKey.MULTI_STAFF_HINT)), - ] - ) - - @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(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) - - option_names = field_meta.exchange_option_ids_to_names(value) - return f'{MULTI_CHECKBOX_SEPARATOR}'.join(option_names) - - logging.warning('%s could not be deserialized', cls.__name__) - return value +from excelalchemy.codecs.staff import * # noqa: F403 diff --git a/src/excelalchemy/types/value/string.py b/src/excelalchemy/types/value/string.py index 7bf70f5..1a90d34 100644 --- a/src/excelalchemy/types/value/string.py +++ b/src/excelalchemy/types/value/string.py @@ -1,140 +1,7 @@ -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.string``.""" -from excelalchemy.const import CharacterSet -from excelalchemy.exc 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.types.abstract import ABCValueType -from excelalchemy.types.field import FieldMetaInfo +from excelalchemy._internal.deprecation import warn_compat_import -SPECIAL_SYMBOLS = set( - '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~"。?!,、;:‘’“”()《》〈〉【】〔〕{}⦅⦆〖〗〘〙〚〛〜〝〞〟〰–—‘‛“”„‟…‧﹏.' -) +warn_compat_import('excelalchemy.types.value.string', 'excelalchemy.codecs.string') - -def _is_chinese_character(character: str) -> bool: - # https://www.unicode.org/versions/Unicode15.0.0/ch18.pdf - # - # Table 18-1. Blocks Containing Han Ideographs - # # Block Range Comment - # CJK Unified Ideographs 4E00–9FFF Common - # CJK Unified Ideographs Extension A 3400–4DBF Rare - # CJK Unified Ideographs Extension B 20000–2A6DF Rare, historic - # CJK Unified Ideographs Extension C 2A700–2B73F Rare, historic - # CJK Unified Ideographs Extension D 2B740–2B81F Uncommon, some in current use - # CJK Unified Ideographs Extension E 2B820–2CEAF Rare, historic - # CJK Unified Ideographs Extension F 2CEB0–2EBEF Rare, historic - # CJK Unified Ideographs Extension G 30000–3134F Rare, historic - # CJK Unified Ideographs Extension H 31350–323AF Rare, historic - # CJK Compatibility Ideographs F900–FAFF Duplicates, unifiable variants, corporate characters - # CJK Compatibility Ideographs Supplement 2F800–2FA1F Unifiable variant - code_point = ord(character) - return ( - (0x4E00 <= code_point <= 0x9FFF) - or (0x3400 <= code_point <= 0x4DBF) - or (0x20000 <= code_point <= 0x2A6DF) - or (0x2A700 <= code_point <= 0x2B73F) - or (0x2B740 <= code_point <= 0x2B81F) - or (0x2B820 <= code_point <= 0x2CEAF) - or (0x2CEB0 <= code_point <= 0x2EBEF) - or (0x30000 <= code_point <= 0x3134F) - or (0x31350 <= code_point <= 0x323AF) - or (0xF900 <= code_point <= 0xFAFF) - or (0x2F800 <= code_point <= 0x2FA1F) - ) - - -def _is_number_character(character: str) -> bool: - return ord(character) in range(ord('0'), ord('9') + 1) - - -def _is_lowercase_letters(character: str) -> bool: - return ord(character) in range(ord('a'), ord('z') + 1) - - -def _is_uppercase_letters(character: str) -> bool: - return ord(character) in range(ord('A'), ord('Z') + 1) - - -def _is_special_symbols(character: str) -> bool: - return character in SPECIAL_SYMBOLS - - -_CHARACTER_SET_TO_VALIDATOR = { - CharacterSet.CHINESE: _is_chinese_character, - CharacterSet.NUMBER: _is_number_character, - CharacterSet.LOWERCASE_LETTERS: _is_lowercase_letters, - CharacterSet.UPPERCASE_LETTERS: _is_uppercase_letters, - CharacterSet.SPECIAL_SYMBOLS: _is_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: - 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): - @classmethod - def 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: - return str(value).strip() - - @classmethod - def deserialize(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: - 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(msg(MessageKey.MAX_LENGTH_CHARACTERS, max_length=field_meta.importer_max_length)) - - errors.extend(cls.__check_character_set__(parsed, field_meta)) - - if errors: - raise ValueError(*errors) - else: - return parsed - - @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(msg(MessageKey.CHARACTER_SET_NOT_CONFIGURED)) - - for single_character in value: - if not any(_CHARACTER_SET_TO_VALIDATOR[cs](single_character) for cs in 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 +from excelalchemy.codecs.string import * # noqa: F403 diff --git a/src/excelalchemy/types/value/tree.py b/src/excelalchemy/types/value/tree.py index b985f78..b156a27 100644 --- a/src/excelalchemy/types/value/tree.py +++ b/src/excelalchemy/types/value/tree.py @@ -1,54 +1,7 @@ -import logging -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.tree``.""" -from excelalchemy.i18n.messages import MessageKey -from excelalchemy.i18n.messages import display_message as dmsg -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.multi_checkbox import MultiCheckbox -from excelalchemy.types.value.radio import Radio +from excelalchemy._internal.deprecation import warn_compat_import +warn_compat_import('excelalchemy.types.value.tree', 'excelalchemy.codecs.tree') -class SingleTreeNode(Radio): - __name__ = '树形单选' - - @classmethod - def 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 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: - 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 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) +from excelalchemy.codecs.tree import * # noqa: F403 diff --git a/src/excelalchemy/types/value/url.py b/src/excelalchemy/types/value/url.py index 0054734..3460794 100644 --- a/src/excelalchemy/types/value/url.py +++ b/src/excelalchemy/types/value/url.py @@ -1,27 +1,7 @@ -from typing import Any +"""Compatibility shim for ``excelalchemy.types.value.url``.""" -from pydantic import HttpUrl, TypeAdapter +from excelalchemy._internal.deprecation import warn_compat_import -from excelalchemy.i18n.messages import MessageKey -from excelalchemy.i18n.messages import message as msg -from excelalchemy.types.field import FieldMetaInfo -from excelalchemy.types.value.string import String +warn_compat_import('excelalchemy.types.value.url', 'excelalchemy.codecs.url') - -class Url(String): - _validator = TypeAdapter(HttpUrl) - - @classmethod - def __validate__(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 +from excelalchemy.codecs.url import * # noqa: F403 diff --git a/src/excelalchemy/util/convertor.py b/src/excelalchemy/util/convertor.py index 62157c0..5ebe643 100644 --- a/src/excelalchemy/util/convertor.py +++ b/src/excelalchemy/util/convertor.py @@ -1,8 +1,8 @@ import re from typing import Any -from excelalchemy.const import FIELD_DATA_KEY -from excelalchemy.types.identity import Key +from excelalchemy._internal.constants import FIELD_DATA_KEY +from excelalchemy._internal.identity import Key def import_data_converter(data: dict[str, Any]) -> dict[str, Any]: # noqa: C901 diff --git a/src/excelalchemy/util/file.py b/src/excelalchemy/util/file.py index d74914b..aa9c51f 100644 --- a/src/excelalchemy/util/file.py +++ b/src/excelalchemy/util/file.py @@ -1,9 +1,10 @@ import math from typing import Any -from excelalchemy.const import UNIQUE_HEADER_CONNECTOR +from excelalchemy._internal.constants import UNIQUE_HEADER_CONNECTOR -EXCEL_PREFIX = 'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64' +EXCEL_MEDIA_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +EXCEL_PREFIX = f'data:{EXCEL_MEDIA_TYPE};base64' def add_excel_prefix(content: str) -> str: diff --git a/tests/contracts/test_core_components_contract.py b/tests/contracts/test_core_components_contract.py index 5686043..e6d6138 100644 --- a/tests/contracts/test_core_components_contract.py +++ b/tests/contracts/test_core_components_contract.py @@ -1,14 +1,12 @@ from pydantic import BaseModel -from excelalchemy import DateFormat, DateRange, FieldMeta +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 excelalchemy.exc import ExcelCellError -from excelalchemy.types.alchemy import ImportMode -from excelalchemy.types.identity import Key, Label, RowIndex from tests.support.contract_models import MergedContractImporter, SimpleContractImporter diff --git a/tests/contracts/test_export_contract.py b/tests/contracts/test_export_contract.py index 422ca37..7dcfa43 100644 --- a/tests/contracts/test_export_contract.py +++ b/tests/contracts/test_export_contract.py @@ -3,7 +3,7 @@ from minio import Minio from excelalchemy import ExcelAlchemy, ExporterConfig -from tests.support import BaseTestCase, decode_prefixed_excel_to_workbook +from tests.support import BaseTestCase, decode_prefixed_excel_to_workbook, load_binary_excel_to_workbook from tests.support.contract_models import ( MergedContractImporter, SimpleContractImporter, @@ -20,6 +20,22 @@ async def test_export_returns_prefixed_base64_payload(self): 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))) diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py index 965acb4..4dccf33 100644 --- a/tests/contracts/test_pydantic_contract.py +++ b/tests/contracts/test_pydantic_contract.py @@ -1,8 +1,20 @@ -from pydantic import BaseModel +from typing import Annotated -from excelalchemy import DateFormat, DateRange, Email, FieldMeta, Label +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.types.field import FieldMetaInfo, extract_declared_field_metadata +from excelalchemy.metadata import FieldMetaInfo, extract_declared_field_metadata class ContractPydanticModel(BaseModel): @@ -33,3 +45,99 @@ def test_instantiate_pydantic_model_maps_validation_errors_to_excel_cell_errors( 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 index 50c400d..3bd5462 100644 --- a/tests/contracts/test_result_contract.py +++ b/tests/contracts/test_result_contract.py @@ -1,5 +1,5 @@ from excelalchemy import Label, ValidateResult -from excelalchemy.types.result import ImportResult, ValidateHeaderResult +from excelalchemy.results import ImportResult, ValidateHeaderResult class TestResultContracts: diff --git a/tests/contracts/test_template_contract.py b/tests/contracts/test_template_contract.py index 082896c..b05dd05 100644 --- a/tests/contracts/test_template_contract.py +++ b/tests/contracts/test_template_contract.py @@ -10,6 +10,7 @@ get_fill_color, list_data_validations, list_merge_ranges, + load_binary_excel_to_workbook, ) from tests.support.contract_models import MergedContractImporter, SimpleContractImporter, creator @@ -22,6 +23,21 @@ async def test_download_template_returns_prefixed_base64_payload(self): 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))) diff --git a/tests/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index 46ab765..b3c3f87 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -1,9 +1,9 @@ import datetime import random -from typing import Any, cast +from typing import Annotated, Any, cast from minio import Minio -from pydantic import BaseModel +from pydantic import BaseModel, Field from excelalchemy import ( Boolean, @@ -14,6 +14,7 @@ Email, ExcelAlchemy, ExcelCellError, + ExcelMeta, ExporterConfig, FieldMeta, ImporterConfig, @@ -427,7 +428,23 @@ class EmptyFieldMetaModel(BaseModel): config = ImporterConfig(EmptyFieldMetaModel, creator=self.creator, minio=cast(Minio, self.minio)) with self.assertRaises(ProgrammaticError) as cm: ExcelAlchemy(config) - self.assertEqual(str(cm.exception), 'Field definitions must be created with FieldMeta') + self.assertEqual( + str(cm.exception), + 'Field definitions must be created with FieldMeta or Annotated[..., 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_passing_non_config_object_raises_config_error(self): class NotImporterConfigModel(BaseModel): diff --git a/tests/support/storage.py b/tests/support/storage.py index 5cd5281..5a94e3e 100644 --- a/tests/support/storage.py +++ b/tests/support/storage.py @@ -3,9 +3,9 @@ from openpyxl import load_workbook +from excelalchemy import UrlStr from excelalchemy.core.storage_protocol import ExcelStorage from excelalchemy.core.table import WorksheetTable -from excelalchemy.types.identity import UrlStr class InMemoryExcelStorage(ExcelStorage): diff --git a/tests/unit/value_types/__init__.py b/tests/unit/codecs/__init__.py similarity index 100% rename from tests/unit/value_types/__init__.py rename to tests/unit/codecs/__init__.py diff --git a/tests/unit/value_types/test_boolean_value_type.py b/tests/unit/codecs/test_boolean_codec.py similarity index 100% rename from tests/unit/value_types/test_boolean_value_type.py rename to tests/unit/codecs/test_boolean_codec.py diff --git a/tests/unit/value_types/test_date_value_type.py b/tests/unit/codecs/test_date_codec.py similarity index 100% rename from tests/unit/value_types/test_date_value_type.py rename to tests/unit/codecs/test_date_codec.py diff --git a/tests/unit/value_types/test_date_range_value_type.py b/tests/unit/codecs/test_date_range_codec.py similarity index 100% rename from tests/unit/value_types/test_date_range_value_type.py rename to tests/unit/codecs/test_date_range_codec.py diff --git a/tests/unit/value_types/test_email_value_type.py b/tests/unit/codecs/test_email_codec.py similarity index 100% rename from tests/unit/value_types/test_email_value_type.py rename to tests/unit/codecs/test_email_codec.py diff --git a/tests/unit/value_types/test_money_value_type.py b/tests/unit/codecs/test_money_codec.py similarity index 100% rename from tests/unit/value_types/test_money_value_type.py rename to tests/unit/codecs/test_money_codec.py diff --git a/tests/unit/value_types/test_multi_checkbox_value_type.py b/tests/unit/codecs/test_multi_checkbox_codec.py similarity index 100% rename from tests/unit/value_types/test_multi_checkbox_value_type.py rename to tests/unit/codecs/test_multi_checkbox_codec.py diff --git a/tests/unit/value_types/test_multi_organization_value_type.py b/tests/unit/codecs/test_multi_organization_codec.py similarity index 100% rename from tests/unit/value_types/test_multi_organization_value_type.py rename to tests/unit/codecs/test_multi_organization_codec.py diff --git a/tests/unit/value_types/test_multi_staff_value_type.py b/tests/unit/codecs/test_multi_staff_codec.py similarity index 100% rename from tests/unit/value_types/test_multi_staff_value_type.py rename to tests/unit/codecs/test_multi_staff_codec.py diff --git a/tests/unit/value_types/test_number_value_type.py b/tests/unit/codecs/test_number_codec.py similarity index 100% rename from tests/unit/value_types/test_number_value_type.py rename to tests/unit/codecs/test_number_codec.py diff --git a/tests/unit/value_types/test_number_range_value_type.py b/tests/unit/codecs/test_number_range_codec.py similarity index 100% rename from tests/unit/value_types/test_number_range_value_type.py rename to tests/unit/codecs/test_number_range_codec.py diff --git a/tests/unit/value_types/test_phone_number_value_type.py b/tests/unit/codecs/test_phone_number_codec.py similarity index 100% rename from tests/unit/value_types/test_phone_number_value_type.py rename to tests/unit/codecs/test_phone_number_codec.py diff --git a/tests/unit/value_types/test_radio_value_type.py b/tests/unit/codecs/test_radio_codec.py similarity index 100% rename from tests/unit/value_types/test_radio_value_type.py rename to tests/unit/codecs/test_radio_codec.py diff --git a/tests/unit/value_types/test_single_organization_value_type.py b/tests/unit/codecs/test_single_organization_codec.py similarity index 100% rename from tests/unit/value_types/test_single_organization_value_type.py rename to tests/unit/codecs/test_single_organization_codec.py diff --git a/tests/unit/value_types/test_single_staff_value_type.py b/tests/unit/codecs/test_single_staff_codec.py similarity index 100% rename from tests/unit/value_types/test_single_staff_value_type.py rename to tests/unit/codecs/test_single_staff_codec.py diff --git a/tests/unit/value_types/test_url_value_type.py b/tests/unit/codecs/test_url_codec.py similarity index 100% rename from tests/unit/value_types/test_url_value_type.py rename to tests/unit/codecs/test_url_codec.py diff --git a/tests/unit/test_converters_and_schema_extraction.py b/tests/unit/test_converters_and_schema_extraction.py index 0131315..72db7b3 100644 --- a/tests/unit/test_converters_and_schema_extraction.py +++ b/tests/unit/test_converters_and_schema_extraction.py @@ -16,6 +16,16 @@ def test_download_template_returns_excel_payload(self): template = alchemy.download_template() assert template is not None and len(template) > 100 + 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) 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 index 9ecc43e..5131479 100644 --- a/tests/unit/test_excel_exceptions.py +++ b/tests/unit/test_excel_exceptions.py @@ -1,5 +1,4 @@ -from excelalchemy import ExcelCellError, Label -from excelalchemy.exc import ExcelRowError +from excelalchemy import ExcelCellError, ExcelRowError, Label from tests.support import BaseTestCase diff --git a/tests/unit/test_field_metadata.py b/tests/unit/test_field_metadata.py index a4a04dc..5f9d080 100644 --- a/tests/unit/test_field_metadata.py +++ b/tests/unit/test_field_metadata.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from typing import Annotated + +from pydantic import BaseModel, Field from excelalchemy import ( ConfigError, @@ -6,6 +8,8 @@ Date, DateFormat, Email, + EmailCodec, + ExcelMeta, FieldMeta, Number, Option, @@ -316,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_i18n_messages.py b/tests/unit/test_i18n_messages.py index c2ad04b..c5b16a0 100644 --- a/tests/unit/test_i18n_messages.py +++ b/tests/unit/test_i18n_messages.py @@ -10,8 +10,8 @@ message, use_display_locale, ) -from excelalchemy.types.field import extract_declared_field_metadata -from excelalchemy.types.result import ValidateRowResult +from excelalchemy.metadata import extract_declared_field_metadata +from excelalchemy.results import ValidateRowResult class TestI18nMessages: From b6ec4bddd415298366e4528af87e224169e0077b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 14:48:16 +0800 Subject: [PATCH 19/27] refactor(typing): modern typing style --- docs/architecture.md | 8 +- pyproject.toml | 72 +++++++--- src/excelalchemy/__init__.py | 20 +-- src/excelalchemy/_internal/__init__.py | 2 - src/excelalchemy/_primitives/__init__.py | 1 + .../{_internal => _primitives}/constants.py | 10 +- .../{_internal => _primitives}/deprecation.py | 0 .../header_models.py | 4 +- .../{_internal => _primitives}/identity.py | 0 src/excelalchemy/_primitives/payloads.py | 17 +++ src/excelalchemy/artifacts.py | 7 +- src/excelalchemy/codecs/base.py | 8 +- src/excelalchemy/codecs/date.py | 2 +- src/excelalchemy/codecs/date_range.py | 123 ++++++++++-------- src/excelalchemy/codecs/email.py | 6 +- src/excelalchemy/codecs/multi_checkbox.py | 25 ++-- src/excelalchemy/codecs/number.py | 14 +- src/excelalchemy/codecs/number_range.py | 75 ++++++++--- src/excelalchemy/codecs/organization.py | 31 +++-- src/excelalchemy/codecs/radio.py | 4 +- src/excelalchemy/codecs/staff.py | 34 ++--- src/excelalchemy/codecs/string.py | 11 +- src/excelalchemy/config.py | 74 ++++++----- src/excelalchemy/const.py | 2 +- src/excelalchemy/core/abstract.py | 21 +-- src/excelalchemy/core/alchemy.py | 75 ++++++----- src/excelalchemy/core/executor.py | 30 ++--- src/excelalchemy/core/headers.py | 37 ++++-- src/excelalchemy/core/rendering.py | 12 +- src/excelalchemy/core/rows.py | 49 +++---- src/excelalchemy/core/schema.py | 23 ++-- src/excelalchemy/core/storage.py | 11 +- src/excelalchemy/core/storage_minio.py | 19 ++- src/excelalchemy/core/storage_protocol.py | 2 +- src/excelalchemy/core/table.py | 78 +++++++---- src/excelalchemy/core/writer.py | 43 +++--- src/excelalchemy/exc.py | 2 +- src/excelalchemy/exceptions.py | 4 +- src/excelalchemy/header_models.py | 4 +- src/excelalchemy/helper/pydantic.py | 22 ++-- src/excelalchemy/identity.py | 4 +- src/excelalchemy/metadata.py | 40 +++--- src/excelalchemy/results.py | 22 ++-- src/excelalchemy/types/__init__.py | 6 +- src/excelalchemy/types/abstract.py | 2 +- src/excelalchemy/types/alchemy.py | 2 +- src/excelalchemy/types/field.py | 2 +- src/excelalchemy/types/header.py | 4 +- src/excelalchemy/types/identity.py | 4 +- src/excelalchemy/types/result.py | 2 +- src/excelalchemy/types/value/__init__.py | 2 +- src/excelalchemy/types/value/boolean.py | 2 +- src/excelalchemy/types/value/date.py | 2 +- src/excelalchemy/types/value/date_range.py | 2 +- src/excelalchemy/types/value/email.py | 2 +- src/excelalchemy/types/value/money.py | 2 +- .../types/value/multi_checkbox.py | 2 +- src/excelalchemy/types/value/number.py | 2 +- src/excelalchemy/types/value/number_range.py | 2 +- src/excelalchemy/types/value/organization.py | 2 +- src/excelalchemy/types/value/phone_number.py | 2 +- src/excelalchemy/types/value/radio.py | 2 +- src/excelalchemy/types/value/staff.py | 2 +- src/excelalchemy/types/value/string.py | 2 +- src/excelalchemy/types/value/tree.py | 2 +- src/excelalchemy/types/value/url.py | 2 +- src/excelalchemy/util/convertor.py | 26 ++-- src/excelalchemy/util/file.py | 21 +-- .../test_excelalchemy_workflows.py | 4 +- tests/support/__init__.py | 6 +- tests/support/mock_minio.py | 18 +-- tests/support/registry.py | 4 +- tests/support/workbook.py | 4 +- 73 files changed, 690 insertions(+), 495 deletions(-) delete mode 100644 src/excelalchemy/_internal/__init__.py create mode 100644 src/excelalchemy/_primitives/__init__.py rename src/excelalchemy/{_internal => _primitives}/constants.py (92%) rename src/excelalchemy/{_internal => _primitives}/deprecation.py (100%) rename src/excelalchemy/{_internal => _primitives}/header_models.py (85%) rename src/excelalchemy/{_internal => _primitives}/identity.py (100%) create mode 100644 src/excelalchemy/_primitives/payloads.py diff --git a/docs/architecture.md b/docs/architecture.md index 09eda8a..011e8d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,11 +149,11 @@ Use `locale='zh-CN' | 'en'` to control workbook-facing display text without chan - `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/_internal/identity.py`: internal typed string and index wrappers used across the core layer -- `src/excelalchemy/_internal/constants.py`: internal constant and enum definitions +- `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/_internal/header_models.py`: internal workbook header model objects -- `src/excelalchemy/_internal/deprecation.py`: internal deprecation helpers used by compatibility shims +- `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 diff --git a/pyproject.toml b/pyproject.toml index d704375..c24abca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,23 +67,54 @@ exclude = [ ] enableTypeIgnoreComments = false reportAbstractUsage = false -reportArgumentType = false -reportAssignmentType = false reportAttributeAccessIssue = false reportCallIssue = false -reportDeprecated = false -reportGeneralTypeIssues = false -reportImportCycles = false -reportMissingTypeArgument = false -reportMissingTypeStubs = false reportPrivateImportUsage = false reportRedeclaration = false -reportUnknownArgumentType = false -reportUnknownMemberType = false -reportUnknownParameterType = false -reportUnknownVariableType = false -reportUnusedFunction = false -reportUnusedImport = 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.ruff] @@ -93,16 +124,17 @@ src = ['src', 'tests'] extend-exclude = ['files'] [tool.ruff.lint] -select = ['E', 'F', 'I'] -ignore = ['E501'] +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/exc.py' = ['E402'] -'src/excelalchemy/header_models.py' = ['E402'] -'src/excelalchemy/identity.py' = ['E402'] -'src/excelalchemy/types/*.py' = ['E402'] -'src/excelalchemy/types/**/*.py' = ['E402'] +'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' diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index 531bf06..8f9751e 100644 --- a/src/excelalchemy/__init__.py +++ b/src/excelalchemy/__init__.py @@ -1,9 +1,9 @@ """A Python Library for Reading and Writing Excel Files""" __version__ = '2.0.0rc1' -from excelalchemy._internal.constants import CharacterSet, DataRangeOption, DateFormat, Option -from excelalchemy._internal.deprecation import ExcelAlchemyDeprecationWarning -from excelalchemy._internal.identity import ( +from excelalchemy._primitives.constants import CharacterSet, DataRangeOption, DateFormat, Option +from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning +from excelalchemy._primitives.identity import ( Base64Str, ColumnIndex, DataUrlStr, @@ -52,26 +52,29 @@ from excelalchemy.util.file import flatten __all__ = [ + 'Base64Str', 'Boolean', 'BooleanCodec', 'ColumnIndex', 'CompositeExcelFieldCodec', + 'ConfigError', + 'DataRangeOption', + 'DataUrlStr', 'Date', 'DateCodec', 'DateFormat', 'DateRange', 'DateRangeCodec', - 'DataRangeOption', 'Email', 'EmailCodec', - 'ExcelStorage', 'ExcelAlchemy', + 'ExcelAlchemyDeprecationWarning', 'ExcelArtifact', 'ExcelCellError', - 'ExcelAlchemyDeprecationWarning', 'ExcelFieldCodec', 'ExcelMeta', 'ExcelRowError', + 'ExcelStorage', 'ExporterConfig', 'FieldMeta', 'ImportMode', @@ -96,12 +99,9 @@ 'Option', 'OptionId', 'PatchFieldMeta', - 'DataUrlStr', - 'Base64Str', 'PhoneNumber', 'PhoneNumberCodec', 'ProgrammaticError', - 'ConfigError', 'Radio', 'RowIndex', 'SingleChoiceCodec', @@ -116,8 +116,8 @@ 'UniqueKey', 'UniqueLabel', 'Url', - 'UrlStr', 'UrlCodec', + 'UrlStr', 'ValidateHeaderResult', 'ValidateResult', 'ValidateRowResult', diff --git a/src/excelalchemy/_internal/__init__.py b/src/excelalchemy/_internal/__init__.py deleted file mode 100644 index b48a57f..0000000 --- a/src/excelalchemy/_internal/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Internal implementation helpers for ExcelAlchemy.""" - 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/_internal/constants.py b/src/excelalchemy/_primitives/constants.py similarity index 92% rename from src/excelalchemy/_internal/constants.py rename to src/excelalchemy/_primitives/constants.py index 89fee28..4e6659b 100644 --- a/src/excelalchemy/_internal/constants.py +++ b/src/excelalchemy/_primitives/constants.py @@ -1,8 +1,8 @@ from dataclasses import dataclass -from enum import Enum +from enum import StrEnum from typing import Any -from excelalchemy._internal.identity import Key, Label, OptionId +from excelalchemy._primitives.identity import Key, Label, OptionId from excelalchemy.i18n.messages import MessageKey from excelalchemy.i18n.messages import display_message as dmsg @@ -45,7 +45,7 @@ type IntStr = int | str -class CharacterSet(str, Enum): +class CharacterSet(StrEnum): CHINESE = 'CHINESE' NUMBER = 'NUMBER' LOWERCASE_LETTERS = 'LOWERCASE_LETTERS' @@ -53,14 +53,14 @@ class CharacterSet(str, Enum): SPECIAL_SYMBOLS = 'SPECIAL_SYMBOLS' -class DateFormat(str, Enum): +class DateFormat(StrEnum): YEAR = 'YEAR' MONTH = 'MONTH' DAY = 'DAY' MINUTE = 'MINUTE' -class DataRangeOption(str, Enum): +class DataRangeOption(StrEnum): NONE = 'NONE' PRE = 'PRE' NEXT = 'NEXT' diff --git a/src/excelalchemy/_internal/deprecation.py b/src/excelalchemy/_primitives/deprecation.py similarity index 100% rename from src/excelalchemy/_internal/deprecation.py rename to src/excelalchemy/_primitives/deprecation.py diff --git a/src/excelalchemy/_internal/header_models.py b/src/excelalchemy/_primitives/header_models.py similarity index 85% rename from src/excelalchemy/_internal/header_models.py rename to src/excelalchemy/_primitives/header_models.py index 513b10f..8fac9ac 100644 --- a/src/excelalchemy/_internal/header_models.py +++ b/src/excelalchemy/_primitives/header_models.py @@ -3,8 +3,8 @@ from pydantic import BaseModel from pydantic.fields import Field -from excelalchemy._internal.constants import UNIQUE_HEADER_CONNECTOR -from excelalchemy._internal.identity import Label, UniqueLabel +from excelalchemy._primitives.constants import UNIQUE_HEADER_CONNECTOR +from excelalchemy._primitives.identity import Label, UniqueLabel class ExcelHeader(BaseModel): diff --git a/src/excelalchemy/_internal/identity.py b/src/excelalchemy/_primitives/identity.py similarity index 100% rename from src/excelalchemy/_internal/identity.py rename to src/excelalchemy/_primitives/identity.py 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 index a4d8b9b..a664953 100644 --- a/src/excelalchemy/artifacts.py +++ b/src/excelalchemy/artifacts.py @@ -5,7 +5,7 @@ import base64 from dataclasses import dataclass, replace -from excelalchemy._internal.identity import DataUrlStr +from excelalchemy._primitives.identity import DataUrlStr from excelalchemy.util.file import EXCEL_MEDIA_TYPE, add_excel_prefix, remove_excel_prefix @@ -18,14 +18,14 @@ class ExcelArtifact: media_type: str = EXCEL_MEDIA_TYPE @classmethod - def from_data_url(cls, data_url: str, *, filename: str, media_type: str = EXCEL_MEDIA_TYPE) -> 'ExcelArtifact': + 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': + def with_filename(self, filename: str) -> ExcelArtifact: return replace(self, filename=filename) def as_bytes(self) -> bytes: @@ -36,4 +36,3 @@ def as_base64(self) -> str: def as_data_url(self) -> DataUrlStr: return DataUrlStr(add_excel_prefix(self.as_base64())) - diff --git a/src/excelalchemy/codecs/base.py b/src/excelalchemy/codecs/base.py index a0fcd13..1a3dd84 100644 --- a/src/excelalchemy/codecs/base.py +++ b/src/excelalchemy/codecs/base.py @@ -1,15 +1,15 @@ +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._internal.identity import Key +from excelalchemy._primitives.identity import Key if TYPE_CHECKING: from excelalchemy.metadata import FieldMetaInfo -else: - FieldMetaInfo = Any class ExcelFieldCodec(ABC): @@ -68,7 +68,7 @@ def __get_pydantic_core_schema__( return core_schema.any_schema() -class CompositeExcelFieldCodec(ExcelFieldCodec, dict): +class CompositeExcelFieldCodec(ExcelFieldCodec, dict[str, object]): """Excel codec for fields that expand into multiple worksheet columns.""" @classmethod diff --git a/src/excelalchemy/codecs/date.py b/src/excelalchemy/codecs/date.py index 541f40e..9cd0705 100644 --- a/src/excelalchemy/codecs/date.py +++ b/src/excelalchemy/codecs/date.py @@ -5,7 +5,7 @@ import pendulum from pendulum import DateTime -from excelalchemy._internal.constants import DATE_FORMAT_TO_HINT_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption +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 diff --git a/src/excelalchemy/codecs/date_range.py b/src/excelalchemy/codecs/date_range.py index eaf83fe..c205b1d 100644 --- a/src/excelalchemy/codecs/date_range.py +++ b/src/excelalchemy/codecs/date_range.py @@ -1,13 +1,14 @@ import logging +from collections.abc import Mapping from datetime import datetime -from typing import Any +from typing import Any, cast import pendulum from pendulum import DateTime from pydantic import BaseModel -from excelalchemy._internal.constants import DATE_FORMAT_TO_PYTHON_MAPPING, MILLISECOND_TO_SECOND, DataRangeOption -from excelalchemy._internal.identity import Key +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 @@ -61,52 +62,40 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: ) @classmethod - def parse_input(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') - 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(): + 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 - 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 _: + + 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: dict[str, DateTime | None] | Any, + value: object, field_meta: FieldMetaInfo, ) -> 'DateRange': try: parsed = DateRange.model_validate(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 + 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 @@ -132,7 +121,7 @@ def normalize_import_value( return parsed @classmethod - def format_display_value(cls, value: dict[str, str] | str | Any | None, field_meta: FieldMetaInfo) -> str: + 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 @@ -144,25 +133,57 @@ def format_display_value(cls, value: dict[str, str] | str | Any | None, field_me if isinstance(value, datetime): return value.strftime(py_date_format) - if isinstance(value, dict): - return cls.__deserialize__dict(py_date_format, value) + 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 value if value is not None else '' + return str(value) @classmethod - def __deserialize__dict(cls, py_date_format: str, value: dict[str, Any]) -> str: - start, end = value['start'], value['end'] + 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) - - 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 + 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 index ea3aabc..ff642a3 100644 --- a/src/excelalchemy/codecs/email.py +++ b/src/excelalchemy/codecs/email.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import ClassVar from pydantic import EmailStr, TypeAdapter @@ -9,10 +9,10 @@ class Email(String): - _validator = TypeAdapter(EmailStr) + _validator: ClassVar[TypeAdapter[EmailStr]] = TypeAdapter(EmailStr) @classmethod - def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> str: + def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> str: # Try to parse the value as a string try: parsed = str(value) diff --git a/src/excelalchemy/codecs/multi_checkbox.py b/src/excelalchemy/codecs/multi_checkbox.py index e82097b..c9a2b71 100644 --- a/src/excelalchemy/codecs/multi_checkbox.py +++ b/src/excelalchemy/codecs/multi_checkbox.py @@ -1,8 +1,8 @@ import logging -from typing import Any, cast +from typing import cast -from excelalchemy._internal.constants import MULTI_CHECKBOX_SEPARATOR -from excelalchemy._internal.identity import OptionId +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 @@ -26,10 +26,11 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: ) @classmethod - def parse_input(cls, value: str | Any, field_meta: FieldMetaInfo) -> list[str] | str: + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> list[str] | object: # 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)] + items = cast(list[object], value) + return [str(item).strip() for item in items] # If the value is a string, split it into a list using MULTI_CHECKBOX_SEPARATOR and strip whitespace if isinstance(value, str): @@ -40,21 +41,24 @@ def parse_input(cls, value: str | Any, field_meta: FieldMetaInfo) -> list[str] | return value @classmethod - def normalize_import_value(cls, value: list[str] | Any, field_meta: FieldMetaInfo) -> list[str]: # OptionId + 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 value + return parsed - if len(value) != len(set(value)): + if len(parsed) != len(set(parsed)): raise ValueError(msg(MessageKey.OPTIONS_CONTAIN_DUPLICATES)) - result, errors = field_meta.exchange_names_to_option_ids_with_errors(value) + result, errors = field_meta.exchange_names_to_option_ids_with_errors(parsed) if errors: raise ValueError(*errors) @@ -69,7 +73,8 @@ def format_display_value(cls, value: str | list[OptionId] | None, field_meta: Fi case str(): return value case list(): - option_names = field_meta.exchange_option_ids_to_names(value) + 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) diff --git a/src/excelalchemy/codecs/number.py b/src/excelalchemy/codecs/number.py index 0c7505e..ad3e197 100644 --- a/src/excelalchemy/codecs/number.py +++ b/src/excelalchemy/codecs/number.py @@ -11,7 +11,8 @@ def canonicalize_decimal(value: Decimal, digits_limit: int | None) -> Decimal: """将 Decimal 转换为指定精度的 Decimal""" - if digits_limit is not None and abs(value.as_tuple().exponent) != digits_limit: # type: ignore[arg-type] + 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}'), @@ -30,9 +31,6 @@ def transform_decimal(value: Decimal | int | float | None) -> float | int | None if isinstance(value, (int, float)): return value - if not isinstance(value, Decimal): - raise TypeError(f'Expected Decimal, got {type(value)}') - if value.as_tuple().exponent == 0: return int(value) else: @@ -58,11 +56,13 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: 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(value)) # type: ignore[arg-type] + 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) if value is not None else '' + return str(value) @classmethod def format_display_value(cls, value: str | None | Any, field_meta: FieldMetaInfo) -> str: @@ -134,7 +134,7 @@ def normalize_import_value(cls, value: Decimal | Any, field_meta: FieldMetaInfo) if parsed is None: raise ValueError(msg(MessageKey.INVALID_NUMBER_ENTER_NUMBER)) # 初始化一个错误信息列表。 - errors: list[str] = cls.__check_range__(value, field_meta) + errors: list[str] = cls.__check_range__(parsed, field_meta) if errors: raise ValueError(*errors) parsed = canonicalize_decimal(parsed, field_meta.fraction_digits) diff --git a/src/excelalchemy/codecs/number_range.py b/src/excelalchemy/codecs/number_range.py index ad6090f..8ae855d 100644 --- a/src/excelalchemy/codecs/number_range.py +++ b/src/excelalchemy/codecs/number_range.py @@ -1,8 +1,9 @@ import logging +from collections.abc import Mapping from decimal import Decimal -from typing import Any +from typing import cast -from excelalchemy._internal.identity import Key +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 @@ -35,7 +36,7 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: return Number.build_comment(field_meta) @classmethod - def parse_input(cls, value: dict[str, str] | str | Any, field_meta: FieldMetaInfo) -> Any: + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: # Strip leading/trailing whitespace from a string value if isinstance(value, str): value = value.strip() @@ -45,27 +46,38 @@ def parse_input(cls, value: dict[str, str] | str | Any, field_meta: FieldMetaInf return value # Attempt to create a new NumberRange object from a dictionary - try: - start, end = Decimal(value['start']), Decimal(value['end']) # type: ignore[index] - 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) + 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 the original value if parsing fails return value @classmethod - def format_display_value(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: + def format_display_value(cls, value: object | 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))) + 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: Any, field_meta: FieldMetaInfo) -> 'NumberRange': + 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: @@ -82,21 +94,48 @@ def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> 'Numbe return parsed @staticmethod - def __maybe_number_range__(value: dict[str, Decimal] | Any, field_meta: FieldMetaInfo) -> 'NumberRange': + def __maybe_number_range__(value: object, 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) + start = NumberRange._canonicalize_boundary(value.start, field_meta) + end = NumberRange._canonicalize_boundary(value.end, field_meta) return NumberRange(start, end) - if isinstance(value, dict): + mapping = NumberRange._coerce_mapping(value) + if mapping is not None: 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']) + 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 index 651956c..059ccca 100644 --- a/src/excelalchemy/codecs/organization.py +++ b/src/excelalchemy/codecs/organization.py @@ -1,7 +1,8 @@ import logging -from typing import Any +from typing import cast -from excelalchemy._internal.constants import MULTI_CHECKBOX_SEPARATOR +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 @@ -19,19 +20,19 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: 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: - if isinstance(value, str): - return value.strip() - return value + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> str: + return super().parse_input(value, field_meta) @classmethod - def format_display_value(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + 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[value.strip()].name + return field_meta.options_id_map[OptionId(value.strip())].name except KeyError: logging.warning('无法找到组织 %s 的选项, 返回原值', value) - return value if value is not None else '' + return value class MultiOrganization(MultiCheckbox): @@ -47,11 +48,11 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: ) @classmethod - def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> Any: + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: return super().parse_input(value, field_meta) @classmethod - def format_display_value(cls, value: str | list[str] | None | Any, field_meta: FieldMetaInfo) -> str | Any: + def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) -> str: if value is None or value == '': return '' @@ -59,14 +60,16 @@ def format_display_value(cls, value: str | list[str] | None | Any, field_meta: F return value if isinstance(value, list): - option_names = field_meta.exchange_option_ids_to_names(value) + 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 反序列化失败', cls.__name__) - return value + return str(value) @classmethod - def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> list[str]: + def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> list[str]: return super().normalize_import_value(value, field_meta) diff --git a/src/excelalchemy/codecs/radio.py b/src/excelalchemy/codecs/radio.py index 6858552..6ad6519 100644 --- a/src/excelalchemy/codecs/radio.py +++ b/src/excelalchemy/codecs/radio.py @@ -1,8 +1,8 @@ import logging from typing import Any -from excelalchemy._internal.constants import MULTI_CHECKBOX_SEPARATOR -from excelalchemy._internal.identity import OptionId +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 diff --git a/src/excelalchemy/codecs/staff.py b/src/excelalchemy/codecs/staff.py index 49a176a..9455f71 100644 --- a/src/excelalchemy/codecs/staff.py +++ b/src/excelalchemy/codecs/staff.py @@ -1,8 +1,8 @@ import logging -from typing import Any +from typing import cast -from excelalchemy._internal.constants import MULTI_CHECKBOX_SEPARATOR -from excelalchemy._internal.identity import OptionId +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 @@ -21,20 +21,20 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: return f'{dmsg(MessageKey.COMMENT_REQUIRED, value=dmsg(value_key))} \n{dmsg(MessageKey.COMMENT_HINT, value=extra_hint)}' @classmethod - def parse_input(cls, value: Any, field_meta: FieldMetaInfo) -> Any: - if isinstance(value, str): - return value.strip() - return value + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> str: + return super().parse_input(value, field_meta) @classmethod - def format_display_value(cls, value: Any | None, field_meta: FieldMetaInfo) -> str: + 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[value.strip()].name + return field_meta.options_id_map[OptionId(value.strip())].name except KeyError: logging.warning('类型【%s】无法为【%s】找到【%s】的选项, 返回原值', cls.__name__, field_meta.label, value) - return value if value is not None else '' + return value class MultiStaff(MultiCheckbox): @@ -50,27 +50,29 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: ) @classmethod - def parse_input(cls, value: str | list[str] | Any, field_meta: FieldMetaInfo) -> Any: + def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: return super().parse_input(value, field_meta) @classmethod - def normalize_import_value(cls, value: Any, field_meta: FieldMetaInfo) -> list[str]: + 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: str | list[OptionId] | Any, field_meta: FieldMetaInfo) -> Any: + def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: if isinstance(value, str): return value if isinstance(value, list): - if len(value) != len(set(value)): + 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(value) + 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 value + return str(value) SingleStaffCodec = SingleStaff diff --git a/src/excelalchemy/codecs/string.py b/src/excelalchemy/codecs/string.py index d73bdf1..ced9e3d 100644 --- a/src/excelalchemy/codecs/string.py +++ b/src/excelalchemy/codecs/string.py @@ -1,8 +1,7 @@ from typing import Any -from excelalchemy._internal.constants import CharacterSet +from excelalchemy._primitives.constants import CharacterSet 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 @@ -110,9 +109,8 @@ 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(msg(MessageKey.MAX_LENGTH_CHARACTERS, max_length=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)) @@ -124,9 +122,6 @@ def normalize_import_value(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(msg(MessageKey.CHARACTER_SET_NOT_CONFIGURED)) - for single_character in value: if not any(_CHARACTER_SET_TO_VALIDATOR[cs](single_character) for cs in field_meta.character_set): errors.append( diff --git a/src/excelalchemy/config.py b/src/excelalchemy/config.py index 9da7fb0..8775edb 100644 --- a/src/excelalchemy/config.py +++ b/src/excelalchemy/config.py @@ -2,12 +2,14 @@ from __future__ import annotations -from dataclasses import dataclass, field -from enum import Enum -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal +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 @@ -19,44 +21,44 @@ from minio import Minio -class ExcelMode(str, Enum): +class ExcelMode(StrEnum): """Excel 模式""" IMPORT = 'IMPORT' EXPORT = 'EXPORT' -class ImportMode(str, Enum): +class ImportMode(StrEnum): CREATE = 'CREATE' # 创建 UPDATE = 'UPDATE' # 更新 CREATE_OR_UPDATE = 'CREATE_OR_UPDATE' # 创建或更新 -@dataclass +@dataclass(slots=True) class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateModelT: BaseModel]: - create_importer_model: type[ImporterCreateModelT] | None = field(default=None) - update_importer_model: type[ImporterUpdateModelT] | None = field(default=None) + create_importer_model: type[ImporterCreateModelT] | None = None + update_importer_model: type[ImporterUpdateModelT] | None = 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) + data_converter: DataConverter | None = import_data_converter + creator: DmlCallback[ContextT] | None = None + updater: DmlCallback[ContextT] | None = 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) + context: ImportContext[ContextT] = None + is_data_exist: ExistenceCheckCallback[ContextT] | None = None + exec_formatter: Callable[[Exception], str] = str - import_mode: ImportMode = field(default=ImportMode.CREATE) + import_mode: ImportMode = ImportMode.CREATE - storage: ExcelStorage | None = field(default=None) - minio: Minio | None = field(default=None) - bucket_name: str = field(default='excel') - url_expires: int = field(default=3600) - locale: str = field(default='zh-CN') + storage: ExcelStorage | None = None + minio: Minio | None = None + bucket_name: str = 'excel' + url_expires: int = 3600 + locale: str = 'zh-CN' - sheet_name: Literal['Sheet1'] = field(default='Sheet1') + sheet_name: str = 'Sheet1' - def validate_model(self): + 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)) @@ -71,21 +73,21 @@ def validate_model(self): return self # 创建模式验证 - def _validate_create(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): + 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): + 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)) @@ -99,28 +101,28 @@ def _validate_create_or_update(self): 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): + def __post_init__(self) -> None: self.validate_model() -@dataclass +@dataclass(slots=True) class ExporterConfig[ExporterModelT: BaseModel]: 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) + data_converter: DataConverter | None = export_data_converter - storage: ExcelStorage | None = field(default=None) - minio: Minio | None = field(default=None) - bucket_name: str = field(default='excel') - url_expires: int = field(default=3600) - locale: str = field(default='zh-CN') + storage: ExcelStorage | None = None + minio: Minio | None = None + bucket_name: str = 'excel' + url_expires: int = 3600 + locale: str = 'zh-CN' - sheet_name: Literal['Sheet1'] = field(default='Sheet1') + sheet_name: str = 'Sheet1' - def validate_model(self): + 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): + def __post_init__(self) -> None: self.validate_model() diff --git a/src/excelalchemy/const.py b/src/excelalchemy/const.py index e9d407f..71c69bb 100644 --- a/src/excelalchemy/const.py +++ b/src/excelalchemy/const.py @@ -1,4 +1,4 @@ """Compatibility re-exports for lower-level constant definitions.""" -from excelalchemy._internal.constants import * # noqa: F403 +from excelalchemy._primitives.constants import * # noqa: F403 diff --git a/src/excelalchemy/core/abstract.py b/src/excelalchemy/core/abstract.py index 3560af0..76f5bdd 100644 --- a/src/excelalchemy/core/abstract.py +++ b/src/excelalchemy/core/abstract.py @@ -1,9 +1,10 @@ from abc import ABC, abstractmethod -from typing import Any +from collections.abc import Sequence from pydantic import BaseModel -from excelalchemy._internal.identity import Base64Str, Key, UrlStr +from excelalchemy._primitives.identity import DataUrlStr, UrlStr +from excelalchemy._primitives.payloads import ExportRowPayload from excelalchemy.artifacts import ExcelArtifact from excelalchemy.results import ImportResult @@ -17,13 +18,13 @@ class ABCExcelAlchemy[ ExporterModelT: BaseModel, ](ABC): @abstractmethod - def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> str: - """下载导入模版, Excel 字段顺序与定义的导出模型一致""" + def download_template(self, sample_data: list[ExportRowPayload] | None = None) -> DataUrlStr: + """下载导入模版,返回 Data URL,字段顺序与定义的导出模型一致。""" @abstractmethod def download_template_artifact( self, - sample_data: list[dict[str, Any]] | None = None, + sample_data: list[ExportRowPayload] | None = None, *, filename: str = 'template.xlsx', ) -> ExcelArtifact: @@ -34,21 +35,21 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im """导入数据""" @abstractmethod - def export(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> Base64Str: - """导出数据,返回 base64 编码的 excel 文件, 字段顺序与定义的导出模型一致""" + def export(self, data: list[ExportRowPayload], keys: Sequence[str] | None = None) -> DataUrlStr: + """导出数据,返回 Data URL 形式的 Excel 文件,字段顺序与定义的导出模型一致。""" @abstractmethod def export_artifact( self, - data: list[dict[str, Any]], - keys: list[Key] | None = None, + data: list[ExportRowPayload], + keys: Sequence[str] | None = None, *, filename: str = 'export.xlsx', ) -> ExcelArtifact: """导出数据,返回结构化 Excel 产物。""" @abstractmethod - def export_upload(self, output_name: str, data: list[dict[str, Any]], keys: list[Key] | None = None) -> UrlStr: + def export_upload(self, output_name: str, data: list[ExportRowPayload], keys: Sequence[str] | None = None) -> UrlStr: """导出数据, 自动将文件上传到配置的存储后端,字段顺序与定义的导出模型一致""" @abstractmethod diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index d5033be..873a8e9 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -1,15 +1,17 @@ import logging +from collections.abc import Iterable, Sequence from functools import cached_property -from typing import Any, Callable, Iterable, cast +from typing import cast from pydantic import BaseModel -from excelalchemy._internal.constants import ( +from excelalchemy._primitives.constants import ( REASON_COLUMN_KEY, RESULT_COLUMN_KEY, ) -from excelalchemy._internal.header_models import ExcelHeader -from excelalchemy._internal.identity import Base64Str, Key, Label, RowIndex, UniqueKey, UniqueLabel, UrlStr +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 @@ -84,7 +86,7 @@ def __init__( self._layout: ExcelSchemaLayout self._issue_tracker: ImportIssueTracker | None = None self._row_aggregator: RowAggregator | None = None - self._executor: ImportExecutor[ContextT] | None = None + self._executor: ImportExecutor[ContextT, ImporterCreateModelT, ImporterUpdateModelT] | None = None self.__init_from_config__() @@ -115,8 +117,9 @@ def _reset_import_runtime_state(self) -> None: self.df = WorksheetTable() self.header_df = WorksheetTable() self.__state_df_has_been_loaded__ = False - self.__dict__.pop('input_excel_has_merged_header', None) - self.__dict__.pop('input_excel_headers', None) + 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 @@ -143,7 +146,7 @@ def __get_importer_model__(self) -> type[ImporterCreateModelT] | type[ImporterUp raise ConfigError(msg(MessageKey.NO_IMPORTER_OR_EXPORTER_MODEL_CONFIGURED)) return importer_model - def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> str: + 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)) @@ -158,7 +161,7 @@ def download_template(self, sample_data: list[dict[str, Any]] | None = None) -> def download_template_artifact( self, - sample_data: list[dict[str, Any]] | None = None, + sample_data: list[ExportRowPayload] | None = None, *, filename: str = 'template.xlsx', ) -> ExcelArtifact: @@ -183,7 +186,7 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im all_success, success_count, fail_count = True, 0, 0 for table_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())) + aggregate_data = self._aggregate_data(cast(FlatRowPayload, row.to_dict())) success = await self._executor.execute(cast(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) @@ -201,7 +204,7 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im fail_count=fail_count, ) - def export(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> Base64Str: + 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( @@ -213,14 +216,14 @@ def export(self, data: list[dict[str, Any]], keys: list[Key] | None = None) -> B def export_artifact( self, - data: list[dict[str, Any]], - keys: list[Key] | None = None, + 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[dict[str, Any]], keys: list[Key] | None = None) -> UrlStr: + 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: @@ -280,14 +283,14 @@ def get_output_parent_excel_headers(self, selected_keys: list[UniqueKey] | None 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[dict[str, Any]], keys: list[Key] | None = None) -> tuple[WorksheetTable, bool]: + 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 = keys or list( - filter(None, [cast(Key | None, field_meta.parent_key) for field_meta in self.ordered_field_meta]) - ) - model_keys = cast(list[Key], get_model_field_names(self.exporter_model)) + 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) @@ -306,7 +309,7 @@ def _validate_header(self, input_excel_name: str) -> ValidateHeaderResult: 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) -> str: + 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, @@ -314,7 +317,7 @@ def _render_import_result_excel(self) -> str: errors=self.cell_errors, ) - def _upload_file(self, output_name: str, content_with_prefix: str) -> UrlStr: + 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]: @@ -323,7 +326,7 @@ def _order_errors(self, errors: list[ExcelRowError | ExcelCellError]) -> Iterabl 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: list[Key] | None = None) -> list[UniqueKey]: + 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: @@ -341,16 +344,16 @@ def _read_dataframe(self, input_excel_name: str) -> WorksheetTable: def _generate_export_df( self, - records: list[dict[str, Any]] | None, + records: list[ExportRowPayload] | None, selected_keys: list[UniqueKey], - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = None, + data_converter: DataConverter | None = None, ) -> WorksheetTable: - rows = [] + rows: list[dict[UniqueLabel, object]] = [] records = records or [] for record in records: - row = {} + row: dict[UniqueLabel, object] = {} record = data_converter(record) if data_converter else record - for key, value in flatten(record).items(): # type: ignore[arg-type] + for key, value in flatten(record).items(): if key not in selected_keys: continue field_meta = self.unique_key_to_field_meta[UniqueKey(key)] @@ -361,18 +364,18 @@ def _generate_export_df( def _export_with_merged_header( self, - records: list[dict[str, Any]] | None, + records: list[ExportRowPayload] | None, selected_keys: list[UniqueKey], - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = None, + 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[dict[str, Any]] | None, + records: list[ExportRowPayload] | None, selected_keys: list[UniqueKey], - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None = None, + data_converter: DataConverter | None = None, ) -> WorksheetTable: return self._generate_export_df(records, selected_keys, data_converter) @@ -386,7 +389,7 @@ def _add_result_column(self): ) return self - def _aggregate_data(self, row_data: dict[UniqueLabel, Any]) -> dict[Key, Any]: + def _aggregate_data(self, row_data: FlatRowPayload) -> ModelRowPayload: assert self._row_aggregator is not None return self._row_aggregator.aggregate(row_data) @@ -423,15 +426,15 @@ 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) + 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) + return self._header_parser.extract_merged(self.header_df) - def __setattr__(self, key: str, value: Any): + 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): + 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 index 4b01694..e175138 100644 --- a/src/excelalchemy/core/executor.py +++ b/src/excelalchemy/core/executor.py @@ -1,10 +1,11 @@ """Import execution helpers for create, update, and upsert flows.""" -from typing import Any, Awaitable, Callable +from collections.abc import Callable from pydantic import BaseModel -from excelalchemy._internal.identity import Key, RowIndex +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 @@ -15,20 +16,20 @@ from .table import WorksheetTable -class ImportExecutor[ContextT]: +class ImportExecutor[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateModelT: BaseModel]: """Execute import-side DML while keeping validation and error mapping isolated.""" def __init__( self, - config: ImporterConfig[Any, Any, Any], + config: ImporterConfig[ContextT, ImporterCreateModelT, ImporterUpdateModelT], issue_tracker: ImportIssueTracker, - get_context: Callable[[], ContextT | None], + 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: dict[Key, Any], df: WorksheetTable) -> bool: + 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: @@ -39,7 +40,7 @@ async def execute(self, row_index: RowIndex, data: dict[Key, Any], df: Worksheet 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: dict[Key, Any], df: WorksheetTable) -> bool: + 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: @@ -54,7 +55,7 @@ async def _create(self, row_index: RowIndex, data: dict[Key, Any], df: Worksheet self.config.exec_formatter, ) - async def _update(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: + 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: @@ -69,7 +70,7 @@ async def _update(self, row_index: RowIndex, data: dict[Key, Any], df: Worksheet self.config.exec_formatter, ) - async def _create_or_update(self, row_index: RowIndex, data: dict[Key, Any], df: WorksheetTable) -> bool: + 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)) @@ -82,16 +83,16 @@ async def _create_or_update(self, row_index: RowIndex, data: dict[Key, Any], df: async def _invoke_dml( self, row_index: RowIndex, - data: dict[Key, Any], + data: ModelRowPayload, df: WorksheetTable, importer_model: type[BaseModel], - dml_func: Callable[[dict[str, Any], ContextT | None], Awaitable[Any]], - data_converter: Callable[[dict[str, Any]], dict[str, Any]] | None, + 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 not isinstance(importer_instance_or_errors, 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) @@ -99,8 +100,7 @@ async def _invoke_dml( self.issue_tracker.register_cell_errors(row_index, cell_errors, df) return False - importer_instance = importer_instance_or_errors - converted_data = importer_instance.model_dump(exclude_unset=True) + converted_data = importer_instance_or_errors.model_dump(exclude_unset=True) if data_converter is not None: converted_data = data_converter(converted_data) diff --git a/src/excelalchemy/core/headers.py b/src/excelalchemy/core/headers.py index 5666043..c70ad60 100644 --- a/src/excelalchemy/core/headers.py +++ b/src/excelalchemy/core/headers.py @@ -1,7 +1,10 @@ """Header parsing and validation helpers for import workbooks.""" -from excelalchemy._internal.header_models import ExcelHeader -from excelalchemy._internal.identity import Label, UniqueLabel +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 @@ -28,6 +31,14 @@ def extract(self, header_df: WorksheetTable) -> list[ExcelHeader]: 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()] @@ -52,7 +63,7 @@ def _extract_merged(self, header_df: WorksheetTable) -> list[ExcelHeader]: 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 = value, 1 + last_header, next_offset = str(value), 1 headers.append(current_header) return headers @@ -70,7 +81,7 @@ def apply_columns( raise ConfigError(msg(MessageKey.UNSUPPORTED_COLUMN_NAME, unique_label=header.unique_label)) columns.append(header.unique_label) - df.columns = columns # type: ignore[assignment] + df.columns = columns return df @@ -99,7 +110,7 @@ def validate( schema_label_set = set(schema_labels) input_label_set = set(input_labels) - unrecognized = self._ordered_difference(input_labels, schema_label_set) + unrecognized = [Label(label) for label in self._ordered_difference(input_labels, schema_label_set)] missing_primary: list[Label] = [] if import_mode == ImportMode.UPDATE: @@ -115,22 +126,22 @@ def validate( ) @staticmethod - def _ordered_difference(values: list[Label], allowed: set[Label]) -> list[Label]: + def _ordered_difference[T](values: Sequence[T], allowed: Container[T]) -> list[T]: seen: set[Label] = set() - result: list[Label] = [] + result: list[T] = [] for value in values: if value in allowed or value in seen: continue - seen.add(value) + seen.add(cast(Label, value)) result.append(value) return result @staticmethod - def _ordered_missing( - expected: list[Label], - actual: set[Label], + def _ordered_missing[T]( + expected: Sequence[T], + actual: Container[T], *, - excluded: set[Label] | None = None, - ) -> list[Label]: + 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 index 01ed1a3..86fbfb6 100644 --- a/src/excelalchemy/core/rendering.py +++ b/src/excelalchemy/core/rendering.py @@ -1,8 +1,6 @@ """High-level rendering helpers built on top of the low-level writer module.""" -from typing import cast - -from excelalchemy._internal.identity import Base64Str, ColumnIndex, RowIndex, UniqueLabel +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 @@ -14,11 +12,11 @@ class ExcelRenderer: def render_template( self, df: WorksheetTable, field_meta_mapping: dict[UniqueLabel, FieldMetaInfo], *, has_merged_header: bool - ) -> Base64Str: + ) -> DataUrlStr: """Render a template workbook with either a simple or merged header layout.""" if has_merged_header: - return cast(Base64Str, render_merged_header_excel(df, field_meta_mapping)) - return cast(Base64Str, render_simple_header_excel(df, field_meta_mapping)) + return render_merged_header_excel(df, field_meta_mapping) + return render_simple_header_excel(df, field_meta_mapping) def render_data( self, @@ -27,7 +25,7 @@ def render_data( *, has_merged_header: bool, errors: dict[RowIndex, dict[ColumnIndex, list[ExcelCellError]]] | None = None, - ) -> Base64Str: + ) -> DataUrlStr: """Render a data workbook and optionally annotate cell-level import errors.""" return render_data_excel( df, diff --git a/src/excelalchemy/core/rows.py b/src/excelalchemy/core/rows.py index 91ef1cd..e241641 100644 --- a/src/excelalchemy/core/rows.py +++ b/src/excelalchemy/core/rows.py @@ -1,9 +1,11 @@ """Row aggregation and import issue tracking helpers.""" from collections import defaultdict -from typing import Any, cast +from collections.abc import Iterator +from typing import cast -from excelalchemy._internal.identity import ColumnIndex, Key, RowIndex, UniqueLabel +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 @@ -23,13 +25,14 @@ def __init__(self, layout: ExcelSchemaLayout, import_mode: ImportMode): self.layout = layout self.import_mode = import_mode - def aggregate(self, row_data: dict[UniqueLabel, Any]) -> dict[Key, Any]: + 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: dict[UniqueLabel, Any]) -> dict[Key, Any]: - aggregated: dict[Key, Any] = {} - for unique_label, value in row_data.items(): + 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: @@ -42,16 +45,20 @@ def _aggregate(self, row_data: dict[UniqueLabel, Any]) -> dict[Key, Any]: continue if field_meta.parent_key == field_meta.key: - aggregated[field_meta.key] = value + aggregated[str(field_meta.key)] = value else: - aggregated.setdefault(field_meta.parent_key, {}) - aggregated[field_meta.parent_key][field_meta.key] = value + 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: dict[Key, Any]) -> dict[Key, Any]: - serialized: dict[Key, Any] = {} + def _serialize(self, aggregated: AggregatedRowPayload) -> ModelRowPayload: + serialized: ModelRowPayload = {} for parent_key, value in aggregated.items(): - field_metas = self.layout.parent_key_to_field_metas[parent_key] + 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 @@ -100,7 +107,7 @@ def add_result_columns( reason: list[str] = [] for index in df.index[extra_header_count_on_import:]: - row_errors = self.row_errors.get(index) + row_errors = self.row_errors.get(RowIndex(index)) if not row_errors: result.append(str(ValidateRowResult.SUCCESS)) reason.append('') @@ -108,18 +115,18 @@ def add_result_columns( result.append(str(ValidateRowResult.FAIL)) numbered_reasons = [ - f'{idx}、{str(error)}' for idx, error in enumerate(self.layout.order_errors(row_errors), start=1) + 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 + 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): + 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)) @@ -131,9 +138,5 @@ def _column_indices(self, df: WorksheetTable, unique_label: UniqueLabel): yield from self._single_column_index(df, unique_label) @staticmethod - def _single_column_index(df: WorksheetTable, unique_label: UniqueLabel): - index = df.columns.get_loc(unique_label) - if isinstance(index, int): - yield ColumnIndex(index) - return - raise ValueError(msg(MessageKey.COLUMN_NOT_FOUND, unique_label=unique_label)) + 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 index f400d72..97c5018 100644 --- a/src/excelalchemy/core/schema.py +++ b/src/excelalchemy/core/schema.py @@ -2,14 +2,15 @@ import itertools from collections import defaultdict +from collections.abc import Iterable, Sequence from decimal import Decimal from itertools import chain -from typing import Iterable, cast +from typing import cast from pydantic import BaseModel -from excelalchemy._internal.constants import DEFAULT_FIELD_META_ORDER -from excelalchemy._internal.identity import Key, Label, UniqueKey, UniqueLabel +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 @@ -105,19 +106,21 @@ def get_output_child_excel_headers(self, selected_keys: list[UniqueKey] | None = 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: list[Key] | None = None) -> list[UniqueKey]: + 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 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]) + 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=key)) + 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)] diff --git a/src/excelalchemy/core/storage.py b/src/excelalchemy/core/storage.py index 900c646..26c189d 100644 --- a/src/excelalchemy/core/storage.py +++ b/src/excelalchemy/core/storage.py @@ -2,6 +2,8 @@ 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 @@ -22,7 +24,14 @@ def upload_excel(self, output_name: str, content_with_prefix: str): raise ConfigError(msg(MessageKey.NO_STORAGE_BACKEND_CONFIGURED)) -def build_storage_gateway(config: ImporterConfig | ExporterConfig) -> ExcelStorage: +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: diff --git a/src/excelalchemy/core/storage_minio.py b/src/excelalchemy/core/storage_minio.py index b643ee3..61900b8 100644 --- a/src/excelalchemy/core/storage_minio.py +++ b/src/excelalchemy/core/storage_minio.py @@ -3,15 +3,15 @@ import base64 import io from datetime import timedelta -from tempfile import TemporaryFile 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._internal.identity import UrlStr +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 @@ -24,7 +24,15 @@ class MinioStorageGateway(ExcelStorage): """Excel storage strategy backed by a Minio-compatible object store.""" - def __init__(self, config: ImporterConfig | ExporterConfig): + 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: @@ -95,10 +103,7 @@ def _normalize_cell_value(value: object) -> str | None: @staticmethod def _construct_file_like_object(response: BaseHTTPResponse) -> IO[bytes]: """Construct a file-like object from an object storage response.""" - tmp = TemporaryFile() - tmp.write(response.read()) - tmp.seek(0) - return tmp + return io.BytesIO(response.read()) @classmethod def _read_file_object(cls, client: Minio, bucket_name: str, filename: str) -> IO[bytes]: diff --git a/src/excelalchemy/core/storage_protocol.py b/src/excelalchemy/core/storage_protocol.py index 3541ed1..c258900 100644 --- a/src/excelalchemy/core/storage_protocol.py +++ b/src/excelalchemy/core/storage_protocol.py @@ -2,7 +2,7 @@ from typing import Protocol, runtime_checkable -from excelalchemy._internal.identity import UrlStr +from excelalchemy._primitives.identity import UrlStr from excelalchemy.core.table import WorksheetTable diff --git a/src/excelalchemy/core/table.py b/src/excelalchemy/core/table.py index 8ba1505..c6ddc2b 100644 --- a/src/excelalchemy/core/table.py +++ b/src/excelalchemy/core/table.py @@ -2,22 +2,26 @@ from __future__ import annotations +from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass -from typing import Any, Iterable, Iterator, overload +from typing import cast, overload + +type WorksheetValue = object +type WorksheetColumn = object @dataclass(frozen=True) class _WorksheetStringAccessor: - values: list[Any] + 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[Any]): +class WorksheetColumns(list[WorksheetColumn]): """List-like column container with the small API surface used by the core layer.""" - def get_loc(self, value: Any) -> int: + def get_loc(self, value: WorksheetColumn) -> int: try: return self.index(value) except ValueError as exc: @@ -27,7 +31,7 @@ def get_loc(self, value: Any) -> int: class WorksheetRow: """Row view used by header parsing and row iteration.""" - def __init__(self, index: Iterable[Any], values: list[Any]): + def __init__(self, index: Iterable[WorksheetColumn], values: list[WorksheetValue]): self._index = list(index) self._values = values @@ -35,55 +39,63 @@ def __init__(self, index: Iterable[Any], values: list[Any]): def str(self) -> _WorksheetStringAccessor: return _WorksheetStringAccessor(self._values) - def items(self) -> Iterator[tuple[Any, Any]]: - return iter(zip(self._index, self._values)) + def items(self) -> Iterator[tuple[WorksheetColumn, WorksheetValue]]: + return iter(zip(self._index, self._values, strict=True)) - def tolist(self) -> list[Any]: + def tolist(self) -> list[WorksheetValue]: return list(self._values) - def to_dict(self) -> dict[Any, Any]: - return dict(zip(self._index, self._values)) + def to_dict(self) -> dict[WorksheetColumn, WorksheetValue]: + return dict(zip(self._index, self._values, strict=True)) - def __getitem__(self, key: Any) -> Any: + 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'): + def __init__(self, table: WorksheetTable): self._table = table @overload - def __getitem__(self, key: tuple[int, int]) -> Any: ... + def __getitem__(self, key: tuple[int, int]) -> WorksheetValue: ... @overload - def __getitem__(self, key: slice) -> 'WorksheetTable': ... + def __getitem__(self, key: slice) -> WorksheetTable: ... @overload def __getitem__(self, key: int) -> WorksheetRow: ... - def __getitem__(self, key: slice | int | tuple[int, int]) -> 'WorksheetTable | WorksheetRow | Any': + def __getitem__(self, key: slice | int | tuple[int, int]) -> WorksheetTable | WorksheetRow | WorksheetValue: if isinstance(key, tuple): row_index, column_index = key - return self._table._rows[row_index][column_index] + return self._table.cell_at(row_index, column_index) if isinstance(key, slice): - return WorksheetTable(columns=self._table.columns, rows=self._table._rows[key]) - return WorksheetRow(self._table.columns, self._table._rows[key]) + return self._table.slice_rows(key) + return self._table.row_at(key) class WorksheetTable: """A minimal 2D table API that mirrors the table features ExcelAlchemy actually uses.""" - def __init__(self, columns: Iterable[Any] | None = None, rows: Iterable[Iterable[Any]] | None = None): + 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[Any] | dict[Any, Any]) -> list[Any]: - if isinstance(row, dict): + 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())) - return [row.get(column) for column in self._columns] + 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: @@ -100,7 +112,7 @@ def columns(self) -> WorksheetColumns: return self._columns @columns.setter - def columns(self, value: Iterable[Any]) -> None: + def columns(self, value: Iterable[WorksheetColumn]) -> None: self._columns = WorksheetColumns(list(value)) @property @@ -115,10 +127,19 @@ def shape(self) -> tuple[int, int]: def index(self) -> range: return range(len(self._rows)) - def head(self, count: int) -> 'WorksheetTable': + def head(self, count: int) -> WorksheetTable: return WorksheetTable(columns=self.columns, rows=self._rows[:count]) - def reset_index(self, *, drop: bool = False) -> 'WorksheetTable': + 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: if not drop: raise NotImplementedError('WorksheetTable only supports reset_index(drop=True)') return WorksheetTable(columns=self.columns, rows=self._rows) @@ -127,10 +148,13 @@ 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[Any]]) -> 'WorksheetTable': + 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: Any, value: list[Any]) -> None: + 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) diff --git a/src/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py index d9b0bb1..8251760 100644 --- a/src/excelalchemy/core/writer.py +++ b/src/excelalchemy/core/writer.py @@ -7,19 +7,20 @@ 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._internal.constants import ( +from excelalchemy._primitives.constants import ( BACKGROUND_ERROR_COLOR, BACKGROUND_REQUIRED_COLOR, CHARACTER_WIDTH, DEFAULT_SHEET_NAME, FONT_READ_COLOR, ) -from excelalchemy._internal.identity import Base64Str, ColumnIndex, Label, RowIndex, UniqueLabel +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 @@ -52,13 +53,17 @@ def _create_workbook(sheet_name: str) -> tuple[Workbook, Worksheet]: return workbook, worksheet -def _encode_workbook(workbook: Workbook, file: BinaryIO, *, close_file: bool) -> str: +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 add_excel_prefix(content) + return DataUrlStr(add_excel_prefix(content)) def _build_comment(field_meta: FieldMetaInfo) -> Comment | None: @@ -74,7 +79,7 @@ def _build_comment(field_meta: FieldMetaInfo) -> Comment | None: ) -def _style_header_cell(cell, field_meta: FieldMetaInfo) -> None: +def _style_header_cell(cell: Cell, field_meta: FieldMetaInfo) -> None: comment = _build_comment(field_meta) if comment is not None: cell.comment = comment @@ -85,14 +90,14 @@ def _style_header_cell(cell, field_meta: FieldMetaInfo) -> None: cell.number_format = numbers.FORMAT_TEXT -def _style_child_header_cell(cell) -> None: +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(row=HEADER_HINT_ROW_INDEX, column=HEADER_HINT_COL_INDEX) + 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) @@ -120,8 +125,8 @@ def _write_simple_header( start=column_write_offset + OPENPYXL_EXCEL_INDEX_START_AT, ): field_meta = field_meta_mapping[cast(UniqueLabel, column)] - cell = worksheet.cell(row=header_row_index, column=openpyxl_col_index) - cell.value = column + cell = _worksheet_cell(worksheet, row=header_row_index, column=openpyxl_col_index) + cell.value = str(column) _style_header_cell(cell, field_meta) @@ -169,8 +174,8 @@ def _write_horizontally_merged_header( 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(row=start_row, column=openpyxl_col_index) - cell.value = field_meta.parent_label + 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, @@ -199,8 +204,8 @@ def _write_merged_header( 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(row=child_row_index, column=column_index + column_write_offset) - cell.value = child_value + 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 @@ -254,7 +259,7 @@ def _mark_error( 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 = _worksheet_cell(worksheet, row=openpyxl_row_index, column=openpyxl_col_index) cell.fill = PatternFill( start_color=BACKGROUND_ERROR_COLOR, end_color=BACKGROUND_ERROR_COLOR, @@ -279,7 +284,7 @@ def _write_value( 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 = _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) @@ -332,7 +337,7 @@ def render_simple_header_excel( file: BinaryIO | None = None, close_file: bool = True, column_write_offset: int = 0, -) -> str: +) -> DataUrlStr: if file is None: close_file = True @@ -365,7 +370,7 @@ def render_merged_header_excel( file: BinaryIO | None = None, close_file: bool = True, column_write_offset: int = 0, -) -> str: +) -> DataUrlStr: if file is None: close_file = True @@ -399,7 +404,7 @@ def render_data_excel( file: BinaryIO | None = None, close_file: bool = True, has_merged_header: bool = False, -) -> Base64Str: +) -> DataUrlStr: if file is None: close_file = True @@ -433,4 +438,4 @@ def render_data_excel( table_data_start_index=table_data_start_index, ) - return Base64Str(_encode_workbook(workbook, tmp, close_file=close_file)) + return _encode_workbook(workbook, tmp, close_file=close_file) diff --git a/src/excelalchemy/exc.py b/src/excelalchemy/exc.py index ad23dad..f144e7e 100644 --- a/src/excelalchemy/exc.py +++ b/src/excelalchemy/exc.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.exc``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.exc', 'excelalchemy.exceptions') diff --git a/src/excelalchemy/exceptions.py b/src/excelalchemy/exceptions.py index 140239c..c9411bc 100644 --- a/src/excelalchemy/exceptions.py +++ b/src/excelalchemy/exceptions.py @@ -2,8 +2,8 @@ from typing import Any -from excelalchemy._internal.constants import UNIQUE_HEADER_CONNECTOR -from excelalchemy._internal.identity import Label, 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 diff --git a/src/excelalchemy/header_models.py b/src/excelalchemy/header_models.py index 45b6b72..f1afefb 100644 --- a/src/excelalchemy/header_models.py +++ b/src/excelalchemy/header_models.py @@ -1,11 +1,11 @@ """Compatibility shim for ``excelalchemy.header_models``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import( 'excelalchemy.header_models', 'ExcelAlchemy internals only; avoid importing header models directly', ) -from excelalchemy._internal.header_models import * # noqa: F403 +from excelalchemy._primitives.header_models import * # noqa: F403 diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index 48223a9..5c0ff6c 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -1,11 +1,13 @@ +from collections.abc import Generator, Iterable, Mapping from dataclasses import dataclass from types import UnionType -from typing import Any, Generator, Iterable, cast, get_args, get_origin +from typing import Any, Union, cast, get_args, get_origin from pydantic import BaseModel, ValidationError -from pydantic.fields import FieldInfo, PydanticUndefined +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined -from excelalchemy._internal.identity import Key +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 @@ -28,7 +30,7 @@ def annotation(self) -> Any: def excel_codec(self) -> type[Any]: annotation = self.annotation origin = get_origin(annotation) - if origin in (UnionType, getattr(__import__('typing'), 'Union')): + 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)) @@ -55,9 +57,7 @@ def required(self) -> bool: return declared.required if self.raw_field.default is not PydanticUndefined or self.raw_field.default_factory is not None: return False - if self.allows_none: - return False - return True + return not self.allows_none @property def declared_metadata(self) -> FieldMetaInfo: @@ -115,8 +115,8 @@ def get_model_field_names(model: type[BaseModel]) -> list[str]: return PydanticModelAdapter(model).field_names() -def instantiate_pydantic_model[ModelT: BaseModel]( # noqa: C901 - data: dict[Key, Any], +def instantiate_pydantic_model[ModelT: BaseModel]( + data: Mapping[str, Any], model: type[ModelT], ) -> ModelT | list[ExcelCellError | ExcelRowError]: """实例化 Pydantic 模型, 并返回错误.""" @@ -126,7 +126,7 @@ def instantiate_pydantic_model[ModelT: BaseModel]( # noqa: C901 failed_fields: set[str] = set() for field_adapter in model_adapter.fields(): - raw_value = data.get(Key(field_adapter.name), PydanticUndefined) + raw_value = data.get(field_adapter.name, PydanticUndefined) if raw_value is PydanticUndefined: continue @@ -196,7 +196,7 @@ def _model_validate[ModelT: BaseModel]( failed_fields: set[str], ) -> ModelT | list[ExcelCellError | ExcelRowError]: try: - return cast(ModelT, model.model_validate(data)) + return model.model_validate(data) except ValidationError as exc: return _map_validation_error(exc, model_adapter, failed_fields) diff --git a/src/excelalchemy/identity.py b/src/excelalchemy/identity.py index 23b0272..dcdb6f2 100644 --- a/src/excelalchemy/identity.py +++ b/src/excelalchemy/identity.py @@ -1,8 +1,8 @@ """Compatibility shim for ``excelalchemy.identity``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.identity', 'the excelalchemy package root') -from excelalchemy._internal.identity import * # noqa: F403 +from excelalchemy._primitives.identity import * # noqa: F403 diff --git a/src/excelalchemy/metadata.py b/src/excelalchemy/metadata.py index 3405872..740fbd9 100644 --- a/src/excelalchemy/metadata.py +++ b/src/excelalchemy/metadata.py @@ -3,13 +3,15 @@ import copy import datetime import logging +from collections.abc import Callable, Mapping, Set from functools import cached_property -from typing import AbstractSet, Any, Callable +from typing import Any, Self, cast from pydantic import BaseModel, Field -from pydantic.fields import FieldInfo, PydanticUndefined +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined -from excelalchemy._internal.constants import ( +from excelalchemy._primitives.constants import ( DATE_FORMAT_TO_HINT_MAPPING, DATE_FORMAT_TO_PYTHON_MAPPING, DEFAULT_FIELD_META_ORDER, @@ -22,7 +24,7 @@ IntStr, Option, ) -from excelalchemy._internal.identity import Key, Label, OptionId, UniqueKey, UniqueLabel +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 @@ -30,6 +32,8 @@ 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): @@ -103,10 +107,10 @@ def __init__( self.importer_max_items = max_items self.importer_unique_items = unique_items - def clone(self) -> 'FieldMetaInfo': + def clone(self) -> Self: return copy.copy(self) - def inherited_from(self, parent: 'FieldMetaInfo') -> 'FieldMetaInfo': + def inherited_from(self, parent: Self) -> Self: runtime = self.clone() runtime.order = parent.order runtime.character_set = runtime.character_set or parent.character_set @@ -126,7 +130,7 @@ def bind_runtime( parent_key: Key, key: Key, offset: int, - ) -> 'FieldMetaInfo': + ) -> Self: runtime = self.clone() runtime.required = required runtime.excel_codec = excel_codec @@ -330,7 +334,12 @@ def _resolve_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: if isinstance(item, FieldMetaInfo): return item - metadata = (field_info.json_schema_extra or {}).get(EXCEL_FIELD_METADATA_KEY) + 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 @@ -400,9 +409,6 @@ def _build_excel_metadata( min_length: int | None = None, max_length: int | None = None, ) -> FieldMetaInfo: - if fraction_digits is not None and not isinstance(fraction_digits, int): - raise ValueError(msg(MessageKey.FRACTION_DIGITS_MUST_BE_INTEGER)) - return FieldMetaInfo( label=label, is_primary_key=is_primary_key, @@ -487,7 +493,7 @@ def ExcelMeta( # pylint: disable=invalid-name # pylint: disable=too-many-locals def FieldMeta( - default: Any = PydanticUndefined, + default: object = PydanticUndefined, *, label: str, is_primary_key: bool = False, @@ -503,12 +509,12 @@ def FieldMeta( options: list[Option] | None = None, unit: str | None = None, hint: str | None = None, - default_factory: Callable[[], Any] | None = None, + default_factory: FieldDefaultFactory | None = None, alias: str | None = None, title: str | None = None, description: str | None = None, - exclude: AbstractSet[IntStr] | Any = None, - include: AbstractSet[IntStr] | Any = None, + exclude: FieldIncludeExclude = None, + include: FieldIncludeExclude = None, const: bool | None = None, ge: float | None = None, le: float | None = None, @@ -525,7 +531,7 @@ def FieldMeta( regex: str | None = None, discriminator: str | None = None, repr: bool = True, - **extra: Any, + **extra: object, ) -> Any: metadata = _build_excel_metadata( label=label, @@ -553,7 +559,7 @@ def FieldMeta( max_length=max_length, ) - json_schema_extra = {EXCEL_FIELD_METADATA_KEY: metadata} | extra + 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: diff --git a/src/excelalchemy/results.py b/src/excelalchemy/results.py index 567982c..ee2a13f 100644 --- a/src/excelalchemy/results.py +++ b/src/excelalchemy/results.py @@ -1,22 +1,26 @@ """导入 Excel 的结果""" -from enum import Enum +from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field -from excelalchemy._internal.identity import Label +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 -class ValidateRowResult(str, Enum): +def _empty_labels() -> list[Label]: + return [] + + +class ValidateRowResult(StrEnum): """导入结果""" SUCCESS = 'SUCCESS' FAIL = 'FAIL' - def __str__(self): + def __str__(self) -> str: if self is ValidateRowResult.SUCCESS: return dmsg(MessageKey.VALIDATE_ROW_SUCCESS) return dmsg(MessageKey.VALIDATE_ROW_FAIL) @@ -37,7 +41,7 @@ def is_required_missing(self) -> bool: return bool(self.missing_required) -class ValidateResult(str, Enum): +class ValidateResult(StrEnum): """导入结果类型""" HEADER_INVALID = 'HEADER_INVALID' # 表头无效 @@ -53,10 +57,10 @@ 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='重复的表头') + missing_required: list[Label] = Field(default_factory=_empty_labels, description='缺失的必填表头') + missing_primary: list[Label] = Field(default_factory=_empty_labels, description='缺失的关键列') + unrecognized: list[Label] = Field(default_factory=_empty_labels, description='无法识别的表头') + duplicated: list[Label] = Field(default_factory=_empty_labels, description='重复的表头') url: str | None = Field(default=None, description='导入结果文件的下载链接, 失败时有值') success_count: int = Field(default=0, description='导入成功的数据条数') diff --git a/src/excelalchemy/types/__init__.py b/src/excelalchemy/types/__init__.py index 7a24f22..957b903 100644 --- a/src/excelalchemy/types/__init__.py +++ b/src/excelalchemy/types/__init__.py @@ -1,14 +1,14 @@ """Compatibility re-exports for the pre-refactor ``excelalchemy.types`` namespace.""" -from excelalchemy._internal.deprecation import warn_compat_import +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._internal.header_models import * # noqa: F403 -from excelalchemy._internal.identity import * # noqa: F403 +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 diff --git a/src/excelalchemy/types/abstract.py b/src/excelalchemy/types/abstract.py index 94b0c38..e0a3f83 100644 --- a/src/excelalchemy/types/abstract.py +++ b/src/excelalchemy/types/abstract.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.abstract``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.abstract', 'excelalchemy.codecs.base') diff --git a/src/excelalchemy/types/alchemy.py b/src/excelalchemy/types/alchemy.py index 1aa5572..6368a5a 100644 --- a/src/excelalchemy/types/alchemy.py +++ b/src/excelalchemy/types/alchemy.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.alchemy``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.alchemy', 'excelalchemy.config') diff --git a/src/excelalchemy/types/field.py b/src/excelalchemy/types/field.py index c4fd228..c6db574 100644 --- a/src/excelalchemy/types/field.py +++ b/src/excelalchemy/types/field.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.field``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.field', 'excelalchemy.metadata') diff --git a/src/excelalchemy/types/header.py b/src/excelalchemy/types/header.py index 0bd9327..870a56f 100644 --- a/src/excelalchemy/types/header.py +++ b/src/excelalchemy/types/header.py @@ -1,10 +1,10 @@ """Compatibility shim for ``excelalchemy.types.header``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import( 'excelalchemy.types.header', 'ExcelAlchemy internals only; avoid importing header models directly', ) -from excelalchemy._internal.header_models import * # noqa: F403 +from excelalchemy._primitives.header_models import * # noqa: F403 diff --git a/src/excelalchemy/types/identity.py b/src/excelalchemy/types/identity.py index 7fdf43e..863ce85 100644 --- a/src/excelalchemy/types/identity.py +++ b/src/excelalchemy/types/identity.py @@ -1,7 +1,7 @@ """Compatibility shim for ``excelalchemy.types.identity``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.identity', 'the excelalchemy package root') -from excelalchemy._internal.identity import * # noqa: F403 +from excelalchemy._primitives.identity import * # noqa: F403 diff --git a/src/excelalchemy/types/result.py b/src/excelalchemy/types/result.py index 5b34fd9..582c1f1 100644 --- a/src/excelalchemy/types/result.py +++ b/src/excelalchemy/types/result.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.result``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.result', 'excelalchemy.results') diff --git a/src/excelalchemy/types/value/__init__.py b/src/excelalchemy/types/value/__init__.py index dd90278..3073ac1 100644 --- a/src/excelalchemy/types/value/__init__.py +++ b/src/excelalchemy/types/value/__init__.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value', 'excelalchemy.codecs') diff --git a/src/excelalchemy/types/value/boolean.py b/src/excelalchemy/types/value/boolean.py index 023e085..894a930 100644 --- a/src/excelalchemy/types/value/boolean.py +++ b/src/excelalchemy/types/value/boolean.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.boolean``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.boolean', 'excelalchemy.codecs.boolean') diff --git a/src/excelalchemy/types/value/date.py b/src/excelalchemy/types/value/date.py index ac18266..0bd18a8 100644 --- a/src/excelalchemy/types/value/date.py +++ b/src/excelalchemy/types/value/date.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.date``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.date', 'excelalchemy.codecs.date') diff --git a/src/excelalchemy/types/value/date_range.py b/src/excelalchemy/types/value/date_range.py index 458e538..9165370 100644 --- a/src/excelalchemy/types/value/date_range.py +++ b/src/excelalchemy/types/value/date_range.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.date_range``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.date_range', 'excelalchemy.codecs.date_range') diff --git a/src/excelalchemy/types/value/email.py b/src/excelalchemy/types/value/email.py index 3c74342..722a84d 100644 --- a/src/excelalchemy/types/value/email.py +++ b/src/excelalchemy/types/value/email.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.email``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.email', 'excelalchemy.codecs.email') diff --git a/src/excelalchemy/types/value/money.py b/src/excelalchemy/types/value/money.py index 0ee8b66..22a53c3 100644 --- a/src/excelalchemy/types/value/money.py +++ b/src/excelalchemy/types/value/money.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.money``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.money', 'excelalchemy.codecs.money') diff --git a/src/excelalchemy/types/value/multi_checkbox.py b/src/excelalchemy/types/value/multi_checkbox.py index 1baa1cf..b8a3e58 100644 --- a/src/excelalchemy/types/value/multi_checkbox.py +++ b/src/excelalchemy/types/value/multi_checkbox.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.multi_checkbox``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.multi_checkbox', 'excelalchemy.codecs.multi_checkbox') diff --git a/src/excelalchemy/types/value/number.py b/src/excelalchemy/types/value/number.py index 05c0f0e..f8ebc65 100644 --- a/src/excelalchemy/types/value/number.py +++ b/src/excelalchemy/types/value/number.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.number``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.number', 'excelalchemy.codecs.number') diff --git a/src/excelalchemy/types/value/number_range.py b/src/excelalchemy/types/value/number_range.py index cbc8086..e2c6cb3 100644 --- a/src/excelalchemy/types/value/number_range.py +++ b/src/excelalchemy/types/value/number_range.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.number_range``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.number_range', 'excelalchemy.codecs.number_range') diff --git a/src/excelalchemy/types/value/organization.py b/src/excelalchemy/types/value/organization.py index caf047f..7608111 100644 --- a/src/excelalchemy/types/value/organization.py +++ b/src/excelalchemy/types/value/organization.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.organization``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.organization', 'excelalchemy.codecs.organization') diff --git a/src/excelalchemy/types/value/phone_number.py b/src/excelalchemy/types/value/phone_number.py index 1161171..3c9f0e4 100644 --- a/src/excelalchemy/types/value/phone_number.py +++ b/src/excelalchemy/types/value/phone_number.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.phone_number``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.phone_number', 'excelalchemy.codecs.phone_number') diff --git a/src/excelalchemy/types/value/radio.py b/src/excelalchemy/types/value/radio.py index 4a0585d..1436261 100644 --- a/src/excelalchemy/types/value/radio.py +++ b/src/excelalchemy/types/value/radio.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.radio``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.radio', 'excelalchemy.codecs.radio') diff --git a/src/excelalchemy/types/value/staff.py b/src/excelalchemy/types/value/staff.py index 64795c9..0752b28 100644 --- a/src/excelalchemy/types/value/staff.py +++ b/src/excelalchemy/types/value/staff.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.staff``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.staff', 'excelalchemy.codecs.staff') diff --git a/src/excelalchemy/types/value/string.py b/src/excelalchemy/types/value/string.py index 1a90d34..86f1f29 100644 --- a/src/excelalchemy/types/value/string.py +++ b/src/excelalchemy/types/value/string.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.string``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.string', 'excelalchemy.codecs.string') diff --git a/src/excelalchemy/types/value/tree.py b/src/excelalchemy/types/value/tree.py index b156a27..efef912 100644 --- a/src/excelalchemy/types/value/tree.py +++ b/src/excelalchemy/types/value/tree.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.tree``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.tree', 'excelalchemy.codecs.tree') diff --git a/src/excelalchemy/types/value/url.py b/src/excelalchemy/types/value/url.py index 3460794..123975d 100644 --- a/src/excelalchemy/types/value/url.py +++ b/src/excelalchemy/types/value/url.py @@ -1,6 +1,6 @@ """Compatibility shim for ``excelalchemy.types.value.url``.""" -from excelalchemy._internal.deprecation import warn_compat_import +from excelalchemy._primitives.deprecation import warn_compat_import warn_compat_import('excelalchemy.types.value.url', 'excelalchemy.codecs.url') diff --git a/src/excelalchemy/util/convertor.py b/src/excelalchemy/util/convertor.py index 5ebe643..79d6de6 100644 --- a/src/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._internal.constants import FIELD_DATA_KEY -from excelalchemy._internal.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 index aa9c51f..e4e60ea 100644 --- a/src/excelalchemy/util/file.py +++ b/src/excelalchemy/util/file.py @@ -1,7 +1,8 @@ import math -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, cast -from excelalchemy._internal.constants import UNIQUE_HEADER_CONNECTOR +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' @@ -19,19 +20,20 @@ def remove_excel_prefix(content: str) -> str: return content.removeprefix(prefix) -def flatten(data: dict[str, Any], level: list[Any] | None = None) -> dict[str, Any]: +def flatten(data: Mapping[str, object], level: list[str] | None = None) -> dict[str, object]: """平铺嵌套的字典 >>> flatten( {'a': {'b': {'c': 12}}}) # dotted path expansion {'a.b.c': 12} """ - tmp_dict = {} + tmp_dict: dict[str, object] = {} level = level or [] for key, val in data.items(): - if isinstance(val, dict): - tmp_dict.update(flatten(val, level + [key])) + 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 + tmp_dict[f'{UNIQUE_HEADER_CONNECTOR}'.join([*level, key])] = val return tmp_dict @@ -43,7 +45,8 @@ def value_is_nan(value: Any) -> bool: if isinstance(value, float) and math.isnan(value): return True - if isinstance(value, list | tuple): - return any(value_is_nan(item) for item in value) + 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/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index b3c3f87..4bd0907 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -393,7 +393,7 @@ async def test_export_detects_merged_header_layout_for_composite_fields(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_returns_success_for_merged_header_workbook(self): @@ -451,7 +451,7 @@ class NotImporterConfigModel(BaseModel): name: str = FieldMeta(label='姓名') with self.assertRaises(ConfigError) as cm: - ExcelAlchemy(NotImporterConfigModel) + ExcelAlchemy(cast(Any, NotImporterConfigModel)) self.assertEqual(str(cm.exception), 'Export mode requires an ExporterConfig instance') diff --git a/tests/support/__init__.py b/tests/support/__init__.py index 32c640f..a179356 100644 --- a/tests/support/__init__.py +++ b/tests/support/__init__.py @@ -14,15 +14,15 @@ __all__ = [ 'BaseTestCase', - 'decode_prefixed_excel_to_workbook', 'FileRegistry', + 'InMemoryExcelStorage', + 'LocalMockMinio', + 'decode_prefixed_excel_to_workbook', 'get_fill_color', 'get_font_color', - 'InMemoryExcelStorage', 'list_data_validations', 'list_merge_ranges', 'load_binary_excel_to_workbook', - 'LocalMockMinio', 'local_minio', 'worksheet_matrix', ] diff --git a/tests/support/mock_minio.py b/tests/support/mock_minio.py index 0d9b369..35dfe14 100644 --- a/tests/support/mock_minio.py +++ b/tests/support/mock_minio.py @@ -3,7 +3,7 @@ from copy import copy from pathlib import Path from tempfile import NamedTemporaryFile -from typing import Any +from typing import Any, ClassVar from openpyxl import Workbook, load_workbook from openpyxl.worksheet.cell_range import CellRange @@ -15,10 +15,10 @@ 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: [ { '不存在的表头': '是', @@ -108,19 +108,19 @@ def __init__(self): automatically add HEADER_HINT to first row """ for filename, data in self.mock_excel_data.items(): - f = NamedTemporaryFile(suffix='.xlsx', delete=False) - f.close() # 关键:先关闭,避免 Windows 文件锁问题 + with NamedTemporaryFile(suffix='.xlsx', delete=False) as temporary_file: + temporary_filename = temporary_file.name workbook = self._build_workbook(data) - workbook.save(f.name) + workbook.save(temporary_filename) workbook.close() - with open(f.name, 'rb') as rf: + 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, f.name) + 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): @@ -202,7 +202,7 @@ 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(): + for data in self.storage.values(): if isinstance(data['file'], str) and os.path.exists(data['file']): os.remove(data['file']) diff --git a/tests/support/registry.py b/tests/support/registry.py index d23666d..8d369af 100644 --- a/tests/support/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/workbook.py b/tests/support/workbook.py index dc5a380..10cc37c 100644 --- a/tests/support/workbook.py +++ b/tests/support/workbook.py @@ -3,7 +3,7 @@ from typing import Any from openpyxl import load_workbook -from openpyxl.cell.cell import Cell +from openpyxl.cell.cell import Cell, MergedCell from openpyxl.workbook.workbook import Workbook from openpyxl.worksheet.worksheet import Worksheet @@ -38,7 +38,7 @@ 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) -> str | None: +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) From 7cb402b6838a12775a40c7234da03be357312336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 14:54:37 +0800 Subject: [PATCH 20/27] feat(docs): update readme --- .github/workflows/jekyll-gh-pages.yml | 51 ------- README.md | 131 ++++++++++-------- README_cn.md | 6 + src/excelalchemy/_primitives/constants.py | 12 +- src/excelalchemy/_primitives/header_models.py | 10 +- src/excelalchemy/_primitives/identity.py | 16 +-- src/excelalchemy/codecs/base.py | 12 +- src/excelalchemy/codecs/boolean.py | 2 +- src/excelalchemy/codecs/date.py | 6 +- src/excelalchemy/codecs/date_range.py | 4 +- src/excelalchemy/codecs/multi_checkbox.py | 5 +- src/excelalchemy/codecs/number.py | 15 +- src/excelalchemy/codecs/number_range.py | 9 +- src/excelalchemy/codecs/organization.py | 8 +- src/excelalchemy/codecs/radio.py | 2 +- src/excelalchemy/codecs/staff.py | 11 +- src/excelalchemy/codecs/tree.py | 6 +- src/excelalchemy/config.py | 19 ++- src/excelalchemy/core/abstract.py | 16 +-- src/excelalchemy/core/writer.py | 2 +- src/excelalchemy/exceptions.py | 10 +- src/excelalchemy/helper/pydantic.py | 4 +- src/excelalchemy/metadata.py | 6 +- src/excelalchemy/results.py | 48 +++---- src/excelalchemy/util/file.py | 4 +- 25 files changed, 189 insertions(+), 226 deletions(-) delete mode 100644 .github/workflows/jekyll-gh-pages.yml 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/README.md b/README.md index e92d52c..d1879b1 100755 --- a/README.md +++ b/README.md @@ -1,15 +1,81 @@ # ExcelAlchemy +[![CI](https://github.com/RayCarterLab/ExcelAlchemy/actions/workflows/ci.yml/badge.svg)](https://github.com/RayCarterLab/ExcelAlchemy/actions/workflows/ci.yml) +![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) + [中文 README](./README_cn.md) · [About](./ABOUT.md) · [Architecture](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [Migration Notes](./MIGRATIONS.md) ExcelAlchemy is a schema-driven Excel import/export library for Python. -It turns Pydantic models into Excel templates, validates spreadsheet input back into application data, and keeps the import/export workflow explicit, typed, and extensible. +It turns Pydantic models into Excel templates, validates spreadsheet input back into application data, and keeps the workflow explicit, typed, locale-aware, and extensible. This repository is also a design artifact. It documents a series of deliberate engineering choices: `src/` layout, Pydantic v2 migration, pandas removal, pluggable storage, `uv`-based workflows, and locale-aware workbook output. The current release track being prepared is `2.0.0rc1`, the first public release candidate for ExcelAlchemy 2.0. +## Screenshots + +| Template | Import Result | +| --- | --- | +| ![Excel template screenshot](./images/001_sample_template.png) | ![Excel import result screenshot](./images/002_import_result.png) | + +## Minimal Example + +```python +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) + + +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 +``` + +## Modern Annotated Example + +```python +from typing import Annotated + +from pydantic import BaseModel, Field + +from excelalchemy import Email, ExcelAlchemy, ExcelMeta, ImporterConfig + + +class Importer(BaseModel): + 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') +``` + +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. + +## Highlights + +- Pydantic v2-based schema extraction and validation +- Locale-aware workbook text with `locale='zh-CN' | 'en'` +- Pluggable storage via `ExcelStorage` +- No pandas runtime dependency +- Python 3.12-3.14 support, with 3.14 as the primary target +- `uv`-based development and CI workflow +- Contract tests that protect import/export behavior during refactors + ## What This Project Is - A library for building Excel workflows from typed schemas. @@ -35,16 +101,6 @@ ExcelAlchemy treats Excel as a typed contract: - import execution is separated from parsing - storage is an interchangeable strategy, not a hard-coded implementation -## Highlights - -- Pydantic v2-based schema extraction and validation -- Locale-aware workbook text with `locale='zh-CN' | 'en'` -- Pluggable storage via `ExcelStorage` -- No pandas runtime dependency -- Python 3.12-3.14 support, with 3.14 as the primary target -- `uv`-based development and CI workflow -- Contract tests that protect import/export behavior during refactors - ## Architecture ExcelAlchemy exposes a small public surface and delegates the real work to internal components. @@ -115,51 +171,6 @@ If you want the built-in Minio backend: pip install "ExcelAlchemy[minio]" ``` -## Minimal Example - -```python -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) - - -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 -``` - -## Modern Annotated Example - -```python -from typing import Annotated - -from pydantic import BaseModel, Field - -from excelalchemy import Email, ExcelAlchemy, ExcelMeta, ImporterConfig - - -class Importer(BaseModel): - 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') -``` - -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. - ## Locale-Aware Workbook Output `locale` affects workbook-facing display text such as: @@ -204,7 +215,7 @@ alchemy = ExcelAlchemy( result = await alchemy.import_data("people.xlsx", "people-result.xlsx") ``` -## Storage Extension Point +## Storage Protocol Storage is modeled as a protocol, not a product decision. @@ -257,6 +268,12 @@ 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: diff --git a/README_cn.md b/README_cn.md index dc19f3e..f181a05 100644 --- a/README_cn.md +++ b/README_cn.md @@ -9,6 +9,12 @@ ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 +## 截图 + +| 模板 | 导入结果 | +| --- | --- | +| ![Excel 模板截图](./images/001_sample_template.png) | ![Excel 导入结果截图](./images/002_import_result.png) | + ## 这个项目适合什么 - 需要给业务方发 Excel 模板并回收数据 diff --git a/src/excelalchemy/_primitives/constants.py b/src/excelalchemy/_primitives/constants.py index 4e6659b..ae8a478 100644 --- a/src/excelalchemy/_primitives/constants.py +++ b/src/excelalchemy/_primitives/constants.py @@ -11,14 +11,14 @@ 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__') @@ -26,15 +26,15 @@ 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 -# options 最多允许的选项数量 +# Soft option-count limit used for warning logs. MAX_OPTIONS_COUNT = 100 DEFAULT_FIELD_META_ORDER = -1 diff --git a/src/excelalchemy/_primitives/header_models.py b/src/excelalchemy/_primitives/header_models.py index 8fac9ac..74325a0 100644 --- a/src/excelalchemy/_primitives/header_models.py +++ b/src/excelalchemy/_primitives/header_models.py @@ -8,15 +8,15 @@ class ExcelHeader(BaseModel): - """用于表示用户输入的 Excel 表头信息""" + """Normalized workbook header extracted from user input.""" - label: Label = Field(description='Excel 的列名') - parent_label: Label = Field(description='Excel 的父列名, 如果没有父列名, parent_label 等于 label') - offset: int = Field(default=0, description='合并表头·子单元格所属父单元格的偏移量') + 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 diff --git a/src/excelalchemy/_primitives/identity.py b/src/excelalchemy/_primitives/identity.py index 707418e..e768f59 100644 --- a/src/excelalchemy/_primitives/identity.py +++ b/src/excelalchemy/_primitives/identity.py @@ -27,31 +27,31 @@ def __get_pydantic_core_schema__( class Label(_StringIdentity): - """Excel 的列名""" + """Workbook header label.""" class UniqueLabel(Label): - """Excel 唯一的列名""" + """Fully qualified workbook header label.""" class Key(_StringIdentity): - """Python 模型的键名""" + """Schema key used by the Python model.""" class UniqueKey(Key): - """Python 模型唯一的键名""" + """Fully qualified schema key.""" class RowIndex(_IntegerIdentity): - """Excel 的行索引, 从 0 开始""" + """Zero-based workbook row index.""" class ColumnIndex(_IntegerIdentity): - """Excel 的列索引, 从 0 开始""" + """Zero-based workbook column index.""" class OptionId(_StringIdentity): - """选项 ID""" + """Selection option identifier.""" class DataUrlStr(_StringIdentity): @@ -63,4 +63,4 @@ class Base64Str(DataUrlStr): class UrlStr(_StringIdentity): - """URL 字符串""" + """Generic URL string.""" diff --git a/src/excelalchemy/codecs/base.py b/src/excelalchemy/codecs/base.py index 1a3dd84..0f2faa5 100644 --- a/src/excelalchemy/codecs/base.py +++ b/src/excelalchemy/codecs/base.py @@ -18,24 +18,22 @@ class ExcelFieldCodec(ABC): @classmethod @abstractmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: - """用于渲染 Excel 表头的注释""" + """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 - """用于把用户填入 Excel 的数据,转换成后端代码入口可接收的数据 - 如果转换失败,返回原值,用户后续捕获更准确的错误 - """ + """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: - """用于把 worksheet 读入后的值转回用户可识别的数据, 处理聚合之前的数据""" + """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: - """验证用户输入的值是否符合约束. 接收 serialize 后的值""" + """Validate and normalize parsed input before handing it to the Pydantic layer.""" @classmethod def comment(cls, field_meta: FieldMetaInfo) -> str: @@ -74,7 +72,7 @@ class CompositeExcelFieldCodec(ExcelFieldCodec, dict[str, object]): @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]]: diff --git a/src/excelalchemy/codecs/boolean.py b/src/excelalchemy/codecs/boolean.py index f217fe1..cde48ea 100644 --- a/src/excelalchemy/codecs/boolean.py +++ b/src/excelalchemy/codecs/boolean.py @@ -11,7 +11,7 @@ @excel_choice_codec class Boolean(ExcelFieldCodec): - __name__ = '布尔' + __name__ = 'Boolean' @staticmethod def _true_display() -> str: diff --git a/src/excelalchemy/codecs/date.py b/src/excelalchemy/codecs/date.py index 9cd0705..8c6b420 100644 --- a/src/excelalchemy/codecs/date.py +++ b/src/excelalchemy/codecs/date.py @@ -14,7 +14,7 @@ class Date(ExcelFieldCodec, datetime): - __name__ = '日期选择' + __name__ = 'Date' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: @@ -32,7 +32,7 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: @classmethod 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: @@ -40,7 +40,7 @@ def parse_input(cls, value: str | DateTime | Any, field_meta: FieldMetaInfo) -> value = str(value).strip() try: - 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: diff --git a/src/excelalchemy/codecs/date_range.py b/src/excelalchemy/codecs/date_range.py index c205b1d..2ec264a 100644 --- a/src/excelalchemy/codecs/date_range.py +++ b/src/excelalchemy/codecs/date_range.py @@ -25,7 +25,7 @@ class DateRange(CompositeExcelFieldCodec): start: datetime | None end: datetime | None - __name__ = '日期范围' + __name__ = 'DateRange' @classmethod def model_validate(cls, obj: Any) -> 'DateRange': @@ -34,7 +34,7 @@ def model_validate(cls, obj: Any) -> 'DateRange': return self def __init__(self, start: datetime | None, end: datetime | None): - # trick, BaseMode.dict() 会得到时间戳,而不是 datetime 对象,这是预期的行为 + # 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) diff --git a/src/excelalchemy/codecs/multi_checkbox.py b/src/excelalchemy/codecs/multi_checkbox.py index c9a2b71..aaedcdb 100644 --- a/src/excelalchemy/codecs/multi_checkbox.py +++ b/src/excelalchemy/codecs/multi_checkbox.py @@ -12,7 +12,7 @@ class MultiCheckbox(ExcelFieldCodec, list[str]): - __name__ = '复选框组' + __name__ = 'MultiChoice' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: @@ -27,16 +27,13 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: @classmethod def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> list[str] | object: - # If the value is a list, convert all items to strings and strip whitespace if isinstance(value, list): items = cast(list[object], value) return [str(item).strip() for item in items] - # 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> could not parse Excel input %s; returning the original value', cls.__name__, value) return value diff --git a/src/excelalchemy/codecs/number.py b/src/excelalchemy/codecs/number.py index ad3e197..f9dbfc7 100644 --- a/src/excelalchemy/codecs/number.py +++ b/src/excelalchemy/codecs/number.py @@ -10,7 +10,7 @@ def canonicalize_decimal(value: Decimal, digits_limit: int | None) -> Decimal: - """将 Decimal 转换为指定精度的 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: @@ -24,7 +24,7 @@ def canonicalize_decimal(value: Decimal, digits_limit: int | None) -> Decimal: def transform_decimal(value: Decimal | int | float | None) -> float | int | None: - """将 Decimal 转换为 float 或 int""" + """Convert a Decimal into an int or float for workbook-facing output.""" if value is None: return None @@ -38,7 +38,7 @@ def transform_decimal(value: Decimal | int | float | None) -> float | int | None class Number(Decimal, ExcelFieldCodec): - __name__ = '数值输入' + __name__ = 'Number' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: @@ -89,7 +89,7 @@ def __get_range_description__(cls, field_meta: FieldMetaInfo) -> str: # type: i @staticmethod def __maybe_decimal__(value: Any) -> Decimal | None: - # 如果输入不是 Decimal 类型,尝试转换。 + # Convert non-Decimal input through Decimal for validation. if isinstance(value, Decimal): return value @@ -104,11 +104,11 @@ def __maybe_decimal__(value: Any) -> Decimal | None: def __check_range__(value: Decimal | float | int, field_meta: FieldMetaInfo) -> list[str]: errors: list[str] = [] - # 从 field_meta 对象中获取导入者上限和下限值。 + # 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') - # 确保解析后的 decimal 在接受范围内。 + # 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( @@ -129,11 +129,10 @@ def __check_range__(value: Decimal | float | int, field_meta: FieldMetaInfo) -> @classmethod def normalize_import_value(cls, value: Decimal | Any, field_meta: FieldMetaInfo) -> float | int: - # 如果输入不是 Decimal 类型,尝试转换。 + # 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) diff --git a/src/excelalchemy/codecs/number_range.py b/src/excelalchemy/codecs/number_range.py index 8ae855d..feeb753 100644 --- a/src/excelalchemy/codecs/number_range.py +++ b/src/excelalchemy/codecs/number_range.py @@ -16,10 +16,10 @@ class NumberRange(CompositeExcelFieldCodec): start: float | int | None end: float | int | None - __name__ = '数值范围' + __name__ = 'NumberRange' def __init__(self, start: Decimal | int | float | None, end: Decimal | int | float | None): - # trick: for dict call to get the correct value + # 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) @@ -37,15 +37,12 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: @classmethod def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: - # 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 mapping = cls._coerce_mapping(value) if mapping is not None: try: @@ -59,8 +56,6 @@ def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> object: value, exc, ) - - # Return the original value if parsing fails return value @classmethod diff --git a/src/excelalchemy/codecs/organization.py b/src/excelalchemy/codecs/organization.py index 059ccca..bc1a58e 100644 --- a/src/excelalchemy/codecs/organization.py +++ b/src/excelalchemy/codecs/organization.py @@ -11,7 +11,7 @@ class SingleOrganization(Radio): - __name__ = '组织单选' + __name__ = 'SingleOrganization' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: @@ -30,13 +30,13 @@ def format_display_value(cls, value: object, field_meta: FieldMetaInfo) -> str: try: return field_meta.options_id_map[OptionId(value.strip())].name except KeyError: - logging.warning('无法找到组织 %s 的选项, 返回原值', value) + logging.warning('Could not resolve organization option %s; returning the original value', value) return value class MultiOrganization(MultiCheckbox): - __name__ = '组织多选' + __name__ = 'MultiOrganization' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: @@ -65,7 +65,7 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - option_names = field_meta.exchange_option_ids_to_names(option_ids) return MULTI_CHECKBOX_SEPARATOR.join(map(str, option_names)) - logging.warning('%s 反序列化失败', cls.__name__) + logging.warning('%s could not be deserialized; returning the original value', cls.__name__) return str(value) @classmethod diff --git a/src/excelalchemy/codecs/radio.py b/src/excelalchemy/codecs/radio.py index 6ad6519..6c1c528 100644 --- a/src/excelalchemy/codecs/radio.py +++ b/src/excelalchemy/codecs/radio.py @@ -12,7 +12,7 @@ class Radio(ExcelFieldCodec, str): - __name__ = '单选框组' + __name__ = 'SingleChoice' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: diff --git a/src/excelalchemy/codecs/staff.py b/src/excelalchemy/codecs/staff.py index 9455f71..8854177 100644 --- a/src/excelalchemy/codecs/staff.py +++ b/src/excelalchemy/codecs/staff.py @@ -12,7 +12,7 @@ class SingleStaff(Radio): - __name__ = '人员单选' + __name__ = 'SingleStaff' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: @@ -33,12 +33,17 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - try: return field_meta.options_id_map[OptionId(value.strip())].name except KeyError: - logging.warning('类型【%s】无法为【%s】找到【%s】的选项, 返回原值', cls.__name__, field_meta.label, value) + 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__ = '人员多选' + __name__ = 'MultiStaff' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: diff --git a/src/excelalchemy/codecs/tree.py b/src/excelalchemy/codecs/tree.py index 4526925..c56767c 100644 --- a/src/excelalchemy/codecs/tree.py +++ b/src/excelalchemy/codecs/tree.py @@ -9,7 +9,7 @@ class SingleTreeNode(Radio): - __name__ = '树形单选' + __name__ = 'SingleTreeNode' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: @@ -31,13 +31,13 @@ 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('无法找到树结点 %s 的选项, 返回原值', value) + 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__ = '树形多选' + __name__ = 'MultiTreeNode' @classmethod def build_comment(cls, field_meta: FieldMetaInfo) -> str: diff --git a/src/excelalchemy/config.py b/src/excelalchemy/config.py index 8775edb..e3ae1be 100644 --- a/src/excelalchemy/config.py +++ b/src/excelalchemy/config.py @@ -1,4 +1,4 @@ -"""实例化 ExcelAlchemy 时的配置""" +"""Configuration objects used to instantiate the ExcelAlchemy facade.""" from __future__ import annotations @@ -22,16 +22,16 @@ class ExcelMode(StrEnum): - """Excel 模式""" + """Top-level Excel workflow mode.""" IMPORT = 'IMPORT' EXPORT = 'EXPORT' class ImportMode(StrEnum): - CREATE = 'CREATE' # 创建 - UPDATE = 'UPDATE' # 更新 - CREATE_OR_UPDATE = 'CREATE_OR_UPDATE' # 创建或更新 + CREATE = 'CREATE' + UPDATE = 'UPDATE' + CREATE_OR_UPDATE = 'CREATE_OR_UPDATE' @dataclass(slots=True) @@ -39,7 +39,7 @@ class ImporterConfig[ContextT, ImporterCreateModelT: BaseModel, ImporterUpdateMo create_importer_model: type[ImporterCreateModelT] | None = None update_importer_model: type[ImporterUpdateModelT] | None = None - # Callable function receive Key as dict key instead of Label. + # 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 @@ -72,21 +72,18 @@ def validate_model(self) -> Self: 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)) @@ -97,7 +94,7 @@ def _validate_create_or_update(self) -> None: 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)) @@ -108,7 +105,7 @@ def __post_init__(self) -> None: @dataclass(slots=True) class ExporterConfig[ExporterModelT: BaseModel]: exporter_model: type[ExporterModelT] - # Callable function receive Key as dict key instead of Label. + # The converter receives schema keys rather than workbook labels. data_converter: DataConverter | None = export_data_converter storage: ExcelStorage | None = None diff --git a/src/excelalchemy/core/abstract.py b/src/excelalchemy/core/abstract.py index 76f5bdd..dc00ef2 100644 --- a/src/excelalchemy/core/abstract.py +++ b/src/excelalchemy/core/abstract.py @@ -19,7 +19,7 @@ class ABCExcelAlchemy[ ](ABC): @abstractmethod def download_template(self, sample_data: list[ExportRowPayload] | None = None) -> DataUrlStr: - """下载导入模版,返回 Data URL,字段顺序与定义的导出模型一致。""" + """Render an import template and return it as a data URL.""" @abstractmethod def download_template_artifact( @@ -28,15 +28,15 @@ def download_template_artifact( *, filename: str = 'template.xlsx', ) -> ExcelArtifact: - """下载导入模板,返回结构化 Excel 产物。""" + """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: - """导出数据,返回 Data URL 形式的 Excel 文件,字段顺序与定义的导出模型一致。""" + """Export rows and return the workbook as a data URL.""" @abstractmethod def export_artifact( @@ -46,12 +46,12 @@ def export_artifact( *, filename: str = 'export.xlsx', ) -> ExcelArtifact: - """导出数据,返回结构化 Excel 产物。""" + """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): - """添加上下文""" + def add_context(self, context: ContextT) -> None: + """Attach runtime context used by importer callbacks.""" diff --git a/src/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py index 8251760..3df78a4 100644 --- a/src/excelalchemy/core/writer.py +++ b/src/excelalchemy/core/writer.py @@ -73,7 +73,7 @@ def _build_comment(field_meta: FieldMetaInfo) -> Comment | None: return Comment( text=comment_text, - author='https://github.com/SundayWindy/ExcelAlchemy', + author='https://github.com/RayCarterLab/ExcelAlchemy', height=sum(ceil(len(line) / 20) for line in comment_text.splitlines()) * 28, width=300, ) diff --git a/src/excelalchemy/exceptions.py b/src/excelalchemy/exceptions.py index c9411bc..741a2ff 100644 --- a/src/excelalchemy/exceptions.py +++ b/src/excelalchemy/exceptions.py @@ -9,7 +9,7 @@ class ExcelCellError(Exception): - """Excel 单元格错误""" + """Cell-level import error tied to a specific workbook header.""" message = msg(MessageKey.EXCEL_IMPORT_ERROR) label: Label @@ -33,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: @@ -56,7 +56,7 @@ def _validate(self) -> None: class ExcelRowError(Exception): - """Excel 整行发生导入错误""" + """Row-level import error not tied to a single workbook cell.""" message = msg(MessageKey.EXCEL_ROW_IMPORT_ERROR) @@ -69,10 +69,10 @@ 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}')" diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index 5c0ff6c..c2ee143 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -105,7 +105,7 @@ def field_names(self) -> list[str]: def extract_pydantic_model( model: type[BaseModel] | None, ) -> list[FieldMetaInfo]: - """根据 Pydantic 模型提取 Excel 表头信息.""" + """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))) @@ -119,7 +119,7 @@ def instantiate_pydantic_model[ModelT: BaseModel]( data: Mapping[str, Any], model: type[ModelT], ) -> ModelT | list[ExcelCellError | ExcelRowError]: - """实例化 Pydantic 模型, 并返回错误.""" + """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] = [] diff --git a/src/excelalchemy/metadata.py b/src/excelalchemy/metadata.py index 740fbd9..20225d8 100644 --- a/src/excelalchemy/metadata.py +++ b/src/excelalchemy/metadata.py @@ -37,9 +37,9 @@ class PatchFieldMeta(BaseModel): - unique: bool | None = False # 当前列是否唯一,不用于校验,用于渲染 Excel 表头的注释 - is_primary_key: bool | None = False # 当前列是否为主键,不用于校验,用于渲染 Excel 表头的注释 - hint: str | None = None # 当前列的提示信息,不用于校验,用于渲染 Excel 表头的注释 + 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 diff --git a/src/excelalchemy/results.py b/src/excelalchemy/results.py index ee2a13f..64cf7bd 100644 --- a/src/excelalchemy/results.py +++ b/src/excelalchemy/results.py @@ -1,4 +1,4 @@ -"""导入 Excel 的结果""" +"""Import result models for ExcelAlchemy workflows.""" from enum import StrEnum @@ -15,7 +15,7 @@ def _empty_labels() -> list[Label]: class ValidateRowResult(StrEnum): - """导入结果""" + """Per-row validation status.""" SUCCESS = 'SUCCESS' FAIL = 'FAIL' @@ -27,48 +27,48 @@ def __str__(self) -> str: class ValidateHeaderResult(BaseModel): - """校验表头结果""" + """Header validation result.""" - 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='是否校验通过') + 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' # 成功 + 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='导入结果') + result: ValidateResult = Field(description='Overall import result.') - is_required_missing: bool = Field(default=False, description='是否缺失必填表头') - missing_required: list[Label] = Field(default_factory=_empty_labels, description='缺失的必填表头') - missing_primary: list[Label] = Field(default_factory=_empty_labels, description='缺失的关键列') - unrecognized: list[Label] = Field(default_factory=_empty_labels, description='无法识别的表头') - duplicated: list[Label] = Field(default_factory=_empty_labels, description='重复的表头') + 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='导入结果文件的下载链接, 失败时有值') - success_count: int = Field(default=0, description='导入成功的数据条数') - fail_count: int = Field(default=0, description='导入失败的数据条数') + 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( diff --git a/src/excelalchemy/util/file.py b/src/excelalchemy/util/file.py index e4e60ea..536c9cc 100644 --- a/src/excelalchemy/util/file.py +++ b/src/excelalchemy/util/file.py @@ -21,7 +21,7 @@ def remove_excel_prefix(content: str) -> str: 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} @@ -38,7 +38,7 @@ def flatten(data: Mapping[str, object], level: list[str] | None = None) -> dict[ def value_is_nan(value: Any) -> bool: - """判断 value 是否为空单元格或 NaN。""" + """Return whether a worksheet value should be treated as empty or NaN.""" if value is None: return True From 3f4da30e5eb7ae33eae2bfd19a1633f3f38bdc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 19:11:08 +0800 Subject: [PATCH 21/27] feat(docs): update readme --- .github/workflows/ci.yml | 8 + README.md | 16 +- files/portfolio-import-input-en.xlsx | Bin 0 -> 7296 bytes files/portfolio-import-result-en.xlsx | Bin 0 -> 7617 bytes files/portfolio-template-en.xlsx | Bin 0 -> 7335 bytes images/portfolio-import-input-en.png | Bin 0 -> 156059 bytes images/portfolio-import-result-en.png | Bin 0 -> 189455 bytes images/portfolio-template-en.png | Bin 0 -> 129496 bytes scripts/generate_portfolio_assets.py | 168 ++++++++++++++++++ src/excelalchemy/core/alchemy.py | 5 +- src/excelalchemy/metadata.py | 6 + tests/contracts/test_import_contract.py | 36 +++- .../test_excelalchemy_workflows.py | 15 ++ 13 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 files/portfolio-import-input-en.xlsx create mode 100644 files/portfolio-import-result-en.xlsx create mode 100644 files/portfolio-template-en.xlsx create mode 100644 images/portfolio-import-input-en.png create mode 100644 images/portfolio-import-result-en.png create mode 100644 images/portfolio-template-en.png create mode 100644 scripts/generate_portfolio_assets.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58b4a1b..c563f7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,3 +114,11 @@ jobs: pytest.xml if-no-files-found: warn retention-days: 14 + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.14' && secrets.CODECOV_TOKEN != '' + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index d1879b1..c69dd03 100755 --- a/README.md +++ b/README.md @@ -1,25 +1,32 @@ # 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) [中文 README](./README_cn.md) · [About](./ABOUT.md) · [Architecture](./docs/architecture.md) · [Locale Policy](./docs/locale.md) · [Changelog](./CHANGELOG.md) · [Migration Notes](./MIGRATIONS.md) -ExcelAlchemy is a schema-driven Excel import/export library for Python. -It turns Pydantic models into Excel templates, validates spreadsheet input back into application data, and keeps the workflow explicit, typed, locale-aware, and extensible. +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. 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. +It documents a series of deliberate engineering choices: `src/` layout, Pydantic v2 migration, pandas removal, +pluggable storage, `uv`-based workflows, and locale-aware workbook output. The current release track being prepared is `2.0.0rc1`, the first public release candidate for ExcelAlchemy 2.0. ## Screenshots +These screenshots are generated from the repository itself using +[`scripts/generate_portfolio_assets.py`](./scripts/generate_portfolio_assets.py). +They show the English workbook locale because it is the clearest presentation for the public-facing README. + | Template | Import Result | | --- | --- | -| ![Excel template screenshot](./images/001_sample_template.png) | ![Excel import result screenshot](./images/002_import_result.png) | +| ![Excel template screenshot](./images/portfolio-template-en.png) | ![Excel import result screenshot](./images/portfolio-import-result-en.png) | ## Minimal Example @@ -70,6 +77,7 @@ For browser downloads, prefer `template.as_bytes()` with a `Blob`, or return the - Pydantic v2-based schema extraction and validation - Locale-aware workbook text with `locale='zh-CN' | 'en'` +- Row-level failure reporting and cell-level error marking - Pluggable storage via `ExcelStorage` - No pandas runtime dependency - Python 3.12-3.14 support, with 3.14 as the primary target diff --git a/files/portfolio-import-input-en.xlsx b/files/portfolio-import-input-en.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..87e1eae3b94aecd852b2204748ef95c30b701015 GIT binary patch literal 7296 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 literal 0 HcmV?d00001 diff --git a/files/portfolio-import-result-en.xlsx b/files/portfolio-import-result-en.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..614f51994a7a4397133bab847085ab6703ee1011 GIT binary patch literal 7617 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 literal 0 HcmV?d00001 diff --git a/images/portfolio-import-input-en.png b/images/portfolio-import-input-en.png new file mode 100644 index 0000000000000000000000000000000000000000..0f216ea4e0e375ca352492d469eb623404a04ab9 GIT binary patch literal 156059 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 hWNx3O9sdgdjyaL69b8q$ncNRaAtis2l~Q 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

Gv%^VHk)3{pMFd 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�zoA*oD zeshplaMqwV!TA4S|(V z@#q6jsJXgfRG;+2&l@1h)`1ej5*mU`A)Nv0WeU<3@Xk%EU?CX{*iV2ofY{I7<0 zsk05?Y%vs+^>yL{bty4KBon(4)L~kIDHC!ur&1u54+1n(pvcp1owDu^3)&X_p$#kiTdFA z`DbVdE}dZE@(A2yFIo0q0lcV3T3h9KYgW|h8r!dVbS!Hv6l-HqpVH_~xRQ8_&a+hk z3iJ?B&46`*uP^gOPGyO{t(Xwv&5VN_s92bbn>qDpZjM;*iKfm?@QEe7wD}sPZo3&M zk{&qmNoaz$kl6RTh|qw>pL7)UI&?<>C}~V}_7v5d ztLCrTbm4AJy_=%(o2vbV6JVZ;Q2ysR_UQEh+#*^GxV1743Xd+MdXu(Ubp5O9O#=r) zut3NZgZ-WdcrV@yq+Q;^j7ewBkGJh}v*j!T5%;r1=^Air=lJg{^vGMsL$j3=5a7O^ zcUX&mwhX&GzIivta;J}(NP1cM4ZYmWqINRkM0U9ovu~fY|HxN3o9vcmX`SG}2we`3FU{l4%>&`rRXdF894<)h{Z=SNE&0><$!q3AxO z7yRZ5AcNE;=ibT$khogiR;gYD1@?^)8ler*Z&%f2y2rWoWqKL;DGB%ZQKV2homedw zbKNir$0PO$D%T(W{vdrHBP|h7>3fQe!+!)i-0fgHef;pe%%U$F;~pJ_KLQ=ZK@iFM z*7Hd2Wg?av33oo-E&$L{bW`><7aakkbl%CGr?wFX)Fu+gNva2?rm5btFw|kVWYBPc z_#9?b6$x649L;~gz3O^4yPUr8U}s6N&kJ7K3fK9tTQ#Mdy<6w~yA0b&(e^*_nv4+_mCabWkiOU^T)BoS%I^ACr8Plvxtzm%nT-5Ut#{KX03i zU#@fsM^S&Zh$Bj?@g<3BpqSx6=Vhnh3n^zfzF5TwKbL=u%P)SESRm*UGU5{r+`GDC zx$jBxlO`&n?-hwE_5oM&hiGD=GCQ770$h(a)?;tORF8GDy12k+b)wD?3P^2>VZgjk zQ}N?6ZeawiF%&WL3Yv%h#xBG-K~ZZ!e@V^hJk2acc3$|)nLc0 z>+@_801l7Q6;Ymv9(GT;q~$L9pZfv9Mr8w;*GO`eG#xMv#y5i2At26w3n%;95^X}(K z;*I1)x)fU>+UlpJv7C0I!8Ag*%akRDC-1ZQRPr%h2z_MbB-B4;l#kh1UM|RPQcu5N zr}v`fG*FgpJ+y_W2KUsFsDQ07ZqQ+-GI#y~_LHF{-A6+8C_aKP$y(+WAZ8wbJj?aySD2vpUQ9jtyPWJKE&COd z)+2~xQFppt5cuQGn$^1P0H@&xi+m2+7HigB;tEr6%q1Hhz3rj%%iGCu?7r!(Cq?o1mT#JDqb){?Yd#-=(C#-#c z6Fk%+F%{LP`G7if-`1G~n~kB+(!A6GjxoUWL`o#3uv1O8n)z0GmfAAyB*)L3Pc+;O zf9WZD<4&djvhml~NJVjzatL~h_ReK?pNcr^`@XlHz3zQZK?$M|&^#xhdG1lIS%GD+10lINbg>$Aw1 zNf|oD3c5(Cps{ax)iDB=m2jIlXz1$F$e^v*Vntm@;F9aPTQgs59kz=qb*BQFtV@(brhe1e{gV@t=%DWE^jr4>mrdF zHjaLqfQck(Pfbm2@60iI?#G$^xlL{TwIiN6uVNe&b^~sD-s_~dW(bp|tW>7ptmj(; z>B$?boJ`RV`|=^=LYT=NK?C&cTS!xiBv!9s?qoLcG0}!TLOE?k>D_Xgk1l1&qt#4U zS?JGADfMjBAp|@oQLM&!ruFDr?HTh-otAz-Qxf{4zx-9uO<*F0#qWkwSlt~EWHS^m zskg?AdojQNlBv38UfQB^Vf77m>u-i{aOLPHNPzmGfb*1mf*ofx8RL3U`FJt-?>u&@ ziLrx=i{7!asw($LFM5*VL7uKP6iWE37CgfP_EU|%&{1US2Kkh-k)YS%58)R4?uYNC zeNTFdeZG0~=6kIyg960tCKYq)3){G+W zwGS`xO}=RvJ%?+k^_>OrDx08xm?zA@ZLq|A)O*1wn=^eNgC}r#!7gGy#z%6$P2ZVg zN)RrydV?1~50<^I0s4vnoAs-Vr*6pXSW7GH6N`+?>uxDG#|j1CQ;8LEw!$Ta+tB3s zap5JLDaGQ>*U7UZ`WAJizb%FB4zd#NKnZLL(OYMS<%@IasOp4PS4S!X=gXso(S6K2X+KM9*VeUsNRwva`!98rgGsY>)Z$Jx9i-MXbnl| zy`RfV`gDz-(Zz-Y-P{79gv6+%?EQl0j5GGFS3UV)^1|!6hV7k8%cu}&2{5XXRhWfMu*3Pnvb^X z$L{(nWV(AAiaFq4znaI5`RF?aRx)CAs|LI{bFW{&mRVeQv@l#k&HoAq@8rzt1tB{b zx1jv&Eb}I9>(%j^%=wa`ejZhAic5*iK8|1bbF%!7%jAQi`!+VnfB!Sm$obhILBmshO4=w_QG6ZISfo&9#mSA98&pT3Q#W zhp{`%(8)gw6&xKRsmQ zGRypr#(IjKtwZbfppo|^h_gG#O^It*1^tsv3(rPz>uA_hii}sFWb!x&;PR4}UpLCV ziu#ccdB{K$R=|2VwZpWAFLwtN-U{)Q0EW?=r4{uDFlu(9=CNFwXEh4M&AIo44vqMH zxpZg1-u4|HU6QqVtOkuT-6tUx!;bUM3`Tg^iZ@dyE}0gKK9tBc?Kp1t?WOq(Y>D(R z{{y{DCV|E7%LkWPe?3&ud(ZnmZnrjLTh+c|io5V=jnO8GzS1FFAnTK1FdUWMD_Om{ z#|^6l1ss`2{&_}jN>-QDh8LlYDMA~C?7wg9(TKZi5&i(CpcYI$a|Jg7NZHMUCdegU3tH1EMDYZ_lMNa#`+30o z?%g~3O6!2@1pIzWL&b(51VbaNvA_X@cZZ*+D0yU}`5&rc(Y85eT?+Z6yyM@6sXD{r zPeLN!IMJzB`hXUen->X*r zbepAK*NCpczz+;Y-OQ5k>N>~SDj(fL+m2P@i46l%=~Bxb&^dS00_dzW*=R|MWHOH1 zGRyWt#tVKqqb7T|zPX%_>Aps`kKFpk7oJ_Ue3q>1H5n`6`J+Jmr>qaJb@2`yoQ)!mY zf+G>gPN+YJ%gDr}R^sZ|lB`>3w{n1tb0*AFO_0{gw~C`cE7ZQ?)}d!KCT@I2GWmv) zHY1cq=;g;~{2|%Xy4gnZ+>&`3M1)^&vp%t>`a*E`^PLJ%*FS-ZTB?@)T?gcfq$wI{ z(qOmhc(qVmf%=pA8OB#p?!;y~RbbPVGx`&b7U3KVsvpqI45GFa{O+kDw((iW{|r|~ zD`>y8JA>p)bl_zH*i$+DWIQV=}@%7$LxGj_m!zrQ>o~QK<%boW@Y*h{k&4$6z zEY8#q%b+BlIe6Jwg0kozW8eI-n4jVy+ITIMfecCUK1FrfK8#9^T?YOAC56a~z7mH! z$5vNYfy#VJQKLgy)0<{IRe9ytv-l$fy7Ra+3JxFc&fX~C$tt^FJ*#1h&Bbu!f4|qE zQGC1S;)=rOMa`yHW|tR53d9n%E%}upraIzV3>K(4-vO};nKOEAQl7gTJc>B&ChHSQ zN`mrt5}#zCb>;%=?1|G9zlLq$!jqp}&4!dZO-gh;169)hrsj_y)(9Wx)2^42-)o{1 zQaB7mHay>`q%AdA{+l4<{altlb#piDIN1X;>WnOsebNkS2CT$tQNv}&>ZFYFVY43p zF=dg<$4O5#3Qql2Tk?8?r;7pjM)e!!qSMZ@`b9+tM$JP^a8=%G|DgC<#F+;JPE{H1 zR*7f4pJ z1|Kutazxxn$KsqD$z@2>b=`0wHm!i-vLDk6_k~5z!F_u%gZd)Nzn zpL-{m7@e4m1a~#Oi-@r?TDbE!a;ywGnyYKov3;+4`-h9TO|uLemAQ8R4?pN%_`rRZ zw|J`>%Zsd*KC3romZ)`t{cXfvqlbFKoo;L_Wdu*k7tXfy6L$RlXZrV#!iQAYzstjS zCEJobH6|w@_$bqT!GUfqi*p0Dr4ca#H#V#cIKp$R>0Y+{>mU6eexZS*O~gxe=rgZj zTSS}|(|e9@fK^~uBc@waKBnl#xn<7BQFiLz2VM>Q|L|<|xS~(coM(yaVu+NmJe7{? ze3tHpDPk!*L--@g9_G=LyH+u-$mW*&KYr0m?WSv;8!PIV-3a4*=Kv^8@)HHQXyRs_ zb!F`Zs(~a-mie5PrChY*K<5Lczmp>W>23H)`pBOn#uBH;SgKp$E->B^G*xE>nV~w5 zK-$*x(V>X_Ji^Xrx#GwU=^^xn4le5b?PZlKV*ueKKtU3)7X4xmF?F|3MU@D@7;B8= zobWzDD;Lpn`tKjrg7fgccO<3J#K|$EoOe_daIO1y0>@ZJdao>cEWQy=8tgTIhqv({ zK|_bgD%=?^%Hm)Q{!zcIRFhV)J)|5E8$R3dYMG$;^MY1NQttf^k{|xv&*M#xTbVeB zwvOt>pGaGLe$!dsy9mE8`1O$gVA|r34`~9ai)oG@LUJ*=&wnc-{R0ClH)cE9fc2~_ zP{0MH`*T3xNr{Ouj(0Ra89QCanZ14=?vB?WuHAFM$YfAC>dK#vmi^Pp{8zsXOTx{e zAC9MV?nt@&8WS-y)VZE!SiMz?;Sk(ZF1;!s?V{eJT?^4(vX*J3j?U{XsQ%ei-yT_; zZV82E#4)(MRlay5w9O32t{L|!d34J`v2FuuR3;USs4G|3=et!<*AS7ChJW;Y)K~!M zj9R*`P<3r>(m*CSFsq$De}%o_5I~n;xXzFNsHb`N{(X3ktr*!OpM%*@d$^-iAsd_b zRC|McVF270`<;(r5=yhLjGc9zLV1ffjVh@Ht$b+c!1(QY!%LPn@Y8XaC=dBNXQ^C) z{wsdBEZH*WbIP_N;$+4Cc>keJ;`g^T^MaKpY=?X1v8GH}?(R<;wl%-&L?MA;R&f+u z)J%Fa8K8NO+T%?9o2w}lw}wFqwgU~1w>@kaZRiB8bQy~o$tW30(GJ7K&b*Lp+3v1A z^nbhoaMw+EK@3k^NN>f92gAF40ALd<=iLdQ;Jto&)>l4N%?SD=7J{^rvFjB%NNVsm zgSeZtJ!56-AXdUl{Qf}V$=hzU@|%uV8~Ga#H{#}@0t4dY(a6HQl@3KWND;4}D!<%5 zxfs&4wE8d~bEyEJwpI9SjK2Kj0F#VPQc93Ww)_!0NqkYvB>IxlZZ~OP^Q1lojhG8aN)+lGUeuOU4Yv>jFvozNp z$9o+LF^#Q?yvh;R|KZfA^y{I(JLOh+nxE#-`PHH_V*g7+{zGZwXegenb%-nX0~eGp zm@?53ux@d~!TCMQIqMB&#B7Db^6&-5SFh$}p*nHIpiioN3**$}aaibW%aET4E(M6c z0JN757l={Y3w@-3U$+hbB(yp3DmsA}jwEF^v<+%AbvKjY8nyXz@f4z-x-Wyai+NJ` zHuOlIe#%f%=Li|#w|78W(6T}+>}q24zfzuG_=WcdGtmQKNQm^Umy!mnAq`y}wfc5> zlld%jHsymTQ_f9y-{o$84rBP9^#!<~7*O`t$_5E6Mha-MyUEb8Jk_8WECa`ZxW2dm z{V@voLy7x<^L~U}Ke@>m<8s5TgrdUJ!h%a90~HLNlQU~jVc!S9QCJULJN;*deF_S$ zxdRH(j{=-J_nd@zcWRPW9j9ix3`W#N6^eQ}kLdm8IFZ~F4?l%Pc<^2qrv_&847A%m zM)*tkmKz+7)~r`cRljp|Lpj1*|K#ZE!pKK8rU*{WX6yKu_i{|s#CXPH+vza{zMuR< zr<9M4&=n#q6VGo(I8jfC@4MLGtc0!l4`c^ zivk|B5`@4L(NS0`Lx^<{Q;(Z-7jAg|G>nTIjn z9o3UMzN@7;Zp3vuiwZg&&AohCSG{&Mn;e|>&mNXY#|R*a4cw`KT;{F0GK}? zaH*o;v8;WElTnUXAo$Y>&F8Xa1YY(dDW#^y z1}Prl)6`9l*$`xDUzA1!9nss{1E0VgB2C=Hidti5jDZ*Us39_+rR#Z9u0wz!mGFA_ ze!a~j)muM)EI@Y9;t7{h7~WCPYy|c&5~!oz$qVD3tZ^++iiJP5_M0|0>= z{n8?S{nPCa7K^-Dloqt!TTx#%hfaM9!;asNExEYgSI@QIae8P$f+~No}s(dO=06C$6^EQupJoM8r-STAY z(56Cp;g#u4VaMi}|6=cqo~I}3-gB4xg8!wPq9VdQwc?_kAZ9?BtI}~-D_>cMSXlm2 zR)(Isia`geUBj*0ox>1`4ZXNBK0M=ctCz$~THoFwuF^5q{iwMdvi~Y2P=2f_BT(; z>zJ2XAoZ}31rB5{-e|L*P!iL>(9x@tm-+eEE zu!D@dkRBue`_})Z_#EkKzQz9WLaPRM7|w&UdfIDaMlDsZSi>!c!rKG7GQ^YtbJzEm z`~l6yHxPLJ5Rd>zn)=e@ER}IEsB0Cv{OD^r1km>#Foh?DHYoRV+-hHRGX5xw|IG!E zcg#5;$s>TbV$MvpU3jk~#&d-~A?$kukDjm^xe)6QUt|&mC?C3Ivp-5|HR>F#ZgMhf zsTw(46J%}pQYECS)6fDd`$4xcE?pD=r z#R2OIK&}R0zJKnT4LxM=e4zfmb0HI7brF}iHmH0Jf>brs1z}=tP8R(+@}~rz>ZLsQ zFy(z`v)YO(w0p=MOR1LO)7z`1{tcSHFI6!4Z5#Q$E^y!F522GtuQRV+F?a{;oOXou zRm!=YR7>SOutg}6=%Y}}_TQdjT7N~4eW-LOo$0~wt{0$vIGLIKg7pVdqSzQBa&lh? zq%q&#+S+}8;OzCm$No`&yu8${rNaP!|JGACC*6F}#8LD;&maMiq|kfq%RYOzMcCNo zi!Yj7aCk25A*&wT`=RGhYl)>kCuq@h#8@gIL<&+O6Fr{V-cA!$H4DNWcO8nO3Gk$Ru zrg$IX+@?b+5vNp@Pi|5X%bPkE8=gEq*xzRR96$J+Q8v(oaKFLQ+L_kQE1anDP=R3j zYqrEwTa|{x7kDh$m!yZg>4Ruxh`1BrdPSWYX-~mCe+UYI{;GZG^%%R#%JJnm@zVY& z?=|Hi$tn16@%@QxH+MkS_j0M}M4Xf~H=R9NKq1K!jwjBFE4++jiyy(eb=wa0X7$qF zSqmrpf-Y|}alinhF~rGDd8@rv2Id#c3mc+(dvm_a_xE0^bY}7s+*B${!Za8I#1OWM z?+yZ(*O!e$w)QM; zZP&3OikIHa$V75cfm*H`XBhGdlNeB!R=i|UR87&~R!s9Tf?!E1a663TXxRn_{NyzX zdq$I7t7pQclGjO8_V}Gv>A7i#YTlRaLK`t2R1nvdl$73mhg-8dh>(Pw`7)EJsAx3z z&^PJ0XF~^(x0|CMdL4fn|MEZ`IFl>TMGhiVLL}f4$4%G2I0miY+j9aXa}D&9+$5m= zy#eRFq&8xhx}x^s_D;4AZ|Cl?T@!av16Va|P)nsQJ{)`D15`OWdlBP|IewIcLyY&> z>xTk=BN(x=juKbvOy4JeeqPO$ezj;aCbY}%s%5S*c^UOs@h5ZcaM2M<^%l~UQcJwz zO$(~xTbyx1r>g7plf$QTju8{Jlq1|L4=osPQ&|o!hWQp3T**{3-W5id@i2)aJJB7{cxfO>s@C}`9km8qI~BI)2{GY^rIO z=K<97t_-qGuediW3HA49)s{gKTLB|aRza$=f0j)zAL{((RTk1L9o`7}rtu5+ESP(G z#Xa^$VIzTO_V zkP-gL{tbd8g<0WF7R9t>lu+#+hEeT&7^tZE2gbtB?A?jY=r18m(kwY$cpk? z|9;TK#V%jN9)Y(v34Oe-ROyp*kA?)XA3bq+6y-@gW_WWqV>+B#KkvA%7wwJeGOr^p zc1)$WNDG{3C3mv)$Lb@SlAS6ax%+C{EDAmL_i{S6yT(SAcO>1{nqZhWV{SbO!vQ-nJ(}sd1WZ~RcTT_5%DjIxaR_wovv=14-|5+c zOi_^Ape2eoeoKb?09E85e`-DLT@MI_Ol*HZbMi*PhY;QH>TpLwM?Du1yVE~}nIjtJ zQscHeD-Tq>pj&Mt@fgd?Y`TxZlPbV0f)uj`$PU#+Ib!aw5?+T`&2ZIPN(&m$mmRf& zA*fus1*&cp5cMPwTxH)4oIb>QoG(A#U7wx+r1NUb(TQ7e!gw3CKf!9|0rXWL9oXYU zkdgT+L0+=vQp;~nH%8(g#hOx$fW{N5Z=C8c`MCEi#&~rXU9;JZ7kaa1y3=CBr+)-< zh^tI}OQw zvu&gJEdoe>XQUt5oH~5ShV=5fKFEkGO6Yu%-%-EKRGV;5;#>$%IK_&^JsLGfX^Bc+$iVA)J(M2X0YtnZL(P-5pcuh$5?J zv+EPvRks$RzYmutv9&bxG1^SVYn^r#JUj0s+_f(HcJNI3PU(w+_iuVsE@>5V(^e&; z+Uvs4@n{Zu=O4c@-aI#Kt(s%rB3z($*>GiRdbJ>dex+`Mru$u3hx`y-b#HtfPs(ns z;W{dLPmFEF{rZyDRXHeiVJn-U0Y2v5)2xI znK~1fu$^@W0==E*SKB&nT{%x4JaEx>?0wzs9w)Ql7fuVr=LAL0$EU9TJm}Ig`JA3l zce*J_&|`Ty>8v4%oF$I@kfxUi(KDwO{k4IIH6#zT)^sZ!8hdr{}-m zow%gKPgVRgBdO=0-l{Cxs?MhTcVsvZAC2J(Fd4${NuwPKCZ9+XYbDH08tlxky>Z%l z?FYed+TsVozP#_zd8|$wOYg$d3-zTrY?Sjf7qoYLbxwz-;xG3!rEq;Z)01WY|8e#f zP*LuC+rQE!DIiix3)0dJDhenFB1ngTNOyxMjf5g0jerQq(A|x6Hw@h{)G#nZyuWdu z^PKZv&-4D@eV(=UV(qmzj52fIzxx~4^||uq#F{owjCQ9T@PHgx#{#-3ghZ#|%L5xM7;MvnNgdwEguTmmvaq6ow1^upkZnP!R*pb`NwH`7S>qK- zCd6JBA|xQK!DI7i0gB!z>w_+w9hhy>A1#A{)W?uL)~@mbFz^1Uo*fpiUrN^1a1TLE zD05)XOJqj3GzRz$iyOn);d{!+G6`u@nOujLc`xxBFOm7?FTs?cPrgnX7@+Baz*v~g zxy}t{0CcTwTmxpeo*M|c&D-kqeJ7SNNDl+TEq z#)HHQi*ETTnI932+WoBfC5Df(=s5KHJRJ)}eUJ~{@INbBh-!WI&Od=}d+FXBmY6w< zDnx?(%Lva`2QU_TL+85vRNqf-jpOKxnC{137|>AxJw^)#DMX$hTpHVFL8mTmSih^I$#v5OgK{Z z6r%#)dYU%e*G|79`9EENTO&9^Hb#Q9;ho3#Ls=S! zsh$=xpIeJpKXcf+N4(BUd9Nu(-Sr;Ex+7qfZXPnAK`iQpx7bN?EifGG*ShXPG74W$ z3Uv)J`*EL_`jl#BPv%AIO>j!Dua{x8khW&1)ll&?Xmp$tn2xBb1!cW>@wASO}M*@ zZw+mr2c3H>5P;W=qT~C0PfQSH9t4 z;%5n8Ums4s{;Wpt`AryQ1Vs)-p31GSlM3GQmh$fohi?&SGWc8N38g2otvul{;%p_? zuGu8t^ARSlPYO0&%e1xoN#j~X<8D;gAO1rA>B3y_98tccH(4!l6)8f-Gi0Etb~o?F zZX=;7pSh-)zB8=b0NXwO83cnnNO#(9^VabMs=If zP5zuuB>mPNh>U7CI)d?(SuHfRIAkDf*1n8N?i>nM+_E6XxG`2-d)Zy=rR-6j@a8)L z5$;~zlBHc3E}9+ioqTx7_hcee^y{u0eng%`(N^pVW8)s#p`9oBh6q27Q$uKHNF|Nz=y2zBw*sV=V&~c*+06i#Hi%uE|{(E1;obzj3AB3!|%a>G%cvsgHXJl7j z(X!cWAuB+UsGO31*Ny?cw^sX}RjKS^-nmJU6oy0vkhd0&nbWo6U<<{+4BWqxOnK>$ z7@w_swf;5U8-KNDFfbgz;K-?%m zf-5fcN$ta__Id7nYV>UMsHFU2{Osiv&jtQ@iIzvW%-+LqOnXO99+$m|8DS1my&`kO znz(&^9$Hu~l~}xHifHge*6AHBz2I|OXg>t!n0!+|B4QN64mG^>mt;Xe$`Ej41M?bVL3{y-z%_MAd)UF)2Xi7DCDCDAIe6(L==^4(wIQMg{F?4g%>O7?r|rVbYyRC}fa zzM?V-*}CG>kNnrw*OWtF*K)_iRuePloVf=(qv zf3{)sXAwp^n^>FG3m&y1AqdNw*$&R_%6lTkR%5rTeAS3>N*lug@mR9;~P;wo6Q=Nr8V@#j|Nd3E|X61>E)PcXxhEnk%Wm9zvDv^K+SGJm(%YtlrA4W69C`)@@A! z#cC7ji7Bqxt*_xtB8_c}>UxrUx%9d1ohAytpDsMZ3CDUKroR${@y~plSgS7b&- zd)0VoJxTP5AD;u`D%tIByyH9lOzW0|gMrrwjPDp8v+D~Gg}r4+^x=_{=}-WShGK@hlae;fsu($5WEgueyr#JIH!_eWLGU1%+0v z)~P6e%f@#oGdl6jHu|v@(dSj{43evDU($FMciQbA_j><4Dx2V>pRJs2S&C717rwV>jjW2mg6#9vArn>ObXA*&mbxOi4Sx6_HP6=&Ogp%)uwSDF3PJbp*DA@0Lwo- zL|Higc*~Rql5NUG1d|2xvk?n(l^PnIw`r5uh~J3ZOKP|{opGIYVUI3ku}9v~i<$pu zdft=z4r1x#GR^-hsj6hca-wjC%&edQhXbKQ+yoxK*U(gVpt_?g7 zMQX_uNx-%VCJqZFZQ9wx^L3s*^u7@L%4L%R+d)O^kVY#^oe^o5Jz4*~>^8Bi%4|LU zMihBG>e1l$A%ajOwz8dnsLDrz{Ii#`P-iUEGq!EQ?*0C%;ZJPBtqEy-VUJp_Bi_H- z(#LlPfitj+UHeI)XLCopC?i>zxZJ<^4WGd5`vLoEpDK9hq(_UEesZO<~^ zllS@xuqDOc@oK~6cZc=3w{d3F;L|6y8ISKjp?hJ1q*CQvr_gGtl##g0h!3qJ-!ofR zie+IYTZdjgd~lMhn6=cbF5fUabZhRZNAq67oxK}o=hGn(GOa&4jHcBERpGXnigd^a zQSGD6FBtqYkCV8`IbLj#SM0db_QgvOf1DAGmeQq?6dS3(CF`*=*ty?<|6;nde9i;z z%S`pB4r=5WYxHx-ipVYM7E)S3X??vtPyTRF)GZd}ov+z{G1HdHym$0N%|N-G>vxNX zz7i?P6Z+TA#+|9f0w$hB&y#VcMO+smw@TF7=A!PYhF961Vco5}AD4LILKJ7?Z1~Ib z!LurcfMK^HkAN25ufDHEmPxBBeMQZAqB=h?f|r9aOt6+$S+&1HBvI;&U6(F9|N3Sf zc`<$NZatk1IcFY29GT;Hn)jMfi#PnF63-Q}g(Xw@-`67Egp$;#nc6?--vAMTVszn= z&sz4UDDW|SXuhSOzIfrBCk^3)I%wB7s@UoL_w4R;ObxG}D@|d)lOHtB2EXs!^|YvP zb*LG6gV+A4Q)zYFWopC1W&i%k_W-Zci6p&*(j|dc^|vG6nYA3dVSRHdf{_LFj6pgd zWd=$9S-e)A;7^|UjqP381yR9tgRuUwjUBy{?kn<)v_%(t$7I(1orsB>Rcd2@L=#$m zulWWTRCbakC%@Vp@q1ifR`lFtTK^g)rKz_qa!v6{ZH5ewk-selMkhJU9n_dX$9IA+ zTI1pr@54t6!SwUk!d7l0q0MdI=iz8RDax=XDhrwBj`#?tew8gicO>J#{5jZ#i>z$h zCYIV&ks_QG_U62(6B4pGrcl+d|9-yy>%J5TU8Aj>w}lAFNQ-}|E&qAJjl~2(LI{;x z{&!`2=?p3iEWG9pN^67by=>i)e%`;RT5Hp(XgDHoA+iz`$&6Zc+GLPbISu;rsW>YC zgV{}%lN@A^vzMLL0V=rrVjwl2QPZ{E_6q!@AoO)66xu6DTX-ZC$TlT;&0m3C&wbOBJ#hkOO^Py-SZTvk=?vk%rNF#} z)4^LsMArAv3=>&_*UD_u`a+bk$f%q`;fH~ID8^#KmGi4lo1o10LU9wORU|ydt z3d4iWy+l{kU?T=Qdvv+=wV}j8w|C`)(FhExbh8c-eTe0)m~a^~c+eiDuR~+H^M?b`_$3AENhz`bEQh+@+EH{L4)x4BB8is^%Ll? zy?0Ti>@MRM48hf0DP2i~3Fk$~f+khGRqvw_$+zP^ypNWPb9s{MuMxc@RQq&?=d15- zZ)*|%uV0vg9zP<>KW+9ZEL12F-{f8x43Bc9bB4mG3c@Frj;ASGMw4#VL5WP(ky2F= zx|`DQ`~Ykd`8Oxs+R3jB7il$}7T9`d6<4ImGi*Ocx#G1h)3+X1vm(as^~@sVEor-b zo^A2dZmVTQoRO0~4!hoz61B zlk+LzlPrkPuHDS!5q0nlzgzwB2Eog-WK|9N_y@OPo>nEb@AOv%zN^r<>CE1vhxt?snt;OE!@-h%Ozrk4~2aM+xll1H}pC+Fc;rC z#7p?x!+}gd1FXg+jn*EdScF#=`q=&t?qgX_vgq~OVBz2V^`1tV#VH{3|B>r2qn6sk zhd}ZHTGR%h;==|@?G)bGO0mwc$9H6$e_mM-AF4bIfFJ6afp#oaWG95Q0gS4_@{BkY zWb5AKh4|8IErQYeBj8Ru0K@f6pk5S@@_TZp0KcvMdcL0vp^&GrOL)(4&A|4YZ~P4X zsk^glM22R8`|6z3WBlqlRMV=ui9PGX`2C@ucxAa1xg#HE+A{Yt`4f&JCM;o*8%(`P@EV%Ojq zDjO^gTvUnjP3P}W+Jl6O`^2hlP6Knt52Y> zx2%qEStyWODt#3Zg^H{TiN^-^6F62 zw)ZK^1Jf_~yas%K@~m;z4MY7EXKEU+TpF1f`a1I}SG*|;8(>uh2d7R>RpXb$c1hTX zQ4PdkH}TB@nDmo0ST5vIW1G>o0c?2^D#gpZs&9n~I=cI(RC|&rn|`JBEdP^p)S(EK z;PZdD4=1RuN8Oos4;EoMksT=`fU9aD=?3qP!2rI9Zk!@jG3%@k-hYl9bSjm$P^U+9e9;V zwO)86L(6YKTxjdC4#+g1!Ug_y9xrv_?+m4a1FUCqM-wOdp_=hpb?@p<3G}v1ZxHKY zJ*Nv+hJ$;sod17vUqpvtp{Hf6%5g?3U7vxn4HG0$WMaLw9g?^HrTg_Z8Cgc)0e}t$ zfgC{jQvl^O0rPW?*Ci~|m59b`zFU2p< zFVZ!E5z@?7G7UXAvIH zeR#2FF}U<}3^>vw-GAdQ9W>41cUs?tx2Rg-nHh|`s>HmP!8lwC3MHIl?vK{PT zFHd_=)As5`9a$mmP;H^R{*SkZD)hgPC?|DyZ#UOkvcn7Rsx_2H3hmxH;nM>(xz0Iv zvc?a~>*aVI^a!`|R|b?g)HeANp;U~NW3_4_ky<)z z546sM1!%#4DEUc$bhxK#FTU~uCI`ol#PD#A+|EJ@Z7A8_Ww&3BFc;KZ8cm4bu0v94 z;=159oX^IVv3|Kaa{4L8AnnX5C~Bu{#r8jF-tLUyIqyzWf*AL26 zZUQ^nIwWcHIcVVc0Uk#s@c}-QUi@ICo#C!ZQ~qIWglXsOo*vNEB>fci^M&uo2Lk1t zS2yR&pXq^!R3f$~3!J2<&gFQi{^KrlANunLS6${Fk+CLoXVppJ5_HCLJeD_rPY#Hz z46IVMrJhdEqvF`zYPV0{_8PA4RsJMTh3a?uQ&_z3Yh>SJpFG`-T!fYGVJjZqa#peE zahl}9p(D=mZV;=dS9711 zqy|btMAd-ocapftgM@kzo){jYX8TL$r^$_y_m;iK?nL#xI5#+Jq;BZ;Xnc|OX%57l z4|@#Zpf-gM&8+q|{OapUNe9!j-Ay{OyglHs0CBMY!dUWPY*P4;l*c zdv@c|%t@F`d5!7b673TDc<_cd*gMumJV0B z4wqCN)tm9-kvJJ`NZ}Kj@ESN?U9qYM$XB-*H84;9(Ir?}4A`^%wf1~UhIews3DbQ# zWJ>c_zpq2FkGGV~oKHIMZ2kXd?3=+iwt>Bn9tr2*uQ}wn+%T;PUG|S0RLz6jviE7! z318Ij@-Hfsesnt5=koMiie8?)O`35zfF_3L;L0?r_Qi&uhLr&^9J2ux z%t77a=@Ojz5P!fZnS>;GdkMR$KRF2WOdbE1o@uh)tCFgUnnUdx+7S(4Ic0Dl&|xEQ zqXwZ`z+})4q>}uA)a4EGPk;gzH;9Ch!TuotJBRZ~*e_fIYCbZ`*nQ?I_o#Ta%*QwA zd(wWru9tf4Nsw^I{{hSX?yJfJ1~-<05%b%`yNM6z$yOW;B4}4X+ukQmR)ZP7G@&`T zW7iUix%V=HRs*JUfLhePue}>^x|AToLugyLR3WvIa*H*@E!;)riX4}=vD{XD^Y9UK zG%FKTvC_chlcf7yxdEYUDyK|3_2o^0Z?iG`<9c(0#Cb{}6d&UJuNhgSrSyUeIWw)LbnHHM1{_58KRY~d# zeZg*k8V&WX_Mbp1rn^=LSbc;gXp~%>MePOhwN8t_$WPW)t6LA5`QFQ{8*o3hc%pL7 z<0}ujZIYqVT1UmEYWlK`vAd5y1V1qg_`Tw%a^;xS32fA3R@s z)yKdiMa!@G4Rjdu_EAFY^@(Y$i)al%%)-O-4ub#_KjNv%%SYJKcMyVr5*6{*l zi(vd2bbhWonswK=YU%8ids?8T9`RbnL`FH?}hCraLP) z1B$+By!ZB4pc7CJUA%hbddO5am13aqye~p+E%r_0yM7L>>ywof2MA}m)3VBipPCf? z_%RFW->*{>G#xa1Im%8hrMxk`-+PQT?~Ah8fi}tvNrEv#;9B2YxGp)RK_(5_2z1QA27Xw=;h|T1SS)g>QBvJQGV!n4m$f1+R-)^PIs5TU3v$BZ|| zvXYeoSu*|~s$12R&vs5dNCqvI%v5|u$;FuT-EH^GcBD5xxL4Khnj=Jy)_T8_J52gE zao@j5QmK0tqOhC90yQ|b)lK$HCH#612dG=?{QLC3?cX0w%^rMb_M1FtT12AAx0cX{ zt!*Ji?lV|M5gA_4g?HwNy~qo}9DqmXt^avDqJl;nKh6y3JHCJ z_MgJ3Udu%KwwZe7bP#)2bO$bKERX5lS^CU68`vB(H=mc?>#kX8TKMHq)G#+d_oW?2 z!Iji={3TwwIvtX?d-~>ESr6j)Zby}`c5ZOfI{CgTEk?=Y4|5OPS2ixjy*tVYC~aXnamuK5X=kVw zEO))(Rp6mi%ie_mxaDr#)%nX0-}M%2=e)5S_3H7A72O|Qww@tYnde~0xY^cYBHktU zr%Az;VBK%R#g?BwmuUBl*6YjqKv1q#+hK`KC&ZHaWP?-`>aA##$M`nyB_^l-+1R>L zU{Nsg?&W6u`w?6+#vgiF8;nY`BuOr@&twd9X(S1f1aI^wZ8snEn2SD2hSaCHUtPPY zu{36}}7<+1R^_0&f zdz69KEh{nxDd=@#jV6_WDs99Bxxa6pEammeE1X*OXi3PR4Nk-o&44BSdX>tw?AJ4X zZ_j;_(e>WXpFtQS>u$BrRL9$g90k2ogj@A-N)OeRvm`G#J9An9ev`* zcc1q5r%xU0GNQ%K3J(s~T^AB`((f)FM3QZZIhn|1&iRfw8MtamQA?epH<&N! z`zc@V3w2Qr&?$0!nwDu|y5bXB-%2~fSm?ae10OfP&Q@yxs^T)TdSL#81p}}1K z^z6>)@<6|}IZ;oTcq^|lQh-|8kTzmxi&W~T!A%}xqa^$C-OGm7<$=Bx9hAg@Ki&iH z`>*_<++}A>-^aADUVq}W%ee&=G4@ZILm7FF)sCMKt}Fa`N<}Bga{N=k7HZK+_ty%` zD)9X!$u#GyO3%cn!Fg{@J5={uu$XLG**f4v-F!O1l`XC$tL;#ofkRX&4r|W_ zTg&}LfQ&SYLwU<1(vv=F+0N(PD0k`fC;VU%u3^xXb6sSfXrkc)-{!WK=6nxVbL?uj z%s1&7Vt8wXY0ROzx0QlfKAxUO80H1Cd2Gpcj`;J|qBh6Hy(s=gs5e2Gb-_+<4iww= zeWq9E`(=mG{O}@undY%yVtP~In_`*1n4+(E6}1cZ@OQhl0+xl#MVr_+BsHn-i-u9Y zu>Kms8CR3h+Y?OdUD3KV`gn3L;@y0~s75>ayS;b(vS*z3b#S(p)Z5E6soe9a(<*u- zSPzK=e2~tdYk_+vKqlyL|FVh;wz?}IZtLB))vL0<&V==4AK*8Q031h$C=Dp!ya6KT zI!wV*l%?^9;Fjo!m)_=FBQ&M`-%PDl^0IZsAh&{EGe6Jby6*??bv+O%1Gd%KX*Mgp z-_SPGCIC@+0X$R>!`<2gMq}@+GC3n|0Qk$0PonqKC2;u+0>j=-&X;(@z{)ru3btHm z9A!^ckOE`4gR{J1@n>hY*p0(3LVa9VloO&&?I}F$;A)cK1b?TV< z&ePfi4D0<2L01>-T%L2R11|Jk-)?L`1&EH)`tzb9pu{YbMG}_*R8Mo-ypG4+nC{Md zE8ledNh9mknRfCP3#am_DBA^Gx8kQlIOoy2(!ys6ud05oLWcDqfx_-=|1G6-%MY-M z-xl5%WFepIw@j6=>JIPaLvk9@y6_NU`Yd*$z8>sKY08fJu~Erd;1?j5FJ4!cP$jO6 zo`eduRoWKqX6=y_Y(F$vwR(ZP{LC9qzC($zmq!^SRA`~lqjN$%gLPXW-D7f*x{K{d zkjKBf`=~rwqWv(Jl2WThfx8uc#$ObRs*Bzc*ebY#q|=xLo42rs`a;iI_NywHlKW4$1}smUEH|93r%}AB7D#5$43e{rY|^}2n9!N+y07K3Q_fv*#%z%J)QVb=->{%z7w zm5TkD8-kX7hJ*F&GJuPd7KRB#0<4kz^czshy-4a!MDtd;iA0vENe=S03Wby2&eaG3 zd(7(8$+NB{=7SpEa6$ov=nL-o#aidh@7Vhwuyp9dX|6`j63Eya)*w|)_B%ho?f68q z{QD>+$vxJv*0tb-2Tk!tQRTSL^owfDJ{2IEq}KwL=3?VkoKc9rt`|FeSLww6{^QM= zO5RkDH5%yB`xziEc>5mvuVxkfHJS(``sO0L#QUO_}U>7)|{`h+}36^f~ zcd*#Q@h2(2gj~}NC<7fO!0EEu19H<#nqQivyp{*aZ+%JwoXKfv!*y+|u_au01}Boy zkUEEuCkFD>j;qeKbYfqAN_Op*l;YEWe#gH_fWT)z zA>yxweSiC#0KiEjc(*gxjD0k+edYrAiSIfobM~f4XnBRJ<~`>y+Y~aKs$^T~jwJ->PLZV4 zDsZB2jONCiSo3_aA#+Z;6aJtrzaan}g7#T^epK1g+9LVhF0tu+;3Q_?Wq?a{qw*9;I)=}f!l+aa$N|x55EQ5(YKASq zpXK6RaE^|e%nOMvq+}3?0w=+DU=TV5H+(UGNeqmFP*Hv~?#?UD52VOSovC24-LWHe z->Y87&@#gR(`*0bxsheU072+OcEcV3NFnb_tHEU2`(HQYU;pxi%wO1r>0o|=LvmQ0 z_B&In#+&en|6zprhaMy#d_C$=|KaxIDqA?I9c2@3M$NY`^vyNJmeWtHtrJhoXAJ4B z7|=@tnwTbF!7W3$h{c2E_v``f8!+R?3{&FNg8V{VECw?cc_C~I`2o~hP}FfHu2TLYTH`WO!n4>1~~(1tn(!n033@w{H$yVKxuLZcNHEG@{v6L9>F zL2n~+iwk5VN@zEDSC@C$)c{*f6$k~phzm@#q+c&K?c_7Z^M2L{f)?Y= zg+bxIn*v=)a$oI^oviaHoxj-+T7wo4r2IB4hJL*#o&@c{Ahx`-x(oicz~k0yv5w8sTBo1j3!F*0O!zlLwIOdlm=Qy;yAW$7GW*qO-2M zPFwQ7r7x*Jxg$SKJFq`K`A#cfyTQc$o{0R(ziU`&GwOt*0Nuy`lkx&czHSHH!!pDP zm@NO8k`2Fkk9_b1{51}MhR7ILDW?^@fi|BH;H*PJ4C=iuP{}Ve7N>IMv*W%f<6>v@M4iArGZ|@ zB3x9jZYEq78bHpfv;cZ7ML;ip_UgNKT?UFmB`FNJJ{16fweJTz)eNva!L5@VfXj^< z+>j*=yibh%F|hwhFt1qSvH^zaA5!H>*jOcedBE9_AZ&A+iHYgBQ+d3M`7I**kcf=U5l@_AVjM@4%Yb3s~L2mr)g((t`m4V*`DwXyE4RGjPqF-tZ01}h$ zX^+4FIJ~8X6zkSdK?=ddi|y2g4?!baY}AawJq^6_e;DMGDTVGg94&E!7plk;DI z!|b4&tI*x$-bE;>A=Pz&z$5_p&?8RO-$CwGXj3dh55U5QZH?u7om7uO<__50uC!4l zF$^-kbtm?Wl$6;XX#9G~hA~JNK_(PUKT-A_9tgmvc%blFn{*=rJLA)YxaZnDBmXv=~64LNTXqupAP7EyV4TL7z}T@00UMq)+ddn3^7d z405@Lo1n~6OpH*K%wwnQj^{=D^DKD}mR7w1^F_PVJmL~K9Tme@dSbbP!3E3=I3P*n z#T96H0GsRPFgD<>p81{ZVbACBUoD?zKHuC@f2x$F{)8v4Y-7Iz$ZGzvO<4S$ zw0h4U6iu~2)K~=!5HwQ8aU|>us6*N)PVps8rHuNN4JFS99;bhwGLdURTRGM<8(Ky_XOg8;H!CNZZz-%-n$U?Od zJT&G&p#4g4etJa(Wtc7KZRL0tj|OB^N25L*B!1RgpV-+$%sNGRfyhOlCT;%{mVsOJ zh2M%PaeTr1;?rdyIFP@A{a!vE$e_Pgl-%?sjdxDDzi?Ev{d_f?9)yI+7a^wiGkQE4=_IFTK@=wTQ0 z#XI0+nY)flXEe5HHhjRTW;i{-?o)!r1*Ur&+kD6t!>p^AAbkO3>mjW)yD| z$jTr$GJT;uwL43gTLjKr+J+(IDO38!rV(1DrGN=HHQ;>ZcZKlVD;s(4L?XE_yK|4S z7mz=EcN&IY?t%Kimnd88Rj0P7AIN^S2SzoIP}^fKrt_Y=fH-cSXz8aBo3%W4s&sU= z;-!D$^Tn_X-0_`SQ-rwFlec`4y~ioyOCij&fR2)K`R;wJcc)cn@qc(_Mq|YO5+uB* z6Ee4z*~51VafvPBu`aw*=li|=!?Acj5^qM^XSSh;ejD#jktOznkJaXeB!1u*7Leh< zcGqO=x4#4rSk%Tgoe>h9!vs#pJJ;B~GBu=1($U%VvcQs=Q3xDpQ0*t^B?? zg2>!v`gy6bRNRQw;^F8fdAD;RMm{G>Bx* zWh~?bt)?&IbHt4gekJfsb4RskXulv>(Ev9_V><4-!V(DT`IQ!^yIi1OTR{t+*n$$+ zS0dNKpw5LH?R9_<4R(qI|o2HF$Uf?b0aAE+V`pnr)M-2qv?j^K(Y)GaddCn>fHLITly zEwHSEEU%6;nkA5Aws!2WJ5z~RJe@G~o2z_LRSCxMzE^-*w5PB+T?u}+79{zN1y52Q zzw!V~whQ&EH`|J+U2K1#=sPQ_GCzIQq$B6S1wO=-Z4j$~4sWso-8V33rC3dsWP1U5 zFFJW>7R>nHJ{wW)Gb;SH<`f(AT>Lk9XSq(+0f4#7#Xa<*8lvS#DLWwuk_u#8^`juq zR=+6oJe7eeJJ*GBe)&`hJ_Ac$3&2g7hZT1+93fKopaAwZ1a0)Z-f}rCXNlt7b-3LL zB*Z#P`(fhqnk8vW0 zzAzhZ;Zk9vJf=Y^Yji}a=I#zaXHGzL#Us3s;yV4i{`{~G@&Y{OFnU2?LC9&UN)@`D zwmTsNQa766dvi0599go}<}F%Xx)a&2=UczzGL~0^%y&J&NCV+zw%eF6FgiODoCM5~ zMF=>)H*G=w(Iz^1tP%hsssWv~8n~;gL4XcY&HMNa37nQypnc;7>zgp-_<9jIp=v`CNX8f)vTniJuWwnD)cR4jGCj1mu??AsCFk{_ZMV5 zH)Yp{!pXlWeL2{g^p;LjSyxi$jR6N+4^*&*-Gz6{EDYRELoP62v&(?yuCB6^zzeQ< zm!XZ*QI8M-m!2QMk3L2j)BYzP)-`{50_zw8{Y%oIaT_)_>^}@ul1~e@E&Bki{UQ2>PTRZwQdpoqn4H;Vz44 z2A_r3g!RFck{+JEU?msJuhsFF0OqwDDS7yW5kgXk8dM;00bxNm^0gKJRT~-QQ*o?Z zMNQ@1tv>BjH@Z00Nm{L{Hx>5ZhzbbBGkxrgtgA#uow3=(rrO)16#c&Z7a*eC34bgxxr@<}* zh|hCtr_{2(lgFy(`mw8a>1qwASq-c$_d^! zKAropT_GbW4N={#G!+QkEa0`GKKU(b99+opPg zyCjIcNcp^y{6?MyVe<_C5MSL^fvSY`bYXe%#W=O{|LTRoAEA=|<(2h`D>wHoyBlR? zG;^8PU9hLPdu{I`_N%JkMN#{wMFrCVyDdJynE#Us_TS#3JJ%UQqLSL@+*^!1D+X)J z!z+r5>fsisg8H#wWx{UV8GA-hiT&?h{7HJ3d0^$Y#nx-J^4nBO12>H-g%^jBW!cYf zPS{mQyAWZ6cRU(+8=*UE18IgBncx8h0gpP?Es>ls03sZImLgu>7LAy)V07yjTmSkq z=RbOU{P~C${Qj*I^1S7FWk`LcTl&5hZrQ^}R5(6v_e0v|>dMpn#&xU;bAQlC&VcSB z|CSu6==8v#C~V9ok`~On?Sac|e_+D?93-i7_@9ynxcsLd>7B>vU&da$gL@_$dqUG9 z^cdgm^BNRDnq!@|OZ zQC5o>|LKM5AHf|>+@@*5>jX3g2ZF0tdv1+KymwglSO$V}k{!dWMt(d)KKda^0%b}BgRQyW#=u$Y z45o^%AT#8r{e;V`LuWO3=2e$lO~my~>Qvh99#0Son*%cJ=PO@yUhS&ubcBb#1LB2c zqoHR92z8NMN0ijT!W~gY!po7%((8Szj^7cQ!zd?>Ig6nKTb}+;6o2!kPPfO{ycfz{ z1u?r7M$)=1?BsQPEF4pdu~WftV>0+cXK-%Xe2Z6SKo|q>rKG>A%*CkB>0&Sh1js

zwd2Q;*bBR75P;c4-!W?`LP&w5r){@>p0pfKIz$u3t z!wIGE=F-4;najEIHEP7|)xfRl_=*+etjL;6`i;J>UiGA!4t)Q4L*U@B7N+ zASgC~!13vyn%2tl8A5Sx9{K%wZ(p$}cYurKZygOq4p_s2ZQITS#lgnzV}WWYE=;3M5Px^g6^%e#eDkJ7fKdsK z@jqDrma_ndW(#&un~a2?tk_^h2HRi+SK7)sS^@@xwhK-EuRD*o4X>tgp_J}^U^6fa z0`b;BaMy-`@0r;|)>Dd{K@%|CC_VXXnSeCLf@eCYu`dNW~q@!R5p z5&DR)E&GY|7hs3t@TZv@@Vku36@yc%q?e_6W$-eO_>^UPHxlfY=c2fFePTA9=0_(q zrjMNb;$B5A|J@r^I8N3x$US9He#aQpyIYIadld{gf*J-XQ?#W}X zYm0ct!O&(2A05->s|Id2E<`gBxXq*h|K6%^>N6LN`j_cbFrikTGIX&T0hqO#x$5mw zfSS6O{*hQo00rwa z!;c2?>35=|WPhOET3z|BnpTOG^&N}Z5t~NQG9xUK30(fxMF1Wllutng!(9gPe|>9S zeuv7lGQEboH5?Cf*Vw1Z_9B-DLSkiQ>|w*cIT2|J9`;F(n=lT*QqmcGa24ogX$C|z zC!0HhtU6;k+;VX7*MRDax@-VsVReDOXf?$9#~T!`nyMV30HmVC(f6$SE+J2rrMA=|Y^UEI;*38RBP=;u-NMXxtb7 z^}GkH2gjgZi5>>5*70r1h(zR|j}s6^qNH2=Y#T3J{di9RvU#(RbcGYiVC8DX4fpAx zLQV*&CW{XFL2b^5E@QkKomHgQ%bhlA8o}Jk&-Y?1pz6!<5n&Yfip%T9>W&Nxo=@XMiL5M{bDcRWx`fp3ZCxIv zU7fxQTn8xI4Rja8(|CS(tSn8y4zT6htS#Zjcil3n3oeirz}CCs$gH$kbBskAqzh+HORhri3m45Z_+9}CW{`}&Q5Y0Hp~WRXr>)|!o|+d*JV^2 z>(Wi(!yj8xYu4fNK?Z|req679?PpBt@&4R68j5Ned?oI-w{yiLcPtKMQLCWrNo~k+ zNxJ|6hi;ysElOWt@zZPKg>#)q+ex>j%EpU=+LLLkIrLD}5ZQG+#ddc70LO`U#UJ}+d5=7CBD4_HXDowg{ zNP?(QwAfO^dYUmMA1B5Ds7DB$U+;^XS?m6douKM{`9^oO$T64`g z#@og>iX$6$nypYenHm)kIvkP(vj;okfz^creNe^}1C_v#y+ISr9uCM7i%4Y6Qc*N> zirKb(#a7emY5?ZO<06b3Rk6)y6^^RPimlnDo6G%*&3PKGdgWx8S_gMq1i@fNdvN)G zuU3N3?$sT7>h?{^bDxuoY>~w03)R=MIt!O2OI6AD;WDUA3w(@xSL}_~vpIL;shuG# z=!%2G_vOHx0`xBK@tr}79|kc ziv+0pDt|hBthUuNttXfJxiIJl(DS#_FVq3l%)Y&WCu@d5rGVJYALYk^VY%~B%POxQ zNdPTsgZEHV_1$fEE^L$2=-6Aik zIevTMsk`bB{Q(7Q)k(kLc(1Njs%ut~MyccT6p4z6t??bntzFpQ16(oNv#nvXG)z!d zC;P)_!W*qi1O+iZBsw_N%K1Hg)+D##H(}diihQ--o+=%;{pD8EAI@#Hq|}hvqi##S z-C#0_SE&*TXco!);hLz`Y2!=-RzJkV{&U5W0w8M?10uZhPj34Q}TpH@16m zb=VoUWPU*yv_9kdw#*B%any8!I*PVM+k-B?N$ApE;X{;+g5-rg3%B*l#m;~Yxd*8z z5Xy}91x(83pt_HCI+^MGfBi?5vO9JZeSg3AhDEnk&RaY4_DYQg|@}l$K10a*u&^xnq;;8>cf$vrwK5 zK`{2loewP)eD7-H-Rt_$d3ge2IOn&y7G!UDW>?(Fu7jRNUK7!=xdsk(-=zfIIhwil(Jvqc^68WbQl%%!2si{8KiomU zJ-l~->K#g7PaU{dM=vW`U;o%mM&6j_A^WCIE-LDtp0(jpNtFH;ZL05yYP*}W!fTft zTV6ik$Akt2tIa+d^9~vFmKI>COdG8^l2mMI-drc6M1rvbIE>IP$|d>e$8l4TMPt zJ3n^oin}t+A11aRH||G~-GiPJBu13YDM#*2u=X`4;~EFv2UzW$SNT>xt~F(zPuWAG zJDyiypsBNx&2$0W3lD}z?LP13JWJU(K+2`Tq>maAzfZv)shZU9g-yz1s%2n(ht-uv zPm_VUI;*9A{Oskp=~cfkFhG z%e8f#c1ki%VKJ?iHhu#230cv7%%nqQ;SG6q_1nqW+Xf5>+lmBu^p&}T4PcTsBWfn1 z(pxapC|=!5oF3 z>^l1|um?>Z^NFvmoKUjFmyWGgok~3$8##8KY__G6m}v4v!1#>m6%qWn-@^n2qtb6m zZrP01@-!9^nKXK+tR}tV6SL_gNh6QxG;^{3H#E#)nC^oqI}@)1c&k!!BMnV>Xrt8epI zyV?^`w&j!-TFnkOuf@W63OkWgbFR_q>``FI%RFJ?!lKpPa}xKNYIbSZXA$}-{VX{aLRz?+ykzqqw+?OQtt~<-l?mT zx3wA^smrn}lG4wu@HJuOeJs9K_MN93o926EA9>9kiv5>vNgv5}?$k*h`bjMqOZ~vq z+33Z>g}dj4dD@X|Uei&cS-wLb(&wQE7C~!wvCCn<{w(TZ|XoUhNAq+g-toTX_ zSvzp7Ysp+I@ZB+7W1@9_c)9yCCfwoTGqcCC2c|?KYV>cOzd1E5do$9Unqk89CJf3P zI&m8X6VIbgX5_gtWcXjUfP%(Jxhrj3W|fd+W^be>*=jz)D9A*O_8KbF_Z)%iFeM|I z9P2aoN|M*x!gkyBduNA*RCWGw<6h9MUIdh8pJB5`V81zK6#2e)S}GLyYT^ z@6)BkJ4?fLASmhPuxXrP1R5BUsn-3mA{ki1ay6xL$KXGBgF8PuuQGNyb$z%)eZmKG zgwKub;8=T#RUltz)dfZOOFu52{g$5fa=5|?z%n{V;o!vGCzQ{`Sc$uD4HXhdZXOwi zagq|P)ndAeV1%TC6z#l-+iJ%+o*NP$(e9trjPmTxc73t`Z1BtvfDLWgm(XDipu|*w zbn+rDlF(@D1wB?h={_^GpX>IpVM#cgJV z&O_)b8PZ%OZ5BSdmS_{OXPhh7xt@V|lgTU|i-sy8nO?wXwleYq1E>XTNI za!YwyoNPbkhw+R13xZaw-uQ8 z)R5k8$lGOWM!hp_D+Rs=50Gej#JnQ`DA-^(7O&`$SPC*2Z2Q?@)%ukg^b3F+HrvcW z8&3=S^j;~nXkZ)?hl?&-r5^g$VGpDns1GmsGO~yZhf1!okg64t_mrjCEki+dahsVG zji)G&tB78sG7*Yvoc}mOzKb=|k;g_A1VU?&r)zRvQCy`b(oq*6z{Lw{ZRC*b4j6O$_HN<5P(qO#o7-%$mRM?;JG>yOvMK0Z5-G?$Gj$gOaX{*~3gSE5;!c<#EaWF8^8q;)v{bd1XQkqQf z0dQkcOCeya8)}aw4PFcKU4^bWp2^}aKTivzLXW*2Mv+r{%Vqj&Z0Pk&|IePZ+W6G6 zVduq=MGTJzt(_T3>;k5Bm=n7QIHETm^X(P`isyxWa(AEKht+WfAAQRIp2E8+`5|^9 z4M2^+Ol8n=ERL&`S0t0QHcL%{tQtZ826WxFdPN!ttr zUN#W#Knbr3c;Ea^!$dYRj0^fNW%L*f7B2#YR0-rsFZje57*X&D=5lax1_m0J!W6q6 zt4Hb|c+ArWm3R%D3mMMrb;9x=4)PJUT^4XRGS)-$s0RT|mJK5irS)D~%4|Avx#ewG zYwcZOx_vd3Zk+<1vMUUQnW1(*JGO#j0xGs@pD1H-Nhva1pbuSxPZ2(wawPlr8+kQR zibXrbq41+IKFGBs(_2R>#p9K{zsX!2v|Y(9#q2qFwip_cMSB97=Nb9Uv+-DxaARWB z9t7c-))H-G7iL9?Px}uC=GIA<&QbDV>;{gw^qV?q;LJDnd3uWKIG{+w0L1Al_>P(T zA{sLfJ65VLKCp@LUOsX!>vUj3@~p;--}kar9`?>gH|SY1Q@rbrB&hc&v<3N>9DJ4~ z^kiE7`g?96bL&)Qo%#|=f`NUp{0ZIf5z6gRw@iTaFnJv!rQ|3dlUXrH_C$(-RF01# zNGNX~%?tifG}m#ZxQVsJ^Ern@me0L;Q=L?$-?+LQ!4$L>GSgC+!j=glNhzS_86KF? z=z4V*a6Ch6gp!ASGVwddJ}AEHMf4{SHP>I*-T4oSUhTz3Cvs&d&wIMwwE4MZFpMi? z8^4~P(Lg68b;=f=n*)gyZN^4E%a>TB z!EB*a6H=DLDwo!f6yVx%I|9yCgfFzTo8~*%7-;7WOMFVm5W57EA2~e-S7sf`vHM_n zNN4vvD4*L84rVVUY2?+x6vG~n5j2G5Fb`M4R7Ls`QV!^VJCR1SR~;W^7fVcJG#6z# zuwb&w6`(NOFK0}Nk7NZNqrcijPsF}zBAM?Sn$)aKnzAizI5W^_|B?|Avrlzgj%q%T zRoLTWCYsq&$BRtQQeK>>x_xwSxn+6xvjJw)wGrf;k%o3KNX8*TMtz|yeIQ+Rx5u0QNmcD#iIqjIw@jzzf~p-X+NE4 z(w~PN3n68@$Mppivcwi~`JM?qyrAle>4%SZXe;*JxDduT`hYT?H$f7g#@elrAWZJZ z_9DG0jT^wxY`~Fvx`B9D9B=xra!`>O?@WF7gF8%nK37*V*F)3;*WEJ?VwP2Ux-FGs zh50sNtW4>Wd{Pp#v%!1u&DsIB&4KAlt7;U{!yDSSa>0zR+0=76C7HH>UZdV91K5bx zDz*zWQd2fN_s8#i_>3$&XQMgOy87Y|394O=f^PLFVZr*Vs!(m`h>tT7Aenqs_a^BZ z>ga0yHQ&XYFx#Z6!?#GCFZ|d)Xi#CziY4Kf8#rjYJ?o;Icj+DqW)3(BWXdS1|4j@3 zr&rb&dG(dP#+GSECmfcSUgM@3vU~1O#m?+7mk~wY%35aD#jG2QzZmtLdES-`BMJg+ zQ!Zsi*L#Nac~{4J1t#*Xg-5l3VS$M>Y+;t1%5&2TUiDj6z89ZBc1POM z4AFKpbl~TN1}}5ChG1uBc)Vr~zODLH+GbE$l2g8LTS73Ih;3X}#&lwt{C2e>a-S~D zw1??{MQ?=U{J(vEI-;b6phK-p&jk?bx24pF5fl~S*!7VP(v$^)}kYDyd2 z`j`%4y_F2X08j>mT=Z@`>D_o0+fLx_9aSvg*Lty#v+hrFx`TVTEsFt%#pCjgfj{?! z)s_X<8$WB`GRBk3I3Q{Bw&&VNp}xBXy01_~c-4r3&?vRj(XrM^6_VhnsGs~2e-k77 zbtN|tHk5LAWCe4#NG)auWMzouxIXtbTM&7Tme<4R8jSe0ngy8}q5nt9^q-c`|NVb! zxG}p>apgU2AQ(6{3p$MxubJk3-VB{ulR?$7t*wsaco`M`UncaQUgduyk-2kSrIRvW zW><++)(YO7E}tTO(KaHJ!1lQl((96jHH}C(&iqk4{@>~ibw1>~cY(WkGmZa!0Vii}6FMX8TBgzX(Xr&Il6GZJFn1A^T~hpsJAvH5oRl@mkH(R=SB=|^;;S&z?U6=lTK6pE zU(C-(jlw5z+T4~4icJB%Pf-5*LcenhrWfR97-U2O3;qO|zfAOu|p|kLWGu9KNC};6*FkmV>h_f^_I`|$S@I~)I(<^MG1`y4roXh)Q)N7q^h)Wids3VM(6?j_?uKf|n@y=#i?nESG7&Ef4T@d5NVLjK}XK3sev^`IDKVGM)VB z9l8%lZn0}ka&3kKk0AI!q6RPL!ui~IkXrY9?yJ(}wY1nyQ0M!N>MzkbHVsq)7&wzr#h5>o=NV3EB-;0FWlJ7uAzM4wHvNqK}yfX#u`jwKeu%Q=&JFOUIvnZSF@OZNk zx7er+=Zgs^YS~QtMSfo(@K5;)b-8|g7Oba?-5DZ@^!BlUk|27BZfF{}cQFyij@4Df zqxDD!P^JrA8WQ!#@v+(7uw^6N?+}FnbCW$m^nC?3QnE5E)#fk;VG{;SJiTY1wCPAw z&(wYiT$;cP9ZNBsHWI#Mt=%R~TdG32SYU%-aQfK&-yxjVJ2&Joe z1#LQ70*A+Fd))$P_)9B-);RFO7ny~FvmaOURF5f`g=d~3(yZUx8wl~!1F z%ACpb(XDWz>f*k)Eb&CZ3*TtQD7cMPU4V*%x%(vdO@k7zq5k7_GZ)U>S~2xfw*m)n z(-@2$_tjCEJTdZsM<0%zmepQzvSrtvfZrg6zo16s*+i^*C5TIo1)-LpcF2xAS1r>G2yaZU_F-+gUO??J6Be?I$d))n-gKVmYC*FCwg7`K zS;BJ`=|lIk#09+C3J^;eN^$4x;ov*Whu0!`Mrs7Gs{+T0Qy-BR!X@Mzz#~@NR~42T zbl}|p5Xq%_gH!~+_yk@hZM1yrJ2u$yykJK$QfhZ~Nz#fxHB0%2KB6b!m^DBEi2iO2 zHcB15nawz2mKyW`o z9aTMGfU|EDA4tQovI3!=jK@V?3c$Tsij`}e_rz+?e7GfhE8t5>uQMg^u7ac{#c5U} zn{JWK$oDi0DjqxK%vvFLh+&xL?!nEW^b{d_6 zxRNp=dLD9 z(a!%`Kn90PeHS2Xx8CQJwd`hyT$Z%!wpFx(>`iSUwJ*`^6SujCyyKV~3v(BWur|t2 zmRL9QB12kn8#eDhGa(x)u{ERPrre4NQO;cLwe{|)@jma{@iPZ2GEM5_HjnIyQ0ce# zR|Rj>;+f&b6=8ha`i~Fubb;uA?2CxN@ZR(ML378IRCcJI?3&+^x{U@Kg0ZNf!P`6l zVo6#d1yzS3tAq#{0q95mFAibaEx*P%E`bTqC(1G)jsKyO&lkk*+apQyu!L2$v@i7c zi#0`v@da@YjiqN~pPoIir(RLYzTDxNC*w=lndLspx_P65>2E!uu+qk-WgFR?tMARA ze0Y!GE7M781bhWv(_5T@l*2%q~x8N5RlGk{4idEE0=u!F?Y;^dr~eohwFWG zcuj1b+*rGMJUZL0RUn-ML)1Uwk?e+pKH~MgghOT5k_F#1&~cJ&CY5(z;l@A!JUx3m zK_p;u00;@D3`PZYv zudDq{tAB_7dFg7J6Z8Hp=2rruJv zSK?}FV#P3QDnnaNj)R<(aGOYV@Mcs6S|6uRY z$v5kQqEF=AVAx&-I!T&+B}APT@a~x4;aj~2{;C3S@iOnu49jO@Sd`BNfP>FPZ9<%o z_Cd$gcYlR4rIft*MFuDDAQC%`yEFC26$@K-x3{R2dC^BUlv_U6SFQUNl|goXW`E6+ ztFatU@qX%=O+jlaWBGy5Tg_2zE#Ge?plZf6Z=UrAweixvb;VBPrQfdE-P1Y$%|H8b ztI6!mrSxk{khQWlU_Yb!(O6F@D|$|@_Y6g6R4ZU9dDcvtu$NvZZ+Z7<_GeIn2^A;! zY7&|k>b%i4cMZHlw!3YjoVlo5ZgGl4AtY-ng|Q7btIjg1Tu1E6-#n+^e^Nw$j6;7> zmbTD&h-`~pHNqCI2Pvb2-nnHGg_$shZaCc_s+dMzgx53`m*esQ!E2zMCDwyR2kq@hxmiaoc$vqJh>EoJt+G)HX+)+h1oV3 zhrOuSYPs7C`(mMyo|%{n4X^k@+?QF@5<(bb}{ZZt_l=#C0*6&!|$|ta;EjX1v>6%3{3296d)@xNjCA9m?H!pLr^Gp9UxrQ~Ei!%~-#O2a_gk4qEE=s!KT z_<_DSufpkX$SA=y*kf-D6qLM@%u^k2aYIp~87PT5#nE=>0jayGOkruQsm1rTnlg@q z^C{%@&lAa4H-`3A<=$YVY%}u1WHZ%g$oo`?p8mO_{{~9^Cy1vI6`VNnP$2T$+A0Qk z6E~#Ibd`i`GD!7qb&C9gm{$k{AP;6!a07s5WRi14v%;92FQD|E6Q{y@XMl~f}kN_ z-v^<#P-^rCfv>G2)g9s2IGPbi7ZdQppP{#W5iExFl)V`nO1K`21I2ZAb$OP{3uL=VuKPsL3YH<-_wIMzDWe%bDJUWXyme7MV^8E`rD_WxBWk;wpd5Gy@ z-$$6IBYHUwg$)RpapygT_@&_~d40LQvKRQz^Az66C3YHJ?6liJ9`(qSQd5T_zU&=&G<`|Y07@$p7OwR8#f-d&Lz{k*Q-{t1Q5Jl2F z_3e8}>(u5YWa(#ft1wy|lFx>!SlB2uq)o0=T%7fNf=24OsXxf1+_hrqG94)EbUn=xbV4= zRvyy_wND5tikTYyC_;HmQ31TQz7S%#FplSnLC`(9gUs1_rMIAy0jVCyEAaeQm`ROW ztm85iF=v!b86TAGvwDa7)zpv9!vUcoGTR+y{GPC9X*8L-Z#voZ({=!?M>RoeT3GP6q0QWf*T zA2S-syDi5{>)+hRSIKyoFS4r1r6gRT)UF(KAh!_ifc2JER?5iT7WX=)`2|92HtCrRZG|!i{Aiqa6 z1S|?dgYvj zvLz1Ho-yM@gZ>hn`_q;IChEs3q;ka`XD|%z>uJbnPBW-(N#^Ne7ujPZ z5N>1N7DMokcAt@iZL#Phb0ZV9ud*d=UwvFAln_Wc&k@?^g+92|Dw1*Nc9SIYaJc1g zW8RrQ54Og7)fKDwcaCs|L+tXGIw-iWrG+M+M^PVx0?WZ!aZ5XvQG7u=g^e4Ee$F~C zlkO1eSI*}JijNWysH!vu&F)hTLgDq~L8GH}L)h{0XxW1o+Z154xD&s(``pdR$W;5n zy0>s-bPo~CM*y(<4vzvS?34ao7d^Tqr8nyHyy0+z=Z>OcG||(1XXy`-Pu3D!{?H)` z3z_p*4GEeOQ%pJvuj%tVPM;p_qRKFJw%#C~R<5}=33S^`7fLx4XaM~)!2=QKUiNe< znz_3kL9a$Zx9!@LtcjXdyzu7c^fNiuHwi*nzx}u&8{|DBD-woQtu;a-69KZC-<0K6 zGPIwFSY`l=n!6te!Uc0cZ7rZygDP`iG4s7(3z|LMYyssn+j$};%~&==LM8KAYPel) zghYy}bf*!Z$QIy9DGQl7*qz|!KlnT>&W>)h)o>>gGa$iW+6q?z_V>l!2M2S+x*-;I zq<-4ul6cH>6WPaZBUeDz?8jTbNCV>)dQT7{dOmc-shGXg(LNwPJ~b5hvI(Z2aL!+~ z01Z?>~wxjqhcq+}9#@af7h$nS5zH|*_Js`1;bb9sM0*eL^2q*@Yj^NjH`uqoYp(!m?s4gV>A>jF2(_Hgmph!926I4Q}4%L`!>I&{8Cc% zJ0WRvxlQueUrBYmV2gz{A-#SEFep4zOV(iql(xGy5k<#<)zSc_VGu>^bh7eq^% zx}m`CMyq6v;St$sa3e)gJp#TCZ54@46Z1<=oO-ug^W2~TTfB@g0F$Wc_9|4qDFSIB z9Te8=&w<$O$MauG?;xYh>dOf3SqnxtnU%aUUWklbRBtYh1NyXXP`3$<9v&#HtSON$ z|9C&mK~l3OjfMuIqLA}}nRp!+A(p9n9FNT z$)<1Uz8jmvg()H@Ui3a z+6@_qutkDMa^>nU1JVonws36O#dvkD@tSiIOuK&l5Efy4fx)$?@ zs@bIXS|T%Blv|FuS<1#;6+GKxMFZ#210+=D#On>8M6{E3+xx(!^Y4TO%qB-yGi|0?$Yb1@R|Wv+0qiC=NW zev=nw-|mShf?LPhb{|sQ9`=fcesWPQpQ?RwhS})j{Ns1+f3XnG;x>m+ zarLTvHDTs4qj@$}p$(mv=i%(1Q^O!cJw%KqOqiLb3@K5gaN5sVcw6pwAo2?%FTbJ%3 z)OK?smM7J$y{B^0G?ZZ@T+fF4#+@n7H zBXL~+ZRh=e!4TFA0VoeNtoxYvkN0`=zA(Gr6Dep8$D1*48{(@kM4NX`rUNDFQkNt3 z%UYj5o*D15gg>7n9P#gprN)lF0txVmwUsR7vJ3T9e^g5TuXjIK*BX>9ZF63wm2^gs zF&$@2-aRr_HJH|_pT&Y1^6dMjI_&W<=91s&EEC-}Ka+NmeYmxfs8e9@8+XOvvSm9n zG*2AU4{B8M&e3L_XQu}V(4-5VD19VaaR&T>X~B|%s5PbY`#Ar3^{d$hUneR4Hl;oD zpMU<3b)@^6ySIHPr0k^eJ3F7*I?+N+k<4wK3e(T47xXPHT1|DRoT7&@A5<-znPK{0 zLT|D{ElfGP<@nhF+>C-#*cA2gu4F@rls2|UT0?7;l}-VFgbx0yz<+}k|Mgn^^K#${ zPCWhOydzWecF2H>nO6OnE3?p5mJ~Lc>5}hSzKOZ*bm5XSzJ3D@Jj;CLx}cW&B6SF+ z83gf=!oEnI<^1y&pewd~Dd^1ey+ih%Tb`oW+-T>eAWZe*v>(dg1103#?)zu2j=RW< z%c(lg_@ z&B&hBYTu6M?sW;8o;yQd)0OvAfpUL;iyUtxS}yw91Z;Y@R<-7Zd>VZUSBcq{Dk2J+ z!*(3wHk9@M$FltU&;74g`*k%XUS&7Fhq_O8n`|8jB~spMXVW z^D7(f_ESC(?5FpunQ9vZUMBPKlDkSGd8)#Kz08OyoiD{EzGo)`Pw#WQI)amKsez?J z1t$VV+Xdah9vJGeKJHaxp=dkHm3fPGCZZ}r6r~`>`CamNRBp(T+o$m`E zf}`WZ1ay2;JtF{)4Rz3~a0RZf=W@`kVe}^XUtY&A;cZDLY%d%7O7Ns+T;d@}{DD~W zS@IDo-)@@0C=w)Jv$^Cn5*NnZUo`=OVr*3lx)0T=LCiulg}=7Pf<6CsrQIzMHegL_ zu1&xZS90uf3ZPkIV1)5OY$JhRo){r~!g$pP>`cCp6m|vl!}nH=WQk@_3*(z-elnZ) z@5R4M97zjXWBJ-!psR3&bKy*pH3s0q9{keufmsO0DMA!(23e-9lfFkOQ}W}@baH$>r>pUrS#-5zIMcFjFP!w3g?EbX%#gP!{r?krSFyTZ;2KdE z^LQWM3wxk`N*!Vy$c41dQBKl3DZorFWr3GuF z+ES%7b|7(j#IcBwj9=Wq-tNn(hdx!Wcv>_LhjGBhwk!m=lzR2Zchcqa=ljB#XhQ1=pU&Q0 z;Kq(%!_&f2Y8mbHQy_m9{nf`|ukO3m9V>Xhe3Xhy2+t`!od&#L-q~T%EN6$gD*3dN zX(BxDB-*kcUVf0Sr;9R3U4I_U)5h?tKa3AAa&ySa_PVFvwQjk;$kX&`kz_-?TH~!K zCHaEnzgNc3z1kI=`2LAR;M)52R{Hw+Hj#5%g?UpYx?LGIc||4eWIT4MmZ!h~ZTLz= z*}fqNn+JussVB6SIS;<42~pn8y6>s-RQl(uAy%-AgMQKCwWEl8jXTF^g#Q60^+FKp z&iY`*w2`Q~6!mTFDEVbA-*rrH8^zc>*Ls25-mn-G= z5^nJnb-L4?ib_UdRtng4&JcH%N=X&%bnVI&yh=>r{_GM|ls{mQigM|Sv@!eTqVtcn znQ4!C;t7X%La0&|uHOG134FpnjG8?$=WfOruAS*R|LZmsu3&*D=Y=IIJGxwYu1yMh zMMz)Q##~i}EaHV`53nOCpY-l@qw`X#ei2w%#)amYQ(L6+WSqJ_(YbyFqtI1rPyVt} z7nnP5gEixHy44yF_C$83G6#?9>wLeXzi^7|+AvolCZNVSB%ns4Tw}a$@KUtc?Tu6j zosMQ2!LtxO{szILk%%VHDu0XzKLe5x_v*z^ipa&+a}LXT8*E0KR=@hy+N5GtFuw#4 zX0F=$$NT&wkPJ{uqjDTVO9jEml70{)d_{1;CE&G+VJr#Dq(6&%XwmicY!Nn{x64%c zm%*6im0$Hwhcq<@`|(yf75c(kgN4k692v85>D>Xm7pRnd6neZK3Bw3W#0HIdX2C?f zH!EgrMAP|aL%P0;7ymvGf9BO6Yd;)39nwlWf4}F?_VMC-!8<_+DF$D+``js!FEMbe z@ejdtCl&~LEuetGY(zc=p-wFD~ zf~C+W*!JL6h1=&}PcgwhgZ*9L=J@kZB_PpT)$VfAmp3OiPMsmH6)8DchlKB)#iLZQ zWnAAtndz9TTh2^bF#@N1qYLfm{{yV~58w7*&d;1_ZqTH6PitcV=ig>~nJFJDAAVt*Ett>6$n(Yt+~ z*zdTi^Ho&#Lg(o%>W(YYc!iD~-!pLa_BF33cX{8b>uN^X2y$x*S%h4Ae*2k)!`kz{ zA?l9a1E=4d5HnfxF!Hx+jy9*|&uzNVzWw2c59I@_glOL@UuX75ztMC*Oj%9G(dL)p z0~4Zmuf$Sv7OQ<#6DHyn38TYxK9S#i};l};>HWe&sW@}~_ z_v8b%w*fxpC*D-;>i1v1Q4#h)0gd03F*G|kH*nYCa2yPV@vWL{#Q`1NnKCdikYRS_ z?Ad}*6;)DO_WF~1Sn1^9?S$OdOWTb$!)5!acdUpMFs>&NR`s7cb&7VkxtF{{r5=0# zrSLt9Pb3({i?Zlnzji92rD0x4p{L}G79F8X@>Ef~ z)16#HhE}hbIBI#Z;N&md&-1(TMpMJe=Rc}pw0DSzu+Qq-y9!uRJ_T4*?YxiOc8Ack zj%+l6Z8x6rrnl{o`|&O4f1VfTZNajWFQQ#UO(P?)qDn1CfGuKoR905fq9Pa2&d0~u zDvl(_1+DTgN@GL~Bbjm8t)jC67siRl=5ft+a=$~!G8NKS%w(96hGU+NeDV^r5`oiQ zq!;sxQ$Yokg?Bo>1qJ*+yfOau6t{2UvZ~!Z!g`UKN_U+uq(-=k9=>?x3e0R4x-~N| zKe%&pp0C_j3w>?GBr<92oSRu}uA691e%5B3RRT@Bj%`>uAz?B<)|Sz?AQ$iYeY^je z$N3XZ=q(@1Q-QbYKt+4jmO?ig?y`r+WHpY=q5naRXjwZ^oZZ<-?%Y{ul_}CzUh?%M z!;_O|&*{m&E3~a7Oq*-v7^wmgu%h13`g({nEC1;N$e*m>T$HbAl#bP_80XBg=wInm z?4|iRpOv9&=SoFhBt}#$tXyPgQr=}U*Un*0@sCVqPgaZ%lzYrmmpe%o z$hZ2`yM}ME^vUvRp*{AvVM87A(AvoYKWFC=LL+L+a;Dt78NOFWPgNPr$2^Vn?y-fj z&ba1ysz~$xzb^ETF8yGZ;VxN5*l$h=+h)uny}A26qi)^0MLc!#WJ1K(#T*NX3e?s+ zqADV!8mFb4a&a*$&#?N&Q$M|b%|21HYsE+GnD1g>GrU*#dse9&M9I@c>j{kI+nfoX z0^0nocF$$*IYG0OLo-Ytd0SpjSqQtkuEAU9r{62q@)P52#CXJ+g%a&h0 zR1__6Y!=(7q8F=gv3!Q_wS8I%*EczW@G}ajWc;2z%{u6A##*F&_^I_o+Nij>V-hPX zE1T*%d)HNY1D^kBTwL63>&50qjO@-GN9P!+Z+yvWtNNzWtuelmRQ;=@Uq5tT2pNYf z_{nCc!aw@hXP26>OXsq0?OMd>lP!&n=jy@M`2>iQJ<0anCE;YzB)mo zd?er6Hc7AJKlSyHU!J{Qkvh34?|}%KZDINKp7zag)FJI~ba_!x(KvMZVFL@|+4~7F znLrLvqXw=^*klzvtF)F>YE_sKbVv-XNKyUy%}YF((BHN5o)gV?T@~GoHo11PxafUj z(kY=NcIlcNP9NYgVg0v7P>`?bnRS*ym1f(b;pj0W5E(%0MML`G%NbfDw0qIf&6cr&-1eM-FO6XM# zO*#Y!Rch!Z$-Lq7E%*2ScQ5D3lf$G^^xBnt8IPt5Fi zpRlW@*HwsL{T()vmzT^$kWH<0dNde5xk|99aGt)A;#8@Y)Uz>@VJ~tUgUhwQrpm6y z((-LCWezlL5fyMK*o>9Pu6&qH5hiv#aH^9v#7Vnece|oKrPi^rZ~A2U^LFsctmoD& zeNycW)AzH&WH-2rC6P5yu1ERgp>!{6ccv1QWxo|&6YSIKkt_IQAT-<g;K;LL_GO4G|u6Sk-o`Cjkw-t6txK z$PYG)=Z}1}WkH9*y<3!fJGV4(RnoA}bl$;_J~J#c^O4LgT$d>IKN8|A)wjPM-4}0c zC)c$beO$ZD@ro>HOTyOv&e(wB*H>@UZfwP3c3Ge1tF{||&rDOt!z60hJB3vu%|A1E zbxV2U4o-cHGd|KRBr9^#bH@owKCQy4cLT;%qPZ-vYA+yhVY*v?Dy5ecfkbdGJxt+G z*?ghee(^(m&)t@Azm1Dh7HpbHSD5VsO)YZr>9|+zvR1#^WgY*S+d#KoJ0XzNJa!;- zudfbDspo-0I|Dyjx9y$?H82GG*%f#X(7tjL8j;qO)J+aM<`qm?l)5C4b0t}{$H+R) zdhpJDCfTm{f}YlUb%P^*q(YAn^05uEtG;TT1Y7G5bCsx$`v`fKanv}kIlVwFt_H)^%r)2znBtdoXdKz0QQ{3WF4Rd6@0nidjIgvgFK($2P%^#p`D&sCuPc)fv`XsRdz_LTi7LZY?I*zcEn??ypL%i&OW2E z>eJshZ9R*amvkk!u5&OfHGdIX|G8SvJDA5*m1eg@QW|heq19EFfajd@`)cEfpi;nz zDjk_rD>Ba&k`J8O^~r_UVj8ze3RUyqhmYW*?Jv<}3V*`dT8yFGr~>}=15>z*@GQxA zV52#BhW8KJBOgJN)P4cUL18$3ht+5wae;8Md8YeHU!UYUaZW=*LCQ@+Nt}@qZ>pp` z|D9`-ijk22bDWHXB+8zI;@{6PAl{$Yr`HfWf(bXLilDj0jnre^Xq#HrBAuqK49BgLx+`mws#L}&PS1%+f zTE$%XH9xX z!0#esm68B|k=p_v*FGw~+iw`#e)6X%;K$$5q90Q=w?^Xx5GY$j@afv7XWjHSJ(1fC z=%`TK^6`Fie!%Wpbb-2(h6PIH$jO#B)=c0EMR*7c3z_;|8XD67)j#Yk&tmGedTa%X zx99hKcKt4o8CyE0v{gM!YXUb1@OUxn2JMRMh z|L!CH=S=z|n*$&EUqApDPv#A`Sr{U9ELM$Gnb})Ai@9S39(dRx8k_i<?jB-s;*RWosDPn-U{fO5;O3C?aUdB}I^ zz^kZgrIM@F%JBxzZZ4Scu!q8R0)wOUy5??YzgYGcq4+Io=oY=wJc}U z8Epa#oW_nKUg+FQWNs?XJCHAeIN+bGqfCG?Lf9d! z63oTRZ$h4q64nz2j9|~J9R7?cd9CiipKUr!qif~Hzl}>VDe|)Ncj|O%#;X_NwN1*c zV`khi@jgPT57KTrBkx9g3Y1b&tbUA+6d$}2J!pA>LQ9+{b3TA^cExhesH21CdRAOm z#1unQy;>3k)$%l&9w+s>$7=6(pZ*TZv{?A_TSZf>($0($Z*ldj^gtETBB_fnhah?) zbW(urvJqSi?S!rI4p;ca>@r@^ZM!dlTsr{RfOPE-}|nM`%ZB^RTykSk+wgr&e_gYBI@r z@HM)KFEridXAj4(bX%GC$HNF0rvqFxsX%EH%bc_=19q&aVI`51t~lC)qP?VLPKc|i zR)94wPI*okZL#o4)2gQLCE>n)^HR*h>R?Ltnb%;&x9)+dgrbwEW!NWx3V(0UwFqK| z*II`@Ojt;MGm5Sxd^<4Srz(^fVo6jxkKuA+?xjis6U%}{)`4#kGDp!4R6AqtuLqOY zKJ-{`g&49r1Kbz0yB(xF7Cx?;XgWN`QQaQylS^zq#$q;OL=kSPgI6|tq@mo1^VR5K z+})dL6_oTG&0I8eJL(@7QVmA%7)!ftvn2r21!x7wbWq z8nzQhp7rlyKLX2K$Ld-?tnR_#JgSayrlMthl}Z$DTbX775>6-&%Vy29GUYgt;<{yS zj1r~5zzdq4@y7ei!+H9*{l$9c^)_p-uyTHX<}=B)RQ{yO=^{Ji`Rf%dq zD&bPxRxaR3VAZDXN49Dj?LqsIRfHt2tB&We%!kj`mj}~&Qe)kIIMt!B3_8v1kY@z0F&ZLRd ztnBP;xI>SLcwX^pV#XJ3#%+dtB%JHKcp^u7uYvia)savPZYTVFd#J zYu0Zi!&}=n$hsZV(<;b2LcgE>qrH~gh#B{jT>Qe|T`_Y*8c64__^<)gjxsY;Bch6a zdPgDnw)uChpHmNR zCOr6`q;??mKcS*H&uYs4yXU_I=7q_5;VzYaIj}84Iug@YGL>=?5Tu#}h6z?n+sK!` zKdt1_8|BMrUmi*D{FN0`SLbG(i9ki#82!y+5mU4HJM#UonVgwIND!_*?{53T?yEa% z8~zCG8kcj0VpNuFI3HRwYyj!ss>Lv!!GAlQ?WQ5EVP}(LP6vOv0}dxvHliT7tp3l6 zo@Oa#`6q@g*-NSr+5JnOon}XN#^xR>E4%GcoqjVtKJ#l+}~0Ap+aAq z_A?>H>>3L&T!IVsE*7-Dr7ryM3N6{icsm}#4SWUefgYAu@J6RK%lyRk z+2G?Kj!n?;zcn7yLL>b8-!)zY(crNAPiCZ?r$zTc#nUG6r{&GGdhs$wK_zeXZ%cO) z@hwZo^;Rdw-iRTdejWax#aa8&WjpcDgRfFV^lx!fiB+mnJOjt#_?!zIOLr3LE+TI) zn&o7NYEX4vE^wAv_+nVCO7&#njh4k=wwBKGsaVe)cJnN<3+-p6KAQ9f2ke5RqmyjgBN7&VexC2LlIlp6fg)_rZQI{>% z^3nnR#BVL+E+hhTa}|rdl+1a|e$+OcC7Q6~0%E!dcSa;bl9Aki4B$wKV#Em;Gn^fr`ZS(Sv`|88cVO&#$n zI6Hk}C4qy4v_r5G{Mg;rI|I&0rxj#5Kb%N15iST~wo018ZthQtIyRjh)*zjgwrX=W zD~AoLk7ff(6xFVG4nrZ%0h?86n-wV4>hzR7 zE;d|{c56O_v6Jx}-J>ACo5vcYE{xOQv0OJi6XN&_WI^u{OU?l|c+6vyeZGARK~g&S zS4W1Bvfu#j!TFk#C1j6RTbDEehECH*`Kt)M5kkb`bHrzC-u>6A!#2uT3FhH-oXVC= zmk0$JnXsxia_Zjd&PI$7PTC0bc$m?N7{8 zu4i+9dJ!%B;Qe?`B4OC{dA>o0Gl)NuTy)2|cg?R>NKQUAb+$p{mgQ&0%XZ3JU20i+ zV6B`tybPNf>Qk918DA5t=a6h2VL=k!6hX_QqFZQE)tZO;VA+=@(RRDma-}4iHC+cW zDE1op=1s-ak9PW)VLkSA=zWCO-R}4l6Qv;k=V!z5K=~f(tStVr(x|wF{&9^kA;S`9 zfB(#p4)o*IfMeq2ClgWN+eU0B+_ldEKoo-Vl)wS_W%52Dt6!J!fMDU4f_sOw5 zL{#U1#jbehY1BS*wKOpjM6?oF#rv;G5&krPAG(Hcw!uF?^}TBmM2r9-i@3=XR{?uM z-RK_b*Iy?eub1NNQR2wRprLFXACaa6vEY%Nn)>Y~9-c`B6S0e1Ki7*#YJS!vsvLd@ z5dOK689+Dd__uQ?Wi+JuU<~HkXVJ|9RQ;N#J7kr5WwB=YE9uTc96e3nd&!FRVr22l zXQrhVp^b#U>n_Rz{HR+lEIfR0pZ zY~UezQkU-2-V8#uAx3RX{JmRL5k(&eLWzVv1Lb+m`hDZH$+{Inv-_g;#VR97f^!U5 zWe@1jgRER6nQjZTK5Ji*<(^1r;2*;IZmb%EbG;9y;Kq*gv5+`L4r#9+e@;SK!e~@_$g5J>ly~+By6x+9c#JBl=ZAUeY$ENxF-a{zBI& zxM;lE()Q??x&1uRK}UXieAg;pcF>)1&Dz>YReCJlUmq{Cns{-ue&bf+Se2u(60uWo zUjFsb=WMekExsV?U3~M&ihNzqV+M`=WLftGO?iKl3{z*Lp4TxJ{mT<&QLN&AyI+;CZ^3o|zNtFuT_ za2hZ5e&<}VqT%`OI+xvGI*aIplrY_F1Vf5T+(llyc~NQjWe=2=)NCl{6O)PM`Imu; z))H;^xqNnyDVLjZa3*fO1Pe{}--W*gkT>Fi)sTeZo=fj4caBm9Py=X)r+Q>d*`vN8 zaIdIlTwi|HdgiBZO~4kRz~&Aq_1qvA!MNngd(yT)1u;B+4qTvjKj>V8pKz&NqgtOW zikhL%nbMOrTONgh>z{&+reL|ilrvDq<9fF)RH@JG$sf{t95u)%;ue8=3fQPyH4@dx z!&$H$=<9{dOX8;Xj!O>D2Rirw`1b%0e-Z>vME^a;RL79Q;Qx#VN9fT%1XukhgurnI zGivG!v32QPNyn`;u$?38z*F`n*@aiVsn_IOrWF!MGiI8B57C)ETyV3dz&ia8Qdx$4zIJ+=D7F*K}Tk1@U72eKJ6i>Pf7}--|-s;H_}AfIm39%S1j@RTpQ_1n%_ z(!|xJ3EZO5xZ;aVmrB=Xh_fTps83_Kk=z=<<*^g`;N%MIcDJ~7ryMkW$3kjPcWvd~ z?KPcucfp?gP75c$RsuGW+G)*$`%E?yNU*d;1ijDj*uCb{Bg`pJcOC>btISeS1bnCu zX}y*$I|}~HLysJCt|hh%=0g+I>iBWgiwg_oo32QF6Yq}7lJ#6bOwC^PC}A=HWCK%6 zHjN2C2v0x<@GrRe1Wd)H{Ck8E@#H#lGWb7(8!TlX4L|VhG@m-Kh5o=N`XE6e{Daf9 z{gQ)X;GkvAsPfYF@Yg&XPOILX!`l~UWp+h2pR7g;CAfvfO}W(f8|^7nXfP?}IvFU7 zJW!*llp#|Vbi}^o9ycUuyGqWRbbR(L(k2+1aNVd$D{p$UKGu;23BjP>M*y;O`laJ^{qOZUYqGLUh)I!o4*+)$<~ zondokqKL0O4{5e~XxuO5Q=v{0B|Zokkz+1dYmO~Fhuo4FVN>C^8GJe;&Pv#UG3l_B z(-j==vJIfJ<9?-094}@?tPLkzUud~dmLc~ZG{{hzDgp40U86HdHt}3n8t|Y6(${}S zt*D9ypI3<_11VHwq+YBV#xQ2r;=M(h(!lpcs3}-6^<@a@COJOg^UpX5XaOu zP6H47XCNDkk@pC?{~mwLK!&~?=K#FfF6`d}8Q%wtGg!JFT!&vX`JK`GT8(v!xg#IK zFDI-_%w@iySc&S=qMF&sZE8kt>vL3Wo9WlNj6$GqCIAj}uils{(hL+Z#z)ybZf`u} zjlb^p)tw>n76xnkoXaeiJf00MGnp*>ppYtk;mI*4qodro1v!sg;emgz?%wmQO#EqE zqUw#E^+@g*wLi_F+7a4B5zeUg6XS1ttv?<|)=|43T8`GcXvbvcIDd z%syx9GuhMRKhQE-0-yy?WxJ0iAuk*Yj0>w>2}n$lQo>2V-|zXG+W->9_`8}%vA1a_ zBFMRZQ#?@XHuV{l#fe7 zNhhkt2rcU;K(kI1)8&mCW5TFNlSon>&Zfjz0MTkeqrSNDbX=vlUsf#o>)60R1 z%^LJ9SoZwv2w!n*$RZO%r{@J z{B8GH-&rv2EupwDR)BlCG@(^Im&a-Jahm;BfJY2^Z^=6#w;sYmPqHc?^|cY?Ay zoOHArTzNWK3T3VmYI4tI#pdT+x9xs+#GxK1S|Pv(wxiP}_# zaa#?2feSQn-Dxsq7T?)UVQELmyN0UZe;FP2(o0S(UfZ606&5w#y{SE}8n9^?Byou7`+0o$`-h}P1rvd+E zHPSHIo&0}!xq%6e*U}9r)?A>L&{M6}alHt<5B|si?}3_UN&kTpR8Q!vwA$8PKLHMe zb3P97uL+U7YGpS%Z~ZE<2b{#JU5~BS01o9)5Qg!`c+s1%UCDS{n|}Rj=xW^U-=&@? zl`$g%@tZVvZdU~Z^AVhrF`}8ZoxCWeZROa^c)p;0!y~4OPR%z1e_6S1zlz%{B?821 zF3IV+yik_JwVXjU1-jm}KZT|>E-qr6@o!#ETZDw`qph)-5&6=F8s_mPaV_4OAGDQ; z#EAH!cHuM^`Itl`Ls>BX^x|w$A`drxhR>wE9J97qf#t8(Vd?J;TCOVde2LGZ2JFS4 zaue1m2ZpKM??n^i0tqK8o6&7do~pHPbWjp5Vwz9wHWr+3X(ely(8TH1D81O=s{ZpZ zUB*7y`R7`moO9>nT#Yj=Wp2A)gdHJeYNqJmwgaFtV7n2;7x?X1p_egWzZ5wekm#%w zn0q<+IO|X*E^sK+`|Rj!t0Kc^R?&;O;WK{FX&n+G_wABJP{EAXu>Rq=Ria|XemkFPWKdR6{Ihp^cpvm?L%8uyKsIL2wmRzXBCgukwp$JTR>1rsBTWBN#QYlG zOq9nBJ}rLsyj>gv*&ooT9^(s8KoDEd8vM~({+rnCkv4ul@zcaJa2_^v3?mf4CZFW6 zDPNG1Y#l$#LEzdG-FRhRhPUmsc>;E67J)aH5S`63UfgnC9~u=c1GDghT$?(aFD#H} zGuuuR1wL&gluHsUG_Y98DqeBEqBnM;WTTyKCH=RtbL3Dg_5%yY+zqacwXe)5#og_ zZdka_JLOL@WOyNqKO2#p$jfj#gikz3q=v($3G0r4<1nc8FWTUoWjb$p#ql{lgmZk6 zzwg=)kpYKrw;Kt$C-TNJe~SToeAe1}i3u=Bq`qP2EHP&h@|;JN+_c9F+fD#ajBSxG z21Ls0I%kH1PQF~5H*liqYR3G2sj%BEgejVd(zl}&c-TWT@XT(!)N+(u`y!uRL};_V zEjLqqR2eH{%yEW!EQh2ySN4hfqu}*Oiv3rQ&CD+IRhA(u=6Xb&^)wHoY1=H|A|~ig z&D`4ohfeZ4+!Q6^M2eu!JM@TcGGb&7TEjQvOw*9d$X8y+cuBdv8f_W%TAH;nwOSW!q#yY}-#)bDWQ6 zd?mQp1~DU%Jpu2*5153#TFTS?4~x)UZIlkN&vXUlocgjxffS3RHkuqAm^0*bF!ABd zROY~4Ppl+h-*p*yAtlm!M7rZpGrZzc-o$foFf{aGF&*5KsQ2HO&9psb&N^*jMtNHa z4sFjirswy(DsVd9aV`Rogq^q)_&4=%;sRp=67cvFxhWT((w@h4SPQ!r)|F=F#5o7C zBtlRA=MCa7h?H&+I+%i6P1{Vb5^WNM=FXON#`Wl9wAXNavqdEjn=_flSE~e-Q6`mr zsZ@KbL`ZOJ_(PEqkS5jD3eI^wmA|gs)$gRGtr`wv0=L^f`1YoLH{aNrxzhEp`-b(~ zF<=Nvd*S(BwP7n#bdg^w)dk2$_~qd*cxzc@8)V$6b6=|7c{xJt=uE3$el!c7SgK|p zn{SKCiAxXns3+AIxpvg~d!_nY+B1;UQ%_Y_Twue73`{fkkGUN1tIS0&oJ-Em!j5yF zs^c!E!j%B2jCIuEoP?MF$u~ zw#WI-+{cu3!uX*J#73D&XI!~LEHcie*L_sy{MX8V*p_*J+h65c|ycBE&tWmidO zQ`OUvZTscBQNI7rZr__9>72`ZLSB!35V4(Zr`XT-Xk(P0+Y?2)L}JPuEN&!eT^@{n zo2l)x7y!Rkhb!-!-s@x4;axKyISjM?^gyz)GJ3(xcbb%0hLO!bd;pY(9$D| ziWYO1RbPDW@etIG;C=>Abz&?QaN%>OCpCQ9#`41z-L$1^$K5M~&4{U9%CFuR+lSxw z+qGUN$HaHd3A^-GUX!z5xXK&DF^fFgsz2-&0`&$(J6P;qjFzCa523u5W#@~qa-N-D zM^fQr{^1Y;mdf;9l-flfsfo;+;2)d!QA@w6c)Ti1UF-hjwGIzdpp+deR6pBr;uh6+ z!mN)6rY=kGvy`i&>9_4jj(y=dA0fcaga!W@gF9*)l7 zUMfG;&o4Ts=7f)8kkwMgu~9&Rpf^jd?Y-Y50}yxv7`sR_i84m>n*#*H<}b;o&z=)L z!ko_nR%U!R-lw}_lgs**vF(^cDu%D-K|!DOo3VD(flDMui^FAic-#<*Z3!OJY83C< zJ)nq_(0kp&Rj<++)bh9By?`_X^hx#o+R-hq$W_QQQ2u7>O^ zGaPP3q1E|g4RFKI|I$YR6~=?tz6D5d)Ad{0k@IQXk)fo{X+Uow40gEA!ihugjUg9Q zF$W5V-Xru3aa)>=4b+lO@-u-so8UXVku!OnkbYD`2Hj@j0oxhEmb*n_3~csg1#Pgf zGlJx4VQ=nb8sIK#saQ8q1v!B5`LQpREJ7PGz_E(j@V;aZtOs^3CYUkZ1Cs~>_`dqr z`zUL?)@jYY>$&txWX>_K*L$0@r%wx(Vd3}oPwrYC*D3NwNqU?u!vLU?f zAV@k#FBs$m3Ea!>1eeOJ_m=zjD5&}178&8x{DE%s6U4T}yml;}<=bP5)$h@fYSX_G zw5hzdTH+Up5+VC!fXt8iWuK$185Mou0Fjpyu;3FL`8RKDT%9ftbVq87=oyD$61m{e zK=|kJxfXJs^gnkJb;207wuW;U;<<+B&3X0rO?l%-z8g`G@Q>`hh*#O&5t4iLi;XW{ z$1}d^kQ<(ELUB9%bN>g`Ha-A{_2OWh9V?{bu;WqWdfIfdJY^IA#?DTLluAeTi#{go z(-Tr;`6VGqW<{=NUm96q{%__l1W8?eJ;-v2)bPXQxm7XVibeFt>(X4h3qRf8u5d0S zS5sS$=0jb%UK9EbCv2f8-<fI*$}r}V z14pgGuBZEJ6 zD=&FiB0C5PRU0Hd11RHo7~)j*5?VDWUcaT_o}Bg)igS0P;5y-#erY=O4GKebo=CQL zFrH9rwu{4^UW$AubtiibWuk+BQjPz}d#mA<94jb;pKbJStNqKWv_ry8ZWCXQ`CFh< zzrvTdg0(CwUjEG(=`f);>hA?r77ewy$l#s;IaJ`OR6Kq71}FjEU&-R9GpZpBFA1QO zJ+}J9n74Hp_$_&yN2b<)9DY`0k5LW~TSlK8j8$!J%OeRz5?`e`&CPc({8I)>n{ixO z`iy*1<9xLs>5<7(vl(e?+Q5m{9)L!m7-LKghDeyg+Yy^D=gGL71o0EL88$k6-jx+x zSdccsbqWCS%8uMzcwz)E+@X9uTHq@w@1?p!^7m>pmsbg2(7Q4E+vy512dzmJisq85 z51w~p8Gni<-X6`;c*kEQ58p`=JavMgL4u-B`+U@te8O>L(Dvu;2pOjr3~-dgjZI+b zM6wgjb}JD&^dH!0NOA@rN+Qzr?sYGGaIgpX2fN1q8Rz7%!K*-Gf5t45>Aj3RC-1Y& z?twQ7!MhfAc#U<)0KY7_U}BXr2FijXMA7asQF{l6P7&g4p8yN$0>x!mlA?%wt|0RgY;a{l6&PdChZG;weJpZ;LN0c zD)8zUC2;Gi8XmR>4E|Qy{%e1-LIdx-6muk1AvOSRBq&85eNkD*Gt=!`)m!|IYE?pw znz}9_6?BIoi;l3IAR6^f5re>~J}p;uhRgGtOP@V|s|C-=_>_c$u0dx+ab|QfoEGDr zNV9m^7sm@92=y6gq4wk5MOo!18HNV);clyjrTbZwcieqpcl>r4nW{QZ_LmR6-GJvD z7Bx_sd!`b34=Eq_e0+;5NUlLj3#St|9$7XWVw2c-RBa3s2D>F&kf}{j5u^S2-X2Wa zD8LPt@CJSfIw*ii>>!GPhwAr^1w@vPj}&l5Qv{K$?Y)4pn`WC+qMt*oy{GsaZ)y=P z0|V~0_ayBBnGkvzHk{S-WXDCYY7Dmb$ZKO@uqoj85&fp*3;~JXWK+Lx4I$`P(+l?e zJTg_f5UHxYln;AdvkJ0eFVVwaTDbc{vk+jKlqjCE=ndkIMVru z-PFSdVx{~eUWHQ*bru3qMByHIX9~U}NZ?IReP{4G{gBBcTu!G)z|i~Xsb^D|;bG3( z+$T{yyH=fX*V>n*A1aSt#nb9&M2MZUkVKgLodqVZ{w0|;T3bscpxo4YrcF;~_Dfgx zr%${IoPQ0mnK9AQDqnZt>L%|8+6HhZdo#^q+(J?Yby$@VZgcBOm%eBWI0$tgh}2s2nejG;W7aSzCWSe1DGw6nIdF*+R(;;4FaJunTFE>k zuixzz-#qW#(Xf-VJKDNhm)-}iO;$P1e^8pVUY-kDEm}Oj&CS*rwp*tox{94}qUM@n zpf`VXe>VnpE!w`+kjCX+`1Z3a-NL9Avf~g=u0RW@^>c0~DGSR_DIrPR)=cKYP z++Yd7$~)lYC)MciKjhmf&j=-y(8{eP6% z)IuU<_m39neT0-DF8f3ikIU<-ae@)lw8|ks4_}CNQDpfz?+TppC}$S#1n~J1RhP6> zJTK8>!xsf?5tshfMwjrrj%p#1VwTkDK^HUrkjtf)VShuvM*UI#Bu-n_p!`FMd$HYj zZJkK0&*80J^9a7%g_0)?VLFEF)7LX2AMN*Qw0%=cXLL#0m<`yD&sp=g$dU}pspgo} zU{HgcF;8$RY!86Kt)T3kQ6*H+dY@0PqW9k--W>4oAi!>bN4)83QckL5n3W+BF= zcq>FNIpkv^e)l7XBjlz8tW6GP8?TLc?w0CtS=5>=8ErJk43n$bicxoUp zz*wc>Lmo7ww+D*~Zj~$(#6QCEWTUM5xwu4oV?)WQC+Q^{tj~h6vG0qDBSVd6&D)Qx z+@>dpHcJQ3Wy}Z3FHc{9HzYj;gJ#Eyz|J`oZv&rSQ9hvuVjoIDqbQv(-?e_tm8ThA z%!~Ip4&eJ0xwvB~)`1{YwwhkBX_VG3cFVP{VN|b=-js|7P>cZ_yJqpKonCdhFe&3# z87@Fz`}9mK?}=?{E`gef%UZHw|8QwH zB)cFnX*npbco#qz58V&@{00v@PJ0X5!L)XnomF}fLj=w#2zTyL#5nk7iP@iyExY6! z{ySdwDbZe_OK`-|TQx40I9hw}L*gNhABYAb&qhsRQ~r-t=DZcx*h~Dm4wwK7dP2{^ z(bK3=pmh1`0rk1RN^PBa4G%2>tlTu8q0gWNr)d zSm}Zgq!UhQ&1LwAvVObKeSz=9tkn{{D|FF+C2O9MoYDRG7xi_QTA_hq6wx+_uY-IW zfM`SoPd?yKxq$6O5gTTwq1+zt`}*5=7R8NUMz_n%%u{}QlmJgx_PeFbRHpr_dOUFN zPGBy-*Xlrv-DAx#Qtl0}zataB@2nSkrB*fN8?rJ++LbmBS2}nU%oovkxgj%pKC71T3C+*Besxjg=KaUyu6G!AWEV5_7s@Pnxwu|aGxv^#gqfNoxs+eIj0vz# zgoa&-ys>L_NiC|YQFv{lTxI*hX;Vd9TKA|%?)K;<-Gsn-TUfr_7hPcM1+VnGaQ!*j zn81TAeUzb}sR9r>oZt275n;xZkv@C?7$yw`??IG{+W=|&smEL02@({3k|*A$G28mFVO-9FI69A{}0^{fjbQ2K2@f zp~3+cMJ5}L{Ktn|O2WVN&i5tm`#Z>AU=0iDMMHl8BT9s;7q8%Wz5`b=u65YNhxZ9a zK=U&wWm^~cW$qhZt?MnZEv3i5<|*X{U+A$D5yjv!Tb|nxmzkk1>YmQ64@`|CbwPf@uZa_CtEgC0T9oY#ZrX(scR*Iuyms?-EAMd{b}yf$QnP zy6u!JvXS3cn>>z4RF52@0U+_*3^`8hBK1bC-Rk6<`(iR)s}GOvu_sEz9^6`Qd%ja`S$&ePx};^!WJS)+R`hy>dG{9!#%F!B z$gMpMX*UnqZam_<9tnEN?GI~=J^REA!40Uim0rAQ+B9PBw`Oz3n~-6`8LF*i>Rb`u zh+j?*F8n0<`0g zZKrS1b6A5Ajxhp8EVE9r5}y&FVN{nGTUAft)r}{kQ`NGiR(``c$mw_v<#p2*v74|6 zhG)k^cg>ptM!##>+Z{VsAEZef2+{5Bgli}VEx=lVPpSfYh42p11+>^{rWCsjy%6sL zbSc?e=M=#$_e&e5H2GWg%xNkpQN`&d!x=o#F5?u6{rPh3LZ9n+|K45q`|8bz!8XZ- zSga_%3E%qGC$24cZb=u5$~k%8oRoC2$D{KU?bV!jNZkKg#hi-u0pKSix5BsZYBnt# zJHgxq*8(oZhA1hAeLWE+l&S2#|H^zz#43!6ao-0kJ=sA4Kh4hK!|33iSA)1SX_39V zbyN$pk@(Qz&dZr!ryy$;%2@Af#Oz$lGM!j;EUxynu&bTg3K;)f=ehAczjGvuUo4;C zKa+RNd}TC?e;p~knJBD(4hgRH&OhCcokHwLP7$U0*SII3#@?4-_j*7vU>H-f$lQ7H zCB-%IlloWqiekc(0^ofD=DVjT$7!4*_W$uDo%=%1!8Oqy-47Y9B$S+R?nh)F z0R0dQA#oY~!BzB3(gYknMT{awQKxK9w2|)HUVC@aA2TY?4cZJ!&5OfM?vqQm6_f-Y z6htTh_;ncl5&CTQXk(|1Su&6>pwK60XWA1bp$wT1XQQ+CGWtmKnF7T`1ZoBsZ#vEF zx{iP7%9Cy=(QJ!IxU4kUBTBu^{9RKbI6Z3Yy}TuRE91VMCVPqUt=L)d*IA5m`ORDq zdiU^A9@hrLRW^y=njguWDhIV)9*5KI)ZC~Uqb%NTOLCp8v?pF>abI3!L3W4h)`AM- zD?8=YE~Uhv#y72v*)r!GR$VgB1ghPQg51V3(Uc9(Ur9*!kUAQ)PQ%e+)&(eD+usdg5>(}9l z!|EvV1#!SIwA6Z^ez(KQyWnx{cEOLhOLWca)Mxcxq^UAtS~VK1cH~kApwYKZ%zS5^ zjbC7uB#m<+wghF*kBKyw2Ud=z10 zX?ABqO(CZhi_4$xvnzh!g_&gcwQ`Y=q0p_ z3tX&&^$tO=kYz-Jy-PbLS}u?F*VY^<7+5boZTlkH4_QYB$ou!w`l%#Z)zaaV`hOma z%9_qW%s`%_P1lT{1EmsHPzav=UPSNJS43DB@^+h4&Z?84#Of9Q6uP>t2K#rpEQS+Y zcqz&3XHkFkH&M~8Ak%ePJ7 zEieR|BETjITF9paT4MU=AFkl#5{T6iA|y-DK7merc#mWjn}^Uj@|C6ybG?j zRoeE3sZ2<=Z|3~?i+s^+x2pz^Jfw(%`?#dt>Z9+W-t9Rp7H#vPO9}k`bVQwZEUqi# zq@tVR*a{Rv|M}zf>ggrRpzSKu_Ff+mAchY=jd z0YshMiD)fcvql_eMjGd6CwOd|Hn`r8rV8Ps+Iz zc@GZp{J7?(lYTa*Uu@7Rp+sEYA%1~#T8JrTs|KCsyw@Ljva8Fs}ySC9~MiQ(3c5z{W@ z!>m{GQ5s?QQNVn}%Er77UwB#rFt&v7=;f6E7+(AGR`Sj=9IhIlllZIgcTy|{8F+GQ zeKi^l!3Fec^k%&%{sJOX`Z-SZod zhhn=WwmrU9f*Fa`XU<%T2)EW4pB75UZh1IiOo|}m@dlR?YzT@w17Go|zNS2)%D`9! z%$FSI6QbluPQ?GY&Pwzv=zx=0g*Za~krjyA`jb1vnJ`iNOZ$J@4dNCRh-D9;b;qZn zWx?jw+EJiP`_BkovLA9zjo#h@st0iO)I}7p#lx@6j2)-(L@Ou6X>*uOudB7``tPsK zchbdjNL;_wSxY!w=P~$JQ$!aU#&m~9r(M~G$Od)g-4yvvv%kv{?{OT5re@@`j^g#W zr-jwF66DUj@vQX9HAyPb@(^gv*326Qhw2uro=luo0MlDC@%_i`m{#pLE<@?e!24>m zL>EYW7tdfw_c}Sxlyu=UHP_3!Bzx9Kwm2%C1YY~gcf6uquP>aAPPv|8ZQ22vIO#X> z8M{6UF`|>Xx+%`LE31~o=TwAEZ8-6Zf{A??gzD*z5d771XCj)0ZA^Otb9|pfpus4j zZcxI3NmPv(pTlud#kgyDJ&%c6)qB`LG)S;ZFmT4WGhW=`ZrGzVoqHQ0sI4K${q(M+ z*0&-n+XUF#$dVU+U6Pom0p^6)a+e|=e%ZE(RWuwd_=?JRlE~!7uU4D7R-0mJ%Nj|l zv7J0q!kn-RyINWPM}IMyF~RUHW@nE3llc|4Q7V*BNiX@cJAN?}Aoxu=r_Crsd%sI=)w{BSvThow-i}{G zT*d4b(>m0%xOLI%Ev#x6@-&{qQ|H>4RW|s!KMm3(%=#P`YeYHJ&t=oU&8{U7aV^bR zPlOmG!7+f3@4e5Iv%Kxe-Bf^|%Yn$x@FhU*l|glgrYaBzs-T#{bnf>4Bm#RhMDM{b z`{@qm_}b&EkuBs@6PIj1leT?<`OS{OJd4btpmzqE%lD`h65`oN9{0c137)x01|eQ> zs8JVlw#7H&4z_Xzl`ncVa+Bk^OAT;D^3NF(*7d_P#m9Z@vhR4tal1!nc|31~kNXMH zL@@@rzo}S;uh>kp2VGLa*bO)iTFY<3n#ej0n5Q%nm7UBi6_yUlDTvZusvoDBTh?&& z0g7-WkPs7RNwiy=cWnS)H%r*TR(G$BRAGEcB|gH3+!$@Qz1qnK=;LAgv1iB~$@fHj zw{enm;hb(tMyfKMDv;x$7-$(GA1iNX@~?|DQwbTF;7ASO%Q=Pm*=X$Fv-2wfz7wx$ z1S<*88^G+7nq~AFUi7?=4-L>Oa>IXBswwzn{jfb=&R@wsPMTHC(qfwEoshkzfA2f8 zh?11sdxz^bH_JtaH}FKpjZ%R)MDatFDqKoFJ5#VEo63H$c=$V3VpPg)+Q&Z?i0|6gg1n_2Xgr3g|?70PrG*U z1;kzU8!*Qhd*QM+>;Tcs4Pc9c}cyqNYdAANmY;}Bj zADqwh#HF%>_hZw0Rm2?mDY~UBYUQ(Mg0}=bvoC;u!X_=!@< z8<*mhvHN0%of40uDiWX|L^54Ai2}$nI+5hq*ICdxPOndQkybi$QFk-BqQ_8ffn%L> zF`=^+?+5HCEyWW4p6wxAE6AA?zD28I|3A*&IxNcX`5PAmMQM=`q@`Or7fETP8qph-OFN2C1kTH^3>&*LKo zlMg}JR0Qz*9g<@XrI&2)IJ8&*;P5!lUGO}h5 znPohO^aDpeu|2W<>m}8|0@gTs7c-I8aHdJ%v`uCEQpKJ0tgHZhnOY}>{BfCm6kzA~ z;!ox5?U|h%a>Y%x5kU{ES=t^quV4&X_ih3zN=0Aqy&2wFygjhfmfTtV^Ow=U(q_~0 zD`Wns6J@u983W?{xgDsfv_!(D+TOlj)qCXA*daZ|0R$Ue9%11^Uk@d6A)EoA14WR1 z?e&x~c)kTYw+V%RPS|ySMOJ&vx4>ZO$U?E{F@;unk-<+L`i#?snt7MmI0Y@P5&G4i zz( zj-@Dq;+XxhH*HLUS6h=M-MS$1+Y#12e)sHC(zkCuR6CjD$Vz(Kw(#wurwf@#5dC^y zS@CX6LLa4!%i9Did02y*vRaq#w99$0!|!C7&B~7~q*5>zYFzy;icJz0c5VMTySH%8 z&74R?)K17moxyQ{DSzgm43Wl`T!-(5!*+2O7}UcX7p1uRep7Nw$*wWxtubndx?bhdi&2bNMhR zT<_sN>5%xbobfdOD5LFNMG1}3%{9@CwCPrvQR~dk>(2CofUZFFC!fp~=v&Uhm3$6F z`D3RowzMWGDLQEoxu>*97N>TLT><@8+XkQn?@n(IHhUq`FN8R2AwFvXrhm^aHx>-F zc)5yXAK=87ac|mf)cW4V9vOp}pd*!sy~XY!a&!yH?#-Hyj`N>vLJ+v**YfEHjW%?P?{w7r~F++vDtuqK*uE{jApioVb8^busarc&Dx6rtL9O4KaSk0khdh zcC=Xo2Ykj?N3u5{Dyl2>HUt44^CalDPB}RXseq72#HS!{19OsSJU}*OiNg)uDc&~a zHsC}V-|EL}&C;lyk=x2Bd1P@D60b+=wz%;2Th7z=bQ{`pO8pW9)2A%PV`oERjM2OI zTV4*O@KfF++amh;Grt0dzp$+NSbjAMv^@&FCq2?Gd_Mxk)I`u4PfF7FRklpYnD9uU z=Fda_SNLHXZ}g0|rOl@dU6rbIqb?j;&f>jK$7Inz1?jkW&=;QVs<}%q0@W#VeZDeW zQ_EYxvaTP)pmI)2wEd*SAW`sro@Xv8>fIP9pDsHB=0SuV94@fSN%Ctck<`QJRoyyKKO+_cZ^bh~uM-Jkw=HMreTiMMa~jl+#b!r?PD-cDjXTx2;U zPhQjKlknP@28^YQ?0^r{|x=9f4?@(wqQ0pH7)JTJd+mHoEb;mu%?u;E4^&O4UOWXMwwJfFe zkCN`)O?%3R0FrjAfE>-v3L)#aY<8-^Ns5RY)U6YnO^2Pre3Ha8t55Rlt5-xa)Sdp= z?^QUslyi-&4URWb96GrBd+jkgOao=w69bJzn5nyt$5zLezI|$W2~?5Q0;SQ5*tQ<$ z`_@R0HRHpfM_6{uUxt*}BzYKxP7gaVzbkhr$teo};w8ny*FouJh4z`Hjr&k1rMQR} z{t=he|J|3VWK!Vlg*4HB@+H@y8>sR9i@SopTuGKO0A-qy21=y$FSev`)E()ZEs&L40S7G5f)1K;qXKsGqVALNo@V1C;ikOaIM{-0q3>6k4JiUus&L&eB zQ9}ckmfv=et=?EQ%-T0#%Ww+LT4jgr>Mz2y6o?$tpc`CfSUx@Ugy#Io5AR87*5^~J z(vQ#%nzVen2qngb0DiE)c;tpxk3a)fgj~^aeY(R+_XCE8M!!c9lC;=3cb+^gs^vQs zcHTA)gR<1z=`G*AsIMeLm?w&{|=3|*jCJjmxokd zB7WghB3)SAyB&F|79xm+PN0?0P5QjYjKR}{I=AIFOmKi4y`o(xdg`j1k?!E>Kl?VsJhJ<|R^`CzsmBNaL-|^fmin&a!Tytc z-jjItYoG{alOoHCQHqX@IYEmrf%j+(kz)j4} zqOFPoKG6A)6+%W@F5!%2m<32NLE z;Xz`=unzw|b|-rdoMf^;J(B%X#;(7Gtq1-AE1Jxx0cv^g?}2_Jp3H!)TCQ<_5=Xsw zmAbY)1IOsgEV$xwuzQ@qrf)GZG0~H~KJ(_bq}%pVYxc6|G5<5KDxvB6JT6uG&g_xs z_4}n#j(2J35yRy+EPI)Ma+8W)bEb+T)0>)gR(in+N-_CM^kvO z9Y47*U2p+N9TSMrGLk=lB`qvg@NaG@1zqWoz` z?zW5~W^ysBBKDS6pm_cVjJpQ?FILBon7;@@wP`m~g&}xv13c#+KsKarQ8|>~0X5Aw zMjcu7Xv}Dk?`guT1>C#p%NbWkrio6M%()?_9uv#gJwXQZlfnSM0E zSdz15{OqR73O9v)G#k2m)!b zo>O+bt-3{<$+}_K1H)+ac4ona{vKt2fz8+g#q)%0FgHf(o^V_IT$L{NV&BdgK{Z}AX{|nKkaSuRJ zD-88eW%@7Q{1{3Da;18;n&6u^2O|FBNl+uUChnm!fx`a&SnL}heM;y@2xNn1)7BAZ( zaW4mc_!`m2GX@2CNj$hB_c|m?KVmw;6oIL8nC3Be^}7jmy%3SAOt!^_MvX_7C9oQQ zdTvn?{7QTgGlo;Io#*-fb|h|T&Pe<5+Kt$#D03;*g4fujc^54Gl=5U9)K_=M)=ls*7ltGk(68b){W`c)F>knh83BD*%vGvO6F>r1jS`%Km?@jB>>H zGltS{$Ju9_RAe=Xp(|YNL{4J)4~Y8B&KulC%tI!gsq2c`GQdO!yv4pYhJtf|8qX-8 z_)HdtdoioGgYY~H&zSCa($K58T9qL%@&87syYTLW^}(wKXN8AO!-PgUnxo|%ZO8aw zE|r|mUq-*aicyIIf!q>FcrNo&+kEj*yh+Ks4BR$*DEqHFK0aS@mwE7FBW;-4J7?qq z<7ukGHtp-E7t%Q@dWzDk0a(w3OvHViF zq@%Q~8>p|i7)hF)`h2*zl7C%ZTqMkwX@#O>C!}?7yJ}crc z&f;Q0>nkFqUbyS7Vsy1&dB<1QicTC;x?x|TA*T276P$f0ccfvrbz@N=Ln+fBPIqcWRCaTt#`;K*MfKSI_=>KW`Ga?$%t91)1*U9z$?ppjB?z)37?G|No0%A4t(nLcTLjm~! zo9-7SAWnRT#xUGm#_oH_dxp_KmUndj=Rs!;MsUCOR6^3TKH~*0^DMX=;+fmMGGi6%GV=>!^T5;hm(uS_mZk2(6GkK8(lB=&@-YSctFY52`H5iF+`62{ zH)@Exi$Pg>xJdVSK?n`hwbdZ=9_+Qy;{kO_WN|ItTSvAMum|Nalh!AAmEGvO0Mr%{ zB&+d{)}7T1mjPQc^I2nw3`f-vKh2!{x++r`ukxYwR>P6ujK+PKK? z1Q4@^oz=yikm|SFz31AQ2lgG2vXbzsRvikcf&>$iM>QDXOOpE($>r^R6LBe9roIrD zR*IA>xCe7LA@Rar_7sIT*@`o>2TzWGs<6E#2HVzDKH^tAygXr_Yy;*TnDzN1z_`F7 zPBY5e<0!zn$Y-$R5paPg=Z{}VD7ZlXPE>(?{8n2s*pG>hI0)?nP|n)T+at8&kWRIAd#(Un=BHqG;kP$W zsD2oNmEy}F5o@@cQe2OBkTb|*cbXZMh9ib>r5R+QCGcpu#<(?=SPsV<>1*Q|Bov`N zxDVDGNxeehgH=rXN}rX#!!jlI!X@K0ILtB$I4q1aF6D2S{7f;tDmq7lmu<=!nz?)@ zL;b6K^tVwom1ZPyy0o?L9FU%wc+XK4!)N4MJxxd`^mLU+sDPP~_;`uK5qc_@LJiHo zNLPH|9WQGC{GJ$RP)>zpun!aR+q;j0nNcUI!D|ZtJ_9e3cd;z|)H^*MQgrKFA*DQ(UnCOM}V{jFL3$G{fTz zG^L$s*?k3MBm@6{QU<#P0VuHs!W{&gpjh5qSlL>{B>2}5a*WaIsl?O~rKI)E{Tu(i z9J3q9<((q`DdSuBf1nJgY8GV0l}G%TK-M6h@(>`xc97OrMQ5AQX9v+Y2_4A-C|!)2 z_Q)5v)^C=39dM6Tw)o*JSL)3O3B{lG!RL$5z(3V6u<6lez?+HcZ8s>h5$B7mMlR%6 zgYLQKi(^p&O{Fa0B8FyONW^O_JT!dK6g))=)5qaKD`ELcSob*Hdue2_vBFlapH|Cx zo%9QjA1{Qt&5_b>gei)Da-6Qg|CG;0P3c2hQvQWv>-u>MaZ*^yLz0k@0(FXmRr$0wi+q9Tqkc$`@u2#T8)RC=BY%BMUwA+?Z{*`f_N zus&T&aj}Nkf9`%B!|%m7pM>KmQo0ElcL+k?y59MWWG#ae0}EtVLab!Fr$rdvR=_b= z80f?l;}VM!{5YK(e~0(6zgU0{Mc0uA9MeaK#DEqVs(^(l>)}5425}X_=#T2{MVFS- zzpRwmUb{Tauqc^!&q(x>$JYCg9ejk6R^&WNS*}lG%RY6j$oT{ZupYN5((cbTKsst0vQ-X!52=s+n;ctk0|2>BTSDg zuS}e}kMG0e^x`{1)b;b(Pa}y8mh3V!@?SmLrcMb2jZf5qd(6Bp1kqvCXfPC&9)=q) z?18%xW^z?duw}6v+|!u3!;kJ0@q*PHttAO!R+(3{T>{12{gf-D*T#|H-N7!o;{e9; zmYj75YvkrptSt~7Cg4{=H<-p&9b@Z(V^$Pj$#Ryfw%m?4!`DxG==w8qgi%uNB&9cvKbRBW<(omG z;#g5)>47I37xIJM_rrX*9JkWW#?_xh^Ob!>~NX>C+A2vU>SXdagrkc6I_-q z;F#r!{o3-pup_QVl>OHi!A4=OdZD0upk;Dlu+Brw#k-Okc^^My_EPHb)T82KV@3;{ zCKI@L;0MHr64Sjnd#A)Z8hl#hq^T8CA~(?W$lsq(gj>}ev(V?UI7O%X@|w`YqR8;+ zP0Z5c zD`MM=4?3EPG~Z7~>-~})O}c&JCipXv%C0ZX?MTs5>Zme?<@lif1oNXh+h{&HjbiD` z6!Wh4(2+xwpe13#X17`sx5r!K#dNspBG9rgUoa8)xX_Jz`qhPHl8c@o$Fpd+pr@|% zW_R$Rt`w0GCnMTj8RlKRj{S)+?nIs{&|n78do zrsT$$YkZAE@)9ACSB%l0q0weO`{GkH`~^}HGYVMozIf#gH!4UJ=hq{xJTU{k~P&n>d*DV+8xQ`&~-4HsudGx8WuU%wzY64dcW*!b#(w z0Y6m=@>hcGCEX=6-)>e%1D?Ye3^3In6-R;X;j=Y5cE=lq9ng>7Km`mj7pt55K(V0g z&Pa{tLp>xU{K2=cHyo_MDlU6%OhV0rTxllQAxQOpv)@lhzSnja-{f2E@{8+(8E}A@ zQcEM(N!bt(>S7Xv4nii8=iY8lVt1sYd)q~Ea88hYsgu!_*AK( z;8A%llwp$P`#`fdZM#p;Rm17|`8-Z?h-d%VhJ@aS&+Rj`c$4T@JE(dix}J~Y)czoe z>j|Sm_4Pfm3*mp_4dv{3fvc}Iot-~Te`}EDB$6k4kbAJ|xzVu+Kh*W2DBI@QzOLp1 z()zO?uYj-Xp`oO#4Mq=9s7EU?Vo4s>+P;=*;lfZ|I4*dJ{ts4sobR1Yd|oI6^A~0f zf4#X*~|j;e6wkIFn;tjxsxX%YK=KrBHK zt(imHVoalumnzeHmiJE1)Sq#V+G4Dkt z7mW>U$U3!D?#Sj6ph}6aew_KA5K3r(UW6+5KOpU@B)Hjo%B0a&o1HegxYArssb@FES$QX5s}K8uTDSSe*vn4IeTWeD*tL$tj^ z^#3q~F+v<5$Xlg3z6V=NJXhtYC3Orh`^bG}l#yPE4C_dD#)?rX^&;2t3239=^HwmJ z$si)3Xw+|*n z`G1I01)*Yx23UKh|AIb+hQQWC1LapwJZE$m^;mKtZe`^~W=|M8 zOTGxR+$!EX+L0bG1``{hN#`mo8mbed|-yUC0r#ko$&i~ zmBO%snd^Ksp8vVW3J0Ks@BTp6-#A`exVYr%>*_0}8U1jhfIgj>_JE5c!)BR{>@Sg) zpRA#5!#VG%N$>RKfSR8SZ1T$5Q2hLxfR~w=gT(6%%ku&z# z+C-MoJ8_zn%wc$p0lpXNX|IiONJalN41l9%*}s`9pkcN7ECc|S)k{8g3rv{c?%4fw<@=l;GsK9#dJ z4igJmaQV_YMmP;S+GL<_8Ricw6CGOBV5&dSU^|LiliV8Io%x3(9#n_`MGs}Vl;@XI zUNYn7Z9ODsQnb`Y+4!$m6}*Vr5{w3)5aezr^h~+_nSPyKjJ4-G0+Qwof5Xcrdw6}D z9T86^*=y7Qszw>nGOyQ+&`>oX7+u&;)P&!JXN+F>J-^ngPLVO%I9iOuS#Ys`%V}rb8;8Y2e8b zrYv$nR*~NWS2KYw(oKz9RX1L{g3|3*^HdB>8?Idr%$w{RZb(;|B>ZUF7X##wg`)$n zDrSUG4>%I-O!HgK9l)2Rc0X$Z>{|NsZP(ggPv)BD*M4ujBtVS|0OWq4h#;pKwx$$~s{d~ElX z8Uwf!MF4ap>)WfZ)c7ZQFhuxL3EX3Ko|M;<`vKMV{u&+$ z#CGO;+XDd(l~W9@VP zbs)}LY^F$5@R@NTqbg^n*#kvS;tEP=%iX%wuD89lr#nYLS^*PcqSrss12W97?W^9W zxq1Lv+YWmn?y&>=rrGci0>i7{KN8;S*UqTj3ka*$S7~jwttQK;pQZ5Len3{S&gXJ% ztsNVd9RP@Ju|6QPBzqrLYGUQjlABSbX}@gwF&Zgw2OmL4rpy$(>Z5#`^Tu~t&uH8I zFWNC{2y)v*k%3sjdf=Ll-Y4NALuMs~Y=_Z@qgB2Be71-2(odDrc;_v=;ZZHT=2>aZ z<0^X#?}1{_W}@3B6{85eL^0QRa+m3^wYJ?(mgy;s88c?wDO?wK$v{_ZDPlJMX*@9S z-pD5WyT5}TtHKPW-ii(e4-N`>`iwv-GqskUO4d`agPX6lR{d|tPG-1WM%ValPhEXp zXCDr}^?N8S&L^!@nE1hyBcWV>vvi$e=LxXEaS$HPWY#y&n6_y0cruf9U~$$u9x@;! zep7wB3$sbVVAX5w!nrz=YwdZ7!#`^lX~+9cK_7!QTYJ%ceq$)Z)Y2IIB@Nt_ zzg7vaD-N%K6OD&LOUJK3;IH!2VDPwzu(~l8j$|_I1qG%`IY@0^*YMK6p*YhOI!cq+ zkzMMqGp>%<_|DGx+`Y$Brp}r6zE|U+B7X@1uuTe{QTw?(p1teZi>Sv@AfEHgoxk|; z>3n~P>3rbFgWB*ZnMS)iE5Wx?3T`~r+@>=2#@I1g-5N)^_4*(V}Y*PEFpVgzL2^QLt_!;sCLM67yN zpoWt=mqo#;$3CoTf^^z)5}+9ASL%8a)}9XXGv^fAPAz-YzUiVKGuVr*IeVPXlpkVP z88C`}A)^L1+3@3UJD5FEmfm|(!j5H~nAMg;Ddr4_Wswxbvf}md&D6A*K3F*&4|8iv z2BR1RSj|$9ZCuq~D@yTO0za?BJ(VJ(Fsj-nj%rc6`{gnXvv*ALl^0PN7h74_k>cak zglx*iTWeW=(r`a5$fxC6FT#HQ2Ej2hw+MS;v-wojO+309^Ktm>O%b}-;>|mcNhQIoCiqcS%pocYyE8dDmvIfpe0m#y^s*Pf)5$5uSI8;i+M-yMQu z%L`W z?wRsh+M%ckQJ34}lOPe#JeQJWMaJi4_Y43f3b^ZeR)@Uq5k&Z09j~8v>5p8uAb?3J z_rZ@`X!JSK-2W>?LU>lYZi#HY%BS+Nd_*hW@K^cBQMYMFygd^ zPcAc4t>rgHKFG@=Q`_kQLc5r4mkv#_ggR2M8A&(0Un%H(*B=fH>mB0w3#yv=?Dfqj zzK*hmw8tELt|-2~H@};DgUGjijWhH5pyNb4uT}FFD+=(?sFqgM?jD>KuHauTAdn*- z-$KwkR|(R;ULM!>qN-ikaCmrn7B2>`>N>To6iT5#Le0gnQe>2t0=kyKq?f+d8GmGt zf3K1b61x0R*(D}y_8EKnL;2DDhWF*^rMF_sO-0pG^F*BJ0l;ivXIS8d7JU@yFP%#n zFG}(>NmEwFiuG{|uJorY=)txCmtiw>e6-p9#CCtK;pkONsUjujM9$}6g9?nfqZLUX zxB_2{vjfgaJX^=uD`a`@VEpt=*-cY;KtQm$6uJ%Zty=$FxDi-~V04_c5g1pIA-(fY z7V|9~X3Ym*l~9B{y?6h1)|36)V{!A{KdNApyqOnrW=nBH3qNj#>l0r#?fKiP^Mz9Y zzOQQN9bA>+AJrwa8X#>D62WB~p#; zF-mR_1`YW$A}Ra|>PgBsLFCyGd}MP8%L?kzDZeQ7MYnxP5J^u3 zeSwyKec<|z@BQ_h=*axOk>@^lGezye_h!r5xxKkitqzCTV$B&eF=W6sl-(2s6{Cxz z{k;u5Zx&|7Z2f$^Oi4eAYF$QJV0@CDt)l>+4lP*EWIyYQ z-p8(XPRLGVXR_3$QB0u;6%$W9Gun3{;jL!!V5k~_Rg)$e!MY#lW((}XTEp{W?ULT- zM%qhB*>ujj=-2yiU%YW6Q^Ff~_9Z71ZyXKFxOAC964>LtONCq2KAzq;9YHKpSXWzr zk{?WbMT+W?K3VMWUcNI;C%&wGSDLIz%Av+QBo+gTRXOQ<*@q45=mql(n^ zI1EtW_em|yCaJHNgK~Xv6Z+4KKixq+dieAD3l8tkFT$lK@Q2bCYzLs(hf&J215!fv zEF|0Y*e*t~_TO#0Y`bZi9QPGcxg)4;eAh?q93^0#o^;V(|m zY%5--d!Jg*wP$m8#8+4D9*DS}%wgKflo-4((qo8Hx>G9iH3C_0<~Z=Icrg>iNfTS_ z>#9<4=<`}${0;<;vBh2KKg>7Hx{l)7xQjIFEZf<7Xs1^luk8;?buzR7xR!d$wXglH zczqg~%6mdtEs>FfrxDL3})MstnYmd0ZvGEE75!G9}?ft*1^bB-74W8uXRd<#9nD zUKTt-MZzxKLDWdtp`iQQIRy)k?q8Y+c(0h35yI_~6Pz3c#eZp4r^~zRwTDb;eg3vD zPA-Y_^?4moPpQ%;oY8jRJazH$gMCd}YIW~xxt&<36wUI@O3W6OKU*@mBtB^Gb*_2;x zXwWlv44oR0c{1qZ%M=G@$NqZ%0$;YSAgjl@rJ#~l&5V@a_E|OuiKaEBbMpa8GGFFw zn>;5SPv_ht=7Pss61p-P3I>^e7v3HG&vkutnyJj?e|d0dwcyHBezW=d@~+{dJhM0S zs1=xpbELvd!csswfKi%9A8i}bX26+))caHOdH*a^0v7=aniH+bK|n1|B>Atg-TEhu z+#d{BaHoT%T=W7+ZM(C=)M*?w=%L=$EYh`VQSK>lpi?`!1#jlyf9&VjdS|tCh zvrcc-?|YwYf2@_5O&iHNTCrYh(etr8M9l{mu{~^Rwu1E3RVH;)JoZzk4s&@9>Rn2zjb=LXnM`QSHQs80uPW8PKc$HcIqbnd~o9}zzB`7Y429;=f zLnR!nJ6Wo4^Tv7GVI-bUNsq4k-7$RoID#0#G^kw~nLyvl5^x|wWi3e1&5_AL$A_@b zyIdvZ^~Y|Z57PIzNgYW>w!o`xO^K~uUFa6<@w;7VHL2d;tjCBg7vG%RTc3YGIpAuP z`x7@`IZ{+;Plt)Os)a}>zC_1VaEK)M%!95 zgdtbtQg+cN+DVKXuK)$p15L z&RvMY)tsgY+$d7@C|A_WW0!>80c>5t%*{)B2CM3%4pR3{i(sGU@Zo{H*EBNN+XtF}hI5B{x70 z?OCLY4eriVX#X?rntBFJ8Ky6S3RA2JHW~1ypo&^E$L|P+Sk>J@hQf{MGNKXidd#Yy z64TUK2kiX^wCDJv2TsiO9A*;TLKs~%nTsK=2cr|aJ71>KBM*q|;NfHQR|KoZQPx#$ z?=88)@1P12Ej94=w(n8^;vt^-3~lcEO9?}cq^kkJ8$CuSep7pil-cIC6WPkIjiG&& zfZ9QBSBlkKCQiriW)<_v|0&b=IyGmWOvPA0Asx0PeY-sDgaIj#IUlTAD})1*u)&x`0OQArH@#~j=Dy(g_ zrP}D7f;Qq`J3)P9fr*(aJH5K<{RXe5tXoP$6Kd=Uv?BFs?hPU98gZW`4#w z_bi^x{NQ`jKxW#xLW45XaIxH#mi&HK%?5^n%i+OVrV&uYejv)mO*9lu>7&P}M8+3o zm+ZiHeYVRYdm&b^@hm=;D)K&xo#4ndXJi1aV_-Szc6y8+pEY%wUTq9S^dhpfqz<>q zvv%;)oe^*nZ9dDvH_;CTDDzbJ#~)AeOr6y~!>r42(YK=&tvz7Y=Upg#7q!$UNXQAm z$F(`V9r}*{lI~sR)UPZ1*#oMJ=9|x6JKmIHrr!*{veo2x@7S%i8+pnd?0F1Cwi#0? z`Rv(?PKYH20Nbhu!0MMl=P5x&00Ah7p-ezB&d<6iH+l4G?U|=v=;qAw8A3}tQPJ=M zwc7!UCQ6O%vK2n1*W_YXBf7L!_&iD`2dIn{I_yl`Oy^y_#{RjO(TjnK@i?I!N(V|V ztxD!wSLj(8NGdeVzS?MMh@)4kEB26%|7|1zmO$u2z_EHY4IJx`WXaeH;HHq49}LZ# zAixUOldQ3A8%&+Z3`6iR*vh-EjWUTAF+ZQ1O8(Y^WNKiKJ=`a&1_A6X6`oT20n{HN5Y-`67(9dXBJWK&oug!CH99k&m8+#lU4jdHyz*{CX@as5{`fKBsAU=2B4(@u z3e`FW6=3aUd^fcoq5?z!pBGf{z&Acd0eULEI1@d<_Ws~_Xb(1ox>58ziXvh_N-K>T zwVOkAc|zfc(*bYjTX3HnyuaYYe7{&-vkNd;drf8m>kXnrnkUG zQ94aj3I~O@-Ch2L_^sTFi|aem-M$fctvC!Yv`yKPZGKr)I<3qE(=$rnR=LCOpmwvV zMrG!vtbG46P%*o-{-jN%Y`5d-Z}xRyY*k6YB3S)`2xO%*^ByFggc6kGncIxz1?+f? zSzzN!wi7RjoH3ES_^oB8DEk5CsaMa)Z(rR`Tz(;0=S)-5M1W<~DOL07nBhAEK$7EwXKpAu22Y}`ZL7B%6{pNaZ(j>+rdT6?b;^a~_V42}3ZtCt%7z_R z4qiymCGL29SrcSOO~OpPmI&qugbtO2M=mDqX|2w7m8-6uDW?D&tNkJXOTgG-|xA>SX1!5R%^`> zRMy3GV@SSn$}A>->1BjgU%Kg+^oa^jj%wNa;%(dG@M>DV-?l|O^E0sHVOt)e{q_3+ z6%Xn!B(HD2kpFtITfZn0<&(&2W=^BW5X^QkjSoyjq*l8Te7v;%H=!tK$Mlhr!Ppuf zhROKvGb^B^-wZsT#Z0s+-9hi#9K|qWZEvAAW!93qejLHz`2uhuVIj3r=p1)WUXyQf@r?A<2}n;9|CWWvDDPE{ z!3%iiDz+haP`A;BPK+DaRT3&}U!4Jcbr5Ptp0Yly^K_Zk9OfONH%N9$9zO2=B97 zK1D$bR&O^F4(4|0VvcL?v3XxeHzfI6OfxxA2{lyNu|#CqSRh^Z7M><(`V$LO7))WN zqCBO5yhXb-$|R*Cx|My5*swbzCFU^`&mRBYKs-cgp`3wn!|4Tdp7fQPZO(3g zAjH`9aIu=gRi3^2_66{i^;sUWTap44D45*kKnAl&J`W9>Dve!SH1|&_C%xqfurnd} zsagTKR{Q>8nhTW-{ghi|B8Ud{hTI)`=mCvhGWQSAqRge0~KAYwiBI_Hn5-#VTp zcg+GC=fZc&OVeJnAh$--Ec2byh3)g58DN$P(_e1C-k6jV234D9nV4s#6O-c^q*?zF z_un%AAASVNnGBFSTm$!qN0c1I{E&zejW;Y97oxzbjgB<5aJ9;x-}^NC>qGf*q<#hC z1ne(of3x>L{D8Qj+Ztvp8SS(l!V{N7^6>xIj{k7iZ|?hJHXk1=FsWPVG0Hc5PWr=~ zzXs>G)%m~uP<27AiDx&|Yfz~|k)41*)-K-_ZVu=D%RdIvYUL@prIV#Q)IwA6OwCeTOhOS|IU% z>HH6$GMmv0R5cc^RDS(y5qbG2Q{vU5`~QAcW9tB-285AvqW^m2a;O@N@+&5&w-Wg; zg!T^Pm>`7G2|(w&=V38%2mvn z#VF+%Ho3^1{Ci2e19^u^r~bw2Xr&tpU~|fUwVkW4xJ}K1dReR~n`}dKE7#-olLrie z=f=s!(68^C>4u;!Rv9I+Ob ze3$xPOtdO3x_q9*E1*I+$7OF|Oo_KlW<#S-YB-r!0Y`Y3^TZYCr1E)A;mAR2d|Y>Y zTpLao3WPBKGaFUKQ0d0kb%FlVQu;>IKtsy4G}i?#L7;sHLq7=U40qgLg2!|V5*_-4 zyT`V{>&mHNC74BGm(D!~XcJ;^50}DLkLlOw7s=xeRHb#AG<13SfliB<);B{Lu|YsM z>)%M^<6QB6K!D|2o`?OgNU@_I5jjyz%4@xhQ9?WhQ{eyJUH>P23)anaqI}c+RDZ?) zKM(pJz6cTmx~c3wE%>j z&#n96v|G_T0GwjL4WOk>;bJf=>)F~8Tc8O>C4e&0uWOk`2lVMaoT{L0RA~Z>P8Ph% z&M|KDfmU0qTLZGUTi%J|1VDB4b@*ZK(OU-$#{Rj||La)3evg{T?OKr(U!gq$;W06NmT@Lug!jli#x!+}mj zQ^xVbeTAAuIj#o_7RJAhJ zUd`Jikf=7szmXiONPS3S!@Rf8g_z~{r|L;E5sedC`uzZ*)IuZv+vE8mh!y}U{um&l zAHYIZ>L%2GAw4xN{}Mo$ivS>%g^#<5jbGlt&h|Er0dc;(1|4+~n1B0-j(ARf^{c3C zXfct?bPa$qjiZead2Zd1<_6&StpmW9u|O)-tggi+(91alv3(3Qml_Pjr;UzRNfVj? zwT6!XYN;%GrV&Hr+RbY-dAZYYQWG7k#aH=)<%Qn^5E5SH1>4TIP_J8w+bN3!&;Xw@ zO&Poo)6mek3>cGt4!bxW~xKTc%{`$yEFZ&<3Vk#*okU=TZds$A6_d4fqAG2!jo>fQ}i%if;$$+4QRM z?9OCZW~@dhb?;%3P_by2@|4W~C^gjAtF>hU@Z*m4+mb}wD-E0{G@Ztk_>=5DBBvj6 zq}bIaSx>xV8McysLXJ2cS2=E4yitZ-9B>S$@T<&duwYpii4|#=@txFkr~zHarB*ZJ zlB%DF)^ffF3g45p+ySK8-JHF>E}*p6yl&p5NaL$q!cOZwpk1ogggg_a_i5CVsYxGz zU%1?*zzfL`p(L_%M6uNq`2cH2HAZ1;F)C6lfmRd+V?YPgTmb51qff7bMdww~`_1op zgjv&7mMQX5jo<4QeON409sTFLPKKk#b<<1@p6R(O>VMV)T0XZ8(kmq=HbalIUe7l= zA1{5vO`fbQZ8_H)uhg2mBGJAYltmz}7m?>BfBe?}v|vEs4}KH?@d8_*HAu~N=`WxI zCg*&rHL)q5RS3TxM3Tkb3z$fY1{dC1fdA8)%r)>;j!S1LmL@BHO+7iux`}vWTRQT2 z&JEp&^$nJ1mzwBI;b-I2`(n$DCt zXhXK9@swa&=Q5L}d;DS=3pSi_iYUFfMZ#o{271YA0*x7n1=A)3VfRA?oB+AMjti=` z5#2ji94{kmt**&4YVo*|9TgduH+Re#S^UcX$nvV{de|H>V`_yiBHDK9u+kOe!uPM1 z0ldB_@vsurADJ)is(+IOBxrQf#J*vjXiQ#)?{sHsL_^^1cwSi2M1`sB)u1ur4ROjt zN`#b&l>UVKQ8%#?9eL9hFop2|KK!V1u#5MG%T$h`vvr*|Y4v&z(A{15Yd2NcEP&)> zx>C4fWy3e4_Db`GJ|_$?t!tkNlsVaEepYMLD7D5rA1uskpw0lWhByBobMG0}WV*Et z&p6_YUdN=Ip-qx2#m5Nar+^bVl}0s#dAgg_7i zgiykF#l83J=lh=7-=6p1dmJ46GIHm>?`y4do$FlZx@&L6SG(ByJZD&(wwN=3r zxC=1IPU;nIF$KY#w;-_35mJn%ddv-1V{^XO$)KZRs*V-C}ehEQb$ zM4Bi&^;?3HZqG2*oS$;4&9G&Vn0$4HQ>F_5oI@>y zz$f4YrzBST%ZXEO*`4d|6U9wiw^cc5okkgHmxwE4&R&@-f(9M79FdRON>{7F{HPD9U(%{A>^zuR zV4VaDoveW0^Vk}H4$eiU%(PwvcYA3dgD{9z*AJz4NovvSo>hMAX8L#Tw)$gG7F>24 zEKNT@9qNg;3Neu`)Jl`^tm#V?@8v!lQZZpq@oGNzT8l-NS_#4$ zC$hLXhQ&YQ(y?c^i0CV68d+p{M^KKP{Y3E`IZx_pkAM6 zJljp-@u<4J3Or=%>TRiN}KX~NCB+5TXoc4AHQ-bzWkkxY?sdFIyg zaI0BC&=D0J)$6_hgdW(*W+&R;eNqQ>TqjB3vP`modmEa{leH{ zf7*{znKVV`tfB3hdB!b8yL9@(DGU#`qLWJ?{N9ziE=@8Bpjk)Yp>>VuD6Z)suL?JQ zf2mjxf(ZqDt50|VcS@u{Pzaai5AN*0m9I*Tw5scY0N&L5Tj-q8m0@-1tM{YAjIXse z*)hnda(W~*9#QwHEEs0sHipINaJ#gf&rXbRyeQ}WX%)m`PrU(ovZ)V1yN7mbi|WWt z6g@v14RBmljoE^~x#WU0-QOdW@EoG*N zHogM}-3Gv$d4AAU0J|WPL2TaMU2onm<9-8_>8Wxh#SF611&d9mY4()<;LmxY+_CwI zP6|f{Zb37b-*C*u62jQue4;!G+g#$k;z+gk1*qGfWL!wXYW+I@cVO(OdvHTuoKopi z61n9_{zd~PU=%F#6AA*>(Q%7Q4i-R(F7o7$pYYT5RbXj?Dq^k`RFM7O(!?Ejy{27M z^9pV0vO&=82FJU9_C`Va6;?C<3dhQB-c5iWuT{I|Oi9|Ia#0GEDZ>pO4)~cSBtSBf z)>W1<()SV7Spb@+mQ9K^8$&%39cw17Cs!r-2=;U(K?n2Pl^}Yg$otjUs-%O>s%pTV zT+39vrSusXO~SqS<;?f_cfIZ)Zcijfi>^Q>(0w_X3&R4!Hma{n! z4A|HtolQT1`^fP3?&H(oIJ*yp6Cxi#sm`m8aYX_6!NbI-y#?SAT{75GZ43f@iTbcJ zD90Kpi)s0BvCv)cGIxU|Rfj$I8n847#PGl~h)PtDC4kA8zv_4ptZoi zRldJaat6Er$mydWpb~}#2G_#tn5__W$#+6dq~H$s$vTihZCiqfXBsF@Cnl*w^4f3C za2`-TzjG*W8b?Tw0P0Q~4-$hx9zTUcJ>Rt);Dk}xDv@7#sfe1!9Ok$8quHz)G)Klm z5Del@c-u}+uLQ7$brf-B;n{aW7;!3Jr&IKFgHCCqE7k)Y?pd+}?ATJ(Wx>}LEnTy< zf;%e9fil)5>OE&E((p3$zonf~b!{&Bp@?*D!g75^6=L zynoUqmSN5Twltoa1TLRi1SwJLzPDhBWwKGo&WztPX;9_n>0~M7f6;Alw=)H`oK6Z@ zP>#$`281CXY5?QMs@72IisOr+*AE;h0mXl<9$9oxF1^mk5Qq2s~iOZR_QlDHHIo#GX1T@PrDNPV!-^` z+YiJE{q2t^{{|@2<7{dFEwBA~(GT@$47R>olKbBw_+Tcl-AFVN^8V87MH zY;1bD6f8%7%u%43a%B&$23ECySOpv6P=Nf>G_uK%V>keiNyKeY(`}SR5b^*plA9bM zeP@jMH~MahFK%vq{wF&)k zc+FGK^P=(#*NewW!z53-nT4ft5V>{fCjSHHolQR)#u739@$prVW~!%wm--^k#vq}% zhFjV-_Y|Ah^_&(O*=C@JpQy-b1pJ!eTFvjhTXdc@78j&NRH;er%eD`1e+>XQu0PT> z;Bg?Jmsmyk4Y`m8bC0dAh&=KJbP0v>VgC5>9yO)%JHCP_MfoKL=J9efVYsO)D5KHZ zXQUU-U6OE2KE)yRFjc~d-)X8Q5!VZ6<+t~dcJ{#Qc+HwNdRKt`UEK?_0MD*W?{Z&n(#q9kz0j9rR}eA>HGWO z`_rq~WcuWPG_9|^F8aVkAXy=xq3DH}|L*tfhk=9dwbwx3C)+#ftwjW=drE(%epkCUVgZ|30s~=N0~-tMywDS zbk`#n+_vN_&i?gV_*OPJZf#Q&c|Q4;`OE3&t*1BVB83;V3nSzm;Jg4%8uW-GUXB13 z5LZ&(EYiyq{>rx2u^|v_y4QDZ?OQ%pW!)bK7{S3+TQb&568cqTR=^K*_fxO`v3*6_ zxEeJ?L!pDKnCg9>z{>xr)w%E1SJL4VM~+0j-6fAN@_;e?PnV6 zZ$cYJCu~JGK+)Y(t-pL2d-1k1%ooo?0US%80jg6eTuk4 z(ly3#SL@%`elrmGWYJEq?^ATop)}gSnDTkU8Hfo%g@W3cH+XB_>7SIj&eEPuWr=p4s<%A31?E2ehdk@D2u;Pgm9JOnBc9pn4s)W*VFK z)N@Hf6_75jogtKUAvf^b7H&1~P+QS@o&3#TUU9Ncw(v%QB29e`GyV z{nAFjIFxV4dVCH36$7QT0#2jftjx}jMxj`Fw)wdb%SA!cMOJq0AxX|Q$sXf(G7b?7 z42ipDwMp9m#*hj^ru-6x838S0f!guB5*3e?h&R98HsTlj_B(%1?zXOQ`4!Xb;sd=| zK!+Dhzai_%e!xvMn1P;1qU{$MLi6;YukZ{b3?~wl31!*Ad^5bK?hcX@in6gdcf#LYRW-tl3$g;fOw$nyyE@f869U(wITNV$3;J?PPnq~`1eUb?ShXnRB8n^>|2|XT9X_k zAN!UL+nhGx4Qc)AAvJb5i_MbF6v}XEo08Y|nAaupnG;x3Vr1U9w!DHg9}E*a);ua_2~&X3Ed z?ce*pmt-%qdyvInMr^#bWH{kk)~N;1$|90d?vp4~M=6||cpF`gz5hm+*lG_ke2nsE z*gsh4c<1uh5w^?pkJ7Z9dAu@ook33dL9pFOem7+)E6dg4FcCQ8>bI9Xyfzc*Tcr&U z>_U$~xhCWY5Ky9}=O$T{5zG+)7#JePUH}?t6d^QcxXb9I^~Zkp*n#pRuje2j^vqIs zBuORXpdHyuC~px0`*ea(%^u1@+k2p^N+FH3qs_jd-+kIZjo(4g9X}1jF^=RV=1;u_ zv}kWk>IZV=oft)`cTlEt6l0q|Aa2tCxji#Jz>}e>603GyQJR#ij>uO`Pl7pKNHc2K*`h3>hgCvm_AyW5fJK-hRcR=^BPQZ|8Z@{9HW zHtcei|7JL?z{saNBOlbnTAn}sx*k@Tk@>6hB#lt@t$h_|}I!BbD34Vu+X9q&O{wLNR5beM*0Q|_54kFuQG&MYeJZ@=`VLv}>! zUQ^bq`Ye?jeC9FmB9#R1b`LINR`e$TiKQit@llX7C2qa$K4rwgo!yboUGULi7>6PMJ^Yu!G-*hp_OpwFRDUR6x0ei!b_J+MTI zj~4z9eY7y$_ZK02D5CWMr zo_~)m5)_;KYWQh$$wabTfEzh+s!3?%z@stZzFMzH%8=O~m;ht+%Y=OfGh{TH$5c7U z$}t4p^ibJ^ud=r@mirlNiur|tTu#H=y35|aiCT=X&=a#tq^=W!Ibh9K)}hr(Zt1tW z&wluX(U&@a=VAnqe$#lP;rPc*F9meNYRv~a3-O!5#h1FT9pZ^;F8#4NrpVGJI{{${Z{}Dgj74fD0}P&d zvRpYAny_&TDl;GE#rrPIS25gIaYV{mPumePCzXjc_GopSW8Ri46B6?D8QoG17>G^< z0T|SQ2!*lJw7lQk9&mJaCbqGsDqOhb02Ny{qftG^y?^k0;hpMUulu!PzvTUU5;(;h zm1CMuD`yLFa!`eWwDrZHVlYmn=B%(HA@OP>8oYShq0wfydTV1*f82{PH?TmpT2T%ct61We=V$+fc1vZZRhZ|j`_kIND-P4_(T z<7CKF>7YUDqxxCXHVJ3G{F zkF%=I2#*BhJb8?cI44o=v3o!o`6Hka|kU3A}fhH9~!Bj-+R}=4IrppLLE*Ssls5iunD=ZzN~Q{ zS=xLG)#w`HscQrORe*WX_ehsC7M+!Ur`!YPaL;+Oug;`Mqt+!P?ai2>k~?T~`{*z? zCWSD9>(uZTba)DFp7x9M+%H5gJ^2pFBat-LFTr|^sQkL}9;!cHp~=ixm)u^}V(V-1 zTLs8x9pZ}i_2Z-&&#Gc{=+|wGL-nBW-J&w-v2%HAseJa0C9-8uuI)g^?ZEtvuH5vA z-3}KyWckYL$u@x%A5*C5>SiH6b}yY>JOe*KD8voz8Axh+`IG&7+Ye`(xP3~qcp6zA z3#e=ZNzG0<4@7r(O+vibuZDaw+EfD9qGwT@{H{ANCyC@vNq25eJ4!Xizf6m5h(0~J zcfaZE(jKKdKl1WlcetPOy|oL?KHA1EG`-__%J;SD5A8{nx1NIT7`p-r^b99x8i_pZ zdaEvrCVD%5hi#=f_pc&k5m0r!+*;*5q$BEUb^g_vUe}T8SkDx9(DMzS%8T=SmqT#) z4wK4V%k2LM76ppKW`q-QfIJcR+k$9rkXNfGEIOKcbmk0&7}w!T)))u;-A1seq*li) zU^_9TU<34C_Z5St{G*~cq$~B{r+ueA*D&3n|6r5-p7Qjr0Ut8U@eL)WVDauP;pQW% zpAQhU+#>j^d=IrnW zEYn6z`z&R6iflbZ)>VEksk5AWR`*KgtkvKHI;^^s(H;gEfoJZp z2Q4Jg`ULj^^!()Pe2T+CU3{%y0rVtrAFBpLNR8uear_s2u)-9AJx>hKsqT#!M&FBu zqnTs9p;TLfalUfAQ5a1qf&+ah@i$}Y%rI7`IS=^n4YWmLiG(twHjOeNS%7&>_ySCT z!B9nX+-h$#5QzG2knz=A@veae1qrgH*4CHji-a%GYRL`AsdMF1AMGvnTH0xdPH`A; zra^L2sA>mv0&~Qi`%XiY2($mo&NHHG%@0?B&Z(#8X2UF#w$x=D5lH(JgQrjL{SQ?H zLRsJAvqNx6gCRMtm#3)=(`VM7f*A)avVA-3ndP`I@L(ki{W9GrTn4l%DQnMy z1YiV*cRBBb1bqeVL6JY<`d;|CQ)sr7e2KbJI!+>QFkPhB%bZvAtI^2)~6`JhGylPX{#QdwKtjV=1&c%K2v3B7BQeA^3 ze)Bp$b~8P##J$7tjA%uPbB5`37SX&j_`pryZ)W{;@GjY2;y&@ioF)NzyYM}S%l zau2xtq$P}OlYenooz+=tdsJU5k{LrE99VP&4n+@W5nJ%D*wS&b3pH)&$wJm{**7Ap z&p6f3g|fnK;mHkkB7)cKdbQXPR%Zjwe-QpM>pg#Z#Fr+}?Aw!YErL{3^t#?2?fHqV zz){(tz%j-OEqG6^T7|`lX9=u1x+PrbWV-qssOfH(y3})drkq(C%v#rxa2W3c0*u@e z#|5I~s*gpi2Y|4hM!HVGSi`e6TUev=G>q&^<(+aymMU^_48uthwmePD0!9YK>9%k< z>ehh&m{Hi~d1A{^;IJ9Sp}m3DfV(SXQqzYR%`5e=Bi!+gq0Vz3ohxUkAJQ!@-YhVB zQu$dh#KjkLSisit*jZGXo0M2)U?;fM_z8z0cEMH+d1SQZ6g0}#jOxw$+6ITPv zFI~r7c@^!&>jLlz`02eCLzJN?WcsZZcg)MhmdI#x{PXGF7kp!w!aOBH&SZ*8qk8{w zqBh$notQGs=g2`X9+i{6bcML%W^A}W&?XtP|GYUft5{v2_pkZ7-_t<@=u5pJGO<~6 zT>Unr=s_!pzJg%6lw|4VL^PJ=E`nVYSq9DQekkzSmf?EN{b-RAhguuRhvhOPQH1%d zdwz}b6O)Rrr&%s-8pvlTdKETA+z4-);~Y1}(<6UllwQSQ{Ns72VKw9c>V4^R4!|W) z_b3-#=Hd8_x#vAwk35@{+w=uG?dVRjZ_&jquEYG+ct9C{AwV|(bT4{A)7ZjTjje%M zz>#dCCU7A0tvSjS-icprK5&gaZ$0hA{71kf<21b48&T?J2U)EzWG-|R_!VGYdgnzQ zoivGlZZu2rOTi2ZgNEZZ@HB-ON#_yUtZR#!0uD6oYrbJMs7}nb(EE@ZdLgiptwtc0 z`%+W6*!=NKes6#-!RTq^h^V)wm&|}`S~n%R+`ZoJX3_?DmQFqo%-kKwcb!@9`En1_ zXV=r4&u6IlXV2(l&kTx$=ODG4W0$y)#T!zC^Tjbts(U$dON;y&y1nLx*6lTYO-sk; zYix(F1SOeJq_xSKM5e%N^kR&v(t|3ae45D**POwjoL5LMUn;O?>ZAXwGYLl9s=))S z>YQgVsOXl5+Gcmi56lSPKhB+#8Y@pQd0y$)+-S8r4B*)8mtR!DYI87M=eS~#~ z2WK?$HOSy-_u=e$41Ii=@#^I>1^&6W?d8^iyhbq<68=CSh%Tt242sTUaPBpMxa4PN zD{r+c-?F8PGPrDipuZ*>0v~;(q@FP{2_6|=6tm@N2-unIJisHG4aQg_fG`yaip#mofSt$E z#Jc%D_nITS+8aeLYhEujPkWfW?1tMk_c}GB5_^yrmI8nRBVVtg11gYi=amDwp__G< zO~Uc|N)c|fNeHedgY|w>|0B&hB(Bd}v9`G{u?>bT^>1OB>Ss#K|jtKIAF zO^Nt@sL9Vu8b~V+^}Jkr|^_lXR@64~s;GK`F`IBF^up=~b9$ zi(r>KoysRUF-Z;Xi`uel3&Moi zmKMb_<_Ipo)!RXsnC0Hz$tsCX*e)SqoOuP#!2=x$Qa$KP;w`9KsjII+?Vd)c5~{rn z^{MGCVYBB!ZOnHXLsXDurY(U@DdnZy_0T7ay`r-?36L;fL9<1<0=9Hpsz3G$A^Q+7 z^Q}xwUW^bKt6g81T*@AeyMzm5>^!`=Y&hm|m9>+BOA+gCxPfxobm8hup?(^yuUEH8 za7`8(jPPiliB+KP&Z`e2fr9MN`FS;S5tqF0E@7butEeP?bu977USiPRB^@VM^2<>} zrO}nsCz{3`G|Dl`P>#zh0aE|%0Y5gpjv|JFeW;+~cl{G&sX|rO49-^$_{B;#)NuR+Zxl>bJc_ z>4TK9s5WL{NvEB+7{erXa7+}vP*F!awJoJ$!XuZQ;S8y>ItrBB7CboexGxOCVu@u3 zDd+Fcxkj7|jgp9F)N#UE0R0m=cHSsvn3V-XpO|#pr2Dhk@FhDiT$&DMU#RJTNxD&V z4Any27A{)xk75iudM|x(xaSUNZ0wNzf%vrxD6^M!9B>K&;d4gEMxCjSLty=*mznGh zOxy&JU9IXkgfgzlXk>`n!kKFFt_I~Xa@~0C-@oWUrCF)i5Le%FD(?&!eK(lLsvi9l}>@JU}!m6qp?J1-p=Rfa9{2-`v(SLgKN$Wm;QRZE!d2%QC%q9Vs~nBRN*9f zaNp4C{^xz>V(J0vh!j|q;wf8B_j^zikXsf&%Zuq|G)#lIL6G$_EZOUARR5VO0W;P; zSw6)PltONQmJNnosIFR(9sLAMA?g4~jb>(r#=_7T9aJD_G{wIntGE>#_RFEQYd!9A zY@|xLSm^mTM%*~L4sRryGs5T+b3Gc96ylQJqLY;%AGKvx^`Sdva>)3^ygk0n?nX3dT)GnN*r0C9y+ ztYuBk%X`ewm}l*3L=~}@C|ft18}cf7$ZgVru5UI7$f1~Ey{0GE(bFf(6NQ4BxsE^w zw9Nf@`F5H5k`GAPj1bsZSBJ?Pn8ri3DH}bZEjBCiyA{l%G4bC4ebff%Bh9&j&cc<0 zF+6{f!h% z86+r~J&#t94d{3sXb#j~Cm$J*uLd-4G&GBl<@e=E(aZtX8x0B!_vQ5{^ln*T@?4)P zpd!^Dr3sq+y*>^8+LA$I#fEBwF{zTUUbpa{e>t%`cw_I>BvADq!QNYSyuQ^8h<)>{ ztZ7%S9XF(w=zpM=3U7WL?;qZ9|L_rVeY1I2=)sWD&j^ppr!lxm5Ksu6t$w#}VzBTv zE=|^(X3eGuaZab<2O-XWfZmFj@_qOC-0pgm=)i;2`GAU>P8ft*Dja{0V*>~P;Q$tl zD=x&y60c*lV%ZUNEbAH;K}qCeZQN&NMSQoGa|3*ZWi)G{hSk|jJC9tXLghUrrc}b0 zfLJ)$AO^uMbf|*Z9xWpP8SsMYQp?fdvpi>(MmXE%?tE;d@%iqLOVGDZ0YjASlXJ)9 z&I%S;dr4>8EMd0*V`fT1B)$awpdNSG!u>7&cU?fAZwER0QHdl23=3^!y-G!J4wL4A z`trg$PjUtijE6@bHuj*^$i#^Ul3yMAB9$fLvYBWT%oZjO5-+c!7&jt~sT`Htc(^l_ zZ*s#=!M0Y|_e@=>_|vp!&Ts<;R-pud{&$hz`M0{6bmT7kYSZ0qmLox(0lt&5r))8PWbhi$^+H8)D0(ejuc=BKqyHuh4U_O(#l-U zU>|0?IACC?aoC8l8h8CL9dCFm4k$9Q{+=J5%~FJ7UAf0A-vacgInwI;AJAjH61LA+ za6p_Lw4GT^p9kBLpRmceFTa(@hLxNTZ2z^EQ3G>Y@8*#8VG~PeA7i;k#03X~OkB0m z6`Cqn69^C3qH>H7!>l8qef$Qm_Z9qo=5^zud`49;sstFP%WBXZLxnFL(ip2a&pYq2 zINQ42xkyQ&2c(w3%cWSxLg+2+1~rZinS<}G{k_kyX2ta9_pItb1cu(A>FpZqbKh@_ zt8)VJbB1lLu6>cPED-(L#N-JpiPhmg0FAd@Y*DWxP6*3;aqc-iaU?^?U2fY71t#$p zVuKEH`~lr4;pvAc zD3y_)F7{MnyQL36CYZ7cw z+bveNF3u{}c`X}&RE=X1dhBIK-Hf27z3@J1tBJ}p&Xn;Xoh@q;_k z=ni2=nUO><dMv)9wL!IB=cy}RN@Lre7yVCA|GVoPb~@!8PK|kZspy^})z94`e!4f9>Ga z68-nM_FIUO*_M4MJMrXru0_Xs z&}J9ywufpS72ZHQM<5JKfo^5?8S_Il+DPcXjfWlw+RF866K7=cZ}WloaY`yGp?#V1CSbScWiTt5M4Jr; zcp_H=E)-~$qt1T&7cjdId(x9|BRhxYI^r`&A-bVSDKFq{Osq=S?0#OX=**yNV0uGjv$w+{Z51@rsfDvSP0 zbAMmZ`!l$^20yu<`QZoi7nk|Jib|yldf>A0$nCZF-xk|G{0YcfnSaW-`*-H#e_X@E zkAMv3r#XZD>mmIq_We)SctHt}hjdCyZ~y)$N&vADA#M})!#(G}{sG#ZpgRZV*nb|9 zzV`q7LjBMGs+}eq{y%*rD=c4=E4W<2?ARtq@!cRDSrue>#(|pgL6h&ch5UU(*^p8;uPcXCt1k{@ILu-HDfjKpy9|9*@0=G;nLS8NKRVLjv8-ndg9O%5 zm-uZao;4rup(v2RP(>G*!8+{$6oTA)}gNzq;SeRlq{8N&7*Z7yXKAS5-o5HuIe z51{H?=Dj@!VCTAmm856aMQ~Ajrs;T>N5ZK;Q7-hymi|xUk>9J8x@#V80S8HSAdkrd zgA1-SlFqIm!{C~21KK=B2HZ7ys3#Myrerf(vnP2tlJ9Pxh7R6ZQk{XH9NRY8`Ev@+Q3biNYnAwXF*K zFB)N0*`G|VzWpWGwPn^yoo&1RQOn&@Fxx*`Y^VV^uOz(rV_}WpLP`Se8-0xouIR}Y z$HjleQ>FKsbU?E@Oqv$_tgnqF8}X$ z9#>gw^sqhsc?N3+?5+>^S^M2c(`&5(6De2sPa%=*D#EqY8D%Tj!94F%7*Gs z!EcYT1Jn7S-J-;^Cx5$l{=J;a|67P!$$Gt>US;ny3C!f~wB(}9YHgV?7`C@liT&Un z)2JNFohxIGANt>Cfqrmyi$&!fWEX}u6A3Vzi0#ntBn=~^W>BaXmdGg71N@Duu^@|Oqlq&-vr(#z9u#hgo50hwD!&h z?S@SI5=v>Bksg}2MYRKb8m$xPdvWsmA07#Dw6Kwo^b>jw)gx>H+_C-Xfb$^TD_L>>?_4%v! zlUd$@62-~wY zy73EwEq)RD!Ln@W=uchpb$55Ez;K3#nIeNqWm73875{$NO1{ByB@eg2eBOQ$9@JB2 zDGzKUp3RsF)qOp7#4`%4Gw$shI-_#KTeL=h_#Vu*)97*9XkP*L9cWa2Hh7K_9u%`B zJ3_vx%|G~GIzlkw@$yCBxXSfgte+XqH&y{I&4SPB z&Ho6^1~(qMI%zxTXGqwNOn&}KMe)3QaIM<ed{fLsP;xAy|*|A=4~cuGB>;1ZWP3Tgn? zj6fC52>gcQQ=-DnGCNb|{FAuLj$9Tw_LcucVPX2eXAQQybReg9R1^|TV&Y-{BWu(1 zJ#=M`7y#nYk^ad~=B@XT91O34YU_`zu}Uiqnlp(aXXgW;Nj${b!tF8XWYD6B+o7v5 z@?8O{cnX8W{@QvM`(vOYO!u1cK^5q5+l_xr5p$e)=)1Fe19bJ$i=r0F96D+Jtx0hF z`)2{xzmD-my}f)l7fcPKKz&aem7ujsvNsTS(k?3eOK_W*Q#0i(D@~K!nmHRXC*6E} z1r*KeR!DIq&EJ8UW>GGPWEc9RU z-OBkG*`6d5=NsU;-M0GlY_{3;a2p-DsFr*#%4`~6>w*&270ZN3m>_J;=Vl9auewof zQ=lNbLC3gPeDd$_k%#V{rI7TamtB!%&aC1zPl2QZ33Fq$$88-knS9N%39qy&YgS?O( zTErN5CTaF1o!e#am2}_);Z6!XJz~Q~jHS)@Xybx_RcwTlKQts+5Y( z-o-t$Fp_lIQm_dt4M+E7h(xuQj zd2M@&C_LqAS36=}BSfr8iLo&m&|8yqY_{3=m-63zel)YB5IH4Pzk~?ntFRyAM|N@B zlh=2AU|Atpr!7%z1);*(O}qKK&S^eTKoD$YP#FN z26}j~knJUU7L>ATV5F#zEV%cvXjVJ}xboak96WJ0&Os-w`O~1o=iU6YGx0H|2_(IS zGk{(d@Eb!{oa&ZaNt@jd$A zcU=@~-LPjCkB|I22w7eIy6utmZw2((_{VSfGQE}4^1dIP0TSqsWL@kDVCZ2eaCC9U zd&;p&>r2V#+wTg`4QnISdpn~p#ee6&X@Duf)*4G+zwNrvr9S1u%z5B+;tWUfkko|J z^arx5{I{lbjcG6e5DpT3zG-KPcibajp_*s89Q6+Y_2`5846aUx@Ea~b)}J>mk(L5e z3B5-qVn?_-{eT!7G`X2(SZ8i{v5x~InxL&%yy2Hk^4eY|jXgj38YWaV1N1w@o%$|r zuOqf!1%rU}U}xT9KKHKRFEj8S347{L$qV>*{+Yb#4(D~lFb2ai>@M!gVd`(|k;$)& zH$L|4`Z0fH1YSzpBOkBM!0P}Ntdg5+_4y>!Ss-Mt=`S%9GjOv$6ZWqcc`M_kto#f5;XePp= zu_3=A>nb_BixL5zeDy+#x@AWcg{-Oot}ubXNsiiwxORDNw{1Dg-lvAGj)NMKGZ2;$ zy4rLgh|6sloUoFKa-~|uukR!~`=X#ss}00kj`c3F1^0cM9(ZMJ2Iju=2IvFZx;Jm0 zJ}1+1@W4QnadFSdE5)fEF%{}kF=Z_i1sTX*@Jj}J0>L1!u;s5@-cRzLW_n`msIG#M zvGO~Q@;n;3`F9D+OIc5(&yaj7P7%nD-~#X{WnnR8yDSMGe-7l(XtuxigL?*&5v1GxbW+zG2Y zGv%IIbmz5NG79dN8hj8&dzhL9+SBDf0WaGVwo|ERfMSRTcjs5mgo|Rf55G7-+ZA6E zNr&Xwv(A|t2y{eTQSw5^V*!3V+y;nAT0o~vU`;Z@l9&75_Fc<{uYvDJBD|#GMNE`VD1Mg^r~W@?mV-^ zk>l~L)OlFv=okTlI;ck*5=!hwzXJ%y2VR?khd~Xcr*+F@iw4d{+vG1jD<&8z^j5Ro z>uxE;qvzlB2Vo`BzBYdykwekng)zKTW1igFTdAghD+To9pt%uXP1YTVmGaN#Q0&|O zUDIcX)p<|uLy+%U%~G*~!vsaf0R1E^P62DI(4&(;2vQhax;6NVOPlc2JWFw7t_=5l zVCKoBb%T+~Ot^Dnt6XP3I40gI(npp3UVfUA@{^5-GbYZ{^OFwoXSUz)>^;^g*CN_n zJ2cI;bG!SfKx#l(l4~sbO?}{0fVGK`2lA89PIAhr`Rg|3B>7d+2eej05cjAJgj^Mw z|2XSBR*Dlirl?&2b0rlmroBb4-EmSQOZ~aOxN1=i3Jxn?u+MLWb@ok@)<}?B zplHj4C2H&o$1XmV6M1cC^723V*h%P2@c+o>Kj^ytLx{g{n zHWS^>!43%_dbx7Co50s_OPG zW*r^aC{i0tP4KR*!0c;Yn4B8t#Y&l%ncZ6h5)JBsdhM6fNRn6&0W+M8Jyk_3DB(czQgVBcvw$(MDfwPI+GDjt zUH1rxAL{9F4s#8>w>|8APdcy$9a4z9Gf${-ecQb)p}x0i7?=$^B6%ph0l9&#Bh~v(^w_LaF@^bSW}St&U})Wk4-F0TQ(q!GGt` z-l-*{CtQkwjnJpBbxXU}Conyc{=1 zanA|C}HdD@11td~2K^mAI99qff{Whq%DSadElIFc1Nr4z> z%uh=S#z&vBN<_P0L5c1I4a&)bCN8&}WGF9C&QA$sNa#n0$aFO*js1JQoWZjQf zJ^uF1e;8cI!68!24W zp!`Es?M?6aSD?DLBbP-Uf<4!;%#y<%?d1#d%=w0bDe71ZAcfyGtE98Z=8}``J z&;$iOC+k6+DSDXY8RGFXWF}QaWa?|+jl6xeF}*mhDW~u@r#bn5y~N!f)Vh6R$wS6v za1V;w){Qz)PL9uk>Y4y6dMPoh;5hNA4M_QthsPv}b`*|*Lm@Y}jY;Lk~_#0rspXDr`6(7WUh()t)$yfzhrL7IY=D|xl zO}aauyhceQHYAq2a*7`Tfvh@ppYB@7`KDM5%h8q$iySwCN6MsQ+RuBGbhTJ#bl%2K zJ`BPoCNOOiyuyD$Ma!(YM`x4})I6~CY48`_dDfjeijXjok{r2|a!N?WI`IdZG0(Id z!P;bJ2I+3f>zB9_tlTnO4!2~=vFG#bIdeg8iW=}0oZW(;kTj^dxGL{AK zno;TInH9i=4qTifHWThXYC#bqf`9onb#H=OT?v1$prZ66hWpCrb}+rAV24h-|Khl; zS_fP>3vnnIJi)J47)U8lJnTlC&OH6OJ>6O0BVW#-{;J0@6yOY-g1bHqd?dZNf;>zw zVUvqTxP_=2gnci4^Gq=oIXl)Xw`y|KINt7(4h?!n8K0`1&C7`OUrK4jbi*T`Aq|p+ z`tWtsr|_)yh73}T-7(_ZpHBYyBgS}4jX5GI@hsnp%+}hJaTL4cz~~)F(Zf!Z^4suU zUse9OFq-1yfHD}w0;K->`bh$L7$QMu2JFm1}yawd7S3vas z9b`f}bgccfI_^gN2}`z(-yXc#YQ7U_$e*jH8{$Y~F)j;cg@O67lTqS!7LW&#=KN6D z{4IV1V4WK3UUe~AqO;kRQp0@_|2vd?(oRr|JJktr8)N=gTAC$YCO&g)cU_IW(4Hs% zoAi2J)U1>bX$9T9X4AW_7nHSA+gpY>JL&%RERv46hT2SwVMMXLpR8TFIJ;pkk|-_J=|E3BL%v6)Ywr)@cqMB0qyEkrnVEX9|^tBLbxP*B(|T} zVU^m5%H77L7$Xac^y3h_0auvE*snotGCbOVZGoZO_;Z#RlTv{qFy2S7E%S9{Fg6JZb)hGe0KpOUNJ3-KS!9^$;t>^j_Hpdn!$z(pbNHUICDf5j*Y)vk z>a58gnXx=?(T*G{H=HIbN1fneEXd5|NXCvYwr=}f|+;L`>bc(&mE=Hl5Z6DW41-&w1tBg z<_5S-Zm~VDw|D5=chBQD6=Re@|KsaWIur2bGU*sNRVc<@cA)xvFTX6{y^dq~M!kwO zx=t;m0l5{T6Zinf)O?z)tPap0hk<%%+)+I84)ciMYaVa3JflZW#w!;ApydAIuq|=( zqgu4_h?atytJtSH5FC(`y${C+kdYQ!jcsbrv$&`HtobB?*TIlNlb06t$~ckb~J zedRST`B{sl(}rqnIGSrT9zBDae3F0(W`n8+9I?m1?RhNeVxxL&m9OB=-GTCXe#aHS&?Y!4v+P^-p&+z_Qevod6#Aw*7PPB1(TDm=Myun*)&^NlM zF{aGN#lDzuRL9*{HzsOV-w6-l6&UG;qqj-s&vbqF@JfiyH!yg6DlFf4u&9Q>KdL6Y z=!Q>vBSQubKSz_P4a?rpv6%Um23$Xs>VR$Xr&lL=kM)ow12+(N?@=HSR=g09QVV$8S@j=<4 zJCc+>%sznTutp-LHi#=z-pQvIqR>viezP~&Y~H~17i$}sk{ z!z2BBL7J4alNEWbq4)6@qssMa0X7Tu_mv;qmU#Xw#nBS;%qo?*GAa`>KC4ghY^pyGjYR2BRB^hn^yeO28~B! zHYpb?_&!0aSHf9O#%>(;2z6(OPm}o5nmUOmKDkpJ2s^g=2ILn#OfOGT_%%}Ifl26K z7?p@_NDuqIb6Iz&o(5Tth zMDZ@Cysow*0Fknt-ubFD&kl1y^(67NzXdLDx4+O%vVOEBd782$uuwNIq9LSTC>x^R;7GT&&pV6qbQ%vI#&itCJXr@kFvL9>}& zb}2vbjMG*>>8bP6Yf26C?4haj%iD|>yqGY?Ln+E>pQVA{cJ|QV)=2x#51E{Dm zCH=m!voeyy+&_<(j~u7$)fukaNuSF3VgB*;l{il^_>PW-e|8|gqr+7XoqHC^=6=FV zMJhqgy~5if{@ki_U)ydwaDc_ay$dD+1&oeG&#^^p2aJ;-d&i^v>LgO$y9Wr=FWz8=t5%59Ww=9#C`_&zn~t ztRfohy;tEd%!c%VFvpiV5kBvVpDTKAuBq9-wb^;VMUv*)6sT2oV**sF__~*~zQ6^X zM3-mK>X2+6hhPc9W()EFRQ&iIji{bU0dx=}?p{l}Ae37!t~?67t^b*;*eP$ZX~f6U zu6-N@ZK@eXGr5z1wY}<%%BqdT`+Ngzp^?*7uNL}?y~q0!C*hZ#6^(YD*SL+IU7g~` zn$DKNgO6GwK{*-qtlq)V^@%;Dd+uAZPE)z9-zb8LLIif5N7?LC^s%(NiO#TR*i=Ug zTJ3Cd%XfX}GmLdg(nv2`A0-sow>-`{RF1{p_ZlM%h}%97ZIv0- zWv|~J();vTVr4;y>Zr4&wgr&mH>$CGZ6|h|$J6e+R+0zJZ1XNw#9@bz?h{PU6MB_h zmp@BU(zm)@YCKn&Q}iULbocWu68w!rS&h4>N-!0jh+8-Dwbd#RDKC_-1ofvT133=r zk8iE9*1X4qv(T<>&RXBrqZioCR^y?mbK>T+CMo2HdEMcWS-J!?nC-|WTwr4bBb zR`CF}$GS;ds_}d;Jlj9yMaez%=~i8A9p}&AKrg_aXd}MkpKmQpC$7UO^;#dIz%iB( z_|~hgszmx^rC`fe)v%J%1@em{d?&F> zY5E%0(X>y!x`w^j?7UHm@bmRquMW3DXb-C&+nno?@7fk>I5U|BK1<*z5q6a%vCQ0Urs>2}HAaG>>9zG^Zd2j=>%Ib}c7N!o~;~Kpi^e4|%D^=!6 zZE(kf`Hgbt8K}?i2D9%1&+Ta@-{MRq$7w@039D``mQwQ~&2;#hDi>m8y?=CR)>2;s zG)P@Dzimq9;@dtJQHHSP0c3=GB)S_mu978RADEz+S8{qfZiCGoAY_%tD{hEeD0 z%Fiz!oloK1Vw+`yVKieaCmaYl6*zJIVMHnfYhHTXm~|=IjnSfjXG+gsd?2 zwyQJ*3YS{~05)0&NO;2Dkg|-snLy3DI^2x5TB?jT9(R@u9gRC;<|-d$X3%@h#vZe& z&=KYyr^E}PM<{r{Vt$!t`7dC^VZOD$oc^B7`Gk#Qg6>)$v*&%K}_u&_|syx}I{V*^fm9>O@EV)vc(4;VowjShrv2J`r$4iEzKDCoPg4o3BHqfu0%($nDiR zc9nVx)A7rjde+#TQh)Z-jL`)CHEJw1;F8CWGeum@)q(7rcmS7qRQy5$8`)G(QFwc{ z&pj!3U$ImaI4VEo_0mKSmjrWm4K3>(LL6Te^Z+;Vy5~cS^VmZ185@p2B(X!mTAF+` zD`HT&dZiVkD*3CTBIf&!VX{}Q$sCm`IeyN;_>SSh+;nb!GSPsbsZ-r4MO7#Re3_Ip zlbQF^mO)F& zZ;?5a`wjR6<#+_z_QPh|B=irYaw~xsb&P%m@B^ffxs}>yLzP{4zgZSRxET_%Zvy`Y zX5L{W9b5IsM$4W?%ewX{!*%M2?y}|5H6;BQx1C}3>b|wFe&}($x{~VgAz&d$8i4!G z6bvpEc8Lg@%UeY_KlYvc@I0~I=MdNME!nQtDUJZSDRh2|!GOe`cX;K_Z!I?uX|yoc zextXy*8DB=ARU6Lj%9WG^ov&ekXK`sw}PRyCysO{*cI-5p=Oo7YiDDu2aw<8Ai|WY-NM+&*&#u5WV+6TmKhUwYcYi#@IIP3y(vHUWna9S5WWN(KMV zszvADgqibIF^lYS?08+TXCrZ-vdS;?8c*(*(1?eKn_Ce=iee(tOGmTDKw98wh1edP4`&94rdAJit2PM3Ys#ezuU*-F!x^6(9?d)cdB&n-!76>cT)f_9&!qoLT-d z8p~VySY2Q2-r!-AXMU!7G@=4`ZygzMzW13fVW!({z*I@Hrt62MV%gI>8Op!nJ6}E) z>)@sxB^#@BFlMYoJggYNRWF>_lavVbxl21rvVb=;Y7?7Yayq`3*{I0WoGnyXKB5;K zj7QGjAr$2VT(QY1bxSfBPdZ4mFPd=e5Y?t40a;}w=5q&vws9lu@{=)b7E|)PVk&PY zeK&S*KXjO-q%PfnHcx%uA)crsZ0Xavoaer!D6%8?fO&I6-`Tq;LNBjQWntQrBkW0w zhhvr>?u<}$=@6`Nzi7Pg#74|Cz=yrmu3M?o2UucjZfZ zzny_qio3xX!mMl3!mBOArgnUDW?z`gs_}EzH=RhlBZ2k@?mM&3m*;sKHrBf~QN7bv zD5mE)t#E^SsRKTwyX;Q{i0Dqsd61HgqWAeIAFg&*Hr7eN7B(=Ge(MJPnbZBgDzifW zgL-@yQ-ht^`GM)Vg|}^}1I5!>)hqz!=n>*ftkV{gQ=`jvDH(mQYSmD?B7Z#{ZW5Nbxs=e7 zuXRer6b5-Es$Bp(MeeuIF!p`%2oa!6L1F5n({lAY?l(B+B7w>GvR6c(({qJ#Hi1ZI-70{9}FrAF~u zB3^d;O&A1k!Gu=dkS#&G1+c%03dNuk<#TM8!9DCq!4Y9y#~>`KD)d2PG^x8fo6WEQ zJTeZ#y=NI`bA)ci?N?p^OQ`~hUs8VK81GGefuJ(s0rzGA-iu*CwU+#rrL~6Z5m2I* zi@^~%i|57p&;SiMkQruZx&1Kfe01r7ZHII0<}LsP3s?;k__u74a>ib}!S6vh)OQ-= zk}1HUlVZzD5~zZr7l*yIQ_n=)0o}K*FRVO21IRGIl=lPzd&RJTWH_OTXu%0Ug?V=YXmy$ z9oVqkWQs08F=e&J@E)l20qd5Ny#le{*vrthOJyoLb0@>0+VhBf692HQ$C-Ma@6v00 zvhv2j!R_f%FM*%twePr5XU?dsrEO>PM}5I_L_J6`#0zohpAC)Mvyrz>S4vvR^zZ8g zHpPDS?u+M^bK6UvdNUIzgJpU{!EjWO%B?a zA})Rf_s_YIHYy_Q{&35S+R26rN|7(`WeZdZQq15NZTz61rX1Ida!oHEC#FSp`Al8( zx~2EKbrq!TEoHB3JB;UwIUx04s~Y{KoT7=NcbyrgZx8P*7AEM#CfcHW6ScJ71^8d= za9SC;3sq^v8Yi=on*(mD=2rHCHdUwQ&3LLziot3Z3nvh}f_EC6?QIq4T^0KkgMo#$ zx#bXOMTRi)Z}5*w%*@VaN(Kg<&y_U0xqENC8pGMdj;PoRRtkjOc)5agY`$JpnI6eC zCUvy~n|Lys;4r~Bki|R_V8@|T^{>gRAG6m8v^E3vE91;#{H>L?DB9xq#2piR;76!I zFtamr1JO4Zz~1hBT}fp35gKtf7^GCc7j(&0yAoqeiYrpm8g6#N6Y@1?3uZ}7U231f zcq&cydSY~MmMT8WgbNb;nnD&|Kt83y{jHfwJQDuk?xGfx(W%!N^FmMQ=j^Ju;7_ilwCr@22hL%J|?%WpV=X z^AbOsa>#~UuFFJ9Z;l9Pz>RdVatqt(jofXpI8dz~Oji-k!Pes4x1q2bXKX=WqWVCo z--jaQJ$L4LNXW@Gt2NF1sk6HEy13_Fnb&K7jn0Fb2{-@7U*NMj?$dCHbnC9UaGGwO z2p|_!IT;5oB3SVTySyvF3_(lb9T0s3^U8y0wTfV=+Xg@b!-$w?AfZL>4fbL;Tu4?j zF9h@QOtgKqyEa1k(z60k(AXY+u*4Rw8c|-3ar^bTy}0kZc(KP?gy9$AIvV+@S7%Q_ zBjc?ovGrS%4G9%i?M!?7y6svueJAh4VeYcRas||DG9bdDxTSFDH0R>3d@Y0T>(u#V zOQ>=$LX{gJHp>n$9~@mNr_ciOIYU&E5y1z$c`3NNhav6QA~mqfvflI;sQMaq^&LjH z=$H(d*qgU+R4xRP9rDdYY<_I)jOQ9SF3yilcYZg`a9M@v6laQ^1PtIaXX|I?^<0tj8)vWCHR=sfY3p7KynQSDU-{SF2h6LTafZ3{BCG5Sd==C{ z6!&Yh)Ji*_hV7?wW0AXmV9nscp}Yxem2d%8|3kso+jn?gOrBZy^AJGT4c&b-FV#=J zj06<-B+d_ueKlp|jtSPf=6)>lh!b@LUO~B0bgJaEVi*U3(X6(}=|bG+Taf$w9AMz7 zEdve~0|19_rX(`U(cSRZqwOo_GNv4PqL2DbB;y3K`rfK53AVEMBO8Xch@8Euzsx5v zst{mC3NBGNf#t`h`YgB_a0?IV+~Z_r(jw{sk@2fJubR zhl%5c5BBA=`#;qZIFJq0^uN+@z z%da*noU1193>}4m5(Q{*{?i$A^Ddh$ZznzAf!RB%MTGCD&kuU;Anp(?M;=FAj&)3 zHCW&KO#FZby>_!ACD~(^EH583vN(5OppY5-ts>BGN%b0 zEBS0jI2M;|fvzAMKqc9LR+|kbbB}+rc;$D;UwkUfzdvE-5fJn!jlR73zr48r`O@zz0rik|>Sm|jU$MUb`MkgRjvq0Q zDhWQDm-+Ws^OxD#-~R|)VxWt1s$jQ~`9EJJB_P6D2#O&8Kfhdwc__5F?I+^@*N3Vz zviga##u6B>&H;uWDTIKI0zyhFgpwx%%1&(%i!UH1&jETMd9aiulvra5+^|VOYG9vC zA^^+TuY%Yw5}TrOEiUuY=rzIv2B}GR&vx|Qw_7MHjIpbBLE>e)^}(-T zjZ-h%zpsxI%DPyfl4;8=aAn3HF*xaORFF5K%h42@0u>IZ z|Diyd6b>NChaH#Tf)bTvx;CL}4?%FP}@=dM-vIgDV zluz0?M#8{LSu2w7c$Kgm<=LsSa`FF(dmcQZkKtQ)oUNM)zpYAg)jw-@26F~ zcnbQXe_K_AdBR{!ciBH%HmF3bmvqgki?cFSV&WId_m?tLv$kXwhCxd6omhMbiBhv3 z*d6^k{1dpma6ra_y8sGjvylNxD}2&0Te+u}zF>HweUXKunWV||8;pNV!tXfh3Skl! z&Vn4IjOT&d)vV?|PLh+A`iX(oNK7F|hlgJ0@LV#%^Un|P-}XL1pKq#`_t5tX_NV#nRRk`RAu$61Cc3bqOTFpTo06XNoB4FZr zxArOj&qt|1oWCSszZ@-%&+`$|>Oc;LF5T2bbpD`=EWN{#Oo|)a9efIMMq5$^U3j@e zu!d;;MNrQ!`m z)^qBA`3(R0ky?jJ7_yR}835asR$!G=bg5HJ%L@57D0Iw+92Si?`4u$aQl}vnfpvga%-dz@V;h7kGfS8fx*< zbSgKV_Baep10|SZt|Vjcs%ynpje|1~M<55X6!f877C+HKNyvV54%}>fp5NRss$2mA z)RguOV3zDkLaU7aFwuU(9@=HVLS`}0dGk*%04FD~lZ02;jen}~0OOV6dMZAhtbl@A zC;*`l@C{6%(bNXis`0$sF+pQOl+6ONJKvKn@ZYPj@luES!x5$;r8Kt@-MdkMNhJZ7 z)GN7K2|Ue{_E(TX=OB!he^)O2Qk@lY#-Ga!#8(?B)hn~is)8((o=#dQBm_EPMe5s? zC+L9fl4LqTOHcl=3lv&iFQ7_*b170`H`M(4S-1_|!@yH49H_W-3hVYdO@UwIjlFZg zD93v4XA7)GwY*<|p$~C8mCHN8wbcqd=zj}k%6*!a*ewCu>nAE5Qt8*h_)x+L)K{;4 zcu(*{`w5JRlq9n;4+;m)a7G}Qx2(Qt{|046K$x+yER zIVUXEe3wf@9RbZ^AfW5exVsW4%pylY>1lzqlXDAun+{NyaC9N<63SluxjO@KQ9-h; zN0p%}xUXZe+F<4|KjJU+bihrk!!3fVc4FkPAq)UL83QBY_6D#57r0Bq;ipAZjG@Q0 zTg3Ju*E)X2=@{H_?o8Wt3QZe!%CjldXWyuXvT9e(7cm%vYlyiUT&s5VK{O)0+RM4b zS1Tx;Yo@k0g{t2Xqh68sip`IKzvA;?sbEDU=J1Ts^^#v1<{mLZd&-o=EZs})PF?kP z9fc+{o$&^l^=z?sBLWMhESB?xG^aJ{E12YC?B7{ZW+R7peo!;DYHnGGa0cU#DFs-L z&@U+1HRsM#)m!e~IYi3OfIJp_xWGca=YiVy_EZ@6$x|=LEyT+ed)Za6>M=Izto~@M zfR426iBKAie*T7Pw8Fh$5R;l!0-BY?Ck&*4xZ>wKfxUWmo9Fa_8hWg9>>5nPvbWT} ze^_}%RR?}GR(_u59wp~I*sFA+3Etl}(C31PObjb*+$wNqR4en^EeU~~Ty4D~s(EzBSxz|uN%4XrH>0}#()mgo$!y}ZU!Q|YT{=sEdaWPql-gc$x zRJo9t-EYg%hj_B>P&h~NORuvb^3h_baK)l)ees>`!ljR8hf%kYR`Ue!cP0m(>|WI+YLr$Sc)MwiF%k{ZLn#_^NsZz@r|Mu zeTq*b*DbxX+so-b_6cbs+uc%E)(r(~BW(=r;YHhC%R*cmKG59pd5#6wANF%3SsF40dQuWYKdrq zi`F0<;vq2(BrpiTx2FBoS-ttCI*V%I7$i}IgQm&o@YVsdW1ukiY3cYvk}I~CU6&4+ zH&m3W1Yd@U`O(p$hXc=%P$*^Lt?B^phN}rCo+L$Izn>5M8<3FZwN+JkVZ8uy!3e+d z6d;3_*6kV4a?FuR8U=UOA>5js_K3AI^n{X3l8u&iop#h0cv53l^}Of72$2k^mBpX5G5&Q1qa37=h19pLXxLPl9OaU|3MQ<<_ZE%r=O6C^%LKCqj(A+O8ys6zWPZgW@LI08ha6NL*k*TySGNl23^SAVOX}_z z7ANiV2NrUW_!qejYPEb^6= zuh6vHPljL(sP5S@IqZ_m<5x$Dbp3&TiU(&@UtX#%;)}sq zTAvVmPtD_~4+{FP#U9t@g1Y-lWQjRGF6Yu|UdXQQw}tm1U2L6ldfgjIZMAGO?H;-= z+wX~O#@cg+MP)eRS;b`~$Lr&2)UcVoFJJnUL7TPL%C~rt$5>NeY1-}6wt!?U%27!e z5qH3s)@ISLPfgzM6SH`R>7uf7?mhlM&ll#&+vAlro~r*Z2h!i^L>(y-Y8O;Gh&&sZ z#X#)aSJ!ai=vDY%qrA^&7f(R|LSN>@Y@*z9R8iT^O~zkPV%P#6RwPKOtmQN&^Ur*B^rv_I=NEXoThqJjc1Sa_*Dsf%bvWX2no@4_haYqp3-ucq7?Sc@sADJ z=kKS#mr0BJ3-$Q!%2rXCg5R>Km7kHpqK;z*|Z{CGdl>lGJL0 zyhG(tf3wf7*RPA?f|V-wuix(zq-Ia-nCkoF7l-qY19j8S4qpI@yg$hiz88Zp^YFZx z#510DjB>&a)meNT`@%3DF%+m{4ubx=4jJnbrP?v0~x<*G($lx$#8&f6LjvB73o6*ME&r94GKve)^i~v^Q!d#r?9gfcEU@LmAX+e(=7( zCE!qX+d;)*a(g_aT6x$`!h+J_SdJ<`^MmAnK$QX`!<2_q^|$z5K{ z!hdYs-s8iF#x9Z2qq6am0TD7gUz&eM&%q8ZVT1|X?cvA%jJ~Q!HNUQ#r)qkkjK+~* z)ChMLM2E=$YZ&dVJVWlx-I6)d?EMeio5_(l!(tg7Csw@W3wm!Q?D+qqV zKhtp-udriR0u&_x#%brVobEiy=rm_<%p(D^7jsoz$9?olTeY9^t%~rJt6aqkHORIGq$! zoHzw;PQIk|nLQjywj!JbUqD35H?yLbm(V&8EqfT?C2<#DWJjVrW4MuC`pvcc(Lp*C zI8e#HqZK!5Rb0%ty38&kv6Z(0#R@?tH&>zn{G{rHJe6y`S-)Ps2{?KbJf@FqX&eJT zVyp!_8L?TyYz45j^&R1FGRLo?K(3qyqOHUs1WZry921Ot#`=3vD~gU?WH+EZ&1f>O zc+|ZitkTtLu=GTWJBr0Ly|WjgqaCERdC0Ytv!`=ER49?aJXqr$pM5VO&T()>3ZFN9 zTPeQd$GgUstPewl@VWL}S&$ZNhZwa!!|4wVmx}AovK+(*?BJCNW33)uYt94qUlO|- zu*)5$jpSP2T)m^P{$(^l4;xA(B~lfwt7paOr!_X1D9#pxG+im}^E^ z9akgBcY!#mUUkP0J<2tPX=i}`k)PBJ8WzjNcfgjA`?J(d5^T_N2{N@oewP~|R7Zg& zc>|H0$Kc92;6sST(f(+2V4(t9m>L4@as?h7r2$ja*31S2Hz^;q$K?rZ$B4* zpfq)gbL{Raw)@2=QHPptW6xu0q|ft*Phz1{hr6fz@qTpImIG)n-3u)#Y=+fXIs>Ie zkH%k7WUQ!w#b7FImqZNy-$`P)8{g$!PPwn%3T8tyzpn<7jZy@TXZ>~!!*{PV+1je^ zDEbjkw3_{FM&vzPbyCZ4Uzc`^NftkjPpWPwWAkf9?*WKTdZ8# zA^yC!raMl4Jn8SC;7p@yLkUtb_iuBU1YgSnxH~$TRW@3uG=%&EG+5(f&h1YoxH2XD z81N*J@|0J%yC-h-V*U}H1+xj2i)7~jr@#e%(>+0Gp~US{>-!)@Cx*4q!{ z4Cu3gLCt5@%qt%u1<4^|XVGCf81|L^D@4K&JKvEuPq-fD29A#87O75;0}J6Pl1`vu zEI4Tn#AYwhdOvO}gQxQ_khDmRUS7PzW@>1@K0Z~b*ZlWAi@{LVHm)pu`=qQ(u_hzL zz}=VK-0n%&+G}N{foWIWemfefwa?QddnfNIo|Q*-9oBLnb;SSz37fWQn6EKfI57Np z78w_v8uxBJXH!jaUsEl~h7~-?EsfbG6x*EsFMQ~K5~GdL-QX&&&@>&;oYjXtL0~LP zzDiJ>qYT_QGN>XCo!scGIpN?bHy21>zT+VVr^E~shn$uKN!{2nTd^+OAn}OpfQ(13 za)y2Oj@00BE4=r*g7z@J%7`=0i2|8@Z)V+<$|oiG!2XgO$1CCLA`K=BY6EbvIy+VL zY$P4Cu!HYl>eNC3Cu8m6n`^e9SJ5_?s9uVZrBOvRIns<+L;_fU;{}9VIV z#2eDV@JlSsuyscLYdL~IsOs3qup=l*E;4I0{%+HuO1>ZLyy7rN&C2=#{^mL8Oj&m` zCrSadoiF~VJLRErH7)3X|0#G-IF*!PJjaI9m+_qV|Kjc9_<-2>Lm`k1O};Lmxm5M5 zuV{sHbcXeBfH_|OTYRQFEQ&uzTpfuJQ+xRLP>8@yF~#;(PLie>bb>5sS_l%Gtfwn1 zF|ov|?zP|zTKc_4sGz+#SH#j8EH(BTGnP2pYwx1j{Imh4THKm-VYhiqgD5~q?HD$* z{;sTX8Nfu?6HDG;!Fj9BAQm*pll0}BIokl?FhAt6IKE4M$4A3eb0ofIF87XJCrTRi z%wTNOgiGl&vGY`6+qorDybC(@sND}$SQ8c-OoZL9FE*$!Zw8Kitz@?!j%?z6>YsK! zZJ?sGX&m-C;4gl)wzoH0b5JWXqKj~gx^1(#RnTxTtvOOyykDekbTN7Cv+&QwAfM4u zU>MtB`tiF>ngvYPUVPh~m07XFrD0wuhhuSXFJaKbXos4)VGm|D?}C_GV$Y~~;bd1- zVMFF-p8hvYKs2`h8;AaVmf_FuAUj0^oN)0HLXsO0pG=ML$L>oCV&0~K9$>_3UkV}J zMr!zJGXOv#hg_P)G`RZ#)@$MNjv7UQD+2|j_IpxE(6{-_kZCGdJ4!O56Zn2WON=v- z#3p;R#zO^6LOKtrC#IVhlc=?fz1g6!dihz6M$c0TbLu-Km1=o6?^0 z*j07|VK(4&ni@$4tOhH8Dt|F!Z-G~HJs$(($J>eq9mimrYn}NdsG+#%VK7Inno)^D z&Nl!WwSqZKE@O^gSwbV^nkV?tBlG$Efbqd$KWBVOS?y!g z1mNnmfaGTll0pkBq%Hl)H^U5k&>SX0K<2@-{t^3coRt%$E2&~DrTq!aiq!HR`wsG+I$IWAXzllpi>&2$b=s^CY)ch=Db+#d)E5VQ zaro6&P&AM@uU{;3_WCUEEn|Y>N0-U7 zNE@W{pwTje#aqv2kJOWnj6}`kgF}8}c)mW3l}(W$dykpty=S-nW=+fh)i6mIu-Un5 zyWd(u1px#s!4!3QZ@zCdo-cC0!7lu^0fsJ16!xua=uiDbwV1pH(^S^6cA2{%z|+y@4443wm=Z7WI7R>5iJ$rupxz6F{A65VQEX1+0h z&0AIYL(N)3lHx6+!eG`K!TU}L)=svCzsc{9bx-Hb0NOkO0+=Tf0D%s~15geJEcODP z0tVoG3Ph$$-UY`qt11miz-jq6v?Xi(RN} zksRJA`9ToSmF;3hkQ1JPzW27%-z=X*h1ZCUwS|nKXqp7fSF+cERjzt8_txzpZM`?F zow7c=&k=En&fS7mOgSulfHy+E&~*`@(ak1@)Lf%1iT9c9IW)-$>``PBURlX5(ir7M zb{0>E_s-HO@uw=riVtMj_WVpF=ynx#G-7Ka$?3`qD5KFB1-a$nW0DQmx#T=(JGk7U zxg(yQ)=-_wn5&BInLDde<~wS+(qJ#@%+$H&57H5oHEti9=-nE8a-LzVOxZ692*M(HkrR@gdv5y+G^4a+tt zupy2O+Gg(jhMijVM!<%T&2o7gr431rnu)3)%WdAD^Z=wID)HNMJ$;Xn15XGzxBfy` z<|7Kc+kOe6QyYij>p}#cxj0r!1R3t~48#L> z6S`o0oqN>Cjt^>i6{++~SQ$>}Vq&R=ZEkS>{$=lF;S8sk9OIKh<u!g7NoJglcEV?;>THkzGSm6 zAJ!uNAR9#nTIToi>h{=VFJ_MN!G_P4q>*>CWDJb*``&^v__cfkI>_1Bzh=nn3SSvt z6-xK$1&9wVD8a^b@tfZ6DrRg`>1V#@e#K-oC-D}PlR4T=2 zC@CERBIE?B#Br0P8)Q+gw9TO_I-mC+k3lQ}NP*z)I_I6?`Gst$UF7W9Tc7wU$;*@< z9$vYe+*YrQs~vrzC_Kx;%4R9tt$Z-fP#wYL@f}BK+g4j=HClOk3Em&^DL7rRqd6Qh zH!nZj^k`Twv~j`Iniv!Y9Z?WtZr66 z9}l9y=*>r$8r5;j6zZJB_AFXSfYXdu0uvsr&L+!nKb6~nVwdu7pkFx!lb<;_HmuYW z2XZ!)=!h_Azy*Kw1DLzg7sb7Wm>cIHRfu?COc^6)w)7FZ#Aw*<8$@^C`AK2LYiDvm zXP{7MSPt0V*lVqK;xR!)S`G=#+3|L9ya$^(-r$JJ+iDy!XYk6z-fzeK8p(y84rth?;nHy?A6F~BK#@_ZHnt(9zc;XQQm#6+m{wX3=uLAZ0 zl?6^_0uu(X_gLURu;82}65sa@EpB5-ZEUEd}{o$Qh2j0?j<-CCS*^ygc1**_;vBT z!|CQxsR{ggU5c{W_0qjD7_S`A7Q?}mPNzo~N=}I$X$dMLVT23HyE?n86tfHZuAAI5Ht?TWd8nJsrndZ5jsunFX+=$(o{AafH+ z098}C4Nyv`J;aP}%Iv`Mh(TO5xlKJ1ElsCYeasZ8dn`TevjEN4h&MWet-oVc=GShN zhErDR_+~l;@|?|r9-2D`OY%Xl!uPGg>A5Z1;W?G{3;rQ*-t|{^V+JkKf_+v7w;k)j z^kcQ=>LMe~5r7wshs~dhie~}!=fT+3<1;mSdxMV=NKe!V`}*b9Yw>}@pj58)WEEsB zLfBy01_{kU*RO50QF@mp$cHsTB*_7$o_M(otA0hsP+2U9bLg)6ebxGgvXx2r}@ zTkHxrEjm1#mp#(hEs##sNSgka)2F>|28^rZV(W2*`$#Io@du$^EDZMPWnIcBFXk3d zYQ_Gp)Dng@sX2O(vGHYi@k4y;E{i|K0DhR(DOX79c4o+)0W(xm(DOepY=S!cmuvYS zhfaCS+8{b%OYR~LQ64$EO5SaemnB5nHO(rx9*1{?7a4y?F8rM_t9sdGLze%h?f9L$ zl-Y32RRD2fHY#wDTI1rWW!xiS1u@k!R|%W6+Z4Gkd@B=vBkV^7=(AAI;NH-hFjcSMl>P^K9Z@j?I}@aU9dM10$qj{^OscGXs3&PKtY9n86G5&y~7@xI1f&KMTQO zQ2XoG6#@ET-Rdmcsk&JD-qIOyUZn?SQI8h<<5s>5)ITczl{!x~% zJu(4~99mo5BfytMLXh`1QJx*T3LWq`v#Y!bXZW2oDDAgicaJIw??3+XAzri^k%GmM zEZe_n9{xKuJem(M$``Mvw*KGA-JHZg%fVTRZ~pts{3jUqHyh5s|HwZTkkMHzW;i1M z;|KSjR;zUqVC-90yRqE1*5O~?cn* zh!a%Q~WG!|C$OpiVy86x(GL=+?L(kQusF`pa#!`@va=MDU&!=S`E>BWgDBFs20o zE@Ps;SP2UBf70xefCaQtVbxsNGu~bv7)P6-*-We%F(cO}%HuB_XR>p+eE<120w1PE zjKl|37D}*N-CjpI|u`AFD*t&3nW+GcU1mE;S1V0fpwE>iW*PjO> zx}xYHjyp;KNaGW(tZ)M)NcJ%3+4q3Wm_p*w{U3nf!wu|zFx;LQc$FIbf?0o$Wt#J7 zM5R#(I_E&1NN?xKY2G0Mv&1PG67604%qX(}DPX;301GwU`99QVx7!cF9S{D?(7~nFfQhAalqzM|k z_7+iK5ZtNP4>{RGDObePL+%i3-Kp)CMV_?-u$Gwv1bheUYst^Oe{LNPZ>UfPnBSo` z$xsc)1wBp`01S`U&iU z-s>$qQSX!FSuPLofA*%3A{<#3BO7)5shYwg(t#+UCi152I33J>9Xnp)DC^gU|M&W z7j}O>GjTnpEM&n++~#^}?A~*s|5j4v6f(C+!yq~BKDvk%{EJd05G?1B@0M)v*iQ&( zP5^Chi>9_A6G#}0ciZjGfGTe4H9%~9xK#7>;p7kt@v2;%tyvx|x^3T?6}y50`Y>Y& z=*HQ#>xbjB$hzz~3@D{V_o^};TvaL;h-4xD&vjSAn>f#A`T@XGD_;T?f+k*5*kh2l zV3Yd*$aU^Mj-X@$3~}@4HNb%qTAx;tzq8OGqPL!U!SK;eLipyYVl?u1f&tJVO!Zl3 zk+S?3R=R{bJ><^j*kz`VS_RQUCqSZUsJ=N;VW;o3RlQ7^1Pm^0c-%L1ej}a#WpV@- z3#2DH0Wpg*S^tl{ua2s6Tl*CRDWyY{&P5AsK)NMGKqMCp(p}Oa2m;b2A%cK($D+Fy z0s_(?4a%ZB1n*os&fVYn&faH_`}ZBg;aCC!>wVuj=kxrYU#RAj#k$1CL!H|{N&V$U z{%c*~%!K@L$S9Q*UW=%Xn)Os0Ru2=wDkY6MkYm9N80NC^?qCyozVKD2+z3P=MDogj zm>}6Semfb!9`6Q`26TV$$EOvZ?uvu{dRd_L{LRIE0Pr?k1A!75GUg#*lnA~#S6{_y zbFwvUe)xob0|@h!>s8N2mll3r*S!vfg6gbMY^_PZ((LuwfZ;VAuJG1_kz6IRkqT3} ziZK;otOQ}F<*u&tufKD<36Y#BOE8_%1^{I@aF?B}vy4Nwd1g3eTQ%6l-Ah4_%*bJW+(cbDuRn7$=($>%`VF5;eFNvDIQ`!9YOU0BiDVC; zt4ZCD_NDw)2m;zkoFo0C6@YvfJRNaDrb{7ZBOip$06O)>U7un?Cr3#oKxC4GN-bR z1tiE`#a`d-nL#>vj<&F$kvGrUuM=)qVB^U3`P3WwLHg+8^9m$Y9R7HwMYqR!7XM1f z%)6_9lSeE{{QAY%bKr^-IiTBZYVc*d5irlO63Fgw9tX!=gCTCoL646vE8w>O#CQCT zGmxfjYW(giAg6cV#1m0v^<0L<9D6Jos(dc4&q15{vlQRpGEKQ z&u|-l3^qoH?wQR(nN9n3ydq)65|GMq$(!T(+$*_$6irn!3a)x7Qz@{st(a_l7qbIb zzJ^?a(m*94(Pzo7G#<`+r_>V+l5h^t^A$cIa_iJ-3mzcNUMCsf#Bgap{<=t)Xo#)E z)m;60Y|V(x5T9gqAS+JiV>_%L|H=ToY+YR+7|*qEEh^kMEFqZ`Wu^Hq_e2kWL!y#KFV=2UVRJ2T4^Y z?Y7nLsbCX$YT1!donDnN5E5USHy>rSE3N*0r233NCDA^Y6Q-^%M3&U!0ng^PnN> zrQ)&Uwm{9p9evp5N#qJK8FfkP5Oc$uwB)hUP}i1Hgd8+l?5N1_3LdE?YQMexE2|#+ zdBa2dKQ~kVw(;F#LD3VyxO6hsIlob{(*elWkX0pP##8&oZ6>5g>X@50xNzNIWRe(J zj%^eQwizv*No4qJxIO3-zkMl)kKwXM8|}!x)sk}6NqzmEoh$`P2kwl^iG%X50u;q>ZMG>JfBdqIIv~&0 zd=%G@J)sMtegwHe&b)d34MVHW1AKPetUE@cSW7_rrQtMVf-_?VqM-@OO^$#%6=K6R z(-7-ZLE{ce%%_iXGw`?V59Pl|7KlvMQAVZO;H&TsJ)6U6}u+S>}$?T8il1NXDU1kcPJjJ%C9{dk1BUO1;r_AgzZQZLG&SJ z%15URr9H|8&h2%&j|e`UH}o7&z|%RIID2t~XqxGC^8tAh637&G>kqu;?Ap9iFOY=s3AvbAzF!|!t$N>j zSRaBHcX^Y%aN!Fa=CO3ImudJc2SJ;8Oh55x(X_0df6C~l`PTj#6IY=uWRa^BaAmCD{@tpd)Wi{SQOFHnrBP-nqv)5kvVLY#A13V6lSZN&QF5w5h^#kk*P4$$3D&aLf{D5BW27&sPs z!IaUE!m0Z@#nknFwEr&~j-tgMr?NE8t!B_Yr!`Ub5?gT(Ld;M!Z>n+@d-jv9UL?#Y zqSMl+xn`=zD3YyPx7zs)0Vq3C{WxbD}>@2}{?DLj*cxT=nq(~(Wji79HdP8i5x5ams z!=7UHhtW12XO~V#c^SqBDs62ycj^2F>|_p;3+4tYjZ{*Nm&Go*IF`J_Mx3v8b9{j^ZqW0`y4kASv=1pRUZ-&1>#zdHngE#Q)kO>e z7UV4F=z+R1QYCsWUC*i8QjKa3uf3c=^6;0JI=VFDMo|?9xZoiPJ03qTCR>jRH1rzZ zur@D7AIQ&Ad5#a1#w)cRCi3odYeIceZ&e{kCkS_2wSdp4T_QYRiUAD5lRFs~z4lSvkV(KfpW{ z4OeHWgfbQNfc-g}oAm2x&4Y3C*>F*0{$!M<-meb>l>*#!twKs0AUXXN=kUL@@-E!KWdh zCv2wS2<DP?U)G`?w}5^{g96%ch6Y2q$NN0e*^XpTP8U;cXE=NR zL13A*f%E18+|I5(M=B7oapYgFIu1C2bgBD?*XrjVCGSxNBGzuY`&!moR_(9!7MT|0 z8!h7jPH~{PFfxeL1jNuu>8cWdr!?ESE@xONgt~dw2r*H_2JBbtr2(hSgiJ76m!yHO?f+v8BTu*PVixotWr-sSVT%Hj#PNmrQ6 zvSP$D6QE*dSHzjrbR=R)IWeDl+xw;rPu2t zlJj)MSkn0||BK$j#pS^hwsNlIrnNUkhJAidaaLM1VLkh$#D-R)tBrmMq;lgI@?sOJ ztxt0Jeq(e`j?83KRhQ~2DE|s|{AvCFd26sVLEC(kWeKR8fC2kpu*$5TkXD}|#dHfP zBU?WMBNAS0p%>hyYzHYag4@W%^gM>9vx#;;S^{5%(lMh(08*!QzU2kyU2=xoq`p>P z+1yio-8+Kun3e-IAgC@uBTh;T00)IB^8o!}&0#Ev`<@NQ8j*R?hLlfXpNBNZ*e7Lw zB%*Ar#xVdD$0-PC3a)<0SlW8+1q6cF5`dTSjzzD5kM~k7xB6G+kCQL}bs~fl6`GX1 zSD$e$_$4Q&zx^fh(-&Zz*TkqV!5Y$eWg*{sNESG_CIP`8WBd>ep?fz-C;JDA#BLa$4*ThN@M7hNz{kR87{O*!i%kPnV8SdM>dzQ~ogg9sKB`|Zg zGMsGCXyDvE*(0Y0nZBtHzvskjB zcgr1@ARGTA>GCru=flx%d z{8#A;X6d5dwF<97SH)29M$&*c11rvvoOW~rkRc^XJvmwiV(|zd4m1EJd*WV&?G;Lm zG(_6p9)i>2nICAOH78;}C%;2lpWjNDvJDZ^qiWLbQ3_UU->FL+ef4m66uDRbx;Vq! zA&GK*=ZkSYYBDfg>0tjZGQnf>|%+?3}S{l(+^SAwmF_swY8n8Xr{vw_Hry z1u&s~_E_5inS7aEv?bh9G;a^+&90^1`8fKs0vhM;VxBc}Vf!c{^*Ed`37z}FbQfT< zN{dXjN3jV+X0#Vf0JhktmaqMV@4z&0L^h1yZYJgGn~I?M2g@0lY34=fA~myC0XT|1 z2lNVY!*He@tc~X;u_qzB42RBf$Esn+^u_1x>I7oJNUJ|W66)=?O4jQK7{+(5>sqfK z%ac`%6z-+`HKP9>jK%JUWP91&hs9UUn@w}p?V&#MUxTU$T7zj)-bMM`a-njt@?It1 zZ}+6SgnbHc`orXM_b?z(W@Q^UqjA0efHZ#`$q(->PYA@=5h|{Pv%*;?0HjKXG)~WD za0kYszp{XtU(YvG3~k zR7fj5gmIU59GAA&sjne;l|rgCCLOvVJoj@kdu`YMXJg?gGSL{c%^c<9#(-==Air_X zr`Mxq)($w*l|I9B&#<|?Mbrj|l$~I5i@pDHJ0g72z`LXnrPxL)%W}0ZRxy?$7PgVB z+X=>wjca@+h(k45?em7dn*4mY#SpU z*q{dKz>$LKUx1}z%5uCjKMRSX0G1@nNZU0U_Jk)3A8qE1B$RI_d>n_ zW!Mw9hrsuaa^48(IYV@cF#I{QwNdl5$a%SY23TJazj7|+e-wF__SJ3j>`^yB@Q`2X z30(r_0G6Dggd-p;Hhb*PR~+g2xMqzIHEg18X62#Ej_wMSEgd-hSrUg+ zd2ex}gs9oNwRvVP3b!0u3VhnnMNLpJo_@9Dk9J^*mq2Rm^Zuul8o6C$^3}J6TJ;BW zL5bKp<72f(nB5+bK51Iod+%T1)4?XhiGSv}?XAVtH8~x_3m3W)lihsy>esosE=#Ea zf~bTHA1rttz&8efpvPCV$YBdS7vd*CvuJ>vi3vyi(kQL^j7;*Sc%cZ?bvB#do zq@RSr=74Jkf03P~;J$>g3{uPcnBURkCVHuQyXNK$@J&);RG_Q_)6L`TCx)10!fa(t zm0}ut+9TxyoI>P_i-6t3V@Q+h zt3!m*wB_3tK(2cDP(4Mk^(n0KurL|f*A6{YY1z-(xM}F=QwnOs(9MmSE9?z~r1SJne;GODC={?0HG)A?1Q+_5BI5kpRJ;Vf0ZD=R5ZwkKK)Cds8Pe zL%(|Vnd^K+)A23RIyYB2dWCO7Te@x2xFF`4XqIx*R^`vrZ4_-hN}+1Webt#-4l927 z7`l$!Yua;WlJQU_(gV$V7=PS1b01RZSYKLi(^jLO$o1KZ?8Z>WIQ7JtyEgD{9`~Z& zaKq#)b@TgA()54Lx0#XtCkL{(Pfd{Otcykf-uP4z@mdUNaCsRCV);83xq(UxqQr@W zXOZr9kI8|c(DtSP2?nKWjD!&7-r6}}GnL|X0~S)A^3aMbEvoXojgl~v8m=(yD)O0s zlVceQm;@;+zY{<6lP#Ed+Hn(J(xl<4E_#8}@+b?Ahc|3zeiw#^MPPKtGK56)fzEWe zv}GC6tAxV$Xv74Ju9zNwqtvV4LRwVArJ2(mgOKh;-Czhc9)SX(oaX#YbJGB}!q|lC z=sFAC|cWfS6D`t|7dz4TS$wn6#^R2$}K+`lCb3?4d=eqsp@0P4pEZ3BnAY*h9OtNlDP zszkbNujJ+IvR*-bYN%-_zOy|rieU0jpl^Nk>^Om|PebG^@6M>JzocJ{!$D%wd+bp~ zSxLWcy%_rgWf3@_sa=Ivp03d)wuc|=PF0v@`Cgu&>r)S^JFHE%Ne=u39Cz#k^GC6_ z8;ZYawh%-5%050mKR|bV=@-gPtG~$?CJt0YpWHZw6@c-rD2<-M@xgbG7U!8?C!P+e z44hvXn-1Ya2oEB<;ap{blD(;TYP6YO3OHPtMe`Gdzw`m^jTfO zXn>|f_*!U zsJ;b_v7gh>s+yf%JFVI&``aCX^f#C^OF;(`hxf$g&Ou1T3oyn=WaB)KD>Iv>6mrD} zKxc6b7dkK{=XqK`7WXD^8R!xDxV?@noq*c(?Ju6Y!7)T`F?iW;z8$U)K0Q9FSbka7 zMg3wC1x0`ygrU{F0NrHbvv=o*8)=n`OmbrQ0==~E$Z^_Cjg3WRkhhS}bP)ph!y&?U z<{Q%vzu`1Iew@>b%g*(^GG-9Clu)PL9|d!yr(n+cQ_bqL0NCUOQOG2P+I;bgWfjn+ zNzeym0BzJV_Lwe%e~>@IJ(^AY_p&H*W%WL_iP&Bu?U&=M9w*YPrlg0V%f#99i&Dfp z&0CHDh{4g$$0PuS5b5@7(W58?Ynd}_G=jScnvG-IR^)ga))A1Kf+Q zRDS|9onL-39Pcd=Dt`TCg{D+R=Qs^^P#KBW%~udPo{|iPC6)+1hvsLC9*n4SUCm%Y zFN5kv%Rc_t0lNU2Vz7GJKME2Q~LeMaHlZPY%VR^me`08ZRqxMcs zTdSA{OsIHKOH_-XYI&_di$*m8FB3)o)a6joO}f5jxP^XzHo6?R1O77`(&!2a7t?Y) z2t)cRuW?&`rlBo4PZ-GF5^VgM3;Pz~q{0@z2&{G!iA3ISxlEfUvv}AaL>L~cdIKf9 z#*XeD;Uwc0a3Na;K0HtD1%9hDnvrPKd+EizU=RB03*=Z4} zmIr-J&B8g{YFHX+3_YaM{AK(wp3;GkVM=n=hVh}*TG19s>0w}Xp$WXrFE=K~aLT2% zWcA}=lEgkwRT*daQryNijn-*GGnJOmf!w(CTy`87BkLG^(MFH$yJX z`am70KU)Gd42eUB>Kg#c7JLm%NL@mzpF?uYX>%WdwV+kA4soM?(y?Gc0XU-D@zuU; zHRdz>>oZh|tDiS~K^&Ew!xE|oD8+k7wgflQKRijCZ{zrwR~~kE zf1vbPU#mvYHqPr$?w+aMXubIgmy(s~$vbu=vE;Y{#g8|TI)Q9S>K--8H>0|$fnAFX zy#K^P{DZUgm&U}`ayPgL_vo-t6vaVrxB64%(aFhIZ-;##+$fm(orox-z88b+#;h}M z5YrzId;%(xu-5D2GDXYp>yLCUimvXdO8qrv_9rMMUn~%}m*jqO*x0a@25ER!0bKwN|BN(dntRu7*AD0&-Fr)&iO00^$3Lfq@y;OOr*B#%2(7VAmVkDk2G)NswRV|O2m*65q;2KmsE8lfwsV#YarZkI7bd{jO0cH$QP*Rvft8X zZ2%6*W*}9Q{|&)Ex{)-2p9$U%RDG^6Q)0k(M+Mbp@}kSmoZxhWTamkq`*IgnKS)0% z=7eek`M7cSBV%d`VI*rc5T8)H#yXKg+z)D$i)l~&A^QxQNUF*9&o;pSaRmMA2b(Y* z`G#;XJmhIy2>afNHX0c9(h2`c>M(nT3T9b}tj`L~#yeM>{t*RHq6;6qg)l)93e3M| zqWn>?|M^~n*JdIp*x@|a6pmy)aSVEqPh*P zR`H+g(C{CEgm9p&my6CZ=?*6(B8t!YFw^K!hNLL-+fLE&S$~NEL>GnYALqQT%3JAw zjeq^m6*8J8kw-*qoK+7vQ7A6cbq<-tEb0u42P`BM4~znQz?g`Ir0N`>?^&US!dKr@ z{dy49V<$xaHn|Pp0QB<>Zetjq0pI5w)OWnQ?a%=Fg;xL83~8E&P@tSZm#1~cI|uZc z8keoUSo&O`E+*%h0(}@9U_|sF>L)8*#B($X#YITQav+&&IIe>ZvtjR3uw@;1E0xY` zir!n1Iqz=N=e?t}`5P+Tzix}!?Lg&?gWITCpNPvLjeb{N^&sLi7x-wrwfaWEhFqu6 zanWt3%g#e@`kqv6**8# za{}aoDAO$^#R-ky;VXNz$UzuY8C5omZk@i#3CwguOC)+lQvT83-YExdz(Y zHNYYu)@C(p#a0`v!{t4>D zc%+|OvY_upHEH1wKr!u~Fn)f9WxLR#kL0A!@5j>1K_J*aS&X6?zy_pQqRR+G>ys-i z8u>BoI+ZdeCMHMMfIGp-tdcITRbl-oC;+n#w1+c_YHRRBUS|lwg2#j5JI-&;#`;nI z$$f-jnh8n9j!CJFWXtM@5_vWPBg1$gmxluZ%+aD|nE|Ee%hj;os3PovfMYxT0!%%M zQ~DV4KCTIm$vv_I4jddSy}$))r^>c_jQ})YM#OGIDZh48OBt)M*@#zNrbiZ>1!#K4T4u`^5C8 zis;Y7rT_g}o-I(>BCGFv_&T6RCjssw94XibbAlwKvL{u*ffMXGiNG5?H}P@bCYM3e zRPD1$Gll*{w&Zz%SnzdBS+BVQw|q4_f^#$z zYz0pNjL(3iT!Odu=wN-24Se5302=56^JQa1d*b7dDj7M@dS}!+QE<@4VNm<1mTPB1 zfsCVEI_N+ zi`uAn5v7x(uIj3@-=-shB=!;+1JV~0)1 zozwkCSlR>YFOcuRl>sbWIc#DG4UB7!cIvVm>`7vmJiu+0q$>riB>^4xOW>$J&F^=% zgpu}c1_9^CH!}PDUQ)eClf!pAiBn(KK@YIrm6F&MDng8gOm){^8V%#zdY6QtNm)@O zh%35LiUP%f_3r(6(C%51(|gF2aO&ojiO2gB?Qjk=f$halz$0;!xteVC7-Jf8Ye@pNC*KtB4i)x+K zPqbyRRlFdA_vS@=mBsG6_lOHb(N^=I$WXK{#Uc3@x->aTjd1Fkl=bN*&#Gbm(YPsa zdd!}ZXhCC5p7K8bv82QTa|X(vL4KaehFjEg9iFRDch^-nzQ5EusCVDqMN zZ$GnWsnUjM%3KA@NP--eQCuXr=e{Sfgm0V*eqNc$zz%-)nm|iRBAT#d`WKh0r8z-q zAZG_X{dBE2=&m28jqL**W!C4YNG4SQ4k1Y8Fdg!+E-PYqb1ComZeev@_EQnETZ|?p zj0+NtF37-BcQJU-@x=h6ohsQ-!jI?2Jaw#}VOl3v@A-pMOJ}`P7kd0s*hym8SiGpW zyZ5l*Wn0zUWO`m#CRLN(!vUd{ofDry?^o1+*9rcsWYozFc$)SS){0*}P{j*R?N3_~ zixI^*QCx7xlUH_))UCrd3|K?4$Tw(iXbVDT--fhD`T#PvGdLOT648@9m{iigu~Utn zfd#(`3WVcnrsBq8{YK4%_wos@Nk9BD|doWYua?bQ1cd~zJga+;esN{+8<9hao+ z^!v2Q0{ED;P4-C^Pwt@jwx!-K4h^`8no?suuGokZ#CNX-22l*71)Tkw2`g}5a99WG z0VEKx7TDnnU z*lK_rIsp4<{=pfJ&1-@1jS@VS&YGC?)L$T`*YYP zxNW`|O1x7YqI6SrmLpQ(By7(M85-~OdOG160g}c{VX7dm1=Y?|B6V--4mcKj0?ndN z5k}xUdgVr>wU=7+Gn3o(heN`4_co0pF<~uBDm)yMiFOa$!YYDtWfCtMy50CKhA2eK zjQqTTW78TyW0J$+2jRRq>mqs2#pJg=ouWwgCCXlYp-65lQ{9r=^rb94jGfqc)#|e7 z^Ug@@DV>B)gKGh@Qe9qNKB}T{NhP$lwsFd%)Q(F7pjZl4q}$N$0u56ibt_s zg+lU_L0=+F<;qs46U>+`P!?&mtq;T96ssWIm8~i z%nJPn32DM-ELwImh0)*T>P-*Ygkp__0V6X$w{GcSDQHfoueovS%yHJPht^l--oEcL zr(&Ed_k`Oe^&c#z`T!?+TdJ9mV>S!NL#$)B?=2XK^6^EEp-B;x#1l!s-lg5}O_;b;#Uf(I(N^X~Ic=@-?{X?lN7?wApe9A1 zkqC~XX?tg<)AMeMm-Rhpb9(Ta_ms(mtf}mcj}e}?&BWvpo>(VBZSA?oF!uLmbk{r4 z@~vgO%RXq%8F#+~I(J{?&6#i3Pkec1qUGI8zhHNat9fJ47aI}41feaHdmZog&bj;h zbiK>R{^dW5M*nrR{M(B~R3`lV5IQHE&>!1|M_-OL<<0I5V@eW0cYXkR=*5crSny#< z@zPWXAA5O$;g8TG9!Dsg zDnFZEdOS)9A$zj>wOJINoxm(6W?<27jvs7x)@J@zLEzZ&PL`<4(EMpi{1Y!|@{sof ze*TFM>>+QnrniyQyxvfBw~xZ&{y3LQLR>Au4Dd=V!l8AeQ7>v%#M7kir%y!9Q;!K> zNhX*NZ2*UAJMxc4j=+OKNVEnl?_WZ~4u5pa-y2F6h9E55n^J@m86W5|5N~kDoYRzS zFis(8h-o$1eZ`2^byDPP8oOc?^xF*nq~9w4Nco3Q zefIZMjAY^d5O^XR&KxzlhoR$ntbruQ4@M_~S1mv_$OPEgb*A5@Dn87Cv=xO{C<07O zkl0rudu=zLPukc(m1zlj$3P-Sna>skM{qP>jlRqkHWlQ}!+0_BT~T>)Z3pu;yfRRW zaszjc609uw>|&-A{YH*wg$c-5MFamUw~V~v&?%8f1&$!=WDduxV<4yNAK+@WJzQ$& zOQ){EPwn!BLe8gD#%Otr+HPcW*05^i(*S_GZiirFGT4zaxzaLsxUS*6#=8+w+Nko* z=RiSCv#|J*&)lx>_JF+uZ0S~eFL|x)G#yg-Wsaf*dTg%wcl}y8Q%tF5X;hQBRYY@o zEv3e=MQ<>JL7&XfbtE%ymj~j*t>RP$s?x*d9T<@MAAm_9EPmThcURgxO92_nF;6v= zi3MV`N7Wclh@cqo?3AxsNyRccN~2o|Ap#k%F2|rm1)$B<*Sh z@0{LUW~eM(FhV5nxCs``l8c~$_O4MSmtDy>m38`x`8X-q+?%8D1BBC=wSq>Dxp+ta zbzGInuoOgsqe7!7-`Zm8Xz=Q~_0w1X$@TYtH!Ba&P)Ui$xt>0r6m&jFDY##uXFk#! z?gRMLC?c4T-p{yQApwT7GCEK9S=WgdXiPz$XmH-*K2fZ*!lsi@ zM3?z}NXA^Dw?55eE(}ZcGl!4%Q+Su`#e>@h6RK;i(~17-$SXuyBLo&m!|$MheavYoM`EZ57WM74pL0{ING!t9jzK zi3rYfFy>BIP*}EV#!FOspr+Yn^hO2ej7Ien=)aIO`_o)@wb#xH5cuPCnq5G_PX@UZ zvY>3rmz9N}Rp17vVp=tqy3(UuV~pgL1$Y;|*k3kTzmM4y`5pq@;4azd_JjsPF_wfl zH()=y>GxZ3PtW&ynK-_~MYGNt*RN`DBngH>D~BNP%GlfY`9FzS&`#;sJLi@Z&K1r) z{JqcpSGo6}U&Igw2B6Eb@DVGVgZ8enGcYkF5>q>d8i-;cT1w7~Z76o_y>@q=gEGY3 zQX5}cVK1PA&t{_9ZB#gW?!`1GJekX3We1Z~@^OIW5+L1aiXe@ltd;1>Q*Su+0>_IS zfM@H%ZnOtsJrTdEem!HCbJ#9H3lDt3n1VmFashUGbW(Gg+cVAHbyRuIB>5b7+I8;~ zH!a>3yZA=0#<}Y%#(uAT^NU5MZys6xe6MD$tq#YO3sq>NGVFvl>VS+Uo0}G2GU4z= zhnqOire`hb9=sbbs6g9@ZU^x*hJbM&$aL zTfy!YE5^@dg487*bP+xIR#?_@QCQ0qYHF33 zt)ePgvOeh9Cjw}TovshQs)mN#3DZ@^Jej=CGtjNWw=g20%BBkjh`thNLuyFy$7;t+ z@aIzARx*iys56Hr#I6k_BaDH$!t|~qpjKKO8ddaKy~dIX0gi)8V186M(6&vECW{2^ zNU3NEG>IbJ2xeo#WrV0B`2d7AM2kU;x8c<8g+7;VT;T~^US~ExfeB2_F>}}>d>T5C z_wuIJ;3nHI_??nQW~iilOFN|?5U_!Qv~U+d3g??eWy-O)XN6?Zb|kmv8z-3rWgYlO zNU%0Mx%Gv+377_(?{xcy9b!llpvw3TAtqo#oxZPaP6T`Vkd3v z2NO29R3Z?d!rj|k5ac&phCIEC8UUo=8vbB(ZXP%0i!;E_1iT%GW#VmrUSB$-X#!B_ zz5oGcI}%pol;Oa~dPpX)L^`My<|@|GLm{sV18qvyA{`#JHoV~g&St*Y7RUntq-|Dg zO%CONy1Z%Qxg`aKrh`}^VFg@F%>d;9+0c7@ z)+zJl4i)90+-U)_UhKhXH;QDKigjxgA4O4Exi{B3YM~E^ow4gy_pxz_dVp9VZ)uw|X0(c4L$Aht~nQ1w_m$mhH(bNZ{u6?A2$mMLU$tr#O?>giP5Fr3)%F_=Cvw z;zs_k3oyEl&xsTU4`TPcgW_ct49{5b6DB7(@w3s*zfI01hjmiMIeA;4$;kuDaSnd| zob`sv#FvqaKZXF)SAhd2FXDYttP=W+<0D(2V@9uPdNKn>lCl&mitR0JFb5mosT*uK z^!_6+ae=w3x4vPKZ))nrd@n4Eg6`tXiF8~+x^x0zPx-c;{#HW&uRZ>iL>?hA39E*@ zo-+4Ke?UvA=_UztJ=tC0(r<8$%~eWHwe{myK$>AiWVa#G;aspy# zMSDg9R^ zp5QU})$OVb2?`5Fz#`YkZ`*Z+5omUqziI$DLGw4;lly$u4E4@yaUz}vDGXdo_(O+C z>gVuV0+*>Ea^k=>M(qhFU8a|_oUkvvSwIjlp`M~}SaWC6bAQ~!*($36FT>@{cf8Wi z=}TaXa^KlJE2;asnV6J{fENYQxYI~<-DQ59YG&gL60Hq(P9E$EZ^R()PzIT|5sM61 z6D+ss*{2KYk(>Svy7b{Y{)-uOUjfZUJ|c9d^{3x973nTDKAp7ds%zk@TApR!z6I_F z8UG!smZy=zg2YBJrwrHe`X44V*S)>Aovz@>Z|FZZo%uyCIrlFd#&v{X%74(Q69q`{5lCaynO|(i(I;dKjK0Y??<% z^slJNib&7=s?XCBWnW>y?fa^APZspYiv%Tzug}m*M@=C#)AJ2iy^R>fMlO(uCvhzY z2CL460uZ(0D(HSGhIuT!=o09f2a9g+pm@k{yRF&xF0_;#M(nfNJf~vRJ-TQ3eKc1T z*byp?Y5>mAx}u96qrmXTwWgePA$f!oBgz_m`@$7LUsH1IDIYH5+{R7cTNiWkI9f+5 zOL>JHdHf>e2^Q9cYHo1>TlcH#nb9<}9L zc8nODY3Y)b@8|8t3lk$Lg(r3_T+uX9jr1{5CK3Q2#ax)7hKAFT%WRH-b_LAbP>UXx zTLuR-AOV1fC1l?tAN0CR@VQCN5G^Ap2xTyAMoxO}~-NVEKDVjrA93iq&S+P@cC zf!Rc*_o=-e&M@kyLx7OfSTPZn9^MTM?!wByI=7virnF~yk z&xPt>{f;bi2U{a5}S#$t~67033O=f99;UDnex*+h6p!YK0i{jBvNDnFQM1)|~1M z&AS8Z=r}_bWW9AJ-PnOjshi_PI6c=$VXWSw`KC~vutRCU$!bfDMa zr614dVbGF(oF8q^xIcu_1p$WNZ{4?V1!7Cl%&FmSbcJ=O_EL5@LGiQO-;m7R_<-{R>c2ZX!nIh=f=;HV9mD1?9f6)yK>n#?2sTIC>N3oKi&mPvvqJ zCZGQxcM?POw9g4)iyHJae3;4Xct2e5-Y_%f7RZJz(QgAH=%=7)%kO><`tb=DhA#LE zJn%sKH@!0jiRwa?w*>pj#%|g?cJl5OSyzZl+xk< zI{3j0W2PH6&eK)QO+DZ)E9nXTLVAOn7$)`dSp%|W)gzO+2k#uE_!}>jKeZ`2GBlRS z7H*1KiO~(saq5kqkTyVn{E=ryb!^2ZD>L*v&!rIc(f|&HCE||8U}^COM({@W&SIJa z3U#&Kfa^WNw10BJ!z57G8U*kxHSC|5Na{hb^T|k&+^9eP$ZMe@gOriGob`Aio&c=!MOi~j37{M(B@A+$=stL?koK#;rIx@B4- z#;f$sR}=d|uIL{>^q;;6G{gEVrB?YOMd7c-GJn;O;9~|tNHpqQmS3Ot2}a8uiId3f znF)uoZ}ZcCCCZG+gVUr7Z^YWNzEj08*ANT0c*>D-(NHPh#GK_zyvH!nOUl8R@YtGx zMKvUW>h{mSk%;`&oB50ubbOm1pw(Q?)3gVI7$d5+wpiq!Pt3GTGG(}9<8DaaVwh%V zya>C7ki?P_Wd$Xnvb3w#22)7ZYz@__YUMJT742wH{UTc!zIm_44aa)muion4d^~0Z zEgQ^vOL}iaNKO-mNc4oqxHvK`Xi>p4o)+e8O(z;bxJ&7ye*PGDAvR9<2ldszVa_z4 z&{FyBHpfj92(TrUSDzf2!r$jLrV|iKDhI>L7K@{OEX0RkWev*z4;K)VIuJsS-!Vb% zf$gi@P-aX{OHexmBOoK^j;#{45YF4?>&!!}DiAS$+kS(wlHPq6Jf4_018q+|aPrB9 z!(veqO|$m!7d>(CEKa0;eSq-qqKGAzdTUB-!+?a6L*V@YKfOv6VuGrZrCgvsE{fC`ZWnfkle zS>?Snn0rm=C=s&1S%i#c+{je=9+g5F6R;@lUl#Gh9@ z@_7v`e$;k#YV1>+et|DgkNYJdqG4A>E9$hAgji5k{HttJ2@d%Z9{fH+2I5dm__n0# zjs+!=oY;)xjxgeZ%0F9-_=0FE?*!>1>gSb9Lep!GZ!bWwK*)O z+tc3g+s>>L9Y8{y%djPRt#hnu*z8pUP<-idGA>Re#{#;!goTjl*MXq&kF^kR3Xa8u zE`A!rLf#@8Y0SH_bQ~n+nAaK}@WUi)*JAl!-%<=N+8I<4Bi}X-4T|<6vp&F4Kp$(* zOG&HAHXTh$D&W*g{z2vMTuyPcTB<2#Q8|CkvE-l3_$Y7jb0;v(OO={h7&W_>jMKi|RnF&8<^yFOcg|-x<1#o0f;+S9P)730{i%TjRZ=nSM0! z$?T(UUeI|>F<6nF>aosmYE2ph0p|EZsHW;_E$J%p4cZo$4HaacX$HoWNzZ|zEmtm@ zY82Qa+m!TaLwHa>B*;h+6X~S?BaAFYLWUq9C4buHYVGsL2j`PH!LkZt|E~>vqt=UT zJe%!uIntd>2IuWt=}+H1rsFmnPSV#=3G0f@pniHHvvOzS>D}KKc3?G8pFNuRBMGxq z*F5@dz|9{q$^Q1u(&|foEFk~FK~Z83ynri>J8#)!^?ecOpsPmwNb#w1J2f3e77l$c zP*pka9h>w-zoN%XxG}8vK1cgE|4AAOa96M~%uk%Mh!%a_vkXy|MEwjLmevNp%G2yXO@Yf^IzZc|7I@Q54gR zp_N_4{U||n;o)@Wx1D}<=8WM+8@)Kq@~VB&c%S!j-I3-DaGRo#(|6`$#N-p;pNwP) z)zW2ysCpY5S;&n6nIR)I>8WMYF4-ZTur%i#*PZ{6zV@e+|6izOaOREMn96$f(1=&% zJ(IPP^wXzbKhE$?XJyf^hD6mNB--n1x$@?@wJYvj5f;ji%p zP~U^!3Ngf{SqwSe93T4*I?W^`AGy+ezznpiLJKg#G2u<|rwd!A2vXeoOV=cGP}9@! zU`o`7#cW^Odky^L!9aOe_sQwqLq+7-3{%D2t9Q*xx1zFmM7Zdi6nn(g)Z*E!|6(Iu zF8v(rB!fO1k@QHSRiHdW`Oj4+L2|N>p|AK_@<`Z}?AHd-P;7jz${e^Ho}B5Zq^p!G zSGy0}XKeg%m|W^S*qwOe%p_*OO2WZ(teAYL8}FKQO!B1k+KL zIiV&8U5~%-Q;kiI?mnTqjHf<}{`(jW4$Y1t&o7q_mzrr4FsN*Q8gKIaDapCbJgZK< zX(;gk8C!|XWa5u{!s1`fS<~iVc$p%!-qDofaNXp&*0;YhlRJSBzoOxt zERJKIsf|Y<_(pHu{krWZSRx~Wc?X_2srLh8L^=7%tNxtah%VEovWldv48T@dPKeKb zNB;4>J2`$oGh36NT>sG5YLUY7VP$I{tvlT4dmWF3$~F$8KZ>uim4<9|KGqgBfxcUq2hXt6zVV_DY^+g^I^5yD3kbE`dc| zqh4cxN$+H5{PL{q`Lr`nLvN>g>nG25^1Fwf0tfA16r3+Uk#B9r2s8D_{Lumc=Kbd& zI{F{&6Ja6g%_2avascRRN>LPowg-UPVg{5{Uerku_;aM;uGtsE)1|yoJ{JDZDe|b0 z)_K=qp3UaBJx&x+ z+wm8D@7?A|`mH2(Bx;+{A1<1Ozc=(FhVeMS0U}P;`K9yp zD@R?O)AOOFciU0x$%-oGEPa%pgq}B5mYPK^6uVJr1r=%yKVA>Ln~*I!DS}#P`nniIIn%=?YCzUEOeGAw#Jg!w{K(U1^gix za3sJy+3nmKy691l5$0fJRdim1GmgN6Y2oQYZi-0aF=h0#@Odx}{LZ@fVH0;`u99**vxN#r zIg4R&%GXEc!<@TM(rg>gw=!GT_V&@9u=jZv4j%Illegbv3L+pP2_NLLLkJ{HiFl$N zpy~hY3UXjAqV4mo~lw?FY$5WmaF*Z6LTu%?isHZiO|JCbu<@jFt>kodaCSGzSuy9O5&LrJPsGDNyZ|Ip4zG8K za=zOP+jtoET|VCtpZccSBq8akj}^C>!15980oH^&O-i7Ak0EBQ*o-tJflJfx#gUbu z2vC`wUSoPzkGtvGzVy+4Fyrpn$|>N}qrvt|gL=JU(fhfmTw)rgi`_NJ0|fog|3leZ zKvmtXeY=XFAkqRNjdXXHbc3XHOG%f+qESLYq`N`7ySqVJy1Q9)o{9U}`#kUa?Y+Nq z#&9^+5Eo;u|D5x#dEeKMqKSWATqfD(+4;;!N3Cn|3OnS?98w^2-E?<+D;z4lcZ?}h z3SzAHU0w7sJ8SD7RTrYyu-QVW1m$u$H2xy9ho9_QV8FFs2nD08f`h7|y9!;zqt zy}b(%Nx}drytTIdLN(~?&+e(H9P3Kc=76s@?0=#kIae`KJ~U zWXVpYcc;#)b@TF4>Afl^DSAxL&YUKw3}NLxd8D!%T%g*=Yfl<7^U=3=x!fhVJz16c z7u-8Qwx3xFXK#NC5oO|*&XA{ zr~y^ellL=Vr`7&QzPI^Gmp2)KehJ+{k5(KJJ~@j$(CN(~lMqSS!AE#AfxC-z&=2~S0(2%SJaiG@b1W0{{zT=t-oG+kaG!NCC3N|( z$hg04o3OVj??2|_!%sj}_vX#c(Q0ZxSehx-B{_OJt$(Z^^jzu^LHjY?hmY~ba+~ct zpnyJOLF6FRKN;;guwhZ3A5jUE>;hNUQ3E60&nYqaQK}rYOiP(4N z3`gaY1H*m0LN+1Q{4`nlFNiZf%zEdCMS-C*#;1Ok&+4^13RE`QC$IA9`|tmj{rz(|&pGY_Bwpy8J92%lN*p?hEQ_(C| z$NzMU|F;SVynwf=6r+)khH^ywms|P9*z?f*-!-y#bG4E>ca8}+-zk52N2a{al-^m^ zJUf_7!+%e=?IB8oGraehDuSm-=6!89YxFLA!D-^RXgXz)!dx|ZO|n*|7}R)*qLcb% z^RoC|tgfS>Z53wIJ4NwkJNdHn#q{yy zJ?|?%PSs7SN7(*z-Eki89lsDF2!Nm_*p4DKsu?pkl_CBDC*F31L0J(0=U)Obs-nGDqfJ=+KdjZ5(Ge;@3y( zO&v4reW@5-r+CUmGU|Z?u{_f6I8#CfVmyYDd8$^2A}c}|q!aVu1!54FJOU4unwLc3 zAG;q^t{kSOh%40!zP&bVPN6A-`DeMH8BRLIHBfP+@a>z}a)%PE@{h9>()*VekYrxH zoa~_rj_rCyIK=Lwa0=MG(6%g@F+yjuh;!@_jfJ`WaLdBz9_hj0SR&f(v0Z~6jPS+E ze(m`MkEVl0EO!~NfLzs@V>`*rTyCnd753Zm92NQPh$5oAD$8-9f+`Puh6~2OatlG% zhseu8&re`~P>Q&SHV{8JC z1K*L`QW_X~0sSfTt&_WZhs)MpnU#HF!maA26EEBkZ_(jdysyQOqt)}&wsov)f5zVY z+Bsqj^_l}?fv;d1#5}VG5Dc4Cr|e2kGQG*AuR?GQo0Nb4yooA;KmrEb3|r-oI~i6^ zJX+i@jShGSXX?wv2rdl#zUnfnaoC@)(XBzP_(rFVM8*i4J`pFcpxKgh!lNI`>+N_$ zM(j^Em?4**8s*kf%fa0rRPy>#X1JxHK`JtKS@ZMIt!HP_GX!c?ktqAyQ8}Ssd{G3v z5w|=L1hmg$a1inrs>IC&&W(NvFEE<^jG)nC-E__Wo2^MF*esyMKnmNUiMsUjZZg*A z%l7P&vx78<)%MeH%#k}Id8+B5z^9Su>hFc@F}vNq)>TJ0g#=`+grkwib}q#gxNb3* zI&C0Z7%gp8PR8#7u5Bc4)yM7RRKQcci3cgtoIH?9* z0l{{Hw~V9t4RN)L2?>II5&!ZNKE!%(qU$Y&u>4tQ@P6hK`LU}lM#poS*^VUPChbC2 z79#r)rKFi|5>mfl{9^V@q89_dG|F=3r>tjS(>?%%B{FO8mde@|?r$73&<-HR+Yn z$_tvN;8lFlCPJDvDwIpw)Q&cwv%~Nugh2M#n6U9CzRK!b`Rj|Iudat^ zTfd3}jTt?TVALDjvgeyJq}a^VE3yUnvztrNaW=B?#K-?Qg%-1~6291@r9wv+Tqoq4*u$=q&Y*<20uAY~Q8 zYP|S-a&ML|qgf)9=o_wBs`r+v@Wm6oy(g)P!v-J$|EFJh37enN9pEwhjRat{btiko z99C(cJVp}{hbK?^@e`CwAO^vUc@U+jnN8%x5Us73`Q z7?1?eKyw&Kj*OQOPW`DxL{V=a$}3v8AMkfbK32Pi#@2q!u_{vAu^5$Ah#4~2`inSUXV7^D}e zx3JW9C3sZ$=rOu*FKjO+J?-9WoeV^*{&0r1=d^EONKx5gK8eRx+SMeTA66;EUm>ks zCxuE|RU8JpST*}4uSF6zjq6$wC^mhAiHu`ZeO2--N{Do%GrZL% z7M}zr%&zJ13UL_0n1%R_mu?mz!C8))o01w`_|ClC#akj~a{kkVas@1*|1r2Xh{1^5 zci5o56X!T&!uT(Zlf`hsmyP9}*i&+0s8X-JL(y)C1|pbTB-*guulm?dJJ^@WW2(#e zj}sgR_Z7^P~L%A(!)_-_;#} zio$-PaH1(HsfxHo;A$09IzN(cw#a12#VE?|+?UbvRBDD&E4(6qiH`D0ZD0XJX3@)+ zhT}KdqIT7^$1OL9Eub1hK08TvyfIlgf%Vg*tx)(28U4|v36JDgkXK7N5lEsk6C<>? z2Z{dRz{cyYNA2BEvm5G}&g|{Iw>n4*?F^vCK>3CXN^dX7i**Mii{k>kT++=tdNpMH zAUCpO_g81fmFSp>oQZl@kOEg{rh^@du>|9G5%CDKOGh{7^tkGUrSN#dAMx1H)(gar z5$dOc*Tad|7uJ7O!RF+`gCgA{4&{zH=Ji5(zPtRcGuapA_WoM03sZB~%uwNJBFx%g zw4=%uJ&=A+kK8^kl#$F3!T?2scbY%p&|T>kzD$b0l(1|6R}vj2lk#rr`LST0h0(;c z$`9e&xw%TKCoXGQnr~dX@&2h0B=Q2>@?5k;!^2zYql_e)?H{j8eD#7~CczNbNja6h z&*B4HNUwVgD3{0!Y>LHGhPCfD7X3!Hy0rd`Im%R62h@QU>gQM!Uv9a$0#i0Kn2|h(vI%?T5!Zk3AWzDq)?XTx zX><$!%q*PDWI%eU!x6iqv1s}x_>mUKTzyDpb=t)-ku_S;6R>Am9Z?W!B~{9P83p|(>7^8t}o954T|<~?@P&2BnKV%4k*p{ zjn&mP>CDmV`_UEJ{3MIIFYXaW{W73J-2DnhG;*BLumGvhR7dS#^Y2wl-`5XS%ft)h znG`LTn!R*qqxsqj?x7n#cJp;z9~7Z`D zA|3f7?ELh{I%ZxB7!4-UMu26petPVV(BWoPOThU5URYP_Av6zM6!X;S6n70h`Rg9r zyBsxuyTIep`~{*5??!uiK8&D-(M@r38-Kf4xAblgMzUTY{FV)|A200KNjpVEkeY73 znQjL4eS(y)fTHn`MD(a_MX33vlA}J<;qe_dtM}4qR`>fgLW;ZBP4XE2?$c}b8O7;0r~vk4r4L?FQ6;Y8#L|jzztMJtVuuTa!YtN80zDiPxpRV7uceyPrt&r|OI9_QvSi zWBc;PE;#V})5kw-2mB!AT{}-nUr7+1!fGfN5g*80iE=z*LZ$Tq)xa)x_;;Ic*+Spk z)H;;V{;t+(vJe~_CAIb5gOH)RF7Je0@wKTchqZNuO5PuIb(5dgU;earj`xRi^TX-Y z=zI~s%~QH^__oI27fq>)v(I-_$A!8LkDB{q8i<-gPD?*x>L(Al;{*p}_9guO*j7#u zw~C>ADP(VqTN?jj5Fo$%MtU{p{MpV|HAe)&Wiz*O1R69kVIJm|-*b!l=GP_hcNeA_ zk6A!Gi>Z-mLo`;R2rc7vT=-x~=fXX&mIXRyxaW=d1MRKBO+NYY!^pCNh^>mw*O z&)}`&`84DD!2F!QcS-z(8nUa(8K=iDgYBY)3h8Mi7p175gp3h%%D+kNrD3*&h<>}U zWL|PBFN|jNwUoa5D=P-a<8ObOVAqF|4>(Y8dJR0B*5(0gchVVC=pf~wWb_NjV_WpMsN15J07kR~rKJ&{^n zjdnRV1+(vBJBFTjNSp4UDSTh4ZDHeBJzp3*-I}wKTk9;4>QQtJYmE(O;ntvzrZgK} ziM%eCzfYoP9Gz3Ub$$I95myxPWyB6HThxeuFsW89HHf7I5N36mjQC*=?wHhv=wH=v zH;i28vYXo$R#YT?7A-)DfO~^ML}a5uZmyhD3QC+>L%#3ZyVGY$D+(4AYWjnh91T-I z+iec1jq$05`Mm6>Y(b17QjHZwA35=3ipQ+M)J+ssD5fW#rbc`XUj$g_C;?pUkpu>uy4m6XD#ZG8JQogO9KaK zA}Mo5d0Fi?WYi(j!Y@B^b@Tj$%Pb@ijNo{8lOAeZ%N>slR3Xyl5pNQj{iK79f^@o* zttEN9Pr~DoTJ%6mtts7i$}BBam1!u8<3c~6mT1fV(DMVm|7Ouzr#;!&u~$u^l8}#x z@P4Vm<#$9N_cKF3!IRggE1%yR%f4)>U)aGv)g@Zt!zgOUrowYBh8Axn&-4ourSC@z z7B=ZodF0X3?sFC|XngpXI?Je=w;#@1W508be-oo0vvLrjH)G<04(}&e4E;a^b);;} zMljl?75;aZ@TfX4@3_W&;#e-O*RmzMmTLpklWeV@s+kNz8dffb#%w2@v(hiN#}Tq_ z@KtaIgkW7O8dc|2th!KNH;$-9QPkE=*`E~4Jyfk-gYSd#;KC=zh588dqWZXvsg}L$ zJbmGeX;sG<)LNq2qKiEFplPg>nmYUVmtkLTO|tAfo#Yq{`rXgS9&Xtjt~RwVEsun> zO%_VhnGGgbclMtKW#!-$D;kP z)*mZPAXP2`=dG@lcKtn!#iSd$$G!PPqZX2fN%Yo;ac3&p_^5KgZYytS=O)~o>7YwT zzE{v##dx^$z~OaKe0LKTXKOdprdsM^G$mWQ5ZwvrJpq}V<;h4{sTv>6BI2!f#ZQQK;!`Xi zv+VEa1m8CAQf8I!Nw{+JS)K50=hNm^$%4y&zBC}|CrqB5RN7a>8vKE1=8}}Leu=qh z&s@XA^3Zs|41Z!VsgtB|&`CIWAB8cuVl7F0YP?bI)X=@F=>8!k}j~&W-Ss z4bUtZwTTH-D^r^=NKq&MysxapK3}a-3v3_7-MZ-epACi=3Qe~${IfG9i0iI3T=ALf z8|EF-=F$$)D4Pdt$>~-^P<8&({+Q!c`yPUGFNBp#odbRxbsK>PsUholV+0p;;r zpyzp-yUuBq$rmx%%=9b$J}(4;o+^6`TI2&Y&m!jUpr!f2CKXWT=}^g;)p|Vez8_=|f*uchs+sy2qG> zNOo~;!+P2#%D3_wdsx;IPG6yfH1A>S(&OW@ODg6F9T`ZDFjJ@6eDuJX%_-aCS5q$k z7T+Duu|Q^NF&Vw#@#@V?L|@z>ArhOq)6Siv{eWWTsR8qIP_q-)9BLJ^S$Q5=9s|F% z-*`7y{#G+7fX5kYs?=jruEFu{3tXbgOchu7t07p}&i>>#*P(dBZ#smgYLqC9r;qP< zy9e+pU52bK4NsZPRMHD0lvUr!aJpQTyR#e+PNmFd#iHWllMQp(b(5^cAz(>=ntR1O z`vdgZG&@d3HMo_FBA2Cniz>`j5icy5Y^MLo1AV^QM^Paa73FV!*9rORayPQgws)he z#b;=xv`qNEORynRvx*c4&rf=V5TZe^62*^d5P4dHw>>Wwpyqg(Wtcyk`Yr7egZ9fN zjK`gHAC#1BsA!Cr;`T*i4~VpJMD)sByR;i9-nJr7=A#y^uz=DFPDXnlbh2RF9&bI> zk;kcfbRp!2d@bMiWm6>%lU9>%1KL93}RM#UCC|LpOnEIR)@K< zaYwFf->bu;Tz;#$$pq8FggG?%`Si;oz3%H1@W}2M?vhW6$Zot8@|2>IuHoP?qaM<6 z3%>Y{*evpXg+Tb`4(yRuXNz=FL0!rr4?2!C>#9*_w7*8arDtD@BBpf zT?Z@CI1q35?Pld;1qVhl*Fb?sk$y8>M9bahXQ#NTL^w4T-3M%|TQ*+jQSL4Nl3s}|gc<>1ar6mmJ9at>7L?M+}= zOlo>xGs|i~VnVPy=!|{qktQOI5^|%giu6f(QFT;TqObmY+=iNYQutlxpabwlUx^Pp zc4LpXFG_0d`#EfrNvmylQ#|mzhLU@4tvBT7Cnva$2v~ZSTA6Clp797|uriQOMi(MpjI1q$&BRbkCei5VCUCS-94!!sWR##r=g^cU?{3X>?ug1xr5n1 z*J2uGK0KrEm73?U+igcMrm`;L%}bINM%PBV%*EYzA=Vm_)a~kJ1q=KOl@bfr0=Jd7 z%KTg|Cr9ff&ZZ*n&CFOK#RAn58LX(uU}rpSlV;Q_OEqc{Zz!_~ITP@l=r z#Y>Hc%c{ zilM*WLI@7YOzNDU_4HJ8hDR>V&|RNfq&{nu*?YLMN}c6t*68rd3>M<9oN(NxVhX#u zEPrkTVOy*^vRigY_&8goww|vG>LT4KgrEF%9|6DE!#?Q( z(b>1jYwWZUsXh~kIr?%_exXYz)C`kgFPUO<_rB)7SJjStcm$y+zTXz#CL&D!QPbcT zL>7$l>(boufkLfxtPxw3_-nM8sNM#gRyIf6O}AGE7Ym=gg z4g|3N1?CgOYfo+&sHwj=e6%a!%}CQ2hYzfK7enBvo|eCa(T{Sle+zX=}M=O)BK-x~RZrMx8k3 zlD6hZnM&2?;8d+AJc3inyES_`e(I0Ux zl4d`>!?>M;-(T8$)&S>H`9w^DNCegg;)(R35qTJO>cu}Vw*KD0Cg_J6z$}!y+Afs! zqH~wt#KSCzVG8veGvPEW6oMf$J6G7V!OjY69z|=0JDu8}gAKWPYz$e-xEfhF=PL7W z!=q9u_F}&E5Mm%fiD!OyX#32)CvM_b=B4rRHp7Zh9Xe`i12Kl@FkyMHf1;nIQ%%#F z3iseWhz>o_?h8r{{JG$Qtxu_yBc2-fQQ&-v*Er2k3`Ccbs1(;Vd1E3QZc^AFDK-?R z-)F$zs#2^==Yw>$CVsnB=qE}z@5oSuD-HS{Byb;BGsf4rW_ma`^Umg~YR~9tl2HbQ z1SzU=p=;SKCpD&N_@w;Yz$`HCKEQx2d^-!}*w>tGaC)WwN2DXGM|%c zOkz=rvYa9@8Jiusg@zg}L5r?mzWs9Tf?l{`l+ypu!ASH8W;~KD%?auCLkCyIBa@Xv zZM&hagX@p*P_1MO>vETI`nErE5n%E@QE zE8K_N3> zMOxZ4%R*z9i`q+}w(xLOKK-zNtlsH5sH!ztu^ZsLUkqerLJK*GjJFzn2C5@h{AU$B zm3_U5GZ2&9u}>9;J{{Q3oO!dN&7#OKh2zPt0(7Fs*b;UPxHU6ny+Wc5BWtq*HUgkE zE?p~jPry|q6nUd*BhvCFFWnKlIsT_ zr%I)hv}%mUKp)G;RFX>mB*cpg#H-6i8!Er^u%QEp#4qf1-+OlJsH$w4Lv_B0p>V+@ zU2lF06B-M*hYkrq(Ss^Wuf>h)dVNPfAYzzzkJmb|S-8qae~YaeOx5lD#f_m!iX!ub zpuz0GijRVv1H=KLb-EjO=O}y+{aez^0_iV0*WATX9Qbkb=8t~h%Aeu67da>S=D!;1 zz9!4uDKb8N{q{ET_F{Z;laN0K>es~eH3r@+GXnAV+p2lv(i$>v2jYiqkpgwpXv&t1 z_BBoipA1cO5zuoYL5JzIJebH@l}S^vITr*(OY^=|xh9WWqnnGPD?SX*GHfclJ8KW; zxrj62aXG15qsSY;zy59iE<5DDNx!uB=$P{%aUg1d&p73JwQYOk@(S-AKZ@^4$B25c z;7jCZ3`_4B?YkU>mOTfV2OSJkyUh~2|IuhaT-3yCBJh=hOrc5$&T$@nq>l?rEco`UCh|JZ$<|xF zpalg`B-p}Sb?MLY@o5JIrT&OCj}`QbCZ@-77~&%^x%~Pt<`QFJ5TNHoE}}JX;9kmr z%VsyGeZ7~AE_d<=wpFcqF7-JFxiYouUCkrJC;JK&?Ux(EnQ0pZmo2U8$++Czq4$H6 zDju50MJJ891*;(;P8`H7yz)1E*iRrHa90U8qBVllAi1H4Y^J6gIwF162MfZU@2CL- zv6@-DmkG_`c z;rpEc$U10LO{3+=5V0bbOD@-%16OKauN(UbZj^cV{xD6rdgs-&()o!T%mXVs1*khp@i^uWau!_PdDOdZQ z?xDWc%g1@=f}X@N=aYgPI|}adE7tWg!1IGKH$87tARcE2Xc=z!i&Vyr_CSb;9Z5*S z*``pS8tOBJqQcX15*yq1L+t~<8y*j+c4nYS^w~3xb+umdai*Vc_+{uw;Y+y>G5^4hev&GIf;Cpw}I0}^HOXzi#EfzhNZWzI z%}9sc-|y}@YrN2>33)&Lib5cV34W1kN)f})Vz1tY*rlLw`<|#`mWAa>2;UA`jDWz> zGe6n*Bxn7E(~uwHFU>$NKAt0eDLU+hG-<^5#uYva(wutAK*?Pb{Mz}`dl9B z3!0QdeR9L0brdq>JCjAx#KNCVnZkO8;yH>=Hiv~D*ao5^R!nHUuZhvF7;Q7^&cizY zw0|s-AIj97jU3b!IaXaqdchMqS1YSaJoGQ{#9seBS^ZzH-=a5%MrX^$lXs1W-V_h& zwE}n*6)GC>0uEp?QE<%K_{y~k>6i=uoR+k_&;MsXDiP`~SlY1*-WP1|D#c;cqruHH zmp}i0+cy9?eh}IP5)f}LBdPYuW#hVmfj!}W8HO3LQ*hjy__AH3k@jUO-~XOt0aSFs zlp!__i8pK12P-(9y~p^Pk{^(lH6=Zgn-}u{T-l&k{Xa0fmv70`$ZgE_z)Sv#0IzXZS7A=l-jlB zi<1+Fr3wwMjt@u-s@y8?O5au>Ch;Axf_ffky~Ba@ze>b>9Cme`ri>?ieT!^kLYT1d zXoN}(I?Bo5on2PpMhScC+5l2z4X6}zbLlp^E0(UhOzumxa}R-O$B%5qFl7L9)6;Z+ zN7EbcuM9xTV4^pA`8;L+D4h<&9lxlj4Qodb$gD+xxz8;oc8dv_A{wr~e~c$#!+7f@ z`N|R8t?xmu&k(e7V!ck?pf^I(p!-}VsUcPEi(AR0v>BC<3;aHu-la14F|#fW*$FEbXxsb^U27%=z>J^K*|pvQG8z#ejDUOiu@jSNCo3S4rVd&cVQF?B z;Jbh%;fmnM*!eIkO#Kk_0U5U*KuRM6kgVgY5)MBJ&O$I2&J}+i^vtb3>lkp~R=2W~ zqX1+qp7a3~%l`$ChD*)<%p3`-Qh?2l%3I9t3H0Ny604*mD2V|mvT}%lHyHp(a^5up zHMnCyjNIY8a|04w?bYv~=UTD@f0m-m-TbQT*=hh-3$GSR1V1m(WO~5_Lo%Ah0s-A@ z+%@`bpMBzSTt2B6s+VQSeL)|A50HVZT)o)i79d~vn0On*`X4OW->h0G`QvKW785Yh zJkg4pkE!oHPFs4#V^?k8+&2j+3d=sVM3 z^7(*Xjx5n9W&@IpbgfVVKBJ@Mj^7n6T;!y+=W}LedqWO4{VYQe z{!vN*&lA*%7J=w+#BTk!(@*gJGG4gDx}LWxZumYa-LU2M0ID0Wz31J%%eiq_&YzrM znF={?#6yk^YmX5R8RZ=Tus+HMb~FGH&jtAcl6HLp5L0ND zV+ZNHuNk(c``7Aj^{2KJb6f%A7!ATdCwozDQ{O2NM-OeFPbc8t4kg* z&P_H+ye^6W<&=(jA^5CM6yAzPR7@~~1>;^g4hlDW;``BLR{hIAyJi2g-OYcSqMW0s zapA)T7XSg0eEJ=94+#8v7iiYDAV9PhU~6@K!=(Ul#u7`w6zJ+y3Q9Qu-26T;<+TQ6 zGNZtFoi<$I5WpZ)1J8$D%_Qs!CTxrV$clowcBzaMLs3sL^41R3CX5M+rOk|U?yh66Ofs`QQ>!vK8Y3RG-wDImLQ`{hBLlImw;b` zQqA6i&>r44irBju-t=u4+8t*HKk(IohQZNmV2O1Pg+goqeHpXgh)(0qAYk+1} zTu%t33ccYFt7B+1EAyQ~oq+bwroETHNtv8sC4f$SoW8k)7BC_@mJGtG*}h+8f4t!d zNJt-||G@paM;;#3Qx@wLb+|lhc)9Z=U?YD3kN2#fEn+BJkQ>R^)1Ba{P=c`p5hDy3 zH*4qruu~Z106h+%)7h2zqm{tzxP3pvgYl^*_bMdW4|Rf49|Iniq?AF7$SYqFOI?H% z?YmFfE{PZ&sj|7c2rUtHJl!}+LxK}?C`K6et_^#?hnMp{fG*j(ZRgb8jH?yZ>k%x`qsQ=q zk6`}IO9yOkz)ly)ubeiYO8GF9gpbW2mjOwIx(beSJU6Z9&M1~jhUx0Yvn`1CSVa~Z8W4Nqp&&D$;l2#Ro z73oM~iK1qazkLA2qav~1ya1mj#RB1F;1_{#kGV{-%k!dpoR};$GjeC3N|8q_G7cB^+M~c(~Vdc>))d_CtG4 zLM3Frz}-bawYDnT6;p1so|pf>e0T6f=7a^YJj;uz7A6T;Yr(!_c~Iz-MN&R3j~}hn z0XS}=#0e|+K28LO#`A`Qj`a?45)#XCr}bEq585M@oFFsM<=jZHUk-VxE`;H2z5-de zp2sebGea^>|Hl5@y7BVE*~pn~DQsqRKGo90D$pXr1`Ya(7S5n=Y){t$K^)GmUZQ|? zFCYp!x&$nwF7>Cp0hEH>7f^kgEyeRvkrWvPWwXWF=6WEJe6%iyO~kc_Sih0v_Ia;z zhMu5LE3?jAHHaGF^UYITGlDbML*oCq)DIhAQx2w!(9?bPbKfL#c_kx2*oSD*{E7?Y z2{d3Or%URE>CnA9#+HALsvDJz%y;sudejdv6+-+(z?6*ZZX00hBwKnrwIWdXSrpcg zYkaK*p9*)|aQFxqvqoEe&B*&D6i;{%Gw6H*)R?xiA%5H}nOJl>49S~v@LxH`_+uZQ zg2NMU5~yC-PNg5KJnxan2Y8XFAXbLk%OOu>aXecQ>0wFAwUA4jp3CIRUkmsbi6TT8 zFKd2wK?wc&6+G5ik^?_5X3Hn*II%PVC(-p0$nEyfOO-0FrlpnBW51{=@{u~{J+}Qf z+x>5cz(K$p_zu4Orn{>t!HUY1j&V5$pu?&@0d6tfnO+d`^J*-+9QRO^U3kApux>I`M%=H4jA;T0o2dnQ2rW+4eIy(rc(uEz@uz{@Z^VCIj*$`n-)0i zcEbS#;MklmCaM}EGtZs%K-@T#YsBBLVDxys?FF1SGRb#@p!y;7n~Y@wssRYY3W#Kj zX!lCG2!cC|J}VDe3*M5mF%T#wm0M+IyF`Te{x>5am~xGP=k!~D>U9eU3tMBD411qy zOOGi=mH0oq8m`cB$!>JH2Y^v*_Bn^q-L8r0k-kaEC_JM#OCVkE7529evXLvS(>=X%*vnhQ*LG@?iU>3kkBs6zS z3p+E9RC^88=M%~hPT}_^uKihE++{s`+)+98As#?-QT%e0vc=ZY=mpWSw^~W7z=7!u zR;a)=0+vyB=BW5;QGe(Q%6~G9e|CzX3C1h-kMFg_f4%mZfH7(jm%}mxYGZhlPeb_5 zet-O~5&oT0+=Yzw2db#8hVX;VX_-6j$KtnGMUKH^Tc5yWe+lwhIE_Z=^n^jiv~0n# z>G@OZgC~R+g5~w;1$E>yF{oYXdv&axz9{^hKqv;Bvf6yY@vAvgw=K>*e|et0@@lBx zRbvO1rk}R+99!R2kGu!7sT+$WUshvJ&R%*bKetRAD^)!>1|^`ay=3V#?$gAkh&oy% zOq`-^YzE5~=r?QkH?u%GY$;Mr2VwfNto!(+FEY2pl+DBOsQn?R`{&tDkI2kOg3yC` z@a=<;`3IbRae=FMoZvCG0qpJ9w@#mNQv|!E>$2w_!3NCOF6GVEvpOfP-0{IF7xWx@e@JEg5w50)8*pi z#zvX#gDd#AjtTrLgotsq`pvaMT-rZQviBf4BhW|S^r{yvA0)tR`0|fE{qoJPO?OU_ z@vI5&kz$UT?c6-Ea8nR#LOyNhWT$aM3MwXog6ybB)HKX8Wdt*r>B$w}pVlM?MHpn< z+B3b&WF7MT@4MjtI65yt!z-nt68%liSbjS{O<}bG=aL^r+M{l0HVILc)T)O@=tdr? z>ai0|W~SqxqyArw^8a+u8uEI@9K^$+h@QNI5bWE{-)+6|8YIWX*E;OFoqaU(GkN1q z8Yuez`LX>wvzJl<%l!5o^-0cH#t5`EdF*vqtKI&_&f%4|NQOg^Qe*Z;-+x_-fAc$E z;Cag^kfw$rx{uW(x+^~Z?%X%(unR+?PM%B3Xosel>8d=xlN(jY`{$7Vn_uuB)?H5z z{)OwtUX?T%#uK{d4Vp=(&%LlZCfra}*Uhm`N%Q#FvJ%yWurekD`q&tr%% z8m63h4Lzsy4T&Tuqt<=VQ7sxpett5_lJ0r915Fe~A*CqUzW$4M!9 z2qK80KW@842js08gWN$%|1yI3`Io1sAHGv;4G|fK4BEX{IFfobmWQ}4TY6s^t8T50 zwNGcjrLA1f866zx3MZxh?%%~-|KZC&9EIjb7_XQe{4ou}(V-bLp;TIui%L{-n5$L1 zN?MI_9V+%o&YfRP45Em;@YU&Pj=&of|D228z7UU-B_Tc+XdNQr8ioBm`XQA4Lx0R& zLCo}DUrZKNPsbeQwJ4Fo_eaTPKF!_p6fpG*SAL^HRIMNgBI0SVa#oRnl(Y06}LrFxY2 zlmGIp|L0c!^QrU%;a|L?0cYt$<1T{aDET%ic~KFmwznWq69=r0 zIcGT>w0{%O&B=OgaLqwHaPa^9kX6j!in;iOGWwc4tDV8Jd#)= zC9#VR3g#_Tb&*$^cW=-kApHNgLa#VsjD6Ci22?T7GhHBkEFCIkipr9V5fcSAnY?B^ ze#DQw2T8o_n#u;T8~pzd;{l#6@6Zz8Ulsx0`|`W<<9~do<`V(rmIAn%0}xGJbU{L+ zlY;^jZQp4`N89VJMeMt*uKe%)`JXK3U-mLnS`PtH6#YgS{hXEL=4fxoeW$GclZ3GB zj@)Q`3#0S??4d8=4bjge29Kj){qFB%pGRdXEY&{%Kf45ULQsGrm-{O&JEPyOI|XeZ z8=sAWWGbP4xAO#v*%DyIBgylfDu24aULXV|!sx^K^TsOb1fc9nHw$8yB_MT!%(Gi8 z{2-N{w@&sL29=WHaFFM~Z{cR0W2uKC`9-?ex95hPUlA+{DpXyvj5j4F@wKHNVkMw? z)A-_BLaI>8HTGN@!)Uw8;5-+~~Mr$2#<V>MI! z1T>slqcp8>&cGcPYtGi8;8~LdMK%aPtr|`z9^$Go+6n9=HX8Dh1q7KyZnm2>2G79c zaI`mK$_4kH_vfA~&cDvX>w0h=Jz~v}6nU#$zrm*uTv}0FC@lQgo5gdJisP-RK~N>f zNj@6uos2FA=yI|L@2l^N#12nWP_C$(t3hqd({L{h_xc+aNy-M7E5AJm-Z*aDvo>#~ z>(&@x1N#3gPq7TD1RX}j_O(sjz!%Z$GMRd`SEPS7!U=kefTxF9ix1pUrcLIg?)}wd z=_mZ_J_4aAf5!yR1`z5Q+j5^p)A>Z5jFjBOQ_Q{8CFjlEX}q30=*@rUK4;U0( z_zWry(I34@aI-_z8UyL2Lix_#Qy>TPJ99q9ppQ6l9g2?5yD0tAkx5tiJ8c(Y5qy*6y9S2>w(BDWghfTV8Bfp@MP%<>W_OfW!a}P z9GV|Li}+YlWVGC{DR9(|vG;o~>f8)e{zE3ifew4+n&mJ!2ETZ{U7?D@*MWJ3wbVNI zBi9JpD44oEm<0j_E2&=ysFCowS#>Osxi?e(S~^pTDiFzWPH3!MwU((iWSo8ztQd+7 z+*{_F`&c$0#mC2oiTi+2k|OU8z6{}7j|p-K@Dr3unZA~Fxw)fl5L0`;1DTZCxZNDP zZkIdoq#2@fvX_p%A!jV~rWZzpMI=^oqO{_f((+OORy>^1{clW$EP^JdwJK6C`n zG`r#5c1y&66^SVUpl1DHWRW{rg5!j`8LK{gd4gBOwJW~MYS{1(ula01A69#B_>Ij6 zfv^=QPG6delI;a}=(~W-qVwb%`!ppU2)gxu7wZpe3Tph#b z?_mpT*5it&&GY={ifTcr&OwW zqs?`N@FNO*dbneXIhIfUkN%PG%zdxFYm#l#;rnQk*)6QBZS-ZE&1YSkX%rOS3ysK> zKhvayC->W~)s^MHIc!5~nYJjxm+^b|*$`MPB=YSb+osz+>%I|AuVz6Eo3+O2$Tiv; zx~f^`-)GWHyaw$7dNYh2#dNA73->*xK?#<7*fONpZKe4n(puy)yR}1(ctrO^zrEZ>g23&+4nFg9+rwLihcku><4P_(yCBZu>P%y_LMu;c>_)M)$!EBObp{?^Xov z-uy<9*v7JmGMNkN`K1V>d(n#BT3EOLdiM+85+JY-OpiD}2omypbVd2+0?|TaGhj=% zU{CNJL9fQ!30t0FN7s9vFqf)xE=ZEn>ZQ!^b+l98!rn&Y5eS@6y#$$Hw%mXID7fOE zwSwvo-@QzivifsYHnCu0Q@TY3BA-SDLH2EMRsQlG$KfUI4<{;LuE^jaObDb%f@gmBzNaT4dE^vzJh3ySVAkVmH2E`t zsyZMXMQnsKE(2G8(st2&3W^hmK+FZ{12IWJq`H6i$fy?9O4`V>eMskG25QbE_An+j@U9bl=W;s%dz{zx6?|%QaAVKJXHp4a}|_(MM}Vs|l&e znsf#;ManA%PMJ3Z54@Dn1$@|(*dKt9t~}*cz7{)s4Pukh3&3utA_&FfDy6-v>3~b5w=>@*6x_gV zpLptlhD8A;w#UG6r|}aR>yBz$!rYfcfZ&K4sIG2lP%pxVUNWl=ZmdQLyk zUp8RVa~@{+r&@N7(VItz-Mv?{k!0L2q9n&fW;L2y5Ktj&;aTmy z>lt>p5A6D%@aQ4@nll(U&evqZ(U4mNr{&uWl7&^>)pnkt+e4Ku!TJjdAwfsqrQFLz z`MkL=YddSNV5q!L8s8m#ndf>6%Hg{-ji4%T5yD zR#i%Jranf2s`K_{L!j!k?%ugF=Jm=RcGNv#4X2TAgdu+J4$utxS~5-EiG^i=)3K9T zfd0`N5_p_fP^lr4%wi(9wWE9QY6iUI(I*fXQBIKvzliq_AnT2NM42^J6ekU!#k1&J z1jD1FO3XVVOhs3zB^OwVNff7+scd~a5a$ICNc0D~9=}@<7;8l>V5Liw_fq#|`yz47 zGOpJFuDtXI$E^H*tGYeak!zBJAe;W^wdz}hF=p?qoiF@-$8%fIHBEB&Jqumv=H^aA z&UsGYo8INU=wD18b$gXG!5b+qFcpLG=8-P;0`lAx|4(J#8P#OBb*qR1B7$_0qV(RI zNE1PjDn&{{mtLhx3ka`>bZMa%5k#a!iUFwt(xekQ2!c{WkrHBnz}@Kke)oK1oO|w( zUm1*yEUqq^f>)o9N;C87lQ8k`W65alWwpAu z8&)5%#j?1Ivsw5QIeAoa0H_muVx)%#otd{TEyCZ-t9W5ioa?}UPeAO*55Qb*tsL2# zODF}M8kJ%G@*xlV^PXq7g*BWf@z17^;bX(Lsb=t=$-s$T+38EEQL$7cp_gymcQ)ts zRIqA2TtBc>>Z}|>PatSMFPE#~&F_w1HOurSGnm^@C9BwMa`TtGkH>mYW3~WBim`fM z+4rN=kZv2VkvF#73$6PaM!Ilw411gEMrR-g%@+Mf#+K5 zZwQGaWXLARIL~S-Cr3rfmJ-PZD3*@bG-1kWg-gOi1P;=1yNT%^2b7hI)4JLyFG}nr zo_VQzI{~jQDgR>zY$%mtg(~uL*N201i<1=ZZbSBxx_Ep}FO&s!%CgeCj!CN{7xiLL z3rlmy8*^|M=L)}u){A_}%pphH)qdS9V5rY4mRSGlpKgW^)H@lyG`mYxKS>KLye4lq z2xZQDu;y&C7){>0plkZkAUI@F(w(D9@ydrHTXvV>4+(zi{-B&$^ee3mIVcMGTnUiB zSfE;I_)0S_vzcf0cIIb?gzv~{x1Jr|ujFrBrk@)x7Z>F~WI(!M)lV5}dox0_m9x@Y0fGe{W>u3%G!9`@iAqs zLoX6z5>vV1v=CJISN^ztAJbcBpri}oftD1r`zBh~)tTD8a-{1}H613en@!0#iGWq& z)V$u#hGe))6xF9Wf^-nIQ4q$OG9Kg#t+TZ@@7t~O?OLRRM8(}QOgu_DGr1hphgr)V z$Xu&hS{sqLH6Yo>;hHK;C=;t#=Fg)5hhV9I=7U{$ua142lC^-r=`}f(8{&CiZQW)0 z`H{$4u8tgD!pU9tqm}d(o3dX?=#*gPP7FU&b9XN(_lW(Le$$h;5!{eH@1lrTY3m2iQ#m)w5AQu%U5gRtJgNBR@jzQ|F4vP2$RG{Lr z(Kh9y0#@#jkFSZmkXlf~LWS#N3+ii8o|-+Bo0kY;%`Nwu65rh3;)A(?vC3~bF8r*@ z#q+=Bi=WM;eDmS!9S=x#Cx$!}vaHp5nMAr@`ddt?$n=3o{cHvf4Zt=4{_7p6H}PiAD5N^y{WBby+s-+>=ommc!))Q5 zGW-G!4#$vz^iPEd zynAxeRI3kHC?prf`cDP1DO(?N@mC4Kz;WMMUK72twbiyE$wIrK^ZA+ZFP6kx9>_** zEy(Bg1E%Z68utdwm41Blvpk`8kM?W%BrWn;S)!$s%FlCYx)pM1S_o4meZIjQ7f^g0 zbj`7;vX$ntrgZYCe;b+9Q=Om%s%=|CA8R-0*GsS8_Nl)WsG_HicP**rpVh>L0~Un{ zDtp<^_C|-o-r${~)8R*9k=Mfnh&#r?*mmm%Ih(%EMQ5SL#CEMR69mq*uH>&;uc$I! ziSIKAqD9E}X5W%pk^@Rg)z5&lP+2lNTh^ofnr5>7Gu#ucYQh?qT0YUuqjHbE8y8IK z`cYtr4`sX%&Ur)KW^(t#9;d!d=^HmL$>$e?y?MQz*`nC$NT0%uG zRB=)q&$ArgSUGU8PPSx?S`R3x)^5m&W0sSl1e4Lwt-tU$EBswIXC|T9fGc0wGY90f z4~q8gf^0rcdp9M^qK8KE?7Ifbv7`Kx5(_-9OZCuQk(pl1B)|T7=maq*cPy5`?l!l> zv6)9Z&BN@2@vcLz9+$JzoZ@LKl6TWBdD0Ylp9z7I(vnnDL>4gBc^|w%4Ye(ex3}=E z5-C{+#RYv5Y~9g3JoD;fNQ7(r6>_cn6q?G{+74tLX2m^Rg>UP^8-=z~n>@Q^K&@kT zH*XgIR6x7)3c5}jkSLWy)A8(;(gQ~Dmdk6Sh%ExUD}uBdTe-WKpW8sAWYJ_a!4?L2 z&LRvk%?=?!jC}_fKS`+2B1Ge(^1GH&sgAq7N-C0bg=MtaI)~rn`CW4#J*E&YdHDbl zA8e+nIq7LTouc=061%d8qY}Gw*@QR%j!n);$ruk5m~(NwM?-T z+&h1hfLl3L9csVG+`Me!k;A_WvuY~m1Abq zO^pl0=_m?urpDhrX5->e?~00=XleRld}uK}7RRD9o;?EF92K$kU<%mmUqdIKjhZOW zOUKI}I3Net@?Yt|*7QAe4G1n&-M_Ny+%k!j>M`Hv`)&y>A*_-yFhsC8oE<@4*lFuk z*r-N@nHWBg9<%$x)^YM9LLsKfM7L|_0qL6tod(^<>{;IV*O;q7r6zg?FW@+_}KS44NORU7wQ! zI3!)g0(7zKONucdisqfK0<%Nz>h@EfE2S868`qlN7wv~qM%2}|8+`J(oHW=`O5CBl zLONe?FDVS4$Qh-R*(i^$a)3UyvpHopEa6WpAD(A`o%r3z{!3lZ?9AKba_K5%`T?s9 zK~7J>cj~ANrkZi9F{)Qjj5?bJ*Bp@oPSW^2R2vCb)>qw^r_uMQTL!RFAo>yxNxrc3 ztOh6F{%UP(@{^IZmkdYnY$uUM{xe z!6_tY`tJVJ-eRrD#hGM9mD6*xs7yjEcfS*6JVn&n!x?%v=g#w9Rr#F+;*}x=yyKu) zV#r}l2q`#4KH7uw?YLKLwf>&heesZ~PN#wNVOhu8gk!!Ci<^b6l$+WFvGV^H$cp?1 zvc?&N47k68k6){)n(SBbX~ZiAS+BIR;5KFX6Z&GMX6avHqPa{uw#5hduIQR;f*8y) zcstkg8Iii_k0~^?X1OVlu|@|iMzQ9UI!`cTh7!YE2Pl)kXuH05VDMi=rhC)PAZ+~{ z8Q6%cGgvF)l@&Z0QsZo0mjUO{^GQaeqF-UutlQDbAfL%ZXc&>fVLq&c;J+6%PipbT zpnzX{EY(Kkj^T6mO0{fS5zLETq-x|4#JC(ukq*}g*9oXifxHI;P9D_oOD$%T%I<8h zdRR!m#!zg!5Fi^3ezuSYQxLr*g8L^}$jD3^@?E^KM{3u&{6!8dqR2$oVSmq8DV>kV z3&b*Xlsapv)H3d;ww@P0)!rURRC&>Hw%X)qRq~5Qm2YTCM{%k9B{X;h zDyW%5>T}f`LqYyoj=|obDHg2NITDN3jK#4vEhx5n>^L0*=JycQU6m3ZLN#2{;Gsd0|@^=0o3t70rb&QVxyG> zi`qP;nFZB&wWo8sMMIhUe@fn+TKX=Qi_;v0Kg4bHH!zQ)qaWcUWzGF}9nEK9u10KF zuw23yJ*!u;oBCyUd*2N$xSGunB&7PxzuGctNFXGGG_t*^6ljAvZS<|N$+6CC>$_-P3+GEezg?)|o@ZT0QFz+pedNhhrX^Th9*R`B|XUrpz zzNoM_S*3D?+VIh8vf@v`C(1s)<}c}*D@<6Iu6UZCj1HF-+&R>s#h-cP8cziEnoD1- zisgjFUXv0WD|3xWdF^^7)1z!NL=46Yc}+Z!?~~LlM-14Z%n#(w@d?Ax+8ozk#xylM z+=7^NjiM`i1WD`UF{yk9H@P#Kn-5G%azeivl)#3kG8Ge+#ZI z<-D$cxZtFIQrItfH1wDaNVMkJw0gVw%;~pMRnFBomKI0K^9pL z8Ut_@MTOgr5J*I8rA$G)#RqTa1p0)dq}qFKzqV{V6$ zM%Gs2r@$m^#&SUU*WND=7y~OL8m$hbD<@OwF{Ry84%ER%4Jzs|ufGXc+Sn##mVkT0 z*PTV16iZdE%pLIC@&827Fp~O)u4^PbVOG5Nu4n)`Ycb`d&Po%~y=J(^K9ZF5LMLAm z=4;DrFn$i^hPm4M9{$7~%e@4lNI~qr12SKyvt1L$w<$;6hOHvDJd-j^jgceTx9&Ci zfTu`Eos!xyD+^f3{M>pYc6P{fPyx`(9saED-G~(ouK5_< zoF6Xtg%qS^2@g_(io|w>xz?(cLoR}?ZwjuB*sYV}t?lDGVLUZ?>{lLe7iz$f3Xa%7gelaTO@`&Bg5q@;Q33G|M$Z3OWZk90WpUrIg&}Hz} zZ#J8nK;<7oCfn0|h3mJL%668DDCNkO^$9&Z7d&K$5P?FW!%?Gd{)l9k!*_{tS&@## zhTYQ>0wf({s&l*gwU=$z4NprO^GmwjtZqQEC1-F{_Kj;hN` zlRpka72of^ek(QW_iddq&5!+=mW#e5zmxvL0GcdRwXIKbVV{c9rL!%1yrV+q<=5N)X#0ac6yA-CJ^` zH|zz!H5 z|Av%g@S1gumVoX7hWpW-8A?^C&S|uYWX9{mX@Y?P_8FbI-wiigHfMGx`;-TUJSEy2 zZ^)q4g3;p@BnA251>!Ae_vD8V_#=C&L3@*}$N+8J%;Y(g15A^)ZLcKA?2e7rIv5=v zmn%0AF$W>2Tk2-Wl*@}Jb9CloLT9|Oz^u&_l>Z9b%J9$$Gb*Ka=bF+wFwKvzU!~61 zxxN?F0k7{|%=Y~-=|0FVrCJR*27Qek=nwo$M?K9)8opn!p|l_5d+L%(H-U*j z@;2j_RU@f**&VeRdF`ZqRYgf?$+KgtK zLU+)473q~@0Ufa)E!yb(9u)({HU5g*DMKx2k0aXPOVaaLd9FGI;dywJZT~bHEen0B zF(wU+&u2sJHo3%I`i!R5bjMY`h@lUDMCt^wu9(aE^o`|?y?ociw$h}Eoy5;ys5^<> z_Xjh7t(x)sC3WGhA=@t13g4ZX&cfzK8BTC z6${~oT8n4>SsQPWi0=ly9y+As8gs)nTKxRmOQcQ+!Tik%7JrKR?de-FC-`g6nTJe> z=A?FziwRj2m_fU9>I8oW^%R*j+y`J|Ot4!97mpW#<&uxC+?Z8wg&H3X|kNb*F z;W0I}W2O`%&_Hp7GXCakK?gCGCO1AVyO`WWZHV+2VuF+Wv=p8VO1__>mGYTlz=E+g z@)$aUdQPi1$tM#K!lnCURJTZ=oLQ7pH7U4nS{OU_aP zuY4D*@IzWx_?;*XGe}MAm+$gN^W+gglNB!ofM)*Zw|MeXbwG+NzT(tRK`%^TszY?i z2P!2V^z(_A*o*C>^>@6U3hzxeQ99E2GO^J7Z``u0v{JHFUFhmE^(FTkQZ^?h--hUSE zKYZHn@8Ewx;7*ot3tMJ!i8uUCwUtRXQa}u<%qf`o@eS9$%TT`FyFChh^?iklkpb-r zfAP;yg)16DZ27lXdt|#V2hyg-D-|*?)`%}w_|cKKPvp#&4buvg2c8;6>(TDQq6bI+ zva0`bFaMt}FFbp)8sJtTFwh!fSr5O*SkP=<(-jw3Bbt=vPytbM$`kI2yFiZI7|MU~ zMyjU$Q>8A00fSBJjYYJ8yL!P&ewrFlbd6#3C@af~+6< zS6_LG50(c=sBU*~s&uUUQ-Cko{x{&Caw)E0Yc%FII(3$yrs z;jvZxwP=Hcp%<3huIdqJ8Q}%@JUrrSR=0P|j*Y(fv&m+rMI6|Z!*?eIkg2TfNQXclY+&N?k7tM;QBvuBNu z!HxT`;}iUVb83nV`6^R8nqfJCi}h#J=0|v%GA6iat`v?%y-gXYNf^u2(Li7SPtN{- z^L5q9ALZW4U5ujTH-3~9PIqr63FNh}s@tIEd#uIEN=I3^z^a2iO8FcZXpUIMRQ~a^ zN--#CpENQ|frkRWlewtwkPYc((YKL%>ugfMgF1*pC2`u$gU;k|(1oSItYK=PVVq1; z`DA>fm6cG1hv~P%``<1DNT^9#J-dmJ=rGpvnuOkEk)it~?3)QMRsd1{w(^1ZsWStJ zN@CU-pOgi8cj}VA8a@Y5kM#Mlbo-y$e9v+`DyxB$M1j%SGN(P0beXmGOmqi+uOH9) zb6wz|o&u;*PG(nX(k6-D>}(A%?Z3E%K*8FJR&&Vz)cSm%KnUvWQ7`sBgCt6GMr;5n zcZehjZc5qe2W=t`M}Ew=wVBvwr~j_(s0H97 zag^@6L;pt+P2oTiI$mn%_)Ij_gj+9$Bw$vOPi0PgexRm`k@gX(%4yYK3wrKaw}P?t z?||O^6gXyC*8zK}tM+|5tJsvmH{nGPNz{(E$LR2m|IO3puKzYtK;k_}`6yL*Ro$7N zikhdTl-kTLKYsx7=o(W`Bi`5jDoNy?=VQ=Jry4pmX`jmk;QI%c!zzRO1r37iZ`m33 zvP=uB(?0&E28Vx>+w*7$nQPRAdW=kwi|LeF6IFxeMaIHnT;FM+deg<)PAeSHAw>^b(2Pcv z5=1o&lIbq{oAOGpm8GBjn_TiA(y$^aiK$gawuRB#=KMUht6B!$b*?aEbmSA{9I&bX zW$vcZ6|R`+QhAS>8kFB55G74(3IX(@w-0^Ek^QeIJKyL%ZG>j@*f5;&soRN z0+I}ivXr-BtTe_zaI?qk?ZspZTeThmL=^!HG3-=CJa|xOC(+d?d!0>qs#5IL@*=3w z)HHcXcVc((+aHH*_{SjBN{j@)zZCL5r6h#+yQyPg%BC4G5y$1o1@7lZumnqA3^6?t zgB|P{TLTAvbnvWt0!VRAG|P)Lxe!kEHpi=fmRFxB<`VOZ_Vo{e&Nx&f#;KT(TkrfF zuK%%{_||tY)K#bs2o7!FqzsC3py~^Q#Li7D^Zmsg_{5IpqUm*md&ULck8^S^#>$fY zVdh(*3RN*Yu%V*5cg|p(tT^+b$1~8-xIsk3kJaCWN!de5VG@%eM1CbSvr3exPBE zjM!7p9+s7h<@!TlB(D5bqK9K6PF%Lx?TvD*=Hglo!_{l|0zkj8VYbxPmn5z0-jL#I zhiuCW$(n-H>T=PotXK6#jd(HK5R#WR3v7{*-`Vf*3apWc*~wDHkIz}!?BV|KXBx2o zsB8~D1S6q8cp@721M02gI9U%L-2c9zaJXK@moeoylXqJ#*QEMgb^N{X3s_~91d`|c zNl%qM3)^|wBBN+i0@_uGqgWY^e>sx>+wC!rGF0VtPr6G$rn8osc=uD~U0&NY4O0z5 z6~~CuZ|KSE@{S&XrF3>NjI_BjT7+Ij?|ichC zb+<8}4@3NDcI^r))35A$A@R)au;TFRGTRi;4QGq#70UM54Tj3hIMNWPfRaE!DZ1fz z4NyjyMqZEU6%{;XntLvVO1TtZr_s!TUu+;*sgRpHH_A#)eGrwOc^N+12Bcd$`~c&H z{a#Q*>R literal 0 HcmV?d00001 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/core/alchemy.py b/src/excelalchemy/core/alchemy.py index 873a8e9..cd30463 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -185,9 +185,10 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im self.df = self.df.reset_index(drop=True) all_success, success_count, fail_count = True, 0, 0 - for table_row_index, row in self.df.iloc[self.extra_header_count_on_import :].iterrows(): + 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(cast(RowIndex, table_row_index), aggregate_data, self.df) + 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) diff --git a/src/excelalchemy/metadata.py b/src/excelalchemy/metadata.py index 20225d8..0d69d04 100644 --- a/src/excelalchemy/metadata.py +++ b/src/excelalchemy/metadata.py @@ -334,6 +334,12 @@ def _resolve_declared_field_metadata(field_info: FieldInfo) -> FieldMetaInfo: 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)) diff --git a/tests/contracts/test_import_contract.py b/tests/contracts/test_import_contract.py index 775d5dd..dd22ec1 100644 --- a/tests/contracts/test_import_contract.py +++ b/tests/contracts/test_import_contract.py @@ -1,11 +1,13 @@ +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 SimpleContractImporter, creator, failing_creator +from tests.support.contract_models import MergedContractImporter, SimpleContractImporter, creator, failing_creator class TestImportContracts(BaseTestCase): @@ -139,3 +141,35 @@ async def test_import_result_workbook_supports_english_display_locale(self): 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/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index 4bd0907..0376e67 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -433,6 +433,21 @@ class EmptyFieldMetaModel(BaseModel): '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)] From dac1224243ef5fd6555260c726e7fb1f1e9ce335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 19:13:12 +0800 Subject: [PATCH 22/27] feat(docs): update readme --- README.md | 24 +++++++++++------------- README_cn.md | 4 +++- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c69dd03..e514bc8 100755 --- a/README.md +++ b/README.md @@ -18,6 +18,15 @@ pluggable storage, `uv`-based workflows, and locale-aware workbook output. The current release track being prepared is `2.0.0rc1`, the first public release candidate for 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 These screenshots are generated from the repository itself using @@ -73,24 +82,13 @@ template = alchemy.download_template_artifact(filename='people-template.xlsx') 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. -## Highlights - -- Pydantic v2-based schema extraction and validation -- Locale-aware workbook text with `locale='zh-CN' | 'en'` -- Row-level failure reporting and cell-level error marking -- Pluggable storage via `ExcelStorage` -- No pandas runtime dependency -- Python 3.12-3.14 support, with 3.14 as the primary target -- `uv`-based development and CI workflow -- Contract tests that protect import/export behavior during refactors - -## What This Project Is +## 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. -## What This Project Is Not +## Non-Goals - Not a general spreadsheet analysis library. - Not a pandas-first data wrangling tool. diff --git a/README_cn.md b/README_cn.md index f181a05..313cdd0 100644 --- a/README_cn.md +++ b/README_cn.md @@ -11,9 +11,11 @@ ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 ## 截图 +这些截图由仓库内的 [`scripts/generate_portfolio_assets.py`](./scripts/generate_portfolio_assets.py) 生成。 + | 模板 | 导入结果 | | --- | --- | -| ![Excel 模板截图](./images/001_sample_template.png) | ![Excel 导入结果截图](./images/002_import_result.png) | +| ![Excel 模板截图](./images/portfolio-template-en.png) | ![Excel 导入结果截图](./images/portfolio-import-result-en.png) | ## 这个项目适合什么 From e1e3d0d4ee7d9de64bd7b9899730195617ad1a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 19:20:08 +0800 Subject: [PATCH 23/27] feat(docs): update ci --- .github/workflows/ci.yml | 6 ++++-- README.md | 4 ---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c563f7f..d31f642 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: timeout-minutes: 20 permissions: contents: read + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} strategy: fail-fast: false matrix: @@ -116,9 +118,9 @@ jobs: retention-days: 14 - name: Upload coverage to Codecov - if: matrix.python-version == '3.14' && secrets.CODECOV_TOKEN != '' + if: matrix.python-version == '3.14' && env.CODECOV_TOKEN != '' uses: codecov/codecov-action@v5 with: files: coverage.xml disable_search: true - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ env.CODECOV_TOKEN }} diff --git a/README.md b/README.md index e514bc8..1bc68d8 100755 --- a/README.md +++ b/README.md @@ -29,10 +29,6 @@ The current release track being prepared is `2.0.0rc1`, the first public release ## Screenshots -These screenshots are generated from the repository itself using -[`scripts/generate_portfolio_assets.py`](./scripts/generate_portfolio_assets.py). -They show the English workbook locale because it is the clearest presentation for the public-facing README. - | Template | Import Result | | --- | --- | | ![Excel template screenshot](./images/portfolio-template-en.png) | ![Excel import result screenshot](./images/portfolio-import-result-en.png) | From 43f85a6e5a49c7edad0f2b94fda163a8b7e219dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 19:27:06 +0800 Subject: [PATCH 24/27] feat(docs): prepare release --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 11 +++ MIGRATIONS.md | 4 +- README.md | 2 +- README_cn.md | 2 +- docs/releases/2.0.0.md | 70 +++++++++++++++++++ pyproject.toml | 4 +- src/excelalchemy/__init__.py | 2 +- .../test_excelalchemy_workflows.py | 4 +- tests/support/contract_models.py | 2 +- tests/support/mock_minio.py | 6 +- 11 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 docs/releases/2.0.0.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d31f642..6f98998 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: permissions: contents: read env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + CODECOV_TOKEN: ${{ secrets.CODECOVEXCELALCHEMY }} strategy: fail-fast: false matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b367c..4e313b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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. diff --git a/MIGRATIONS.md b/MIGRATIONS.md index ee93f14..ba7fe13 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -6,7 +6,7 @@ ExcelAlchemy 2.0 keeps the public workflow recognizable, but the project has cha 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.0rc1` and later `2.0.0`. +`1.x` line to `2.0.0`. ## Platform Support @@ -106,4 +106,4 @@ Additional top-level module guidance: 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 the release candidate before moving to the final `2.0.0`. +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 1bc68d8..c5c28fa 100755 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This repository is also a design artifact. It documents a series of deliberate engineering choices: `src/` layout, Pydantic v2 migration, pandas removal, pluggable storage, `uv`-based workflows, and locale-aware workbook output. -The current release track being prepared is `2.0.0rc1`, the first public release candidate for ExcelAlchemy 2.0. +The current stable release line is `2.0.0`, the first public stable release of ExcelAlchemy 2.0. ## At a Glance diff --git a/README_cn.md b/README_cn.md index 313cdd0..e59330c 100644 --- a/README_cn.md +++ b/README_cn.md @@ -5,7 +5,7 @@ ExcelAlchemy 是一个面向 Excel 导入导出的 schema-first Python 库。 它的核心思路不是“读写表格文件”,而是“把 Excel 当成一种带约束的业务契约”。 -当前准备发布的版本线是 `2.0.0rc1`,也就是 ExcelAlchemy 2.0 的首个公开预发布版本。 +当前稳定发布版本是 `2.0.0`,也就是 ExcelAlchemy 2.0 的首个公开正式版。 你用 Pydantic 模型定义结构,用 `FieldMeta` 定义 Excel 元数据,用显式的导入/导出流程去完成模板生成、数据校验、错误回写和后端集成。 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/pyproject.toml b/pyproject.toml index c24abca..c4f227c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,12 @@ build-backend = 'flit_core.buildapi' [project] name = 'ExcelAlchemy' description = 'A Python library for reading and writing Excel files with Pydantic-based schemas.' -authors = [{ name = 'Ray', email = 'hrui835@gmail.com' }] +authors = [{ name = 'Ray' }] readme = 'README.md' license = { file = 'LICENSE' } keywords = ['excel', 'openpyxl', 'pydantic', 'minio', 'schema'] classifiers = [ - 'Development Status :: 4 - Beta', + 'Development Status :: 4 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', diff --git a/src/excelalchemy/__init__.py b/src/excelalchemy/__init__.py index 8f9751e..c727830 100644 --- a/src/excelalchemy/__init__.py +++ b/src/excelalchemy/__init__.py @@ -1,6 +1,6 @@ """A Python Library for Reading and Writing Excel Files""" -__version__ = '2.0.0rc1' +__version__ = '2.0.0' from excelalchemy._primitives.constants import CharacterSet, DataRangeOption, DateFormat, Option from excelalchemy._primitives.deprecation import ExcelAlchemyDeprecationWarning from excelalchemy._primitives.identity import ( diff --git a/tests/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index 0376e67..76f52ff 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -332,7 +332,7 @@ async def test_export_returns_simple_header_dataframe_for_flat_model(self): 'address': '北京市朝阳区', 'is_active': True, 'birth_date': datetime.datetime.now(datetime.UTC), - 'email': 'norepy@icloud.com', + 'email': 'noreply@example.com', 'price': 100.0, 'web': 'https://www.baidu.com', 'hobby': '篮球', @@ -374,7 +374,7 @@ async def test_export_detects_merged_header_layout_for_composite_fields(self): 'address': '北京市朝阳区', 'is_active': True, 'birth_date': datetime.datetime.now(datetime.UTC), - 'email': 'norepy@icloud.com', + 'email': 'noreply@example.com', 'price': 100.0, 'web': 'https://www.baidu.com', 'hobby': '篮球', diff --git a/tests/support/contract_models.py b/tests/support/contract_models.py index 2303af8..46703a6 100644 --- a/tests/support/contract_models.py +++ b/tests/support/contract_models.py @@ -101,7 +101,7 @@ def sample_simple_export_row() -> dict[str, Any]: 'address': '北京市朝阳区', 'is_active': True, 'birth_date': datetime.datetime(2021, 1, 1), - 'email': 'noreply@icloud.com', + 'email': 'noreply@example.com', 'price': 100, 'web': 'https://www.baidu.com', 'hobby': ['1', '2'], diff --git a/tests/support/mock_minio.py b/tests/support/mock_minio.py index 35dfe14..c380ecd 100644 --- a/tests/support/mock_minio.py +++ b/tests/support/mock_minio.py @@ -54,7 +54,7 @@ class LocalMockMinio: ], FileRegistry.TEST_EMAIL_CORRECT_FORMAT: [ { - '邮箱': 'excelalchemy@163.com', + '邮箱': 'person@example.com', }, ], FileRegistry.TEST_SIMPLE_IMPORT: [ @@ -64,13 +64,13 @@ class LocalMockMinio: '地址': '北京市', '是否启用': '是', '出生日期': '2021-01-01', - '邮箱': 'noreply@icloud.com', + '邮箱': 'noreply@example.com', '价格': 100.0, '爱好': '篮球', '公司': '阿里巴巴', '经理': '李四', '部门': '研发部', - '电话': '13223658966', + '电话': '13800138000', '单选': '选项1', '老板': '马云', '领导': '张三', From ded6afc8499b4f61c92c700902f2767e6922b980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 19:40:36 +0800 Subject: [PATCH 25/27] feat(docs): release --- .github/workflows/ci.yml | 1 + README.md | 2 ++ pyproject.toml | 4 ++-- src/excelalchemy/codecs/number.py | 2 -- src/excelalchemy/core/table.py | 13 ++++++++++++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f98998..428f92b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: push: branches: - main + - v2 workflow_dispatch: concurrency: diff --git a/README.md b/README.md index c5c28fa..7a89458 100755 --- a/README.md +++ b/README.md @@ -244,6 +244,8 @@ Use the built-in Minio implementation when you want it, but the library no longe ### Why no pandas? 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: diff --git a/pyproject.toml b/pyproject.toml index c4f227c..26e085e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = 'flit_core.buildapi' [project] name = 'ExcelAlchemy' -description = 'A Python library for reading and writing Excel files with Pydantic-based schemas.' +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' } keywords = ['excel', 'openpyxl', 'pydantic', 'minio', 'schema'] classifiers = [ - 'Development Status :: 4 - Production/Stable', + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', diff --git a/src/excelalchemy/codecs/number.py b/src/excelalchemy/codecs/number.py index f9dbfc7..f071245 100644 --- a/src/excelalchemy/codecs/number.py +++ b/src/excelalchemy/codecs/number.py @@ -122,8 +122,6 @@ def __check_range__(value: Decimal | float | int, field_meta: FieldMetaInfo) -> 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)) - else: - pass return errors diff --git a/src/excelalchemy/core/table.py b/src/excelalchemy/core/table.py index c6ddc2b..5b4bd09 100644 --- a/src/excelalchemy/core/table.py +++ b/src/excelalchemy/core/table.py @@ -77,7 +77,12 @@ def __getitem__(self, key: slice | int | tuple[int, int]) -> WorksheetTable | Wo class WorksheetTable: - """A minimal 2D table API that mirrors the table features ExcelAlchemy actually uses.""" + """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, @@ -140,6 +145,12 @@ 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) From 380119bb54c6693086e9380314fc6b89b957b598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 19:49:33 +0800 Subject: [PATCH 26/27] feat(ruff): fix ruff formatting before release --- src/excelalchemy/_primitives/header_models.py | 4 +- src/excelalchemy/codecs/date.py | 14 +++++- src/excelalchemy/codecs/multi_checkbox.py | 8 +++- src/excelalchemy/codecs/number.py | 14 +++++- src/excelalchemy/codecs/number_range.py | 7 ++- src/excelalchemy/codecs/organization.py | 10 +++- src/excelalchemy/codecs/radio.py | 4 +- src/excelalchemy/codecs/staff.py | 6 ++- src/excelalchemy/codecs/tree.py | 10 +++- src/excelalchemy/const.py | 1 - src/excelalchemy/core/abstract.py | 4 +- src/excelalchemy/core/alchemy.py | 32 +++++++++---- src/excelalchemy/core/headers.py | 4 +- src/excelalchemy/core/rows.py | 4 +- src/excelalchemy/core/writer.py | 4 +- src/excelalchemy/exc.py | 1 - src/excelalchemy/header_models.py | 1 - src/excelalchemy/helper/pydantic.py | 4 +- src/excelalchemy/i18n/messages.py | 22 +++------ src/excelalchemy/identity.py | 1 - src/excelalchemy/metadata.py | 8 +++- src/excelalchemy/results.py | 22 ++++++--- .../test_core_components_contract.py | 4 +- tests/contracts/test_pydantic_contract.py | 8 +++- .../test_excelalchemy_workflows.py | 48 +++++++++++++++---- tests/unit/test_i18n_messages.py | 10 ++-- 26 files changed, 182 insertions(+), 73 deletions(-) diff --git a/src/excelalchemy/_primitives/header_models.py b/src/excelalchemy/_primitives/header_models.py index 74325a0..c152b1d 100644 --- a/src/excelalchemy/_primitives/header_models.py +++ b/src/excelalchemy/_primitives/header_models.py @@ -11,7 +11,9 @@ 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.') + 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 diff --git a/src/excelalchemy/codecs/date.py b/src/excelalchemy/codecs/date.py index 8c6b420..7bd07ba 100644 --- a/src/excelalchemy/codecs/date.py +++ b/src/excelalchemy/codecs/date.py @@ -32,7 +32,12 @@ def build_comment(cls, field_meta: FieldMetaInfo) -> str: @classmethod def parse_input(cls, value: str | DateTime | Any, field_meta: FieldMetaInfo) -> datetime | Any: if isinstance(value, DateTime): - logging.info('Codec %s received a parsed datetime for %s; returning it unchanged: %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: @@ -44,7 +49,12 @@ def parse_input(cls, value: str | DateTime | Any, field_meta: FieldMetaInfo) -> dt: DateTime = cast(DateTime, pendulum.parse(v)) return dt.replace(tzinfo=field_meta.timezone) except Exception as exc: - logging.warning('ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', cls.__name__, value, exc) + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) return value @classmethod diff --git a/src/excelalchemy/codecs/multi_checkbox.py b/src/excelalchemy/codecs/multi_checkbox.py index aaedcdb..d9054a4 100644 --- a/src/excelalchemy/codecs/multi_checkbox.py +++ b/src/excelalchemy/codecs/multi_checkbox.py @@ -34,7 +34,9 @@ def parse_input(cls, value: object, field_meta: FieldMetaInfo) -> list[str] | ob if isinstance(value, str): return [item.strip() for item in value.split(MULTI_CHECKBOX_SEPARATOR)] - logging.warning('ValueType <%s> could not parse Excel input %s; returning the original value', cls.__name__, value) + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value', cls.__name__, value + ) return value @classmethod @@ -49,7 +51,9 @@ def normalize_import_value(cls, value: object, field_meta: FieldMetaInfo) -> lis raise ProgrammaticError(msg(MessageKey.OPTIONS_CANNOT_BE_NONE_FOR_VALUE_TYPE, value_type=cls.__name__)) if not field_meta.options: # empty - logging.warning('Field %s of type %s has no options; returning the original value', field_meta.label, cls.__name__) + 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)): diff --git a/src/excelalchemy/codecs/number.py b/src/excelalchemy/codecs/number.py index f071245..36f151e 100644 --- a/src/excelalchemy/codecs/number.py +++ b/src/excelalchemy/codecs/number.py @@ -61,7 +61,12 @@ def parse_input(cls, value: str | int | float | None, field_meta: FieldMetaInfo) 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) + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) return str(value) @classmethod @@ -72,7 +77,12 @@ def format_display_value(cls, value: str | None | Any, field_meta: FieldMetaInfo 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) + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) return str(value) @classmethod diff --git a/src/excelalchemy/codecs/number_range.py b/src/excelalchemy/codecs/number_range.py index feeb753..1f2fd64 100644 --- a/src/excelalchemy/codecs/number_range.py +++ b/src/excelalchemy/codecs/number_range.py @@ -68,7 +68,12 @@ def format_display_value(cls, value: object | None, field_meta: FieldMetaInfo) - 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) + logging.warning( + 'ValueType <%s> could not parse Excel input %s; returning the original value. Reason: %s', + cls.__name__, + value, + exc, + ) return str(value) @classmethod diff --git a/src/excelalchemy/codecs/organization.py b/src/excelalchemy/codecs/organization.py index bc1a58e..66db89f 100644 --- a/src/excelalchemy/codecs/organization.py +++ b/src/excelalchemy/codecs/organization.py @@ -16,8 +16,14 @@ class SingleOrganization(Radio): @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)]) + 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: diff --git a/src/excelalchemy/codecs/radio.py b/src/excelalchemy/codecs/radio.py index 6c1c528..c6ad935 100644 --- a/src/excelalchemy/codecs/radio.py +++ b/src/excelalchemy/codecs/radio.py @@ -60,7 +60,9 @@ def normalize_import_value(cls, value: str, field_meta: FieldMetaInfo) -> Option 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__) + 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: diff --git a/src/excelalchemy/codecs/staff.py b/src/excelalchemy/codecs/staff.py index 8854177..71e2dc9 100644 --- a/src/excelalchemy/codecs/staff.py +++ b/src/excelalchemy/codecs/staff.py @@ -17,7 +17,11 @@ class SingleStaff(Radio): @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 + 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 diff --git a/src/excelalchemy/codecs/tree.py b/src/excelalchemy/codecs/tree.py index c56767c..f88a4ae 100644 --- a/src/excelalchemy/codecs/tree.py +++ b/src/excelalchemy/codecs/tree.py @@ -42,8 +42,14 @@ class MultiTreeNode(MultiCheckbox): @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)]) + 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: diff --git a/src/excelalchemy/const.py b/src/excelalchemy/const.py index 71c69bb..afc3ef8 100644 --- a/src/excelalchemy/const.py +++ b/src/excelalchemy/const.py @@ -1,4 +1,3 @@ """Compatibility re-exports for lower-level constant definitions.""" from excelalchemy._primitives.constants import * # noqa: F403 - diff --git a/src/excelalchemy/core/abstract.py b/src/excelalchemy/core/abstract.py index dc00ef2..924a7d1 100644 --- a/src/excelalchemy/core/abstract.py +++ b/src/excelalchemy/core/abstract.py @@ -49,7 +49,9 @@ def export_artifact( """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: + 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 diff --git a/src/excelalchemy/core/alchemy.py b/src/excelalchemy/core/alchemy.py index cd30463..21382cb 100644 --- a/src/excelalchemy/core/alchemy.py +++ b/src/excelalchemy/core/alchemy.py @@ -157,7 +157,9 @@ def download_template(self, sample_data: list[ExportRowPayload] | None = None) - 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) + return self._renderer.render_template( + df, self.unique_label_to_field_meta, has_merged_header=has_merged_header + ) def download_template_artifact( self, @@ -190,7 +192,9 @@ async def import_data(self, input_excel_name: str, output_excel_name: str) -> Im 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) + success_count, fail_count = ( + (success_count + 1, fail_count) if success else (success_count, fail_count + 1) + ) url = None if not all_success: @@ -224,7 +228,9 @@ def export_artifact( ) -> 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: + 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: @@ -284,16 +290,26 @@ def get_output_parent_excel_headers(self, selected_keys: list[UniqueKey] | None 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]: + 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 - ] + 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) + 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) diff --git a/src/excelalchemy/core/headers.py b/src/excelalchemy/core/headers.py index c70ad60..a2e0f0c 100644 --- a/src/excelalchemy/core/headers.py +++ b/src/excelalchemy/core/headers.py @@ -96,7 +96,9 @@ def validate( ) -> 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] + 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] diff --git a/src/excelalchemy/core/rows.py b/src/excelalchemy/core/rows.py index e241641..d5da170 100644 --- a/src/excelalchemy/core/rows.py +++ b/src/excelalchemy/core/rows.py @@ -36,7 +36,9 @@ def _aggregate(self, row_data: RowPayloadLike) -> AggregatedRowPayload: 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__)) + 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}: diff --git a/src/excelalchemy/core/writer.py b/src/excelalchemy/core/writer.py index 3df78a4..f9e3e41 100644 --- a/src/excelalchemy/core/writer.py +++ b/src/excelalchemy/core/writer.py @@ -289,7 +289,9 @@ def _write_value( 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): + 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( diff --git a/src/excelalchemy/exc.py b/src/excelalchemy/exc.py index f144e7e..dc49e19 100644 --- a/src/excelalchemy/exc.py +++ b/src/excelalchemy/exc.py @@ -5,4 +5,3 @@ warn_compat_import('excelalchemy.exc', 'excelalchemy.exceptions') from excelalchemy.exceptions import * # noqa: F403 - diff --git a/src/excelalchemy/header_models.py b/src/excelalchemy/header_models.py index f1afefb..aaf239e 100644 --- a/src/excelalchemy/header_models.py +++ b/src/excelalchemy/header_models.py @@ -8,4 +8,3 @@ ) from excelalchemy._primitives.header_models import * # noqa: F403 - diff --git a/src/excelalchemy/helper/pydantic.py b/src/excelalchemy/helper/pydantic.py index c2ee143..fdc10e1 100644 --- a/src/excelalchemy/helper/pydantic.py +++ b/src/excelalchemy/helper/pydantic.py @@ -169,9 +169,7 @@ def _extract_pydantic_model(model: PydanticModelAdapter) -> Generator[FieldMetaI yield field_adapter.runtime_metadata() else: - raise ProgrammaticError( - msg(MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED, value_type=excel_codec) - ) + raise ProgrammaticError(msg(MessageKey.VALUE_TYPE_DECLARATION_UNSUPPORTED, value_type=excel_codec)) def _handle_error( diff --git a/src/excelalchemy/i18n/messages.py b/src/excelalchemy/i18n/messages.py index 0bb468b..4182f61 100644 --- a/src/excelalchemy/i18n/messages.py +++ b/src/excelalchemy/i18n/messages.py @@ -200,9 +200,7 @@ class MessageKey(StrEnum): 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.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]' @@ -210,15 +208,9 @@ class MessageKey(StrEnum): 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.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(...)]' @@ -306,9 +298,7 @@ class MessageKey(StrEnum): '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.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.' @@ -368,7 +358,7 @@ class MessageKey(StrEnum): MessageKey.LABEL_END_DATE: '结束日期', MessageKey.LABEL_MINIMUM_VALUE: '最小值', MessageKey.LABEL_MAXIMUM_VALUE: '最大值', - } + }, } diff --git a/src/excelalchemy/identity.py b/src/excelalchemy/identity.py index dcdb6f2..ae63dc8 100644 --- a/src/excelalchemy/identity.py +++ b/src/excelalchemy/identity.py @@ -5,4 +5,3 @@ 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 index 0d69d04..3856b18 100644 --- a/src/excelalchemy/metadata.py +++ b/src/excelalchemy/metadata.py @@ -248,7 +248,9 @@ def options_name_map(self) -> dict[str, Option]: @property def comment_required(self) -> str: - value_key = MessageKey.COMMENT_REQUIRED_VALUE_REQUIRED if self.required else MessageKey.COMMENT_REQUIRED_VALUE_OPTIONAL + 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 @@ -290,7 +292,9 @@ def comment_unit(self) -> str: @property def comment_unique(self) -> str: - value_key = MessageKey.COMMENT_UNIQUE_VALUE_UNIQUE if self.unique else MessageKey.COMMENT_UNIQUE_VALUE_NON_UNIQUE + 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 diff --git a/src/excelalchemy/results.py b/src/excelalchemy/results.py index 64cf7bd..e41795a 100644 --- a/src/excelalchemy/results.py +++ b/src/excelalchemy/results.py @@ -57,12 +57,22 @@ class ImportResult(BaseModel): 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.') + 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.') diff --git a/tests/contracts/test_core_components_contract.py b/tests/contracts/test_core_components_contract.py index e6d6138..b52010c 100644 --- a/tests/contracts/test_core_components_contract.py +++ b/tests/contracts/test_core_components_contract.py @@ -39,7 +39,9 @@ class DualRangeImporter(BaseModel): travel_range: DateRange = FieldMeta(label='出行时间', order=2, date_format=DateFormat.DAY) layout = ExcelSchemaLayout.from_model(DualRangeImporter) - header_df = WorksheetTable(rows=[['停留时间', None, '出行时间', None], ['开始日期', '结束日期', '开始日期', '结束日期']]) + header_df = WorksheetTable( + rows=[['停留时间', None, '出行时间', None], ['开始日期', '结束日期', '开始日期', '结束日期']] + ) parser = ExcelHeaderParser() validator = ExcelHeaderValidator() diff --git a/tests/contracts/test_pydantic_contract.py b/tests/contracts/test_pydantic_contract.py index 4dccf33..2efd3c5 100644 --- a/tests/contracts/test_pydantic_contract.py +++ b/tests/contracts/test_pydantic_contract.py @@ -61,7 +61,9 @@ def must_use_company_domain(cls, value: str) -> str: wrong_domain = instantiate_pydantic_model({'name': 'long-enough-address@openai.com'}, FieldValidatedModel) assert isinstance(too_short, list) - assert too_short == [ExcelCellError(label=Label('邮箱'), message='Value should have at least 20 items after validation, not 6')] + 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')] @@ -140,4 +142,6 @@ class AnnotatedContractModel(BaseModel): assert declared_metadata.importer_min_length == 20 assert [meta.unique_label for meta in metas] == ['邮箱', '停留时间·开始日期', '停留时间·结束日期'] assert isinstance(result, list) - assert result == [ExcelCellError(label=Label('邮箱'), message='Value should have at least 20 items after validation, not 6')] + assert result == [ + ExcelCellError(label=Label('邮箱'), message='Value should have at least 20 items after validation, not 6') + ] diff --git a/tests/integration/test_excelalchemy_workflows.py b/tests/integration/test_excelalchemy_workflows.py index 76f52ff..0fb86f1 100644 --- a/tests/integration/test_excelalchemy_workflows.py +++ b/tests/integration/test_excelalchemy_workflows.py @@ -310,15 +310,47 @@ async def test_import_records_cell_errors_for_invalid_simple_workbook(self): 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')], + 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')], + 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' + ) + ], } } diff --git a/tests/unit/test_i18n_messages.py b/tests/unit/test_i18n_messages.py index c5b16a0..376572f 100644 --- a/tests/unit/test_i18n_messages.py +++ b/tests/unit/test_i18n_messages.py @@ -16,10 +16,7 @@ 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' - ) + 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 ( @@ -29,7 +26,10 @@ def test_message_falls_back_to_default_locale(self): 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 ( + 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): From e00b24670af04ac3cd17565d7e84279d5dad7cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=9D=BF?= Date: Sat, 28 Mar 2026 19:58:08 +0800 Subject: [PATCH 27/27] feat(ci): add codecov config for release PRs --- codecov.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 codecov.yml 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"