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.
Files changed (175) hide show
  1. spring/__init__.py +66 -0
  2. spring/ai/__init__.py +78 -0
  3. spring/ai/advisors.py +139 -0
  4. spring/ai/annotations.py +74 -0
  5. spring/ai/autoconfig.py +481 -0
  6. spring/ai/core.py +391 -0
  7. spring/ai/etl.py +188 -0
  8. spring/ai/memory.py +109 -0
  9. spring/ai/observability.py +129 -0
  10. spring/ai/providers.py +789 -0
  11. spring/ai/resilience.py +258 -0
  12. spring/ai/tools.py +106 -0
  13. spring/ai/vectorstore.py +303 -0
  14. spring/annotations/__init__.py +188 -0
  15. spring/annotations/cache.py +126 -0
  16. spring/annotations/cloud.py +207 -0
  17. spring/annotations/conditional.py +272 -0
  18. spring/annotations/core.py +864 -0
  19. spring/annotations/messaging.py +107 -0
  20. spring/aop/__init__.py +4 -0
  21. spring/aop/cloud_aop.py +404 -0
  22. spring/aop/comprehensive_aop.py +1015 -0
  23. spring/aop/method_interceptor.py +19 -0
  24. spring/aop/proxy_factory.py +55 -0
  25. spring/cloud/__init__.py +76 -0
  26. spring/cloud/discovery.py +364 -0
  27. spring/cloud/feign.py +469 -0
  28. spring/cloud/gateway.py +452 -0
  29. spring/cloud/load_balancer.py +149 -0
  30. spring/cloud/seata.py +557 -0
  31. spring/cloud/sentinel.py +525 -0
  32. spring/cloud/tracer.py +337 -0
  33. spring/config/__init__.py +21 -0
  34. spring/config/binding.py +206 -0
  35. spring/config/config_loader.py +405 -0
  36. spring/context/__init__.py +13 -0
  37. spring/context/application_context.py +589 -0
  38. spring/context/bean_definition.py +70 -0
  39. spring/context/bean_factory.py +1052 -0
  40. spring/context/registry.py +58 -0
  41. spring/context/scanner.py +106 -0
  42. spring/core/__init__.py +3 -0
  43. spring/core/graceful_shutdown.py +196 -0
  44. spring/core/typing_utils.py +50 -0
  45. spring/csv/__init__.py +52 -0
  46. spring/csv/annotations.py +402 -0
  47. spring/csv/converters.py +69 -0
  48. spring/csv/easy_csv.py +95 -0
  49. spring/csv/exceptions.py +27 -0
  50. spring/csv/reader.py +195 -0
  51. spring/csv/writer.py +155 -0
  52. spring/data/__init__.py +54 -0
  53. spring/data/page.py +181 -0
  54. spring/data/repository.py +274 -0
  55. spring/data/specification.py +228 -0
  56. spring/datasource/__init__.py +66 -0
  57. spring/datasource/annotations.py +133 -0
  58. spring/datasource/context.py +69 -0
  59. spring/datasource/dynamic.py +148 -0
  60. spring/event/__init__.py +7 -0
  61. spring/event/publisher.py +69 -0
  62. spring/excel/__init__.py +51 -0
  63. spring/excel/annotations.py +405 -0
  64. spring/excel/converters.py +231 -0
  65. spring/excel/easy_excel.py +94 -0
  66. spring/excel/exceptions.py +31 -0
  67. spring/excel/reader.py +254 -0
  68. spring/excel/style.py +95 -0
  69. spring/excel/writer.py +197 -0
  70. spring/i18n/__init__.py +97 -0
  71. spring/i18n/accessor.py +94 -0
  72. spring/i18n/auto_config.py +177 -0
  73. spring/i18n/holder.py +106 -0
  74. spring/i18n/locale.py +152 -0
  75. spring/i18n/locale_resolver.py +367 -0
  76. spring/i18n/message_source.py +250 -0
  77. spring/i18n/middleware.py +79 -0
  78. spring/i18n/properties.py +168 -0
  79. spring/i18n/sources.py +255 -0
  80. spring/logging/__init__.py +1 -0
  81. spring/logging/loguru_logger.py +228 -0
  82. spring/main.py +378 -0
  83. spring/messaging/__init__.py +1 -0
  84. spring/messaging/rabbitmq.py +302 -0
  85. spring/monitoring/__init__.py +1 -0
  86. spring/monitoring/prometheus.py +199 -0
  87. spring/orm/__init__.py +258 -0
  88. spring/orm/database.py +222 -0
  89. spring/orm/ddl_auto.py +1217 -0
  90. spring/orm/migration.py +419 -0
  91. spring/orm/mybatis_integration.py +400 -0
  92. spring/orm/pymybatis/__init__.py +86 -0
  93. spring/orm/pymybatis/annotations/__init__.py +30 -0
  94. spring/orm/pymybatis/annotations/annotations.py +332 -0
  95. spring/orm/pymybatis/cache/__init__.py +47 -0
  96. spring/orm/pymybatis/cache/cache.py +371 -0
  97. spring/orm/pymybatis/cache/redis_cache.py +434 -0
  98. spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
  99. spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
  100. spring/orm/pymybatis/configuration.py +525 -0
  101. spring/orm/pymybatis/core/__init__.py +10 -0
  102. spring/orm/pymybatis/core/sql_session.py +1382 -0
  103. spring/orm/pymybatis/core/sql_session_factory.py +76 -0
  104. spring/orm/pymybatis/dialect/__init__.py +9 -0
  105. spring/orm/pymybatis/dialect/dialect.py +445 -0
  106. spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
  107. spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
  108. spring/orm/pymybatis/interceptor/__init__.py +31 -0
  109. spring/orm/pymybatis/interceptor/interceptor.py +427 -0
  110. spring/orm/pymybatis/mapper/__init__.py +9 -0
  111. spring/orm/pymybatis/mapper/mapper.py +540 -0
  112. spring/orm/pymybatis/metrics/__init__.py +41 -0
  113. spring/orm/pymybatis/metrics/metrics.py +595 -0
  114. spring/orm/pymybatis/pool/__init__.py +9 -0
  115. spring/orm/pymybatis/pool/connection_pool.py +711 -0
  116. spring/orm/pymybatis/security/__init__.py +19 -0
  117. spring/orm/pymybatis/security/access_control.py +415 -0
  118. spring/orm/pymybatis/security/password_encoder.py +293 -0
  119. spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
  120. spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
  121. spring/orm/pymybatis/transaction/__init__.py +9 -0
  122. spring/orm/pymybatis/transaction/transaction.py +288 -0
  123. spring/orm/pymybatis/type_handler/__init__.py +37 -0
  124. spring/orm/pymybatis/type_handler/type_handler.py +473 -0
  125. spring/orm/pymybatis/version.py +9 -0
  126. spring/orm/pymybatis/xml_parser/__init__.py +9 -0
  127. spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
  128. spring/retry/__init__.py +12 -0
  129. spring/retry/retry_annotations.py +71 -0
  130. spring/retry/retry_decorator.py +155 -0
  131. spring/scheduling/__init__.py +3 -0
  132. spring/scheduling/scheduler.py +389 -0
  133. spring/security/__init__.py +39 -0
  134. spring/security/jwt_utils.py +281 -0
  135. spring/security/replay_protection.py +206 -0
  136. spring/security/secret_manager.py +226 -0
  137. spring/security/security_aop.py +248 -0
  138. spring/security/security_context.py +172 -0
  139. spring/test/__init__.py +45 -0
  140. spring/test/slicing.py +341 -0
  141. spring/tracing/__init__.py +11 -0
  142. spring/tracing/skywalking.py +229 -0
  143. spring/tx/__init__.py +52 -0
  144. spring/tx/events.py +172 -0
  145. spring/tx/synchronization.py +143 -0
  146. spring/utils/__init__.py +5 -0
  147. spring/utils/banner.py +32 -0
  148. spring/utils/logger.py +73 -0
  149. spring/utils/redis_client.py +526 -0
  150. spring/validation/__init__.py +55 -0
  151. spring/validation/aop.py +141 -0
  152. spring/validation/constraints.py +357 -0
  153. spring/validation/exceptions.py +55 -0
  154. spring/validation/validator.py +139 -0
  155. spring/web/__init__.py +12 -0
  156. spring/web/actuator.py +319 -0
  157. spring/web/exception_handler.py +61 -0
  158. spring/web/health.py +399 -0
  159. spring/web/interceptor.py +91 -0
  160. spring/web/result.py +44 -0
  161. spring/web/swagger.py +601 -0
  162. spring/web/web_context.py +755 -0
  163. spring/websocket/__init__.py +86 -0
  164. spring/websocket/annotations.py +169 -0
  165. spring/websocket/broker.py +238 -0
  166. spring/websocket/exceptions.py +26 -0
  167. spring/websocket/handler.py +243 -0
  168. spring/websocket/router.py +526 -0
  169. spring/websocket/session.py +216 -0
  170. springbootai-1.8.0.dist-info/METADATA +2796 -0
  171. springbootai-1.8.0.dist-info/RECORD +175 -0
  172. springbootai-1.8.0.dist-info/WHEEL +5 -0
  173. springbootai-1.8.0.dist-info/entry_points.txt +2 -0
  174. springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
  175. springbootai-1.8.0.dist-info/top_level.txt +1 -0
spring/cloud/tracer.py ADDED
@@ -0,0 +1,337 @@
1
+ """
2
+ 原生 OpenTelemetry 兼容分布式追踪 (Native OpenTelemetry-compatible Tracer)
3
+
4
+ 实现 W3C Trace Context 标准的分布式追踪,无需外部 SkyWalking agent:
5
+ - 生成/传播 traceId, spanId (W3C traceparent header)
6
+ - 支持 HTTP 自动注入/提取
7
+ - 支持 Feign 跨服务调用追踪
8
+ - 支持方法级 @Trace 注解
9
+ - 支持导出到日志/控制台,兼容 Zipkin/Jaeger OTLP 格式(可选)
10
+ """
11
+
12
+ import time
13
+ import uuid
14
+ import random
15
+ import threading
16
+ import logging
17
+ import functools
18
+ from typing import Dict, List, Optional, Any, Callable
19
+ from contextvars import ContextVar
20
+ from enum import Enum
21
+
22
+ logger = logging.getLogger("Spring.Cloud.Tracer")
23
+
24
+
25
+ class SpanKind(Enum):
26
+ INTERNAL = "INTERNAL"
27
+ SERVER = "SERVER"
28
+ CLIENT = "CLIENT"
29
+ PRODUCER = "PRODUCER"
30
+ CONSUMER = "CONSUMER"
31
+
32
+
33
+ class SpanStatus(Enum):
34
+ OK = "OK"
35
+ ERROR = "ERROR"
36
+ UNSET = "UNSET"
37
+
38
+
39
+ class Span:
40
+ """追踪 Span"""
41
+ __slots__ = (
42
+ 'trace_id', 'span_id', 'parent_span_id', 'name', 'kind',
43
+ 'start_time_ns', 'end_time_ns', 'attributes', 'events',
44
+ 'status', 'status_description', 'service_name', '_ended'
45
+ )
46
+
47
+ def __init__(self, trace_id: str, span_id: str, parent_span_id: Optional[str],
48
+ name: str, kind: SpanKind, service_name: str = "unknown"):
49
+ self.trace_id = trace_id
50
+ self.span_id = span_id
51
+ self.parent_span_id = parent_span_id
52
+ self.name = name
53
+ self.kind = kind
54
+ self.start_time_ns = time.time_ns()
55
+ self.end_time_ns = 0
56
+ self.attributes: Dict[str, Any] = {}
57
+ self.events: List[Dict[str, Any]] = []
58
+ self.status = SpanStatus.UNSET
59
+ self.status_description = ""
60
+ self.service_name = service_name
61
+ self._ended = False
62
+
63
+ def set_attribute(self, key: str, value: Any):
64
+ self.attributes[key] = value
65
+ return self
66
+
67
+ def set_status(self, status: SpanStatus, description: str = ""):
68
+ self.status = status
69
+ self.status_description = description
70
+ return self
71
+
72
+ def add_event(self, name: str, attributes: Dict[str, Any] = None):
73
+ self.events.append({
74
+ 'name': name,
75
+ 'timestamp_ns': time.time_ns(),
76
+ 'attributes': attributes or {},
77
+ })
78
+ return self
79
+
80
+ def record_exception(self, exception: Exception):
81
+ self.add_event("exception", {
82
+ 'exception.type': type(exception).__name__,
83
+ 'exception.message': str(exception),
84
+ })
85
+ self.set_status(SpanStatus.ERROR, str(exception))
86
+ return self
87
+
88
+ def end(self):
89
+ if not self._ended:
90
+ self.end_time_ns = time.time_ns()
91
+ if self.status == SpanStatus.UNSET:
92
+ self.status = SpanStatus.OK
93
+ self._ended = True
94
+
95
+ @property
96
+ def duration_ms(self) -> float:
97
+ end = self.end_time_ns or time.time_ns()
98
+ return (end - self.start_time_ns) / 1_000_000
99
+
100
+ def to_dict(self) -> dict:
101
+ return {
102
+ 'traceId': self.trace_id,
103
+ 'id': self.span_id,
104
+ 'parentId': self.parent_span_id or '',
105
+ 'name': self.name,
106
+ 'kind': self.kind.value,
107
+ 'timestamp': self.start_time_ns // 1000, # microseconds for OTLP
108
+ 'duration': (self.end_time_ns - self.start_time_ns) // 1000 if self.end_time_ns else 0,
109
+ 'localEndpoint': {'serviceName': self.service_name},
110
+ 'tags': self.attributes,
111
+ 'annotations': [{'timestamp': e['timestamp_ns'] // 1000, 'value': e['name']} for e in self.events],
112
+ 'tags': {**self.attributes, **{
113
+ 'otel.status_code': self.status.value,
114
+ 'error': self.status_description if self.status == SpanStatus.ERROR else '',
115
+ }},
116
+ }
117
+
118
+
119
+ def _generate_trace_id() -> str:
120
+ """生成 W3C 兼容 32-hex traceId"""
121
+ return uuid.uuid4().hex
122
+
123
+
124
+ def _generate_span_id() -> str:
125
+ """生成 16-hex spanId"""
126
+ return '%016x' % random.getrandbits(64)
127
+
128
+
129
+ def _parse_traceparent(header: str) -> Optional[tuple]:
130
+ """解析 W3C traceparent header: 00-traceid-spanid-flags"""
131
+ try:
132
+ parts = header.strip().split('-')
133
+ if len(parts) >= 4:
134
+ version = parts[0]
135
+ trace_id = parts[1]
136
+ span_id = parts[2]
137
+ flags = parts[3]
138
+ if len(trace_id) == 32 and len(span_id) == 16:
139
+ return trace_id, span_id, flags
140
+ except Exception:
141
+ pass
142
+ return None
143
+
144
+
145
+ def _build_traceparent(trace_id: str, span_id: str, sampled: bool = True) -> str:
146
+ flags = '01' if sampled else '00'
147
+ return f"00-{trace_id}-{span_id}-{flags}"
148
+
149
+
150
+ class Tracer:
151
+ """
152
+ OpenTelemetry 兼容追踪器
153
+
154
+ Usage:
155
+ tracer = Tracer("my-service")
156
+ with tracer.span("my-operation") as span:
157
+ span.set_attribute("http.method", "GET")
158
+ ...
159
+ """
160
+ def __init__(self, service_name: str = "springpy-app", enabled: bool = True,
161
+ sample_rate: float = 1.0, export_to_log: bool = True):
162
+ self.service_name = service_name
163
+ self.enabled = enabled
164
+ self.sample_rate = sample_rate
165
+ self.export_to_log = export_to_log
166
+ self._spans: List[Span] = []
167
+ self._lock = threading.Lock()
168
+ self._span_stack: ContextVar[List[Span]] = ContextVar('span_stack', default=[])
169
+
170
+ def _should_sample(self) -> bool:
171
+ if self.sample_rate >= 1.0:
172
+ return True
173
+ if self.sample_rate <= 0:
174
+ return False
175
+ return random.random() < self.sample_rate
176
+
177
+ def start_span(self, name: str, kind: SpanKind = SpanKind.INTERNAL,
178
+ attributes: Dict[str, Any] = None,
179
+ traceparent: str = None) -> Span:
180
+ if not self.enabled or not self._should_sample():
181
+ # 返回一个空span(disabled)
182
+ span = Span("0" * 32, "0" * 16, None, name, kind, self.service_name)
183
+ span._ended = True
184
+ return span
185
+
186
+ # 从context或traceparent获取父span
187
+ stack = self._span_stack.get()
188
+ parent_trace_id = None
189
+ parent_span_id = None
190
+
191
+ if traceparent:
192
+ parsed = _parse_traceparent(traceparent)
193
+ if parsed:
194
+ parent_trace_id, parent_span_id, _ = parsed
195
+
196
+ if parent_trace_id is None:
197
+ if stack:
198
+ parent = stack[-1]
199
+ parent_trace_id = parent.trace_id
200
+ parent_span_id = parent.span_id
201
+ else:
202
+ parent_trace_id = _generate_trace_id()
203
+
204
+ span_id = _generate_span_id()
205
+ span = Span(parent_trace_id, span_id, parent_span_id, name, kind, self.service_name)
206
+ if attributes:
207
+ span.attributes.update(attributes)
208
+
209
+ new_stack = list(stack) + [span]
210
+ self._span_stack.set(new_stack)
211
+ return span
212
+
213
+ def end_span(self, span: Span):
214
+ if span._ended or span.trace_id == "0" * 32:
215
+ return
216
+ span.end()
217
+ # 从栈中移除
218
+ stack = self._span_stack.get()
219
+ if stack and stack[-1] is span:
220
+ self._span_stack.set(stack[:-1])
221
+ with self._lock:
222
+ self._spans.append(span)
223
+ if self.export_to_log and span.status == SpanStatus.ERROR:
224
+ logger.error(f"[Trace] {span.trace_id[:16]}... {span.name} "
225
+ f"status={span.status.value} duration={span.duration_ms:.2f}ms error={span.status_description}")
226
+ elif self.export_to_log:
227
+ logger.debug(f"[Trace] {span.trace_id[:16]}... {span.name} "
228
+ f"duration={span.duration_ms:.2f}ms")
229
+
230
+ def span(self, name: str, kind: SpanKind = SpanKind.INTERNAL,
231
+ attributes: Dict[str, Any] = None, traceparent: str = None):
232
+ return _SpanContext(self, name, kind, attributes, traceparent)
233
+
234
+ def get_current_span(self) -> Optional[Span]:
235
+ stack = self._span_stack.get()
236
+ return stack[-1] if stack else None
237
+
238
+ def get_traceparent_header(self) -> str:
239
+ span = self.get_current_span()
240
+ if span and span.trace_id != "0" * 32:
241
+ return _build_traceparent(span.trace_id, span.span_id)
242
+ return ""
243
+
244
+ def inject_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
245
+ """将traceparent注入HTTP headers(用于Feign跨服务调用)"""
246
+ tp = self.get_traceparent_header()
247
+ if tp:
248
+ headers['traceparent'] = tp
249
+ headers['X-B3-TraceId'] = self.get_current_span().trace_id
250
+ headers['X-B3-SpanId'] = self.get_current_span().span_id
251
+ return headers
252
+
253
+ def extract_from_headers(self, headers: Dict[str, str]) -> Optional[str]:
254
+ """从HTTP headers提取traceparent"""
255
+ if not headers:
256
+ return None
257
+ tp = headers.get('traceparent') or headers.get('Traceparent')
258
+ if not tp:
259
+ # 尝试 B3 格式
260
+ tid = headers.get('X-B3-TraceId')
261
+ sid = headers.get('X-B3-SpanId')
262
+ if tid and sid:
263
+ tp = f"00-{tid}-sid-01"
264
+ return tp
265
+
266
+ def get_spans(self, trace_id: str = None) -> List[Span]:
267
+ with self._lock:
268
+ if trace_id:
269
+ return [s for s in self._spans if s.trace_id == trace_id]
270
+ return list(self._spans)
271
+
272
+ def clear(self):
273
+ with self._lock:
274
+ self._spans.clear()
275
+
276
+
277
+ class _SpanContext:
278
+ """Span 上下文管理器"""
279
+ def __init__(self, tracer: Tracer, name: str, kind: SpanKind,
280
+ attributes: Dict[str, Any], traceparent: str):
281
+ self.tracer = tracer
282
+ self.name = name
283
+ self.kind = kind
284
+ self.attributes = attributes
285
+ self.traceparent = traceparent
286
+ self.span = None
287
+
288
+ def __enter__(self):
289
+ self.span = self.tracer.start_span(self.name, self.kind,
290
+ self.attributes, self.traceparent)
291
+ return self.span
292
+
293
+ def __exit__(self, exc_type, exc_val, exc_tb):
294
+ if exc_val is not None:
295
+ self.span.record_exception(exc_val)
296
+ self.tracer.end_span(self.span)
297
+ return False
298
+
299
+
300
+ # 全局Tracer实例
301
+ _tracer_instance: Optional[Tracer] = None
302
+ _tracer_lock = threading.Lock()
303
+
304
+
305
+ def get_tracer(service_name: str = "springpy-app", **kwargs) -> Tracer:
306
+ global _tracer_instance
307
+ if _tracer_instance is None:
308
+ with _tracer_lock:
309
+ if _tracer_instance is None:
310
+ _tracer_instance = Tracer(service_name, **kwargs)
311
+ return _tracer_instance
312
+
313
+
314
+ def trace_span(name: str = "", kind: SpanKind = SpanKind.INTERNAL):
315
+ """
316
+ @Trace 方法级追踪注解装饰器
317
+
318
+ Usage:
319
+ @trace_span("my-method")
320
+ def my_method():
321
+ ...
322
+ """
323
+ def decorator(func: Callable) -> Callable:
324
+ span_name = name or f"{func.__module__}.{func.__qualname__}"
325
+
326
+ @functools.wraps(func)
327
+ def wrapper(*args, **kwargs):
328
+ tracer = get_tracer()
329
+ with tracer.span(span_name, kind) as span:
330
+ try:
331
+ result = func(*args, **kwargs)
332
+ return result
333
+ except Exception as e:
334
+ span.record_exception(e)
335
+ raise
336
+ return wrapper
337
+ return decorator
@@ -0,0 +1,21 @@
1
+ """
2
+ 配置模块
3
+ 提供配置加载和管理功能
4
+ """
5
+ from .config_loader import (
6
+ ConfigLoader,
7
+ ConfigurationError,
8
+ config_loader,
9
+ set_global_config_loader,
10
+ get_config,
11
+ get_config_value,
12
+ )
13
+
14
+ __all__ = [
15
+ 'ConfigLoader',
16
+ 'ConfigurationError',
17
+ 'config_loader',
18
+ 'set_global_config_loader',
19
+ 'get_config',
20
+ 'get_config_value',
21
+ ]
@@ -0,0 +1,206 @@
1
+ """配置属性松散绑定与校验(对齐 Spring Boot ``@ConfigurationProperties`` 松散绑定 +
2
+ ``@NestedConfigurationProperties`` + ``@Validated``)。
3
+
4
+ 能力:
5
+ - **松散绑定**:``kebab-case`` / ``camelCase`` / ``snake_case`` / ``SCREAMING_SNAKE`` 等命名
6
+ 规范化后等价匹配(对齐 Spring ``RelaxedNames``)。如 ``max-connections``、``maxConnections``、
7
+ ``max_connections`` 均绑定到属性 ``max_connections``。
8
+ - **嵌套绑定**:属性类型标注为 ``@NestedConfigurationProperties`` 类时,递归绑定子字典到该类实例。
9
+ - **类型强转**:标量值按属性类型注解强转(``int``/``float``/``bool``/``str``),避免 YAML 字符串误绑。
10
+ - **校验**:类上标注 ``@Validated`` 时,绑定后调用 ``BeanValidator.validate_or_raise``,
11
+ 违反约束抛 ``ValidationError``(对齐 Spring ``@Validated`` + Hibernate Validator)。
12
+
13
+ 设计:
14
+ - **复用既有范式**:``@NestedConfigurationProperties`` 继承 ``SpringAnnotation``;校验复用
15
+ ``spring.validation.BeanValidator``,不重复造轮子。
16
+ - **独立可测**:``ConfigurationPropertiesBinder.bind`` 为静态方法,无需 IoC 容器即可使用。
17
+ - **集成点**:``ApplicationContext._apply_configuration_properties`` 调用本绑定器替代原扁平绑定。
18
+
19
+ 与 Java 的差异:
20
+ - Spring 用 ``Binder`` + ``BeanBinder``;本实现用反射 + ``get_type_hints``,简化但覆盖常见场景。
21
+ - 不支持 ``Duration``/``DataSize`` 等专用转换器(可按需扩展 ``_coerce``)。
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import re
27
+ from typing import Any, Dict, Optional, Type, get_type_hints
28
+
29
+ from spring.annotations.core import SpringAnnotation, Validated
30
+
31
+ logger = logging.getLogger("Spring.Config.Binding")
32
+
33
+
34
+ class NestedConfigurationProperties(SpringAnnotation):
35
+ """``@NestedConfigurationProperties`` 标记一个类可作为嵌套配置属性持有者。
36
+
37
+ 标注后,父 ``@ConfigurationProperties`` 绑定时遇到该类型的属性会递归绑定子字典。
38
+ """
39
+
40
+ _annotation_type = "nested_properties"
41
+
42
+ def __init__(self, prefix: str = ""):
43
+ super().__init__(prefix=prefix)
44
+
45
+
46
+ # 匹配非字母数字字符(kebab/camel/snake 分隔符)用于规范化
47
+ _NON_ALNUM = re.compile(r'[^0-9a-zA-Z]+')
48
+
49
+
50
+ def _normalize(name: str) -> str:
51
+ """规范化命名:去除分隔符并小写,用于松散匹配。
52
+
53
+ ``max-connections`` / ``maxConnections`` / ``max_connections`` / ``MAX_CONNECTIONS``
54
+ → ``maxconnections``。
55
+ """
56
+ if not isinstance(name, str):
57
+ return str(name).lower()
58
+ return _NON_ALNUM.sub('', name).lower()
59
+
60
+
61
+ def _is_nested_config_class(cls: Optional[type]) -> bool:
62
+ """判断类是否标注 ``@NestedConfigurationProperties``。"""
63
+ if cls is None or not isinstance(cls, type):
64
+ return False
65
+ annotations = getattr(cls, '__spring_annotations__', [])
66
+ return any(isinstance(a, NestedConfigurationProperties) for a in annotations)
67
+
68
+
69
+ def _coerce(value: Any, target_type: Optional[type]) -> Any:
70
+ """按目标类型强转标量值;无法强转则原样返回(交由校验器报错)。"""
71
+ if target_type is None or value is None:
72
+ return value
73
+ # 已经是目标类型,直接返回
74
+ if isinstance(value, target_type):
75
+ return value
76
+ try:
77
+ if target_type is bool:
78
+ # YAML 已把 true/false 解析为 bool;字符串场景兜底
79
+ if isinstance(value, str):
80
+ return value.strip().lower() in ('true', '1', 'yes', 'on')
81
+ return bool(value)
82
+ if target_type is int:
83
+ return int(value)
84
+ if target_type is float:
85
+ return float(value)
86
+ if target_type is str:
87
+ return str(value)
88
+ except (ValueError, TypeError):
89
+ return value # 强转失败保留原值,由校验器/使用方处理
90
+ return value
91
+
92
+
93
+ class ConfigurationPropertiesBinder:
94
+ """配置属性松散绑定器(静态方法风格,无状态)。"""
95
+
96
+ @staticmethod
97
+ def _resolve_attr_name(instance: Any, config_key: str, candidates: Dict[str, str]) -> Optional[str]:
98
+ """松散匹配:把 config_key 规范化后在属性候选表中查找。
99
+
100
+ ``candidates`` 为 ``{normalized_attr_name: actual_attr_name}``。
101
+ """
102
+ normalized = _normalize(config_key)
103
+ return candidates.get(normalized)
104
+
105
+ @staticmethod
106
+ def _attr_candidates(instance: Any) -> Dict[str, str]:
107
+ """构造属性候选表:``{规范化属性名: 实际属性名}``。
108
+
109
+ 综合三类来源,确保 ``__init__`` 赋值的实例属性、类级注解属性、继承属性均可命中:
110
+ 1. 类级类型注解(``get_type_hints``,最可靠,含继承)
111
+ 2. 实例 ``__dict__``(``__init__`` 赋值的属性)
112
+ 3. ``dir(cls)`` 类属性兜底
113
+ """
114
+ candidates: Dict[str, str] = {}
115
+ cls = type(instance)
116
+ # 1. 类型注解(含继承,最可靠的属性名来源)
117
+ try:
118
+ for attr in get_type_hints(cls):
119
+ if not attr.startswith('_'):
120
+ candidates[_normalize(attr)] = attr
121
+ except Exception:
122
+ pass
123
+ # 2. 实例属性(__init__ 赋值)
124
+ for attr in vars(instance):
125
+ if not attr.startswith('_'):
126
+ candidates[_normalize(attr)] = attr
127
+ # 3. 类属性兜底
128
+ for attr in dir(cls):
129
+ if attr.startswith('_'):
130
+ continue
131
+ candidates.setdefault(_normalize(attr), attr)
132
+ return candidates
133
+
134
+ @staticmethod
135
+ def bind(instance: Any, config: Dict[str, Any]) -> Any:
136
+ """递归松散绑定 ``config`` 字典到 ``instance`` 的属性。
137
+
138
+ - 嵌套字典 + 属性类型为 ``@NestedConfigurationProperties`` 类 → 递归绑定。
139
+ - 标量按属性类型注解强转。
140
+ - 未匹配的键跳过(不报错,对齐 Spring 宽松绑定)。
141
+ """
142
+ if not isinstance(config, dict):
143
+ return instance
144
+ try:
145
+ type_hints = get_type_hints(type(instance))
146
+ except Exception:
147
+ type_hints = {}
148
+ candidates = ConfigurationPropertiesBinder._attr_candidates(instance)
149
+
150
+ for key, value in config.items():
151
+ attr_name = ConfigurationPropertiesBinder._resolve_attr_name(
152
+ instance, key, candidates
153
+ )
154
+ if attr_name is None:
155
+ logger.debug("配置键 '%s' 未匹配到属性,跳过", key)
156
+ continue
157
+ attr_type = type_hints.get(attr_name)
158
+ # 嵌套配置:值是字典且属性类型为 @NestedConfigurationProperties 类
159
+ if isinstance(value, dict) and _is_nested_config_class(attr_type):
160
+ nested_instance = ConfigurationPropertiesBinder._build_nested(attr_type)
161
+ if nested_instance is not None:
162
+ ConfigurationPropertiesBinder.bind(nested_instance, value)
163
+ setattr(instance, attr_name, nested_instance)
164
+ continue
165
+ # 嵌套字典但属性类型非嵌套注解类:若属性类型是普通类,尝试递归绑定(容错)
166
+ if isinstance(value, dict) and isinstance(attr_type, type) and attr_type not in (dict,):
167
+ nested_instance = ConfigurationPropertiesBinder._build_nested(attr_type)
168
+ if nested_instance is not None and _is_nested_config_class(attr_type):
169
+ ConfigurationPropertiesBinder.bind(nested_instance, value)
170
+ setattr(instance, attr_name, nested_instance)
171
+ continue
172
+ # 标量/列表/字典:按类型强转后赋值
173
+ setattr(instance, attr_name, _coerce(value, attr_type))
174
+ return instance
175
+
176
+ @staticmethod
177
+ def _build_nested(cls: type) -> Optional[Any]:
178
+ """构造嵌套配置类实例;构造失败返回 None。"""
179
+ try:
180
+ return cls()
181
+ except Exception as exc:
182
+ logger.warning("构造嵌套配置类 %s 失败: %s", cls.__name__, exc)
183
+ return None
184
+
185
+
186
+ def validate_configuration_properties(instance: Any) -> None:
187
+ """若 ``instance`` 类标注了 ``@Validated``,运行 BeanValidator 校验,违反则抛错。
188
+
189
+ 复用 ``spring.validation.BeanValidator``,未启用 validation 模块时静默跳过。
190
+ """
191
+ annotations = getattr(type(instance), '__spring_annotations__', [])
192
+ if not any(isinstance(a, Validated) for a in annotations):
193
+ return
194
+ try:
195
+ from spring.validation import BeanValidator
196
+ except ImportError: # pragma: no cover - validation 为内置模块
197
+ logger.debug("spring.validation 未安装,跳过配置属性校验")
198
+ return
199
+ BeanValidator.validate_or_raise(instance)
200
+
201
+
202
+ __all__ = [
203
+ "NestedConfigurationProperties",
204
+ "ConfigurationPropertiesBinder",
205
+ "validate_configuration_properties",
206
+ ]