lorrystream 0.0.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.
@@ -0,0 +1 @@
1
+ from .cmd import parse_launch # noqa: F401
lorrystream/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ # Copyright (c) 2013-2023, The Kotori developers and contributors.
2
+ # Distributed under the terms of the LGPLv3 license, see LICENSE.
3
+
4
+ import logging
5
+ import typing as t
6
+
7
+ import click
8
+
9
+ from lorrystream import parse_launch
10
+ from lorrystream.core import run_single
11
+ from lorrystream.util.about import AboutReport
12
+ from lorrystream.util.aio import make_sync
13
+ from lorrystream.util.cli import boot_click, docstring_format_verbatim
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def help_relay():
19
+ """
20
+ Import and export data into/from InfluxDB
21
+
22
+ SOURCE can be a file or a URL.
23
+ TARGET can be a file or a URL.
24
+
25
+ Synopsis
26
+ ========
27
+
28
+ # Relay messages from MQTT to CrateDB.
29
+ lorry relay \\
30
+ "mqtt://localhost/testdrive/#" \\
31
+ "crate://localhost/testdrive/data"
32
+
33
+ # Relay messages from AMQP to MQTT.
34
+ lorry relay \\
35
+ "amqp://localhost/testdrive/demo" \\
36
+ "mqtt://localhost/testdrive/demo"
37
+
38
+ """ # noqa: E501
39
+
40
+
41
+ def help_launch():
42
+ """
43
+ Launch a LorryStream pipeline.
44
+
45
+ """ # noqa: E501
46
+
47
+
48
+ @click.group()
49
+ @click.version_option(package_name="lorrystream")
50
+ @click.option("--verbose", is_flag=True, required=False, help="Turn on logging")
51
+ @click.option("--debug", is_flag=True, required=False, help="Turn on logging with debug level")
52
+ @click.pass_context
53
+ def cli(ctx: click.Context, verbose: bool, debug: bool):
54
+ verbose = True
55
+ return boot_click(ctx, verbose, debug)
56
+
57
+
58
+ @cli.command("info", help="Report about platform information")
59
+ def info():
60
+ AboutReport.platform()
61
+
62
+
63
+ @cli.command(
64
+ "launch",
65
+ help=docstring_format_verbatim(help_launch.__doc__),
66
+ context_settings={"max_content_width": 120},
67
+ )
68
+ @click.argument("command", nargs=-1)
69
+ @click.pass_context
70
+ def launch(ctx: click.Context, command: t.Tuple[str]):
71
+ logger.info("Starting")
72
+ command_str = " ".join(command)
73
+ parse_launch(command_str)
74
+
75
+
76
+ @cli.command(
77
+ "relay",
78
+ help=docstring_format_verbatim(help_relay.__doc__),
79
+ context_settings={"max_content_width": 120},
80
+ )
81
+ @click.argument("source", type=str, required=True)
82
+ @click.argument("sink", type=str, required=False)
83
+ @click.pass_context
84
+ @make_sync
85
+ async def relay(ctx: click.Context, source: str, sink: str):
86
+ logger.info("Starting")
87
+ await run_single(source, sink)
lorrystream/cmd.py ADDED
@@ -0,0 +1,38 @@
1
+ # Copyright (c) 2013-2023, The Kotori developers and contributors.
2
+ # Distributed under the terms of the LGPLv3 license, see LICENSE.
3
+
4
+ import asyncio
5
+ import logging
6
+ import re
7
+ import typing as t
8
+
9
+ from lorrystream.core import run_single
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def get_location(element: t.Union[str, None]) -> str:
15
+ if element is None:
16
+ raise ValueError("Unable to decode location from empty element")
17
+ matches = re.match(".*location=(.*)", element)
18
+ if not matches:
19
+ raise ValueError(f"Unable to decode location from element: {element}")
20
+ location = matches.group(1)
21
+ return location
22
+
23
+
24
+ def parse_launch(command: str):
25
+ logger.info(f"Running command: {command}")
26
+ elements = map(str.strip, command.split("!"))
27
+ source = None
28
+ sink = None
29
+ for element in elements:
30
+ name, rest = element.split(" ")
31
+ if name.endswith("src"):
32
+ source = element
33
+ elif name.endswith("sink"):
34
+ sink = element
35
+ source_uri = get_location(source)
36
+ sink_uri = get_location(sink)
37
+ logger.info(f"Source: {source_uri}, Sink: {sink_uri}")
38
+ asyncio.run(run_single(source_uri, sink_uri))
lorrystream/core.py ADDED
@@ -0,0 +1,215 @@
1
+ # Copyright (c) 2013-2023, The Kotori developers and contributors.
2
+ # Distributed under the terms of the LGPLv3 license, see LICENSE.
3
+
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ import operator
8
+ import typing as t
9
+
10
+ from streamz import Sink, Source, Stream
11
+ from streamz.batch import Batch
12
+
13
+ from lorrystream.exceptions import InvalidContentTypeError, InvalidSinkError, InvalidSourceError
14
+ from lorrystream.model import Channel, Packet, SinkInputType, StreamAddress
15
+ from lorrystream.streamz.model import BusMessage
16
+ from lorrystream.util.data import get_sqlalchemy_dialects
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ async def run_single(source_uri: str, sink_uri: str):
22
+ """
23
+ Create a single channel and run it on the engine, blocking forever.
24
+
25
+ :param source_uri: Source element URI
26
+ :param sink_uri: Sink element URI
27
+ :return:
28
+ """
29
+
30
+ # Create channel.
31
+ channel = ChannelFactory(source=source_uri, sink=sink_uri).channel()
32
+
33
+ # Run channel with engine.
34
+ engine = Engine()
35
+ engine.register(channel)
36
+ await engine.run_forever()
37
+
38
+
39
+ async def run_channels(*channels: Channel):
40
+ """
41
+ Obtain multiple channel objects and run them on the engine, blocking forever.
42
+
43
+ :param channels: List of channel objects
44
+ :return:
45
+ """
46
+ engine = Engine()
47
+ for channel in channels:
48
+ engine.register(channel)
49
+ await engine.run_forever()
50
+
51
+
52
+ class Decoders:
53
+ @staticmethod
54
+ def decode_mqtt_json(msg):
55
+ """
56
+ Decode `MQTTMessage` object into `Packet` object.
57
+ """
58
+ return Packet(payload=json.loads(msg.payload), busmsg=msg)
59
+
60
+ @staticmethod
61
+ def decode_mqtt(msg):
62
+ """
63
+ Decode `MQTTMessage` object into `Packet` object.
64
+ """
65
+ return Packet(payload=json.loads(msg.payload), busmsg=msg)
66
+
67
+ @staticmethod
68
+ def decode_busmessage(busmsg: BusMessage):
69
+ """
70
+ Decode `BusMessage` object into `Packet` object.
71
+ """
72
+ return Packet(payload=busmsg.data.payload, busmsg=busmsg)
73
+
74
+ @staticmethod
75
+ def decode_json(packet: Packet):
76
+ """
77
+ Decode `MQTTMessage` object into `Packet` object.
78
+ """
79
+ packet.payload = json.loads(packet.payload)
80
+ return packet
81
+
82
+ @staticmethod
83
+ def decode_busmessage_json(busmsg: BusMessage):
84
+ """
85
+ Decode `BusMessage` object into `Packet` object.
86
+ """
87
+ if busmsg.data.payload is None:
88
+ return None
89
+ return Packet(payload=json.loads(busmsg.data.payload), busmsg=busmsg)
90
+
91
+
92
+ class ChannelFactory:
93
+ def __init__(self, source: str, sink: SinkInputType):
94
+ self.source_element: Source = None
95
+ self.sink_element: t.Union[Sink, t.Callable] = None
96
+ self.transformers: t.List[t.Callable] = []
97
+ self.pipeline: t.Union[Batch, Stream] = None
98
+
99
+ # FIXME: Obtain parameters from user.
100
+ self.batch_size = 2
101
+ self.timeout = 0.25
102
+
103
+ # When no sink is specified, use STDOUT.
104
+ # TODO: How to use `sys.stdout.buffer.write` instead?
105
+ if sink is None:
106
+ sink = print
107
+
108
+ self.source_address = StreamAddress.from_url(source)
109
+ self.sink_address = StreamAddress.resolve_sink(sink)
110
+
111
+ # Select source element for pipeline.
112
+ self.select_source(source)
113
+
114
+ # Create first half of pipeline.
115
+ self.mkpipeline()
116
+
117
+ # Select sink element for pipeline.
118
+ self.select_sink(sink)
119
+
120
+ def select_source(self, location: str):
121
+ """
122
+ Select source element of pipeline.
123
+ """
124
+ uri = self.source_address.uri
125
+ if uri.scheme.startswith("amqp"):
126
+ logger.info("Subscribing to AMQP")
127
+ self.source_element = Stream.from_amqp(self.source_address)
128
+ self.transformers.append(Decoders.decode_busmessage)
129
+
130
+ elif uri.scheme.startswith("mqtt"):
131
+ self.source_element = Stream.from_mqtt_plus(uri)
132
+ self.transformers.append(Decoders.decode_busmessage)
133
+
134
+ else:
135
+ raise InvalidSourceError(f"Source scheme unknown: {uri.scheme}")
136
+
137
+ if "content-type" in self.source_address.options:
138
+ source_content_type = self.source_address.options["content-type"]
139
+ if source_content_type == "json":
140
+ self.transformers.append(Decoders.decode_json)
141
+ else:
142
+ raise InvalidContentTypeError(f"Invalid content type for source '{uri}': {source_content_type}")
143
+
144
+ def mkpipeline(self):
145
+ """
146
+ n: int
147
+ Maximum partition size
148
+ timeout: int or float, optional
149
+ Number of seconds after which a partition will be emitted,
150
+ even if its size is less than ``n``. If ``None`` (default),
151
+ a partition will be emitted only when its size reaches ``n``.
152
+ """
153
+ self.pipeline = self.source_element.partition(n=self.batch_size, timeout=self.timeout).to_batch()
154
+ for transformer in self.transformers:
155
+ self.pipeline = self.pipeline.map(transformer)
156
+
157
+ def select_sink(self, location: SinkInputType):
158
+ """
159
+ Select sink element of pipeline.
160
+ """
161
+ db_dialects = get_sqlalchemy_dialects()
162
+ uri = self.sink_address.uri
163
+
164
+ if uri.scheme.startswith("callable"):
165
+ self.sink_element = self.pipeline.stream.sink(location)
166
+
167
+ elif uri.scheme in db_dialects:
168
+ # TODO: Weave in more sophisticated transformations here,
169
+ # like topic/topology/storage convergence from Kotori.
170
+ self.pipeline = self.pipeline.map(operator.attrgetter("payload")).to_dataframe()
171
+
172
+ self.sink_element = self.pipeline.stream.dataframe_to_sql(dburi=str(self.sink_address.uri))
173
+ else:
174
+ raise InvalidSinkError(f"Invalid sink location: {location}. Scheme unknown: {uri.scheme}.")
175
+
176
+ def transform(self, thing: t.Callable) -> "ChannelFactory":
177
+ self.transformers.append(thing)
178
+ return self
179
+
180
+ def channel(self) -> Channel:
181
+ """
182
+ Produce `Channel` object from elements.
183
+ """
184
+ return Channel(source=self.source_element, pipeline=self.pipeline, sink=self.sink_element)
185
+
186
+
187
+ class Engine:
188
+ def __init__(self):
189
+ self.channels: t.Dict[str, Channel] = {}
190
+ self._terminate_event = asyncio.Event()
191
+
192
+ def register(self, channel: Channel):
193
+ logger.info(f"Registering channel: {channel}")
194
+ # FIXME: The registry is currently a bit naive.
195
+ name = str(channel.source)
196
+ self.channels[name] = channel
197
+
198
+ def start(self):
199
+ for name, channel in self.channels.items():
200
+ logger.info(f"Starting channel {name}")
201
+ channel.source.start()
202
+
203
+ def stop(self):
204
+ for channel in self.channels.values():
205
+ logger.info(f"Stopping channel {channel}")
206
+ channel.source.stop()
207
+
208
+ def terminate(self):
209
+ self._terminate_event.set()
210
+
211
+ async def run_forever(self):
212
+ # Start engine.
213
+ self.start()
214
+ # Wait forever.
215
+ await self._terminate_event.wait()
@@ -0,0 +1,14 @@
1
+ # Copyright (c) 2013-2023, The Kotori developers and contributors.
2
+ # Distributed under the terms of the LGPLv3 license, see LICENSE.
3
+
4
+
5
+ class InvalidSourceError(Exception):
6
+ pass
7
+
8
+
9
+ class InvalidContentTypeError(Exception):
10
+ pass
11
+
12
+
13
+ class InvalidSinkError(Exception):
14
+ pass
lorrystream/model.py ADDED
@@ -0,0 +1,132 @@
1
+ # Copyright (c) 2013-2023, The Kotori developers and contributors.
2
+ # Distributed under the terms of the LGPLv3 license, see LICENSE.
3
+
4
+ import dataclasses
5
+ import operator
6
+ import typing as t
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ import funcy
10
+ from streamz import Sink, Source
11
+
12
+ from lorrystream.exceptions import InvalidSinkError
13
+ from lorrystream.streamz.model import URL, BusMessage
14
+ from lorrystream.util.data import asbool, split_list
15
+
16
+ SinkInputType = t.Union[str, t.Callable, None]
17
+
18
+
19
+ @dataclasses.dataclass
20
+ class Channel:
21
+ source: Source
22
+ pipeline: t.Any
23
+ sink: t.Union[t.Callable, Sink]
24
+
25
+ def tap(self, callback: t.Callable):
26
+ self.pipeline.stream.sink(callback)
27
+
28
+
29
+ @dataclasses.dataclass
30
+ class Packet:
31
+ payload: t.Any
32
+ busmsg: t.Optional[BusMessage] = None
33
+
34
+ @staticmethod
35
+ def payloads(data):
36
+ return list(map(operator.attrgetter("payload"), data))
37
+
38
+
39
+ class ConnectionString:
40
+ """
41
+ Helper class to support ``IoAccessor.export()``.
42
+ """
43
+
44
+ def __init__(self, url):
45
+ self.url_raw = url
46
+ self.url = urlparse(url)
47
+
48
+ def get_database(self):
49
+ # Try to get database name from query parameter.
50
+ database = self.get_query_param("database")
51
+
52
+ # Try to get database name from URL path.
53
+ if database is None:
54
+ if self.url.path.startswith("/"):
55
+ database = self.url.path[1:]
56
+
57
+ return database or "dwd"
58
+
59
+ def get_table(self):
60
+ return self.get_query_param("table") or "weather"
61
+
62
+ def get_path(self):
63
+ return self.url.path or self.url.netloc
64
+
65
+ def get_query_param(self, name):
66
+ query = parse_qs(self.url.query)
67
+ try:
68
+ return query[name][0]
69
+ except (KeyError, IndexError):
70
+ return None
71
+
72
+
73
+ @dataclasses.dataclass
74
+ class StreamAddress:
75
+ uri: URL
76
+ options: t.Dict[str, str] = dataclasses.field(default_factory=dict)
77
+
78
+ @classmethod
79
+ def from_url(cls, url: str):
80
+ uri = URL(url)
81
+ options = cls.extract_options(uri)
82
+ return cls(uri=uri, options=options)
83
+
84
+ @staticmethod
85
+ def extract_options(uri: URL) -> t.Dict[str, str]:
86
+ # Separate URI query parameters used by LorryStream.
87
+ control_option_names = [
88
+ # General options.
89
+ "content-type",
90
+ "reconnect",
91
+ # AMQP options.
92
+ "exchange",
93
+ "exchange-type",
94
+ "queue",
95
+ "routing-key",
96
+ "setup",
97
+ ]
98
+ list_options = [
99
+ "setup",
100
+ ]
101
+ boolean_options = [
102
+ "reconnect",
103
+ ]
104
+ options = funcy.project(uri.query_params, control_option_names)
105
+ query_params = funcy.omit(uri.query_params, control_option_names)
106
+ uri.query_params = query_params
107
+
108
+ for list_option in list_options:
109
+ if list_option in options:
110
+ options[list_option] = split_list(options[list_option])
111
+
112
+ for boolean_option in boolean_options:
113
+ if boolean_option in options:
114
+ options[boolean_option] = asbool(options[boolean_option])
115
+
116
+ return options
117
+
118
+ @classmethod
119
+ def resolve_sink(cls, sink: SinkInputType):
120
+ """
121
+ Select sink element of pipeline.
122
+ """
123
+
124
+ if callable(sink):
125
+ url = f"callable://{sink.__name__}"
126
+ elif isinstance(sink, str):
127
+ url = sink
128
+ else:
129
+ raise InvalidSinkError(f"Invalid sink: {sink}")
130
+
131
+ uri = URL(url)
132
+ return cls(uri=uri)
File without changes