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.
- lorrystream/__init__.py +1 -0
- lorrystream/cli.py +87 -0
- lorrystream/cmd.py +38 -0
- lorrystream/core.py +215 -0
- lorrystream/exceptions.py +14 -0
- lorrystream/model.py +132 -0
- lorrystream/streamz/__init__.py +0 -0
- lorrystream/streamz/amqp.py +513 -0
- lorrystream/streamz/model.py +148 -0
- lorrystream/streamz/sinks.py +114 -0
- lorrystream/streamz/sources.py +89 -0
- lorrystream/util/__init__.py +0 -0
- lorrystream/util/about.py +120 -0
- lorrystream/util/aio.py +59 -0
- lorrystream/util/cli.py +54 -0
- lorrystream/util/common.py +62 -0
- lorrystream/util/data.py +87 -0
- lorrystream-0.0.2.dist-info/LICENSE +165 -0
- lorrystream-0.0.2.dist-info/METADATA +382 -0
- lorrystream-0.0.2.dist-info/RECORD +23 -0
- lorrystream-0.0.2.dist-info/WHEEL +5 -0
- lorrystream-0.0.2.dist-info/entry_points.txt +9 -0
- lorrystream-0.0.2.dist-info/top_level.txt +1 -0
lorrystream/__init__.py
ADDED
|
@@ -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
|