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.
- fiuai_sdk_python-0.12.0/CHANGELOG.md +48 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/PKG-INFO +1 -1
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/pyproject.toml +1 -1
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/__init__.py +14 -0
- fiuai_sdk_python-0.12.0/src/fiuai_sdk_python/_resp_helpers.py +163 -0
- fiuai_sdk_python-0.12.0/src/fiuai_sdk_python/error_code.py +145 -0
- fiuai_sdk_python-0.12.0/src/fiuai_sdk_python/error_parsers.py +122 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/redis_manager.py +14 -3
- fiuai_sdk_python-0.12.0/src/fiuai_sdk_python/resp.py +411 -0
- fiuai_sdk_python-0.11.9/CHANGELOG.md +0 -37
- fiuai_sdk_python-0.11.9/src/fiuai_sdk_python/resp.py +0 -289
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/.gitignore +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/LICENSE +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/README.md +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/__init__.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/context_mgr.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/header.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/helper.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/auth/type.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/bank.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/client.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/company.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/config.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/const.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/context.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/datatype.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/doctype.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/error.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/examples/fastapi_integration.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/http/__init__.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/http/client.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/item.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/perm.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/__init__.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/cache_client.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/circuit_breaker.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/decorator.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/types.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/scheduler/__init__.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/scheduler/runner.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/profile.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/setup.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/type.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/util.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/utils/__init__.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/utils/ids.py +0 -0
- {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/utils/logger.py +0 -0
- {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.
|
|
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
|
|
@@ -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 {}
|
{fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.12.0}/src/fiuai_sdk_python/pkg/cache/redis_manager.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
_, AsyncRedisSaver = _import_langgraph_redis_savers()
|
|
131
142
|
|
|
132
143
|
pool = self._async_pools.get(db_name)
|
|
133
144
|
if not pool:
|