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,114 @@
1
+ # Copyright (c) 2013-2023, The Kotori developers and contributors.
2
+ # Distributed under the terms of a BSD-3-Clause license, see LICENSE.
3
+
4
+ import logging
5
+ import re
6
+ import typing as t
7
+
8
+ import pandas as pd
9
+ import sqlalchemy as sa
10
+ from sqlalchemy import Engine
11
+ from streamz import Sink, Stream
12
+
13
+ from lorrystream.exceptions import InvalidSinkError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @Stream.register_api()
19
+ class dataframe_to_sql(Sink):
20
+ """
21
+ Store data into SQLAlchemy-compatible database.
22
+
23
+ Requires ``sqlalchemy``
24
+
25
+ :param dburi: str
26
+ SQLAlchemy connection URI.
27
+ :param engine_options:
28
+ Propagated to SQLAlchemy's ``create_engine(**kwargs)``.
29
+ """
30
+
31
+ def __init__(self, upstream, dburi, engine_options=None, **kwargs):
32
+ self.dburi = dburi
33
+ self.engine: t.Union[Engine, None] = None
34
+ self.engine_options = engine_options or {}
35
+ self.table_name = None
36
+ self.if_exists = "append"
37
+ self.method = None
38
+ self.chunksize = 10_000
39
+ super().__init__(upstream, ensure_io_loop=True, **kwargs)
40
+
41
+ def update(self, x, who=None, metadata=None):
42
+ """
43
+ Store packets into database.
44
+ """
45
+ df: pd.DataFrame = x
46
+ logger.info(f"to_sql.update: who={who}, metadata={metadata}")
47
+ df.info()
48
+ print(df) # noqa: T201
49
+
50
+ if self.engine is None:
51
+ logger.info(f"Connecting to {self.dburi}")
52
+ # TODO: Improve.
53
+ matches = re.match(r"^.*table=([\w-]*)", self.dburi)
54
+ if matches:
55
+ self.table_name = matches.group(1)
56
+ else:
57
+ raise InvalidSinkError("Unable to obtain table name")
58
+ matches = re.match(r"^.*if_exists=(\w*)", self.dburi)
59
+ if matches:
60
+ self.if_exists = matches.group(1)
61
+ dburi = re.sub(r"\?.*", "", self.dburi)
62
+ logger.info(f"Effective dburi: {dburi}")
63
+ logger.info(f"Writing to table: {self.table_name}, if_exists={self.if_exists}")
64
+ self.engine = sa.create_engine(dburi, **self.engine_options)
65
+
66
+ # Use CrateDB bulk operations endpoint for improved efficiency.
67
+ if self.dburi.startswith("crate"):
68
+ self.method = self.insert_bulk
69
+
70
+ df.to_sql(
71
+ name=self.table_name,
72
+ con=self.engine,
73
+ if_exists=self.if_exists,
74
+ index=False,
75
+ chunksize=self.chunksize,
76
+ method=self.method,
77
+ )
78
+
79
+ @staticmethod
80
+ def insert_bulk(pd_table, conn, keys, data_iter):
81
+ """
82
+ A fast insert method for pandas and Dask, using CrateDB's "bulk operations" endpoint.
83
+
84
+ The idea is to break out of SQLAlchemy, compile the insert statement, and use the raw
85
+ DBAPI connection client, in order to invoke a request using `bulk_parameters`::
86
+
87
+ cursor.execute(sql=sql, bulk_parameters=data)
88
+
89
+ - https://crate.io/docs/crate/reference/en/5.2/interfaces/http.html#bulk-operations
90
+ """
91
+
92
+ """
93
+ # Vanilla
94
+ # This is the regular implementation, using SQLAlchemy.
95
+ data = [dict(zip(keys, row)) for row in data_iter]
96
+ conn.execute(pd_table.table.insert(), data)
97
+ """
98
+
99
+ # Bulk
100
+ sql = str(pd_table.table.insert().compile(bind=conn))
101
+ data = list(data_iter)
102
+
103
+ logger.info(f"Bulk SQL: {sql}")
104
+ logger.info(f"Bulk records: {len(data)}")
105
+
106
+ cursor = conn._dbapi_connection.cursor()
107
+ cursor.execute(sql=sql, bulk_parameters=data)
108
+ cursor.close()
109
+
110
+ def destroy(self):
111
+ if self.engine is not None:
112
+ logger.info(f"Disconnecting from {self.dburi}")
113
+ self.engine.dispose()
114
+ super().destroy()
@@ -0,0 +1,89 @@
1
+ # Copyright (c) 2013-2024, The Kotori developers and contributors.
2
+ # Distributed under the terms of a BSD-3-Clause license, see LICENSE.
3
+ import logging
4
+ import queue
5
+ import typing as t
6
+ from collections import OrderedDict
7
+
8
+ from streamz import Stream, from_mqtt, from_q
9
+
10
+ from lorrystream.model import StreamAddress
11
+ from lorrystream.streamz.amqp import AMQPAdapter, ReconnectingAMQPAdapter
12
+ from lorrystream.streamz.model import URL, BusMessage
13
+ from lorrystream.util.aio import AsyncThreadTask
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @Stream.register_api()
19
+ class FromAmqp(from_q):
20
+ """Read from AMQP source
21
+
22
+ See https://en.wikipedia.org/wiki/AMQP for a description of the protocol
23
+ and its uses. Requires the ``pika`` package.
24
+
25
+ - https://www.rabbitmq.com/uri-spec.html
26
+ - https://www.rabbitmq.com/uri-query-parameters.html
27
+
28
+ TODO: See also ``sinks.to_amqp``.
29
+
30
+ :param uri: str
31
+ :param reconnect: bool
32
+ """
33
+
34
+ def __init__(self, address: StreamAddress, **kwargs):
35
+ self.address = address
36
+ self.stopped = True
37
+ consumer_class: t.Callable = AMQPAdapter
38
+ if self.address.options.get("reconnect", True):
39
+ consumer_class = ReconnectingAMQPAdapter
40
+ self.consumer = consumer_class(address=address, on_message=self._on_message)
41
+ super().__init__(q=queue.Queue(), **kwargs)
42
+
43
+ def _delivery_to_dict(self, basic_deliver):
44
+ deliver_attrs = ["consumer_tag", "delivery_tag", "redelivered", "exchange", "routing_key"]
45
+ items = OrderedDict()
46
+ for attr in deliver_attrs:
47
+ items[attr] = getattr(basic_deliver, attr)
48
+ return items
49
+
50
+ def _properties_to_dict(self, properties):
51
+ items = OrderedDict()
52
+ for key, value in properties.__dict__.items():
53
+ if getattr(properties.__class__, key, None) != value:
54
+ items[key] = value
55
+ return items
56
+
57
+ def _on_message(self, channel, basic_deliver, properties, body):
58
+ busmsg = BusMessage.from_amqp_pika(self, channel, basic_deliver, properties, body)
59
+ self.q.put(busmsg)
60
+
61
+ async def run(self):
62
+ AsyncThreadTask(self.consumer.run).run()
63
+ await super().run()
64
+
65
+ def stop(self):
66
+ # TODO: Does not work with CTRL+C yet. Validate if it works on other occasions at least.
67
+ if not self.stopped:
68
+ self.consumer.stop()
69
+ self.consumer = None
70
+ self.stopped = True
71
+ super().stop()
72
+
73
+
74
+ @Stream.register_api()
75
+ class FromMqttPlus(from_mqtt):
76
+ def __init__(self, uri: URL, client_kwargs=None, **kwargs):
77
+ host = uri.host or "localhost"
78
+ port = uri.port or uri.default_port
79
+ topic = uri.path_unquoted.lstrip("/")
80
+ logger.info(f"Subscribing to MQTT topic '{topic}' at broker on '{host}")
81
+ super().__init__(host, port, topic, keepalive=60, client_kwargs=client_kwargs, **kwargs)
82
+
83
+ def _on_message(self, client, userdata, msg):
84
+ busmsg = BusMessage.from_mqtt_paho(self, client, userdata, msg)
85
+ self.q.put(busmsg)
86
+
87
+
88
+ from_amqp = FromAmqp
89
+ from_mqtt_plus = FromMqttPlus
File without changes
@@ -0,0 +1,120 @@
1
+ # Copyright (c) 2013-2023, The Kotori developers and contributors.
2
+ # Distributed under the terms of the LGPLv3 license, see LICENSE.
3
+
4
+ import platform
5
+ import sys
6
+ import textwrap
7
+ import typing as t
8
+ from collections import abc
9
+
10
+ from colorama import Fore, Style
11
+
12
+ string_or_list = t.Union[str, t.List[str], t.Iterable[str]]
13
+
14
+
15
+ def section(text: str):
16
+ return armor(text, title)
17
+
18
+
19
+ def subsection(text: str):
20
+ return armor(text, subtitle)
21
+
22
+
23
+ def armor(text: str, formatter: t.Callable):
24
+ guard = "=" * len(text)
25
+ print(guard)
26
+ print(formatter(text))
27
+ print(guard)
28
+
29
+
30
+ def title(text: str):
31
+ return Fore.CYAN + Style.BRIGHT + text + Style.RESET_ALL
32
+
33
+
34
+ def subtitle(text: str):
35
+ return Fore.YELLOW + Style.BRIGHT + text + Style.RESET_ALL
36
+
37
+
38
+ def text_list(data: string_or_list):
39
+ if isinstance(data, str):
40
+ return data
41
+ elif isinstance(data, t.List):
42
+ return ", ".join(data)
43
+ else:
44
+ raise ValueError("Unable to flatten list")
45
+
46
+
47
+ def bullet_list(data: t.List[str]):
48
+ data.sort()
49
+ lines = [f"- {text_list(line)}" for line in data]
50
+ return "\n".join(lines)
51
+
52
+
53
+ def wrap_list(data: t.List[str], **kwargs):
54
+ data = [item for item in data if item is not None]
55
+ data.sort()
56
+ text = ", ".join(data)
57
+ return "\n".join(textwrap.wrap(text, width=80, **kwargs))
58
+
59
+
60
+ def bullet_item(data: string_or_list, label: str = None):
61
+ if data is None:
62
+ return None
63
+ if label is None:
64
+ label = ""
65
+ else:
66
+ label = f"{label}: "
67
+ if isinstance(data, (list, tuple, abc.KeysView, abc.ValuesView)):
68
+ text = wrap_list(list(data), subsequent_indent=" ")
69
+ else:
70
+ text = data
71
+ return f"- {label}{text}"
72
+
73
+
74
+ class AboutReport:
75
+ @staticmethod
76
+ def platform():
77
+ section("Platform")
78
+ print()
79
+
80
+ subsection("Python")
81
+ print(bullet_item(platform.platform()))
82
+ print()
83
+
84
+ # SQLAlchemy
85
+ from importlib.metadata import entry_points
86
+
87
+ import sqlalchemy.dialects
88
+
89
+ subsection("SQLAlchemy")
90
+ print(bullet_item(sqlalchemy.dialects.__all__, label="Dialects built-in"))
91
+ dialects: t.List[str]
92
+ if sys.version_info >= (3, 10):
93
+ eps = entry_points(group="sqlalchemy.dialects")
94
+ dialects = [dialect.name for dialect in eps]
95
+ else:
96
+ dialects = []
97
+ print(bullet_item(dialects, label="Dialects 3rd-party"))
98
+ print(bullet_item(sqlalchemy.dialects.plugins.impls.keys(), label="Plugins"))
99
+ print()
100
+
101
+ # fsspec
102
+ try:
103
+ import fsspec
104
+
105
+ subsection("fsspec protocols")
106
+ print(bullet_item(fsspec.available_protocols()))
107
+ print()
108
+
109
+ subsection("fsspec compressions")
110
+ print(bullet_item(fsspec.available_compressions()))
111
+ print()
112
+ except ImportError:
113
+ pass
114
+
115
+ # pandas
116
+ subsection("pandas module versions")
117
+ import pandas
118
+
119
+ pandas.show_versions(as_json=False)
120
+ print()
@@ -0,0 +1,59 @@
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 functools
6
+
7
+
8
+ def make_sync(func):
9
+ """
10
+ Click entrypoint decorator for wrapping asynchronous functions.
11
+
12
+ https://github.com/pallets/click/issues/2033
13
+ """
14
+
15
+ @functools.wraps(func)
16
+ def wrapper(*args, **kwargs):
17
+ return asyncio.run(func(*args, **kwargs))
18
+
19
+ return wrapper
20
+
21
+
22
+ def tornado_asyncio_run_forever():
23
+ from tornado.platform.asyncio import AsyncIOMainLoop
24
+
25
+ AsyncIOMainLoop().install()
26
+
27
+ def run_asyncio_loop():
28
+ loop = asyncio.get_event_loop()
29
+ try:
30
+ loop.run_forever()
31
+ except KeyboardInterrupt:
32
+ pass
33
+ finally:
34
+ loop.close()
35
+
36
+ run_asyncio_loop()
37
+
38
+
39
+ class AsyncThreadTask:
40
+ """
41
+ Starts a function using `asyncio.to_thread()`, with a new asyncio I/O loop.
42
+ """
43
+
44
+ def __init__(self, func, *args, **kwargs):
45
+ self.func = func
46
+ self.args = args
47
+ self.kwargs = kwargs
48
+ self.loop = asyncio.new_event_loop()
49
+
50
+ def run(self):
51
+ return self.launch()
52
+
53
+ def launch(self):
54
+ coro = asyncio.to_thread(self._thread)
55
+ return asyncio.create_task(coro)
56
+
57
+ def _thread(self):
58
+ asyncio.set_event_loop(self.loop)
59
+ return self.func(*self.args, **self.kwargs)
@@ -0,0 +1,54 @@
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 textwrap
6
+ import typing as t
7
+
8
+ import click
9
+
10
+ from lorrystream.util.common import setup_logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def boot_click(ctx: click.Context, verbose: bool = False, debug: bool = False):
16
+ """
17
+ Bootstrap the CLI application.
18
+ """
19
+
20
+ # Adjust log level according to `verbose` / `debug` flags.
21
+ log_level = logging.WARNING
22
+ if verbose:
23
+ log_level = logging.INFO
24
+ if debug:
25
+ log_level = logging.DEBUG
26
+
27
+ # Setup logging, according to `verbose` / `debug` flags.
28
+ setup_logging(level=log_level)
29
+
30
+
31
+ def to_list(x: t.Any, default: t.List[t.Any] = None) -> t.List[t.Any]:
32
+ if not isinstance(default, t.List):
33
+ raise ValueError("Default value is not a list")
34
+ if x is None:
35
+ return default
36
+ if not isinstance(x, t.Iterable) or isinstance(x, str):
37
+ return [x]
38
+ elif isinstance(x, list):
39
+ return x
40
+ else:
41
+ return list(x)
42
+
43
+
44
+ def docstring_format_verbatim(text: t.Optional[str]) -> str:
45
+ """
46
+ Format docstring to be displayed verbatim as a help text by Click.
47
+
48
+ - https://click.palletsprojects.com/en/8.1.x/documentation/#preventing-rewrapping
49
+ - https://github.com/pallets/click/issues/56
50
+ """
51
+ text = text or ""
52
+ text = textwrap.dedent(text)
53
+ lines = [line if line.strip() else "\b" for line in text.splitlines()]
54
+ return "\n".join(lines)
@@ -0,0 +1,62 @@
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 shlex
6
+ import subprocess
7
+ import sys
8
+ from textwrap import dedent
9
+
10
+ import colorlog
11
+ from colorlog.escape_codes import escape_codes
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ SQLALCHEMY_LOGGING = True
17
+
18
+
19
+ def setup_logging_basic(level=logging.INFO):
20
+ log_format = "%(asctime)-15s [%(name)-24s] %(levelname)-8s: %(message)s"
21
+ logging.basicConfig(format=log_format, stream=sys.stderr, level=level)
22
+
23
+
24
+ def setup_logging(level=logging.INFO):
25
+ reset = escape_codes["reset"]
26
+ log_format = f"%(asctime)-15s [%(name)-28s] %(log_color)s%(levelname)-8s:{reset} %(message)s"
27
+
28
+ handler = colorlog.StreamHandler()
29
+ handler.setFormatter(colorlog.ColoredFormatter(log_format))
30
+
31
+ logging.basicConfig(format=log_format, level=level, handlers=[handler])
32
+
33
+ # Enable SQLAlchemy logging.
34
+ if SQLALCHEMY_LOGGING:
35
+ logging.getLogger("sqlalchemy").setLevel(level)
36
+
37
+
38
+ def get_version(appname):
39
+ from importlib.metadata import PackageNotFoundError, version # noqa
40
+
41
+ try:
42
+ return version(appname)
43
+ except PackageNotFoundError: # pragma: no cover
44
+ return "unknown"
45
+
46
+
47
+ def run_command(command: str):
48
+ """
49
+ https://stackoverflow.com/a/48813330
50
+ """
51
+ command = dedent(command).strip()
52
+ cmd = shlex.split(command)
53
+
54
+ try:
55
+ output = subprocess.check_output(cmd, stderr=subprocess.STDOUT, universal_newlines=True) # noqa: S603
56
+ except subprocess.CalledProcessError as exc:
57
+ logger.error(f"Command failed (exit code {exc.returncode}). The command was:\n{command}")
58
+ logger.error(exc.output)
59
+ raise
60
+ else:
61
+ if output:
62
+ logger.info(f"Command output:\n{output}")
@@ -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 json
5
+ import sys
6
+ import typing as t
7
+
8
+ import pandas as pd
9
+
10
+
11
+ def jd(data: t.Any):
12
+ """
13
+ Pretty-print JSON with indentation.
14
+ """
15
+ print(json.dumps(data, indent=2)) # noqa: T201
16
+
17
+
18
+ def chunker(seq: pd.DataFrame, chunksize: int):
19
+ """
20
+ Chunks generator function for iterating pandas Dataframes and Series.
21
+
22
+ https://stackoverflow.com/a/61798585
23
+ :return:
24
+ """
25
+ for pos in range(0, len(seq), chunksize):
26
+ yield seq.iloc[pos : pos + chunksize]
27
+
28
+
29
+ def df_convert_datetimes(df: pd.DataFrame) -> pd.DataFrame:
30
+ """
31
+ Convert all datetime columns to ISO format.
32
+
33
+ :param df: df[Columns.FROM_DATE] = df[Columns.FROM_DATE].dt.tz_localize(self.tz)
34
+ :return:
35
+ """
36
+ df = df.copy(deep=True)
37
+
38
+ date_columns = list(df.select_dtypes(include=[pd.DatetimeTZDtype]).columns)
39
+ # date_columns.extend([Columns.FROM_DATE.value, Columns.TO_DATE.value, Columns.DATE.value]) # noqa: ERA001
40
+ date_columns_set = set(date_columns)
41
+ for date_column in date_columns_set:
42
+ if date_column in df:
43
+ df[date_column] = df[date_column].apply(lambda d: d.isoformat() if pd.notna(d) else None)
44
+
45
+ return df
46
+
47
+
48
+ def get_sqlalchemy_dialects() -> t.List[str]:
49
+ """
50
+ Return list of available SQLAlchemy dialects.
51
+
52
+ :return:
53
+ """
54
+ from importlib.metadata import entry_points
55
+
56
+ import sqlalchemy.dialects
57
+
58
+ builtins = sqlalchemy.dialects.__all__
59
+ more: t.List
60
+ if sys.version_info >= (3, 10):
61
+ eps = entry_points(group="sqlalchemy.dialects")
62
+ more = [dialect.name for dialect in eps]
63
+ else:
64
+ eps = entry_points()
65
+ more = [dialect[0].name for dialect in eps.values() if dialect[0].group == "sqlalchemy.dialects"]
66
+
67
+ return sorted(list(builtins) + list(more))
68
+
69
+
70
+ def asbool(obj: t.Any) -> bool:
71
+ # from sqlalchemy.util.langhelpers
72
+ # from paste.deploy.converters
73
+ if isinstance(obj, str):
74
+ obj = obj.strip().lower()
75
+ if obj in ["true", "yes", "on", "y", "t", "1"]:
76
+ return True
77
+ elif obj in ["false", "no", "off", "n", "f", "0"]:
78
+ return False
79
+ else:
80
+ raise ValueError("String is not true/false: %r" % obj)
81
+ return bool(obj)
82
+
83
+
84
+ def split_list(value: str, delimiter: str = ",") -> t.List[str]:
85
+ if value is None:
86
+ return []
87
+ return [c.strip() for c in value.split(delimiter)]