satori-python 1.3.1__tar.gz → 1.3.3__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 (31) hide show
  1. {satori_python-1.3.1 → satori_python-1.3.3}/PKG-INFO +6 -6
  2. {satori_python-1.3.1 → satori_python-1.3.3}/README.md +2 -2
  3. {satori_python-1.3.1 → satori_python-1.3.3}/pyproject.toml +15 -17
  4. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/__init__.py +1 -1
  5. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/__init__.py +28 -22
  6. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/network/websocket.py +13 -8
  7. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/protocol.py +9 -4
  8. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/element.py +125 -52
  9. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/model.py +77 -44
  10. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/parser.py +56 -16
  11. satori_python-1.3.3/src/satori/py.typed +0 -0
  12. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/server/__init__.py +7 -9
  13. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/utils.py +6 -0
  14. {satori_python-1.3.1 → satori_python-1.3.3}/LICENSE +0 -0
  15. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/_vendor/fleep.py +0 -0
  16. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/account.py +0 -0
  17. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/account.pyi +0 -0
  18. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/config.py +0 -0
  19. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/network/__init__.py +0 -0
  20. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/network/base.py +0 -0
  21. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/network/util.py +0 -0
  22. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/client/network/webhook.py +0 -0
  23. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/const.py +0 -0
  24. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/event.py +0 -0
  25. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/exception.py +0 -0
  26. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/server/adapter.py +0 -0
  27. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/server/connection.py +0 -0
  28. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/server/formdata.py +0 -0
  29. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/server/model.py +0 -0
  30. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/server/route.py +0 -0
  31. {satori_python-1.3.1 → satori_python-1.3.3}/src/satori/server/utils.py +0 -0
@@ -1,18 +1,18 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: satori-python
3
- Version: 1.3.1
3
+ Version: 1.3.3
4
4
  Summary: Satori Protocol SDK for python
5
5
  Home-page: https://github.com/RF-Tar-Railt/satori-python
6
6
  Author-Email: RF-Tar-Railt <rf_tar_railt@qq.com>
7
7
  License: MIT
8
8
  Classifier: Typing :: Typed
9
- Classifier: Development Status :: 4 - Beta
9
+ Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Programming Language :: Python :: 3.8
12
- Classifier: Programming Language :: Python :: 3.9
13
11
  Classifier: Programming Language :: Python :: 3.10
14
12
  Classifier: Programming Language :: Python :: 3.11
15
13
  Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Operating System :: OS Independent
17
17
  Project-URL: Homepage, https://github.com/RF-Tar-Railt/satori-python
18
18
  Project-URL: Repository, https://github.com/RF-Tar-Railt/satori-python
@@ -38,11 +38,11 @@ Description-Content-Type: text/markdown
38
38
  [![PyPI](https://img.shields.io/pypi/v/satori-python)](https://pypi.org/project/satori-python)
39
39
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/satori-python)](https://www.python.org/)
40
40
 
41
- 基于 [Satori](https://satori.chat/zh-CN/) 协议的 Python 开发工具包
41
+ 基于 [Satori](https://satori.chat/zh-CN/protocol) 协议的 Python 开发工具包
42
42
 
43
43
  ## 协议介绍
44
44
 
45
- [Satori Protocol](https://satori.chat/zh-CN/)
45
+ [Satori Protocol](https://satori.chat/zh-CN/protocol)
46
46
 
47
47
  ### 协议端
48
48
 
@@ -5,11 +5,11 @@
5
5
  [![PyPI](https://img.shields.io/pypi/v/satori-python)](https://pypi.org/project/satori-python)
6
6
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/satori-python)](https://www.python.org/)
7
7
 
8
- 基于 [Satori](https://satori.chat/zh-CN/) 协议的 Python 开发工具包
8
+ 基于 [Satori](https://satori.chat/zh-CN/protocol) 协议的 Python 开发工具包
9
9
 
10
10
  ## 协议介绍
11
11
 
12
- [Satori Protocol](https://satori.chat/zh-CN/)
12
+ [Satori Protocol](https://satori.chat/zh-CN/protocol)
13
13
 
14
14
  ### 协议端
15
15
 
@@ -21,16 +21,16 @@ requires-python = ">=3.10,<4.0"
21
21
  readme = "README.md"
22
22
  classifiers = [
23
23
  "Typing :: Typed",
24
- "Development Status :: 4 - Beta",
24
+ "Development Status :: 5 - Production/Stable",
25
25
  "License :: OSI Approved :: MIT License",
26
- "Programming Language :: Python :: 3.8",
27
- "Programming Language :: Python :: 3.9",
28
26
  "Programming Language :: Python :: 3.10",
29
27
  "Programming Language :: Python :: 3.11",
30
28
  "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Programming Language :: Python :: 3.14",
31
31
  "Operating System :: OS Independent",
32
32
  ]
33
- version = "1.3.1"
33
+ version = "1.3.3"
34
34
 
35
35
  [project.license]
36
36
  text = "MIT"
@@ -53,15 +53,14 @@ build-backend = "mina.backend"
53
53
 
54
54
  [dependency-groups]
55
55
  dev = [
56
- "isort>=5.13.2",
57
- "black>=24.4.0",
58
- "ruff>=0.4.1",
56
+ "black>=26.3.0",
57
+ "ruff>=0.15.1",
59
58
  "pre-commit>=3.7.0",
60
- "fix-future-annotations>=0.5.0",
61
59
  "mina-build<0.6,>=0.5.1",
62
60
  "pdm-mina>=0.3.2",
63
61
  "nonechat<0.7.0,>=0.6.0",
64
62
  "uvicorn[standard]>=0.37.0",
63
+ "pydantic>=2.13.1",
65
64
  ]
66
65
 
67
66
  [tool.pdm.build]
@@ -74,7 +73,7 @@ excludes = [
74
73
 
75
74
  [tool.pdm.scripts.format]
76
75
  composite = [
77
- "isort ./src/ ./example/",
76
+ "ruff check --select I --fix ./src/ ./example/",
78
77
  "black ./src/ ./example/",
79
78
  "ruff check",
80
79
  ]
@@ -88,14 +87,6 @@ line-length = 120
88
87
  include = "\\.pyi?$"
89
88
  extend-exclude = ""
90
89
 
91
- [tool.isort]
92
- profile = "black"
93
- line_length = 120
94
- skip_gitignore = true
95
- extra_standard_library = [
96
- "typing_extensions",
97
- ]
98
-
99
90
  [tool.ruff]
100
91
  line-length = 120
101
92
  target-version = "py310"
@@ -105,6 +96,7 @@ exclude = [
105
96
  "exam2.py",
106
97
  "src/satori/_vendor/*",
107
98
  ]
99
+ respect-gitignore = true
108
100
 
109
101
  [tool.ruff.lint]
110
102
  select = [
@@ -124,6 +116,12 @@ ignore = [
124
116
  "T201",
125
117
  ]
126
118
 
119
+ [tool.ruff.lint.isort]
120
+ extra-standard-library = [
121
+ "typing_extensions",
122
+ ]
123
+ force-sort-within-sections = false
124
+
127
125
  [tool.pyright]
128
126
  pythonPlatform = "All"
129
127
  pythonVersion = "3.10"
@@ -45,7 +45,7 @@ from .model import Role as Role
45
45
  from .model import Upload as Upload
46
46
  from .model import User as User
47
47
 
48
- __version__ = "1.3.1"
48
+ __version__ = "1.3.3"
49
49
 
50
50
 
51
51
  MessageReceipt = MessageObject
@@ -232,7 +232,8 @@ class App(Service):
232
232
  task.cancel()
233
233
 
234
234
  async def post(self, event: Event, conn: BaseNetwork):
235
- if event.type == EventType.LOGIN_ADDED:
235
+ ev_type = event.type
236
+ if ev_type == EventType.LOGIN_ADDED:
236
237
  if TYPE_CHECKING:
237
238
  assert isinstance(event, events.LoginEvent)
238
239
  login = event.login
@@ -251,7 +252,7 @@ class App(Service):
251
252
  self.accounts[login_sn] = account
252
253
  conn.accounts[login_sn] = account
253
254
  await self.account_update(account, login.status)
254
- elif event.type == EventType.LOGIN_UPDATED:
255
+ elif ev_type == EventType.LOGIN_UPDATED:
255
256
  if TYPE_CHECKING:
256
257
  assert isinstance(event, events.LoginEvent)
257
258
  login = event.login
@@ -260,21 +261,20 @@ class App(Service):
260
261
  return
261
262
  login_sn = f"{login.platform}_{login.user.id}@{id(conn):x}"
262
263
  if login_sn not in self.accounts:
263
- if login.status == LoginStatus.ONLINE:
264
- account = Account(
265
- login,
266
- conn.config,
267
- conn.proxy_urls,
268
- self.default_api_cls,
269
- )
270
- logger.info(f"account added: {account}")
271
- account.connected.set()
272
- self.accounts[login_sn] = account
273
- conn.accounts[login_sn] = account
274
- await self.account_update(account, LoginStatus.ONLINE)
275
- else:
264
+ if login.status != LoginStatus.ONLINE:
276
265
  logger.warning(f"Received event for unknown account: {event}")
277
266
  return
267
+ account = Account(
268
+ login,
269
+ conn.config,
270
+ conn.proxy_urls,
271
+ self.default_api_cls,
272
+ )
273
+ logger.info(f"account added: {account}")
274
+ account.connected.set()
275
+ self.accounts[login_sn] = account
276
+ conn.accounts[login_sn] = account
277
+ await self.account_update(account, LoginStatus.ONLINE)
278
278
  else:
279
279
  account = self.accounts[login_sn]
280
280
  account.self_info = login
@@ -285,7 +285,7 @@ class App(Service):
285
285
  else account.connected.clear()
286
286
  )
287
287
  await self.account_update(account, login.status)
288
- elif event.type == EventType.LOGIN_REMOVED:
288
+ elif ev_type == EventType.LOGIN_REMOVED:
289
289
  if TYPE_CHECKING:
290
290
  assert isinstance(event, events.LoginEvent)
291
291
  login = event.login
@@ -305,12 +305,18 @@ class App(Service):
305
305
  account = self.accounts[login_sn]
306
306
 
307
307
  if self.event_callbacks:
308
- task = asyncio.gather(*(callback(account, event) for callback in self.event_callbacks))
309
- try:
310
- await task
311
- except Exception:
312
- traceback.print_exc()
313
- task.cancel()
308
+ if len(self.event_callbacks) == 1:
309
+ try:
310
+ await self.event_callbacks[0](account, event)
311
+ except Exception:
312
+ traceback.print_exc()
313
+ else:
314
+ task = asyncio.gather(*(callback(account, event) for callback in self.event_callbacks))
315
+ try:
316
+ await task
317
+ except Exception:
318
+ traceback.print_exc()
319
+ task.cancel()
314
320
 
315
321
  if event.type == EventType.LOGIN_REMOVED:
316
322
  logger.info(f"account removed: {account}")
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- from contextlib import suppress
5
4
  from typing import cast
6
5
 
7
6
  import aiohttp
@@ -47,12 +46,18 @@ class WsNetwork(BaseNetwork[WebsocketsInfo]):
47
46
  if self.connection is None:
48
47
  raise RuntimeError("connection is not established")
49
48
 
50
- async for msg in self.connection:
51
- if msg.type in {aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED}:
52
- self.close_signal.set()
49
+ while True:
50
+ msg = await self.connection.receive()
51
+ if msg.type in {
52
+ aiohttp.WSMsgType.CLOSE,
53
+ aiohttp.WSMsgType.ERROR,
54
+ aiohttp.WSMsgType.CLOSING,
55
+ aiohttp.WSMsgType.CLOSED,
56
+ }:
57
+ await self.connection_closed()
53
58
  return
54
59
  elif msg.type == aiohttp.WSMsgType.TEXT:
55
- data: dict = decode(cast(str, msg.data))
60
+ data: dict = decode(msg.data)
56
61
  if data["op"] == Opcode.EVENT:
57
62
  asyncio.create_task(self.event_parse_task(data["body"]))
58
63
  elif data["op"] == Opcode.META:
@@ -65,8 +70,6 @@ class WsNetwork(BaseNetwork[WebsocketsInfo]):
65
70
  else:
66
71
  logger.trace(f"Received payload: {data}")
67
72
  continue
68
- else:
69
- await self.connection_closed()
70
73
 
71
74
  async def send(self, payload: dict):
72
75
  if self.connection is None:
@@ -131,8 +134,10 @@ class WsNetwork(BaseNetwork[WebsocketsInfo]):
131
134
  async def _heartbeat(self):
132
135
  """心跳"""
133
136
  while True:
134
- with suppress(Exception):
137
+ try:
135
138
  await self.send({"op": 1})
139
+ except Exception as e:
140
+ logger.error(f"Error while sending heartbeat: {e!r}")
136
141
  await asyncio.sleep(9)
137
142
 
138
143
  async def daemon(self, manager: Launart, session: aiohttp.ClientSession):
@@ -4,7 +4,7 @@ from collections.abc import Iterable
4
4
  from typing import TYPE_CHECKING, Any, cast, overload
5
5
  from typing_extensions import deprecated
6
6
 
7
- from aiohttp import ClientSession, ClientTimeout, FormData
7
+ from aiohttp import BytesPayload, ClientSession, ClientTimeout, FormData
8
8
  from graia.amnesia.builtins.aiohttp import AiohttpClientService
9
9
  from launart import Launart
10
10
 
@@ -14,6 +14,7 @@ from satori.model import (
14
14
  Channel,
15
15
  Direction,
16
16
  Event,
17
+ Friend,
17
18
  Guild,
18
19
  IterablePageResult,
19
20
  Login,
@@ -28,8 +29,8 @@ from satori.model import (
28
29
  Upload,
29
30
  User,
30
31
  )
32
+ from satori.utils import encode_bytes
31
33
 
32
- from .. import Friend
33
34
  from .network.util import validate_response
34
35
 
35
36
  if TYPE_CHECKING:
@@ -63,7 +64,7 @@ class ApiProtocol:
63
64
  async def call_api(
64
65
  self, action: str | Api, params: dict | None = None, multipart: bool = False, method: str = "POST"
65
66
  ) -> dict:
66
- endpoint = self.account.config.api_base / (action.value if isinstance(action, Api) else action)
67
+ endpoint = f"{self.account.config.api_base!s}/{action.value if isinstance(action, Api) else action}"
67
68
  headers = {
68
69
  "Content-Type": "application/json",
69
70
  "Authorization": f"Bearer {self.account.config.token or ''}",
@@ -93,7 +94,11 @@ class ApiProtocol:
93
94
  async with self.session.request(
94
95
  method,
95
96
  endpoint,
96
- json=params or {},
97
+ data=BytesPayload(
98
+ encode_bytes(params or {}),
99
+ content_type="application/json",
100
+ encoding="utf-8",
101
+ ),
97
102
  headers=headers,
98
103
  timeout=self.timeout,
99
104
  ) as resp:
@@ -4,7 +4,7 @@ from dataclasses import InitVar, dataclass, field
4
4
  from io import BytesIO
5
5
  from pathlib import Path
6
6
  from types import UnionType
7
- from typing import Any, ClassVar, Final, TypeVar, Union, final, get_args, get_origin, overload
7
+ from typing import Any, ClassVar, Final, Literal, TypeVar, Union, final, get_args, get_origin, overload
8
8
  from typing_extensions import Self, override
9
9
 
10
10
  from ._vendor.fleep import get
@@ -17,7 +17,7 @@ TE = TypeVar("TE", bound="Element")
17
17
 
18
18
 
19
19
  def conv_bool(v: str) -> bool:
20
- if v.lower() not in ("true", "false"):
20
+ if v.lower() not in {"true", "false"}:
21
21
  raise ValueError(v)
22
22
  return v.lower() == "true"
23
23
 
@@ -28,13 +28,17 @@ class Element:
28
28
  _children: list["Element"] = field(init=False, default_factory=list)
29
29
 
30
30
  __names__: ClassVar[tuple[str, ...]]
31
- __convert_fields__: ClassVar[dict[str, Callable[[str], Any]]]
31
+ __convert_fields__: ClassVar[dict[str, Literal[True] | Callable[[str], Any]]]
32
+ __unpack_names__: ClassVar[frozenset[str]]
32
33
 
33
34
  def __init_subclass__(cls, **kwargs):
34
- cls.__convert_fields__ = {}
35
+ convert_fields = {}
36
+ for base in cls.__mro__:
37
+ if hasattr(base, "__convert_fields__"):
38
+ convert_fields.update(base.__convert_fields__)
35
39
  annotations = cls.__annotations__
36
40
  for name, typ in annotations.items():
37
- if name.startswith("_"):
41
+ if name.startswith("_") or isinstance(typ, InitVar):
38
42
  continue
39
43
  # _type = get_args(typ)[0] if hasattr(typ, "__origin__") else typ
40
44
  orig = get_origin(typ)
@@ -46,39 +50,49 @@ class Element:
46
50
  _type = args[0]
47
51
  else:
48
52
  _type = typ
49
- if _type is not str:
50
- if _type is bool:
51
- cls.__convert_fields__[name] = conv_bool
52
- elif _type in (list, dict):
53
- cls.__convert_fields__[name] = decode
53
+ if _type is bool:
54
+ convert_fields[name] = conv_bool
55
+ elif _type in (list, dict):
56
+ convert_fields[name] = decode
57
+ else:
58
+ convert_fields[name] = True if _type is str else _type
59
+ cls.__convert_fields__ = convert_fields
60
+ names = getattr(cls, "__names__", None)
61
+ for base in cls.__mro__:
62
+ if parent_names := getattr(base, "__names__", None):
63
+ if names is None:
64
+ names = parent_names
54
65
  else:
55
- cls.__convert_fields__[name] = _type
56
-
57
- @property
58
- def children(self) -> list["Element"]:
59
- return self._children
60
-
61
- @property
62
- def tag(self) -> str:
63
- return self.__class__.__name__.lower()
66
+ names = names + parent_names
67
+ cls.__unpack_names__ = frozenset(names if names is not None else annotations.keys())
64
68
 
65
69
  @classmethod
66
70
  def unpack(cls, attrs: dict[str, Any]):
67
71
  data = {}
68
- names = getattr(cls, "__names__", None)
69
- for name in cls.__dataclass_fields__.keys():
72
+ args = {}
73
+ convert_fields = cls.__convert_fields__
74
+ names = cls.__unpack_names__
75
+ for name in convert_fields:
70
76
  if name not in attrs:
71
77
  continue
72
- if name in cls.__convert_fields__:
73
- data[name] = cls.__convert_fields__[name](attrs[name])
74
- else:
78
+ convert = convert_fields[name]
79
+ if convert is True:
75
80
  data[name] = attrs[name]
76
- obj = cls(**{k: v for k, v in data.items() if names is None or k in names}) # type: ignore
81
+ else:
82
+ data[name] = convert(attrs[name])
83
+ if name in names:
84
+ args[name] = data[name]
85
+ obj = cls(**args) # type: ignore
77
86
  obj._attrs.update(data)
78
87
  return obj
79
88
 
80
- def __post_init__(self):
81
- self._attrs = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
89
+ @property
90
+ def children(self) -> list["Element"]:
91
+ return self._children
92
+
93
+ @property
94
+ def tag(self) -> str:
95
+ return self.__class__.__name__.lower()
82
96
 
83
97
  def attributes(self) -> str:
84
98
  def _attr(key: str, value: Any):
@@ -94,6 +108,8 @@ class Element:
94
108
  return "".join(_attr(k, v) for k, v in self._attrs.items())
95
109
 
96
110
  def dumps(self, strip: bool = False) -> str:
111
+ if not self._attrs:
112
+ self._attrs = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
97
113
  if self.tag == "text" and "text" in self._attrs:
98
114
  return self._attrs["text"] if strip else escape(self._attrs["text"])
99
115
  inner = "".join(c.dumps(strip) for c in self._children)
@@ -124,6 +140,9 @@ class Element:
124
140
  def __getitem__(self, key: str) -> Any:
125
141
  return self._attrs[key]
126
142
 
143
+ def raw(self) -> RawElement:
144
+ return RawElement(self.tag, self._attrs, [c.raw() for c in self._children])
145
+
127
146
 
128
147
  @dataclass(repr=False)
129
148
  class Text(Element):
@@ -131,6 +150,13 @@ class Text(Element):
131
150
 
132
151
  text: str
133
152
 
153
+ @override
154
+ @classmethod
155
+ def unpack(cls, attrs: dict[str, Any]):
156
+ obj = cls(attrs["text"])
157
+ obj._attrs["text"] = attrs["text"]
158
+ return obj
159
+
134
160
  @override
135
161
  def dumps(self, strip: bool = False) -> str:
136
162
  return self.text if strip else escape(self.text)
@@ -145,6 +171,12 @@ class At(Element):
145
171
  role: str | None = None
146
172
  type: str | None = None
147
173
 
174
+ @classmethod
175
+ def unpack(cls, attrs: dict[str, Any]):
176
+ obj = cls(attrs.get("id"), attrs.get("name"), attrs.get("role"), attrs.get("type"))
177
+ obj._attrs.update(attrs)
178
+ return obj
179
+
148
180
  @staticmethod
149
181
  def role_(
150
182
  role: str,
@@ -164,6 +196,12 @@ class Emoji(Element):
164
196
  id: str
165
197
  name: str | None = None
166
198
 
199
+ @classmethod
200
+ def unpack(cls, attrs: dict[str, Any]):
201
+ obj = cls(attrs["id"], attrs.get("name"))
202
+ obj._attrs.update(attrs)
203
+ return obj
204
+
167
205
  def to_model(self):
168
206
  from .model import EmojiObject
169
207
 
@@ -177,6 +215,12 @@ class Sharp(Element):
177
215
  id: str
178
216
  name: str | None = None
179
217
 
218
+ @classmethod
219
+ def unpack(cls, attrs: dict[str, Any]):
220
+ obj = cls(attrs["id"], attrs.get("name"))
221
+ obj._attrs.update(attrs)
222
+ return obj
223
+
180
224
 
181
225
  @dataclass(repr=False)
182
226
  class Link(Element):
@@ -184,6 +228,12 @@ class Link(Element):
184
228
 
185
229
  href: str
186
230
 
231
+ @classmethod
232
+ def unpack(cls, attrs: dict[str, Any]):
233
+ obj = cls(attrs["href"])
234
+ obj._attrs.update(attrs)
235
+ return obj
236
+
187
237
  def __post_call__(self):
188
238
  if not self._children:
189
239
  return
@@ -255,7 +305,6 @@ class Resource(Element):
255
305
  return cls(**data)
256
306
 
257
307
  def __post_init__(self, extra: dict[str, Any] | None = None):
258
- super().__post_init__()
259
308
  if extra:
260
309
  self._attrs.update(extra)
261
310
 
@@ -267,7 +316,7 @@ class Image(Resource):
267
316
  width: int | None = None
268
317
  height: int | None = None
269
318
 
270
- __names__ = ("src", "title", "width", "height")
319
+ __names__ = ("width", "height")
271
320
 
272
321
  @property
273
322
  @override
@@ -282,7 +331,7 @@ class Audio(Resource):
282
331
  duration: float | None = None
283
332
  poster: str | None = None
284
333
 
285
- __names__ = ("src", "title", "duration", "poster")
334
+ __names__ = ("duration", "poster")
286
335
 
287
336
 
288
337
  @dataclass(repr=False)
@@ -294,7 +343,7 @@ class Video(Resource):
294
343
  duration: float | None = None
295
344
  poster: str | None = None
296
345
 
297
- __names__ = ("src", "title", "width", "height", "duration", "poster")
346
+ __names__ = ("width", "height", "duration", "poster")
298
347
 
299
348
 
300
349
  @dataclass(repr=False)
@@ -303,7 +352,7 @@ class File(Resource):
303
352
 
304
353
  poster: str | None = None
305
354
 
306
- __names__ = ("src", "title", "poster")
355
+ __names__ = ("poster",)
307
356
 
308
357
 
309
358
  @dataclass(init=False, repr=False)
@@ -312,6 +361,12 @@ class Style(Element):
312
361
 
313
362
  __names__ = ()
314
363
 
364
+ @classmethod
365
+ def unpack(cls, attrs: dict[str, Any]):
366
+ obj = cls()
367
+ obj._attrs.update(attrs)
368
+ return obj
369
+
315
370
  def __init__(self, *text: "str | Text | Style"):
316
371
  super().__init__()
317
372
  self.__call__(*text)
@@ -422,6 +477,12 @@ class Message(Element):
422
477
  id: str | None
423
478
  forward: bool | None
424
479
 
480
+ @classmethod
481
+ def unpack(cls, attrs: dict[str, Any]):
482
+ obj = cls(attrs.get("id"), conv_bool(attrs["forward"]) if "forward" in attrs else None)
483
+ obj._attrs.update(attrs)
484
+ return obj
485
+
425
486
  def __init__(
426
487
  self,
427
488
  id: str | None = None,
@@ -451,6 +512,12 @@ class Author(Element):
451
512
  name: str | None = None
452
513
  avatar: str | None = None
453
514
 
515
+ @classmethod
516
+ def unpack(cls, attrs: dict[str, Any]):
517
+ obj = cls(attrs["id"], attrs.get("name"), attrs.get("avatar"))
518
+ obj._attrs.update(attrs)
519
+ return obj
520
+
454
521
 
455
522
  @dataclass(repr=False)
456
523
  class Button(Element):
@@ -462,6 +529,12 @@ class Button(Element):
462
529
  text: str | None = None
463
530
  theme: str | None = None
464
531
 
532
+ @classmethod
533
+ def unpack(cls, attrs: dict[str, Any]):
534
+ obj = cls(attrs["type"], attrs.get("id"), attrs.get("href"), attrs.get("text"), attrs.get("theme"))
535
+ obj._attrs.update(attrs)
536
+ return obj
537
+
465
538
  @classmethod
466
539
  def action(cls, button_id: str, theme: str | None = None):
467
540
  return Button("action", id=button_id, theme=theme)
@@ -533,7 +606,7 @@ class Raw(Element):
533
606
 
534
607
  @override
535
608
  def dumps(self, strip: bool = False):
536
- return self.content if strip else escape(self.content)
609
+ return self.content
537
610
 
538
611
 
539
612
  def register_element(cls: type[TE], tag: str | None = None) -> type[TE]:
@@ -547,7 +620,7 @@ def register_element(cls: type[TE], tag: str | None = None) -> type[TE]:
547
620
  return cls
548
621
 
549
622
 
550
- ELEMENT_TYPE_MAP = {
623
+ COMMON_TYPE_MAP = {
551
624
  "text": Text,
552
625
  "at": At,
553
626
  "emoji": Emoji,
@@ -558,6 +631,9 @@ ELEMENT_TYPE_MAP = {
558
631
  "video": Video,
559
632
  "file": File,
560
633
  "author": Author,
634
+ "button": Button,
635
+ "a": Link,
636
+ "link": Link,
561
637
  }
562
638
 
563
639
  STYLE_TYPE_MAP = {
@@ -586,35 +662,32 @@ STYLE_TYPE_MAP = {
586
662
  "br": Br,
587
663
  }
588
664
 
665
+ ELEMENT_TYPE_MAP = {**COMMON_TYPE_MAP, **STYLE_TYPE_MAP}
666
+
589
667
 
590
668
  def transform(elements: list[RawElement]) -> list[Element]:
591
669
  msg = []
592
670
  for elem in elements:
593
671
  tag = elem.tag()
594
672
  if tag in ELEMENT_TYPE_MAP:
595
- seg_cls = ELEMENT_TYPE_MAP[tag]
596
- msg.append(seg_cls.unpack(elem.attrs)(*transform(elem.children)))
597
- elif tag in ("a", "link"):
598
- link = Link.unpack(elem.attrs)
599
- if elem.children:
600
- link(*transform(elem.children))
601
- msg.append(link)
602
- elif tag == "button":
603
- button = Button.unpack(elem.attrs)
673
+ seg = ELEMENT_TYPE_MAP[tag].unpack(elem.attrs)
604
674
  if elem.children:
605
- button(*transform(elem.children))
606
- msg.append(button)
607
- elif tag in STYLE_TYPE_MAP:
608
- seg_cls = STYLE_TYPE_MAP[tag]
609
- msg.append(seg_cls.unpack(elem.attrs)(*transform(elem.children)))
610
- elif tag in ("br", "newline"):
611
- msg.append(Br())
675
+ seg(*transform(elem.children))
676
+ msg.append(seg)
612
677
  elif tag == "message":
613
678
  msg.append(Message.unpack(elem.attrs)(*transform(elem.children)))
614
679
  elif tag == "quote":
615
- msg.append(Quote.unpack(elem.attrs)(*transform(elem.children)))
680
+ quot = Quote.unpack(elem.attrs)
681
+ if elem.children:
682
+ quot(*transform(elem.children))
683
+ msg.append(quot)
684
+ elif tag in ("br", "newline"):
685
+ msg.append(Br())
616
686
  else:
617
- msg.append(Custom(elem.type, elem.attrs)(*transform(elem.children)))
687
+ custom = Custom(tag, elem.attrs)
688
+ if elem.children:
689
+ custom(*transform(elem.children))
690
+ msg.append(custom)
618
691
  return msg
619
692
 
620
693
 
@@ -1,6 +1,6 @@
1
1
  import mimetypes
2
2
  from collections.abc import AsyncIterable, Awaitable, Callable
3
- from dataclasses import Field, dataclass, field
3
+ from dataclasses import dataclass, field
4
4
  from datetime import datetime
5
5
  from enum import IntEnum
6
6
  from os import PathLike
@@ -13,16 +13,20 @@ from .parser import Element as RawElement
13
13
  from .parser import parse
14
14
 
15
15
 
16
- @dataclass
16
+ @dataclass(slots=True)
17
17
  class ModelBase:
18
18
  __converter__: ClassVar[dict[str, Callable[[Any], Any]]] = {}
19
19
  _raw_data: dict[str, Any] = field(init=False, default_factory=dict, repr=False, compare=False, hash=False)
20
20
 
21
+ @classmethod
22
+ def before_parse(cls, raw: dict):
23
+ pass
24
+
21
25
  @classmethod
22
26
  def parse(cls: type[Self], raw: dict) -> Self:
23
- fs: dict[str, Field] = cls.__dataclass_fields__
24
27
  data = {}
25
- for name in fs.keys():
28
+ cls.before_parse(data)
29
+ for name in cls.__dataclass_fields__:
26
30
  if name in raw:
27
31
  if name in cls.__converter__:
28
32
  data[name] = cls.__converter__[name](raw[name])
@@ -32,6 +36,42 @@ class ModelBase:
32
36
  obj._raw_data = raw
33
37
  return obj
34
38
 
39
+ def __init_subclass__(cls, **kwargs):
40
+ has_converter = False
41
+ keys = set()
42
+ for c in cls.__mro__:
43
+ if c is Generic:
44
+ Generic.__init_subclass__.__func__(cls, **kwargs)
45
+ if getattr(c, "__converter__", None):
46
+ has_converter = True
47
+ keys.update(getattr(c, "__annotations__", {}).keys())
48
+ keys = frozenset(k for k in keys if not k.startswith("_"))
49
+
50
+ def parse1(cls_: type[Self], raw: dict, _keys=keys) -> Self:
51
+ data = {k: v for k, v in raw.items() if k in _keys}
52
+ obj = cls_(**data) # type: ignore
53
+ obj._raw_data = raw
54
+ return obj
55
+
56
+ def parse2(cls_: type[Self], raw: dict, _keys=keys) -> Self:
57
+ data = {}
58
+ cls_.before_parse(data)
59
+ for name in _keys:
60
+ if name in raw:
61
+ if name in cls_.__converter__:
62
+ data[name] = cls_.__converter__[name](raw[name])
63
+ else:
64
+ data[name] = raw[name]
65
+ obj = cls_(**data) # type: ignore
66
+ obj._raw_data = raw
67
+ return obj
68
+
69
+ if "parse" not in cls.__dict__:
70
+ if has_converter:
71
+ cls.parse = classmethod(parse2) # type: ignore
72
+ else:
73
+ cls.parse = classmethod(parse1) # type: ignore
74
+
35
75
  def dump(self) -> dict:
36
76
  raise NotImplementedError
37
77
 
@@ -43,7 +83,7 @@ class ChannelType(IntEnum):
43
83
  VOICE = 3
44
84
 
45
85
 
46
- @dataclass
86
+ @dataclass(slots=True)
47
87
  class Channel(ModelBase):
48
88
  id: str
49
89
  type: ChannelType = ChannelType.TEXT
@@ -61,7 +101,7 @@ class Channel(ModelBase):
61
101
  return res
62
102
 
63
103
 
64
- @dataclass
104
+ @dataclass(slots=True)
65
105
  class Guild(ModelBase):
66
106
  id: str
67
107
  name: str | None = None
@@ -76,7 +116,7 @@ class Guild(ModelBase):
76
116
  return res
77
117
 
78
118
 
79
- @dataclass
119
+ @dataclass(slots=True)
80
120
  class User(ModelBase):
81
121
  id: str
82
122
  name: str | None = None
@@ -97,7 +137,7 @@ class User(ModelBase):
97
137
  return res
98
138
 
99
139
 
100
- @dataclass
140
+ @dataclass(slots=True)
101
141
  class Friend(ModelBase):
102
142
  user: User | None = None
103
143
  nick: str | None = None
@@ -117,7 +157,7 @@ class Friend(ModelBase):
117
157
  return res
118
158
 
119
159
 
120
- @dataclass
160
+ @dataclass(slots=True)
121
161
  class Role(ModelBase):
122
162
  id: str
123
163
  name: str | None = None
@@ -126,7 +166,7 @@ class Role(ModelBase):
126
166
  def parse(cls, raw: str | dict):
127
167
  if isinstance(raw, str):
128
168
  return cls(id=raw)
129
- return super().parse(raw)
169
+ return cls(raw["id"], raw.get("name"))
130
170
 
131
171
  def dump(self):
132
172
  res = {"id": self.id}
@@ -135,7 +175,7 @@ class Role(ModelBase):
135
175
  return res
136
176
 
137
177
 
138
- @dataclass
178
+ @dataclass(slots=True)
139
179
  class Member(ModelBase):
140
180
  user: User | None = None
141
181
  nick: str | None = None
@@ -177,11 +217,11 @@ class LoginStatus(IntEnum):
177
217
  """正在重新连接"""
178
218
 
179
219
 
180
- @dataclass
220
+ @dataclass(slots=True, kw_only=True)
181
221
  class Login(ModelBase):
182
- sn: int
183
- status: LoginStatus
184
- adapter: str
222
+ sn: int = 0
223
+ status: LoginStatus = LoginStatus.ONLINE
224
+ adapter: str = "satori"
185
225
  platform: str
186
226
  user: User
187
227
  features: list[str] = field(default_factory=list)
@@ -203,29 +243,22 @@ class Login(ModelBase):
203
243
  return res
204
244
 
205
245
  @classmethod
206
- def parse(cls, raw: dict):
246
+ def before_parse(cls, raw: dict):
207
247
  if "self_id" in raw and "user" not in raw:
208
248
  raw["user"] = {"id": raw["self_id"]}
209
- if "sn" not in raw:
210
- raw["sn"] = 0
211
- if "adapter" not in raw:
212
- raw["adapter"] = "satori"
213
- if "status" not in raw:
214
- raw["status"] = LoginStatus.ONLINE
215
- return super().parse(raw)
216
249
 
217
250
  @property
218
251
  def id(self) -> str:
219
252
  return self.user.id
220
253
 
221
254
 
222
- @dataclass
255
+ @dataclass(slots=True)
223
256
  class LoginPartial(Login):
224
257
  platform: str | None = None
225
258
  user: User | None = None
226
259
 
227
260
 
228
- @dataclass
261
+ @dataclass(slots=True)
229
262
  class ArgvInteraction(ModelBase):
230
263
  name: str
231
264
  arguments: list
@@ -235,7 +268,7 @@ class ArgvInteraction(ModelBase):
235
268
  return {"name": self.name, "arguments": self.arguments, "options": self.options}
236
269
 
237
270
 
238
- @dataclass
271
+ @dataclass(slots=True)
239
272
  class ButtonInteraction(ModelBase):
240
273
  id: str
241
274
  data: str | None = None
@@ -262,7 +295,7 @@ class Opcode(IntEnum):
262
295
  """元信息更新 (接收)"""
263
296
 
264
297
 
265
- @dataclass
298
+ @dataclass(slots=True)
266
299
  class Identify(ModelBase):
267
300
  token: str | None = None
268
301
  sn: int | None = None
@@ -271,7 +304,7 @@ class Identify(ModelBase):
271
304
  def parse(cls, raw: dict):
272
305
  if "sequence" in raw and "sn" not in raw:
273
306
  raw["sn"] = raw["sequence"]
274
- return super().parse(raw)
307
+ return cls(token=raw.get("token"), sn=raw.get("sn"))
275
308
 
276
309
  @property
277
310
  def sequence(self) -> int | None:
@@ -281,7 +314,7 @@ class Identify(ModelBase):
281
314
  return {k: v for k, v in (("token", self.token), ("sn", self.sn)) if v is not None}
282
315
 
283
316
 
284
- @dataclass
317
+ @dataclass(slots=True)
285
318
  class Ready(ModelBase):
286
319
  logins: list[LoginPartial]
287
320
  proxy_urls: list[str] = field(default_factory=list)
@@ -292,7 +325,7 @@ class Ready(ModelBase):
292
325
  return {"logins": [login.dump() for login in self.logins], "proxy_urls": self.proxy_urls}
293
326
 
294
327
 
295
- @dataclass
328
+ @dataclass(slots=True)
296
329
  class MetaPayload(ModelBase):
297
330
  """Meta 信令"""
298
331
 
@@ -302,7 +335,7 @@ class MetaPayload(ModelBase):
302
335
  return {"proxy_urls": self.proxy_urls}
303
336
 
304
337
 
305
- @dataclass
338
+ @dataclass(slots=True)
306
339
  class Meta(ModelBase):
307
340
  """Meta 数据"""
308
341
 
@@ -315,7 +348,7 @@ class Meta(ModelBase):
315
348
  return {"logins": [login.dump() for login in self.logins], "proxy_urls": self.proxy_urls}
316
349
 
317
350
 
318
- @dataclass
351
+ @dataclass(slots=True)
319
352
  class EmojiObject(ModelBase):
320
353
  id: str
321
354
  name: str | None = None
@@ -330,7 +363,7 @@ class EmojiObject(ModelBase):
330
363
  return Emoji(self.id, self.name)
331
364
 
332
365
 
333
- @dataclass
366
+ @dataclass(slots=True)
334
367
  class MessageObject(ModelBase):
335
368
  id: str
336
369
  content: str = ""
@@ -342,6 +375,8 @@ class MessageObject(ModelBase):
342
375
  updated_at: datetime | None = None
343
376
  referrer: dict | None = None
344
377
 
378
+ _parsed_message: list[Element] | None = field(init=False, default=None, repr=False, compare=False, hash=False)
379
+
345
380
  @classmethod
346
381
  def from_elements(
347
382
  cls,
@@ -361,10 +396,10 @@ class MessageObject(ModelBase):
361
396
 
362
397
  @property
363
398
  def message(self) -> list[Element]:
364
- if hasattr(self, "_parsed_message"):
399
+ if self._parsed_message is not None:
365
400
  return self._parsed_message
366
- self._parsed_message = transform(parse(self.content))
367
- return self._parsed_message
401
+ self._parsed_message = msg = transform(parse(self.content))
402
+ return msg
368
403
 
369
404
  @message.setter
370
405
  def message(self, value: list[Element]):
@@ -372,11 +407,10 @@ class MessageObject(ModelBase):
372
407
  self.content = "".join(str(i) for i in value)
373
408
 
374
409
  @classmethod
375
- def parse(cls, raw: dict):
410
+ def before_parse(cls, raw: dict):
376
411
  if "elements" in raw and "content" not in raw:
377
412
  content = [RawElement(*item.values()) for item in raw["elements"]]
378
413
  raw["content"] = "".join(str(i) for i in content)
379
- return super().parse(raw)
380
414
 
381
415
  __converter__ = {
382
416
  "channel": Channel.parse,
@@ -406,7 +440,7 @@ class MessageObject(ModelBase):
406
440
  return res
407
441
 
408
442
 
409
- @dataclass
443
+ @dataclass(slots=True)
410
444
  class Event(ModelBase):
411
445
  type: str
412
446
  timestamp: datetime
@@ -444,7 +478,7 @@ class Event(ModelBase):
444
478
  }
445
479
 
446
480
  @classmethod
447
- def parse(cls, raw: dict):
481
+ def before_parse(cls, raw: dict):
448
482
  if "id" in raw and "sn" not in raw:
449
483
  raw["sn"] = raw["id"]
450
484
  if "platform" in raw and "self_id" in raw and "login" not in raw:
@@ -458,7 +492,6 @@ class Event(ModelBase):
458
492
  if "login" not in raw:
459
493
  raw["login"] = {"sn": 0, "status": LoginStatus.ONLINE, "platform": raw.get("platform", "unknown")}
460
494
  raw["login"]["user"] = {"id": raw["self_id"]}
461
- return super().parse(raw)
462
495
 
463
496
  @property
464
497
  def platform(self):
@@ -509,7 +542,7 @@ class Event(ModelBase):
509
542
  T = TypeVar("T", bound=ModelBase)
510
543
 
511
544
 
512
- @dataclass
545
+ @dataclass(slots=True)
513
546
  class PageResult(ModelBase, Generic[T]):
514
547
  data: list[T]
515
548
  next: str | None = None
@@ -526,7 +559,7 @@ class PageResult(ModelBase, Generic[T]):
526
559
  return res
527
560
 
528
561
 
529
- @dataclass
562
+ @dataclass(slots=True)
530
563
  class PageDequeResult(PageResult[T]):
531
564
  prev: str | None = None
532
565
 
@@ -569,7 +602,7 @@ Direction: TypeAlias = Literal["before", "after", "around"]
569
602
  Order: TypeAlias = Literal["asc", "desc"]
570
603
 
571
604
 
572
- @dataclass
605
+ @dataclass(slots=True)
573
606
  class Upload:
574
607
  file: bytes | IO[bytes] | PathLike
575
608
  mimetype: str = "image/png"
@@ -12,27 +12,37 @@ def escape(text: str, inline: bool = False) -> str:
12
12
  return result.replace('"', "&quot;") if inline else result
13
13
 
14
14
 
15
+ uc_escape_pat1 = re.compile(r"&#(\d+);")
16
+ uc_escape_pat2 = re.compile(r"&#x([0-9a-f]+);")
17
+ uc_escape_pat3 = re.compile(r"&(amp|#38|#x26);")
18
+
19
+
15
20
  def unescape(text: str) -> str:
16
21
  result = text.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"')
17
- result = re.sub(r"&#(\d+);", lambda m: m[0] if m[1] == "38" else chr(int(m[1])), result)
18
- result = re.sub(r"&#x([0-9a-f]+);", lambda m: m[0] if m[1] == "26" else chr(int(m[1], 16)), result)
19
- return re.sub("&(amp|#38|#x26);", "&", result)
22
+ result = uc_escape_pat1.sub(lambda m: m[0] if m[1] == "38" else chr(int(m[1])), result)
23
+ result = uc_escape_pat2.sub(lambda m: m[0] if m[1] == "26" else chr(int(m[1], 16)), result)
24
+ return uc_escape_pat3.sub("&", result)
20
25
 
21
26
 
22
27
  def uncapitalize(source: str) -> str:
23
- return source[0].lower() + source[1:]
28
+ return source[:1].lower() + source[1:]
29
+
30
+
31
+ camel_pat = re.compile(r"[_-][a-z]")
32
+ param_pat = re.compile(r".[A-Z]+")
33
+ snake_pat = re.compile(r".[A-Z]")
24
34
 
25
35
 
26
36
  def camel_case(source: str) -> str:
27
- return re.sub("[_-][a-z]", lambda mat: mat[0][1:].upper(), source)
37
+ return camel_pat.sub(lambda mat: mat[0][1:].upper(), source)
28
38
 
29
39
 
30
40
  def param_case(source: str) -> str:
31
- return re.sub(".[A-Z]+", lambda mat: mat[0][0] + "-" + mat[0][1:].lower(), uncapitalize(source).replace("_", "-"))
41
+ return param_pat.sub(lambda mat: mat[0][0] + "-" + mat[0][1:].lower(), uncapitalize(source).replace("_", "-"))
32
42
 
33
43
 
34
44
  def snake_case(source: str) -> str:
35
- return re.sub(".[A-Z]", lambda mat: mat[0][0] + "_" + mat[0][1:].lower(), uncapitalize(source).replace("-", "_"))
45
+ return snake_pat.sub(lambda mat: mat[0][0] + "_" + mat[0][1:].lower(), uncapitalize(source).replace("-", "_"))
36
46
 
37
47
 
38
48
  def ensure_list(value: T | list[T] | None) -> list[T]:
@@ -49,9 +59,9 @@ def make_element(content: Union[str, bool, int, float, "Element"]) -> Optional["
49
59
  if isinstance(content, Element):
50
60
  return content
51
61
  if isinstance(content, (bool, int, float)):
52
- return Element(type="text", attrs={"text": str(content)})
62
+ return Element.parse(type="text", attrs={"text": str(content)})
53
63
  if isinstance(content, str) and content:
54
- return Element(type="text", attrs={"text": content})
64
+ return Element.parse(type="text", attrs={"text": content})
55
65
  if content is not None:
56
66
  raise ValueError(f"Invalid content: {content!r}")
57
67
 
@@ -68,7 +78,9 @@ class Element:
68
78
  type: str
69
79
  attrs: dict[str, Any]
70
80
  children: list["Element"]
71
- source: str | None = None
81
+ source: str | None
82
+
83
+ __slots__ = ("type", "attrs", "children", "source")
72
84
 
73
85
  def __init__(
74
86
  self,
@@ -99,6 +111,31 @@ class Element:
99
111
  elif not self.attrs:
100
112
  self.attrs["text"] = ""
101
113
 
114
+ @classmethod
115
+ def parse(
116
+ cls, type: str, attrs: dict[str, Any], children: list["Element"] | None = None, source: str | None = None
117
+ ) -> "Element":
118
+ elem = cls.__new__(cls)
119
+ elem.type = type
120
+ elem.attrs = {}
121
+ elem.children = []
122
+ elem.source = source
123
+ for k, v in attrs.items():
124
+ if v is None:
125
+ continue
126
+ if k == "children":
127
+ elem.children.extend(ensure_list(v))
128
+ else:
129
+ elem.attrs[camel_case(k)] = v
130
+ if children:
131
+ elem.children.extend(children)
132
+ if type == "text":
133
+ if "content" in elem.attrs:
134
+ elem.attrs["text"] = elem.attrs.pop("content")
135
+ elif not elem.attrs:
136
+ elem.attrs["text"] = ""
137
+ return elem
138
+
102
139
  def tag(self):
103
140
  if self.type == "component":
104
141
  if is_ := self.attrs.get("is"):
@@ -234,6 +271,9 @@ tag_pat2 = re.compile(
234
271
  attr_pat1 = re.compile(r"([^\s=]+)(?:=\"(?P<value1>[^\"]*)\"|='(?P<value2>[^']*)')?", re.S)
235
272
  attr_pat2 = re.compile(r"([^\s=]+)(?:=\"(?P<value1>[^\"]*)\"|='(?P<value2>[^']*)'|=\{(?P<curly>[^\}]+)\})?", re.S)
236
273
 
274
+ space_pat1 = re.compile(r"^\s*\n\s*", re.MULTILINE)
275
+ space_pat2 = re.compile(r"\s*\n\s*$", re.MULTILINE)
276
+
237
277
 
238
278
  class Position(IntEnum):
239
279
  OPEN = 0
@@ -242,7 +282,7 @@ class Position(IntEnum):
242
282
  CONTINUE = 3
243
283
 
244
284
 
245
- @dataclass
285
+ @dataclass(slots=True)
246
286
  class Token:
247
287
  type: Literal["angle", "curly"]
248
288
  name: str
@@ -299,7 +339,7 @@ def parse_tokens(tokens: list[str | Token], context: dict | None = None) -> list
299
339
  result: list[Element] = []
300
340
  for token in tokens:
301
341
  if isinstance(token, str):
302
- result.append(Element(type="text", attrs={"text": token}))
342
+ result.append(Element.parse(type="text", attrs={"text": token}))
303
343
  elif token.type == "angle":
304
344
  attrs = {}
305
345
  attr_pat = attr_pat2 if context is not None else attr_pat1
@@ -318,10 +358,10 @@ def parse_tokens(tokens: list[str | Token], context: dict | None = None) -> list
318
358
  attrs[key] = True
319
359
  token.extra = token.extra[mat.end() :]
320
360
  result.append(
321
- Element(
361
+ Element.parse(
322
362
  token.name,
323
363
  attrs,
324
- *parse_tokens(token.children["default"], context) if token.children else [],
364
+ parse_tokens(token.children["default"], context) if token.children else [],
325
365
  )
326
366
  )
327
367
  elif not token.name:
@@ -351,9 +391,9 @@ def parse(src: str, context: dict | None = None):
351
391
  def parse_content(source: str, _start: bool, _end: bool):
352
392
  source = unescape(source)
353
393
  if _start:
354
- source = re.sub(r"^\s*\n\s*", "", source, flags=re.MULTILINE)
394
+ source = space_pat1.sub("", source)
355
395
  if _end:
356
- source = re.sub(r"\s*\n\s*$", "", source, flags=re.MULTILINE)
396
+ source = space_pat2.sub("", source)
357
397
  push_text(source)
358
398
 
359
399
  tag_pat = tag_pat2 if context is not None else tag_pat1
File without changes
@@ -71,7 +71,7 @@ StarletteRequest.json = _json
71
71
 
72
72
 
73
73
  async def _request_handler(action: str, request: StarletteRequest, func: RouteCall, platform: str, self_id: str):
74
- if action == Api.UPLOAD_CREATE.value:
74
+ if action == Api.UPLOAD_CREATE:
75
75
  async with request.form() as form:
76
76
  res = await func(
77
77
  Request(
@@ -314,12 +314,10 @@ class Server(Service, RouterMixin):
314
314
  if not self._adapters and not self.routes:
315
315
  return Response(status_code=404, content=request.path_params["method"])
316
316
  action = request.path_params["action"]
317
- if "X-Platform" not in request.headers and "Satori-Platform" not in request.headers:
318
- return Response(status_code=401, content="Missing header X-Platform or Satori-Platform")
319
- platform: str = request.headers.get("X-Platform") or request.headers.get("Satori-Platform") # type: ignore
320
- if "X-Self-ID" not in request.headers and "Satori-User-ID" not in request.headers:
321
- return Response(status_code=401, content="Missing header X-Self-ID or Satori-User-ID")
322
- self_id: str = request.headers.get("X-Self-ID") or request.headers.get("Satori-User-ID") # type: ignore
317
+ platform = request.headers.get("Satori-Platform")
318
+ self_id = request.headers.get("Satori-User-ID")
319
+ if platform is None or self_id is None:
320
+ return Response(status_code=401, content="Missing header Satori-Platform or Satori-User-ID")
323
321
 
324
322
  for _router in self._adapters:
325
323
  if action in _router.routes:
@@ -440,7 +438,7 @@ class Server(Service, RouterMixin):
440
438
  for provider in self.providers:
441
439
  logins.extend(await provider.get_logins())
442
440
  proxy_urls.extend(provider.proxy_urls())
443
- return JSONResponse(content=Meta(logins, proxy_urls).dump())
441
+ return JSONResponse(content=Meta(logins=logins, proxy_urls=proxy_urls).dump())
444
442
 
445
443
  async def webhook_create_handler(self, request: StarletteRequest):
446
444
  body = await request.json()
@@ -556,7 +554,7 @@ class Server(Service, RouterMixin):
556
554
  stop_signal: Iterable[signal.Signals] = (signal.SIGINT,),
557
555
  ):
558
556
  if manager is None:
559
- manager = it(Launart)
557
+ manager = manager or it(Launart)
560
558
  manager.add_component(self.asgi_service)
561
559
  manager.add_component(self)
562
560
  with suppress(ValueError):
@@ -24,10 +24,16 @@ try:
24
24
  def encode(obj):
25
25
  return encoder.encode(obj).decode()
26
26
 
27
+ def encode_bytes(obj):
28
+ return encoder.encode(obj)
29
+
27
30
  except ImportError:
28
31
  import json
29
32
 
30
33
  def encode(obj):
31
34
  return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
32
35
 
36
+ def encode_bytes(obj):
37
+ return json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
38
+
33
39
  decode = json.loads
File without changes