libsrg_log2web 1.0.7__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.
libsrg_log2web/Demo.py ADDED
@@ -0,0 +1,67 @@
1
+ import platform
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from pathlib import Path
4
+ from time import sleep
5
+
6
+ from libsrg.LoggingAppBase import LoggingAppBase
7
+
8
+ from libsrg_log2web import FlaskApp, Log2Web
9
+ from libsrg_log2web.LoggerGUIProxy import LoggerGUIProxy
10
+
11
+
12
+ class Main(LoggingAppBase):
13
+
14
+ def __init__(self):
15
+
16
+ fmt = "%(asctime)s %(levelname)-8s %(lineno)4d %(name) 20s.%(funcName)-22s %(threadName)-12s-- %(message)s"
17
+ self.file0 = Path(__file__)
18
+ self.project_path = self.file0.parent
19
+ self.node = platform.node()
20
+ self.node0 = self.node.split(".")[0]
21
+
22
+ logfile_path = Path.home() / "Log2Web.log"
23
+ logfile_path.unlink(missing_ok=True)
24
+ self.logfile_name = str(logfile_path)
25
+ super().__init__(logfile=self.logfile_name, format=fmt)
26
+
27
+ self.bridge = Log2Web.Bridge(self.run, FlaskApp.app, title="Log2Web Demo",
28
+ headertext="Log2Web Demo Threads")
29
+ self.bridge.run()
30
+
31
+ def run(self) -> None:
32
+ self.logger.info("callback to application")
33
+
34
+ executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="POOL_")
35
+ [executor.submit(self.app_thread_target, i) for i in range(10)]
36
+
37
+ # sleep(10)
38
+ executor.shutdown(wait=True)
39
+
40
+ self.logger.debug("Bye")
41
+ # """Raises a signal to main thread which is WebGUI to shut down."""
42
+ # signal.raise_signal(2)
43
+
44
+ def app_thread_target(self, n: int) -> None:
45
+
46
+ LoggerGUIProxy.gui_new_line()
47
+ self.logger.info(f"app {n} starting")
48
+ for i in range(20):
49
+ match i:
50
+ case 3:
51
+ LoggerGUIProxy.gui_set_colors(foreground='pink', background='blue')
52
+ case 5:
53
+ LoggerGUIProxy.gui_set_colors(foreground='black', background='white')
54
+ case 7:
55
+ LoggerGUIProxy.gui_set_colors(foreground='cyan', background='red')
56
+
57
+ if i == n:
58
+ self.logger.warning(f"app {n=} cycle {i=}")
59
+ else:
60
+ self.logger.info(f"app {n=} cycle {i=}")
61
+
62
+ sleep(1 + n / 10.)
63
+ LoggerGUIProxy.gui_end_line()
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main = Main()
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+
3
+ from flask import Flask, render_template
4
+ from libsrg_log2web.Log2Web import LogWatcher, Bridge
5
+
6
+ my_file = __file__
7
+ flask_dir = Path(my_file).parent
8
+ app_dir = flask_dir.parent.parent
9
+ template_dir = flask_dir / "templates"
10
+ static_dir = flask_dir / "static"
11
+ app = Flask(__name__,
12
+ template_folder=template_dir,
13
+ static_folder=static_dir)
14
+
15
+
16
+ @app.route('/hello')
17
+ def hello_world(): # put the application's code here
18
+ return 'Hello World!'
19
+
20
+
21
+ @app.route('/')
22
+ def display_status(): # put the application's code here
23
+ # print("prepare display list")
24
+ watchers = LogWatcher.instance.prepare_display_list()
25
+ # print("rendering template")
26
+ page = render_template('status.html',
27
+ delay=0.25,
28
+ watchers=watchers,
29
+ title=Bridge.instance.title,
30
+ headertext=Bridge.instance.headertext)
31
+ # print("returning page")
32
+ return page
33
+
34
+
35
+ if __name__ == '__main__':
36
+ app.run()
@@ -0,0 +1,293 @@
1
+ import logging
2
+ import signal
3
+ import threading
4
+ import webbrowser
5
+ from threading import Thread
6
+ from time import sleep, time
7
+ from typing import Callable
8
+
9
+ from flask import Flask
10
+ from libsrg_log2web.LoggerGUIProxy import LogOps, LOG_OP_FMT_STRING
11
+ """
12
+ This module does not import the user's Flask application module or the user's Main module.
13
+ The user's Flask application can include the Log2Web module.
14
+ The user's Main module must import (and instantiate) the user's FlaskApp module.
15
+
16
+ The User's Main module must connect the FlaskApp module to the Log2Web module via a Bridge object.
17
+
18
+ As general guidance, attempting to log within the Log2Web module may cause infinite recursion.
19
+ """
20
+
21
+
22
+
23
+
24
+
25
+
26
+ """
27
+ ThreadLogWatcher is an object that receives log messages from a given thread and retains information for display.
28
+ """
29
+
30
+ status_color = {
31
+ logging.DEBUG: 'green',
32
+ logging.INFO: 'blue',
33
+ logging.WARNING: 'orange',
34
+ logging.ERROR: 'red',
35
+ logging.CRITICAL: 'magenta'
36
+ }
37
+
38
+
39
+ class ThreadLogWatcher:
40
+ def __init__(self, record0: logging.LogRecord):
41
+ self.thread_id: int = record0.thread
42
+ self.live = True
43
+ self.time_of_death = None
44
+ self.record: logging.LogRecord = record0
45
+ self.fg = 'green'
46
+ self.bg = 'white'
47
+ self.worst = logging.DEBUG
48
+ self.latest = logging.DEBUG
49
+ self.tname = record0.threadName
50
+ self.msg = "No message yet"
51
+ self.args0 = record0.args
52
+ self.updated: bool = False
53
+ if len(self.args0) > 0 and isinstance(self.args0[0], LogOps):
54
+ print(f"created with OP {self.args0[0].name=} {type(self.args0[0])=}")
55
+
56
+ def process_log_record(self, record: logging.LogRecord):
57
+ # print(f"{self.thread_id} {record.getMessage()} {record.message=} {type(record.message)=} {record.args=}")
58
+ self.record = record
59
+ self.msg = record.msg
60
+ self.worst = max(self.worst, record.levelno)
61
+ self.latest = record.levelno
62
+ self.tname = record.threadName
63
+ self.updated = True
64
+
65
+ # noinspection PyUnreachableCode
66
+ def process_log_op(self, op: LogOps, record: logging.LogRecord, *args):
67
+ # print(f"PROC OP {self.thread_id} {op=} {type(op)=} {args=}")
68
+ self.record = record
69
+ match op:
70
+ case LogOps.FG_COLOR:
71
+ self.fg = args[1]
72
+ case LogOps.BG_COLOR:
73
+ self.bg = args[1]
74
+ case LogOps.NEW_THREAD:
75
+ print(f"NEW_THREAD thread {self.thread_id} got exit OP {op=} {args=}")
76
+ pass # no action, but expected
77
+ case LogOps.THREAD_EXIT:
78
+ self.live = False
79
+ self.time_of_death = time()
80
+ print(f"THREAD_EXIT {self.thread_id} got exit OP {op=} {args=}")
81
+ case _:
82
+ # noinspection PyUnreachableCode
83
+ print(f"Unknown OP {op=} {args=}")
84
+ pass
85
+ # print(f"PROC OP results {self.thread_id} {self.fg=} {self.bg=}")
86
+
87
+ # noinspection PyUnusedLocal
88
+ def terminated(self, op: LogOps, *args):
89
+ # print(f"TERMINATED {self.thread_id} OP {op.name=} {type(op)=}")
90
+ self.live = False
91
+ self.time_of_death = time()
92
+
93
+ def time_dead(self, now: float) -> float | None:
94
+ if self.live:
95
+ return 0
96
+ return now - self.time_of_death
97
+
98
+ def display_row_start(self) -> str:
99
+ """HTML lead in"""
100
+ stat_w = status_color.get(self.worst, 'violet')
101
+ stat_l = status_color.get(self.latest, 'violet')
102
+ live_fg = 'green' if self.live else 'black'
103
+ live_bg = 'white' if self.live else 'lightgrey'
104
+ busy_fg = 'green' if self.updated else 'darkgrey'
105
+ self.updated = False
106
+ out = [
107
+ f"<td><span style='background-color:{live_bg};color:{live_fg};'>",
108
+ self.tname if self.live else f"<del>{self.tname}</del>",
109
+ f"</span></td>",
110
+
111
+ f"<td><span style='background-color:white;color:{stat_w};'>",
112
+ logging.getLevelName(self.worst),
113
+ f"</span></td>",
114
+
115
+ f"<td><div style='background-color:white;color:{stat_l};'>",
116
+ logging.getLevelName(self.latest),
117
+ f"</div></td>",
118
+
119
+ f"<td><div style='background-color:white;color:{busy_fg};'>",
120
+ f"{self.record.filename}:{self.record.lineno}@{self.record.funcName}",
121
+ f"</div></td>",
122
+
123
+ f"<td><div style='background-color:{self.bg};color:{self.fg};'>",
124
+ # f"{stat_w=} {stat_l=}<br>"
125
+ ]
126
+ return '\n'.join(out)
127
+
128
+ def display_row_body(self) -> str:
129
+ return f"{str(self.msg)[:80]}"
130
+
131
+ @staticmethod
132
+ def display_row_end() -> str:
133
+ return "</div></td>"
134
+
135
+
136
+ """
137
+ LogWatcher is a logging handler that can be attached to the logging subsystem to observe
138
+ log messages generated by all threads of the user's application.
139
+ It manages a collection of ThreadLogWatcher objects, one for each thread.
140
+ """
141
+
142
+
143
+ class LogWatcher(logging.Handler):
144
+ instance: LogWatcher = None
145
+
146
+ def __init__(self, *args, **kwargs):
147
+ """
148
+ Constructor.
149
+ :param args: positional arguments passed to logging.Handler
150
+ :param kwargs: keyword arguments passed to logging.Handler
151
+ """
152
+ super().__init__(*args, **kwargs)
153
+ self.kwargs = kwargs
154
+ logging.getLogger().addHandler(self)
155
+ LogWatcher.instance = self
156
+ self.thread_log_watchers: dict[int, ThreadLogWatcher] = {}
157
+ self.display_watchers: list[ThreadLogWatcher] = []
158
+ self.data_event = threading.Event()
159
+ self.mylock = threading.RLock()
160
+ self.listlock = threading.RLock()
161
+
162
+ def emit(self, record: logging.LogRecord) -> None:
163
+ """
164
+ Emit a record. (record is recorded to queue)
165
+ :param record:
166
+ :return:
167
+ """
168
+ if record.name == 'werkzeug':
169
+ return
170
+ with self.mylock:
171
+ self.data_event.set()
172
+
173
+ thread = record.thread
174
+ # tname = f"{record.funcName=} {record.threadName=} {record.name=} {record.filename=}"
175
+ watcher = self.thread_log_watchers.get(thread, None)
176
+ oflag = record.msg.startswith(LOG_OP_FMT_STRING) if isinstance(record.msg, str) else False
177
+ op = record.args[0] if (len(record.args) > 0) and oflag else None # and (record.message == LOGOP)
178
+
179
+ # print(f"EMIT {thread} {op=} {type(op)=} {record.getMessage()} {watcher=} {tname=} {oflag=}")
180
+ # On termination of a thread, remove the old watcher from the dictionary (but not the list of display watchers).
181
+ if watcher is not None and (op in [LogOps.NEW_THREAD, LogOps.THREAD_EXIT]):
182
+ self.thread_log_watchers.pop(thread)
183
+ watcher.terminated(op)
184
+ return
185
+
186
+ # dont create a watcher for EXIT ops
187
+ if op in [LogOps.NEW_THREAD, LogOps.THREAD_EXIT]:
188
+ return
189
+
190
+ # if there is no watcher for the thread, create one and add to dictionary and list of display watchers.
191
+ if watcher is None:
192
+ watcher = ThreadLogWatcher(record)
193
+ self.thread_log_watchers[thread] = watcher
194
+ self.display_watchers.append(watcher)
195
+
196
+ if op is None:
197
+ watcher.process_log_record(record)
198
+ else:
199
+ watcher.process_log_op(op, record, *record.args)
200
+
201
+ def prepare_display_list(self) -> list[ThreadLogWatcher]:
202
+ # wait up to 1 second for data to arrive.
203
+ # self.data_event.wait(timeout=0.5)
204
+ self.bring_out_your_dead()
205
+ watchers = self.display_watchers.copy()
206
+ if len(watchers) > 30:
207
+ watchers = [watcher for watcher in watchers if watcher.live]
208
+ return watchers
209
+
210
+ def bring_out_your_dead(self, delay: float = 5):
211
+ # print(f"Bringing out your dead before lock {delay=}")
212
+ with self.listlock:
213
+ # print(f"Bringing out your dead inside lock {delay=}")
214
+ self.data_event.clear()
215
+
216
+ now = time()
217
+ dead_watchers = list([watcher for watcher in self.display_watchers if watcher.time_dead(now) >= delay])
218
+ for watcher in dead_watchers:
219
+ self.display_watchers.remove(watcher)
220
+
221
+
222
+ """
223
+ The bridge object connects the user application and flask application.
224
+ All three are expected to be singletons.
225
+ """
226
+
227
+
228
+ class Bridge:
229
+ instance: Bridge = None
230
+
231
+ def __init__(self,
232
+ user_callable: Callable[[], None],
233
+ flask_app: Flask,
234
+ browser: bool = True,
235
+ title: str = "Log2Web",
236
+ headertext: str = "Data from threads",
237
+ address: str = "0.0.0.0",
238
+ port: int = 8080,
239
+ terminate_callback: Callable[[], None] = None,
240
+ ) -> None:
241
+ self.user_callable = user_callable
242
+ self.address = address
243
+ self.port = port
244
+ self.flask_app = flask_app
245
+ self.user_thread = Thread(target=self._run_user_in_thread, name="USER_MAIN")
246
+ self.terminate_callback = terminate_callback
247
+ Bridge.instance = self
248
+ self.log_watcher = LogWatcher()
249
+ self.browser = browser
250
+
251
+ self.title = title
252
+ self.headertext = headertext
253
+
254
+ """
255
+ The run method swaps control of the main thread to the flask application.
256
+ The user application starts in the main thread. Flask must run in the main thread.
257
+ A second thread is created to run the user_callable provided in the constructor.
258
+ This method does not return until the flask application exits.
259
+ """
260
+
261
+ def run(self):
262
+ self.user_thread.start()
263
+ self.flask_app.run(host=self.address, port=self.port)
264
+
265
+ def _run_user_in_thread(self):
266
+ """
267
+ Runs the user callable in a separate thread.
268
+ On completion, raises a signal to the main thread which is WebGUI to shut down.
269
+ :return:
270
+ """
271
+ try:
272
+ if self.browser:
273
+ # noinspection HttpUrlsUsage
274
+ webbrowser.open(f"http://{self.address}:{self.port}")
275
+ logging.getLogger().info("starting user callable")
276
+ self.user_callable()
277
+ logging.getLogger().info("user callable completed")
278
+ sleep(4)
279
+ except Exception as e:
280
+ logging.getLogger().exception(e)
281
+ finally:
282
+ if self.terminate_callback is not None:
283
+ logging.getLogger().info("Calling terminate callback")
284
+ try:
285
+ self.terminate_callback()
286
+ except Exception as e:
287
+ logging.getLogger().exception(e)
288
+ logging.getLogger().info("GUI Shutdown")
289
+ sleep(1)
290
+ signal.raise_signal(2)
291
+
292
+ if __name__ == "__main__":
293
+ print("This module does not run as a standalone program.")
@@ -0,0 +1,77 @@
1
+ # libsrg (Code and Documentation) is published under an MIT License
2
+ # Copyright (c) 2023,2024 Steven Goncalo
3
+ # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
4
+
5
+ # 2024 Steven Goncalo
6
+ import logging
7
+ from enum import Enum
8
+
9
+
10
+ class LogOps(Enum):
11
+ NEW_THREAD = 1
12
+ THREAD_EXIT = 2
13
+ FG_COLOR = 3
14
+ BG_COLOR = 4
15
+
16
+
17
+ LOG_OP_FMT_STRING = "LOG_OP_FMT_STRING %s"
18
+
19
+
20
+ def _send_log_op(op: LogOps, *args, logr=None):
21
+ fmt = LOG_OP_FMT_STRING
22
+ fmt += " %s" * len(args)
23
+ if not logr:
24
+ logr = logging.getLogger("LoggerGUIProxy")
25
+ # print(f"send op {op=} {args=} {fmt=}")
26
+ stacklevel = 2
27
+ logr.info(fmt, op, stacklevel=stacklevel, *args)
28
+
29
+
30
+ class LoggerGUIProxy:
31
+ """
32
+ LoggerGUIProxy provides several methods to control the LoggerGUI via calls to logging.
33
+ """
34
+
35
+ @classmethod
36
+ def gui_new_line(cls, logr=None):
37
+ """
38
+ Schedule the GUI line item for this thread to be deleted and disassociate it with this thread.
39
+ If subsequent logging occurs from the same thread, a new GUI line is created.
40
+ """
41
+ _send_log_op(LogOps.NEW_THREAD, logr=logr)
42
+
43
+ @classmethod
44
+ def gui_freeze_line(cls, logr=None):
45
+ """
46
+ Freeze the current contents of the GUI line for this thread.
47
+ Do not schedule the GUI line item for this thread to be deleted but disassociate it with this thread.
48
+ If subsequent logging occurs from the same thread, a new GUI line is created.
49
+ """
50
+ _send_log_op(LogOps.THREAD_EXIT, logr=logr)
51
+
52
+ @classmethod
53
+ def gui_end_line(cls, logr=None):
54
+ """
55
+ Freeze the current contents of the GUI line for this thread.
56
+ Do not schedule the GUI line item for this thread to be deleted but disassociate it with this thread.
57
+ If subsequent logging occurs from the same thread, a new GUI line is created.
58
+ """
59
+ _send_log_op(LogOps.THREAD_EXIT, logr=logr)
60
+
61
+ @classmethod
62
+ def gui_configure(cls, logr=None, **kwargs):
63
+ """
64
+ Send one or more logs to set configuration values for the GUI line associated with this thread.
65
+ Each log sent is a single name/value pair.
66
+ """
67
+ if "background" in kwargs:
68
+ _send_log_op(LogOps.BG_COLOR, kwargs["background"], logr=logr)
69
+ if "foreground" in kwargs:
70
+ _send_log_op(LogOps.FG_COLOR, kwargs["foreground"], logr=logr)
71
+
72
+ @classmethod
73
+ def gui_set_colors(cls, logr=None, foreground=None, background=None, ):
74
+ if foreground:
75
+ _send_log_op(LogOps.FG_COLOR, foreground, logr=logr)
76
+ if background:
77
+ _send_log_op(LogOps.BG_COLOR, background, logr=logr)
File without changes
@@ -0,0 +1,8 @@
1
+ table.status {
2
+ border: 1px solid black;
3
+ }
4
+
5
+ tr.header {
6
+ background-color: black;
7
+ color: cyan;
8
+ }
@@ -0,0 +1,30 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta http-equiv="refresh" content="{{ delay }}">
6
+ <link href="../static/style.css" rel="stylesheet" type="text/css">
7
+ <title>{{title}}</title>
8
+ </head>
9
+ <body>
10
+ {{headertext}}
11
+ <table class="status">
12
+ <tr class="header">
13
+ <th>Thread</th>
14
+ <th>Net Status</th>
15
+ <th>Latest Status</th>
16
+ <th>Location</th>
17
+ <th>Message</th>
18
+ </tr>
19
+ {% for watcher in watchers %}
20
+ <tr>
21
+ {{ watcher.display_row_start()|safe }}
22
+ {{ watcher.display_row_body() }}
23
+ {{ watcher.display_row_end()|safe }}
24
+
25
+ </tr>
26
+ {% endfor %}
27
+ </table>
28
+
29
+ </body>
30
+ </html>
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: libsrg_log2web
3
+ Version: 1.0.7
4
+ Summary: Multithreded logging to webpage
5
+ Author-email: Steve Goncalo <steven@goncalo.us>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3.14
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Natural Language :: English
11
+ Classifier: Topic :: Utilities
12
+ Classifier: Intended Audience :: Developers
13
+ Requires-Python: >=3.14
14
+ Requires-Dist: flask>=3.1.2
15
+ Requires-Dist: libsrg>=6.0.4
@@ -0,0 +1,11 @@
1
+ libsrg_log2web/Demo.py,sha256=bzPkIb-0mCuooME8q5pdUZzUn-Gc9_naMRo-8wdukBE,2224
2
+ libsrg_log2web/FlaskApp.py,sha256=x_xBZbRR5Al7hnqlp1cy5arC0Ba_3a-K8_eL56mx9OI,1027
3
+ libsrg_log2web/Log2Web.py,sha256=gOLqbDD103NNj3A_LpwKGEUb4fE3hrIv1F2sZrJTNaI,10837
4
+ libsrg_log2web/LoggerGUIProxy.py,sha256=_v7vScn1DEF9BT8XCuMTtQ_0u3yMjtmLlq7RiCOdI_k,2714
5
+ libsrg_log2web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ libsrg_log2web/static/style.css,sha256=GBktqyouX908PaeRx8BreKqrT9aAMEUQx08zSXV4UBU,106
7
+ libsrg_log2web/templates/status.html,sha256=ZM8a1x95JukuvGVMqp8IiBkwxXmzeLYGJXAZV42staQ,743
8
+ libsrg_log2web-1.0.7.dist-info/METADATA,sha256=u3b7zp8M0HAKBr2pd-i1ogWXmnGbNjaXI7_jsAkSA_U,521
9
+ libsrg_log2web-1.0.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ libsrg_log2web-1.0.7.dist-info/top_level.txt,sha256=1lhFTpg88gjbyp67E4jaEF4qOdgh4nS7XerT50wmelM,15
11
+ libsrg_log2web-1.0.7.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ libsrg_log2web