fiuai-sdk-python 0.6.6__py3-none-any.whl → 0.6.7__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.
@@ -9,6 +9,15 @@ from .helper import (
9
9
  get_impersonation,
10
10
  is_impersonating
11
11
  )
12
+ from .context_mgr import (
13
+ ContextManager,
14
+ init_context,
15
+ WorldData,
16
+ get_auth_data_from_context,
17
+ set_auth_data,
18
+ update_auth_data,
19
+ get_world_data,
20
+ )
12
21
 
13
22
  __all__ = [
14
23
  "parse_auth_headers",
@@ -22,4 +31,11 @@ __all__ = [
22
31
  "get_company_unique_no",
23
32
  "get_impersonation",
24
33
  "is_impersonating",
34
+ "ContextManager",
35
+ "init_context",
36
+ "WorldData",
37
+ "get_auth_data_from_context",
38
+ "set_auth_data",
39
+ "update_auth_data",
40
+ "get_world_data",
25
41
  ]
@@ -0,0 +1,228 @@
1
+ # -- coding: utf-8 --
2
+ # Project: fiuai_sdk_python
3
+ # Created Date: 2025-01-31
4
+ # Author: liming
5
+ # Email: lmlala@aliyun.com
6
+ # Copyright (c) 2025 FiuAI
7
+
8
+ """
9
+ 上下文管理:ContextManager、init_context、WorldData,
10
+ 以及从当前上下文获取/设置认证数据的 get_auth_data_from_context、set_auth_data、update_auth_data、get_world_data。
11
+ """
12
+
13
+ import uuid
14
+ import logging
15
+ from typing import Dict, Any, Optional
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from ..context import RequestContext, get_current_headers, is_current_context_valid
20
+ from .type import AuthData
21
+ from .header import parse_auth_headers
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _current_context_manager: Optional["ContextManager"] = None
26
+
27
+
28
+ class WorldData(BaseModel):
29
+ """World 上下文数据模型"""
30
+ event_id: Optional[str] = Field(default=None, description="事件ID")
31
+ task_id: Optional[str] = Field(default=None, description="任务ID")
32
+
33
+
34
+ class ContextManager:
35
+ """
36
+ 上下文管理器,用于在后台任务中初始化和管理请求上下文。
37
+ 使用 context manager 模式,确保上下文在任务结束时自动清理。
38
+
39
+ 注意:
40
+ - 上下文只在 `with` 语句块内生效
41
+ - 退出 `with` 块时,上下文会被自动清理
42
+ - 在异步环境中,上下文会在同一个异步任务中保持
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ auth_data: Optional[AuthData] = None,
48
+ headers: Optional[Dict[str, Any]] = None,
49
+ world_data: Optional[WorldData] = None,
50
+ event_id: Optional[str] = None,
51
+ task_id: Optional[str] = None,
52
+ ):
53
+ if auth_data and headers:
54
+ raise ValueError("不能同时提供 auth_data 和 headers,请只提供其中一个")
55
+
56
+ if world_data:
57
+ self.world_data = world_data
58
+ elif event_id is not None or task_id is not None:
59
+ self.world_data = WorldData(event_id=event_id, task_id=task_id)
60
+ else:
61
+ self.world_data = WorldData()
62
+
63
+ if auth_data:
64
+ self._context = self._create_context_from_auth_data(auth_data)
65
+ elif headers:
66
+ self._context = RequestContext.from_dict(headers)
67
+ else:
68
+ raise ValueError("必须提供 auth_data 或 headers 之一")
69
+
70
+ def _create_context_from_auth_data(self, auth_data: AuthData) -> RequestContext:
71
+ """从认证数据创建请求上下文"""
72
+ headers = {
73
+ "x-fiuai-user": auth_data.user_id,
74
+ "x-fiuai-auth-tenant-id": auth_data.auth_tenant_id,
75
+ "x-fiuai-current-company": auth_data.current_company,
76
+ "x-fiuai-impersonation": auth_data.impersonation or "",
77
+ "x-fiuai-unique-no": auth_data.company_unique_no or auth_data.current_company,
78
+ "x-fiuai-trace-id": auth_data.trace_id or str(uuid.uuid4()),
79
+ "x-fiuai-client": auth_data.client or "unknown",
80
+ "x-fiuai-channel": (auth_data.channel or "default").lower(),
81
+ "x-fiuai-lang": "zh",
82
+ "accept-language": "zh",
83
+ }
84
+ return RequestContext.from_dict(headers)
85
+
86
+ def __enter__(self):
87
+ global _current_context_manager
88
+ _current_context_manager = self
89
+ self._context.__enter__()
90
+ logger.debug("Context initialized")
91
+ return self
92
+
93
+ def __exit__(self, exc_type, exc_val, exc_tb):
94
+ self._context.__exit__(exc_type, exc_val, exc_tb)
95
+ global _current_context_manager
96
+ _current_context_manager = None
97
+ logger.debug("Context cleaned up")
98
+
99
+ def update_auth_data(self, auth_data: AuthData) -> None:
100
+ """更新上下文中的认证数据"""
101
+ new_context = self._create_context_from_auth_data(auth_data)
102
+ if hasattr(self._context, "_token") and self._context._token:
103
+ self._context.__exit__(None, None, None)
104
+ self._context = new_context
105
+ self._context.__enter__()
106
+ logger.debug("Context auth data updated")
107
+
108
+ def update_headers(self, headers: Dict[str, Any]) -> None:
109
+ """更新上下文中的请求头"""
110
+ new_context = RequestContext.from_dict(headers)
111
+ if hasattr(self._context, "_token") and self._context._token:
112
+ self._context.__exit__(None, None, None)
113
+ self._context = new_context
114
+ self._context.__enter__()
115
+ logger.debug("Context headers updated")
116
+
117
+
118
+ def init_context(
119
+ auth_data: Optional[AuthData] = None,
120
+ headers: Optional[Dict[str, Any]] = None,
121
+ user_id: Optional[str] = None,
122
+ auth_tenant_id: Optional[str] = None,
123
+ current_company: Optional[str] = None,
124
+ world_data: Optional[WorldData] = None,
125
+ event_id: Optional[str] = None,
126
+ task_id: Optional[str] = None,
127
+ **kwargs: Any,
128
+ ) -> ContextManager:
129
+ """
130
+ 初始化上下文,用于后台任务。返回的 ContextManager 必须在 `with` 语句中使用。
131
+
132
+ 支持三种方式:1) auth_data 对象 2) headers 字典 3) user_id, auth_tenant_id, current_company。
133
+ """
134
+ if auth_data:
135
+ return ContextManager(auth_data=auth_data, world_data=world_data, event_id=event_id, task_id=task_id)
136
+ if headers:
137
+ return ContextManager(headers=headers, world_data=world_data, event_id=event_id, task_id=task_id)
138
+ if user_id and auth_tenant_id and current_company:
139
+ auth_data = AuthData(
140
+ user_id=user_id,
141
+ auth_tenant_id=auth_tenant_id,
142
+ current_company=current_company,
143
+ impersonation=kwargs.get("impersonation", ""),
144
+ company_unique_no=kwargs.get("company_unique_no", current_company),
145
+ trace_id=kwargs.get("trace_id", str(uuid.uuid4())),
146
+ client=kwargs.get("client", "unknown"),
147
+ channel=kwargs.get("channel", "default"),
148
+ )
149
+ return ContextManager(auth_data=auth_data, world_data=world_data, event_id=event_id, task_id=task_id)
150
+ raise ValueError(
151
+ "必须提供以下之一:"
152
+ "1. auth_data 对象 "
153
+ "2. headers 字典 "
154
+ "3. user_id, auth_tenant_id, current_company 基本参数"
155
+ )
156
+
157
+
158
+ def get_auth_data_from_context() -> Optional[AuthData]:
159
+ """从当前上下文中获取认证数据。上下文无效或不存在时返回 None。"""
160
+ try:
161
+ if not is_current_context_valid():
162
+ logger.warning("Current context is invalid, cannot get auth data")
163
+ return None
164
+ headers = get_current_headers()
165
+ if not headers:
166
+ logger.warning("Cannot get headers from current context")
167
+ return None
168
+ return parse_auth_headers(headers)
169
+ except Exception as e:
170
+ logger.warning("Failed to get auth data from context: %s", e)
171
+ return None
172
+
173
+
174
+ def set_auth_data(auth_data: AuthData) -> bool:
175
+ """设置当前上下文中的认证数据。需在 init_context 的 with 块内使用。"""
176
+ global _current_context_manager
177
+ try:
178
+ if _current_context_manager is None:
179
+ logger.warning(
180
+ "ContextManager does not exist, cannot set auth data. "
181
+ "Please initialize context with init_context first"
182
+ )
183
+ return False
184
+ _current_context_manager.update_auth_data(auth_data)
185
+ return True
186
+ except Exception as e:
187
+ logger.warning("Failed to set auth data: %s", e)
188
+ return False
189
+
190
+
191
+ def update_auth_data(
192
+ user_id: Optional[str] = None,
193
+ auth_tenant_id: Optional[str] = None,
194
+ current_company: Optional[str] = None,
195
+ **kwargs: Any,
196
+ ) -> bool:
197
+ """更新当前上下文中部分认证数据,未提供的字段保持不变。"""
198
+ try:
199
+ current = get_auth_data_from_context()
200
+ if not current:
201
+ logger.warning("Cannot get current auth data, please initialize context first")
202
+ return False
203
+ new_auth_data = AuthData(
204
+ user_id=user_id or current.user_id,
205
+ auth_tenant_id=auth_tenant_id or current.auth_tenant_id,
206
+ current_company=current_company or current.current_company,
207
+ impersonation=kwargs.get("impersonation", current.impersonation),
208
+ company_unique_no=kwargs.get("company_unique_no", current.company_unique_no),
209
+ trace_id=kwargs.get("trace_id", current.trace_id),
210
+ client=kwargs.get("client", current.client),
211
+ channel=kwargs.get("channel", current.channel),
212
+ )
213
+ return set_auth_data(new_auth_data)
214
+ except Exception as e:
215
+ logger.warning("Failed to update auth data: %s", e)
216
+ return False
217
+
218
+
219
+ def get_world_data() -> Optional[WorldData]:
220
+ """从当前上下文中获取 World 上下文数据。"""
221
+ global _current_context_manager
222
+ try:
223
+ if _current_context_manager is None:
224
+ return None
225
+ return _current_context_manager.world_data
226
+ except Exception as e:
227
+ logger.warning("Failed to get world data: %s", e)
228
+ return None
@@ -6,7 +6,7 @@
6
6
  # Copyright (c) 2025 FiuAI
7
7
 
8
8
  from typing import Dict, Optional, Union, Literal
9
- from .type import AuthData
9
+ from .type import AuthData, FIUAI_CHANNEL_VALUES
10
10
  from fastapi import Request
11
11
 
12
12
 
@@ -32,7 +32,9 @@ def parse_auth_headers(headers: Dict[str, str]) -> Optional[AuthData]:
32
32
  unique_no = headers.get("x-fiuai-unique-no", "")
33
33
  trace_id = headers.get("x-fiuai-trace-id", "")
34
34
  client = headers.get("x-fiuai-client", "unknown")
35
-
35
+ channel_raw = (headers.get("x-fiuai-channel") or "").strip().lower()
36
+ channel = channel_raw if channel_raw in FIUAI_CHANNEL_VALUES else "default"
37
+
36
38
  # 验证必需字段
37
39
  if not user_id:
38
40
  raise ValueError("Missing required header: x-fiuai-user")
@@ -59,7 +61,8 @@ def parse_auth_headers(headers: Dict[str, str]) -> Optional[AuthData]:
59
61
  impersonation=impersonation,
60
62
  company_unique_no=unique_no,
61
63
  trace_id=trace_id,
62
- client=client
64
+ client=client,
65
+ channel=channel,
63
66
  )
64
67
 
65
68
  except Exception as e:
@@ -7,7 +7,11 @@
7
7
 
8
8
 
9
9
  from pydantic import BaseModel, Field
10
- from typing import List
10
+ from typing import List, Literal
11
+
12
+ # 渠道枚举:wechat, web, default, app
13
+ FiuaiChannel = Literal["wechat", "web", "default", "app"]
14
+ FIUAI_CHANNEL_VALUES: tuple = ("wechat", "web", "default", "app")
11
15
 
12
16
  class AuthData(BaseModel):
13
17
  user_id: str = Field(description="用户ID")
@@ -17,6 +21,7 @@ class AuthData(BaseModel):
17
21
  company_unique_no: str = Field(description="当前公司唯一编号,正常情况等于current_company")
18
22
  trace_id: str = Field(description="追踪ID", default="")
19
23
  client: str = Field(description="客户端标识", default="unknown")
24
+ channel: str = Field(description="渠道: wechat, web, default, app", default="default")
20
25
 
21
26
 
22
27
 
@@ -32,7 +37,8 @@ class AuthHeader(BaseModel):
32
37
  x_fiuai_lang: str = Field(alias="x-fiuai-lang", default="zh", description="用户当前语言")
33
38
  x_fiuai_trace_id: str = Field(alias="x-fiuai-trace-id", default="", description="追踪ID")
34
39
  x_fiuai_client: str = Field(alias="x-fiuai-client", default="unknown", description="客户端标识")
40
+ x_fiuai_channel: str = Field(alias="x-fiuai-channel", default="default", description="渠道: wechat, web, default, app")
35
41
  accept_language: str = Field(alias="accept-language", default="zh", description="语言")
36
-
42
+
37
43
  class Config:
38
44
  populate_by_name = True
@@ -95,6 +95,7 @@ class FiuaiSDK(object):
95
95
  "x-fiuai-unique-no": company_unique_no_value,
96
96
  "x-fiuai-trace-id": context_headers.get("x-fiuai-trace-id", self.headers.x_fiuai_trace_id),
97
97
  "x-fiuai-client": context_headers.get("x-fiuai-client", self.headers.x_fiuai_client),
98
+ "x-fiuai-channel": context_headers.get("x-fiuai-channel", self.headers.x_fiuai_channel),
98
99
  "x-fiuai-lang": context_headers.get("x-fiuai-lang", self.headers.x_fiuai_lang),
99
100
  "accept-language": context_headers.get("accept-language", self.headers.accept_language),
100
101
  })
@@ -114,6 +115,7 @@ class FiuaiSDK(object):
114
115
  "x-fiuai-unique-no": company_unique_no_value,
115
116
  "x-fiuai-trace-id": self.headers.x_fiuai_trace_id,
116
117
  "x-fiuai-client": self.headers.x_fiuai_client,
118
+ "x-fiuai-channel": self.headers.x_fiuai_channel,
117
119
  "x-fiuai-lang": self.headers.x_fiuai_lang,
118
120
  "accept-language": self.headers.accept_language,
119
121
  }
@@ -230,6 +230,7 @@ def extract_auth_headers_from_context(
230
230
  unique_no: str = "",
231
231
  trace_id: str = "",
232
232
  client: str = "",
233
+ channel: str = "default",
233
234
  lang: str = "zh",
234
235
  accept_language: str = "zh"
235
236
  ) -> AuthHeader:
@@ -244,6 +245,7 @@ def extract_auth_headers_from_context(
244
245
  unique_no: 公司唯一编号
245
246
  trace_id: 追踪ID
246
247
  client: 客户端类型(小程序、web、app等)
248
+ channel: 渠道 wechat/web/default/app,可为空默认 default
247
249
  lang: 语言
248
250
  accept_language: 接受的语言
249
251
 
@@ -263,17 +265,19 @@ def extract_auth_headers_from_context(
263
265
  x_fiuai_unique_no=unique_no or current_company,
264
266
  x_fiuai_trace_id=trace_id or str(uuid.uuid4()),
265
267
  x_fiuai_client=client,
268
+ x_fiuai_channel=channel or "default",
266
269
  x_fiuai_lang=lang,
267
270
  accept_language=accept_language
268
271
  )
269
-
272
+
270
273
  # 从上下文中提取信息,优先使用上下文中的值
271
274
  context_trace_id = context_headers.get("x-fiuai-trace-id", "")
272
275
  context_unique_no = context_headers.get("x-fiuai-unique-no", "")
273
276
  context_client = context_headers.get("x-fiuai-client", "")
277
+ context_channel = context_headers.get("x-fiuai-channel", "") or "default"
274
278
  context_lang = context_headers.get("x-fiuai-lang", "")
275
279
  context_accept_language = context_headers.get("accept-language", "")
276
-
280
+
277
281
  return AuthHeader(
278
282
  x_fiuai_user=username,
279
283
  x_fiuai_auth_tenant_id=auth_tenant_id,
@@ -282,6 +286,7 @@ def extract_auth_headers_from_context(
282
286
  x_fiuai_unique_no=context_unique_no or unique_no or current_company,
283
287
  x_fiuai_trace_id=context_trace_id or trace_id or str(uuid.uuid4()),
284
288
  x_fiuai_client=context_client or client,
289
+ x_fiuai_channel=context_channel or channel or "default",
285
290
  x_fiuai_lang=context_lang or lang,
286
291
  accept_language=context_accept_language or accept_language
287
292
  )
@@ -0,0 +1,30 @@
1
+ # -- coding: utf-8 --
2
+ # Project: fiuai_sdk_python
3
+ # Created Date: 2025-01-31
4
+ # Author: liming
5
+ # Email: lmlala@aliyun.com
6
+ # Copyright (c) 2025 FiuAI
7
+
8
+ from .client import (
9
+ get_async_http_client,
10
+ create_http_client,
11
+ get_sync_http_client,
12
+ create_sync_http_client,
13
+ close_global_clients,
14
+ extract_auth_headers,
15
+ auth_header_interceptor,
16
+ sync_auth_header_interceptor,
17
+ AUTH_HEADER_KEYS,
18
+ )
19
+
20
+ __all__ = [
21
+ "get_async_http_client",
22
+ "create_http_client",
23
+ "get_sync_http_client",
24
+ "create_sync_http_client",
25
+ "close_global_clients",
26
+ "extract_auth_headers",
27
+ "auth_header_interceptor",
28
+ "sync_auth_header_interceptor",
29
+ "AUTH_HEADER_KEYS",
30
+ ]
@@ -0,0 +1,396 @@
1
+ # -- coding: utf-8 --
2
+ # Project: fiuai_sdk_python
3
+ # Created Date: 2025-01-31
4
+ # Author: liming
5
+ # Email: lmlala@aliyun.com
6
+ # Copyright (c) 2025 FiuAI
7
+
8
+ """
9
+ HTTP 客户端与认证头拦截器:从当前上下文注入认证头,支持同步/异步、可选重试。
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ import time
15
+ from typing import Any, Dict, Optional, Union
16
+
17
+ import httpx
18
+
19
+ from ..context import get_current_headers, is_current_context_valid
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # 需要传递的认证头列表(与 auth 目录下 AuthHeader 对齐)
24
+ AUTH_HEADER_KEYS = [
25
+ "x-fiuai-user",
26
+ "x-fiuai-auth-tenant-id",
27
+ "x-fiuai-current-company",
28
+ "x-fiuai-impersonation",
29
+ "x-fiuai-unique-no",
30
+ "x-fiuai-trace-id",
31
+ "x-fiuai-client",
32
+ "x-fiuai-channel",
33
+ "x-fiuai-lang",
34
+ "accept-language",
35
+ ]
36
+
37
+
38
+ def extract_auth_headers() -> Dict[str, str]:
39
+ """
40
+ 从当前上下文中提取认证头信息。
41
+
42
+ Returns:
43
+ Dict[str, str]: 认证头字典,若上下文无效则返回空字典。
44
+ """
45
+ try:
46
+ if not is_current_context_valid():
47
+ logger.debug("current context is invalid, cannot extract auth headers")
48
+ return {}
49
+ headers = get_current_headers()
50
+ if not headers:
51
+ logger.debug("cannot get request headers from current context")
52
+ return {}
53
+ auth_headers = {}
54
+ for key in AUTH_HEADER_KEYS:
55
+ value = headers.get(key)
56
+ if value:
57
+ auth_headers[key] = value
58
+ return auth_headers
59
+ except Exception as e:
60
+ logger.warning("failed to extract auth headers from context: %s", e)
61
+ return {}
62
+
63
+
64
+ async def auth_header_interceptor(request: httpx.Request) -> None:
65
+ """
66
+ 异步认证头拦截器:为请求注入当前上下文的认证头,并在有 body 时设置默认 Content-Type。
67
+ """
68
+ auth_headers = extract_auth_headers()
69
+ if auth_headers:
70
+ for key, value in auth_headers.items():
71
+ request.headers[key] = value
72
+ logger.debug("added auth headers to request: %s", list(auth_headers.keys()))
73
+ else:
74
+ logger.debug("no auth headers found in context, skipping header injection")
75
+ if request.content and "content-type" not in request.headers:
76
+ request.headers["content-type"] = "application/json"
77
+ logger.debug("added default Content-Type: application/json")
78
+
79
+
80
+ def sync_auth_header_interceptor(request: httpx.Request) -> None:
81
+ """
82
+ 同步认证头拦截器:为请求注入当前上下文的认证头,并在有 body 时设置默认 Content-Type。
83
+ """
84
+ auth_headers = extract_auth_headers()
85
+ if auth_headers:
86
+ for key, value in auth_headers.items():
87
+ request.headers[key] = value
88
+ logger.debug("added auth headers to request: %s", list(auth_headers.keys()))
89
+ else:
90
+ logger.debug("no auth headers found in context, skipping header injection")
91
+ if request.content and "content-type" not in request.headers:
92
+ request.headers["content-type"] = "application/json"
93
+ logger.debug("added default Content-Type: application/json")
94
+
95
+
96
+ class RetryableAsyncClient(httpx.AsyncClient):
97
+ """支持重试的异步 httpx 客户端。"""
98
+
99
+ def __init__(
100
+ self,
101
+ retry_count: int = 0,
102
+ retry_interval: float = 1.0,
103
+ *args: Any,
104
+ **kwargs: Any,
105
+ ):
106
+ super().__init__(*args, **kwargs)
107
+ self.retry_count = retry_count
108
+ self.retry_interval = retry_interval
109
+
110
+ async def _request_with_retry(
111
+ self,
112
+ method: str,
113
+ url: Union[httpx.URL, str],
114
+ *args: Any,
115
+ **kwargs: Any,
116
+ ) -> httpx.Response:
117
+ last_exception: Optional[Exception] = None
118
+ for attempt in range(self.retry_count + 1):
119
+ try:
120
+ response = await super().request(method, url, *args, **kwargs)
121
+ if response.status_code >= 500 and attempt < self.retry_count:
122
+ logger.warning(
123
+ "request failed with status %s, retrying (%s/%s)",
124
+ response.status_code,
125
+ attempt + 1,
126
+ self.retry_count,
127
+ )
128
+ await response.aclose()
129
+ await asyncio.sleep(self.retry_interval)
130
+ continue
131
+ return response
132
+ except httpx.HTTPStatusError as e:
133
+ if e.response.status_code >= 500 and attempt < self.retry_count:
134
+ logger.warning(
135
+ "request failed with status %s, retrying (%s/%s)",
136
+ e.response.status_code,
137
+ attempt + 1,
138
+ self.retry_count,
139
+ )
140
+ await e.response.aclose()
141
+ await asyncio.sleep(self.retry_interval)
142
+ continue
143
+ last_exception = e
144
+ logger.error("request failed with client error: %s", e)
145
+ raise
146
+ except httpx.RequestError as e:
147
+ last_exception = e
148
+ if attempt < self.retry_count:
149
+ logger.warning(
150
+ "request failed: %s, retrying (%s/%s)",
151
+ e,
152
+ attempt + 1,
153
+ self.retry_count,
154
+ )
155
+ await asyncio.sleep(self.retry_interval)
156
+ else:
157
+ logger.error("request failed after %s attempts: %s", self.retry_count + 1, e)
158
+ raise
159
+ if last_exception:
160
+ raise last_exception
161
+ raise httpx.HTTPError("request failed")
162
+
163
+ async def get(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
164
+ return await self._request_with_retry("GET", url, *args, **kwargs)
165
+
166
+ async def post(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
167
+ return await self._request_with_retry("POST", url, *args, **kwargs)
168
+
169
+ async def put(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
170
+ return await self._request_with_retry("PUT", url, *args, **kwargs)
171
+
172
+ async def delete(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
173
+ return await self._request_with_retry("DELETE", url, *args, **kwargs)
174
+
175
+ async def patch(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
176
+ return await self._request_with_retry("PATCH", url, *args, **kwargs)
177
+
178
+ async def head(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
179
+ return await self._request_with_retry("HEAD", url, *args, **kwargs)
180
+
181
+ async def options(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
182
+ return await self._request_with_retry("OPTIONS", url, *args, **kwargs)
183
+
184
+
185
+ class RetryableSyncClient(httpx.Client):
186
+ """支持重试的同步 httpx 客户端。"""
187
+
188
+ def __init__(
189
+ self,
190
+ retry_count: int = 0,
191
+ retry_interval: float = 1.0,
192
+ *args: Any,
193
+ **kwargs: Any,
194
+ ):
195
+ super().__init__(*args, **kwargs)
196
+ self.retry_count = retry_count
197
+ self.retry_interval = retry_interval
198
+
199
+ def _request_with_retry(
200
+ self,
201
+ method: str,
202
+ url: Union[httpx.URL, str],
203
+ *args: Any,
204
+ **kwargs: Any,
205
+ ) -> httpx.Response:
206
+ last_exception: Optional[Exception] = None
207
+ for attempt in range(self.retry_count + 1):
208
+ try:
209
+ response = super().request(method, url, *args, **kwargs)
210
+ if response.status_code >= 500 and attempt < self.retry_count:
211
+ logger.warning(
212
+ "request failed with status %s, retrying (%s/%s)",
213
+ response.status_code,
214
+ attempt + 1,
215
+ self.retry_count,
216
+ )
217
+ response.close()
218
+ time.sleep(self.retry_interval)
219
+ continue
220
+ return response
221
+ except httpx.HTTPStatusError as e:
222
+ if e.response.status_code >= 500 and attempt < self.retry_count:
223
+ logger.warning(
224
+ "request failed with status %s, retrying (%s/%s)",
225
+ e.response.status_code,
226
+ attempt + 1,
227
+ self.retry_count,
228
+ )
229
+ e.response.close()
230
+ time.sleep(self.retry_interval)
231
+ continue
232
+ last_exception = e
233
+ logger.error("request failed with client error: %s", e)
234
+ raise
235
+ except httpx.RequestError as e:
236
+ last_exception = e
237
+ if attempt < self.retry_count:
238
+ logger.warning(
239
+ "request failed: %s, retrying (%s/%s)",
240
+ e,
241
+ attempt + 1,
242
+ self.retry_count,
243
+ )
244
+ time.sleep(self.retry_interval)
245
+ else:
246
+ logger.error("request failed after %s attempts: %s", self.retry_count + 1, e)
247
+ raise
248
+ if last_exception:
249
+ raise last_exception
250
+ raise httpx.HTTPError("request failed")
251
+
252
+ def get(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
253
+ return self._request_with_retry("GET", url, *args, **kwargs)
254
+
255
+ def post(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
256
+ return self._request_with_retry("POST", url, *args, **kwargs)
257
+
258
+ def put(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
259
+ return self._request_with_retry("PUT", url, *args, **kwargs)
260
+
261
+ def delete(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
262
+ return self._request_with_retry("DELETE", url, *args, **kwargs)
263
+
264
+ def patch(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
265
+ return self._request_with_retry("PATCH", url, *args, **kwargs)
266
+
267
+ def head(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
268
+ return self._request_with_retry("HEAD", url, *args, **kwargs)
269
+
270
+ def options(self, url: Union[httpx.URL, str], *args: Any, **kwargs: Any) -> httpx.Response:
271
+ return self._request_with_retry("OPTIONS", url, *args, **kwargs)
272
+
273
+
274
+ def create_http_client(
275
+ base_url: Optional[str] = None,
276
+ timeout: float = 30.0,
277
+ follow_redirects: bool = True,
278
+ verify: bool = True,
279
+ retry_count: int = 0,
280
+ retry_interval: float = 1.0,
281
+ **kwargs: Any,
282
+ ) -> httpx.AsyncClient:
283
+ """创建带认证头拦截器的 httpx 异步客户端。"""
284
+ client_kwargs: Dict[str, Any] = {
285
+ "timeout": timeout,
286
+ "follow_redirects": follow_redirects,
287
+ "verify": verify,
288
+ **kwargs,
289
+ }
290
+ if base_url is not None:
291
+ client_kwargs["base_url"] = base_url
292
+ if retry_count > 0:
293
+ return RetryableAsyncClient(
294
+ retry_count=retry_count,
295
+ retry_interval=retry_interval,
296
+ **client_kwargs,
297
+ event_hooks={"request": [auth_header_interceptor]},
298
+ )
299
+ return httpx.AsyncClient(
300
+ **client_kwargs,
301
+ event_hooks={"request": [auth_header_interceptor]},
302
+ )
303
+
304
+
305
+ def create_sync_http_client(
306
+ base_url: Optional[str] = None,
307
+ timeout: float = 30.0,
308
+ follow_redirects: bool = True,
309
+ verify: bool = True,
310
+ retry_count: int = 0,
311
+ retry_interval: float = 1.0,
312
+ **kwargs: Any,
313
+ ) -> httpx.Client:
314
+ """创建带认证头拦截器的 httpx 同步客户端。"""
315
+ client_kwargs: Dict[str, Any] = {
316
+ "timeout": timeout,
317
+ "follow_redirects": follow_redirects,
318
+ "verify": verify,
319
+ **kwargs,
320
+ }
321
+ if base_url is not None:
322
+ client_kwargs["base_url"] = base_url
323
+ if retry_count > 0:
324
+ return RetryableSyncClient(
325
+ retry_count=retry_count,
326
+ retry_interval=retry_interval,
327
+ **client_kwargs,
328
+ event_hooks={"request": [sync_auth_header_interceptor]},
329
+ )
330
+ return httpx.Client(
331
+ **client_kwargs,
332
+ event_hooks={"request": [sync_auth_header_interceptor]},
333
+ )
334
+
335
+
336
+ _global_async_client: Optional[httpx.AsyncClient] = None
337
+ _global_sync_client: Optional[httpx.Client] = None
338
+
339
+
340
+ def get_async_http_client(
341
+ base_url: Optional[str] = None,
342
+ timeout: float = 30.0,
343
+ follow_redirects: bool = True,
344
+ verify: bool = True,
345
+ retry_count: int = 0,
346
+ retry_interval: float = 1.0,
347
+ **kwargs: Any,
348
+ ) -> httpx.AsyncClient:
349
+ """获取全局 httpx 异步客户端(单例)。"""
350
+ global _global_async_client
351
+ if _global_async_client is None:
352
+ _global_async_client = create_http_client(
353
+ base_url=base_url,
354
+ timeout=timeout,
355
+ follow_redirects=follow_redirects,
356
+ verify=verify,
357
+ retry_count=retry_count,
358
+ retry_interval=retry_interval,
359
+ **kwargs,
360
+ )
361
+ return _global_async_client
362
+
363
+
364
+ def get_sync_http_client(
365
+ base_url: Optional[str] = None,
366
+ timeout: float = 30.0,
367
+ follow_redirects: bool = True,
368
+ verify: bool = True,
369
+ retry_count: int = 0,
370
+ retry_interval: float = 1.0,
371
+ **kwargs: Any,
372
+ ) -> httpx.Client:
373
+ """获取全局 httpx 同步客户端(单例)。"""
374
+ global _global_sync_client
375
+ if _global_sync_client is None:
376
+ _global_sync_client = create_sync_http_client(
377
+ base_url=base_url,
378
+ timeout=timeout,
379
+ follow_redirects=follow_redirects,
380
+ verify=verify,
381
+ retry_count=retry_count,
382
+ retry_interval=retry_interval,
383
+ **kwargs,
384
+ )
385
+ return _global_sync_client
386
+
387
+
388
+ def close_global_clients() -> None:
389
+ """关闭全局客户端。"""
390
+ global _global_async_client, _global_sync_client
391
+ if _global_async_client:
392
+ _global_async_client.close()
393
+ _global_async_client = None
394
+ if _global_sync_client:
395
+ _global_sync_client.close()
396
+ _global_sync_client = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fiuai_sdk_python
3
- Version: 0.6.6
3
+ Version: 0.6.7
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,10 +1,10 @@
1
1
  fiuai_sdk_python/__init__.py,sha256=WWAL1xTmdB8EKGatITf6wBKtCmbvBCDGhppOYt_8ZuU,581
2
2
  fiuai_sdk_python/bank.py,sha256=GRAmkI33rRZw9UE67SpkYsqPN-kdLm-iuJuuBLxSvrE,5438
3
- fiuai_sdk_python/client.py,sha256=rDc0l4nhcv6sT_T_5lbcfkKbk4GT0WibStUH7C0YVtA,18917
3
+ fiuai_sdk_python/client.py,sha256=AGc8iqXaHOYUsAPDekV8dYZL6OFgERufR7FvAZscW8g,19087
4
4
  fiuai_sdk_python/company.py,sha256=ZELOAxCTIA-F9cUWakvq7hv6SflM19Rq3XMDkV0mtbg,4771
5
5
  fiuai_sdk_python/config.py,sha256=vCD-aW2Y9OpJitfTDB3Acjw_PCurYT69vQjZ_wY950I,825
6
6
  fiuai_sdk_python/const.py,sha256=fPoUeOJ4WPymEQqarA7cJE3bmdz5fCPq2PtYvzZJ4Tk,346
7
- fiuai_sdk_python/context.py,sha256=Gu77o_tQN9eEh42RYC9KoJQcJxlmi4i8LH50IFfFH4I,9319
7
+ fiuai_sdk_python/context.py,sha256=uVUFvk9cjyZDzOaGjPK5j3f_426HDN_xBiQQ8GJCQ-s,9607
8
8
  fiuai_sdk_python/datatype.py,sha256=WQjsoJgpkkw0GtAQLzTrsem4joT8e3cnhcfy3dKN0bA,8919
9
9
  fiuai_sdk_python/doctype.py,sha256=5o6WsZDeKuCWfTiQa0H_BNh4jb96EpZt9WaDh8TgKZo,5013
10
10
  fiuai_sdk_python/error.py,sha256=YYsqP39vY8N7wWD4ervsx7ngcdXIMR59Wc4A4h4Rb-k,235
@@ -15,11 +15,14 @@ fiuai_sdk_python/resp.py,sha256=4twCxmwqe2e1vlhfFnu-5_FR8PwQiveB9njY3exaQ88,1018
15
15
  fiuai_sdk_python/setup.py,sha256=ER0IPAouHhrVSzG0Iu87Ky0R5c4kCgOF77kRAOO-1MI,8025
16
16
  fiuai_sdk_python/type.py,sha256=vinZKflNvmQNhqO5mDARAE6O133k0LiR1s1ZvexN_q4,28940
17
17
  fiuai_sdk_python/util.py,sha256=x3TkNsC8_nzA-8x6ndIGrIpE9sRKpn3vlxnj2Hqpxwo,2326
18
- fiuai_sdk_python/auth/__init__.py,sha256=MvI8FZ96-etsNGaHk1XKsQHXGEw2j9Qd2k8lnEAx97M,585
19
- fiuai_sdk_python/auth/header.py,sha256=vMK9a2AP09zntDc4EqoZ4YMBM7Uas-T61bv62gJzPNo,3401
18
+ fiuai_sdk_python/auth/__init__.py,sha256=fcb8EwuKP341XJaYLlM8heMHLIctA6H_hbkakbUfrkg,920
19
+ fiuai_sdk_python/auth/context_mgr.py,sha256=s5h80Xky1YIWiMKasH3IJ62niolWBLLaJ8CIsExvkY0,8871
20
+ fiuai_sdk_python/auth/header.py,sha256=JoMT6OJQUU7laGP6235kolfG-Qy_AP6ZQQQ9Ca8U88c,3606
20
21
  fiuai_sdk_python/auth/helper.py,sha256=GeuRwOZivtIsAo4_XBt0PDDbOLntF2uxSrO9mPvVMBs,5014
21
- fiuai_sdk_python/auth/type.py,sha256=v3O6jlyNNek_praTCdIHhWBtQYgir13MLfEr0wJKCww,1841
22
+ fiuai_sdk_python/auth/type.py,sha256=lPUgbSOW-mQfOZpxPGhwDmpbKr5UfjznEuSKGwDosxY,2233
22
23
  fiuai_sdk_python/examples/fastapi_integration.py,sha256=6VonD8xkpwUFh3qwQh6mdHGbITscX-MMJF2k3Gj2bJc,1701
24
+ fiuai_sdk_python/http/__init__.py,sha256=RMtrf0O-iuAGMIqfpQg6sQHj_O9Lo4Lhn7KejcapZzo,692
25
+ fiuai_sdk_python/http/client.py,sha256=omcM8R8NGSszZlKBPikZr-NJXHgwpNYu7qDa_9H1Jug,14321
23
26
  fiuai_sdk_python/pkg/cache/__init__.py,sha256=7mVRUKkAxCAHIWVZrIslel_kr0S5Je_l0I9Fy_iZjzI,112
24
27
  fiuai_sdk_python/pkg/cache/redis_manager.py,sha256=APSRRmsJKRWfDvzIJiwNMtIqjzbheOdULskj5mI55Fk,6114
25
28
  fiuai_sdk_python/pkg/db/__init__.py,sha256=IK-zw5tTiSpVMtT3zdVGMaqup08TACIWcEYWpe1htkc,709
@@ -33,7 +36,7 @@ fiuai_sdk_python/utils/__init__.py,sha256=UwwsvqBsaRCHbWdx-wvM48szT3j50h95k9MZdb
33
36
  fiuai_sdk_python/utils/ids.py,sha256=ZDtEqt_Woth8ytPB2tdnnTIv7noWr8XYhSsUvkZ7Hc0,6448
34
37
  fiuai_sdk_python/utils/logger.py,sha256=OcH8l7nQdLOtsZW5s4lBkd6kxPK7oIWF6KdGazQIzzc,2675
35
38
  fiuai_sdk_python/utils/text.py,sha256=bnob_W0nj_Vj8Hp93B0cYmFOY8IhUWF0C8UedOYCNvs,1667
36
- fiuai_sdk_python-0.6.6.dist-info/METADATA,sha256=6d_UtJCCx_dJ8BsIOrAmjoqf99tpmVfJCesCkHiTSw0,1523
37
- fiuai_sdk_python-0.6.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
38
- fiuai_sdk_python-0.6.6.dist-info/licenses/LICENSE,sha256=PFMF0dFErrBFqU-rryEby0yW8GBagYqrdbyZQHMUCJg,1062
39
- fiuai_sdk_python-0.6.6.dist-info/RECORD,,
39
+ fiuai_sdk_python-0.6.7.dist-info/METADATA,sha256=F-yN6GFq3Aa4BibEspDm0iDd5r5zZ8QupcP4JurTekA,1523
40
+ fiuai_sdk_python-0.6.7.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
41
+ fiuai_sdk_python-0.6.7.dist-info/licenses/LICENSE,sha256=PFMF0dFErrBFqU-rryEby0yW8GBagYqrdbyZQHMUCJg,1062
42
+ fiuai_sdk_python-0.6.7.dist-info/RECORD,,