pyroflow 0.1.6__tar.gz → 0.2.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.
Files changed (58) hide show
  1. {pyroflow-0.1.6 → pyroflow-0.2.0}/PKG-INFO +12 -8
  2. {pyroflow-0.1.6 → pyroflow-0.2.0}/README.md +4 -4
  3. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyproject.toml +17 -3
  4. pyroflow-0.2.0/pyroflow/__meta__.py +1 -0
  5. pyroflow-0.2.0/pyroflow/enums.py +21 -0
  6. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/listener_coordinator/listener_coordinator.py +1 -8
  7. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/models.py +8 -9
  8. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/typings.py +1 -8
  9. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_coordinated/callback_query_coordinated.py +1 -1
  10. pyroflow-0.2.0/pyroflow/update_history/message_history.py +42 -0
  11. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/utils/async_tools.py +58 -26
  12. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/utils/classes.py +1 -7
  13. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/utils/typings.py +16 -20
  14. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/utils/validate_tools.py +1 -3
  15. pyroflow-0.2.0/tests/__init__.py +0 -0
  16. pyroflow-0.2.0/tests/conftest.py +70 -0
  17. pyroflow-0.2.0/tests/test_compat_static.py +144 -0
  18. pyroflow-0.2.0/tests/test_coordinator.py +110 -0
  19. pyroflow-0.2.0/tests/test_enums.py +31 -0
  20. pyroflow-0.2.0/tests/test_history.py +143 -0
  21. pyroflow-0.2.0/tests/test_imports.py +100 -0
  22. pyroflow-0.2.0/tests/test_listener.py +136 -0
  23. pyroflow-0.2.0/tests/test_models.py +84 -0
  24. pyroflow-0.2.0/tests/test_patch_cls.py +118 -0
  25. pyroflow-0.2.0/tests/test_utils.py +149 -0
  26. pyroflow-0.1.6/pyroflow/__meta__.py +0 -1
  27. pyroflow-0.1.6/pyroflow/enums.py +0 -10
  28. pyroflow-0.1.6/pyroflow/update_history/message_history.py +0 -6
  29. {pyroflow-0.1.6 → pyroflow-0.2.0}/LICENSE +0 -0
  30. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/__init__.py +0 -0
  31. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/client.py +0 -0
  32. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/dispatcher.py +0 -0
  33. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/errors.py +0 -0
  34. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/listener_coordinator/__init__.py +0 -0
  35. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/listener_coordinator/memory_listener_coordinator.py +0 -0
  36. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/listener_coordinator/redis_listener_coordinator.py +0 -0
  37. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/types.py +0 -0
  38. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_coordinated/__init__.py +0 -0
  39. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_coordinated/message_coordinated.py +0 -0
  40. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_coordinated/update_coordinated.py +0 -0
  41. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_coordinator/__init__.py +0 -0
  42. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_coordinator/memory_update_coordinator.py +0 -0
  43. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_coordinator/redis_update_coordinator.py +0 -0
  44. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_coordinator/update_coordinator.py +0 -0
  45. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_history/__init__.py +0 -0
  46. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_history/callback_query_history.py +0 -0
  47. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_history/update_history.py +0 -0
  48. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_history_store/__init__.py +0 -0
  49. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_history_store/memory_update_history_store.py +0 -0
  50. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_history_store/update_history_store.py +0 -0
  51. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_listener/__init__.py +0 -0
  52. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_listener/callback_query_listener.py +0 -0
  53. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_listener/message_listener.py +0 -0
  54. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/update_listener/update_listener.py +0 -0
  55. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/utils/__init__.py +0 -0
  56. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/utils/enums.py +0 -0
  57. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/utils/iter_tools.py +0 -0
  58. {pyroflow-0.1.6 → pyroflow-0.2.0}/pyroflow/utils/misc_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyroflow
3
- Version: 0.1.6
3
+ Version: 0.2.0
4
4
  Summary: Conversation-oriented Pyrogram extension with per-update listeners and multi-session coordination
5
5
  Project-URL: Homepage, https://github.com/eeeob/pyroflow
6
6
  Project-URL: Bug Tracker, https://github.com/eeeob/pyroflow/issues
@@ -13,7 +13,6 @@ Classifier: Framework :: AsyncIO
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.9
17
16
  Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
19
18
  Classifier: Programming Language :: Python :: 3.12
@@ -22,16 +21,21 @@ Classifier: Programming Language :: Python :: 3.14
22
21
  Classifier: Topic :: Communications :: Chat
23
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
23
  Classifier: Typing :: Typed
25
- Requires-Python: >=3.9
24
+ Requires-Python: >=3.10
26
25
  Requires-Dist: cachetools
27
26
  Requires-Dist: kurigram>=2.2.0
28
27
  Requires-Dist: sortedcontainers==2.4.0
29
- Requires-Dist: typing-extensions>=4.6.0; python_version < '3.10'
28
+ Requires-Dist: typing-extensions>=4.6.0; python_version < '3.13'
30
29
  Provides-Extra: dev
31
30
  Requires-Dist: hatch<=1.16.5; extra == 'dev'
32
31
  Requires-Dist: twine<=6.2.0; extra == 'dev'
33
32
  Provides-Extra: redis
34
33
  Requires-Dist: redis>=4.2.0; extra == 'redis'
34
+ Provides-Extra: test
35
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
36
+ Requires-Dist: pytest>=7.0; extra == 'test'
37
+ Requires-Dist: redis>=4.2.0; extra == 'test'
38
+ Requires-Dist: tomli; (python_version < '3.11') and extra == 'test'
35
39
  Description-Content-Type: text/markdown
36
40
 
37
41
  # pyroflow
@@ -72,7 +76,7 @@ pyroflow solves all three problems:
72
76
 
73
77
  ## Installation
74
78
 
75
- **Minimum requirements:** Python 3.9+
79
+ **Minimum requirements:** Python 3.10+
76
80
 
77
81
  ```
78
82
  pip install pyroflow # core
@@ -233,10 +237,10 @@ client.run()
233
237
  A `UpdateHistory` records which handlers ran successfully for each update. This enables features like `back` buttons that replay or inspect previous processing steps.
234
238
 
235
239
  ```python
236
- from pyroflow import Client, MessageHistory
240
+ from pyroflow import Client, CallbackQueryHistory
237
241
 
238
242
  client = Client("my_session")
239
- client.register_history(MessageHistory())
243
+ client.register_history(CallbackQueryHistory())
240
244
 
241
245
  client.run()
242
246
  ```
@@ -248,7 +252,7 @@ They can also be removed at runtime:
248
252
  ```python
249
253
  await client.unregister_listener(Message)
250
254
  await client.unregister_coordinated(Message)
251
- await client.unregister_history(Message)
255
+ await client.unregister_history(CallbackQueryHistory)
252
256
  ```
253
257
 
254
258
  ---
@@ -36,7 +36,7 @@ pyroflow solves all three problems:
36
36
 
37
37
  ## Installation
38
38
 
39
- **Minimum requirements:** Python 3.9+
39
+ **Minimum requirements:** Python 3.10+
40
40
 
41
41
  ```
42
42
  pip install pyroflow # core
@@ -197,10 +197,10 @@ client.run()
197
197
  A `UpdateHistory` records which handlers ran successfully for each update. This enables features like `back` buttons that replay or inspect previous processing steps.
198
198
 
199
199
  ```python
200
- from pyroflow import Client, MessageHistory
200
+ from pyroflow import Client, CallbackQueryHistory
201
201
 
202
202
  client = Client("my_session")
203
- client.register_history(MessageHistory())
203
+ client.register_history(CallbackQueryHistory())
204
204
 
205
205
  client.run()
206
206
  ```
@@ -212,7 +212,7 @@ They can also be removed at runtime:
212
212
  ```python
213
213
  await client.unregister_listener(Message)
214
214
  await client.unregister_coordinated(Message)
215
- await client.unregister_history(Message)
215
+ await client.unregister_history(CallbackQueryHistory)
216
216
  ```
217
217
 
218
218
  ---
@@ -7,7 +7,7 @@ build-backend = "hatchling.build"
7
7
  name = "pyroflow"
8
8
  dynamic = ["version"]
9
9
  description = "Conversation-oriented Pyrogram extension with per-update listeners and multi-session coordination"
10
- requires-python = ">=3.9"
10
+ requires-python = ">=3.10"
11
11
  authors = [{ name = "Abdullah", email = "aldheeb01@gmail.com" }]
12
12
  readme = "README.md"
13
13
  license = { text = "MIT" }
@@ -27,7 +27,6 @@ classifiers = [
27
27
  "Intended Audience :: Developers",
28
28
  "License :: OSI Approved :: MIT License",
29
29
  "Programming Language :: Python :: 3",
30
- "Programming Language :: Python :: 3.9",
31
30
  "Programming Language :: Python :: 3.10",
32
31
  "Programming Language :: Python :: 3.11",
33
32
  "Programming Language :: Python :: 3.12",
@@ -43,7 +42,7 @@ dependencies = [
43
42
  "cachetools",
44
43
  "sortedcontainers==2.4.0",
45
44
  "kurigram>=2.2.0",
46
- "typing_extensions>=4.6.0; python_version < '3.10'",
45
+ "typing_extensions>=4.6.0; python_version < '3.13'",
47
46
  ]
48
47
 
49
48
 
@@ -62,6 +61,13 @@ redis = [
62
61
  "redis>=4.2.0",
63
62
  ]
64
63
 
64
+ test = [
65
+ "pytest>=7.0",
66
+ "pytest-asyncio>=0.23",
67
+ "redis>=4.2.0",
68
+ "tomli; python_version < '3.11'",
69
+ ]
70
+
65
71
 
66
72
  [tool.hatch.build.targets.sdist]
67
73
  exclude = [
@@ -78,3 +84,11 @@ allow-direct-references = true
78
84
  [tool.hatch.build.targets.wheel]
79
85
  ignore-vcs = true
80
86
  packages = ["pyroflow"]
87
+
88
+
89
+ [tool.pytest.ini_options]
90
+ asyncio_mode = "auto"
91
+ testpaths = ["tests"]
92
+ filterwarnings = [
93
+ "ignore::DeprecationWarning",
94
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,21 @@
1
+ import sys
2
+
3
+ if sys.version_info >= (3, 11):
4
+ from enum import StrEnum
5
+ else:
6
+ from enum import Enum
7
+
8
+ class StrEnum(str, Enum):
9
+ def _generate_next_value_(self, *args):
10
+ return self.lower()
11
+
12
+ from enum import IntEnum, auto
13
+
14
+
15
+ class UpdateLockState(IntEnum):
16
+ PROCESSING = 0
17
+ HANDLED = 1
18
+
19
+ class DuplicatePolicy(StrEnum):
20
+ REJECT = auto()
21
+ REPLACE = auto()
@@ -1,18 +1,11 @@
1
1
  from abc import ABC, abstractmethod
2
- from typing import Optional, Union, Tuple, TYPE_CHECKING
2
+ from typing import Optional, Union, Tuple, TypeAlias, TYPE_CHECKING
3
3
 
4
4
 
5
5
  from ..utils.typings import AsyncLockProto
6
6
  from ..typings import ListenerCoordinatorIdT
7
7
  from ..models import ListenerKey
8
8
 
9
- import sys
10
-
11
- if sys.version_info >= (3, 10):
12
- from typing import TypeAlias
13
- else:
14
- from typing_extensions import TypeAlias
15
-
16
9
  if TYPE_CHECKING:
17
10
  from ..update_listener import UpdateListener
18
11
 
@@ -8,7 +8,6 @@ import asyncio
8
8
  import time
9
9
 
10
10
 
11
-
12
11
  @dataclass(frozen=True, slots=True, eq=False)
13
12
  class ListenerKey:
14
13
  chat_id: int
@@ -18,11 +17,11 @@ class ListenerKey:
18
17
  @property
19
18
  def to_tuple(self) -> Tuple[int, Optional[int], Optional[int]]:
20
19
  return (
21
- self.chat_id,
22
- self.user_id,
20
+ self.chat_id,
21
+ self.user_id,
23
22
  self.message_id
24
23
  )
25
-
24
+
26
25
 
27
26
  def sub_keys(self, min_dep: int = 1) -> Generator["ListenerKey", None, None]:
28
27
  keys = self.to_tuple
@@ -32,26 +31,26 @@ class ListenerKey:
32
31
  yield ListenerKey(
33
32
  *keys[:i]
34
33
  )
35
-
34
+
36
35
  def __hash__(self):
37
36
  return hash(self.to_tuple)
38
-
37
+
39
38
  def __eq__(self, value):
40
39
  return isinstance(value, ListenerKey) and self.to_tuple == value.to_tuple
41
40
 
42
41
  @dataclass(slots=True)
43
42
  class ListenerModel:
44
43
  key: ListenerKey
45
- meta: Optional[JsonValueT] = None
44
+ meta: Optional[JsonValueT] = None
46
45
  future: asyncio.Future = field(default_factory=asyncio.Future)
47
46
 
48
47
 
49
48
  def resolve(self, update) -> None:
50
49
  self.future.set_result(update)
51
-
50
+
52
51
  def set_exc(self, exc: Exception) -> None:
53
52
  self.future.set_exception(exc)
54
-
53
+
55
54
  def done(self) -> bool:
56
55
  return self.future.done()
57
56
 
@@ -1,11 +1,4 @@
1
- from typing import Union, Tuple
2
-
3
- import sys
4
-
5
- if sys.version_info >= (3, 10):
6
- from typing import TypeAlias
7
- else:
8
- from typing_extensions import TypeAlias
1
+ from typing import Union, Tuple, TypeAlias
9
2
 
10
3
  UpdateCoordinatorKeyT: TypeAlias = Tuple[Union[int, str], ...]
11
4
  UpdateHistoryKeyT = UpdateCoordinatorKeyT
@@ -28,4 +28,4 @@ class CallbackQueryCoordinated(UpdateCoordinated[CallbackQuery]):
28
28
  return (update.id, )
29
29
 
30
30
  async def _is_coordinatable(self, update):
31
- True
31
+ return True
@@ -0,0 +1,42 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from ..types import Message
4
+ from .update_history import UpdateHistory
5
+
6
+
7
+ class MessageHistory(UpdateHistory[Message]):
8
+ __update_type__ = Message
9
+
10
+ if not TYPE_CHECKING:
11
+ def __init__(
12
+ self,
13
+ store_factory = None,
14
+ is_recordable_func = None,
15
+ extract_key_func = None,
16
+ extract_data_func = None,
17
+ **kw
18
+ ):
19
+
20
+ if is_recordable_func is None:
21
+ is_recordable_func = self._is_recordable
22
+
23
+ if extract_key_func is None:
24
+ extract_key_func = self._extract_key
25
+
26
+ if extract_data_func is None:
27
+ extract_data_func = self._extract_data
28
+
29
+ super().__init__(store_factory, is_recordable_func, extract_key_func, extract_data_func, **kw)
30
+
31
+ async def _is_recordable(self, update):
32
+ return update.chat is not None
33
+
34
+ async def _extract_key(self, update):
35
+ return (
36
+ update.chat.id,
37
+ getattr(update.from_user, "id", None),
38
+ update.id,
39
+ )
40
+
41
+ async def _extract_data(self, update):
42
+ return update.text
@@ -10,11 +10,34 @@ import functools
10
10
  import logging
11
11
  import contextvars
12
12
  import inspect
13
+ import traceback
13
14
 
14
15
 
15
16
  log = logging.getLogger(__file__)
16
17
 
17
18
 
19
+ def _log_exc(
20
+ header: Optional[str],
21
+ caller_stack: List[traceback.FrameSummary],
22
+ exc: BaseException,
23
+ index: Optional[int] = None,
24
+ ) -> None:
25
+ exc_trace = traceback.format_exception(type(exc), exc, exc.__traceback__)
26
+ caller_trace = traceback.format_list(caller_stack)
27
+
28
+ if header is None:
29
+ header = ""
30
+
31
+ if index is not None:
32
+ header = f"{header} [awaitable index={index}]" if header else f"[awaitable index={index}]"
33
+
34
+ log.error(
35
+ "%s\n%s",
36
+ header,
37
+ "".join(caller_trace) + "".join(exc_trace)
38
+ )
39
+
40
+
18
41
  @overload
19
42
  async def to_thread(
20
43
  func: Callable[_P, _T],
@@ -37,25 +60,26 @@ async def to_thread(
37
60
  func,
38
61
  *args,
39
62
  executor = None,
40
- log_exc = True,
41
- return_exc = False,
63
+ log_exc = True,
64
+ return_exc = False,
42
65
  **kwargs,
43
66
  ):
44
-
67
+ caller_stack = traceback.extract_stack()[:-1]
68
+
45
69
  loop = asyncio.get_running_loop()
46
70
  ctx = contextvars.copy_context()
47
71
  func = functools.partial(ctx.run, func, *args, **kwargs)
48
-
49
-
72
+
73
+
50
74
  try:
51
75
  return await loop.run_in_executor(executor, func)
52
76
  except Exception as e:
53
77
  if log_exc:
54
- log.exception("error in to_thread")
55
-
78
+ _log_exc("error in to_thread", caller_stack, e)
79
+
56
80
  if return_exc:
57
81
  return e
58
-
82
+
59
83
  raise
60
84
 
61
85
 
@@ -77,12 +101,14 @@ async def gather_helper(
77
101
  log_exc = True,
78
102
  ):
79
103
 
104
+ caller_stack = traceback.extract_stack()[:-1]
105
+
80
106
  results = await asyncio.gather(*flat_cont(coros), return_exceptions=return_exc)
81
107
 
82
108
  if log_exc:
83
- for r in results:
109
+ for i, r in enumerate(results):
84
110
  if is_exception(r):
85
- log.error("error in gather_helper", exc_info=r)
111
+ _log_exc("error in gather_helper", caller_stack, r, index=i)
86
112
 
87
113
  return results
88
114
 
@@ -130,15 +156,24 @@ async def safe_await(
130
156
  log_exc = True,
131
157
  ):
132
158
 
159
+ caller_stack = traceback.extract_stack()[:-1]
160
+
133
161
  results = []
162
+ flat_coros = flat_cont(coros)
163
+ is_multi = len(flat_coros) > 1
134
164
 
135
- for coro in flat_cont(coros):
165
+ for i, coro in enumerate(flat_coros):
136
166
  try:
137
167
  result = await coro
138
168
  except Exception as e:
139
169
  if log_exc:
140
- log.exception("Error in safe_await")
141
-
170
+ _log_exc(
171
+ "error in safe_await",
172
+ caller_stack,
173
+ e,
174
+ index=i if is_multi else None,
175
+ )
176
+
142
177
  result = e
143
178
 
144
179
  if is_exception(result):
@@ -172,21 +207,21 @@ async def maybe_awaitable(
172
207
  async def maybe_awaitable(
173
208
  awaitable_or_callable,
174
209
  *args,
175
- executor = None,
176
- return_exc = False,
210
+ executor = None,
211
+ return_exc = False,
177
212
  log_exc = True,
178
213
  **kwargs,
179
214
  ):
180
-
215
+
181
216
  if inspect.isawaitable(awaitable_or_callable):
182
217
  if args or kwargs:
183
218
  raise TypeError(
184
219
  "Cannot pass args/kwargs to an already-created awaitable"
185
220
  )
186
221
  return await safe_await(
187
- awaitable_or_callable,
188
- return_exc=return_exc,
189
- log_exc=log_exc,
222
+ awaitable_or_callable,
223
+ return_exc=return_exc,
224
+ log_exc=log_exc,
190
225
  )
191
226
  elif iscoroutinefunction_wrapped(awaitable_or_callable):
192
227
  return await safe_await(
@@ -209,12 +244,9 @@ async def maybe_awaitable(
209
244
  )
210
245
 
211
246
 
212
-
213
-
214
-
215
247
  __all__ = [
216
- "to_thread",
217
- "gather_helper",
218
- "safe_await",
248
+ "to_thread",
249
+ "gather_helper",
250
+ "safe_await",
219
251
  "maybe_awaitable"
220
- ]
252
+ ]
@@ -1,18 +1,12 @@
1
1
  from abc import ABC
2
- from typing import Generic, Type
2
+ from typing import Generic, Type, ClassVar
3
3
 
4
4
  from pyrogram.types import Update as PyroUpdate
5
5
  from .typings import UpdateType
6
6
 
7
7
  import asyncio
8
- import sys
9
8
 
10
9
 
11
- if sys.version_info >= (3, 10):
12
- from typing import ClassVar
13
- else:
14
- from typing_extensions import ClassVar
15
-
16
10
  class AsyncioLock(asyncio.Lock):
17
11
  async def release(self):
18
12
  return super().release()
@@ -1,19 +1,15 @@
1
1
  from typing import (
2
- Collection as TCollection, Generator, Union, Reversible,
3
- Sequence, AbstractSet, Mapping, List, Any, Dict,
4
- Callable, Coroutine, Awaitable, TypeVar,
5
- Literal, TYPE_CHECKING,
2
+ Collection as TCollection, Generator, Union, Reversible,
3
+ Sequence, AbstractSet, Mapping, List, Any, Dict,
4
+ Coroutine, Awaitable, TypeVar,
5
+ Literal, TYPE_CHECKING,
6
+ ParamSpec, TypeAlias, Protocol, Callable,
6
7
  )
7
8
 
8
9
  from enum import Enum
9
10
 
10
11
  import sys
11
12
 
12
- if sys.version_info >= (3, 10):
13
- from typing import ParamSpec, TypeAlias, Protocol
14
- else:
15
- from typing_extensions import ParamSpec, TypeAlias, Protocol
16
-
17
13
  if sys.version_info >= (3, 11):
18
14
  from enum import EnumType
19
15
  else:
@@ -74,15 +70,15 @@ UpdateType = TypeVar("UpdateType", bound=PyroUpdate)
74
70
 
75
71
 
76
72
  __all__ = (
77
- "Container",
78
- "NestedContainer",
79
- "NotContainer",
80
- "MaybeAwaitable",
81
- "Number",
82
- "MaybeCoroutineCallable",
83
- "AsyncLockProto",
84
- "JsonValueT",
85
- "UpdateType",
86
- "_P", "_T", "_CT",
87
- "_True", "_False",
73
+ "Container",
74
+ "NestedContainer",
75
+ "NotContainer",
76
+ "MaybeAwaitable",
77
+ "Number",
78
+ "MaybeCoroutineCallable",
79
+ "AsyncLockProto",
80
+ "JsonValueT",
81
+ "UpdateType",
82
+ "_P", "_T", "_CT",
83
+ "_True", "_False",
88
84
  )
@@ -6,10 +6,8 @@ import inspect
6
6
 
7
7
  if sys.version_info >= (3, 13):
8
8
  from typing import TypeIs
9
- elif sys.version_info >= (3, 10):
10
- from typing_extensions import TypeIs
11
9
  else:
12
- from typing_extensions import TypeGuard as TypeIs
10
+ from typing_extensions import TypeIs
13
11
 
14
12
  from .typings import Container, NotContainer, _T
15
13
 
File without changes
@@ -0,0 +1,70 @@
1
+ """Shared fixtures and object factories for the pyroflow test suite.
2
+
3
+ Importing ``pyroflow`` here is deliberate and must happen before any test
4
+ runs: ``@patch_cls`` monkey-patches Pyrogram's ``Client``, ``Dispatcher``,
5
+ ``Message`` and ``CallbackQuery`` in-place at import time, and the whole
6
+ suite relies on that global patch being active.
7
+ """
8
+
9
+ from types import SimpleNamespace
10
+
11
+ import pyroflow # noqa: F401 — activates the @patch_cls global monkey-patch
12
+ import pytest
13
+
14
+ from pyrogram.types import Message, CallbackQuery, Chat, User
15
+ from pyrogram.enums import ChatType
16
+
17
+
18
+ def make_message(chat_id=100, user_id=7, message_id=5, **overrides):
19
+ """Build a minimal, listenable :class:`Message` instance.
20
+
21
+ Defaults produce a private, incoming, non-service message that
22
+ ``MessageListener._is_listenable`` accepts. Any field can be
23
+ overridden via keyword to exercise edge cases.
24
+ """
25
+ msg = Message(
26
+ id=message_id,
27
+ chat=Chat(id=chat_id, type=ChatType.PRIVATE),
28
+ from_user=User(id=user_id, is_self=False),
29
+ )
30
+ for key, value in overrides.items():
31
+ setattr(msg, key, value)
32
+ return msg
33
+
34
+
35
+ def make_callback_query(chat_id=100, user_id=7, message_id=5, data="cb", **overrides):
36
+ """Build a minimal :class:`CallbackQuery` whose ``message`` is listenable."""
37
+ cbq = CallbackQuery(
38
+ id="cbq-1",
39
+ from_user=User(id=user_id, is_self=False),
40
+ chat_instance="chat-instance",
41
+ message=make_message(chat_id, user_id, message_id),
42
+ data=data,
43
+ )
44
+ for key, value in overrides.items():
45
+ setattr(cbq, key, value)
46
+ return cbq
47
+
48
+
49
+ @pytest.fixture
50
+ def message_factory():
51
+ return make_message
52
+
53
+
54
+ @pytest.fixture
55
+ def callback_factory():
56
+ return make_callback_query
57
+
58
+
59
+ @pytest.fixture
60
+ def raw_message_factory():
61
+ """A ``Message`` carrying a ``raw.peer_id`` for coordinator key extraction."""
62
+
63
+ def _factory(chat_id=100, user_id=7, message_id=5, peer_channel_id=100):
64
+ msg = make_message(chat_id, user_id, message_id)
65
+ # get_peer_id() understands raw PeerChannel/PeerUser objects; a
66
+ # SimpleNamespace with the right attribute name is enough here.
67
+ msg.raw = SimpleNamespace(peer_id=SimpleNamespace(channel_id=peer_channel_id))
68
+ return msg
69
+
70
+ return _factory