lush-stdx 0.1.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.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.3
2
+ Name: lush-stdx
3
+ Version: 0.1.0
4
+ Summary: see README.md
5
+ Author: straydragon
6
+ Requires-Dist: pydantic>=2.11.0,<3.0.0
7
+ Requires-Dist: typing-extensions>=4.7.1
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+
11
+ # lush-stdx
12
+
13
+ 一些我经常复用的标准库小工具. 没有大而全的野心,只要它们还能保持“小”,就放这里.
14
+
15
+ ## 例子
16
+
17
+ ```python
18
+ from lush_stdx.langx import OptionT
19
+ from lush_stdx.urllibx import url_update_params
20
+
21
+ box = OptionT("hello")
22
+ assert box.unwrap() == "hello"
23
+
24
+ url = url_update_params("https://example.com?a=1", {"b": "2"})
25
+ assert url == "https://example.com?a=1&b=2"
26
+ ```
27
+
28
+ ## 开发
29
+
30
+ ```bash
31
+ uv sync -p 3.10 --frozen
32
+ uv run -p 3.10 pytest
33
+ ```
@@ -0,0 +1,23 @@
1
+ # lush-stdx
2
+
3
+ 一些我经常复用的标准库小工具. 没有大而全的野心,只要它们还能保持“小”,就放这里.
4
+
5
+ ## 例子
6
+
7
+ ```python
8
+ from lush_stdx.langx import OptionT
9
+ from lush_stdx.urllibx import url_update_params
10
+
11
+ box = OptionT("hello")
12
+ assert box.unwrap() == "hello"
13
+
14
+ url = url_update_params("https://example.com?a=1", {"b": "2"})
15
+ assert url == "https://example.com?a=1&b=2"
16
+ ```
17
+
18
+ ## 开发
19
+
20
+ ```bash
21
+ uv sync -p 3.10 --frozen
22
+ uv run -p 3.10 pytest
23
+ ```
@@ -0,0 +1,239 @@
1
+ [project]
2
+ name = "lush-stdx"
3
+ version = "0.1.0"
4
+ description = "see README.md"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "straydragon" }]
8
+ dependencies = [
9
+ "pydantic>=2.11.0,<3.0.0",
10
+ "typing-extensions>=4.7.1",
11
+ ]
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.10.12,<0.11.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [tool.uv]
18
+ package = true
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "freezegun>=1.5.5",
23
+ "pytest>=8.4.1",
24
+ "pytest-asyncio>=1.1.0",
25
+ "pytest-cov>=6.2.1",
26
+ ]
27
+
28
+ [tool.pytest.ini_options]
29
+ addopts = "--import-mode=importlib --cov=lush_stdx --cov-report=term-missing"
30
+ testpaths = ["tests"]
31
+ asyncio_mode = "auto"
32
+ asyncio_default_fixture_loop_scope = "function"
33
+ asyncio_default_test_loop_scope = "function"
34
+
35
+ [tool.ruff]
36
+ line-length = 140
37
+ indent-width = 4
38
+ target-version = "py310"
39
+
40
+ [tool.ruff.lint]
41
+ fixable = ["F401", "ALL"]
42
+ unfixable = []
43
+
44
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
45
+
46
+ select = ["ALL"]
47
+ ignore = [
48
+ # === 代码质量与复杂性 ===
49
+ "C901", # 函数复杂度过高,影响可读性
50
+ "PLR0912", # 函数分支过多,建议重构
51
+ "PLR0913", # 函数参数过多,难以维护
52
+ "PLR0915", # 函数语句过多,建议拆分
53
+ "PLR0911", # 函数返回语句过多,逻辑复杂
54
+ "PLR2004", # 使用魔术数值,建议使用常量
55
+ "SIM108", # 更倾向于使用ifelse表达式,而不是if/else语句
56
+
57
+ # === 类型注解 ===
58
+ "ANN002", # 函数*args参数缺少类型注解
59
+ "ANN003", # 函数**kwargs参数缺少类型注解
60
+ "ANN401", # 使用Any类型,建议使用具体类型
61
+
62
+ # === 函数参数设计 ===
63
+ "FBT001", # 布尔类型位置参数,易混淆
64
+ "FBT002", # 布尔默认值位置参数,易误用
65
+ "N805", # 与pydantic field_validator冲突(cls->self X), 先忽略, 后面再处理
66
+
67
+ # === 异常处理 ===
68
+ "BLE001", # 捕获所有异常,隐藏问题
69
+ "TRY003", # 异常消息应在类中定义
70
+ "TRY004", # 类型检查应抛出TypeError
71
+
72
+ # === 代码简化 ===
73
+ "SIM102", # 可折叠的if语句,简化代码
74
+
75
+ # === 格式规范 ===
76
+ "COM812", # 缺少尾随逗号,影响diff
77
+ "E501", # 行长度过长,影响可读性
78
+
79
+ # === 日志记录 ===
80
+ "G004", # 日志使用f-string,影响性能
81
+
82
+ # === 类型检查优化 ===
83
+ "TC001", # 仅用于类型检查的导入,优化性能
84
+ "TC002", # 仅用于类型检查的导入,优化性能
85
+ "TC003", # 仅用于类型检查的导入,优化性能
86
+
87
+ # === 代码清理 ===
88
+ "ERA001", # 注释掉的代码,应删除
89
+
90
+ # === 私有成员访问 ===
91
+ "SLF001", # 访问私有成员,破坏封装
92
+
93
+ # === 日期时间处理 ===
94
+ "DTZ001", # datetime缺少时区信息
95
+ "DTZ005", # datetime.now()缺少时区
96
+
97
+ # === 调试代码 ===
98
+ "T201", # print语句调试代码,生产环境禁用
99
+
100
+ # === T O D O管理 ===
101
+ "TD002", # 缺少作者信息
102
+ "TD003", # 缺少问题链接
103
+
104
+ # === 其他 ===
105
+ "D", # 文档字符串相关规则
106
+ "EM", # 错误消息相关规则
107
+ ]
108
+
109
+ [tool.ruff.lint.per-file-ignores]
110
+ "tests/**/*.py" = [
111
+ # === 测试代码质量 ===
112
+ "B011", # assert False应改为raise AssertionError
113
+ "B008", # 函数调用作为默认参数
114
+ "ARG001", # 未使用的函数参数
115
+ "ARG002", # 未使用的方法参数
116
+ "ARG005", # lambda中未使用的参数
117
+ "F841", # 未使用的变量
118
+ "B018", # 无用的表达式
119
+ "N806", # 函数中非小写变量名
120
+ "W293", # 空行包含空格
121
+ "N802", # 函数名不规范
122
+ "PERF401", # 性能相关
123
+ "PT", # assert 相关
124
+
125
+ # === 测试安全相关 ===
126
+ "S101", # assert语句(测试中使用)
127
+ "S105", # 硬编码密码字符串
128
+ "S201", # Flask debug=True
129
+ "S301", # 可疑的pickle使用
130
+ "S311", # 非加密安全的随机数
131
+
132
+ # === 测试异常处理 ===
133
+ "BLE001", # 捕获所有异常
134
+ "B017", # assertRaises捕获Exception
135
+ "PT011", # pytest.raises缺少match参数
136
+ "PT017", # except中的assert语句
137
+ "EM101", # 异常中的原始字符串
138
+
139
+ # === 测试代码复杂度 ===
140
+ "C901", # 函数复杂度过高
141
+ "PLR2004", # 魔术数值
142
+
143
+ # === 测试导入相关 ===
144
+ "PLC0415", # 函数内import语句
145
+ "ANN", # 类型注解(测试中宽松)
146
+ "TC", # 类型检查的导入
147
+
148
+ # === 测试参数设计 ===
149
+ "FBT001", # 布尔类型位置参数
150
+ "FBT002", # 布尔默认值位置参数
151
+ "FBT003", # 布尔位置参数调用
152
+
153
+ # === 测试异常处理 ===
154
+ "TRY003", # 异常消息定义
155
+
156
+ # === 测试代码简化 ===
157
+ "SIM117", # 多个连续的with语句
158
+
159
+ # === 测试格式规范 ===
160
+ "E501", # 行长度过长
161
+
162
+ # === 测试日志处理 ===
163
+ "G004", # 日志使用f-string
164
+
165
+ # === 测试调试代码 ===
166
+ "T201", # print语句
167
+
168
+ # === 测试文档相关 ===
169
+ "D", # 文档字符串
170
+
171
+ # === 测试私有访问 ===
172
+ "SLF001", # 私有成员访问
173
+
174
+ # === 测试Unicode ===
175
+ "RUF001", # 模糊的Unicode字符
176
+
177
+ # === 测试路径处理 ===
178
+ "PTH", # pathlib相关规则
179
+
180
+ # === 测试日期时间 ===
181
+ "DTZ001", # datetime缺少时区
182
+ "DTZ005", # datetime.now()缺少时区
183
+
184
+ # === 测试代码清理 ===
185
+ "ERA001", # 注释掉的代码
186
+
187
+ "RUF",
188
+ ]
189
+ "**/*.ipynb" = ["ALL"]
190
+ "**/notebooks/marino/*.py" = ["ALL"]
191
+
192
+
193
+ [tool.ruff.format]
194
+ quote-style = "double"
195
+ indent-style = "space"
196
+ skip-magic-trailing-comma = false
197
+ line-ending = "auto"
198
+ docstring-code-format = true
199
+ docstring-code-line-length = "dynamic"
200
+
201
+ # ============================================================================
202
+ # basedpyright 配置 - 独立包配置
203
+ # ============================================================================
204
+ [tool.basedpyright]
205
+ pythonVersion = "3.10"
206
+
207
+ reportUnannotatedClassAttribute = "none"
208
+ reportUnreachable = "none"
209
+ reportUnnecessaryIsInstance = "none"
210
+ reportAny = "none"
211
+ reportExplicitAny = "none"
212
+ reportConstantRedefinition = "none"
213
+ reportUnnecessaryComparison = "none"
214
+
215
+ [[tool.basedpyright.executionEnvironments]]
216
+ root = "tests"
217
+ reportUnusedCallResult = false
218
+ reportUnknownArgumentType = false
219
+ reportUnknownParameterType = false
220
+ reportMissingParameterType = false
221
+ reportUnknownVariableType = false
222
+ reportUnknownMemberType = false
223
+ reportExplicitAny = "none"
224
+ reportAny = "none"
225
+ reportUnusedParameter = false
226
+ reportUnusedVariable = false
227
+ reportMissingTypeArgument = false
228
+ reportArgumentType = false
229
+ reportOptionalMemberAccess = false
230
+ reportAttributeAccessIssue = "none"
231
+ reportGeneralTypeIssues = false
232
+ reportCallIssue = false
233
+ reportReturnType = false
234
+ reportPrivateUsage = false
235
+ reportUnusedClass = false
236
+
237
+
238
+ [[tool.basedpyright.executionEnvironments]]
239
+ root = "src"
@@ -0,0 +1,6 @@
1
+ """lush-stdx: 标准工具集 (enumx/functoolx/itertoolsx/timex/langx)."""
2
+
3
+ from . import enumx, langx, timex
4
+ from .langx import OptionT
5
+
6
+ __all__ = ["OptionT", "enumx", "langx", "timex"]
@@ -0,0 +1,191 @@
1
+ import dataclasses
2
+ import enum
3
+ from typing import Any
4
+
5
+ from pydantic import GetJsonSchemaHandler
6
+ from pydantic.json_schema import JsonSchemaValue
7
+ from pydantic_core import ValidationError as PydanticValidationError
8
+ from pydantic_core import core_schema
9
+
10
+
11
+ @dataclasses.dataclass(frozen=True, slots=True)
12
+ class XMetaInfo:
13
+ description: str = ""
14
+ display_text: str = ""
15
+
16
+
17
+ class MetaInfoIntEnum(enum.IntEnum):
18
+ """
19
+ IntEnum with XMetaInfo
20
+
21
+ Most of behavior like IntEnum, and you can use ._x_meta to get the pre-defined XMetaInfo
22
+ """
23
+
24
+ def __new__(cls, value: int, meta: XMetaInfo) -> "MetaInfoIntEnum":
25
+ obj = int.__new__(cls, value)
26
+ obj._value_ = value
27
+ obj._x_meta = meta # pyright: ignore[reportAttributeAccessIssue ]
28
+ return obj
29
+
30
+ @classmethod
31
+ def to_db_field_comment(cls) -> str:
32
+ return " ".join([f"{i.value}: {i.x_meta.description}" for i in cls])
33
+
34
+ @property
35
+ def x_meta(self) -> XMetaInfo:
36
+ return self._x_meta # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownVariableType ]
37
+
38
+ @classmethod
39
+ def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema:
40
+ base_schema = handler(source_type)
41
+
42
+ def wrap_validator(value: Any, validator: core_schema.ValidatorFunctionWrapHandler) -> enum.Enum:
43
+ if isinstance(value, cls):
44
+ return validator(value)
45
+
46
+ original_value = value
47
+
48
+ if isinstance(value, str):
49
+ try:
50
+ value = int(value)
51
+ except (ValueError, TypeError) as exc:
52
+ raise ValueError(f"'{original_value}' is not a valid member of {cls.__name__}") from exc
53
+
54
+ try:
55
+ return validator(value)
56
+ except (PydanticValidationError, TypeError, ValueError) as exc:
57
+ raise ValueError(f"'{original_value}' is not a valid value for {cls.__name__}") from exc
58
+
59
+ schema = core_schema.no_info_wrap_validator_function(wrap_validator, base_schema)
60
+ schema["serialization"] = core_schema.plain_serializer_function_ser_schema(lambda x: x.value if hasattr(x, "value") else x)
61
+ return schema
62
+
63
+ @classmethod
64
+ def __get_pydantic_json_schema__(cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue:
65
+ json_schema = handler(core_schema)
66
+ json_schema = handler.resolve_ref_schema(json_schema)
67
+
68
+ descriptions = [f"* `{member.value}`: {member._x_meta.description}" for member in cls] # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
69
+ schema_description = "枚举值:\n\n" + "\n".join(descriptions)
70
+
71
+ # 为 MetaInfoIntEnum 明确设置为 integer 类型(不支持 string)
72
+ json_schema.update(
73
+ type="integer",
74
+ description=schema_description,
75
+ enum=[member.value for member in cls],
76
+ **{
77
+ "x-enum-module": cls.__module__,
78
+ "x-enum-class": cls.__qualname__,
79
+ },
80
+ )
81
+ return json_schema
82
+
83
+
84
+ class MetaInfoStrEnum(str, enum.Enum):
85
+ """
86
+ StrEnum with XMetaInfo
87
+
88
+ Most of behavior like StrEnum, and you can use ._x_meta to get the pre-defined XMetaInfo
89
+ """
90
+
91
+ def __new__(cls, value: str, meta: XMetaInfo) -> "MetaInfoStrEnum":
92
+ obj = str.__new__(cls, value)
93
+ obj._value_ = value
94
+ obj._x_meta = meta # pyright: ignore[reportAttributeAccessIssue ]
95
+ return obj
96
+
97
+ def __str__(self) -> str: # pyright: ignore[reportImplicitOverride]
98
+ return self.value
99
+
100
+ @classmethod
101
+ def to_db_field_comment(cls) -> str:
102
+ return " ".join([f"{i.value}: {i.x_meta.description}" for i in cls])
103
+
104
+ @property
105
+ def x_meta(self) -> XMetaInfo:
106
+ return self._x_meta # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownVariableType ]
107
+
108
+ @classmethod
109
+ def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema:
110
+ base_schema = handler(source_type)
111
+
112
+ def wrap_validator(value: Any, validator: core_schema.ValidatorFunctionWrapHandler) -> enum.Enum:
113
+ if isinstance(value, cls):
114
+ return validator(value)
115
+
116
+ if isinstance(value, str):
117
+ try:
118
+ return validator(cls(value))
119
+ except ValueError:
120
+ try:
121
+ member = cls[value.upper()]
122
+ except KeyError as exc:
123
+ raise ValueError(f"'{value}' is not a valid value or name for {cls.__name__}") from exc
124
+ return validator(member)
125
+
126
+ raise ValueError(f"Input for {cls.__name__} must be a string.")
127
+
128
+ schema = core_schema.no_info_wrap_validator_function(wrap_validator, base_schema)
129
+ schema["serialization"] = core_schema.plain_serializer_function_ser_schema(lambda x: x.value if hasattr(x, "value") else x)
130
+ return schema
131
+
132
+ @classmethod
133
+ def __get_pydantic_json_schema__(cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue:
134
+ json_schema = handler(core_schema)
135
+ json_schema = handler.resolve_ref_schema(json_schema)
136
+
137
+ descriptions = [f"* `{member.value}`: {member._x_meta.description}" for member in cls] # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
138
+ schema_description = "枚举值:\n\n" + "\n".join(descriptions)
139
+
140
+ # 为 MetaInfoStrEnum 明确设置为 string 类型
141
+ json_schema.update(
142
+ type="string",
143
+ description=schema_description,
144
+ enum=[member.value for member in cls],
145
+ **{
146
+ "x-enum-module": cls.__module__,
147
+ "x-enum-class": cls.__qualname__,
148
+ },
149
+ )
150
+ return json_schema
151
+
152
+
153
+ class EnumField:
154
+ """
155
+ EnumField is a field (descriptor) that can be used to validate and serialize enum values.
156
+
157
+ It will automatically convert the value to the enum member, and you can use ._x_meta to get the pre-defined XMetaInfo
158
+ """
159
+
160
+ def __init__(self, enum_cls: type[enum.Enum]) -> None:
161
+ if not issubclass(enum_cls, enum.Enum):
162
+ raise TypeError("enum_cls must be subclass of enum.Enum")
163
+ self.enum_cls = enum_cls
164
+ self.private_name = None
165
+
166
+ def __set_name__(self, owner: Any, name: str) -> None:
167
+ self.private_name = f"_{name}"
168
+
169
+ def __get__(self, instance: Any, owner: Any) -> Any:
170
+ if instance is None:
171
+ return self
172
+ if self.private_name is None:
173
+ return None
174
+ return getattr(instance, self.private_name, None)
175
+
176
+ def __set__(self, instance: Any, value: Any) -> None:
177
+ if value is None:
178
+ setattr(instance, self.private_name, None) # pyright: ignore[reportArgumentType]
179
+ return
180
+
181
+ try:
182
+ member = self.enum_cls(value)
183
+ except ValueError:
184
+ try:
185
+ if not isinstance(value, str):
186
+ raise KeyError # noqa: TRY301
187
+ member = self.enum_cls[value.upper()]
188
+ except KeyError:
189
+ raise ValueError(f"'{value}' is not a valid member, value or name for {self.enum_cls.__name__}") from None
190
+
191
+ setattr(instance, self.private_name, member) # pyright: ignore[reportArgumentType]
@@ -0,0 +1,44 @@
1
+ import sys
2
+ from enum import Enum
3
+
4
+ if sys.version_info >= (3, 11):
5
+ from enum import StrEnum # pragma: no cover
6
+ else:
7
+ # copied from https://github.com/python/cpython/blob/1ae900424b3c888d2b2cc97e6ef780717813d658/Lib/enum.py#L1365
8
+ class ReprEnum(Enum):
9
+ """
10
+ Only changes the repr(), leaving str() and format() to the mixed-in type.
11
+ """
12
+
13
+ class StrEnum(str, ReprEnum):
14
+ """
15
+ Enum where members are also (and must be) strings
16
+ """
17
+
18
+ def __new__(cls, *values: str) -> "StrEnum":
19
+ "values must already be of type `str`"
20
+ if len(values) > 3:
21
+ raise TypeError(f"too many arguments for str(): {values!r}")
22
+ if len(values) == 1:
23
+ # it must be a string
24
+ if not isinstance(values[0], str):
25
+ raise TypeError(f"{values[0]!r} is not a string")
26
+ if len(values) >= 2:
27
+ # check that encoding argument is a string
28
+ if not isinstance(values[1], str):
29
+ raise TypeError(f"encoding must be a string, not {values[1]!r}")
30
+ if len(values) == 3:
31
+ # check that errors argument is a string
32
+ if not isinstance(values[2], str):
33
+ raise TypeError(f"errors must be a string, not {values[2]!r}")
34
+ value = str(*values)
35
+ member = str.__new__(cls, value)
36
+ member._value_ = value
37
+ return member
38
+
39
+ @staticmethod
40
+ def _generate_next_value_(name: str, _start: int, _count: int, _last_values: list[str]) -> str: # pyright: ignore[reportIncompatibleMethodOverride, reportImplicitOverride]
41
+ """
42
+ Return the lower-cased version of the member name.
43
+ """
44
+ return name.lower()
@@ -0,0 +1,65 @@
1
+ import time
2
+ from collections.abc import Callable
3
+ from functools import lru_cache, wraps
4
+ from typing import Any, ParamSpec, TypeVar
5
+
6
+ P = ParamSpec("P")
7
+ R = TypeVar("R")
8
+
9
+
10
+ ONE_SECOND = 1
11
+ ONE_MINUTE = 60
12
+ ONE_HOUR = 60 * ONE_MINUTE
13
+ ONE_DAY = 24 * ONE_HOUR
14
+ ONE_WEEK = 7 * ONE_DAY
15
+
16
+
17
+ def ttl_lru_cache(ttl: int, max_size: int = 128) -> Callable[[Callable[P, R]], Callable[P, R]]:
18
+ """带TTL(生存时间)的LRU缓存装饰器.
19
+
20
+ 将 LRU(最近最少使用) 与 TTL(生存时间) 组合:
21
+ - 限制缓存容量,超限时淘汰最近最少使用的条目
22
+ - 在指定 TTL 时间片变化后,自动视为新的缓存键,从而实现过期
23
+
24
+ 设计说明:
25
+ - 当 ``ttl <= 0`` 时,退化为纯 LRU 缓存(永不过期)
26
+ - 注意: Python 的 ``functools.lru_cache`` 默认不区分 ``1`` 与 ``1.0`` 的键.
27
+ 本实现同样遵循该行为,默认会将 ``1`` 与 ``1.0`` 视为同一键.
28
+ 若你需要区分,建议在调用时自行规范参数类型(例如显式转换为 ``str`` 或引入类型标签).
29
+
30
+ Args:
31
+ ttl: 生存时间(秒).``ttl<=0`` 表示永不过期
32
+ max_size: LRU 最大容量
33
+
34
+ Returns:
35
+ 装饰器函数
36
+ """
37
+
38
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
39
+ # 为了保证 kwargs 顺序无关,我们将其转换为有序元组(sorted by key)
40
+ @lru_cache(maxsize=max_size, typed=False)
41
+ def cached_func(
42
+ _pos_args: tuple[Any, ...],
43
+ _kw_items: tuple[tuple[str, Any], ...],
44
+ _ttl_slice: int,
45
+ ) -> R:
46
+ return func(*_pos_args, **dict(_kw_items))
47
+
48
+ @wraps(func)
49
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
50
+ # 计算时间分片: ttl>0 时按间隔划分,否则固定为 0
51
+ ttl_slice = int(time.time() // ttl) if ttl > 0 else 0
52
+ kw_items = tuple(sorted(kwargs.items()))
53
+ return cached_func(tuple(args), kw_items, ttl_slice)
54
+
55
+ def cache_clear() -> None:
56
+ cached_func.cache_clear()
57
+
58
+ def cache_info() -> Any:
59
+ return cached_func.cache_info()
60
+
61
+ wrapper.cache_clear = cache_clear # pyright: ignore[reportAttributeAccessIssue]
62
+ wrapper.cache_info = cache_info # pyright: ignore[reportAttributeAccessIssue]
63
+ return wrapper
64
+
65
+ return decorator
@@ -0,0 +1,209 @@
1
+ import itertools
2
+ from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable, Iterator, Sequence
3
+ from typing import Protocol, TypeVar
4
+
5
+ T = TypeVar("T")
6
+ V = TypeVar("V")
7
+
8
+
9
+ def filtered_in_sql_values(
10
+ values: Iterable[V] | None,
11
+ target_type_as: Callable[[V], T] = lambda x: x,
12
+ ) -> list[T]:
13
+ if not values:
14
+ return []
15
+
16
+ items: list[T] = []
17
+ seen = set[T]()
18
+
19
+ for item in values:
20
+ if item is None or item == "":
21
+ continue
22
+ try:
23
+ converted_value = target_type_as(item)
24
+ if converted_value not in seen:
25
+ seen.add(converted_value)
26
+ items.append(converted_value)
27
+ except (ValueError, TypeError):
28
+ continue
29
+
30
+ return items
31
+
32
+
33
+ class IterPageFetchFunc(Protocol[T]):
34
+ async def __call__(self, *, offset: int, limit: int) -> list[T]: ...
35
+
36
+
37
+ async def iter_page(
38
+ fetch_func: IterPageFetchFunc[T],
39
+ offset: int = 0,
40
+ limit: int = 100,
41
+ n_max_iters_or_none_limit: int | None = None,
42
+ ) -> AsyncIterator[list[T]]:
43
+ """
44
+ 异步生成器, 分页请求数据并逐页 yield.
45
+
46
+ 这个函数会不断调用用户提供的异步函数, 每次递增 offset,
47
+ 逐页 yield 返回的数据, 直到没有更多数据或达到最大迭代次数限制.
48
+
49
+ Args:
50
+ fetch_func: 异步函数, 接受 offset 和 limit 关键字参数, 返回数据列表
51
+ offset: 起始偏移量, 默认为 0
52
+ limit: 每页大小, 默认为 100
53
+ n_max_iters_or_none_limit: 最大迭代次数, None 表示无限制, 默认为 None
54
+
55
+ Yields:
56
+ 每一页的数据列表
57
+
58
+ Examples:
59
+ async def fetch_users(*, offset: int, limit: int) -> list[User]:
60
+ # 实现分页查询逻辑
61
+ pass
62
+
63
+ # 逐页处理用户数据
64
+ async for page in iter_page(fetch_users, limit=50):
65
+ process_page(page)
66
+ """
67
+ current_offset = offset
68
+ n_max_iters = int(n_max_iters_or_none_limit) if n_max_iters_or_none_limit is not None else None
69
+
70
+ while True:
71
+ if n_max_iters is not None:
72
+ if n_max_iters <= 0:
73
+ break
74
+ n_max_iters -= 1
75
+
76
+ paged_items = await fetch_func(offset=current_offset, limit=limit)
77
+
78
+ if not paged_items:
79
+ break
80
+
81
+ yield paged_items
82
+
83
+ if len(paged_items) < limit:
84
+ break
85
+
86
+ current_offset += limit
87
+
88
+
89
+ def chunks(itr: Iterable[T], batch_size: int = 500) -> Iterator[list[T]]:
90
+ """将一个可迭代对象分割成指定大小的多个块 (chunks).
91
+
92
+ 这个函数是一个生成器,它会懒加载地从输入的可迭代对象中
93
+ 读取数据,并每次生成一个列表形式的数据块.这种实现方式对于
94
+ 处理大型数据集非常高效,因为它不会一次性将所有数据加载到内存中.
95
+
96
+ Examples:
97
+ >>> my_list = list(range(10))
98
+ >>> for chunk in chunks(my_list, 4):
99
+ ... print(chunk)
100
+ [0, 1, 2, 3]
101
+ [4, 5, 6, 7]
102
+ [8, 9]
103
+
104
+ """
105
+ if not isinstance(batch_size, int) or batch_size <= 0:
106
+ raise ValueError("batch_size 必须是一个正整数")
107
+
108
+ # 将可迭代对象转换为迭代器,以确保我们可以在其上持续调用 next()
109
+ it = iter(itr)
110
+ while True:
111
+ # 使用 islice 高效地获取下一个块,避免创建中间列表
112
+ chunk = list(itertools.islice(it, batch_size))
113
+ if not chunk:
114
+ # 当 islice 返回一个空列表时,表示原始迭代器已耗尽
115
+ return
116
+ yield chunk
117
+
118
+
119
+ async def async_chunks(
120
+ aitr: AsyncIterable[T],
121
+ batch_size: int = 500,
122
+ ) -> AsyncIterator[list[T]]:
123
+ """将一个异步可迭代对象分割成指定大小的多个块 (chunks).
124
+
125
+ 这个函数是一个异步生成器,它会从输入的异步可迭代对象中
126
+ 异步地读取数据,并每次生成一个列表形式的数据块.这对于处理
127
+ 异步数据流(例如,从数据库或网络 API 获取的数据)非常有用.
128
+
129
+ Examples:
130
+ ```python
131
+ import asyncio
132
+
133
+
134
+ async def async_generator():
135
+ for i in range(10):
136
+ yield i
137
+ await asyncio.sleep(0.01)
138
+
139
+
140
+ async def main():
141
+ async for chunk in async_chunks(async_generator(), 4):
142
+ print(chunk)
143
+
144
+
145
+ # 运行 `asyncio.run(main())` 将会输出:
146
+ # [0, 1, 2, 3]
147
+ # [4, 5, 6, 7]
148
+ # [8, 9]
149
+ ```
150
+ """
151
+ if not isinstance(batch_size, int) or batch_size <= 0:
152
+ raise ValueError("batch_size 必须是一个正整数")
153
+
154
+ batch: list[T] = []
155
+ ait = aiter(aitr)
156
+ async for item in ait:
157
+ batch.append(item)
158
+ if len(batch) >= batch_size:
159
+ yield batch
160
+ batch = []
161
+
162
+ if batch:
163
+ yield batch
164
+
165
+
166
+ ItemT = TypeVar("ItemT")
167
+
168
+ OffsetPaginationResult = tuple[Sequence[ItemT], int | None]
169
+
170
+
171
+ def get_paged_items_and_cursor(
172
+ query_results: Sequence[ItemT],
173
+ offset: int,
174
+ size: int,
175
+ ) -> OffsetPaginationResult[ItemT]:
176
+ """
177
+ 基于查询结果处理分页逻辑的辅助函数
178
+
179
+ 用户需要自己调用查询函数,传入 size + 1 作为 limit,然后将结果传给这个函数处理
180
+
181
+ Args:
182
+ query_results: 查询结果,应该是用 size + 1 查询得到的
183
+ offset: 当前页的偏移量
184
+ size: 用户请求的页面大小
185
+
186
+ Returns:
187
+ tuple[list[ItemT], int | None]: (数据列表, 下一页offset或None)
188
+
189
+ Example:
190
+ ```python
191
+ # 用户自己控制查询调用
192
+ raw_results = await my_dal.page_users(
193
+ limit=size + 1, # 关键:使用 size + 1
194
+ offset=offset,
195
+ status="active",
196
+ keyword="张",
197
+ )
198
+
199
+ # 处理分页逻辑
200
+ items, next_offset = get_paged_items_and_cursor(raw_results, offset, size)
201
+
202
+ return {"items": items, "next_offset": next_offset, "has_next": next_offset is not None}
203
+ ```
204
+ """
205
+ if len(query_results) <= size:
206
+ # 没有下一页,返回所有结果
207
+ return query_results, None
208
+ # 有下一页,截取前size个元素
209
+ return query_results[:size], offset + size
File without changes
@@ -0,0 +1,5 @@
1
+ """语言特性扩展."""
2
+
3
+ from .optional import OptionT
4
+
5
+ __all__ = ["OptionT"]
@@ -0,0 +1,55 @@
1
+ """语言特性扩展: OptionT 可选值容器."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Generic, TypeVar
6
+
7
+ from typing_extensions import override
8
+
9
+ __all__ = ["OptionT"]
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ class OptionT(Generic[T]):
15
+ """高性能可选值包装器 (类似 Rust 的 ``Option[T]``)."""
16
+
17
+ __slots__ = ("_value",)
18
+
19
+ def __init__(self, value: T | None = None) -> None:
20
+ self._value = value
21
+
22
+ def unwrap(self) -> T:
23
+ if self._value is None:
24
+ raise ValueError("OptionT value is None. Check with 'if box:' before calling .unwrap()")
25
+ return self._value
26
+
27
+ def unwrap_or(self, default: T) -> T:
28
+ return self._value if self._value is not None else default
29
+
30
+ def is_some(self) -> bool:
31
+ return self._value is not None
32
+
33
+ def is_none(self) -> bool:
34
+ return self._value is None
35
+
36
+ def __bool__(self) -> bool:
37
+ return self._value is not None
38
+
39
+ @override
40
+ def __repr__(self) -> str:
41
+ return f"OptionT({self._value!r})" if self._value is not None else "OptionT(None)"
42
+
43
+ @override
44
+ def __str__(self) -> str:
45
+ return str(self._value) if self._value is not None else "None"
46
+
47
+ @override
48
+ def __eq__(self, other: object) -> bool:
49
+ if not isinstance(other, OptionT):
50
+ return False
51
+ return self._value == other._value # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
52
+
53
+ @override
54
+ def __hash__(self) -> int:
55
+ return hash(self._value)
File without changes
File without changes
@@ -0,0 +1,63 @@
1
+ """时间工具函数合集.
2
+
3
+ 原位于 ``lush-timex`` 包, 现并入 ``lush-stdx`` 便于统一维护.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import datetime
9
+ from zoneinfo import ZoneInfo
10
+
11
+ __all__ = [
12
+ "TZ_SHANGHAI",
13
+ "datetime_to_str",
14
+ "datetime_to_timestamp",
15
+ "str_to_datetime",
16
+ "timestamp_to_datetime",
17
+ ]
18
+
19
+
20
+ def datetime_to_timestamp(dt: datetime.datetime) -> int:
21
+ """将 ``datetime`` 对象转换为毫秒级时间戳."""
22
+
23
+ return int(dt.timestamp() * 1000)
24
+
25
+
26
+ def datetime_to_str(dt: datetime.datetime) -> str:
27
+ """格式化 ``datetime`` 为 ``YYYY-MM-DD HH:MM:SS`` 字符串."""
28
+
29
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
30
+
31
+
32
+ def str_to_datetime(d: datetime.datetime | datetime.date | str | None) -> datetime.datetime | None:
33
+ """将字符串/日期转换为 ``datetime``."""
34
+
35
+ if not d:
36
+ return None
37
+ if isinstance(d, datetime.datetime):
38
+ return d
39
+ if isinstance(d, datetime.date):
40
+ return datetime.datetime(d.year, d.month, d.day)
41
+ try:
42
+ return datetime.datetime.strptime(d, "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
43
+ except ValueError:
44
+ try:
45
+ return datetime.datetime.strptime(d, "%Y-%m-%d %H:%M:%S.%f") # noqa: DTZ007
46
+ except ValueError:
47
+ try:
48
+ return datetime.datetime.strptime(d, "%Y-%m-%d") # noqa: DTZ007
49
+ except ValueError:
50
+ return datetime.datetime.strptime(d, "%Y-%m-%d %H:%M") # noqa: DTZ007
51
+
52
+
53
+ TZ_SHANGHAI = ZoneInfo("Asia/Shanghai")
54
+ """默认时区: 上海."""
55
+
56
+
57
+ def timestamp_to_datetime(
58
+ ts_seconds: float,
59
+ tzinfo: ZoneInfo | None = TZ_SHANGHAI,
60
+ ) -> datetime.datetime:
61
+ """将秒级时间戳转换为指定时区的 ``datetime``."""
62
+
63
+ return datetime.datetime.fromtimestamp(ts_seconds, tzinfo)
@@ -0,0 +1,15 @@
1
+ from urllib.parse import parse_qs, parse_qsl, urlencode, urlparse, urlunparse
2
+
3
+
4
+ def url_update_params(url: str, params: dict[str, str]) -> str:
5
+ url_parts = list(urlparse(url))
6
+ query = dict(parse_qsl(url_parts[4]))
7
+ query.update(params)
8
+ url_parts[4] = urlencode(query)
9
+ return urlunparse(url_parts)
10
+
11
+
12
+ def extract_query_param(url_like: str) -> dict[str, list[str]]:
13
+ parsed_url = urlparse(url_like)
14
+ query_str = parsed_url.query
15
+ return parse_qs(query_str)