seerapi 103.2.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.
- seerapi/__init__.py +58 -0
- seerapi/_client.py +168 -0
- seerapi/_client.pyi +855 -0
- seerapi/_model_map.py +60 -0
- seerapi/_models.py +27 -0
- seerapi/_typing.py +125 -0
- seerapi-103.2.0.dist-info/METADATA +374 -0
- seerapi-103.2.0.dist-info/RECORD +11 -0
- seerapi-103.2.0.dist-info/WHEEL +5 -0
- seerapi-103.2.0.dist-info/licenses/LICENSE +21 -0
- seerapi-103.2.0.dist-info/top_level.txt +1 -0
seerapi/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Callable, Coroutine
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, ParamSpec, TypeVar
|
|
5
|
+
|
|
6
|
+
from ._client import SeerAPI
|
|
7
|
+
from ._models import PagedResponse, PageInfo
|
|
8
|
+
|
|
9
|
+
P = ParamSpec('P')
|
|
10
|
+
T = TypeVar('T')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def async_to_sync(async_func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
|
|
14
|
+
"""
|
|
15
|
+
将异步函数包装为同步函数的装饰器。
|
|
16
|
+
|
|
17
|
+
这个装饰器会创建一个新的事件循环来运行异步函数,避免与现有事件循环冲突。
|
|
18
|
+
适用于在同步代码中调用异步函数的场景。
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
async_func: 要包装的异步函数
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
返回一个同步版本的函数,调用时会自动处理事件循环
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
@async_to_sync
|
|
29
|
+
async def fetch_data(url: str) -> dict:
|
|
30
|
+
async with httpx.AsyncClient() as client:
|
|
31
|
+
response = await client.get(url)
|
|
32
|
+
return response.json()
|
|
33
|
+
|
|
34
|
+
# 可以像普通同步函数一样调用
|
|
35
|
+
data = fetch_data("https://api.example.com/data")
|
|
36
|
+
```
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@wraps(async_func)
|
|
40
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
41
|
+
# 尝试获取当前运行的事件循环
|
|
42
|
+
try:
|
|
43
|
+
_ = asyncio.get_running_loop()
|
|
44
|
+
except RuntimeError:
|
|
45
|
+
# 没有运行中的事件循环,使用 asyncio.run
|
|
46
|
+
return asyncio.run(async_func(*args, **kwargs))
|
|
47
|
+
else:
|
|
48
|
+
# 已经在事件循环中运行,需要在新线程中创建新的事件循环
|
|
49
|
+
import concurrent.futures
|
|
50
|
+
|
|
51
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
52
|
+
future = executor.submit(asyncio.run, async_func(*args, **kwargs))
|
|
53
|
+
return future.result()
|
|
54
|
+
|
|
55
|
+
return wrapper
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ['PageInfo', 'PagedResponse', 'SeerAPI', 'async_to_sync']
|
seerapi/_client.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from typing import cast
|
|
3
|
+
from typing_extensions import Self
|
|
4
|
+
|
|
5
|
+
from hishel.httpx import AsyncCacheClient
|
|
6
|
+
from httpx import URL
|
|
7
|
+
from httpx._urls import QueryParams
|
|
8
|
+
from seerapi_models.common import NamedData, ResourceRef
|
|
9
|
+
|
|
10
|
+
from seerapi._model_map import (
|
|
11
|
+
MODEL_MAP,
|
|
12
|
+
ModelName,
|
|
13
|
+
)
|
|
14
|
+
from seerapi._models import PagedResponse, PageInfo
|
|
15
|
+
from seerapi._typing import (
|
|
16
|
+
NamedResourceArg,
|
|
17
|
+
ResourceArg,
|
|
18
|
+
T_ModelInstance,
|
|
19
|
+
T_NamedModelInstance,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_url_params(url: str) -> QueryParams:
|
|
24
|
+
return URL(url=url).params
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_url_page_info(url: str) -> PageInfo | None:
|
|
28
|
+
if url is None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
params = _parse_url_params(url)
|
|
32
|
+
if 'offset' not in params or 'limit' not in params:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
return PageInfo(
|
|
36
|
+
offset=int(params['offset']),
|
|
37
|
+
limit=int(params['limit']),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SeerAPI:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
scheme: str = 'https',
|
|
46
|
+
hostname: str = 'api.seerapi.com',
|
|
47
|
+
version_path: str = 'v1',
|
|
48
|
+
) -> None:
|
|
49
|
+
self.scheme: str = scheme
|
|
50
|
+
self.hostname: str = hostname
|
|
51
|
+
self.version_path: str = version_path
|
|
52
|
+
self.base_url: URL = URL(url=f'{scheme}://{hostname}/{version_path}')
|
|
53
|
+
self._client = AsyncCacheClient(base_url=self.base_url)
|
|
54
|
+
|
|
55
|
+
async def __aenter__(self) -> Self:
|
|
56
|
+
"""进入异步上下文管理器"""
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
60
|
+
"""退出异步上下文管理器,关闭客户端连接"""
|
|
61
|
+
await self.aclose()
|
|
62
|
+
|
|
63
|
+
async def aclose(self) -> None:
|
|
64
|
+
"""关闭客户端连接并释放资源"""
|
|
65
|
+
await self._client.aclose()
|
|
66
|
+
|
|
67
|
+
def _get_resource_name_from_ref(self, ref: ResourceRef[T_ModelInstance]) -> str:
|
|
68
|
+
ref_url = URL(ref.url)
|
|
69
|
+
relative_path = ref_url.path.removeprefix(self.base_url.path)
|
|
70
|
+
return relative_path.strip('/').split('/')[0]
|
|
71
|
+
|
|
72
|
+
def _get_resource_name(
|
|
73
|
+
self, resource_name: ResourceArg[T_ModelInstance]
|
|
74
|
+
) -> ModelName:
|
|
75
|
+
if isinstance(resource_name, str):
|
|
76
|
+
name = resource_name
|
|
77
|
+
elif isinstance(resource_name, ResourceRef):
|
|
78
|
+
name = self._get_resource_name_from_ref(resource_name)
|
|
79
|
+
elif isinstance(resource_name, type):
|
|
80
|
+
name = resource_name.resource_name()
|
|
81
|
+
if name not in MODEL_MAP:
|
|
82
|
+
raise ValueError(f'Invalid resource name: {name}')
|
|
83
|
+
|
|
84
|
+
return name
|
|
85
|
+
|
|
86
|
+
async def get(
|
|
87
|
+
self,
|
|
88
|
+
resource_name: ResourceArg[T_ModelInstance],
|
|
89
|
+
id: int | None = None,
|
|
90
|
+
) -> T_ModelInstance:
|
|
91
|
+
if id is None and not isinstance(resource_name, ResourceRef):
|
|
92
|
+
raise ValueError('id is required')
|
|
93
|
+
|
|
94
|
+
res_name = self._get_resource_name(resource_name)
|
|
95
|
+
if isinstance(resource_name, ResourceRef):
|
|
96
|
+
id = resource_name.id
|
|
97
|
+
|
|
98
|
+
model_type = MODEL_MAP[res_name]
|
|
99
|
+
response = await self._client.get(f'/{res_name}/{id}')
|
|
100
|
+
response.raise_for_status()
|
|
101
|
+
return cast(T_ModelInstance, model_type.model_validate(response.json()))
|
|
102
|
+
|
|
103
|
+
async def paginated_list(
|
|
104
|
+
self,
|
|
105
|
+
resource_name: ResourceArg[T_ModelInstance],
|
|
106
|
+
page_info: PageInfo,
|
|
107
|
+
) -> PagedResponse[T_ModelInstance]:
|
|
108
|
+
res_name = self._get_resource_name(resource_name)
|
|
109
|
+
|
|
110
|
+
async def create_generator(
|
|
111
|
+
data: list[dict],
|
|
112
|
+
) -> AsyncGenerator[T_ModelInstance, None]:
|
|
113
|
+
for item in data:
|
|
114
|
+
yield await self.get(res_name, item['id'])
|
|
115
|
+
|
|
116
|
+
response = await self._client.get(
|
|
117
|
+
f'/{res_name}/',
|
|
118
|
+
params={'offset': page_info.offset, 'limit': page_info.limit},
|
|
119
|
+
)
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
response_json = response.json()
|
|
122
|
+
return PagedResponse(
|
|
123
|
+
count=response_json['count'],
|
|
124
|
+
results=create_generator(response_json['results']),
|
|
125
|
+
next=_parse_url_page_info(response_json['next']),
|
|
126
|
+
previous=_parse_url_page_info(response_json['previous']),
|
|
127
|
+
first=_parse_url_page_info(response_json['first']),
|
|
128
|
+
last=_parse_url_page_info(response_json['last']),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def list(
|
|
132
|
+
self, resource_name: ResourceArg[T_ModelInstance]
|
|
133
|
+
) -> AsyncGenerator[T_ModelInstance, None]:
|
|
134
|
+
"""获取所有资源的异步生成器,自动处理分页"""
|
|
135
|
+
res_name = self._get_resource_name(resource_name)
|
|
136
|
+
|
|
137
|
+
async def create_generator(page_info: PageInfo):
|
|
138
|
+
while True:
|
|
139
|
+
paged_response = await self.paginated_list(res_name, page_info)
|
|
140
|
+
|
|
141
|
+
# 生成当前页的所有结果
|
|
142
|
+
async for item in paged_response.results:
|
|
143
|
+
yield item
|
|
144
|
+
|
|
145
|
+
# 检查是否还有下一页
|
|
146
|
+
if paged_response.next is None:
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
# 更新到下一页
|
|
150
|
+
page_info = paged_response.next
|
|
151
|
+
|
|
152
|
+
return create_generator(PageInfo(offset=0, limit=10))
|
|
153
|
+
|
|
154
|
+
async def get_by_name(
|
|
155
|
+
self, resource_name: NamedResourceArg[T_NamedModelInstance], name: str
|
|
156
|
+
) -> NamedData[T_NamedModelInstance]:
|
|
157
|
+
res_name = self._get_resource_name(resource_name)
|
|
158
|
+
model_type = MODEL_MAP[res_name]
|
|
159
|
+
response = await self._client.get(f'/{res_name}/{name}')
|
|
160
|
+
response.raise_for_status()
|
|
161
|
+
return NamedData.model_validate(
|
|
162
|
+
{
|
|
163
|
+
'data': {
|
|
164
|
+
id: model_type.model_validate(item)
|
|
165
|
+
for id, item in response.json()['data'].items()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
)
|