python-basekit 0.0.11__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.
basekit/limiter.py ADDED
@@ -0,0 +1,179 @@
1
+ import asyncio
2
+ import time
3
+ from collections import deque
4
+ from collections.abc import Awaitable, Callable, Coroutine
5
+ from contextlib import nullcontext
6
+ from functools import wraps
7
+ from typing import Annotated, Any, Literal, Self, override
8
+
9
+ from pydantic import BaseModel, Field, PrivateAttr
10
+
11
+
12
+ class AsyncRateLimiter(BaseModel):
13
+ limit: Annotated[int, Field(gt=0)]
14
+ window: Annotated[float, Field(gt=0)] = 60.0
15
+
16
+ _timestamps: deque[float] = PrivateAttr()
17
+ _lock: asyncio.Lock = PrivateAttr()
18
+ _waiters: deque[asyncio.Future[None]] = PrivateAttr()
19
+ _wake_task: asyncio.Task[None] | None = PrivateAttr()
20
+
21
+ @override
22
+ def model_post_init(self, context) -> None:
23
+ self._timestamps = deque()
24
+ self._lock = asyncio.Lock()
25
+ self._waiters = deque()
26
+ self._wake_task = None
27
+
28
+ def _evict(self, now: float, /) -> None:
29
+ cutoff: float = now - self.window
30
+ dq: deque[float] = self._timestamps
31
+
32
+ while dq and dq[0] < cutoff:
33
+ dq.popleft()
34
+
35
+ def _drain_waiters_unlocked(self, now: float, /) -> None:
36
+ self._evict(now)
37
+
38
+ while self._waiters and len(self._timestamps) < self.limit:
39
+ waiter: asyncio.Future[None] = self._waiters.popleft()
40
+ if waiter.done():
41
+ continue
42
+ self._timestamps.append(now)
43
+ waiter.set_result(None)
44
+
45
+ def _reschedule_wake_unlocked(self, now: float, /) -> None:
46
+ self._drain_waiters_unlocked(now)
47
+
48
+ if not self._waiters:
49
+ if self._wake_task is not None and not self._wake_task.done():
50
+ self._wake_task.cancel()
51
+ self._wake_task = None
52
+ return
53
+
54
+ if self._wake_task is not None and not self._wake_task.done():
55
+ return
56
+
57
+ wait: float = max(self._timestamps[0] + self.window - now, 0.0)
58
+ self._wake_task = asyncio.create_task(coro=self._wake_after(wait))
59
+
60
+ async def _wake_after(self, wait: float, /) -> None:
61
+ try:
62
+ await asyncio.sleep(delay=wait)
63
+ except asyncio.CancelledError:
64
+ return
65
+
66
+ async with self._lock:
67
+ if self._wake_task is not asyncio.current_task():
68
+ return
69
+ self._wake_task = None
70
+ self._reschedule_wake_unlocked(time.monotonic())
71
+
72
+ async def _acquire(self) -> None:
73
+ async with self._lock:
74
+ now: float = time.monotonic()
75
+ self._evict(now)
76
+ if not self._waiters and len(self._timestamps) < self.limit:
77
+ self._timestamps.append(now)
78
+ return
79
+
80
+ waiter: asyncio.Future[None] = asyncio.get_running_loop().create_future()
81
+ self._waiters.append(waiter)
82
+ self._reschedule_wake_unlocked(now)
83
+
84
+ try:
85
+ await waiter
86
+ except asyncio.CancelledError:
87
+ async with self._lock:
88
+ try:
89
+ self._waiters.remove(waiter)
90
+ except ValueError:
91
+ pass
92
+ self._reschedule_wake_unlocked(time.monotonic())
93
+ raise
94
+
95
+ async def __aenter__(self) -> Self:
96
+ await self._acquire()
97
+ return self
98
+
99
+ async def __aexit__(self, exc_type, exc, tb) -> None:
100
+ return
101
+
102
+ def __call__[T, **P](
103
+ self, func: Callable[P, Awaitable[T]], /
104
+ ) -> Callable[P, Coroutine[Any, Any, T]]:
105
+ @wraps(wrapped=func)
106
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
107
+ async with self:
108
+ return await func(*args, **kwargs)
109
+
110
+ return wrapper
111
+
112
+
113
+ class AsyncConcurrencyLimiter(BaseModel):
114
+ limit: Annotated[int, Field(gt=0)]
115
+
116
+ _semaphore: asyncio.Semaphore = PrivateAttr()
117
+
118
+ @override
119
+ def model_post_init(self, context) -> None:
120
+ self._semaphore = asyncio.Semaphore(value=self.limit)
121
+
122
+ async def __aenter__(self) -> Self:
123
+ await self._semaphore.__aenter__()
124
+ return self
125
+
126
+ async def __aexit__(self, exc_type, exc, tb) -> None:
127
+ await self._semaphore.__aexit__(exc_type=exc_type, exc=exc, tb=tb)
128
+
129
+ def __call__[T, **P](
130
+ self, func: Callable[P, Awaitable[T]], /
131
+ ) -> Callable[P, Coroutine[Any, Any, T]]:
132
+ @wraps(wrapped=func)
133
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
134
+ async with self:
135
+ return await func(*args, **kwargs)
136
+
137
+ return wrapper
138
+
139
+
140
+ class AsyncLimiter(BaseModel):
141
+ concurrency_limit: Annotated[int | None, Field(gt=0)]
142
+ rate_limit: Annotated[int | None, Field(gt=0)]
143
+ rate_window: Annotated[float, Field(gt=0)] = 60.0
144
+
145
+ _concurrency_limiter: AsyncConcurrencyLimiter | nullcontext = PrivateAttr()
146
+ _rate_limiter: AsyncRateLimiter | nullcontext = PrivateAttr()
147
+
148
+ @override
149
+ def model_post_init(self, context) -> None:
150
+ self._concurrency_limiter = (
151
+ AsyncConcurrencyLimiter(limit=self.concurrency_limit)
152
+ if self.concurrency_limit is not None
153
+ else nullcontext()
154
+ )
155
+ self._rate_limiter = (
156
+ AsyncRateLimiter(limit=self.rate_limit, window=self.rate_window)
157
+ if self.rate_limit is not None
158
+ else nullcontext()
159
+ )
160
+
161
+ async def __aenter__(self) -> Self:
162
+ await self._concurrency_limiter.__aenter__()
163
+ await self._rate_limiter.__aenter__()
164
+ return self
165
+
166
+ async def __aexit__(self, exc_type, exc, tb) -> Literal[False]:
167
+ await self._concurrency_limiter.__aexit__(exc_type, exc, tb)
168
+ await self._rate_limiter.__aexit__(exc_type, exc, tb)
169
+ return False
170
+
171
+ def __call__[T, **P](
172
+ self, func: Callable[P, Awaitable[T]], /
173
+ ) -> Callable[P, Coroutine[Any, Any, T]]:
174
+ @wraps(wrapped=func)
175
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
176
+ async with self:
177
+ return await func(*args, **kwargs)
178
+
179
+ return wrapper
basekit/py.typed ADDED
File without changes
basekit/utils/batch.py ADDED
@@ -0,0 +1,21 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable
3
+
4
+ import logfire
5
+
6
+
7
+ async def run_batch[T](coros: list[Awaitable[T]], /, *, strict: bool) -> list[T]:
8
+ if strict:
9
+ return await asyncio.gather(*coros)
10
+
11
+ results: list[T | BaseException] = await asyncio.gather(
12
+ *coros, return_exceptions=True
13
+ )
14
+ instances: list[T] = [r for r in results if not isinstance(r, BaseException)]
15
+ errors: list[BaseException] = [r for r in results if isinstance(r, BaseException)]
16
+ if errors:
17
+ exception: BaseExceptionGroup[BaseException] = BaseExceptionGroup(
18
+ f"批量运行协程失败,{len(instances)}成功,{len(errors)}失败", errors
19
+ )
20
+ logfire.exception(str(object=exception), _exc_info=exception)
21
+ return instances
@@ -0,0 +1,26 @@
1
+ from pydantic import BaseModel
2
+ from rich.console import Console
3
+ from rich.table import Table, box
4
+
5
+
6
+ def print_table[T: BaseModel](
7
+ console: Console, instances: list[T], /, *, title: str | None = None
8
+ ) -> None:
9
+ table = Table(title=title, box=box.SQUARE_DOUBLE_HEAD, show_lines=True)
10
+
11
+ if not instances:
12
+ table.add_column(header="无数据", justify="center", overflow="fold")
13
+ table.add_row("无数据")
14
+ console.print(table)
15
+ return
16
+
17
+ cls: type[T] = instances[0].__class__
18
+ for field in cls.model_fields.values():
19
+ table.add_column(header=field.description or "-", overflow="fold")
20
+ for instance in instances:
21
+ fields: list[str] = [
22
+ str(object=getattr(instance, field) or "-")
23
+ for field in cls.model_fields.keys()
24
+ ]
25
+ table.add_row(*fields)
26
+ console.print(table)
basekit/utils/html.py ADDED
@@ -0,0 +1,54 @@
1
+ from urllib.parse import urljoin, urlparse
2
+
3
+ from bs4 import BeautifulSoup, Tag
4
+ from bs4.element import AttributeValueList
5
+
6
+ from basekit.utils.misc import require_non_null, require_truthy
7
+
8
+
9
+ def join_url(url: str, base: str, /) -> str:
10
+ if urlparse(url=url).scheme:
11
+ return url
12
+ if url.startswith("//"):
13
+ return f"{urlparse(url=base).scheme}:{url}"
14
+ return urljoin(base=base, url=url)
15
+
16
+
17
+ def extract_attr(element: Tag, key: str, /) -> str | None:
18
+ value: str | AttributeValueList | None = element.get(key=key)
19
+ if value is None:
20
+ return None
21
+ if isinstance(value, str):
22
+ return value
23
+ return " ".join(value)
24
+
25
+
26
+ def has_class(element: Tag, class_name: str, /) -> bool:
27
+ classes: str | None = extract_attr(element, "class")
28
+ if classes is None:
29
+ return False
30
+ return class_name in classes.split(sep=" ")
31
+
32
+
33
+ def select_one(element: BeautifulSoup, selector: str, /) -> Tag:
34
+ result: Tag | None = element.select_one(selector=selector)
35
+ return require_non_null(result)
36
+
37
+
38
+ def select_many(element: BeautifulSoup, selector: str, /) -> list[Tag]:
39
+ results: list[Tag] = element.select(selector=selector)
40
+ return require_truthy(results)
41
+
42
+
43
+ def combine_htmls(*htmls: str) -> str:
44
+ contents: list[str] = []
45
+ for html in htmls:
46
+ soup = BeautifulSoup(markup=html, features="lxml")
47
+ for element in require_non_null(soup.body).children:
48
+ contents.append(str(object=element))
49
+ html: str = "".join(contents)
50
+ return BeautifulSoup(markup=html, features="lxml").decode()
51
+
52
+
53
+ def extract_mime(content_type: str, /) -> str:
54
+ return content_type.split(sep=";", maxsplit=1)[0].strip().lower()
basekit/utils/jinja.py ADDED
@@ -0,0 +1,20 @@
1
+ import re
2
+
3
+ from jinja2 import Environment
4
+
5
+
6
+ def split_sentences(text: str) -> list[str]:
7
+ if not text:
8
+ return []
9
+ return re.findall(pattern=r"[^。]+。?", string=text)
10
+
11
+
12
+ MARKDOWN_ENV: Environment = Environment(
13
+ autoescape=False, keep_trailing_newline=True, lstrip_blocks=True, trim_blocks=True
14
+ )
15
+ MARKDOWN_ENV.policies["json.dumps_kwargs"]["ensure_ascii"] = False
16
+ MARKDOWN_ENV.filters["split_sentences"] = split_sentences
17
+
18
+
19
+ def render_template(template: str, /, **kwargs) -> str:
20
+ return MARKDOWN_ENV.from_string(source=template).render(**kwargs)
@@ -0,0 +1,38 @@
1
+ from flowmark import reformat_text
2
+ from flowmark.linewrapping.markdown_filling import ListSpacing
3
+ from markdown_it import MarkdownIt
4
+ from markdown_it.tree import SyntaxTreeNode
5
+ from markdownify import markdownify
6
+
7
+ PARSER: MarkdownIt = (
8
+ MarkdownIt(config="commonmark")
9
+ .enable(names=["linkify", "replacements", "smartquotes"])
10
+ .enable(names=["table", "strikethrough"])
11
+ )
12
+
13
+
14
+ def format_markdown(markdown: str, /) -> str:
15
+ return reformat_text(
16
+ text=markdown, width=65535, semantic=False, list_spacing=ListSpacing.tight
17
+ )
18
+
19
+
20
+ def convert_markdown(html: str, /) -> str:
21
+ markdown: str = markdownify(
22
+ html=html, heading_style="ATX", bullets="-", wrap_width=65535
23
+ )
24
+ return format_markdown(markdown)
25
+
26
+
27
+ def parse_markdown(markdown: str, /) -> list[tuple[str, str]]:
28
+ lines: list[str] = markdown.splitlines(keepends=True)
29
+ root = SyntaxTreeNode(tokens=PARSER.parse(src=markdown))
30
+ blocks: list[tuple[str, str]] = []
31
+ for node in root.children:
32
+ if node.map is None:
33
+ continue
34
+ content: str = "".join(lines[node.map[0] : node.map[1]]).strip()
35
+ if not content:
36
+ continue
37
+ blocks.append((content, node.type))
38
+ return blocks
basekit/utils/mime.py ADDED
@@ -0,0 +1,39 @@
1
+ from mimetypes import guess_extension, guess_type
2
+
3
+ from filetype import Type
4
+ from filetype import guess as guess_filetype
5
+
6
+ DEFAULT_MIME = "application/octet-stream"
7
+ DEFAULT_SUFFIX = ".bin"
8
+
9
+
10
+ def guess_mime(
11
+ *, data: bytes | None = None, mime: str | None = None, suffix: str | None = None
12
+ ) -> str:
13
+ if data:
14
+ file_type: Type | None = guess_filetype(obj=data)
15
+ if file_type:
16
+ return file_type.mime
17
+ if mime:
18
+ return mime
19
+ if suffix:
20
+ suffix_mime: str | None = guess_type(url=f"test{suffix}")[0]
21
+ if suffix_mime:
22
+ return suffix_mime
23
+ return DEFAULT_MIME
24
+
25
+
26
+ def guess_suffix(
27
+ *, data: bytes | None = None, mime: str | None = None, suffix: str | None = None
28
+ ) -> str:
29
+ if data:
30
+ file_type: Type | None = guess_filetype(obj=data)
31
+ if file_type:
32
+ return f".{file_type.extension}"
33
+ if mime:
34
+ mime_suffix: str | None = guess_extension(type=mime)
35
+ if mime_suffix:
36
+ return mime_suffix
37
+ if suffix:
38
+ return suffix
39
+ return DEFAULT_SUFFIX
basekit/utils/misc.py ADDED
@@ -0,0 +1,35 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any
3
+
4
+
5
+ def require_non_null[T](v: T | None, /) -> T:
6
+ if v is None:
7
+ raise ValueError(f"期望非空值: {v}")
8
+ return v
9
+
10
+
11
+ def require_truthy[T](v: T | None, /) -> T:
12
+ if not v:
13
+ raise ValueError(f"期望真值: {v}")
14
+ return v
15
+
16
+
17
+ def remove_space(s: str, /) -> str:
18
+ return " ".join(s.split())
19
+
20
+
21
+ def recursive_merge(*args: Mapping | None) -> dict:
22
+ result: dict = {}
23
+
24
+ for mapping in args:
25
+ if mapping is None:
26
+ continue
27
+
28
+ for key, value in mapping.items():
29
+ current: Any | None = result.get(key)
30
+ if isinstance(current, Mapping) and isinstance(value, Mapping):
31
+ result[key] = recursive_merge(current, value)
32
+ else:
33
+ result[key] = value
34
+
35
+ return result
@@ -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,27 @@
1
+ basekit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ basekit/ai/clients/anthropic.py,sha256=iozRCRs3VC-JfZCRyiVPEIJ2vW-X4-IW0IcaCt5d5es,7620
3
+ basekit/ai/clients/dashscope.py,sha256=QD_lSmSJYGkRtOOjg-C5CBHh3rN7tWf_ySHdnPWQ0js,4869
4
+ basekit/ai/clients/gemini.py,sha256=MjM6JWzt7V4_zB3XR3_JtDRBzerUuDUjkRrsctiLTuQ,10795
5
+ basekit/ai/clients/openai.py,sha256=pDypO4DaGygZVLupoBFYFfC_9qbA5rLVVs4GFK1_oVY,15255
6
+ basekit/ai/schema.py,sha256=dTy3qff6YOUGBJN-fhhLjFEdcSGl_Agt35l1pb25JgU,5103
7
+ basekit/ai/utils.py,sha256=P1VtktxlGkQn0suqS4xU5McG1zBHt8Ft75h19Mpv0II,1926
8
+ basekit/cache/clients/sqlite.py,sha256=MpLInsD-xdE7-sIwAIJwhVrtMc2ASsTgthndo9mPumk,2981
9
+ basekit/cache/schema.py,sha256=VYsE6nE5zARp0EYDaF2eqmVPh0qJkGkkP3-hGwq-P4A,1641
10
+ basekit/cache/utils.py,sha256=hNQ7uqIadkMe6e6469UZXzCDYrPb0n52xwThEvFUQFk,3758
11
+ basekit/database.py,sha256=LRt7rJGlg-le_SHKEu7WfnGjPd32yb5GyB_WswoIErE,1003
12
+ basekit/http/clients/curl_cffi.py,sha256=G4FyvVVP3TR_K_6UZ4mGJaM--IYuBqzL0y1lCWRbH6M,3549
13
+ basekit/http/clients/httpx.py,sha256=NgWlBmx164Kjmh07rk86gzZI4BhFn2tr_dh6VN9Li28,3501
14
+ basekit/http/schema.py,sha256=P_QwrHWjsUEXmqx-rZXibrutA83HuOsVeH5OJfpB2bs,3061
15
+ basekit/http/utils.py,sha256=SRLexTifocJ98Utxb1YSHoM5LBFa2r9sbCSeCDHJcvQ,941
16
+ basekit/limiter.py,sha256=18CuaAppqcZVQUg7jHjC_ZnN-9Vx_0vB0VMqI4J2ekA,5885
17
+ basekit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ basekit/utils/batch.py,sha256=HNTc-12e3AkPC5dMUH2FbSinba2ZPxg9suPQeJd3utM,774
19
+ basekit/utils/console.py,sha256=LPx6y0qlCd927z6FNqs6_rdva9QIFOMDrw4OcrY4rDE,867
20
+ basekit/utils/html.py,sha256=RYg1oTe1J54eVNeiNiy8oMJZo-r4Tnz9yjdxU3Q4YKw,1676
21
+ basekit/utils/jinja.py,sha256=8eoKz4dF4iSyvi8yInvtpq8_UYYul2jBVQGM1SxxJk0,568
22
+ basekit/utils/markdown.py,sha256=lyDuvPhDxNUQzGWi2uP-F7qnaje9oizITH7FbZFikCo,1221
23
+ basekit/utils/mime.py,sha256=nOMeAtDkVQP1rPGM5P-kkP0GeLhWIdhKMgs4paNcr6I,1070
24
+ basekit/utils/misc.py,sha256=c5tkhPvyBpoHsvH4X7uuuit98AYEgAWwGU2EZyB4AGw,833
25
+ python_basekit-0.0.11.dist-info/WHEEL,sha256=AKeAJsuKQDVE1yEMr94YEC7FtwPuFZZw2DmbXPmgtaE,81
26
+ python_basekit-0.0.11.dist-info/METADATA,sha256=tkrxndeuUG_XmAyw8PjfL9PCy72rqWK-5xcZ8IU73XE,979
27
+ python_basekit-0.0.11.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.10
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any