fiuai-sdk-python 0.11.9__tar.gz → 0.12.0__tar.gz

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 (48) hide show
  1. fiuai_sdk_python-0.12.0/CHANGELOG.md +48 -0
  2. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/PKG-INFO +1 -1
  3. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/pyproject.toml +1 -1
  4. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/__init__.py +14 -0
  5. fiuai_sdk_python-0.12.0/src/fiuai_sdk_python/_resp_helpers.py +163 -0
  6. fiuai_sdk_python-0.12.0/src/fiuai_sdk_python/error_code.py +145 -0
  7. fiuai_sdk_python-0.12.0/src/fiuai_sdk_python/error_parsers.py +122 -0
  8. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/redis_manager.py +14 -3
  9. fiuai_sdk_python-0.12.0/src/fiuai_sdk_python/resp.py +411 -0
  10. fiuai_sdk_python-0.11.9/CHANGELOG.md +0 -37
  11. fiuai_sdk_python-0.11.9/src/fiuai_sdk_python/resp.py +0 -289
  12. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/.gitignore +0 -0
  13. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/LICENSE +0 -0
  14. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/README.md +0 -0
  15. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/__init__.py +0 -0
  16. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/context_mgr.py +0 -0
  17. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/header.py +0 -0
  18. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/helper.py +0 -0
  19. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/type.py +0 -0
  20. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/bank.py +0 -0
  21. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/client.py +0 -0
  22. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/company.py +0 -0
  23. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/config.py +0 -0
  24. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/const.py +0 -0
  25. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/context.py +0 -0
  26. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/datatype.py +0 -0
  27. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/doctype.py +0 -0
  28. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/error.py +0 -0
  29. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/examples/fastapi_integration.py +0 -0
  30. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/http/__init__.py +0 -0
  31. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/http/client.py +0 -0
  32. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/item.py +0 -0
  33. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/perm.py +0 -0
  34. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/__init__.py +0 -0
  35. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/cache_client.py +0 -0
  36. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/circuit_breaker.py +0 -0
  37. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/decorator.py +0 -0
  38. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/types.py +0 -0
  39. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/scheduler/__init__.py +0 -0
  40. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/scheduler/runner.py +0 -0
  41. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/profile.py +0 -0
  42. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/setup.py +0 -0
  43. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/type.py +0 -0
  44. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/util.py +0 -0
  45. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/utils/__init__.py +0 -0
  46. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/utils/ids.py +0 -0
  47. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/utils/logger.py +0 -0
  48. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/utils/text.py +0 -0
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - 新增结构化错误处理: `ApiError` model + `FiuaiErrorCode` 枚举
12
+ - `ApiError` 字段: `code` / `type` / `message` / `detail` / `context` / `http_status_code` / `traceback`
13
+ - `FiuaiErrorCode` 提供稳定错误码枚举 (跨语言/跨版本不变), 与 frappe 二开侧 codes.py 对齐
14
+ - `ApiResponse` 新增字段:
15
+ - `error: ApiError | None` - 主错误 (errors[0] 规整后)
16
+ - `errors: list[ApiError]` - 所有错误项
17
+ - `warnings: list[str]` - 非错误的提示文案
18
+ - `raw: dict` - 原始 frappe response (调试用)
19
+ - SDK 内置兜底解析器 `error_parsers.py`, 服务端没补 `errors[].context` 时自动从 frappe 异常字符串提取 doctype/name/fields
20
+ - 网络层异常 (`response=None` / 非 JSON / 非 dict body) 也统一走 `ApiError`, code 为 `NETWORK_ERROR` / `API_INVALID_JSON` / `API_NO_RESPONSE`
21
+ - frappe `messages[]` 中红色友好文案 (启发式: indicator=red / title=错误 / 文本以"错误:"开头 / raise_exception 标记) 自动合并到主 `ApiError.message`
22
+
23
+ ### Changed
24
+ - `parse_response` 重写, 返回的 `ApiResponse` 始终包含结构化 `error` 字段, 一定不会抛异常
25
+ - `_is_api_success` 修复: `data is None` 不再判失败, `delete` / `cancel` 等正常返回 None 的接口不会被误判
26
+
27
+ ### Deprecated
28
+ - `ApiResponse.error_code` / `ApiResponse.error_message` 改为派生 property, 访问触发 `DeprecationWarning`
29
+ - 迁移路径:
30
+ - `resp.error_message[0]` → `resp.error.message`
31
+ - `resp.error_code[0]` → `resp.error.code`
32
+ - 下个 major 版本将移除
33
+
34
+ ### Removed
35
+ - N/A
36
+
37
+ ### Fixed
38
+ - `parse_response` 在 HTTP 502/504 等代理层 HTML 错误时不再 silent fallback, 返回 code=`API_INVALID_JSON` 的结构化 ApiError
39
+ - `_extract_error_info` 中无意义的 try/except 包装移除
40
+
41
+ ### Security
42
+ - N/A
43
+
44
+ ## [0.1.0] - 2025-01-01
45
+
46
+ ### Added
47
+ - Initial release
48
+ - Basic SDK structure and functionality
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fiuai_sdk_python
3
- Version: 0.11.9
3
+ Version: 0.12.0
4
4
  Summary: FiuAI Python SDK - 企业级AI服务集成开发工具包
5
5
  Project-URL: Homepage, https://github.com/fiuai/fiuai-sdk-python
6
6
  Project-URL: Documentation, https://github.com/fiuai/fiuai-sdk-python#readme
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fiuai_sdk_python"
3
- version = "0.11.9"
3
+ version = "0.12.0"
4
4
  description = "FiuAI Python SDK - 企业级AI服务集成开发工具包"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -10,6 +10,13 @@ from .context import (
10
10
  is_current_context_valid,
11
11
  get_trace_id,
12
12
  )
13
+ from .error_code import (
14
+ EXCEPTION_TO_CODE,
15
+ FiuaiErrorCode,
16
+ exception_to_code,
17
+ status_code_to_code,
18
+ )
19
+ from .resp import ApiError, ApiResponse, parse_response
13
20
 
14
21
  __version__ = "0.11.6"
15
22
 
@@ -25,4 +32,11 @@ __all__ = [
25
32
  'get_current_missing_fields',
26
33
  'is_current_context_valid',
27
34
  'get_trace_id',
35
+ 'ApiError',
36
+ 'ApiResponse',
37
+ 'parse_response',
38
+ 'FiuaiErrorCode',
39
+ 'EXCEPTION_TO_CODE',
40
+ 'exception_to_code',
41
+ 'status_code_to_code',
28
42
  ]
@@ -0,0 +1,163 @@
1
+ # -- coding: utf-8 --
2
+ # Project: fiuai_sdk_python
3
+ # Created Date: 2026-05-09
4
+ # Author: liming
5
+ # Email: lmlala@aliyun.com
6
+ # Copyright (c) 2025 FiuAI
7
+
8
+ """
9
+ resp.py 的内部 helper 集合.
10
+
11
+ 不是公开 API, 不要从外部直接 import. 拆出来仅为了控制 resp.py 行数 (单文件 < 400 行规约).
12
+ """
13
+
14
+ import logging
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from httpx import Response
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # --------------------------------------------------------------------------
23
+ # 响应体读取 (用于 JSON 解析失败时的日志/预览)
24
+ # --------------------------------------------------------------------------
25
+
26
+
27
+ def get_response_body_preview(response: Response, max_chars: int = 300) -> str:
28
+ """安全读取响应体前若干字符, 用于错误信息预览. 兼容编码问题及中文."""
29
+ try:
30
+ raw = response.content
31
+ if not raw:
32
+ return ""
33
+ text = raw.decode("utf-8", errors="replace").strip()
34
+ if len(text) > max_chars:
35
+ text = text[:max_chars] + "..."
36
+ return text
37
+ except Exception:
38
+ return ""
39
+
40
+
41
+ def get_response_body_for_log(response: Response, max_chars: int = 2000) -> str:
42
+ """安全读取完整响应体用于日志, 避免编码异常, 支持中文."""
43
+ try:
44
+ raw = response.content
45
+ if not raw:
46
+ return "(empty)"
47
+ text = raw.decode("utf-8", errors="replace").strip()
48
+ if len(text) > max_chars:
49
+ text = text[:max_chars] + "...(truncated)"
50
+ return text
51
+ except Exception as ex:
52
+ return f"(decode error: {ex})"
53
+
54
+
55
+ # --------------------------------------------------------------------------
56
+ # 业务成功判定
57
+ # --------------------------------------------------------------------------
58
+
59
+
60
+ def is_api_success(response_data: Dict[str, Any]) -> bool:
61
+ """判断 API 业务是否成功.
62
+
63
+ 与旧版的差异:
64
+ - 不再要求"必须有 data 才算成功". delete/cancel 等接口正常返回 None 不会被误判
65
+ - 仅依据 errors / exc / http_status_code (frappe 内嵌) 判定
66
+ """
67
+ if not isinstance(response_data, dict):
68
+ return False
69
+ if response_data.get("errors"):
70
+ return False
71
+ if response_data.get("exc"):
72
+ return False
73
+ inner_status = response_data.get("http_status_code")
74
+ if inner_status is not None:
75
+ try:
76
+ return 200 <= int(inner_status) < 300
77
+ except (TypeError, ValueError):
78
+ return False
79
+ return True
80
+
81
+
82
+ def extract_success_data(response_data: Dict[str, Any]) -> Any:
83
+ """从成功响应中提取数据. 优先 data 字段, 其次 messages 字段."""
84
+ data = response_data.get("data")
85
+ if data is not None:
86
+ return data
87
+ messages = response_data.get("messages")
88
+ if messages is not None:
89
+ return messages
90
+ return None
91
+
92
+
93
+ # --------------------------------------------------------------------------
94
+ # messages 红色 / warning 启发式分流
95
+ # --------------------------------------------------------------------------
96
+
97
+
98
+ _RED_INDICATOR = "red"
99
+ _ERROR_TITLES = {"错误", "Error", "ERROR"}
100
+ # frappe _get_missing_mandatory_fields() 等默认友好文案的前缀, 用于启发式判定:
101
+ # "Error: Value missing for ..." / "错误: ..." / "错误:..." (中文全角冒号)
102
+ _ERROR_TEXT_PREFIXES = ("错误:", "错误:", "Error:", "ERROR:", "error:")
103
+
104
+
105
+ def _is_error_message(msg: Dict[str, Any]) -> bool:
106
+ """判定一条 message 是否是错误补充文案 (vs 普通提示).
107
+
108
+ frappe 在调用 ``frappe.throw`` / ``MandatoryError`` 时, 默认 msgprint 不设
109
+ indicator 也不设 title, 但 message 文本本身会以 ``错误:`` / ``Error:`` 开头.
110
+ 所以判定要走多重启发式:
111
+ 1. ``indicator == "red"``
112
+ 2. ``title in {"错误", "Error", "ERROR"}``
113
+ 3. ``raise_exception`` 标记
114
+ 4. message 文本以错误前缀开头
115
+ """
116
+ if (msg.get("indicator") or "").strip().lower() == _RED_INDICATOR:
117
+ return True
118
+ if (msg.get("title") or "").strip() in _ERROR_TITLES:
119
+ return True
120
+ if msg.get("raise_exception"):
121
+ return True
122
+ text = (msg.get("message") or "").strip()
123
+ return text.startswith(_ERROR_TEXT_PREFIXES)
124
+
125
+
126
+ def split_messages(raw_messages: Any) -> tuple[List[str], List[str]]:
127
+ """把 frappe messages[] 拆成 (error_texts, warning_texts).
128
+
129
+ 判定基于 ``_is_error_message`` 多重启发式 (indicator=red / title=错误 / message
130
+ 文本以 "错误:" / "Error:" 开头 / raise_exception 标记). 命中即归错误文案,
131
+ 否则进 warnings.
132
+ """
133
+ error_texts: List[str] = []
134
+ warnings: List[str] = []
135
+ if not isinstance(raw_messages, list):
136
+ return error_texts, warnings
137
+
138
+ for msg in raw_messages:
139
+ if not isinstance(msg, dict):
140
+ text = str(msg).strip()
141
+ if text:
142
+ warnings.append(text)
143
+ continue
144
+ text = (msg.get("message") or "").strip()
145
+ if not text:
146
+ continue
147
+ if _is_error_message(msg):
148
+ error_texts.append(text)
149
+ else:
150
+ warnings.append(text)
151
+
152
+ return error_texts, warnings
153
+
154
+
155
+ def merge_error_message(primary: str, extras: List[str]) -> str:
156
+ """把 frappe messages 里的友好文案合并到主错误 message, 去重去空."""
157
+ pieces: List[str] = []
158
+ if primary:
159
+ pieces.append(primary)
160
+ for extra in extras:
161
+ if extra and extra not in pieces:
162
+ pieces.append(extra)
163
+ return "; ".join(pieces)
@@ -0,0 +1,145 @@
1
+ # -- coding: utf-8 --
2
+ # Project: fiuai_sdk_python
3
+ # Created Date: 2026-05-09
4
+ # Author: liming
5
+ # Email: lmlala@aliyun.com
6
+ # Copyright (c) 2025 FiuAI
7
+
8
+ """
9
+ 统一错误码定义.
10
+
11
+ 设计原则:
12
+ - 错误码本身是稳定枚举字符串, 不随 i18n / frappe 异常类名变化
13
+ - 与 frappe 二开侧 frappe/fiuai_utils/api/error/codes.py 保持一一对应
14
+ - SDK 在没有服务端规整的旧 frappe 上仍能凭 frappe 异常类名兜底映射出错误码
15
+
16
+ 调用方使用方式:
17
+ from fiuai_sdk_python.error_code import FiuaiErrorCode, exception_to_code
18
+
19
+ if resp.error and resp.error.code == FiuaiErrorCode.MANDATORY_FIELD_MISSING:
20
+ ...
21
+ """
22
+
23
+ from enum import StrEnum
24
+
25
+
26
+ class FiuaiErrorCode(StrEnum):
27
+ """业务错误码枚举.
28
+
29
+ 命名约定: 大写下划线, 语义明确, 不带前缀.
30
+ 新增错误码时必须同步 frappe/fiuai_utils/api/error/codes.py.
31
+ """
32
+
33
+ MANDATORY_FIELD_MISSING = "MANDATORY_FIELD_MISSING"
34
+ LINK_INVALID = "LINK_INVALID"
35
+ LINK_EXISTS = "LINK_EXISTS"
36
+ DUPLICATE_ENTRY = "DUPLICATE_ENTRY"
37
+ DOC_NOT_FOUND = "DOC_NOT_FOUND"
38
+ TIMESTAMP_MISMATCH = "TIMESTAMP_MISMATCH"
39
+ DOCSTATUS_TRANSITION_ERROR = "DOCSTATUS_TRANSITION_ERROR"
40
+ UPDATE_AFTER_SUBMIT = "UPDATE_AFTER_SUBMIT"
41
+ CANCELLED_LINK = "CANCELLED_LINK"
42
+
43
+ PERMISSION_DENIED = "PERMISSION_DENIED"
44
+ AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
45
+ CSRF_TOKEN_ERROR = "CSRF_TOKEN_ERROR"
46
+ RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
47
+
48
+ INTERNAL_VALIDATION_ERROR = "INTERNAL_VALIDATION_ERROR"
49
+ INTERNAL_API_ERROR = "INTERNAL_API_ERROR"
50
+
51
+ NETWORK_ERROR = "NETWORK_ERROR"
52
+ API_NO_RESPONSE = "API_NO_RESPONSE"
53
+ API_INVALID_JSON = "API_INVALID_JSON"
54
+ SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
55
+ SERVER_ERROR = "SERVER_ERROR"
56
+
57
+ UNKNOWN = "UNKNOWN"
58
+
59
+
60
+ # Frappe 异常类名 -> FiuaiErrorCode 映射. 用于 SDK 在服务端没补 code 时兜底
61
+ # Key 必须是 frappe 异常的 __name__, 见 frappe/exceptions.py
62
+ EXCEPTION_TO_CODE: dict[str, FiuaiErrorCode] = {
63
+ "MandatoryError": FiuaiErrorCode.MANDATORY_FIELD_MISSING,
64
+ "LinkValidationError": FiuaiErrorCode.LINK_INVALID,
65
+ "LinkExistsError": FiuaiErrorCode.LINK_EXISTS,
66
+ "CancelledLinkError": FiuaiErrorCode.CANCELLED_LINK,
67
+ "DuplicateEntryError": FiuaiErrorCode.DUPLICATE_ENTRY,
68
+ "UniqueValidationError": FiuaiErrorCode.DUPLICATE_ENTRY,
69
+ "DoesNotExistError": FiuaiErrorCode.DOC_NOT_FOUND,
70
+ "PageDoesNotExistError": FiuaiErrorCode.DOC_NOT_FOUND,
71
+ "TimestampMismatchError": FiuaiErrorCode.TIMESTAMP_MISMATCH,
72
+ "DocstatusTransitionError": FiuaiErrorCode.DOCSTATUS_TRANSITION_ERROR,
73
+ "UpdateAfterSubmitError": FiuaiErrorCode.UPDATE_AFTER_SUBMIT,
74
+ "PermissionError": FiuaiErrorCode.PERMISSION_DENIED,
75
+ "AuthenticationError": FiuaiErrorCode.AUTHENTICATION_ERROR,
76
+ "SessionExpired": FiuaiErrorCode.AUTHENTICATION_ERROR,
77
+ "CSRFTokenError": FiuaiErrorCode.CSRF_TOKEN_ERROR,
78
+ "TooManyRequestsError": FiuaiErrorCode.RATE_LIMIT_EXCEEDED,
79
+ "RateLimitExceededError": FiuaiErrorCode.RATE_LIMIT_EXCEEDED,
80
+ "ValidationError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
81
+ "DataError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
82
+ "InvalidStatusError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
83
+ "EmptyTableError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
84
+ "InvalidEmailAddressError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
85
+ "InvalidNameError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
86
+ "InvalidPhoneNumberError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
87
+ "InvalidSignatureError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
88
+ "NonNegativeError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
89
+ "CharacterLengthExceededError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
90
+ "CannotChangeConstantError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
91
+ "MappingMismatchError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
92
+ "FrappeTypeError": FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
93
+ "InReadOnlyMode": FiuaiErrorCode.SERVICE_UNAVAILABLE,
94
+ "SessionStopped": FiuaiErrorCode.SERVICE_UNAVAILABLE,
95
+ }
96
+
97
+ # HTTP 状态码兜底映射. 仅在 frappe 没返回 errors[].type 或 type 不在表里时用
98
+ STATUS_CODE_TO_CODE: dict[int, FiuaiErrorCode] = {
99
+ 400: FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
100
+ 401: FiuaiErrorCode.AUTHENTICATION_ERROR,
101
+ 403: FiuaiErrorCode.PERMISSION_DENIED,
102
+ 404: FiuaiErrorCode.DOC_NOT_FOUND,
103
+ 409: FiuaiErrorCode.DUPLICATE_ENTRY,
104
+ 410: FiuaiErrorCode.SERVICE_UNAVAILABLE,
105
+ 415: FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
106
+ 417: FiuaiErrorCode.INTERNAL_VALIDATION_ERROR,
107
+ 429: FiuaiErrorCode.RATE_LIMIT_EXCEEDED,
108
+ 500: FiuaiErrorCode.SERVER_ERROR,
109
+ 502: FiuaiErrorCode.NETWORK_ERROR,
110
+ 503: FiuaiErrorCode.SERVICE_UNAVAILABLE,
111
+ 504: FiuaiErrorCode.NETWORK_ERROR,
112
+ 508: FiuaiErrorCode.SERVER_ERROR,
113
+ }
114
+
115
+
116
+ def exception_to_code(exception_type: str | None) -> FiuaiErrorCode:
117
+ """根据 frappe 异常类名返回对应错误码, 找不到返回 UNKNOWN.
118
+
119
+ Args:
120
+ exception_type: frappe 异常类名, 例如 "MandatoryError"
121
+
122
+ Returns:
123
+ FiuaiErrorCode: 对应的稳定错误码
124
+ """
125
+ if not exception_type:
126
+ return FiuaiErrorCode.UNKNOWN
127
+ return EXCEPTION_TO_CODE.get(exception_type, FiuaiErrorCode.UNKNOWN)
128
+
129
+
130
+ def status_code_to_code(http_status_code: int | None) -> FiuaiErrorCode:
131
+ """根据 HTTP 状态码兜底返回错误码.
132
+
133
+ 用在 frappe 没返回结构化 errors 时 (例如代理层 502/504, 或 frappe 老版本).
134
+
135
+ Args:
136
+ http_status_code: HTTP 状态码
137
+
138
+ Returns:
139
+ FiuaiErrorCode: 兜底错误码
140
+ """
141
+ if http_status_code is None:
142
+ return FiuaiErrorCode.UNKNOWN
143
+ if 200 <= http_status_code < 300:
144
+ return FiuaiErrorCode.UNKNOWN
145
+ return STATUS_CODE_TO_CODE.get(http_status_code, FiuaiErrorCode.SERVER_ERROR)
@@ -0,0 +1,122 @@
1
+ # -- coding: utf-8 --
2
+ # Project: fiuai_sdk_python
3
+ # Created Date: 2026-05-09
4
+ # Author: liming
5
+ # Email: lmlala@aliyun.com
6
+ # Copyright (c) 2025 FiuAI
7
+
8
+ """
9
+ Frappe 异常字符串 -> 结构化 context 的兜底解析器.
10
+
11
+ Frappe v15 在 errors[] 数组里只塞 type/message 字符串, 不带结构化字段.
12
+ SDK 这里用正则把高频的 frappe 异常字符串解析成 dict, 给业务侧拿到 doctype / name / fields.
13
+
14
+ 如果服务端 (frappe 二开层 normalizer) 已经写好 errors[].context 结构化字段,
15
+ SDK 不会再走兜底, 直接用服务端的 context.
16
+
17
+ 设计原则:
18
+ - 解析失败永远返回空 dict, 不抛异常 (兜底必须健壮)
19
+ - 仅覆盖 frappe 原生异常常见字符串格式, 不做猜测式正则
20
+ """
21
+
22
+ import re
23
+ from typing import Any
24
+
25
+ # MandatoryError: 来自 frappe/model/document.py:994
26
+ # raise frappe.MandatoryError(
27
+ # "[{doctype}, {name}]: {fields}".format(...)
28
+ # )
29
+ # 实际字符串: "[Item, 7384919604195962880]: stock_uom"
30
+ # 多字段: "[Item, 7384919604195962880]: stock_uom, item_group"
31
+ _MANDATORY_RE = re.compile(
32
+ r"^\s*\[\s*(?P<doctype>[^,\]]+)\s*,\s*(?P<name>[^\]]+)\s*\]\s*:\s*(?P<fields>.+?)\s*$"
33
+ )
34
+
35
+ # DuplicateEntryError: 来自 frappe/model/base_document.py:675
36
+ # raise frappe.DuplicateEntryError(self.doctype, self.name, e)
37
+ # str(exc) 一般会输出: "('Item', 'Test Item', IntegrityError(...))" (NameError 子类)
38
+ # 也可能是 frappe.throw 包装后的 HTML 文案, 这里仅尝试 tuple 形式
39
+ _DUPLICATE_RE = re.compile(
40
+ r"^\s*\(\s*['\"](?P<doctype>[^'\"]+)['\"]\s*,\s*['\"](?P<name>[^'\"]+)['\"]"
41
+ )
42
+
43
+ # LinkValidationError / LinkExistsError: 没有稳定字符串格式 (大多是 _() 翻译过的 HTML),
44
+ # 不做正则解析, 仅返回原文给上层做日志
45
+
46
+
47
+ def parse_mandatory_error_message(message: str) -> dict[str, Any]:
48
+ """解析 MandatoryError 字符串提取 doctype / name / fields.
49
+
50
+ Args:
51
+ message: errors[].message 字符串, 例如 "[Item, 7384919604195962880]: stock_uom"
52
+
53
+ Returns:
54
+ dict: {"doctype": str, "name": str, "fields": list[str]} 或 {} (解析失败)
55
+ """
56
+ if not message or not isinstance(message, str):
57
+ return {}
58
+
59
+ m = _MANDATORY_RE.match(message)
60
+ if not m:
61
+ return {}
62
+
63
+ fields = [f.strip() for f in m.group("fields").split(",") if f.strip()]
64
+ return {
65
+ "doctype": m.group("doctype").strip(),
66
+ "name": m.group("name").strip(),
67
+ "fields": fields,
68
+ }
69
+
70
+
71
+ def parse_duplicate_entry_error_message(message: str) -> dict[str, Any]:
72
+ """解析 DuplicateEntryError 字符串提取 doctype / name.
73
+
74
+ Args:
75
+ message: errors[].message 字符串
76
+
77
+ Returns:
78
+ dict: {"doctype": str, "name": str} 或 {}
79
+ """
80
+ if not message or not isinstance(message, str):
81
+ return {}
82
+
83
+ m = _DUPLICATE_RE.match(message)
84
+ if not m:
85
+ return {}
86
+
87
+ return {
88
+ "doctype": m.group("doctype").strip(),
89
+ "name": m.group("name").strip(),
90
+ }
91
+
92
+
93
+ # 异常类名 -> 解析器函数 路由表
94
+ # 服务端没补 context 时, SDK 自动按 type 选解析器
95
+ _PARSER_MAP = {
96
+ "MandatoryError": parse_mandatory_error_message,
97
+ "DuplicateEntryError": parse_duplicate_entry_error_message,
98
+ "UniqueValidationError": parse_duplicate_entry_error_message,
99
+ }
100
+
101
+
102
+ def parse_error_context(exception_type: str | None, message: str | None) -> dict[str, Any]:
103
+ """根据异常类型选解析器, 兜底返回空 dict.
104
+
105
+ Args:
106
+ exception_type: frappe 异常类名
107
+ message: errors[].message 字符串
108
+
109
+ Returns:
110
+ dict: 结构化 context, 解析失败返回 {}
111
+ """
112
+ if not exception_type or not message:
113
+ return {}
114
+
115
+ parser = _PARSER_MAP.get(exception_type)
116
+ if parser is None:
117
+ return {}
118
+
119
+ try:
120
+ return parser(message)
121
+ except Exception:
122
+ return {}
@@ -6,7 +6,7 @@
6
6
  # Email: lmlala@aliyun.com
7
7
  # Copyright (c) 2025 FiuAI
8
8
 
9
- from typing import Dict, List, Set, Tuple, Type
9
+ from typing import Dict, List, Set
10
10
 
11
11
  from pydantic import BaseModel, Field
12
12
  from redis import ConnectionPool as SyncConnectionPool
@@ -20,6 +20,17 @@ from ...utils.text import safe_str
20
20
  logger = get_logger(__name__)
21
21
 
22
22
 
23
+ def _import_langgraph_redis_savers():
24
+ try:
25
+ from langgraph.checkpoint.redis import AsyncRedisSaver, RedisSaver
26
+ except ImportError as exc:
27
+ raise ImportError(
28
+ "LangGraph Redis checkpoint is optional; install the SDK with extra [langgraph], "
29
+ "for example: pip install 'fiuai_sdk_python[langgraph]'"
30
+ ) from exc
31
+ return RedisSaver, AsyncRedisSaver
32
+
33
+
23
34
  class RedisDBConfig(BaseModel):
24
35
  name: str = Field(description="连接名称")
25
36
  host: str = Field(description="Redis主机")
@@ -114,7 +125,7 @@ class RedisManager:
114
125
 
115
126
  def setup_langgraph_checkpoint(self, db_name: str):
116
127
  """按需初始化 LangGraph checkpoint (仅 Agent 场景需要, lazy import)"""
117
- from langgraph.checkpoint.redis import RedisSaver
128
+ RedisSaver, _ = _import_langgraph_redis_savers()
118
129
 
119
130
  pool = self._sync_pools.get(db_name)
120
131
  if not pool:
@@ -127,7 +138,7 @@ class RedisManager:
127
138
 
128
139
  async def setup_langgraph_checkpoint_async(self, db_name: str):
129
140
  """异步版 LangGraph checkpoint 初始化"""
130
- from langgraph.checkpoint.redis import AsyncRedisSaver
141
+ _, AsyncRedisSaver = _import_langgraph_redis_savers()
131
142
 
132
143
  pool = self._async_pools.get(db_name)
133
144
  if not pool: