python-basekit 0.0.11__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 (27) hide show
  1. python_basekit-0.0.11/PKG-INFO +46 -0
  2. python_basekit-0.0.11/README.md +19 -0
  3. python_basekit-0.0.11/pyproject.toml +92 -0
  4. python_basekit-0.0.11/src/basekit/__init__.py +0 -0
  5. python_basekit-0.0.11/src/basekit/ai/clients/anthropic.py +192 -0
  6. python_basekit-0.0.11/src/basekit/ai/clients/dashscope.py +133 -0
  7. python_basekit-0.0.11/src/basekit/ai/clients/gemini.py +277 -0
  8. python_basekit-0.0.11/src/basekit/ai/clients/openai.py +384 -0
  9. python_basekit-0.0.11/src/basekit/ai/schema.py +160 -0
  10. python_basekit-0.0.11/src/basekit/ai/utils.py +63 -0
  11. python_basekit-0.0.11/src/basekit/cache/clients/sqlite.py +90 -0
  12. python_basekit-0.0.11/src/basekit/cache/schema.py +53 -0
  13. python_basekit-0.0.11/src/basekit/cache/utils.py +113 -0
  14. python_basekit-0.0.11/src/basekit/database.py +33 -0
  15. python_basekit-0.0.11/src/basekit/http/clients/curl_cffi.py +99 -0
  16. python_basekit-0.0.11/src/basekit/http/clients/httpx.py +100 -0
  17. python_basekit-0.0.11/src/basekit/http/schema.py +111 -0
  18. python_basekit-0.0.11/src/basekit/http/utils.py +31 -0
  19. python_basekit-0.0.11/src/basekit/limiter.py +179 -0
  20. python_basekit-0.0.11/src/basekit/py.typed +0 -0
  21. python_basekit-0.0.11/src/basekit/utils/batch.py +21 -0
  22. python_basekit-0.0.11/src/basekit/utils/console.py +26 -0
  23. python_basekit-0.0.11/src/basekit/utils/html.py +54 -0
  24. python_basekit-0.0.11/src/basekit/utils/jinja.py +20 -0
  25. python_basekit-0.0.11/src/basekit/utils/markdown.py +38 -0
  26. python_basekit-0.0.11/src/basekit/utils/mime.py +39 -0
  27. python_basekit-0.0.11/src/basekit/utils/misc.py +35 -0
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.3
2
+ Name: python-basekit
3
+ Version: 0.0.11
4
+ Summary: Python Base Kit
5
+ Author: YanSH
6
+ Author-email: YanSH <yansh97@foxmail.com>
7
+ Requires-Dist: aiosqlite>=0.22.1
8
+ Requires-Dist: beautifulsoup4>=4.14.3
9
+ Requires-Dist: curl-cffi>=0.14.0
10
+ Requires-Dist: filetype>=1.2.0
11
+ Requires-Dist: flowmark>=0.6.5
12
+ Requires-Dist: greenlet>=3.3.2
13
+ Requires-Dist: httpx[http2]>=0.28.1
14
+ Requires-Dist: jinja2>=3.1.6
15
+ Requires-Dist: logfire>=4.27.0
16
+ Requires-Dist: lxml>=6.0.2
17
+ Requires-Dist: markdown-it-py>=4.0.0
18
+ Requires-Dist: markdownify>=1.2.2
19
+ Requires-Dist: pydantic>=2.12.5
20
+ Requires-Dist: pydantic-settings>=2.13.1
21
+ Requires-Dist: python-dotenv>=1.2.2
22
+ Requires-Dist: rich>=14.3.3
23
+ Requires-Dist: sqlalchemy>=2.0.48
24
+ Requires-Dist: tenacity>=9.1.4
25
+ Requires-Python: >=3.12
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Base Kit
29
+
30
+ 一个面向内部使用的 Python 基础工具包。
31
+
32
+ ## 环境
33
+
34
+ Python `>=3.12`
35
+
36
+ ## 安装
37
+
38
+ ```bash
39
+ uv add python-basekit
40
+ ```
41
+
42
+ ## 使用
43
+
44
+ ```python
45
+ import basekit
46
+ ```
@@ -0,0 +1,19 @@
1
+ # Base Kit
2
+
3
+ 一个面向内部使用的 Python 基础工具包。
4
+
5
+ ## 环境
6
+
7
+ Python `>=3.12`
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ uv add python-basekit
13
+ ```
14
+
15
+ ## 使用
16
+
17
+ ```python
18
+ import basekit
19
+ ```
@@ -0,0 +1,92 @@
1
+ [project]
2
+ name = "python-basekit"
3
+ version = "0.0.11"
4
+ description = "Python Base Kit"
5
+ readme = "README.md"
6
+ authors = [{ name = "YanSH", email = "yansh97@foxmail.com" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "aiosqlite>=0.22.1",
10
+ "beautifulsoup4>=4.14.3",
11
+ "curl-cffi>=0.14.0",
12
+ "filetype>=1.2.0",
13
+ "flowmark>=0.6.5",
14
+ "greenlet>=3.3.2",
15
+ "httpx[http2]>=0.28.1",
16
+ "jinja2>=3.1.6",
17
+ "logfire>=4.27.0",
18
+ "lxml>=6.0.2",
19
+ "markdown-it-py>=4.0.0",
20
+ "markdownify>=1.2.2",
21
+ "pydantic>=2.12.5",
22
+ "pydantic-settings>=2.13.1",
23
+ "python-dotenv>=1.2.2",
24
+ "rich>=14.3.3",
25
+ "sqlalchemy>=2.0.48",
26
+ "tenacity>=9.1.4",
27
+ ]
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=9.0.2", "pytest-asyncio>=1.3.0", "ruff>=0.15.5"]
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.11.10,<0.12.0"]
34
+ build-backend = "uv_build"
35
+
36
+ [tool.uv]
37
+ environments = ["sys_platform == 'darwin'", "sys_platform == 'linux'"]
38
+
39
+ [tool.uv.build-backend]
40
+ module-name = "basekit"
41
+
42
+ # ruff 配置
43
+ [tool.ruff]
44
+ cache-dir = ".cache/.ruff_cache"
45
+
46
+ # pytest 配置
47
+ [tool.pytest.ini_options]
48
+
49
+ # 测试文件查找路径
50
+ testpaths = ["tests"]
51
+
52
+ # 测试文件/类/函数的命名规则
53
+ python_files = ["test_*.py", "*_test.py"]
54
+ python_classes = ["Test*"]
55
+ python_functions = ["test_*"]
56
+
57
+ # pytest 缓存目录统一到 .cache/
58
+ cache_dir = ".cache/.pytest_cache"
59
+
60
+ # 异步测试配置
61
+ asyncio_mode = "auto"
62
+ asyncio_default_fixture_loop_scope = "function"
63
+ asyncio_default_test_loop_scope = "function"
64
+
65
+ # Logfire 测试配置
66
+ logfire = false
67
+
68
+ # pytest 命令行默认选项
69
+ addopts = "-ra --showlocals --strict-markers --strict-config --tb=auto --import-mode=importlib"
70
+
71
+ # 测试标记
72
+ markers = [
73
+ "zero_cost: 无成本测试,不调用 AI 实际接口",
74
+ "low_cost: 低成本测试,仅 create_message 相关",
75
+ "high_cost: 高成本测试,如 structured/tool_use/image 等",
76
+ ]
77
+
78
+ # 警告视为错误,早发现潜在问题
79
+ filterwarnings = "error"
80
+
81
+ # 进度条风格
82
+ console_output_style = "progress"
83
+
84
+ # pyright 配置
85
+ [tool.pyright]
86
+
87
+ # 与项目运行时版本保持一致,避免误报语法不支持
88
+ pythonVersion = "3.12"
89
+
90
+ # 虚拟环境路径
91
+ venvPath = "."
92
+ venv = ".venv"
File without changes
@@ -0,0 +1,192 @@
1
+ from collections.abc import Callable
2
+ from typing import ClassVar, override
3
+
4
+ import logfire
5
+ from httpx import Response
6
+ from pydantic import BaseModel
7
+
8
+ from basekit.ai.schema import AIClient, Tool, Usage, ValidationError
9
+ from basekit.ai.utils import transform_schema
10
+ from basekit.utils.misc import recursive_merge
11
+
12
+
13
+ class AnthropicClient(AIClient):
14
+ cache_key_fields: ClassVar[tuple[str, ...]] = ("max_tokens",)
15
+
16
+ api_key: str
17
+ base_url: str = "https://api.anthropic.com"
18
+ max_tokens: int = 16000
19
+
20
+ def _headers(self) -> dict:
21
+ return {
22
+ "X-Api-Key": self.api_key,
23
+ "Anthropic-Version": "2023-06-01",
24
+ "Content-Type": "application/json",
25
+ }
26
+
27
+ def _message_url(self) -> str:
28
+ return f"{self.base_url}/v1/messages"
29
+
30
+ def _message_params(self, system: str, prompt: str, /) -> dict:
31
+ return {
32
+ "model": self.model.request_name,
33
+ "system": system,
34
+ "messages": [{"role": "user", "content": prompt}],
35
+ "max_tokens": self.max_tokens,
36
+ }
37
+
38
+ def _structured_message_params(self, schema: dict, /) -> dict:
39
+ return {"output_config": {"format": {"type": "json_schema", "schema": schema}}}
40
+
41
+ def _tool_use_params(self, tool: dict, /) -> dict:
42
+ return {"tools": [tool], "tool_choice": {"type": "any"}}
43
+
44
+ def _get_block(
45
+ self, data: dict, stop_reason: str, filter_: Callable[[dict], bool], /
46
+ ) -> dict:
47
+ if data["stop_reason"] != stop_reason:
48
+ raise ValueError(f"STOP REASON:{data['stop_reason']}")
49
+
50
+ blocks: list[dict] = [c for c in data["content"] if filter_(c)]
51
+ if len(blocks) != 1:
52
+ raise ValueError(f"LEN(BLOCKS):{len(blocks)}")
53
+
54
+ return blocks[0]
55
+
56
+ def _get_usage(self, data: dict, /) -> Usage:
57
+ usage: dict = data["usage"]
58
+ cache_creation_tokens: int = usage.get("cache_creation_input_tokens", 0)
59
+ cache_read_tokens: int = usage.get("cache_read_input_tokens", 0)
60
+ return Usage(
61
+ input_tokens=usage["input_tokens"]
62
+ + cache_creation_tokens
63
+ + cache_read_tokens,
64
+ cache_creation_tokens=cache_creation_tokens,
65
+ cache_read_tokens=cache_read_tokens,
66
+ output_tokens=usage["output_tokens"],
67
+ )
68
+
69
+ @override
70
+ async def _create_message_once(
71
+ self, system: str, prompt: str, extra: dict | None
72
+ ) -> str:
73
+ with logfire.span("anthropic client | create message") as span:
74
+ span.set_attribute(key="model", value=self.model)
75
+ span.set_attribute(key="system", value=system)
76
+ span.set_attribute(key="prompt", value=prompt)
77
+ span.set_attribute(key="extra", value=extra)
78
+
79
+ async with self._limiter:
80
+ response: Response = await self._http_client.post(
81
+ self._message_url(),
82
+ headers=self._headers(),
83
+ data=recursive_merge(
84
+ self._message_params(system, prompt),
85
+ self.model.extra_params,
86
+ extra,
87
+ ),
88
+ )
89
+
90
+ try:
91
+ data: dict = response.json()
92
+ block: dict = self._get_block(
93
+ data, "end_turn", lambda x: x["type"] == "text"
94
+ )
95
+ message: str = block["text"]
96
+ usage: Usage = self._get_usage(data)
97
+
98
+ span.set_attribute(key="return", value=message)
99
+ span.set_attribute(key="usage", value=usage)
100
+ span.message = f"{span.message} -> {usage.format()} tokens"
101
+ return message
102
+ except Exception as exc:
103
+ raise ValidationError("生成消息失败") from exc
104
+
105
+ @override
106
+ async def _create_structured_message_once[T: BaseModel](
107
+ self, system: str, prompt: str, schema: type[T], extra: dict | None
108
+ ) -> T:
109
+ schema_name: str = schema.__name__
110
+ with logfire.span(
111
+ f"anthropic client | create structured message | {schema_name}"
112
+ ) as span:
113
+ schema_data: dict = transform_schema(schema.model_json_schema())
114
+
115
+ span.set_attribute(key="model", value=self.model)
116
+ span.set_attribute(key="system", value=system)
117
+ span.set_attribute(key="prompt", value=prompt)
118
+ span.set_attribute(key="schema", value=schema_data)
119
+ span.set_attribute(key="extra", value=extra)
120
+
121
+ async with self._limiter:
122
+ response: Response = await self._http_client.post(
123
+ self._message_url(),
124
+ headers=self._headers(),
125
+ data=recursive_merge(
126
+ self._message_params(system, prompt),
127
+ self._structured_message_params(schema_data),
128
+ self.model.extra_params,
129
+ extra,
130
+ ),
131
+ )
132
+
133
+ try:
134
+ data: dict = response.json()
135
+ block: dict = self._get_block(
136
+ data, "end_turn", lambda x: x["type"] == "text"
137
+ )
138
+ result: T = schema.model_validate_json(json_data=block["text"])
139
+ usage: Usage = self._get_usage(data)
140
+
141
+ span.set_attribute(key="return", value=result)
142
+ span.set_attribute(key="usage", value=usage)
143
+ span.message = f"{span.message} -> {usage.format()} tokens"
144
+ return result
145
+ except Exception as exc:
146
+ raise ValidationError("生成结构化消息失败") from exc
147
+
148
+ @override
149
+ async def _create_tool_use_once[T: BaseModel](
150
+ self, system: str, prompt: str, tool: Tool[T], extra: dict | None
151
+ ) -> T:
152
+ tool_name: str = tool.name
153
+ with logfire.span(f"anthropic client | create tool use | {tool_name}") as span:
154
+ tool_: dict = {
155
+ "name": tool.name,
156
+ "description": tool.description,
157
+ "input_schema": transform_schema(tool.input_schema.model_json_schema()),
158
+ "strict": True,
159
+ }
160
+
161
+ span.set_attribute(key="model", value=self.model)
162
+ span.set_attribute(key="system", value=system)
163
+ span.set_attribute(key="prompt", value=prompt)
164
+ span.set_attribute(key="tool", value=tool_)
165
+ span.set_attribute(key="extra", value=extra)
166
+
167
+ async with self._limiter:
168
+ response: Response = await self._http_client.post(
169
+ self._message_url(),
170
+ headers=self._headers(),
171
+ data=recursive_merge(
172
+ self._message_params(system, prompt),
173
+ self._tool_use_params(tool_),
174
+ self.model.extra_params,
175
+ extra,
176
+ ),
177
+ )
178
+
179
+ try:
180
+ data: dict = response.json()
181
+ block: dict = self._get_block(
182
+ data, "tool_use", lambda x: x["type"] == "tool_use"
183
+ )
184
+ result: T = tool.input_schema.model_validate(obj=block["input"])
185
+ usage: Usage = self._get_usage(data)
186
+
187
+ span.set_attribute(key="return", value=result)
188
+ span.set_attribute(key="usage", value=usage)
189
+ span.message = f"{span.message} -> {usage.format()} tokens"
190
+ return result
191
+ except Exception as exc:
192
+ raise ValidationError("生成工具调用失败") from exc
@@ -0,0 +1,133 @@
1
+ from collections.abc import Callable
2
+ from typing import ClassVar, override
3
+
4
+ import logfire
5
+ from httpx import Response
6
+ from pydantic import BaseModel
7
+
8
+ from basekit.ai.schema import (
9
+ AIClient,
10
+ ImageRatio,
11
+ ImageSize,
12
+ NotSupportedError,
13
+ ValidationError,
14
+ )
15
+ from basekit.utils.misc import recursive_merge
16
+
17
+
18
+ class ImageUsage(BaseModel):
19
+ width: int
20
+ height: int
21
+
22
+ def format(self) -> str:
23
+ return f"{self.width}x{self.height}"
24
+
25
+
26
+ class DashScopeClient(AIClient):
27
+ cache_key_fields: ClassVar[tuple[str, ...]] = ()
28
+
29
+ api_key: str
30
+ base_url: str = "https://dashscope.aliyuncs.com/api/v1"
31
+
32
+ def _headers(self) -> dict:
33
+ return {
34
+ "Authorization": f"Bearer {self.api_key}",
35
+ "Content-Type": "application/json",
36
+ }
37
+
38
+ def _image_url(self) -> str:
39
+ return f"{self.base_url}/services/aigc/multimodal-generation/generation"
40
+
41
+ def _image_resolution(self, ratio: ImageRatio, size: ImageSize, /) -> str:
42
+ base_resolution_map: dict[ImageRatio, tuple[int, int]] = {
43
+ "9:16": (768, 1344),
44
+ "2:3": (832, 1248),
45
+ "3:4": (864, 1184),
46
+ "4:5": (896, 1152),
47
+ "1:1": (1024, 1024),
48
+ "5:4": (1152, 896),
49
+ "4:3": (1184, 864),
50
+ "3:2": (1248, 832),
51
+ "16:9": (1344, 768),
52
+ }
53
+ resolution_ratio_map: dict[ImageSize, int] = {"1K": 1, "2K": 2, "4K": 4}
54
+
55
+ width: int = base_resolution_map[ratio][0] * resolution_ratio_map[size]
56
+ height: int = base_resolution_map[ratio][1] * resolution_ratio_map[size]
57
+ resolution: str = f"{width}*{height}"
58
+
59
+ match self.model.name:
60
+ case "qwen-image-2.0" | "qwen-image-2.0-pro":
61
+ if size not in {"1K", "2K"}:
62
+ raise NotSupportedError(
63
+ f"模型 {self.model.name} 仅支持 1K 和 2K 分辨率"
64
+ )
65
+ return resolution
66
+ case _:
67
+ raise NotSupportedError(f"模型 {self.model.name} 不合法")
68
+
69
+ def _image_params(self, prompt: str, resolution: str, /) -> dict:
70
+ return {
71
+ "model": self.model.request_name,
72
+ "input": {"messages": [{"role": "user", "content": [{"text": prompt}]}]},
73
+ "parameters": {"size": resolution},
74
+ }
75
+
76
+ def _get_block(self, data: dict, filter_: Callable[[dict], bool], /) -> dict:
77
+ choices: list = data["output"]["choices"]
78
+ if len(choices) != 1:
79
+ raise ValueError(f"LEN(CHOICES):{len(choices)}")
80
+
81
+ choice: dict = choices[0]
82
+ if choice["finish_reason"] != "stop":
83
+ raise ValueError(f"FINISH REASON:{choice['finish_reason']}")
84
+
85
+ blocks: list[dict] = [c for c in choice["message"]["content"] if filter_(c)]
86
+ if not blocks:
87
+ raise ValueError(f"LEN(BLOCKS):{len(blocks)}")
88
+
89
+ return blocks[0]
90
+
91
+ def _get_usage(self, data: dict, /) -> ImageUsage:
92
+ usage: dict = data["usage"]
93
+ return ImageUsage(width=usage["width"], height=usage["height"])
94
+
95
+ @override
96
+ async def _create_image_once(
97
+ self, prompt: str, ratio: ImageRatio, size: ImageSize, extra: dict | None
98
+ ) -> bytes:
99
+ with logfire.span("dashscope client | create image") as span:
100
+ resolution: str = self._image_resolution(ratio, size)
101
+
102
+ span.set_attribute(key="model", value=self.model)
103
+ span.set_attribute(key="prompt", value=prompt)
104
+ span.set_attribute(key="ratio", value=ratio)
105
+ span.set_attribute(key="size", value=size)
106
+ span.set_attribute(key="extra", value=extra)
107
+ span.set_attribute(key="resolution", value=resolution)
108
+
109
+ async with self._limiter:
110
+ response: Response = await self._http_client.post(
111
+ self._image_url(),
112
+ headers=self._headers(),
113
+ data=recursive_merge(
114
+ self._image_params(prompt, resolution),
115
+ self.model.extra_params,
116
+ extra,
117
+ ),
118
+ )
119
+
120
+ try:
121
+ data: dict = response.json()
122
+ block: dict = self._get_block(data, lambda c: "image" in c)
123
+ image_url: str = block["image"]
124
+ image: bytes = (await self._http_client.get(image_url)).content
125
+ usage: ImageUsage = self._get_usage(data)
126
+
127
+ span.set_attribute(key="image_url", value=image_url)
128
+ span.set_attribute(key="filesize", value=len(image))
129
+ span.set_attribute(key="usage", value=usage)
130
+ span.message = f"{span.message} -> {usage.format()} pixels"
131
+ return image
132
+ except Exception as exc:
133
+ raise ValidationError("生成图像失败") from exc