PyEventEngine 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PyEventEngine-0.3.2.dist-info/LICENSE +21 -0
- PyEventEngine-0.3.2.dist-info/METADATA +109 -0
- PyEventEngine-0.3.2.dist-info/RECORD +10 -0
- PyEventEngine-0.3.2.dist-info/WHEEL +5 -0
- PyEventEngine-0.3.2.dist-info/top_level.txt +1 -0
- event_engine/__init__.py +3 -0
- event_engine/native/__init__.py +99 -0
- event_engine/native/_event.py +405 -0
- event_engine/native/_topic.py +156 -0
- event_engine/native/_topic_c.py +94 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Bolun Han
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: PyEventEngine
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Basic event engine
|
|
5
|
+
Home-page: https://github.com/BolunHan/PyEventEngine.git
|
|
6
|
+
Author: Bolun.Han
|
|
7
|
+
Author-email: Bolun.Han@outlook.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
|
|
19
|
+
# PyEventEngine
|
|
20
|
+
|
|
21
|
+
python native event engine
|
|
22
|
+
|
|
23
|
+
# Install
|
|
24
|
+
|
|
25
|
+
```shell
|
|
26
|
+
pip install git+https://github.com/BolunHan/PyEventEngine.git
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
or
|
|
30
|
+
|
|
31
|
+
```shell
|
|
32
|
+
pip install PyEventEngine
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
# Use
|
|
36
|
+
|
|
37
|
+
## basic usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
# init event engine
|
|
41
|
+
import time
|
|
42
|
+
from event_engine import EventEngine, Topic
|
|
43
|
+
|
|
44
|
+
EVENT_ENGINE = EventEngine()
|
|
45
|
+
EVENT_ENGINE.start()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# register handler
|
|
49
|
+
def test_handler(msg, **kwargs):
|
|
50
|
+
print(msg)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
EVENT_ENGINE.register_handler(topic=Topic('SimpleTopic'), handler=test_handler)
|
|
54
|
+
|
|
55
|
+
# publish message
|
|
56
|
+
EVENT_ENGINE.put(topic=Topic('SimpleTopic'), msg='topic called')
|
|
57
|
+
time.sleep(1)
|
|
58
|
+
EVENT_ENGINE.stop()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## regular topic
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# init event engine
|
|
65
|
+
import time
|
|
66
|
+
from event_engine import EventEngine, Topic, RegularTopic
|
|
67
|
+
|
|
68
|
+
EVENT_ENGINE = EventEngine()
|
|
69
|
+
EVENT_ENGINE.start()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# register handler
|
|
73
|
+
def test_handler(msg, **kwargs):
|
|
74
|
+
print(msg)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
EVENT_ENGINE.register_handler(topic=RegularTopic('RegularTopic.*'), handler=test_handler)
|
|
78
|
+
|
|
79
|
+
# publish message
|
|
80
|
+
EVENT_ENGINE.put(topic=Topic('RegularTopic.ChildTopic0'), msg='topic called')
|
|
81
|
+
time.sleep(1)
|
|
82
|
+
EVENT_ENGINE.stop()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## timer topic
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# init event engine
|
|
89
|
+
import time
|
|
90
|
+
from event_engine import EventEngine, Topic, RegularTopic
|
|
91
|
+
|
|
92
|
+
EVENT_ENGINE = EventEngine()
|
|
93
|
+
EVENT_ENGINE.start()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# register handler
|
|
97
|
+
def test_handler(**kwargs):
|
|
98
|
+
print(kwargs)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
topic = EVENT_ENGINE.get_timer(interval=1)
|
|
102
|
+
EVENT_ENGINE.register_handler(topic=topic, handler=test_handler)
|
|
103
|
+
|
|
104
|
+
# publish message
|
|
105
|
+
time.sleep(5)
|
|
106
|
+
EVENT_ENGINE.stop()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
See more advanced usage at .Demo
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
event_engine/__init__.py,sha256=0z5AJNA4EwsTqQFxSKYa8YefS1acGuKxlVLQPQk4Xzw,45
|
|
2
|
+
event_engine/native/__init__.py,sha256=_HhWK3NYaS8e4JvGYVyC6MX5EKfNCRNZ3d0ItFj0Pis,3227
|
|
3
|
+
event_engine/native/_event.py,sha256=w_k2znuX3newAnjUbFkYxh8_DFEr3WMkAyyzSc7QBhY,14346
|
|
4
|
+
event_engine/native/_topic.py,sha256=fBcnKEsFoj0vmKZXq3MkEJJ_JyepvZLbDvnAzDjMNjI,4726
|
|
5
|
+
event_engine/native/_topic_c.py,sha256=8CXsuHeRwbcxKqyfsIpp-QDKnk0vjOG4MT9nYoaUY2Y,2907
|
|
6
|
+
PyEventEngine-0.3.2.dist-info/LICENSE,sha256=bTmmkRlafTj1JKDkDoGfnLHD8OiuIdXaXDa35W3Ii4g,1066
|
|
7
|
+
PyEventEngine-0.3.2.dist-info/METADATA,sha256=EGiCnbUBokVgw4D-z5zh2VxLSyv2dGi8XvnB8eSuCzA,2107
|
|
8
|
+
PyEventEngine-0.3.2.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
|
|
9
|
+
PyEventEngine-0.3.2.dist-info/top_level.txt,sha256=rIh2LcDfofRLIIqMSlulp5yAXTf1DNOmlofiyIHuylQ,13
|
|
10
|
+
PyEventEngine-0.3.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
event_engine
|
event_engine/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
__all__ = ['set_logger', 'LOG_LEVEL_EVENT', 'Topic', 'RegularTopic', 'PatternTopic', 'EventHook', 'EventEngine', 'LOGGER', 'use_cpp_override']
|
|
6
|
+
LOGGER: logging.Logger | None = None
|
|
7
|
+
DEBUG = False
|
|
8
|
+
LOG_LEVEL = logging.INFO
|
|
9
|
+
LOG_LEVEL_EVENT = LOG_LEVEL - 5
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ColoredFormatter(logging.Formatter):
|
|
13
|
+
"""Logging Formatter to add colors and count warning / errors"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, fmt=None, datefmt=None, style='{', validate=True):
|
|
16
|
+
self.format_str = '[{asctime} {name} - {threadName} - {module}:{lineno} - {levelname}] {message}' if fmt is None else fmt
|
|
17
|
+
self.date_fmt = '%Y-%m-%d %H:%M:%S' if datefmt is None else datefmt
|
|
18
|
+
self.style = style
|
|
19
|
+
|
|
20
|
+
super().__init__(fmt=fmt, datefmt=datefmt, style=style, validate=validate)
|
|
21
|
+
|
|
22
|
+
def _get_format(self, level: int, select=False):
|
|
23
|
+
bold_red = f"\33[31;1;3;4{';7' if select else ''}m"
|
|
24
|
+
red = f"\33[31;1{';7' if select else ''}m"
|
|
25
|
+
green = f"\33[32;1{';7' if select else ''}m"
|
|
26
|
+
yellow = f"\33[33;1{';7' if select else ''}m"
|
|
27
|
+
blue = f"\33[34;1{';7' if select else ''}m"
|
|
28
|
+
reset = "\33[0m"
|
|
29
|
+
|
|
30
|
+
if level <= logging.NOTSET:
|
|
31
|
+
fmt = self.format_str
|
|
32
|
+
elif level <= logging.DEBUG:
|
|
33
|
+
fmt = blue + self.format_str + reset
|
|
34
|
+
elif level <= logging.INFO:
|
|
35
|
+
fmt = green + self.format_str + reset
|
|
36
|
+
elif level <= logging.WARNING:
|
|
37
|
+
fmt = yellow + self.format_str + reset
|
|
38
|
+
elif level <= logging.ERROR:
|
|
39
|
+
fmt = red + self.format_str + reset
|
|
40
|
+
else:
|
|
41
|
+
fmt = bold_red + self.format_str + reset
|
|
42
|
+
|
|
43
|
+
return fmt
|
|
44
|
+
|
|
45
|
+
def format(self, record):
|
|
46
|
+
log_fmt = self._get_format(level=record.levelno)
|
|
47
|
+
formatter = logging.Formatter(log_fmt, datefmt=self.date_fmt, style=self.style)
|
|
48
|
+
return formatter.format(record)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_logger(**kwargs) -> logging.Logger:
|
|
52
|
+
level = kwargs.get('level', LOG_LEVEL)
|
|
53
|
+
stream_io = kwargs.get('stream_io', sys.stdout)
|
|
54
|
+
formatter = kwargs.get('formatter', ColoredFormatter())
|
|
55
|
+
global LOGGER
|
|
56
|
+
|
|
57
|
+
if LOGGER is not None:
|
|
58
|
+
return LOGGER
|
|
59
|
+
|
|
60
|
+
LOGGER = logging.getLogger('EventEngine')
|
|
61
|
+
LOGGER.setLevel(level)
|
|
62
|
+
logging.Formatter.converter = time.gmtime
|
|
63
|
+
|
|
64
|
+
if stream_io:
|
|
65
|
+
have_handler = False
|
|
66
|
+
for handler in LOGGER.handlers:
|
|
67
|
+
# noinspection PyUnresolvedReferences
|
|
68
|
+
if type(handler) == logging.StreamHandler and handler.stream == stream_io:
|
|
69
|
+
have_handler = True
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
if not have_handler:
|
|
73
|
+
logger_ch = logging.StreamHandler(stream=stream_io)
|
|
74
|
+
logger_ch.setLevel(level=level)
|
|
75
|
+
logger_ch.setFormatter(fmt=formatter)
|
|
76
|
+
LOGGER.addHandler(logger_ch)
|
|
77
|
+
|
|
78
|
+
return LOGGER
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def set_logger(logger: logging.Logger):
|
|
82
|
+
global LOGGER
|
|
83
|
+
LOGGER = logger
|
|
84
|
+
|
|
85
|
+
_event.LOGGER = LOGGER.getChild('Event')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def use_cpp_override():
|
|
89
|
+
try:
|
|
90
|
+
from ._topic_c import Topic, RegularTopic, PatternTopic
|
|
91
|
+
from . import _topic as _Topic_native
|
|
92
|
+
except Exception as _:
|
|
93
|
+
LOGGER.warning(_)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_ = get_logger()
|
|
97
|
+
|
|
98
|
+
from ._topic import Topic, RegularTopic, PatternTopic
|
|
99
|
+
from ._event import EventHook, EventEngine
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import enum
|
|
3
|
+
import inspect
|
|
4
|
+
import time
|
|
5
|
+
import traceback
|
|
6
|
+
from collections import deque
|
|
7
|
+
from logging import Logger
|
|
8
|
+
from threading import Thread, Semaphore
|
|
9
|
+
from typing import Iterable, TypedDict, NotRequired, Callable
|
|
10
|
+
|
|
11
|
+
from . import LOGGER, LOG_LEVEL_EVENT, Topic, DEBUG
|
|
12
|
+
|
|
13
|
+
LOGGER = LOGGER.getChild('Event')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventDict(TypedDict):
|
|
17
|
+
topic: str
|
|
18
|
+
args: NotRequired[tuple]
|
|
19
|
+
kwargs: NotRequired[dict]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EventHookBase(object):
|
|
23
|
+
"""
|
|
24
|
+
Event object with
|
|
25
|
+
a string topic for event engine to distribute event,
|
|
26
|
+
and a list of handler to process data
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, topic: Topic, logger: Logger = None, max_size: int = None):
|
|
30
|
+
self.topic = topic
|
|
31
|
+
self.logger = LOGGER.getChild(f'EventHook.{topic}') if logger is None else logger
|
|
32
|
+
self.handlers: deque[Callable] = deque(maxlen=max_size)
|
|
33
|
+
|
|
34
|
+
def __call__(self, *args, **kwargs):
|
|
35
|
+
self.trigger(topic=self.topic, args=args, kwargs=kwargs)
|
|
36
|
+
|
|
37
|
+
def __iadd__(self, handler: Callable):
|
|
38
|
+
self.add_handler(handler)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def __isub__(self, handler: Callable):
|
|
42
|
+
self.remove_handler(handler)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def trigger(self, topic: Topic, args: tuple = None, kwargs: dict = None):
|
|
46
|
+
if args is None:
|
|
47
|
+
args = ()
|
|
48
|
+
|
|
49
|
+
if kwargs is None:
|
|
50
|
+
kwargs = {}
|
|
51
|
+
|
|
52
|
+
for handler in self.handlers:
|
|
53
|
+
try:
|
|
54
|
+
try:
|
|
55
|
+
handler(topic=topic, *args, **kwargs)
|
|
56
|
+
except TypeError as e:
|
|
57
|
+
if e.__str__().endswith("unexpected keyword argument 'topic'"):
|
|
58
|
+
handler(*args, **kwargs)
|
|
59
|
+
else:
|
|
60
|
+
raise e
|
|
61
|
+
except Exception as _:
|
|
62
|
+
self.logger.error(traceback.format_exc())
|
|
63
|
+
|
|
64
|
+
def add_handler(self, handler: Callable):
|
|
65
|
+
if handler in self.handlers:
|
|
66
|
+
LOGGER.warning(f'Handler {handler} already in {self}. This action might cause it to trigger twice.')
|
|
67
|
+
self.handlers.append(handler)
|
|
68
|
+
|
|
69
|
+
def remove_handler(self, handler: Callable):
|
|
70
|
+
try:
|
|
71
|
+
self.handlers.remove(handler)
|
|
72
|
+
except ValueError as e:
|
|
73
|
+
self.logger.error(f'Handler {handler} not found in {self}.')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EventHook(EventHookBase):
|
|
77
|
+
def __init__(self, topic: Topic, logger: Logger = None, max_size: int = None, handler: list[Callable] | Callable | None = None):
|
|
78
|
+
super().__init__(topic=topic, logger=logger, max_size=max_size)
|
|
79
|
+
self.with_topic: deque[bool] = deque()
|
|
80
|
+
|
|
81
|
+
if handler is None:
|
|
82
|
+
pass
|
|
83
|
+
elif callable(handler):
|
|
84
|
+
self.add_handler(handler)
|
|
85
|
+
elif isinstance(handler, Iterable):
|
|
86
|
+
for _handler in handler:
|
|
87
|
+
self.add_handler(handler=_handler)
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError(f'Invalid handler {handler}, expect a Callable or a list of Callable.')
|
|
90
|
+
|
|
91
|
+
def trigger(self, topic: Topic, args: tuple = None, kwargs: dict = None):
|
|
92
|
+
ts = time.time()
|
|
93
|
+
if args is None:
|
|
94
|
+
args = ()
|
|
95
|
+
|
|
96
|
+
if kwargs is None:
|
|
97
|
+
kwargs = {}
|
|
98
|
+
|
|
99
|
+
for handler, with_topic in zip(self.handlers, self.with_topic):
|
|
100
|
+
try:
|
|
101
|
+
if with_topic:
|
|
102
|
+
handler(topic=topic, *args, **kwargs)
|
|
103
|
+
else:
|
|
104
|
+
handler(*args, **kwargs)
|
|
105
|
+
except Exception as _:
|
|
106
|
+
self.logger.error(traceback.format_exc())
|
|
107
|
+
|
|
108
|
+
if DEBUG:
|
|
109
|
+
self.logger.log(LOG_LEVEL_EVENT, f'EventHook {self.topic} tasks triggered {len(self.handlers):,} handlers, complete in {(time.time() - ts) * 1000:.3f}ms.')
|
|
110
|
+
|
|
111
|
+
def add_handler(self, handler: Callable, with_topic: bool = None):
|
|
112
|
+
sig = inspect.signature(handler)
|
|
113
|
+
|
|
114
|
+
if with_topic is None:
|
|
115
|
+
for param in sig.parameters.values():
|
|
116
|
+
if param.name == 'topic' or param.kind == param.VAR_KEYWORD:
|
|
117
|
+
with_topic = True
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
super().add_handler(handler=handler)
|
|
121
|
+
self.with_topic.append(with_topic)
|
|
122
|
+
|
|
123
|
+
def remove_handler(self, handler: Callable):
|
|
124
|
+
try:
|
|
125
|
+
idx = self.handlers.index(handler)
|
|
126
|
+
self.handlers.__delitem__(idx)
|
|
127
|
+
self.with_topic.__delitem__(idx)
|
|
128
|
+
except ValueError as e:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class EventEngineBase(object):
|
|
133
|
+
EventHook = EventHook
|
|
134
|
+
|
|
135
|
+
def __init__(self, logger: Logger = None, buffer_size: int = 0):
|
|
136
|
+
self.logger = LOGGER.getChild(f'EventEngine') if logger is None else logger
|
|
137
|
+
self._buffer_size = buffer_size
|
|
138
|
+
self._put_lock = Semaphore(self._buffer_size)
|
|
139
|
+
self._get_lock = Semaphore(0)
|
|
140
|
+
self._deque: deque[EventDict] = deque(maxlen=buffer_size if buffer_size else None)
|
|
141
|
+
self._active: bool = False
|
|
142
|
+
self._engine: Thread = None
|
|
143
|
+
self._event_hooks: dict[Topic, EventHook] = {}
|
|
144
|
+
|
|
145
|
+
if buffer_size and buffer_size < 8:
|
|
146
|
+
self.logger.info(f'buffer_size={buffer_size} too small. This might cause a dead lock.')
|
|
147
|
+
|
|
148
|
+
def _run(self) -> None:
|
|
149
|
+
"""
|
|
150
|
+
Get event from queue and then process it.
|
|
151
|
+
"""
|
|
152
|
+
while self._active:
|
|
153
|
+
self._get_lock.acquire(blocking=True, timeout=None)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
event_dict = self._deque.popleft()
|
|
157
|
+
except IndexError as e:
|
|
158
|
+
if not self._active:
|
|
159
|
+
return
|
|
160
|
+
raise e
|
|
161
|
+
|
|
162
|
+
topic = event_dict['topic']
|
|
163
|
+
args = event_dict.get('args', ())
|
|
164
|
+
kwargs = event_dict.get('kwargs', {})
|
|
165
|
+
self._process(topic=topic, *args, **kwargs)
|
|
166
|
+
|
|
167
|
+
if self._buffer_size:
|
|
168
|
+
self._put_lock.release()
|
|
169
|
+
|
|
170
|
+
def _process(self, topic: str, *args, **kwargs) -> None:
|
|
171
|
+
"""
|
|
172
|
+
distribute data to registered event hook in the order of registration
|
|
173
|
+
"""
|
|
174
|
+
for event_topic, event_hook in self._event_hooks.items():
|
|
175
|
+
if matched_topic := event_topic.match(topic=topic):
|
|
176
|
+
event_hook.trigger(topic=matched_topic, args=args, kwargs=kwargs)
|
|
177
|
+
|
|
178
|
+
def start(self) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Start event engine to process events and generate timer events.
|
|
181
|
+
"""
|
|
182
|
+
if self._active:
|
|
183
|
+
self.logger.warning(f'{self} already started!')
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
self._active = True
|
|
187
|
+
self._engine = Thread(target=self._run, name='EventEngine')
|
|
188
|
+
self._engine.start()
|
|
189
|
+
|
|
190
|
+
def stop(self) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Stop event engine.
|
|
193
|
+
"""
|
|
194
|
+
if not self._active:
|
|
195
|
+
self.logger.warning('EventEngine already stopped!')
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
self._active = False
|
|
199
|
+
self._get_lock.release()
|
|
200
|
+
self._engine.join()
|
|
201
|
+
|
|
202
|
+
def clear(self) -> None:
|
|
203
|
+
if self._active:
|
|
204
|
+
self.logger.error('EventEngine must be stopped before cleared!')
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
self._event_hooks.clear()
|
|
208
|
+
self._deque.clear()
|
|
209
|
+
|
|
210
|
+
if self._buffer_size:
|
|
211
|
+
self._put_lock._value = self._buffer_size
|
|
212
|
+
self._get_lock._value = 0
|
|
213
|
+
|
|
214
|
+
def put(self, topic: str | Topic, block: bool = True, timeout: float = None, *args, **kwargs):
|
|
215
|
+
"""
|
|
216
|
+
fast way to put an event, kwargs MUST NOT contain "topic", "block" and "timeout" keywords
|
|
217
|
+
:param topic: the topic to put into engine
|
|
218
|
+
:param block: block if necessary until a free slot is available
|
|
219
|
+
:param timeout: If 'timeout' is a non-negative number, it blocks at most 'timeout' seconds and raises the Full exception
|
|
220
|
+
:param args: args for handlers
|
|
221
|
+
:param kwargs: kwargs for handlers
|
|
222
|
+
:return: nothing
|
|
223
|
+
"""
|
|
224
|
+
self.publish(topic=topic, block=block, timeout=timeout, args=args, kwargs=kwargs)
|
|
225
|
+
|
|
226
|
+
def publish(self, topic: str | Topic, block: bool = True, timeout: float = None, args=None, kwargs=None):
|
|
227
|
+
"""
|
|
228
|
+
safe way to publish an event
|
|
229
|
+
:param topic: the topic to put into engine
|
|
230
|
+
:param block: block if necessary until a free slot is available
|
|
231
|
+
:param timeout: If 'timeout' is a non-negative number, it blocks at most 'timeout' seconds and raises the Full exception
|
|
232
|
+
:param args: a list / tuple, args for handlers
|
|
233
|
+
:param kwargs: a dict, kwargs for handlers
|
|
234
|
+
:return: nothing
|
|
235
|
+
"""
|
|
236
|
+
if isinstance(topic, Topic):
|
|
237
|
+
topic = topic.value
|
|
238
|
+
elif not isinstance(topic, str):
|
|
239
|
+
raise ValueError(f'Invalid topic {topic}')
|
|
240
|
+
|
|
241
|
+
if self._buffer_size:
|
|
242
|
+
self._put_lock.acquire()
|
|
243
|
+
|
|
244
|
+
event_dict = {'topic': topic}
|
|
245
|
+
|
|
246
|
+
if args is not None:
|
|
247
|
+
event_dict['args'] = args
|
|
248
|
+
|
|
249
|
+
if kwargs is not None:
|
|
250
|
+
event_dict['kwargs'] = kwargs
|
|
251
|
+
|
|
252
|
+
self._deque.append(event_dict)
|
|
253
|
+
|
|
254
|
+
self._get_lock.release()
|
|
255
|
+
|
|
256
|
+
def register_hook(self, hook: EventHook) -> None:
|
|
257
|
+
"""
|
|
258
|
+
register a hook event
|
|
259
|
+
"""
|
|
260
|
+
if hook.topic in self._event_hooks:
|
|
261
|
+
for handler in hook.handlers:
|
|
262
|
+
self._event_hooks[hook.topic].add_handler(handler)
|
|
263
|
+
else:
|
|
264
|
+
self._event_hooks[hook.topic] = hook
|
|
265
|
+
|
|
266
|
+
def unregister_hook(self, topic: Topic) -> None:
|
|
267
|
+
"""
|
|
268
|
+
Unregister an existing hook
|
|
269
|
+
"""
|
|
270
|
+
if topic in self._event_hooks:
|
|
271
|
+
self._event_hooks.pop(topic)
|
|
272
|
+
|
|
273
|
+
def register_handler(self, topic: Topic, handler: Iterable[Callable] | Callable) -> None:
|
|
274
|
+
"""
|
|
275
|
+
Register one or more handler for a specific topic
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
if not isinstance(topic, Topic):
|
|
279
|
+
raise TypeError(f'Invalid topic {topic}')
|
|
280
|
+
|
|
281
|
+
if topic not in self._event_hooks:
|
|
282
|
+
self._event_hooks[topic] = self.EventHook(topic=topic, handler=handler, logger=self.logger.getChild(topic.value))
|
|
283
|
+
else:
|
|
284
|
+
self._event_hooks[topic].add_handler(handler)
|
|
285
|
+
|
|
286
|
+
def unregister_handler(self, topic: Topic, handler: Callable) -> None:
|
|
287
|
+
"""
|
|
288
|
+
Unregister an existing handler function.
|
|
289
|
+
"""
|
|
290
|
+
if topic in self._event_hooks:
|
|
291
|
+
self._event_hooks[topic].remove_handler(handler=handler)
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def buffer_size(self):
|
|
295
|
+
return self._buffer_size
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def active(self) -> bool:
|
|
299
|
+
return self._active
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class EventEngine(EventEngineBase):
|
|
303
|
+
EventHook = EventHook
|
|
304
|
+
|
|
305
|
+
def __init__(self, buffer_size=0):
|
|
306
|
+
super().__init__(buffer_size=buffer_size)
|
|
307
|
+
self.timer: dict[float | str, Thread] = {}
|
|
308
|
+
|
|
309
|
+
def register_handler(self, topic: Topic | str | enum.Enum, handler: Callable):
|
|
310
|
+
topic = Topic.cast(topic)
|
|
311
|
+
super().register_handler(topic=topic, handler=handler)
|
|
312
|
+
|
|
313
|
+
def publish(self, topic, block: bool = True, timeout: float = None, args=None, kwargs=None):
|
|
314
|
+
topic = Topic.cast(topic)
|
|
315
|
+
super().publish(topic=topic, block=block, timeout=timeout, args=args, kwargs=kwargs)
|
|
316
|
+
|
|
317
|
+
def unregister_hook(self, topic) -> None:
|
|
318
|
+
topic = Topic.cast(topic)
|
|
319
|
+
super().unregister_hook(topic=topic)
|
|
320
|
+
|
|
321
|
+
def unregister_handler(self, topic, handler) -> None:
|
|
322
|
+
topic = Topic.cast(topic)
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
super().unregister_handler(topic=topic, handler=handler)
|
|
326
|
+
except ValueError as _:
|
|
327
|
+
raise ValueError(f'unregister topic {topic} failed! handler {handler} not found!')
|
|
328
|
+
|
|
329
|
+
def get_timer(self, interval: datetime.timedelta | float | int, activate_time: datetime.datetime = None) -> Topic:
|
|
330
|
+
"""
|
|
331
|
+
Start a timer, if not exist, and get topic of the timer event
|
|
332
|
+
:param interval: timer event interval in seconds
|
|
333
|
+
:param activate_time: UTC, timer event only start after active_time. This arg has no effect if timer already started.
|
|
334
|
+
:return: the topic of timer event hook
|
|
335
|
+
"""
|
|
336
|
+
if isinstance(interval, datetime.timedelta):
|
|
337
|
+
interval = interval.total_seconds()
|
|
338
|
+
|
|
339
|
+
if interval == 1:
|
|
340
|
+
topic = Topic('EventEngine.Internal.Timer.Second')
|
|
341
|
+
timer = Thread(target=self._second_timer, kwargs={'topic': topic})
|
|
342
|
+
elif interval == 60:
|
|
343
|
+
topic = Topic('EventEngine.Internal.Timer.Minute')
|
|
344
|
+
timer = Thread(target=self._minute_timer, kwargs={'topic': topic})
|
|
345
|
+
else:
|
|
346
|
+
topic = Topic(f'EventEngine.Internal.Timer.{interval}')
|
|
347
|
+
timer = Thread(target=self._run_timer, kwargs={'interval': interval, 'topic': topic, 'activate_time': activate_time})
|
|
348
|
+
|
|
349
|
+
if interval not in self.timer:
|
|
350
|
+
self.timer[interval] = timer
|
|
351
|
+
timer.start()
|
|
352
|
+
else:
|
|
353
|
+
if activate_time is not None:
|
|
354
|
+
self.logger.debug(f'Timer thread with interval [{datetime.timedelta(seconds=interval)}] already initialized! Argument [activate_time] takes no effect!')
|
|
355
|
+
|
|
356
|
+
return topic
|
|
357
|
+
|
|
358
|
+
def _run_timer(self, interval: datetime.timedelta | float | int, topic: Topic, activate_time: datetime.datetime = None) -> None:
|
|
359
|
+
if isinstance(interval, datetime.timedelta):
|
|
360
|
+
interval = interval.total_seconds()
|
|
361
|
+
|
|
362
|
+
if activate_time is None:
|
|
363
|
+
scheduled_time = datetime.datetime.utcnow()
|
|
364
|
+
else:
|
|
365
|
+
scheduled_time = activate_time
|
|
366
|
+
|
|
367
|
+
while self._active:
|
|
368
|
+
sleep_time = (scheduled_time - datetime.datetime.utcnow()).total_seconds()
|
|
369
|
+
|
|
370
|
+
if sleep_time > 0:
|
|
371
|
+
time.sleep(sleep_time)
|
|
372
|
+
self.put(topic=topic, interval=interval, trigger_time=scheduled_time)
|
|
373
|
+
|
|
374
|
+
while scheduled_time < datetime.datetime.utcnow():
|
|
375
|
+
scheduled_time += datetime.timedelta(seconds=interval)
|
|
376
|
+
|
|
377
|
+
def _minute_timer(self, topic: Topic):
|
|
378
|
+
while self._active:
|
|
379
|
+
t = time.time()
|
|
380
|
+
scheduled_time = t // 60 * 60
|
|
381
|
+
next_time = scheduled_time + 60
|
|
382
|
+
sleep_time = next_time - t
|
|
383
|
+
time.sleep(sleep_time)
|
|
384
|
+
self.put(topic=topic, interval=60., timestamp=scheduled_time)
|
|
385
|
+
|
|
386
|
+
def _second_timer(self, topic: Topic):
|
|
387
|
+
while self._active:
|
|
388
|
+
t = time.time()
|
|
389
|
+
scheduled_time = t // 1
|
|
390
|
+
next_time = scheduled_time + 1
|
|
391
|
+
sleep_time = next_time - t
|
|
392
|
+
time.sleep(sleep_time)
|
|
393
|
+
self.put(topic=topic, interval=1., timestamp=scheduled_time)
|
|
394
|
+
|
|
395
|
+
def stop(self) -> None:
|
|
396
|
+
super().stop()
|
|
397
|
+
|
|
398
|
+
for timer in self.timer.values():
|
|
399
|
+
timer.join()
|
|
400
|
+
|
|
401
|
+
def clear(self) -> None:
|
|
402
|
+
for t in self.timer.values():
|
|
403
|
+
t.join(timeout=0)
|
|
404
|
+
|
|
405
|
+
self.timer.clear()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from string import Formatter
|
|
4
|
+
from typing import Self, Type
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Topic(dict):
|
|
8
|
+
"""
|
|
9
|
+
topic for event hook. e.g. "TickData.002410.SZ.Realtime"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
class Error(Exception):
|
|
13
|
+
def __init__(self, msg):
|
|
14
|
+
super().__init__(msg)
|
|
15
|
+
|
|
16
|
+
def __init__(self, topic: str, *args, **kwargs):
|
|
17
|
+
self._value = topic
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
19
|
+
|
|
20
|
+
def __repr__(self):
|
|
21
|
+
return f'<{self.__class__.__name__}>({self._value}){super().__repr__()}'
|
|
22
|
+
|
|
23
|
+
def __str__(self):
|
|
24
|
+
return self.value
|
|
25
|
+
|
|
26
|
+
def __bool__(self):
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
def __hash__(self):
|
|
30
|
+
return self.value.__hash__()
|
|
31
|
+
|
|
32
|
+
def match(self, topic: str) -> Self | None:
|
|
33
|
+
if self._value == topic:
|
|
34
|
+
# return self.__class__(topic=topic)
|
|
35
|
+
return self
|
|
36
|
+
else:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def cast(cls, topic: Self | str | Enum, dtype: Type[Self] = None) -> Self:
|
|
41
|
+
if isinstance(topic, cls):
|
|
42
|
+
return topic
|
|
43
|
+
elif isinstance(topic, Enum):
|
|
44
|
+
t = topic.value
|
|
45
|
+
return cls.cast(t)
|
|
46
|
+
elif isinstance(topic, str):
|
|
47
|
+
if dtype is None:
|
|
48
|
+
if re.search(r'{(.+?)}', topic):
|
|
49
|
+
t = PatternTopic(pattern=topic)
|
|
50
|
+
elif '*' in topic or '+' in topic or '|' in topic:
|
|
51
|
+
re.compile(pattern=topic)
|
|
52
|
+
t = RegularTopic(pattern=topic)
|
|
53
|
+
else:
|
|
54
|
+
t = Topic(topic=topic)
|
|
55
|
+
else:
|
|
56
|
+
t = dtype(topic)
|
|
57
|
+
else:
|
|
58
|
+
raise NotImplementedError(f'Can not cast {topic} into {cls}.')
|
|
59
|
+
|
|
60
|
+
return t
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def value(self) -> str:
|
|
64
|
+
return self._value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RegularTopic(Topic):
|
|
68
|
+
"""
|
|
69
|
+
topic in regular expression. e.g. "TickData.(.+).((SZ)|(SH)).((Realtime)|(History))"
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, pattern: str):
|
|
73
|
+
super().__init__(topic=pattern)
|
|
74
|
+
|
|
75
|
+
def match(self, topic: str) -> Topic | None:
|
|
76
|
+
if re.match(self._value, topic):
|
|
77
|
+
match = Topic(topic=topic)
|
|
78
|
+
match['pattern'] = self._value
|
|
79
|
+
return match
|
|
80
|
+
else:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PatternTopic(Topic):
|
|
85
|
+
"""
|
|
86
|
+
topic for event hook. e.g. "TickData.{symbol}.{market}.{flag}"
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
class NotMatchError(Topic.Error):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
def __init__(self, pattern: str):
|
|
93
|
+
super().__init__(topic=pattern)
|
|
94
|
+
|
|
95
|
+
def __call__(self, **kwargs):
|
|
96
|
+
return self.format_map(kwargs)
|
|
97
|
+
|
|
98
|
+
# @classmethod
|
|
99
|
+
# def extract_mapping(cls, target: str, pattern: str):
|
|
100
|
+
# pattern = re.escape(pattern)
|
|
101
|
+
# regex = re.sub(r'\\{(.+?)\\}', r'(?P<_\1>.+)', pattern)
|
|
102
|
+
# match = re.match(regex, target)
|
|
103
|
+
# if match:
|
|
104
|
+
# values = list(match.groups())
|
|
105
|
+
# keys = re.findall(r'\\{(.+?)\\}', pattern)
|
|
106
|
+
# m = dict(zip(keys, values))
|
|
107
|
+
# return m
|
|
108
|
+
# else:
|
|
109
|
+
# raise Topic.Error(f'pattern {pattern} not in string {target} found!')
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def extract_mapping(cls, target: str, pattern: str) -> dict[str, str]:
|
|
113
|
+
dictionary = {}
|
|
114
|
+
|
|
115
|
+
result_parts = target.split('.')
|
|
116
|
+
pattern_parts = pattern.split('.')
|
|
117
|
+
|
|
118
|
+
# Check if the number of parts in result and pattern are the same
|
|
119
|
+
if len(result_parts) < len(pattern_parts):
|
|
120
|
+
raise cls.NotMatchError(f'Target {target} not match with pattern {pattern}.')
|
|
121
|
+
|
|
122
|
+
# Generate the mapping dictionary
|
|
123
|
+
for result_part, pattern_part in zip(result_parts, pattern_parts):
|
|
124
|
+
if pattern_part[0] == '{' and pattern_part[-1] == '}':
|
|
125
|
+
content: str = pattern_part[1:-1]
|
|
126
|
+
dictionary[content] = result_part
|
|
127
|
+
else:
|
|
128
|
+
if result_part != pattern_part:
|
|
129
|
+
dictionary.clear()
|
|
130
|
+
raise cls.NotMatchError(f'Target {target} not match with pattern {pattern}.')
|
|
131
|
+
|
|
132
|
+
return dictionary
|
|
133
|
+
|
|
134
|
+
def format_map(self, mapping: dict) -> Topic:
|
|
135
|
+
for key in self.keys():
|
|
136
|
+
if key not in mapping:
|
|
137
|
+
mapping[key] = f'{{{key}}}'
|
|
138
|
+
|
|
139
|
+
return Topic.cast(self._value.format_map(mapping))
|
|
140
|
+
|
|
141
|
+
def keys(self):
|
|
142
|
+
keys = [i[1] for i in Formatter().parse(self._value) if i[1] is not None]
|
|
143
|
+
return keys
|
|
144
|
+
|
|
145
|
+
def match(self, topic: str) -> Topic | None:
|
|
146
|
+
try:
|
|
147
|
+
keyword_dict = self.extract_mapping(target=topic, pattern=self._value)
|
|
148
|
+
match = Topic(topic=topic)
|
|
149
|
+
match.update(keyword_dict)
|
|
150
|
+
return match
|
|
151
|
+
except self.NotMatchError as _:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def value(self) -> str:
|
|
156
|
+
return self._value.format_map({_: '*' for _ in self.keys()})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import pathlib
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from ._topic import Topic, RegularTopic as RegularTopicBase, PatternTopic as PatternTopicBase
|
|
9
|
+
|
|
10
|
+
__all__ = ['Topic', 'RegularTopic', 'PatternTopic']
|
|
11
|
+
|
|
12
|
+
topic_lib = None
|
|
13
|
+
|
|
14
|
+
if platform.system() == 'Windows':
|
|
15
|
+
package_name = 'topic_api.(.*).pyd'
|
|
16
|
+
elif platform.system() == 'Darwin':
|
|
17
|
+
package_name = '^topic_api(.*).so$'
|
|
18
|
+
else:
|
|
19
|
+
package_name = '^topic_api(.*).so$'
|
|
20
|
+
|
|
21
|
+
ROOT_DIR = pathlib.Path(__file__).parent.parent
|
|
22
|
+
ENCODING = 'utf-8'
|
|
23
|
+
|
|
24
|
+
for _ in ROOT_DIR.iterdir():
|
|
25
|
+
if lib_path := re.search(package_name, _.name):
|
|
26
|
+
topic_lib = ctypes.CDLL(str(_))
|
|
27
|
+
break
|
|
28
|
+
|
|
29
|
+
# Load the shared library
|
|
30
|
+
if topic_lib is None:
|
|
31
|
+
raise ImportError(f'EventEngine.Topic c extension {package_name} not found in {ROOT_DIR}! Fallback to native lib!')
|
|
32
|
+
|
|
33
|
+
# Function prototypes
|
|
34
|
+
# topic_lib.delete_vector.restype = [ctypes.POINTER]
|
|
35
|
+
topic_lib.get_vector_value.restype = ctypes.c_char_p
|
|
36
|
+
topic_lib.is_regular_match.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
|
37
|
+
topic_lib.is_regular_match.restype = ctypes.c_int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Define the RegularTopic class in Python
|
|
41
|
+
class RegularTopic(RegularTopicBase):
|
|
42
|
+
"""
|
|
43
|
+
topic in regular expression. e.g. "TickData.(.+).((SZ)|(SH)).((Realtime)|(History))"
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def match(self, topic: str):
|
|
47
|
+
match = topic_lib.is_regular_match(topic.encode(ENCODING), self._value.encode(ENCODING))
|
|
48
|
+
|
|
49
|
+
if match:
|
|
50
|
+
return Topic(topic=topic, pattern=self.value)
|
|
51
|
+
else:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Define the PatternTopic class in Python
|
|
56
|
+
class PatternTopic(PatternTopicBase):
|
|
57
|
+
"""
|
|
58
|
+
topic for event hook. e.g. "TickData.{symbol}.{market}.{flag}"
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def match(self, topic: str):
|
|
62
|
+
mapping = self.extract_mapping(target=topic, pattern=self._value, encoding=ENCODING)
|
|
63
|
+
|
|
64
|
+
if mapping:
|
|
65
|
+
return Topic(topic, pattern=self._value, **mapping)
|
|
66
|
+
else:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def extract_mapping(cls, target: str, pattern: str, encoding: str = ENCODING) -> dict[str, str]:
|
|
71
|
+
# noinspection PyArgumentList
|
|
72
|
+
keys_ptr, values_ptr = ctypes.POINTER(ctypes.c_void_p)(), ctypes.POINTER(ctypes.c_void_p)()
|
|
73
|
+
mapping = {}
|
|
74
|
+
|
|
75
|
+
topic_lib.extract_mapping(
|
|
76
|
+
target.encode(encoding),
|
|
77
|
+
pattern.encode(encoding),
|
|
78
|
+
ctypes.byref(keys_ptr),
|
|
79
|
+
ctypes.byref(values_ptr)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
for i in range(topic_lib.vector_size(ctypes.byref(keys_ptr))):
|
|
83
|
+
key = topic_lib.get_vector_value(ctypes.byref(keys_ptr), i)
|
|
84
|
+
value = topic_lib.get_vector_value(ctypes.byref(values_ptr), i)
|
|
85
|
+
# print(key, value)
|
|
86
|
+
mapping[key.decode(encoding)] = value.decode(encoding)
|
|
87
|
+
|
|
88
|
+
# topic_lib.delete_vector(ctypes.byref(keys_ptr))
|
|
89
|
+
# topic_lib.delete_vector(ctypes.byref(values_ptr))
|
|
90
|
+
|
|
91
|
+
del keys_ptr
|
|
92
|
+
del values_ptr
|
|
93
|
+
|
|
94
|
+
return mapping
|