libsrg_log2web 1.0.3__tar.gz

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,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: libsrg_log2web
3
+ Version: 1.0.3
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,70 @@
1
+ import platform
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from pathlib import Path
4
+ from time import sleep
5
+
6
+ from libsrg_log2web import FlaskApp, Log2Web
7
+ from libsrg.LoggingAppBase import LoggingAppBase
8
+
9
+ class Main(LoggingAppBase):
10
+
11
+ def __init__(self):
12
+
13
+
14
+ fmt = "%(asctime)s %(levelname)-8s %(lineno)4d %(name) 20s.%(funcName)-22s %(threadName)-12s-- %(message)s"
15
+ self.file0 = Path(__file__)
16
+ self.project_path = self.file0.parent
17
+ self.node = platform.node()
18
+ self.node0 = self.node.split(".")[0]
19
+
20
+ logfile_path = Path.home() / "Log2Web.log"
21
+ logfile_path.unlink(missing_ok=True)
22
+ self.logfile_name = str(logfile_path)
23
+ super().__init__(logfile=self.logfile_name, format=fmt)
24
+
25
+ self.bridge= Log2Web.Bridge(self.run, FlaskApp.app, title="Log2Web Demo",
26
+ headertext="Log2Web Demo Threads")
27
+ self.bridge.run()
28
+
29
+
30
+ def run(self)->None:
31
+ self.logger.info("callback to application")
32
+
33
+ executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="POOL_")
34
+ futures = [executor.submit(self.app_thread_target,i ) for i in range(10)]
35
+
36
+ # sleep(10)
37
+ executor.shutdown(wait=True)
38
+
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
+ Log2Web.send_log_op(Log2Web.LogOps.NEW_THREAD)
47
+ self.logger.info(f"app {n} starting")
48
+ for i in range(20):
49
+ match i:
50
+ case 3:
51
+ Log2Web.send_log_op(Log2Web.LogOps.BG_COLOR, 'pink')
52
+ Log2Web.send_log_op(Log2Web.LogOps.FG_COLOR, 'blue')
53
+ case 5:
54
+ Log2Web.send_log_op(Log2Web.LogOps.BG_COLOR, 'black')
55
+ Log2Web.send_log_op(Log2Web.LogOps.FG_COLOR, 'white')
56
+ case 7:
57
+ Log2Web.send_log_op(Log2Web.LogOps.BG_COLOR, 'cyan')
58
+ Log2Web.send_log_op(Log2Web.LogOps.FG_COLOR, 'red')
59
+
60
+ if i == n:
61
+ self.logger.warning(f"app {n=} cycle {i=}")
62
+ else:
63
+ self.logger.info(f"app {n=} cycle {i=}")
64
+
65
+ sleep(1+n/10.)
66
+
67
+ Log2Web.send_log_op(Log2Web.LogOps.THREAD_EXIT)
68
+
69
+ if __name__=="__main__":
70
+ main = Main()
@@ -0,0 +1,32 @@
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
+ watchers = LogWatcher.instance.display()
24
+ return render_template('status.html',
25
+ delay=0.1,
26
+ watchers=LogWatcher.instance.display_watchers,
27
+ title=Bridge.instance.title,
28
+ headertext=Bridge.instance.headertext)
29
+
30
+
31
+ if __name__ == '__main__':
32
+ app.run()
@@ -0,0 +1,277 @@
1
+ import logging
2
+ import signal
3
+ import threading
4
+ import webbrowser
5
+ from enum import Enum
6
+ from threading import Thread
7
+ from time import sleep, time
8
+ from typing import Callable, Optional
9
+
10
+ from flask import Flask
11
+
12
+ """
13
+ This module does not import the user's Flask application module or the user's Main module.
14
+ The user's Flask application can include the Log2Web module.
15
+ The user's Main module must import (and instantiate) the user's FlaskApp module.
16
+
17
+ The User's Main module must connect the FlaskApp module to the Log2Web module via a Bridge object.
18
+
19
+ As general guidance, attempting to log within the Log2Web module may cause infinite recursion.
20
+ """
21
+
22
+ LOGOP = "LOGOP %s"
23
+
24
+
25
+ class LogOps(Enum):
26
+ NEW_THREAD = 1
27
+ THREAD_EXIT = 2
28
+ FG_COLOR = 3
29
+ BG_COLOR = 4
30
+
31
+
32
+ def send_log_op(op: LogOps, *args):
33
+ fmt = LOGOP
34
+ fmt += " %s" * len(args)
35
+ # print(f"send op {op=} {args=} {fmt=}")
36
+ logging.getLogger().info(fmt, op, *args)
37
+
38
+
39
+ """
40
+ ThreadLogWatcher is an object that receives log messages from a given thread and retains information for display.
41
+ """
42
+
43
+ status_color = {
44
+ logging.DEBUG: 'green',
45
+ logging.INFO: 'blue',
46
+ logging.WARNING: 'orange',
47
+ logging.ERROR: 'red',
48
+ logging.CRITICAL: 'magenta'
49
+ }
50
+
51
+
52
+ class ThreadLogWatcher:
53
+ def __init__(self, thread_id: int):
54
+ self.thread_id: int = thread_id
55
+ self.live = True
56
+ self.time_of_death = None
57
+ self.record: Optional[logging.LogRecord] = logging.LogRecord(
58
+ name="No data yet",
59
+ level=logging.DEBUG,
60
+ pathname="No data yet",
61
+ lineno=0,
62
+ msg="No data yet",
63
+ args=(),
64
+ exc_info=None
65
+ )
66
+ self.fg = 'green'
67
+ self.bg = 'white'
68
+ self.worst = logging.DEBUG
69
+ self.latest = logging.DEBUG
70
+ self.tname = "?"
71
+ self.msg="No message yet"
72
+
73
+ def process_log_record(self, record: logging.LogRecord):
74
+ # print(f"{self.thread_id} {record.getMessage()} {record.message=} {type(record.message)=} {record.args=}")
75
+ self.record = record
76
+ self.msg = record.msg
77
+ self.worst = max(self.worst, record.levelno)
78
+ self.latest = record.levelno
79
+ self.tname = record.threadName
80
+
81
+ def process_log_op(self, op: LogOps,record: logging.LogRecord, *args):
82
+ # print(f"PROC OP {self.thread_id} {op=} {type(op)=} {args=}")
83
+ self.record = record
84
+ match op:
85
+ case LogOps.FG_COLOR:
86
+ self.fg = args[1]
87
+ case LogOps.BG_COLOR:
88
+ self.bg = args[1]
89
+ case LogOps.NEW_THREAD:
90
+ pass # no action, but expected
91
+ case _:
92
+ # print(f"Unknown OP {op=} {args=}")
93
+ pass
94
+ # print(f"PROC OP results {self.thread_id} {self.fg=} {self.bg=}")
95
+
96
+ # noinspection PyUnusedLocal
97
+ def terminated(self, op: LogOps, *args):
98
+ # print(f"TERMINATED {self.thread_id} OP {op.name=} {type(op)=}")
99
+ self.live = False
100
+ self.time_of_death = time()
101
+
102
+ def time_dead(self, now: float) -> float | None:
103
+ if self.live:
104
+ return 0
105
+ return now - self.time_of_death
106
+
107
+ def display_row_start(self) -> str:
108
+ """HTML lead in"""
109
+ stat_w = status_color.get(self.worst, 'violet')
110
+ stat_l = status_color.get(self.latest, 'violet')
111
+ live_fg = 'green' if self.live else 'black'
112
+ live_bg = 'white' if self.live else 'lightgrey'
113
+ out = [
114
+ f"<td><span style='background-color:{live_bg};color:{live_fg};'>",
115
+ self.tname if self.live else f"<del>{self.tname}</del>",
116
+ f"</span></td>",
117
+
118
+ f"<td><span style='background-color:white;color:{stat_w};'>",
119
+ logging.getLevelName(self.worst),
120
+ f"</span></td>",
121
+
122
+ f"<td><div style='background-color:white;color:{stat_l};'>",
123
+ logging.getLevelName(self.latest),
124
+ f"</div></td>",
125
+
126
+ f"<td><div style='background-color:{self.bg};color:{self.fg};'>",
127
+ # f"{stat_w=} {stat_l=}<br>"
128
+ ]
129
+ return '\n'.join(out)
130
+
131
+ def display_row_body(self) -> str:
132
+ return f"{self.msg} {self.record.filename} {self.record.lineno}"
133
+
134
+ @staticmethod
135
+ def display_row_end() -> str:
136
+ return "</div></td>"
137
+
138
+
139
+ """
140
+ LogWatcher is a logging handler that can be attached to the logging subsystem to observe
141
+ log messages generated by all threads of the user's application.
142
+ It manages a collection of ThreadLogWatcher objects, one for each thread.
143
+ """
144
+
145
+
146
+ class LogWatcher(logging.Handler):
147
+ instance: LogWatcher = None
148
+
149
+ def __init__(self, *args, **kwargs):
150
+ """
151
+ Constructor.
152
+ :param args: positional arguments passed to logging.Handler
153
+ :param kwargs: keyword arguments passed to logging.Handler
154
+ """
155
+ super().__init__(*args, **kwargs)
156
+ self.kwargs = kwargs
157
+ logging.getLogger().addHandler(self)
158
+ LogWatcher.instance = self
159
+ self.thread_log_watchers: dict[int, ThreadLogWatcher] = {}
160
+ self.display_watchers: list[ThreadLogWatcher] = []
161
+ self.data_event = threading.Event()
162
+
163
+ def emit(self, record: logging.LogRecord) -> None:
164
+ """
165
+ Emit a record. (record is recorded to queue)
166
+ :param record:
167
+ :return:
168
+ """
169
+ if record.name == 'werkzeug':
170
+ return
171
+
172
+ self.data_event.set()
173
+
174
+ thread = record.thread
175
+ # tname = f"{record.funcName=} {record.threadName=} {record.name=} {record.filename=}"
176
+ watcher = self.thread_log_watchers.get(thread, None)
177
+ oflag = record.msg.startswith(LOGOP)
178
+ op = record.args[0] if (len(record.args) > 0) and oflag else None # and (record.message == LOGOP)
179
+
180
+ # print(f"EMIT {thread} {op=} {type(op)=} {record.getMessage()} {watcher=} {tname=} {oflag=}")
181
+ # On termination of a thread, remove the old watcher from the dictionary (but not the list of display watchers).
182
+ if watcher is not None and (op in [LogOps.NEW_THREAD, LogOps.THREAD_EXIT]):
183
+ self.thread_log_watchers.pop(thread)
184
+ watcher.terminated(op)
185
+ return
186
+
187
+ # if there is no watcher for the thread, create one and add to dictionary and list of display watchers.
188
+ if watcher is None:
189
+ watcher = ThreadLogWatcher(thread)
190
+ self.thread_log_watchers[thread] = watcher
191
+ self.display_watchers.append(watcher)
192
+
193
+ if op is None:
194
+ watcher.process_log_record(record)
195
+ else:
196
+ watcher.process_log_op(op, record,*record.args)
197
+
198
+ def display(self) -> list[ThreadLogWatcher]:
199
+ # wait up to 1 second for data to arrive.
200
+ self.data_event.wait(timeout=1)
201
+ self.data_event.clear()
202
+
203
+ now = time()
204
+ dead_watchers = [watcher for watcher in self.display_watchers if watcher.time_dead(now) > 5]
205
+ for watcher in dead_watchers:
206
+ self.display_watchers.remove(watcher)
207
+
208
+ return self.display_watchers.copy()
209
+
210
+
211
+ """
212
+ The bridge object connects the user application and flask application.
213
+ All three are expected to be singletons.
214
+ """
215
+
216
+
217
+ class Bridge:
218
+ instance: Bridge = None
219
+
220
+ def __init__(self,
221
+ user_callable: Callable[[], None],
222
+ flask_app: Flask,
223
+ browser: bool = True,
224
+ title: str = "Log2Web",
225
+ headertext: str = "Data from threads",
226
+ ) -> None:
227
+ self.user_callable = user_callable
228
+ self.flask_app = flask_app
229
+ self.user_thread = Thread(target=self._run_user_in_thread, name="USER_MAIN")
230
+ Bridge.instance = self
231
+ self.log_watcher = LogWatcher()
232
+ self.browser = browser
233
+
234
+ self.title = title
235
+ self.headertext = headertext
236
+
237
+ """
238
+ The run method swaps control of the main thread to the flask application.
239
+ The user application starts in the main thread. Flask must run in the main thread.
240
+ A second thread is created to run the user_callable provided in the constructor.
241
+ This method does not return until the flask application exits.
242
+ """
243
+
244
+ def run(self):
245
+ self.user_thread.start()
246
+ self.flask_app.run(host='0.0.0.0', port=8080)
247
+
248
+ def _run_user_in_thread(self):
249
+ """
250
+ Runs the user callable in a separate thread.
251
+ On completion, raises a signal to the main thread which is WebGUI to shut down.
252
+ :return:
253
+ """
254
+ try:
255
+ if self.browser:
256
+ webbrowser.open("http://localhost:8080")
257
+ logging.getLogger().info("starting user callable")
258
+ self.user_callable()
259
+ logging.getLogger().info("user callable completed")
260
+ sleep(4)
261
+ except Exception as e:
262
+ logging.getLogger().exception(e)
263
+ finally:
264
+ logging.getLogger().info("GUI Shutdown")
265
+ sleep(1)
266
+ signal.raise_signal(2)
267
+
268
+
269
+ class LoggerGUIProxy:
270
+ @staticmethod
271
+ def gui_configure(background='white', foreground='black'):
272
+ send_log_op(LogOps.BG_COLOR, background)
273
+ send_log_op(LogOps.FG_COLOR, foreground)
274
+
275
+ @staticmethod
276
+ def gui_new_line():
277
+ send_log_op(LogOps.NEW_THREAD)
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,29 @@
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>Message</th>
17
+ </tr>
18
+ {% for watcher in watchers %}
19
+ <tr>
20
+ {{ watcher.display_row_start()|safe }}
21
+ {{ watcher.display_row_body() }}
22
+ {{ watcher.display_row_end()|safe }}
23
+
24
+ </tr>
25
+ {% endfor %}
26
+ </table>
27
+
28
+ </body>
29
+ </html>
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: libsrg_log2web
3
+ Version: 1.0.3
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,12 @@
1
+ pyproject.toml
2
+ libsrg_log2web/Demo.py
3
+ libsrg_log2web/FlaskApp.py
4
+ libsrg_log2web/Log2Web.py
5
+ libsrg_log2web/__init__.py
6
+ libsrg_log2web.egg-info/PKG-INFO
7
+ libsrg_log2web.egg-info/SOURCES.txt
8
+ libsrg_log2web.egg-info/dependency_links.txt
9
+ libsrg_log2web.egg-info/requires.txt
10
+ libsrg_log2web.egg-info/top_level.txt
11
+ libsrg_log2web/static/style.css
12
+ libsrg_log2web/templates/status.html
@@ -0,0 +1,2 @@
1
+ flask>=3.1.2
2
+ libsrg>=6.0.4
@@ -0,0 +1 @@
1
+ libsrg_log2web
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "libsrg_log2web"
3
+ version = "1.0.3"
4
+ description = "Multithreded logging to webpage"
5
+ requires-python = ">=3.14"
6
+ dependencies = [
7
+ "flask>=3.1.2",
8
+ "libsrg>=6.0.4",
9
+ ]
10
+
11
+
12
+ authors = [{name="Steve Goncalo", email="steven@goncalo.us"}]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3.14",
15
+ "Development Status :: 5 - Production/Stable",
16
+ # "License :: OSI Approved :: MIT License",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Natural Language :: English",
19
+ "Topic :: Utilities",
20
+ "Intended Audience :: Developers"
21
+ ]
22
+ #license = {text="MIT"}
23
+ license = "MIT"
24
+
25
+ [tool.uv]
26
+ "link-mode"="symlink"
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "build>=1.2.2.post1",
31
+ "twine>=6.1.0",
32
+ ]
33
+
34
+
35
+
36
+ [build-system]
37
+ requires = ["setuptools", "wheel"]
38
+ build-backend = "setuptools.build_meta"
39
+
40
+ [tool.setuptools]
41
+ packages = ["libsrg_log2web"]
42
+
43
+ [tool.setuptools.package-data]
44
+ # Include all .txt and .rst files in the 'hello' package
45
+ #"hello" = ["*.txt", "*.rst"]
46
+ # Include all .png files in all packages
47
+ "libsrg_log2web" = ["static/*","templates/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+