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
|
@@ -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()
|
lorrystream/util/aio.py
ADDED
|
@@ -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)
|
lorrystream/util/cli.py
ADDED
|
@@ -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}")
|
lorrystream/util/data.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 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)]
|