springbootAI 1.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spring/__init__.py +66 -0
- spring/ai/__init__.py +78 -0
- spring/ai/advisors.py +139 -0
- spring/ai/annotations.py +74 -0
- spring/ai/autoconfig.py +481 -0
- spring/ai/core.py +391 -0
- spring/ai/etl.py +188 -0
- spring/ai/memory.py +109 -0
- spring/ai/observability.py +129 -0
- spring/ai/providers.py +789 -0
- spring/ai/resilience.py +258 -0
- spring/ai/tools.py +106 -0
- spring/ai/vectorstore.py +303 -0
- spring/annotations/__init__.py +188 -0
- spring/annotations/cache.py +126 -0
- spring/annotations/cloud.py +207 -0
- spring/annotations/conditional.py +272 -0
- spring/annotations/core.py +864 -0
- spring/annotations/messaging.py +107 -0
- spring/aop/__init__.py +4 -0
- spring/aop/cloud_aop.py +404 -0
- spring/aop/comprehensive_aop.py +1015 -0
- spring/aop/method_interceptor.py +19 -0
- spring/aop/proxy_factory.py +55 -0
- spring/cloud/__init__.py +76 -0
- spring/cloud/discovery.py +364 -0
- spring/cloud/feign.py +469 -0
- spring/cloud/gateway.py +452 -0
- spring/cloud/load_balancer.py +149 -0
- spring/cloud/seata.py +557 -0
- spring/cloud/sentinel.py +525 -0
- spring/cloud/tracer.py +337 -0
- spring/config/__init__.py +21 -0
- spring/config/binding.py +206 -0
- spring/config/config_loader.py +405 -0
- spring/context/__init__.py +13 -0
- spring/context/application_context.py +589 -0
- spring/context/bean_definition.py +70 -0
- spring/context/bean_factory.py +1052 -0
- spring/context/registry.py +58 -0
- spring/context/scanner.py +106 -0
- spring/core/__init__.py +3 -0
- spring/core/graceful_shutdown.py +196 -0
- spring/core/typing_utils.py +50 -0
- spring/csv/__init__.py +52 -0
- spring/csv/annotations.py +402 -0
- spring/csv/converters.py +69 -0
- spring/csv/easy_csv.py +95 -0
- spring/csv/exceptions.py +27 -0
- spring/csv/reader.py +195 -0
- spring/csv/writer.py +155 -0
- spring/data/__init__.py +54 -0
- spring/data/page.py +181 -0
- spring/data/repository.py +274 -0
- spring/data/specification.py +228 -0
- spring/datasource/__init__.py +66 -0
- spring/datasource/annotations.py +133 -0
- spring/datasource/context.py +69 -0
- spring/datasource/dynamic.py +148 -0
- spring/event/__init__.py +7 -0
- spring/event/publisher.py +69 -0
- spring/excel/__init__.py +51 -0
- spring/excel/annotations.py +405 -0
- spring/excel/converters.py +231 -0
- spring/excel/easy_excel.py +94 -0
- spring/excel/exceptions.py +31 -0
- spring/excel/reader.py +254 -0
- spring/excel/style.py +95 -0
- spring/excel/writer.py +197 -0
- spring/i18n/__init__.py +97 -0
- spring/i18n/accessor.py +94 -0
- spring/i18n/auto_config.py +177 -0
- spring/i18n/holder.py +106 -0
- spring/i18n/locale.py +152 -0
- spring/i18n/locale_resolver.py +367 -0
- spring/i18n/message_source.py +250 -0
- spring/i18n/middleware.py +79 -0
- spring/i18n/properties.py +168 -0
- spring/i18n/sources.py +255 -0
- spring/logging/__init__.py +1 -0
- spring/logging/loguru_logger.py +228 -0
- spring/main.py +378 -0
- spring/messaging/__init__.py +1 -0
- spring/messaging/rabbitmq.py +302 -0
- spring/monitoring/__init__.py +1 -0
- spring/monitoring/prometheus.py +199 -0
- spring/orm/__init__.py +258 -0
- spring/orm/database.py +222 -0
- spring/orm/ddl_auto.py +1217 -0
- spring/orm/migration.py +419 -0
- spring/orm/mybatis_integration.py +400 -0
- spring/orm/pymybatis/__init__.py +86 -0
- spring/orm/pymybatis/annotations/__init__.py +30 -0
- spring/orm/pymybatis/annotations/annotations.py +332 -0
- spring/orm/pymybatis/cache/__init__.py +47 -0
- spring/orm/pymybatis/cache/cache.py +371 -0
- spring/orm/pymybatis/cache/redis_cache.py +434 -0
- spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
- spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
- spring/orm/pymybatis/configuration.py +525 -0
- spring/orm/pymybatis/core/__init__.py +10 -0
- spring/orm/pymybatis/core/sql_session.py +1382 -0
- spring/orm/pymybatis/core/sql_session_factory.py +76 -0
- spring/orm/pymybatis/dialect/__init__.py +9 -0
- spring/orm/pymybatis/dialect/dialect.py +445 -0
- spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
- spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
- spring/orm/pymybatis/interceptor/__init__.py +31 -0
- spring/orm/pymybatis/interceptor/interceptor.py +427 -0
- spring/orm/pymybatis/mapper/__init__.py +9 -0
- spring/orm/pymybatis/mapper/mapper.py +540 -0
- spring/orm/pymybatis/metrics/__init__.py +41 -0
- spring/orm/pymybatis/metrics/metrics.py +595 -0
- spring/orm/pymybatis/pool/__init__.py +9 -0
- spring/orm/pymybatis/pool/connection_pool.py +711 -0
- spring/orm/pymybatis/security/__init__.py +19 -0
- spring/orm/pymybatis/security/access_control.py +415 -0
- spring/orm/pymybatis/security/password_encoder.py +293 -0
- spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
- spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
- spring/orm/pymybatis/transaction/__init__.py +9 -0
- spring/orm/pymybatis/transaction/transaction.py +288 -0
- spring/orm/pymybatis/type_handler/__init__.py +37 -0
- spring/orm/pymybatis/type_handler/type_handler.py +473 -0
- spring/orm/pymybatis/version.py +9 -0
- spring/orm/pymybatis/xml_parser/__init__.py +9 -0
- spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
- spring/retry/__init__.py +12 -0
- spring/retry/retry_annotations.py +71 -0
- spring/retry/retry_decorator.py +155 -0
- spring/scheduling/__init__.py +3 -0
- spring/scheduling/scheduler.py +389 -0
- spring/security/__init__.py +39 -0
- spring/security/jwt_utils.py +281 -0
- spring/security/replay_protection.py +206 -0
- spring/security/secret_manager.py +226 -0
- spring/security/security_aop.py +248 -0
- spring/security/security_context.py +172 -0
- spring/test/__init__.py +45 -0
- spring/test/slicing.py +341 -0
- spring/tracing/__init__.py +11 -0
- spring/tracing/skywalking.py +229 -0
- spring/tx/__init__.py +52 -0
- spring/tx/events.py +172 -0
- spring/tx/synchronization.py +143 -0
- spring/utils/__init__.py +5 -0
- spring/utils/banner.py +32 -0
- spring/utils/logger.py +73 -0
- spring/utils/redis_client.py +526 -0
- spring/validation/__init__.py +55 -0
- spring/validation/aop.py +141 -0
- spring/validation/constraints.py +357 -0
- spring/validation/exceptions.py +55 -0
- spring/validation/validator.py +139 -0
- spring/web/__init__.py +12 -0
- spring/web/actuator.py +319 -0
- spring/web/exception_handler.py +61 -0
- spring/web/health.py +399 -0
- spring/web/interceptor.py +91 -0
- spring/web/result.py +44 -0
- spring/web/swagger.py +601 -0
- spring/web/web_context.py +755 -0
- spring/websocket/__init__.py +86 -0
- spring/websocket/annotations.py +169 -0
- spring/websocket/broker.py +238 -0
- spring/websocket/exceptions.py +26 -0
- spring/websocket/handler.py +243 -0
- spring/websocket/router.py +526 -0
- spring/websocket/session.py +216 -0
- springbootai-1.8.0.dist-info/METADATA +2796 -0
- springbootai-1.8.0.dist-info/RECORD +175 -0
- springbootai-1.8.0.dist-info/WHEEL +5 -0
- springbootai-1.8.0.dist-info/entry_points.txt +2 -0
- springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
- springbootai-1.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""SpringBootAI ``EasyExcel`` —— 流式构建入口(对齐 alibaba EasyExcel API)。
|
|
2
|
+
|
|
3
|
+
用法::
|
|
4
|
+
|
|
5
|
+
# 读
|
|
6
|
+
rows = (EasyExcel.read("/tmp/users.xlsx", head=DemoData)
|
|
7
|
+
.head_row_number(1)
|
|
8
|
+
.sheet(sheet_no=0)
|
|
9
|
+
.doRead())
|
|
10
|
+
|
|
11
|
+
# 写
|
|
12
|
+
EasyExcel.write("/tmp/users.xlsx", head=DemoData).sheet("用户列表").doWrite(data_list)
|
|
13
|
+
|
|
14
|
+
# 多 sheet
|
|
15
|
+
EasyExcel.write("/tmp/multi.xlsx", head=DemoData).doWriteAll({"S1": list1, "S2": list2})
|
|
16
|
+
|
|
17
|
+
# 读所有 sheet
|
|
18
|
+
sheets = EasyExcel.read("/tmp/multi.xlsx", head=DemoData).doReadAll()
|
|
19
|
+
|
|
20
|
+
注解声明不依赖 openpyxl;仅在 ``doRead`` / ``doWrite`` 时检测并提示安装 ``springbootAI[excel]``。
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Optional, Type, Union
|
|
25
|
+
|
|
26
|
+
from .reader import ExcelReader
|
|
27
|
+
from .writer import ExcelWriter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EasyExcel:
|
|
31
|
+
"""EasyExcel 流式入口(静态工厂方法风格,对齐 alibaba EasyExcel)。"""
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def read(
|
|
35
|
+
source: Any,
|
|
36
|
+
head: Optional[Type] = None,
|
|
37
|
+
head_row_number: Optional[int] = None,
|
|
38
|
+
sheet_no: Optional[int] = None,
|
|
39
|
+
sheet_name: Optional[str] = None,
|
|
40
|
+
) -> ExcelReader:
|
|
41
|
+
"""构建读取器。
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
source: 文件路径或类文件对象。
|
|
45
|
+
head: 实体类(带 ``@ExcelProperty`` 注解)。
|
|
46
|
+
head_row_number: 表头所在行号(从 1 起)。默认取类 ``@excel_sheet`` 配置或 1。
|
|
47
|
+
sheet_no: 工作表索引(0 起)。
|
|
48
|
+
sheet_name: 工作表名称(优先于 sheet_no)。
|
|
49
|
+
"""
|
|
50
|
+
return ExcelReader(
|
|
51
|
+
source=source, head=head, head_row_number=head_row_number,
|
|
52
|
+
sheet_no=sheet_no, sheet_name=sheet_name,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def write(
|
|
57
|
+
target: Any,
|
|
58
|
+
head: Optional[Type] = None,
|
|
59
|
+
sheet_name: Optional[str] = None,
|
|
60
|
+
) -> ExcelWriter:
|
|
61
|
+
"""构建写入器。
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
target: 文件路径或类文件对象。
|
|
65
|
+
head: 实体类(带 ``@ExcelProperty`` 注解)。
|
|
66
|
+
sheet_name: 工作表名称。默认取类 ``@excel_sheet`` 配置或 "Sheet1"。
|
|
67
|
+
"""
|
|
68
|
+
return ExcelWriter(target=target, head=head, sheet_name=sheet_name)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# 便捷函数(非流式,一步到位)
|
|
72
|
+
def read_excel(
|
|
73
|
+
source: Any,
|
|
74
|
+
head: Type,
|
|
75
|
+
sheet_no: Optional[int] = None,
|
|
76
|
+
sheet_name: Optional[str] = None,
|
|
77
|
+
head_row_number: Optional[int] = None,
|
|
78
|
+
) -> list:
|
|
79
|
+
"""一步读取:``read_excel(path, DemoData)``。"""
|
|
80
|
+
return EasyExcel.read(source, head=head, head_row_number=head_row_number,
|
|
81
|
+
sheet_no=sheet_no, sheet_name=sheet_name).doRead()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_excel(
|
|
85
|
+
target: Any,
|
|
86
|
+
head: Type,
|
|
87
|
+
data: list,
|
|
88
|
+
sheet_name: Optional[str] = None,
|
|
89
|
+
) -> Any:
|
|
90
|
+
"""一步写入:``write_excel(path, DemoData, rows)``。"""
|
|
91
|
+
return EasyExcel.write(target, head=head, sheet_name=sheet_name).doWrite(data)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["EasyExcel", "read_excel", "write_excel"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""SpringBootAI Excel 模块异常定义。
|
|
2
|
+
|
|
3
|
+
设计对齐 EasyExcel 的错误语义:注解配置错误、读写过程错误、可选依赖缺失均通过
|
|
4
|
+
本模块的异常抛出,便于上层统一捕获。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExcelError(Exception):
|
|
9
|
+
"""Excel 模块所有异常的基类。"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExcelPropertyError(ExcelError):
|
|
13
|
+
"""实体类字段上的 @ExcelProperty / @ExcelIgnore 配置不合法时抛出。"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExcelReadError(ExcelError):
|
|
17
|
+
"""读取 Excel 过程中发生的错误(表头缺失、行数据无法转换等)。"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExcelWriteError(ExcelError):
|
|
21
|
+
"""写入 Excel 过程中发生的错误。"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExcelDependencyError(ExcelError):
|
|
25
|
+
"""缺少可选依赖 openpyxl 时抛出。
|
|
26
|
+
|
|
27
|
+
Excel 读写引擎底层依赖 openpyxl。注解声明(@ExcelProperty 等)不依赖任何第三方库,
|
|
28
|
+
仅在实际 read/write 时检测。未安装时给出明确的安装提示::
|
|
29
|
+
|
|
30
|
+
pip install springbootAI[excel]
|
|
31
|
+
"""
|
spring/excel/reader.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""SpringBootAI Excel 读取引擎。
|
|
2
|
+
|
|
3
|
+
按 ``@ExcelProperty`` / ``@ExcelIgnore`` / ``@excel_sheet`` 注解把 Excel 工作表读为实体对象列表。
|
|
4
|
+
底层依赖 openpyxl(仅 ``doRead`` 时检测,注解声明无需安装)。
|
|
5
|
+
|
|
6
|
+
读取流程对齐 alibaba EasyExcel:
|
|
7
|
+
1. 读取表头行(``head_row_number``,默认 1)。
|
|
8
|
+
2. 表头文案 -> ``ExcelColumnModel`` 映射(按 ``value`` 匹配;无注解时按列位置匹配)。
|
|
9
|
+
3. 数据行逐行按 ``converter.from_excel`` 转换,构造实体实例。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
from typing import Any, List, Optional, Type, Union
|
|
15
|
+
|
|
16
|
+
from .annotations import (
|
|
17
|
+
ExcelColumnModel, ExcelSheet, _get_class_sheet_meta, parse_excel_columns,
|
|
18
|
+
)
|
|
19
|
+
from .converters import resolve_converter
|
|
20
|
+
from .exceptions import ExcelDependencyError, ExcelReadError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_openpyxl():
|
|
24
|
+
try:
|
|
25
|
+
import openpyxl
|
|
26
|
+
return openpyxl
|
|
27
|
+
except ImportError as e:
|
|
28
|
+
raise ExcelDependencyError(
|
|
29
|
+
"Excel 读取依赖 openpyxl,请先安装:pip install springbootAI[excel]"
|
|
30
|
+
) from e
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_instance(cls: Type, kwargs: dict) -> Any:
|
|
34
|
+
"""构造实体实例:优先 ``cls(**kwargs)``,失败则逐字段 setattr(兼容纯 ``__init__`` 模型)。"""
|
|
35
|
+
# 只保留 __init__ 能接收的参数
|
|
36
|
+
try:
|
|
37
|
+
sig = inspect.signature(cls.__init__)
|
|
38
|
+
params = list(sig.parameters.values())[1:] # 跳过 self
|
|
39
|
+
accept_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params)
|
|
40
|
+
if accept_kwargs:
|
|
41
|
+
return cls(**kwargs)
|
|
42
|
+
allowed = {p.name for p in params if p.kind in (
|
|
43
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY,
|
|
44
|
+
)}
|
|
45
|
+
filtered = {k: v for k, v in kwargs.items() if k in allowed}
|
|
46
|
+
return cls(**filtered)
|
|
47
|
+
except TypeError:
|
|
48
|
+
pass
|
|
49
|
+
# 回退:无参构造 + setattr
|
|
50
|
+
try:
|
|
51
|
+
obj = cls()
|
|
52
|
+
except Exception:
|
|
53
|
+
obj = object.__new__(cls)
|
|
54
|
+
for k, v in kwargs.items():
|
|
55
|
+
try:
|
|
56
|
+
setattr(obj, k, v)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return obj
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ExcelReader:
|
|
63
|
+
"""Excel 读取器。由 ``EasyExcel.read(...)`` 构建,调用 ``doRead`` 执行。"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
source: Any,
|
|
68
|
+
head: Optional[Type] = None,
|
|
69
|
+
head_row_number: Optional[int] = None,
|
|
70
|
+
sheet_no: Optional[int] = None,
|
|
71
|
+
sheet_name: Optional[str] = None,
|
|
72
|
+
):
|
|
73
|
+
self.source = source
|
|
74
|
+
self.head = head
|
|
75
|
+
self._head_row_number = head_row_number
|
|
76
|
+
self._sheet_no = sheet_no
|
|
77
|
+
self._sheet_name = sheet_name
|
|
78
|
+
|
|
79
|
+
# ---- 流式配置 ----
|
|
80
|
+
|
|
81
|
+
def head_row_number(self, n: int) -> "ExcelReader":
|
|
82
|
+
self._head_row_number = n
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def sheet(self, sheet_no: Optional[int] = None, sheet_name: Optional[str] = None) -> "ExcelReader":
|
|
86
|
+
self._sheet_no = sheet_no
|
|
87
|
+
self._sheet_name = sheet_name
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
# ---- 执行 ----
|
|
91
|
+
|
|
92
|
+
def _resolve_sheet(self, wb) -> List:
|
|
93
|
+
"""返回要读取的工作表列表。"""
|
|
94
|
+
if self._sheet_name is not None:
|
|
95
|
+
if self._sheet_name not in wb.sheetnames:
|
|
96
|
+
raise ExcelReadError(f"工作表不存在: {self._sheet_name}")
|
|
97
|
+
return [wb[self._sheet_name]]
|
|
98
|
+
if self._sheet_no is not None:
|
|
99
|
+
if self._sheet_no >= len(wb.sheetnames):
|
|
100
|
+
raise ExcelReadError(f"工作表索引越界: {self._sheet_no}")
|
|
101
|
+
return [wb.worksheets[self._sheet_no]]
|
|
102
|
+
# 默认读所有 sheet
|
|
103
|
+
return list(wb.worksheets)
|
|
104
|
+
|
|
105
|
+
def _resolve_head_row_number(self, sheet_meta: ExcelSheet) -> int:
|
|
106
|
+
if self._head_row_number is not None:
|
|
107
|
+
return self._head_row_number
|
|
108
|
+
return sheet_meta.head_row_number
|
|
109
|
+
|
|
110
|
+
def _read_one_sheet(self, ws) -> List[Any]:
|
|
111
|
+
head_cls = self.head
|
|
112
|
+
if head_cls is None:
|
|
113
|
+
raise ExcelReadError("read 需指定 head 实体类")
|
|
114
|
+
|
|
115
|
+
sheet_meta = _get_class_sheet_meta(head_cls)
|
|
116
|
+
columns: List[ExcelColumnModel] = parse_excel_columns(head_cls)
|
|
117
|
+
head_row = self._resolve_head_row_number(sheet_meta)
|
|
118
|
+
|
|
119
|
+
# 读取表头行
|
|
120
|
+
max_col = ws.max_column or 0
|
|
121
|
+
max_row = ws.max_row or 0
|
|
122
|
+
if max_row < head_row:
|
|
123
|
+
return []
|
|
124
|
+
header_cells = [ws.cell(row=head_row, column=c).value for c in range(1, max_col + 1)]
|
|
125
|
+
|
|
126
|
+
# 列映射:列序号(1-based) -> ExcelColumnModel
|
|
127
|
+
col_mapping = self._map_columns(columns, header_cells)
|
|
128
|
+
|
|
129
|
+
# 判断是否走"按位置回退"(类无任何 ExcelProperty 注解)
|
|
130
|
+
used_positional = not _has_explicit_properties(head_cls)
|
|
131
|
+
|
|
132
|
+
results: List[Any] = []
|
|
133
|
+
for row_idx in range(head_row + 1, max_row + 1):
|
|
134
|
+
# 跳过全空行
|
|
135
|
+
row_values = [ws.cell(row=row_idx, column=c).value for c in range(1, max_col + 1)]
|
|
136
|
+
if all(v is None or v == "" for v in row_values):
|
|
137
|
+
continue
|
|
138
|
+
kwargs = {}
|
|
139
|
+
for col_idx, model in col_mapping.items():
|
|
140
|
+
cell_value = ws.cell(row=row_idx, column=col_idx).value
|
|
141
|
+
value = self._convert_from_cell(model, cell_value)
|
|
142
|
+
kwargs[model.attr_name] = value
|
|
143
|
+
# 位置回退:未映射的列按声明顺序补齐(仅当类无注解且字段数匹配时)
|
|
144
|
+
if used_positional:
|
|
145
|
+
# 已按位置映射,无需额外处理
|
|
146
|
+
pass
|
|
147
|
+
results.append(_build_instance(head_cls, kwargs))
|
|
148
|
+
return results
|
|
149
|
+
|
|
150
|
+
def _map_columns(self, columns: List[ExcelColumnModel], header_cells: List) -> dict:
|
|
151
|
+
"""表头 -> 列模型映射。返回 {列序号(1-based): ExcelColumnModel}。"""
|
|
152
|
+
col_mapping: dict = {}
|
|
153
|
+
explicit = any(c.header for c in columns) and _has_explicit_properties(self.head)
|
|
154
|
+
|
|
155
|
+
if explicit:
|
|
156
|
+
# 按表头文案匹配
|
|
157
|
+
header_to_col = {}
|
|
158
|
+
for idx, h in enumerate(header_cells, start=1):
|
|
159
|
+
if h is None:
|
|
160
|
+
continue
|
|
161
|
+
header_to_col.setdefault(str(h).strip(), idx)
|
|
162
|
+
for model in columns:
|
|
163
|
+
col_idx = header_to_col.get(model.header)
|
|
164
|
+
if col_idx is None:
|
|
165
|
+
# 找不到对应表头,按声明顺序回退到下一个空位
|
|
166
|
+
continue
|
|
167
|
+
col_mapping[col_idx] = model
|
|
168
|
+
else:
|
|
169
|
+
# 按列位置匹配(无注解或表头为空)
|
|
170
|
+
for offset, model in enumerate(columns):
|
|
171
|
+
col_idx = (model.index if model.index is not None else offset) + 1
|
|
172
|
+
col_mapping[col_idx] = model
|
|
173
|
+
return col_mapping
|
|
174
|
+
|
|
175
|
+
def _convert_from_cell(self, model: ExcelColumnModel, cell_value: Any) -> Any:
|
|
176
|
+
converter = resolve_converter(model.py_type, model.converter, model.date_format)
|
|
177
|
+
if converter is None:
|
|
178
|
+
return cell_value
|
|
179
|
+
try:
|
|
180
|
+
return converter.from_excel(cell_value)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
raise ExcelReadError(
|
|
183
|
+
f"字段 '{model.attr_name}' 转换失败 (单元格值={cell_value!r}): {e}"
|
|
184
|
+
) from e
|
|
185
|
+
|
|
186
|
+
def doRead(self) -> List[Any]:
|
|
187
|
+
"""读取选定的工作表,返回实体列表。"""
|
|
188
|
+
openpyxl = _require_openpyxl()
|
|
189
|
+
wb = _load_workbook(self.source, openpyxl, read_only=False)
|
|
190
|
+
try:
|
|
191
|
+
sheets = self._resolve_sheet(wb)
|
|
192
|
+
if not sheets:
|
|
193
|
+
return []
|
|
194
|
+
return self._read_one_sheet(sheets[0])
|
|
195
|
+
finally:
|
|
196
|
+
try:
|
|
197
|
+
wb.close()
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
def doReadAll(self) -> dict:
|
|
202
|
+
"""读取所有工作表,返回 {sheet_name: [实体列表]}。"""
|
|
203
|
+
openpyxl = _require_openpyxl()
|
|
204
|
+
wb = _load_workbook(self.source, openpyxl, read_only=False)
|
|
205
|
+
try:
|
|
206
|
+
result = {}
|
|
207
|
+
for ws in wb.worksheets:
|
|
208
|
+
self._sheet_name = ws.title
|
|
209
|
+
result[ws.title] = self._read_one_sheet(ws)
|
|
210
|
+
return result
|
|
211
|
+
finally:
|
|
212
|
+
try:
|
|
213
|
+
wb.close()
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _has_explicit_properties(cls: Type) -> bool:
|
|
219
|
+
"""类上是否声明了至少一个 ExcelProperty(用于决定按表头还是按位置匹配)。"""
|
|
220
|
+
for base in cls.__mro__:
|
|
221
|
+
for value in vars(base).values():
|
|
222
|
+
if isinstance(value, ExcelColumnModel):
|
|
223
|
+
continue
|
|
224
|
+
if isinstance(value, type): # 跳过类属性中的类型
|
|
225
|
+
continue
|
|
226
|
+
if hasattr(value, "__excel_property__"):
|
|
227
|
+
return True
|
|
228
|
+
# ExcelProperty 实例作为类属性
|
|
229
|
+
# 再扫一次 ExcelProperty 实例
|
|
230
|
+
for base in cls.__mro__:
|
|
231
|
+
for value in vars(base).values():
|
|
232
|
+
if _is_excel_property(value):
|
|
233
|
+
return True
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_excel_property(value: Any) -> bool:
|
|
238
|
+
from .annotations import ExcelProperty
|
|
239
|
+
# 避免与 ExcelColumnModel 混淆
|
|
240
|
+
return isinstance(value, ExcelProperty)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _load_workbook(source: Any, openpyxl_module, read_only: bool = False):
|
|
244
|
+
"""支持文件路径或类文件对象。"""
|
|
245
|
+
try:
|
|
246
|
+
if isinstance(source, (str, bytes)) or hasattr(source, "__fspath__"):
|
|
247
|
+
return openpyxl_module.load_workbook(filename=str(source), data_only=True, read_only=read_only)
|
|
248
|
+
# 类文件对象
|
|
249
|
+
return openpyxl_module.load_workbook(source, data_only=True, read_only=read_only)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
raise ExcelReadError(f"加载 Excel 文件失败: {e}") from e
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
__all__ = ["ExcelReader"]
|
spring/excel/style.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""SpringBootAI Excel 默认样式 —— 轻量表头/内容样式(基于 openpyxl)。
|
|
2
|
+
|
|
3
|
+
仅在 writer 实际写入时使用,且为可选项。样式名通过 ``@ExcelSheet(head_style=...)`` 或
|
|
4
|
+
``@ExcelProperty(head_style=...)`` 指定;未指定时使用 ``DEFAULT_HEAD``。
|
|
5
|
+
|
|
6
|
+
设计克制:不提供复杂主题系统,仅给出一个加粗居中、带边框和浅色填充的默认表头样式,
|
|
7
|
+
以及一个带边框的默认内容样式,满足"功能齐全"的同时避免过度设计。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
# 样式缓存:同名样式复用同一 openpyxl 对象(openpyxl 要求相同样式复用以免撑大文件)
|
|
14
|
+
_STYLE_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _require_openpyxl():
|
|
18
|
+
try:
|
|
19
|
+
import openpyxl # noqa: F401
|
|
20
|
+
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
21
|
+
return Font, PatternFill, Alignment, Border, Side
|
|
22
|
+
except ImportError as e: # pragma: no cover - 依赖检测由上层统一处理
|
|
23
|
+
raise e
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_head_style(name: str = "default"):
|
|
27
|
+
"""获取表头样式对象(带缓存)。"""
|
|
28
|
+
from .exceptions import ExcelDependencyError
|
|
29
|
+
try:
|
|
30
|
+
Font, PatternFill, Alignment, Border, Side = _require_openpyxl()
|
|
31
|
+
except ImportError:
|
|
32
|
+
raise ExcelDependencyError(
|
|
33
|
+
"Excel 写入依赖 openpyxl,请先安装:pip install springbootAI[excel]"
|
|
34
|
+
)
|
|
35
|
+
key = f"head::{name}"
|
|
36
|
+
if key in _STYLE_CACHE:
|
|
37
|
+
return _STYLE_CACHE[key]
|
|
38
|
+
thin = Side(style="thin", color="BFBFBF")
|
|
39
|
+
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
40
|
+
style = {
|
|
41
|
+
"font": Font(bold=True, color="FFFFFF", size=11),
|
|
42
|
+
"fill": PatternFill("solid", fgColor="4472C4"),
|
|
43
|
+
"alignment": Alignment(horizontal="center", vertical="center", wrap_text=True),
|
|
44
|
+
"border": border,
|
|
45
|
+
}
|
|
46
|
+
_STYLE_CACHE[key] = style
|
|
47
|
+
return style
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_content_style(name: str = "default"):
|
|
51
|
+
"""获取内容样式对象(带缓存)。"""
|
|
52
|
+
from .exceptions import ExcelDependencyError
|
|
53
|
+
try:
|
|
54
|
+
Font, PatternFill, Alignment, Border, Side = _require_openpyxl()
|
|
55
|
+
except ImportError:
|
|
56
|
+
raise ExcelDependencyError(
|
|
57
|
+
"Excel 写入依赖 openpyxl,请先安装:pip install springbootAI[excel]"
|
|
58
|
+
)
|
|
59
|
+
key = f"content::{name}"
|
|
60
|
+
if key in _STYLE_CACHE:
|
|
61
|
+
return _STYLE_CACHE[key]
|
|
62
|
+
thin = Side(style="thin", color="D9D9D9")
|
|
63
|
+
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
64
|
+
style = {
|
|
65
|
+
"font": Font(size=11),
|
|
66
|
+
"alignment": Alignment(vertical="center", wrap_text=False),
|
|
67
|
+
"border": border,
|
|
68
|
+
}
|
|
69
|
+
_STYLE_CACHE[key] = style
|
|
70
|
+
return style
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def apply_head_style(cell, name: str = "default") -> None:
|
|
74
|
+
"""把表头样式应用到单元格。"""
|
|
75
|
+
style = get_head_style(name)
|
|
76
|
+
cell.font = style["font"]
|
|
77
|
+
cell.fill = style["fill"]
|
|
78
|
+
cell.alignment = style["alignment"]
|
|
79
|
+
cell.border = style["border"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_content_style(cell, name: str = "default") -> None:
|
|
83
|
+
"""把内容样式应用到单元格。"""
|
|
84
|
+
style = get_content_style(name)
|
|
85
|
+
cell.font = style["font"]
|
|
86
|
+
cell.alignment = style["alignment"]
|
|
87
|
+
cell.border = style["border"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"get_head_style",
|
|
92
|
+
"get_content_style",
|
|
93
|
+
"apply_head_style",
|
|
94
|
+
"apply_content_style",
|
|
95
|
+
]
|
spring/excel/writer.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""SpringBootAI Excel 写入引擎。
|
|
2
|
+
|
|
3
|
+
按 ``@ExcelProperty`` / ``@ExcelIgnore`` / ``@excel_sheet`` 注解把实体对象列表写入 Excel。
|
|
4
|
+
底层依赖 openpyxl(仅 ``doWrite`` 时检测,注解声明无需安装)。
|
|
5
|
+
|
|
6
|
+
写入流程对齐 alibaba EasyExcel:
|
|
7
|
+
1. 按列模型排序写表头行(默认带样式)。
|
|
8
|
+
2. 逐行按 ``converter.to_excel`` 转换并写单元格;应用数字格式、大数字防丢精度。
|
|
9
|
+
3. 冻结表头、自适应列宽(可配置)。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Optional, Type
|
|
14
|
+
|
|
15
|
+
from .annotations import (
|
|
16
|
+
ExcelColumnModel, ExcelSheet, _get_class_sheet_meta, parse_excel_columns,
|
|
17
|
+
)
|
|
18
|
+
from .converters import resolve_converter
|
|
19
|
+
from .exceptions import ExcelDependencyError, ExcelWriteError
|
|
20
|
+
|
|
21
|
+
# Excel 有效数字位数上限为 15 位,超过即丢精度;长 ID/大数字按字符串写入
|
|
22
|
+
_EXCEL_MAX_SIGNIFICANT_DIGITS = 15
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _require_openpyxl():
|
|
26
|
+
try:
|
|
27
|
+
import openpyxl
|
|
28
|
+
return openpyxl
|
|
29
|
+
except ImportError as e:
|
|
30
|
+
raise ExcelDependencyError(
|
|
31
|
+
"Excel 写入依赖 openpyxl,请先安装:pip install springbootAI[excel]"
|
|
32
|
+
) from e
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_value(item: Any, attr_name: str) -> Any:
|
|
36
|
+
"""从实体对象或 dict 取字段值。"""
|
|
37
|
+
if isinstance(item, dict):
|
|
38
|
+
return item.get(attr_name)
|
|
39
|
+
return getattr(item, attr_name, None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_large_int(value: Any) -> bool:
|
|
43
|
+
"""是否为会丢精度的长整数(>15 位有效数字)。"""
|
|
44
|
+
if isinstance(value, bool):
|
|
45
|
+
return False
|
|
46
|
+
if isinstance(value, int):
|
|
47
|
+
return len(str(abs(value))) > _EXCEL_MAX_SIGNIFICANT_DIGITS
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExcelWriter:
|
|
52
|
+
"""Excel 写入器。由 ``EasyExcel.write(...)`` 构建,调用 ``doWrite`` 执行。"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
target: Any,
|
|
57
|
+
head: Optional[Type] = None,
|
|
58
|
+
sheet_name: Optional[str] = None,
|
|
59
|
+
):
|
|
60
|
+
self.target = target
|
|
61
|
+
self.head = head
|
|
62
|
+
self._sheet_name = sheet_name
|
|
63
|
+
self._in_memory: bool = False
|
|
64
|
+
|
|
65
|
+
# ---- 流式配置 ----
|
|
66
|
+
|
|
67
|
+
def sheet(self, sheet_name: str = "Sheet1") -> "ExcelWriter":
|
|
68
|
+
self._sheet_name = sheet_name
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def in_memory(self, flag: bool = True) -> "ExcelWriter":
|
|
72
|
+
"""写入内存 Workbook(用于随后取 workbook 对象,而非落盘)。"""
|
|
73
|
+
self._in_memory = flag
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
# ---- 执行 ----
|
|
77
|
+
|
|
78
|
+
def doWrite(self, data: Iterable[Any]) -> Any:
|
|
79
|
+
"""写入单个工作表并保存到 ``target``。返回目标路径或内存 workbook。"""
|
|
80
|
+
openpyxl = _require_openpyxl()
|
|
81
|
+
if self.head is None:
|
|
82
|
+
raise ExcelWriteError("write 需指定 head 实体类")
|
|
83
|
+
wb = openpyxl.Workbook()
|
|
84
|
+
# 移除默认空 Sheet
|
|
85
|
+
default_ws = wb.active
|
|
86
|
+
sheet_name = self._sheet_name or _get_class_sheet_meta(self.head).sheet_name or "Sheet1"
|
|
87
|
+
ws = wb.create_sheet(title=sheet_name)
|
|
88
|
+
wb.remove(default_ws)
|
|
89
|
+
self._write_sheet(ws, self.head, list(data))
|
|
90
|
+
return self._save(wb)
|
|
91
|
+
|
|
92
|
+
def doWriteAll(self, sheets: Dict[str, Iterable[Any]], head: Optional[Type] = None) -> Any:
|
|
93
|
+
"""写入多个工作表。``sheets`` 为 {sheet_name: data_list},所有 sheet 共用同一 head。"""
|
|
94
|
+
openpyxl = _require_openpyxl()
|
|
95
|
+
head_cls = head or self.head
|
|
96
|
+
if head_cls is None:
|
|
97
|
+
raise ExcelWriteError("doWriteAll 需指定 head 实体类")
|
|
98
|
+
wb = openpyxl.Workbook()
|
|
99
|
+
default_ws = wb.active
|
|
100
|
+
for sheet_name, data in sheets.items():
|
|
101
|
+
ws = wb.create_sheet(title=sheet_name)
|
|
102
|
+
self._write_sheet(ws, head_cls, list(data))
|
|
103
|
+
wb.remove(default_ws)
|
|
104
|
+
return self._save(wb)
|
|
105
|
+
|
|
106
|
+
# ---- 内部 ----
|
|
107
|
+
|
|
108
|
+
def _write_sheet(self, ws, head_cls: Type, data: List[Any]) -> None:
|
|
109
|
+
from .style import apply_content_style, apply_head_style
|
|
110
|
+
|
|
111
|
+
sheet_meta = _get_class_sheet_meta(head_cls)
|
|
112
|
+
columns: List[ExcelColumnModel] = parse_excel_columns(head_cls)
|
|
113
|
+
head_row = sheet_meta.head_row_number
|
|
114
|
+
|
|
115
|
+
# 1. 表头
|
|
116
|
+
for offset, model in enumerate(columns):
|
|
117
|
+
col_idx = offset + 1
|
|
118
|
+
cell = ws.cell(row=head_row, column=col_idx, value=model.header)
|
|
119
|
+
apply_head_style(cell, model.head_style or sheet_meta.head_style or "default")
|
|
120
|
+
|
|
121
|
+
# 2. 数据行
|
|
122
|
+
max_lengths = {m.attr_name: len(str(m.header)) for m in columns}
|
|
123
|
+
for row_offset, item in enumerate(data, start=1):
|
|
124
|
+
row_idx = head_row + row_offset
|
|
125
|
+
for offset, model in enumerate(columns):
|
|
126
|
+
col_idx = offset + 1
|
|
127
|
+
raw = _get_value(item, model.attr_name)
|
|
128
|
+
cell_value, as_string = self._convert_to_cell(model, raw)
|
|
129
|
+
cell = ws.cell(row=row_idx, column=col_idx, value=cell_value)
|
|
130
|
+
apply_content_style(cell, model.content_style or sheet_meta.content_style or "default")
|
|
131
|
+
# 数字格式(仅对数值单元格生效)
|
|
132
|
+
if model.num_format and not as_string and isinstance(cell_value, (int, float)):
|
|
133
|
+
cell.number_format = model.num_format
|
|
134
|
+
# 统计列宽
|
|
135
|
+
display_len = len(str(cell_value)) if cell_value is not None else 0
|
|
136
|
+
if display_len > max_lengths[model.attr_name]:
|
|
137
|
+
max_lengths[model.attr_name] = display_len
|
|
138
|
+
|
|
139
|
+
# 3. 冻结表头
|
|
140
|
+
if sheet_meta.freeze_head:
|
|
141
|
+
ws.freeze_panes = ws.cell(row=head_row + 1, column=1)
|
|
142
|
+
|
|
143
|
+
# 4. 列宽
|
|
144
|
+
self._apply_column_width(ws, columns, max_lengths, sheet_meta)
|
|
145
|
+
|
|
146
|
+
def _convert_to_cell(self, model: ExcelColumnModel, raw: Any):
|
|
147
|
+
"""Python 值 -> (单元格值, 是否强制字符串)。"""
|
|
148
|
+
converter = resolve_converter(model.py_type, model.converter, model.date_format)
|
|
149
|
+
if converter is not None:
|
|
150
|
+
try:
|
|
151
|
+
converted = converter.to_excel(raw)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
raise ExcelWriteError(
|
|
154
|
+
f"字段 '{model.attr_name}' 转换失败 (值={raw!r}): {e}"
|
|
155
|
+
) from e
|
|
156
|
+
as_string = isinstance(converted, str)
|
|
157
|
+
return converted, as_string
|
|
158
|
+
# 无转换器:按规则处理
|
|
159
|
+
if raw is None:
|
|
160
|
+
return None, False
|
|
161
|
+
if model.big_number or _is_large_int(raw):
|
|
162
|
+
return str(raw), True
|
|
163
|
+
return raw, False
|
|
164
|
+
|
|
165
|
+
def _apply_column_width(self, ws, columns: List[ExcelColumnModel],
|
|
166
|
+
max_lengths: dict, sheet_meta: ExcelSheet) -> None:
|
|
167
|
+
for offset, model in enumerate(columns):
|
|
168
|
+
col_idx = offset + 1
|
|
169
|
+
col_letter = ws.cell(row=1, column=col_idx).column_letter
|
|
170
|
+
if model.width and model.width > 0:
|
|
171
|
+
ws.column_dimensions[col_letter].width = float(model.width)
|
|
172
|
+
elif sheet_meta.auto_width:
|
|
173
|
+
# 自适应:max(表头/内容长度) + 2 余量,上限 60
|
|
174
|
+
ws.column_dimensions[col_letter].width = min(max(max_lengths[model.attr_name] + 2, 10), 60)
|
|
175
|
+
|
|
176
|
+
def _save(self, wb) -> Any:
|
|
177
|
+
target = self.target
|
|
178
|
+
if self._in_memory:
|
|
179
|
+
return wb
|
|
180
|
+
try:
|
|
181
|
+
if isinstance(target, (str, bytes)) or hasattr(target, "__fspath__"):
|
|
182
|
+
wb.save(filename=str(target))
|
|
183
|
+
return target
|
|
184
|
+
# 类文件对象
|
|
185
|
+
wb.save(target)
|
|
186
|
+
return target
|
|
187
|
+
except Exception as e:
|
|
188
|
+
raise ExcelWriteError(f"保存 Excel 文件失败: {e}") from e
|
|
189
|
+
finally:
|
|
190
|
+
try:
|
|
191
|
+
if not self._in_memory:
|
|
192
|
+
wb.close()
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
__all__ = ["ExcelWriter"]
|