Coverage for src / c41811 / config / path.py: 100%
182 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-06 06:04 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-06 06:04 +0000
1# cython: language_level = 3 # noqa: ERA001
4"""配置数据路径"""
6import warnings
7from abc import ABC
8from collections.abc import Iterable
9from collections.abc import Mapping
10from collections.abc import MutableMapping
11from collections.abc import MutableSequence
12from collections.abc import Sequence
13from functools import lru_cache
14from typing import Any
15from typing import Self
16from typing import cast
17from typing import override
19from ._protocols import Indexed
20from .abc import ABCKey
21from .abc import ABCPath
22from .errors import ConfigDataPathSyntaxException
23from .errors import TokenInfo
24from .errors import UnknownTokenTypeError
27class IndexMixin[K, D: Indexed[Any, Any]](ABCKey[K, D], ABC):
28 """
29 混入类,提供对Index操作的支持
31 .. versionchanged:: 0.1.5
32 重命名 ``ItemMixin`` 为 ``IndexMixin``
33 """ # noqa: RUF002
35 @override
36 def __get_inner_element__(self, data: D) -> D:
37 return cast(D, data[self._key])
39 @override
40 def __set_inner_element__(self, data: D, value: Any) -> None:
41 data[self._key] = value # type: ignore[index]
43 @override
44 def __delete_inner_element__(self, data: D) -> None:
45 del data[self._key] # type: ignore[attr-defined]
48class AttrKey(IndexMixin[str, Mapping[str, Any]], ABCKey[str, Mapping[str, Any]]):
49 """属性键"""
51 _key: str
53 def __init__(self, key: str, meta: str | None = None):
54 """
55 :param key: 键名
56 :type key: str
57 :param meta: 元信息
58 :type meta: str | None
60 :raise TypeError: key不为str时抛出
61 """ # noqa: D205
62 if not isinstance(key, str):
63 msg = f"key must be str, not {type(key).__name__}"
64 raise TypeError(msg)
65 super().__init__(key, meta)
67 @override
68 def __contains_inner_element__(self, data: Mapping[Any, Any]) -> bool:
69 return self._key in data
71 @override
72 def __supports__(self, data: Any) -> tuple[Any, ...]:
73 return () if isinstance(data, Mapping) else (Mapping,)
75 @override
76 def __supports_modify__(self, data: Any) -> tuple[Any, ...]:
77 return () if isinstance(data, MutableMapping) else (MutableMapping,)
79 @override
80 def unparse(self) -> str:
81 meta = "" if self._meta is None else f"\\{{{self._meta.replace('\\', '\\\\')}\\}}"
82 return f"{meta}\\.{self._key.replace('\\', '\\\\')}"
84 def __len__(self) -> int:
85 return len(self._key)
87 @override
88 def __eq__(self, other: Any) -> bool:
89 if isinstance(other, str):
90 return self._key == other
91 return super().__eq__(other)
93 @override
94 def __hash__(self) -> int:
95 return super().__hash__()
98class IndexKey(IndexMixin[int, Sequence[Any]], ABCKey[int, Sequence[Any]]):
99 """下标键"""
101 _key: int
103 def __init__(self, key: int, meta: str | None = None):
104 """
105 :param key: 索引值
106 :type key: int
107 :param meta: 元信息
108 :type meta: str
110 :raise TypeError: key不为int时抛出
111 """ # noqa: D205
112 if not isinstance(key, int):
113 msg = f"key must be int, not {type(key).__name__}"
114 raise TypeError(msg)
115 super().__init__(key, meta)
117 @override
118 def __contains_inner_element__(self, data: Sequence[Any]) -> bool:
119 try:
120 data[self._key]
121 except IndexError:
122 return False
123 return True
125 @override
126 def __supports__(self, data: Any) -> tuple[Any, ...]:
127 return () if isinstance(data, Sequence) else (Sequence,)
129 @override
130 def __supports_modify__(self, data: Any) -> tuple[Any, ...]:
131 return () if isinstance(data, MutableSequence) else (MutableSequence,)
133 @override
134 def unparse(self) -> str:
135 meta = "" if self._meta is None else f"\\{{{self._meta.replace('\\', '\\\\')}\\}}"
136 return f"{meta}\\[{self._key}\\]"
139class Path(ABCPath[AttrKey | IndexKey]):
140 """配置数据路径"""
142 @classmethod
143 def from_str(cls, string: str) -> Self:
144 """
145 从字符串解析路径
147 :param string: 路径字符串
148 :type string: str
150 :return: 解析后的路径
151 :rtype: Self
152 """
153 return cls(PathSyntaxParser.parse(string))
155 @classmethod
156 def from_locate(cls, locate: Iterable[str | int]) -> Self:
157 """
158 从列表解析路径
160 :param locate: 键列表
161 :type locate: Iterable[str | int]
163 :return: 解析后的路径
164 :rtype: Self
165 """
166 keys: list[AttrKey | IndexKey] = []
167 for loc in locate:
168 if isinstance(loc, int):
169 keys.append(IndexKey(loc))
170 continue
171 if isinstance(loc, str):
172 keys.append(AttrKey(loc))
173 continue
174 msg = "locate element must be 'int' or 'str'"
175 raise ValueError(msg)
176 return cls(keys)
178 def to_locate(self) -> list[str | int]:
179 """
180 转换为列表
182 .. versionadded:: 0.1.1
183 """
184 return [key.key for key in self._keys]
186 @override
187 def unparse(self) -> str:
188 return "".join(key.unparse() for key in self._keys)
191def _count_backslash(s: str) -> int:
192 count = 1
193 while s and (s[-1] == "\\"):
194 count += 1
195 s = s[:-1]
196 return count
199class PathSyntaxParser:
200 """路径语法解析器"""
202 @staticmethod
203 @lru_cache
204 def tokenize(string: str) -> tuple[str, ...]:
205 # noinspection GrazieInspection
206 r"""
207 将字符串分词为以\开头的有意义片段
209 :param string: 待分词字符串
210 :type string: str
212 :return: 分词结果
213 :rtype: tuple[str, ...]
215 .. note::
216 可以省略字符串开头的 ``\.``
218 例如:
220 ``r"\.first\.second\.third“``
222 可以简写为
224 ``r"first\.second\.third"``
226 .. versionchanged:: 0.1.4
227 允许省略字符串开头的 ``\.``
229 更改返回值类型为 ``tuple[str, ...]``
231 添加缓存
232 """ # noqa: RUF002
233 # 开头默认为AttrKey
234 if not string.startswith((r"\.", r"\[", r"\{")):
235 string = rf"\.{string}"
237 tokens: list[str] = [""]
238 while string:
239 string, sep, token = string.rpartition("\\")
241 # 处理r"\\"防止转义
242 if not token:
243 token += tokens.pop()
245 # 对不存在的转义进行警告 # 检查这个转义符号是否已经被转义
246 elif sep and (token[0] not in {".", "\\", "[", "]", "{", "}"}) and _count_backslash(string) % 2:
247 warnings.warn(rf"invalid escape sequence '\{token[0]}'", SyntaxWarning, stacklevel=2)
249 # 连接不应单独存在的token
250 index_safe = (len(tokens) > 0) and (len(tokens[-1]) > 1)
251 if index_safe and (tokens[-1][1] not in {".", "[", "]", "{", "}"}):
252 token += tokens.pop()
254 # 将 r"\]" 和 r"\}" 后面紧随的字符单独切割出来
255 if token.startswith(("]", "}")) and token[1:]:
256 tokens.append(token[1:])
257 token = token[:1]
259 tokens.append(sep + token)
261 tokens.reverse()
262 if tokens[-1] == "":
263 tokens.pop()
265 return tuple(tokens)
267 @classmethod
268 def parse(cls, string: str) -> list[AttrKey | IndexKey]: # noqa: C901 (ignore complexity)
269 """
270 解析字符串为键列表
272 :param string: 待解析字符串
273 :type string: str
275 :return: 键列表
276 :rtype: list[AttrKey | IndexKey]
277 """
278 path: list[AttrKey | IndexKey] = []
279 item: str | None = None
280 meta: str | None = None
281 token_stack: list[str] = []
283 tokenized_path = cls.tokenize(string)
284 for index, token in enumerate(tokenized_path):
285 if not token.startswith("\\"):
286 raise UnknownTokenTypeError(TokenInfo(tokenized_path, token, index))
288 token_type = token[1]
289 content = token[2:].replace("\\\\", "\\")
291 def _token_closed(tk_typ: str, tk_close: str, tk: str, i: int) -> None:
292 try:
293 top = token_stack.pop()
294 except IndexError:
295 raise ConfigDataPathSyntaxException(
296 TokenInfo(tokenized_path, tk, i), f"unmatched '{tk_close}'"
297 ) from None
298 if top != tk_typ:
299 raise ConfigDataPathSyntaxException(
300 TokenInfo(tokenized_path, tk, i),
301 f"closing parenthesis '{tk_close}' does not match opening parenthesis '{top}'",
302 )
304 if token_type == "}": # noqa: S105
305 _token_closed("{", "}", token, index)
306 continue
307 if token_type == "]": # noqa: S105
308 _token_closed("[", "]", token, index)
309 try:
310 path.append(IndexKey(int(item), meta)) # type: ignore[arg-type]
311 except ValueError:
312 raise ConfigDataPathSyntaxException(
313 TokenInfo(tokenized_path, token, index), f"index key '{item}' must be numeric"
314 ) from None
315 item = None
316 meta = None
317 continue
319 if token_stack:
320 raise ConfigDataPathSyntaxException(
321 TokenInfo(tokenized_path, token, index), f"'{token_stack.pop()}' was never closed"
322 )
324 if token_type == "[": # noqa: S105
325 token_stack.append("[")
326 item = content
327 continue
328 if token_type == "{": # noqa: S105
329 token_stack.append("{")
330 meta = content
331 continue
332 if token_type == ".": # noqa: S105
333 path.append(AttrKey(content, meta))
334 meta = None
335 continue
337 raise UnknownTokenTypeError(TokenInfo(tokenized_path, token, index))
339 if token_stack:
340 raise ConfigDataPathSyntaxException(
341 TokenInfo(tokenized_path, tokenized_path[-1], len(tokenized_path) - 1),
342 f"'{token_stack.pop()}' was never closed",
343 )
345 return path
348__all__ = (
349 "AttrKey",
350 "IndexKey",
351 "Path",
352 "PathSyntaxParser",
353)