procman 2.0.0__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.
procman-2.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Andrei Sukhanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
procman-2.0.0/PKG-INFO ADDED
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: procman
3
+ Version: 2.0.0
4
+ Summary: Tabbed GUI to start/stop/monitor programs
5
+ Project-URL: Homepage, https://github.com/ASukhanov/procman
6
+ Author-email: Andrei Sukhanov <cyxandr@gmail.com>
7
+ License-File: LICENSE
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.7
12
+ Requires-Dist: qtpy
13
+ Description-Content-Type: text/markdown
14
+
15
+ # manman
16
+ Compact GUI for deployment and monitoring of servers and applications
17
+ associated with specific tasks or apparatuses.<br>
18
+ ![condensed](docs/manman_condensed.jpg)<br>
19
+ The GUI can control multiple independent apparatuses in different tabs.
20
+
21
+ The 'status' column dynamically shows the color-coded status of the server.
22
+
23
+ The commands are executed by right clicking on the cells in the 'Applications' (leftmost) column.<br>
24
+ The top left cell executes table-wide commands:```Check All, Start All, Stop All```.<br>
25
+ It also holds commands to
26
+ - delete current tab (**Delete**),
27
+ - edit the table of the current tab (**Edit**),
28
+ - condense and expand table arrangement (**Condense and Uncondense**).
29
+
30
+ The following actions are defined for regular rows:
31
+ - **Check**
32
+ - **Start**
33
+ - **Stop**
34
+ - **Command**: will display the command for starting the server/application
35
+
36
+ Definition of actions, associated with an apparatus, are defined in the
37
+ startup dictionary of the python scripts, code-named as apparatus_NAME.py. See examples in the config directory.
38
+
39
+ Supported keys are:
40
+ - **'cmd'**: command which will be used to start and stop the server,
41
+ - **'cd'**: directory (if needed), from where to run the cmd,
42
+ - **'process'**: used for checking/stopping the server to identify
43
+ its process. If cmd properly identifies the
44
+ server, then this key is not necessary,
45
+ - **'shell'**: some serverss require shell=True option for subprocess.Popen(),
46
+ - **'help'**: it will be used as a tooltip,
47
+
48
+ ## Demo
49
+ - ```python -m manman config/apparatus*.py```<br>
50
+ Control of all apparatuses, defined in the ./config directory.
51
+ Each apparatus will be controlled in a separate tab.
52
+ - ```python -m manman -c config apparatus1_test.py apparatus3_TST.py```<br>
53
+ Control two apparatuses from the ./config directory.
54
+ - ```python -m manman -i -c config```<br>
55
+ Interacively select apparatuses from the ./config directory.<br>
56
+ ![manman](docs/manman.png)
57
+
@@ -0,0 +1,43 @@
1
+ # manman
2
+ Compact GUI for deployment and monitoring of servers and applications
3
+ associated with specific tasks or apparatuses.<br>
4
+ ![condensed](docs/manman_condensed.jpg)<br>
5
+ The GUI can control multiple independent apparatuses in different tabs.
6
+
7
+ The 'status' column dynamically shows the color-coded status of the server.
8
+
9
+ The commands are executed by right clicking on the cells in the 'Applications' (leftmost) column.<br>
10
+ The top left cell executes table-wide commands:```Check All, Start All, Stop All```.<br>
11
+ It also holds commands to
12
+ - delete current tab (**Delete**),
13
+ - edit the table of the current tab (**Edit**),
14
+ - condense and expand table arrangement (**Condense and Uncondense**).
15
+
16
+ The following actions are defined for regular rows:
17
+ - **Check**
18
+ - **Start**
19
+ - **Stop**
20
+ - **Command**: will display the command for starting the server/application
21
+
22
+ Definition of actions, associated with an apparatus, are defined in the
23
+ startup dictionary of the python scripts, code-named as apparatus_NAME.py. See examples in the config directory.
24
+
25
+ Supported keys are:
26
+ - **'cmd'**: command which will be used to start and stop the server,
27
+ - **'cd'**: directory (if needed), from where to run the cmd,
28
+ - **'process'**: used for checking/stopping the server to identify
29
+ its process. If cmd properly identifies the
30
+ server, then this key is not necessary,
31
+ - **'shell'**: some serverss require shell=True option for subprocess.Popen(),
32
+ - **'help'**: it will be used as a tooltip,
33
+
34
+ ## Demo
35
+ - ```python -m manman config/apparatus*.py```<br>
36
+ Control of all apparatuses, defined in the ./config directory.
37
+ Each apparatus will be controlled in a separate tab.
38
+ - ```python -m manman -c config apparatus1_test.py apparatus3_TST.py```<br>
39
+ Control two apparatuses from the ./config directory.
40
+ - ```python -m manman -i -c config```<br>
41
+ Interacively select apparatuses from the ./config directory.<br>
42
+ ![manman](docs/manman.png)
43
+
@@ -0,0 +1,40 @@
1
+ """Pypet for liteServer peak simulator with embedded plot
2
+ """
3
+ __version__='v0.0.2 2025-04-15'# host="localhost;9701:"
4
+
5
+ _Namespace = "LITE"
6
+ host = "localhost;9701:"
7
+ dev = f"{host}dev1:"
8
+ server = f"{host}server:"
9
+ #``````````````````Definitions````````````````````````````````````````````````
10
+ # Python expressions and functions, used in the spreadsheet.
11
+ _=' '
12
+ def span(x,y): return {'span':[x,y]}
13
+ def color(*v): return {'color':v[0]} if len(v)==1 else {'color':list(v)}
14
+ pvplot = f"python3 -m pvplot -a L:{dev} x,y"
15
+
16
+ #``````````````````Page attributes, optional``````````````````````````````````
17
+ #_Page = {'editable':False, **color(252,252,237)}
18
+ _Page = {**color(240,240,240)}
19
+
20
+ _Columns = {
21
+ 1: {"justify": "center"},
22
+ 2: {"width": 100},
23
+ 3: {"justify": "right"},
24
+ 5: {"width": 400},
25
+ }
26
+
27
+ _Rows = [
28
+ ['Performance:', {server+'perf':span(3,1)},_,_,{_:{'embed':pvplot,**span(1,10)}}],
29
+ ["run", dev+"run", 'debug:', server+'debug'],
30
+ ["status", {dev+"status":span(3,1)}],
31
+ ["frequency", dev+"frequency", "nPoints:", dev+"nPoints"],
32
+ ["background", {dev+"background":span(3,1)}],
33
+ ["noise", dev+"noise", "swing:", dev+"swing"],
34
+ ["peakPars", {dev+"peakPars":span(3,1)}],
35
+ #["x", {dev+"x":span(3,1)}],
36
+ #["y", {dev+"y":span(3,1)}],
37
+ ['yMin:', dev+'yMin', 'yMax:', dev+'yMax'],
38
+ ["rps", dev+"rps", "cycle:", dev+"cycle"],
39
+ [],
40
+ ]
@@ -0,0 +1,24 @@
1
+ '''Definition of the second test apparatus.
2
+ '''
3
+ import os
4
+ homeDir = os.environ['HOME']
5
+
6
+ __version__ = 'v0.0.1 2025-05-21'#
7
+
8
+ # abbreviations:
9
+ help,cmd,process,cd = ['help','cmd','process','cd']
10
+
11
+ #``````````````````Properties, used by manman`````````````````````````````````
12
+ title = 'Test applications'
13
+
14
+ startup = {
15
+ 'xclock':{help:'Digital xclock',
16
+ cmd:'xclock -digital'
17
+ },
18
+ 'htop':{help:'Process viewer in separate xterm',
19
+ cmd:'xterm htop',
20
+ },
21
+ 'sleep30':{help:'Sleep for 30 seconds',
22
+ cmd:'sleep 30', process:'sleep 30'
23
+ },
24
+ }
@@ -0,0 +1,24 @@
1
+ '''Definition of the second test apparatus.
2
+ '''
3
+ import os
4
+ homeDir = os.environ['HOME']
5
+
6
+ __version__ = 'v0.0.1 2025-05-21'#
7
+
8
+ # abbreviations:
9
+ help,cmd,process,cd = ['help','cmd','process','cd']
10
+
11
+ #``````````````````Properties, used by manman`````````````````````````````````
12
+ title = 'Test1 applications'
13
+
14
+ startup = {
15
+ 'xclock':{help:'Analog xclock',
16
+ cmd:'xclock -analog'
17
+ },
18
+ 'top':{help:'Process viewer in separate xterm',
19
+ cmd:'xterm top',
20
+ },
21
+ 'sleep10':{help:'Sleep for 10 seconds',
22
+ cmd:'sleep 10', process:'sleep 10'
23
+ },
24
+ }
@@ -0,0 +1,72 @@
1
+ '''Definition of a test apparatus, running a liteServer with peak simulator.
2
+ The managers for this example could be installed with pip:
3
+ pip install liteserver pvplot pypeto
4
+
5
+ The script should define dictionary startup.
6
+ Supported keys are:
7
+ 'cmd': command which will be used to start and stop the manager,
8
+ 'cd: directory (if needed), from where to run the cmd,
9
+ 'process': used for stopping the manager using 'pkill -f', if cmd properly identifies the
10
+ manager, then this key is not necessary,
11
+ 'help': it will be used as a tooltip,
12
+ '''
13
+ import os
14
+ homeDir = os.environ['HOME']
15
+ epics_home = os.environ.get('EPICS_HOME')
16
+
17
+ __version__ = 'v0.1.5 2025-04-26'# added 'process' to better track of processes
18
+
19
+ # abbreviations:
20
+ help,cmd,process,cd = ['help','cmd','process','cd']
21
+
22
+ #``````````````````Properties, used by manman`````````````````````````````````
23
+ title = 'Peak Simulator'
24
+ startup = {
25
+ # Operational managers
26
+ # liteServer-based
27
+ 'peakSimulator':{help:
28
+ 'Lite server, simulating peaks and noise',
29
+ cmd: 'python3 -m liteserver.device.litePeakSimulator -ilo -p9701',
30
+ process: 'liteserver.device.litePeakSimulator -ilo -p9701',
31
+ },
32
+ 'plot it':{help:
33
+ 'Plotting tool for peakSimulator',
34
+ cmd: 'python3 -m pvplot -aL:localhost;9701:dev1: x,y',
35
+ process: 'pvplot -aL:localhost;9701:dev1: x,y',
36
+ },
37
+ 'control it':{help:
38
+ 'Automatic parameter editing tool of the peakSimulator',
39
+ cmd: 'python3 -m pypeto -aLITE localhost;9701:dev1',
40
+ process: 'pypeto -aLITE localhost;9701:dev1',
41
+ },
42
+ 'control&plot':{help:
43
+ 'Parameter editing with integrated plot',
44
+ cmd: 'python3 -m pypeto -c config -f peakSimPlot',
45
+ process: 'pypeto -c config -f peakSimPlot',
46
+ #Note: It will look for config file: config/peakSimPlot_pp.py
47
+ },
48
+ }
49
+ if epics_home is not None:
50
+ startup.update({
51
+ # EPICS IOCs
52
+ 'simScope':{help:
53
+ 'EPICS testAsynPortDriver, hosting a simulate oscilloscope',
54
+ cd:f'{epics_home}/asyn/iocBoot/ioctestAsynPortDriver/',
55
+ cmd:'screen -d -m -S simScope ../../bin/linux-x86_64/testAsynPortDriver st.cmd',
56
+ process:'testAsynPortDriver st.cmd',
57
+ },
58
+ #'tst_caproto_ioc': {cmd:'python3 -m caproto.ioc_examples.simple --list-pvs',help:
59
+ # 'Simple IOC for testing EPICS Channel Access functionality'},
60
+ 'pet_simScope':{help:
61
+ 'Parameter editing tool for simScope',
62
+ cmd: 'python3 -m pypeto -f Controls/EPICS/simScope',
63
+ },
64
+ })
65
+
66
+ # Managers for testing and debugging
67
+ startup.update({
68
+ 'tst_sleep30':{help:
69
+ 'sleep for 30 seconds',
70
+ cmd:'sleep 30', process:'sleep 30'
71
+ },
72
+ })
@@ -0,0 +1,3 @@
1
+ #__all__ = ['manman']
2
+ from .procman import __version__, Window
3
+
@@ -0,0 +1,55 @@
1
+ """Tabbed GUI for starting/stopping/monitoring programs.
2
+ """
3
+ __version__ = 'v1.1.1 2025-07-30'# added --interactive
4
+
5
+ import sys, os, argparse
6
+ from qtpy.QtWidgets import QApplication
7
+ from . import procman, helpers
8
+
9
+ #``````````````````Main```````````````````````````````````````````````````````
10
+ def main():
11
+ global pargs
12
+ parser = argparse.ArgumentParser('python -m procman',
13
+ description=__doc__,
14
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
15
+ epilog=f'Version {procman.__version__}')
16
+ parser.add_argument('-c', '--configDir', help=\
17
+ ('Root directory of config files, one config file per program, '
18
+ 'if None, then ./config directory will be used'))
19
+ parser.add_argument('-C', '--condensed', action='store_true', help=\
20
+ 'Condensed arrangement of tables: no headers, narrow columns')
21
+ parser.add_argument('-i', '--interactive', default=False, action='store_true', help=
22
+ 'Select files interactively')
23
+ parser.add_argument('-t', '--interval', default=10., help=\
24
+ 'Interval in seconds of periodic checking. If 0 then no checking')
25
+ parser.add_argument('-v', '--verbose', action='count', default=0, help=\
26
+ 'Show more log messages (-vv: show even more).')
27
+ parser.add_argument('-z', '--zoomin', help=\
28
+ 'Zoom the application window by a factor, factor must be >= 1')
29
+ parser.add_argument('files', nargs='*', help=\
30
+ ('Path of config files, can include wildcards. '
31
+ 'If None, then an interactive dialog will be opened to select files.')),
32
+ pargs = parser.parse_args()
33
+ helpers.Verbose = pargs.verbose
34
+ if pargs.configDir is None and len(pargs.filess) == 0:
35
+ pargs.configDir = 'config'
36
+ procman.Window.pargs = pargs# transfer pargs to procman module
37
+
38
+ # handle the --zoomin
39
+ if pargs.zoomin is not None:
40
+ os.environ["QT_SCALE_FACTOR"] = pargs.zoomin
41
+
42
+ # arrange keyboard interrupt to kill the program
43
+ import signal
44
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
45
+
46
+ # start GUI
47
+ app = QApplication(sys.argv)
48
+ window = procman.Window()
49
+ window.show()
50
+ app.exec_()
51
+ print('Application exit')
52
+
53
+ if __name__ == '__main__':
54
+ main()
55
+
@@ -0,0 +1,106 @@
1
+ '''Command line tool to start servers and managers
2
+ '''
3
+ __version__ = 'v0.1.4 2024-10-27'#
4
+ import sys, os, time, subprocess, argparse, threading
5
+ from functools import partial
6
+ from importlib import import_module
7
+
8
+ from . import helpers as H
9
+
10
+ Apparatus = H.list_of_apparatus()
11
+
12
+ ManCmds = ['Check','Start','Stop','Command']
13
+
14
+ def manAction(manName, cmd):
15
+ H.printv(f'manAction: {manName, cmd}')
16
+ cmdstart = Startup[manName]['cmd']
17
+ if cmd == 'Check':
18
+ H.printv(f'checking process {cmdstart} ')
19
+ if H.is_process_running(cmdstart):
20
+ print(f'Manager "{manName}" \tstarted')#, process name: "{cmdstart}"')
21
+ return os.EX_OK
22
+ else:
23
+ print(f'Manager "{manName}" \tis not running')
24
+ return os.EX_UNAVAILABLE
25
+
26
+ elif cmd == 'Start':
27
+ H.printv(f'starting {manName}')
28
+ if H.is_process_running(cmdstart):
29
+ H.printe(f'Manager "{manName}" is already running.')
30
+ return os.EX_CANTCREAT
31
+
32
+ cmdlist = cmdstart.split()
33
+ H.printv(f'popen: {cmdlist}')
34
+ try:
35
+ process = subprocess.Popen(cmdlist, #close_fds=True,# env=my_env,
36
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
37
+ except Exception as e:
38
+ H.printe(f'Exception starting {manName}: {e}')
39
+ return os.EX_IOERR
40
+
41
+ time.sleep(5)# 3 is small for PLC device
42
+ H.printv('slept 5 seconds')
43
+ ex = manAction(manName, 'Check')
44
+ if ex != os.EX_OK:
45
+ return os.EX_UNAVAILABLE
46
+
47
+ elif cmd == 'Stop':
48
+ H.printv(f'stopping {manName}')
49
+ process = Startup[manName].get('process', f'{cmdstart}')
50
+ cmd = f'pkill -f "{process}"'
51
+ H.printv(f'executing: {cmd}')
52
+ os.system(cmd)
53
+ time.sleep(0.1)
54
+ ex = manAction(manName, 'Check')
55
+ if ex != os.EX_UNAVAILABLE:
56
+ return os.EX_SOFTWARE
57
+
58
+ elif cmd == 'Command':
59
+ try:
60
+ cd = Startup[manName]['cd']
61
+ cmd = f'cd {cd}; {cmdstart}'
62
+ except Exception as e:
63
+ cmd = cmdstart
64
+ print(f'Start command for "{manName}": "{cmd}"')
65
+ return os.EX_OK
66
+
67
+ if __name__ == '__main__':
68
+ parser = argparse.ArgumentParser(description=__doc__,
69
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
70
+ epilog=f'Version {__version__}')
71
+ parser.add_argument('-a','--apparatus', choices=Apparatus, default='TST', help='Which apparatus to control')
72
+ parser.add_argument('-c', '--configDir', default=H.ConfigDir, help=\
73
+ 'Directory, containing apparatus configuration scripts')
74
+ parser.add_argument('-m', '--manager', default='all',
75
+ help='Apply command to a particular manager (or all) of the apparatus')
76
+ parser.add_argument('-t', '--test', action='store_true',
77
+ help='Include non-operational (test) managers')
78
+ parser.add_argument('-v', '--verbose', action='count', default=0, help=\
79
+ 'Show more log messages (-vv: show even more).')
80
+ parser.add_argument('command', nargs='?', choices=ManCmds, default='Check')
81
+ pargs = parser.parse_args()
82
+ H.printv(f'pargs: {pargs}')
83
+
84
+ # import the manager
85
+ mname = 'manman.apparatus_'+pargs.apparatus
86
+ module = import_module(mname)
87
+ print(f'imported {mname} {module.__version__}')
88
+ Startup = module.startup
89
+ mname = 'manman.apparatus_'+pargs.apparatus
90
+ module = import_module(mname)
91
+
92
+ if pargs.manager == 'all':
93
+ pargs.manager = list(Startup.keys())
94
+ else:
95
+ pargs.manager = [pargs.manager]
96
+ H.printv(f'Managers: {pargs.manager}')
97
+
98
+ for manName in pargs.manager:
99
+ if manName not in Startup:
100
+ H.printe(f'Wrong manager, supported are: {",".join(Startup.keys())}')
101
+ sys.exit(os.EX_USAGE)
102
+ if not pargs.test:
103
+ if manName.startswith('tst_'):
104
+ continue
105
+ manAction(manName, pargs.command)
106
+
@@ -0,0 +1,416 @@
1
+ """Support for detachable tabs.
2
+ # https://stackoverflow.com/a/50693795/3620725
3
+ """
4
+ from PyQt5 import QtGui, QtCore, QtWidgets
5
+ from PyQt5.QtCore import pyqtSignal, pyqtSlot
6
+
7
+ class DetachableTabWidget(QtWidgets.QTabWidget):
8
+ def __init__(self, parent=None):
9
+ QtWidgets.QTabWidget.__init__(self, parent)
10
+
11
+ self.tabBar = self.TabBar(self)
12
+ self.tabBar.onDetachTabSignal.connect(self.detachTab)
13
+ self.tabBar.onMoveTabSignal.connect(self.moveTab)
14
+ self.tabBar.detachedTabDropSignal.connect(self.detachedTabDrop)
15
+
16
+ self.setTabBar(self.tabBar)
17
+
18
+ # Used to keep a reference to detached tabs since their QMainWindow
19
+ # does not have a parent
20
+ self.detachedTabs = {}
21
+
22
+ # Close all detached tabs if the application is closed explicitly
23
+ QtWidgets.qApp.aboutToQuit.connect(self.closeDetachedTabs) # @UndefinedVariable
24
+
25
+ ##
26
+ # The default movable functionality of QTabWidget must remain disabled
27
+ # so as not to conflict with the added features
28
+ def setMovable(self, movable):
29
+ pass
30
+
31
+ ##
32
+ # Move a tab from one position (index) to another
33
+ #
34
+ # @param fromIndex the original index location of the tab
35
+ # @param toIndex the new index location of the tab
36
+ @pyqtSlot(int, int)
37
+ def moveTab(self, fromIndex, toIndex):
38
+ widget = self.widget(fromIndex)
39
+ icon = self.tabIcon(fromIndex)
40
+ text = self.tabText(fromIndex)
41
+
42
+ self.removeTab(fromIndex)
43
+ self.insertTab(toIndex, widget, icon, text)
44
+ self.setCurrentIndex(toIndex)
45
+
46
+ ##
47
+ # Detach the tab by removing it's contents and placing them in
48
+ # a DetachedTab window
49
+ #
50
+ # @param index the index location of the tab to be detached
51
+ # @param point the screen position for creating the new DetachedTab window
52
+ @pyqtSlot(int, QtCore.QPoint)
53
+ def detachTab(self, index, point):
54
+
55
+ # Get the tab content
56
+ name = self.tabText(index)
57
+ icon = self.tabIcon(index)
58
+ if icon.isNull():
59
+ icon = self.window().windowIcon()
60
+ contentWidget = self.widget(index)
61
+
62
+ try:
63
+ contentWidgetRect = contentWidget.frameGeometry()
64
+ except AttributeError:
65
+ return
66
+
67
+ # Create a new detached tab window
68
+ detachedTab = self.DetachedTab(name, contentWidget)
69
+ detachedTab.setWindowModality(QtCore.Qt.NonModal)
70
+ detachedTab.setWindowIcon(icon)
71
+ detachedTab.setGeometry(contentWidgetRect)
72
+ detachedTab.onCloseSignal.connect(self.attachTab)
73
+ detachedTab.onDropSignal.connect(self.tabBar.detachedTabDrop)
74
+ detachedTab.move(point)
75
+ detachedTab.show()
76
+
77
+ # Create a reference to maintain access to the detached tab
78
+ self.detachedTabs[name] = detachedTab
79
+
80
+ ##
81
+ # Re-attach the tab by removing the content from the DetachedTab window,
82
+ # closing it, and placing the content back into the DetachableTabWidget
83
+ #
84
+ # @param contentWidget the content widget from the DetachedTab window
85
+ # @param name the name of the detached tab
86
+ # @param icon the window icon for the detached tab
87
+ # @param insertAt insert the re-attached tab at the given index
88
+ def attachTab(self, contentWidget, name, icon, insertAt=None):
89
+
90
+ # Make the content widget a child of this widget
91
+ contentWidget.setParent(self)
92
+
93
+ # Remove the reference
94
+ del self.detachedTabs[name]
95
+
96
+ # Create an image from the given icon (for comparison)
97
+ if not icon.isNull():
98
+ try:
99
+ tabIconPixmap = icon.pixmap(icon.availableSizes()[0])
100
+ tabIconImage = tabIconPixmap.toImage()
101
+ except IndexError:
102
+ tabIconImage = None
103
+ else:
104
+ tabIconImage = None
105
+
106
+ # Create an image of the main window icon (for comparison)
107
+ if not icon.isNull():
108
+ try:
109
+ windowIconPixmap = self.window().windowIcon().pixmap(icon.availableSizes()[0])
110
+ windowIconImage = windowIconPixmap.toImage()
111
+ except IndexError:
112
+ windowIconImage = None
113
+ else:
114
+ windowIconImage = None
115
+
116
+ # Determine if the given image and the main window icon are the same.
117
+ # If they are, then do not add the icon to the tab
118
+ if tabIconImage == windowIconImage:
119
+ if insertAt == None:
120
+ index = self.addTab(contentWidget, name)
121
+ else:
122
+ index = self.insertTab(insertAt, contentWidget, name)
123
+ else:
124
+ if insertAt == None:
125
+ index = self.addTab(contentWidget, icon, name)
126
+ else:
127
+ index = self.insertTab(insertAt, contentWidget, icon, name)
128
+
129
+ # Make this tab the current tab
130
+ if index > -1:
131
+ self.setCurrentIndex(index)
132
+
133
+ ##
134
+ # Remove the tab with the given name, even if it is detached
135
+ #
136
+ # @param name the name of the tab to be removed
137
+ def removeTabByName(self, name):
138
+
139
+ # Remove the tab if it is attached
140
+ attached = False
141
+ for index in range(self.count()):
142
+ if str(name) == str(self.tabText(index)):
143
+ self.removeTab(index)
144
+ attached = True
145
+ break
146
+
147
+ # If the tab is not attached, close it's window and
148
+ # remove the reference to it
149
+ if not attached:
150
+ for key in self.detachedTabs:
151
+ if str(name) == str(key):
152
+ self.detachedTabs[key].onCloseSignal.disconnect()
153
+ self.detachedTabs[key].close()
154
+ del self.detachedTabs[key]
155
+ break
156
+
157
+ ##
158
+ # Handle dropping of a detached tab inside the DetachableTabWidget
159
+ #
160
+ # @param name the name of the detached tab
161
+ # @param index the index of an existing tab (if the tab bar
162
+ # determined that the drop occurred on an
163
+ # existing tab)
164
+ # @param dropPos the mouse cursor position when the drop occurred
165
+ @QtCore.pyqtSlot(str, int, QtCore.QPoint)
166
+ def detachedTabDrop(self, name, index, dropPos):
167
+
168
+ # If the drop occurred on an existing tab, insert the detached
169
+ # tab at the existing tab's location
170
+ if index > -1:
171
+
172
+ # Create references to the detached tab's content and icon
173
+ contentWidget = self.detachedTabs[name].contentWidget
174
+ icon = self.detachedTabs[name].windowIcon()
175
+
176
+ # Disconnect the detached tab's onCloseSignal so that it
177
+ # does not try to re-attach automatically
178
+ self.detachedTabs[name].onCloseSignal.disconnect()
179
+
180
+ # Close the detached
181
+ self.detachedTabs[name].close()
182
+
183
+ # Re-attach the tab at the given index
184
+ self.attachTab(contentWidget, name, icon, index)
185
+
186
+
187
+ # If the drop did not occur on an existing tab, determine if the drop
188
+ # occurred in the tab bar area (the area to the side of the QTabBar)
189
+ else:
190
+
191
+ # Find the drop position relative to the DetachableTabWidget
192
+ tabDropPos = self.mapFromGlobal(dropPos)
193
+
194
+ # If the drop position is inside the DetachableTabWidget...
195
+ if tabDropPos in self.rect():
196
+
197
+ # If the drop position is inside the tab bar area (the
198
+ # area to the side of the QTabBar) or there are not tabs
199
+ # currently attached...
200
+ if tabDropPos.y() < self.tabBar.height() or self.count() == 0:
201
+ # Close the detached tab and allow it to re-attach
202
+ # automatically
203
+ self.detachedTabs[name].close()
204
+
205
+ ##
206
+ # Close all tabs that are currently detached.
207
+ def closeDetachedTabs(self):
208
+ listOfDetachedTabs = []
209
+
210
+ for key in self.detachedTabs:
211
+ listOfDetachedTabs.append(self.detachedTabs[key])
212
+
213
+ for detachedTab in listOfDetachedTabs:
214
+ detachedTab.close()
215
+
216
+ ##
217
+ # When a tab is detached, the contents are placed into this QMainWindow. The tab
218
+ # can be re-attached by closing the dialog or by dragging the window into the tab bar
219
+ class DetachedTab(QtWidgets.QMainWindow):
220
+ onCloseSignal = pyqtSignal(QtWidgets.QWidget, str, QtGui.QIcon)
221
+ onDropSignal = pyqtSignal(str, QtCore.QPoint)
222
+
223
+ def __init__(self, name, contentWidget):
224
+ QtWidgets.QMainWindow.__init__(self, None)
225
+
226
+ self.setObjectName(name)
227
+ self.setWindowTitle('manman '+name)
228
+
229
+ self.contentWidget = contentWidget
230
+ self.setCentralWidget(self.contentWidget)
231
+ self.contentWidget.show()
232
+
233
+ self.windowDropFilter = self.WindowDropFilter()
234
+ self.installEventFilter(self.windowDropFilter)
235
+ self.windowDropFilter.onDropSignal.connect(self.windowDropSlot)
236
+
237
+ ##
238
+ # Handle a window drop event
239
+ #
240
+ # @param dropPos the mouse cursor position of the drop
241
+ @QtCore.pyqtSlot(QtCore.QPoint)
242
+ def windowDropSlot(self, dropPos):
243
+ self.onDropSignal.emit(self.objectName(), dropPos)
244
+
245
+ ##
246
+ # If the window is closed, emit the onCloseSignal and give the
247
+ # content widget back to the DetachableTabWidget
248
+ #
249
+ # @param event a close event
250
+ def closeEvent(self, event):
251
+ self.onCloseSignal.emit(self.contentWidget, self.objectName(), self.windowIcon())
252
+
253
+ ##
254
+ # An event filter class to detect a QMainWindow drop event
255
+ class WindowDropFilter(QtCore.QObject):
256
+ onDropSignal = pyqtSignal(QtCore.QPoint)
257
+
258
+ def __init__(self):
259
+ QtCore.QObject.__init__(self)
260
+ self.lastEvent = None
261
+
262
+ ##
263
+ # Detect a QMainWindow drop event by looking for a NonClientAreaMouseMove (173)
264
+ # event that immediately follows a Move event
265
+ #
266
+ # @param obj the object that generated the event
267
+ # @param event the current event
268
+ def eventFilter(self, obj, event):
269
+
270
+ # If a NonClientAreaMouseMove (173) event immediately follows a Move event...
271
+ if self.lastEvent == QtCore.QEvent.Move and event.type() == 173:
272
+
273
+ # Determine the position of the mouse cursor and emit it with the
274
+ # onDropSignal
275
+ mouseCursor = QtGui.QCursor()
276
+ dropPos = mouseCursor.pos()
277
+ self.onDropSignal.emit(dropPos)
278
+ self.lastEvent = event.type()
279
+ return True
280
+
281
+ else:
282
+ self.lastEvent = event.type()
283
+ return False
284
+
285
+ ##
286
+ # The TabBar class re-implements some of the functionality of the QTabBar widget
287
+ class TabBar(QtWidgets.QTabBar):
288
+ onDetachTabSignal = pyqtSignal(int, QtCore.QPoint)
289
+ onMoveTabSignal = pyqtSignal(int, int)
290
+ detachedTabDropSignal = pyqtSignal(str, int, QtCore.QPoint)
291
+
292
+ def __init__(self, parent=None):
293
+ QtWidgets.QTabBar.__init__(self, parent)
294
+
295
+ self.setAcceptDrops(True)
296
+ self.setElideMode(QtCore.Qt.ElideRight)
297
+ self.setSelectionBehaviorOnRemove(QtWidgets.QTabBar.SelectLeftTab)
298
+
299
+ self.dragStartPos = QtCore.QPoint()
300
+ self.dragDropedPos = QtCore.QPoint()
301
+ self.mouseCursor = QtGui.QCursor()
302
+ self.dragInitiated = False
303
+
304
+ ##
305
+ # Send the onDetachTabSignal when a tab is double clicked
306
+ #
307
+ # @param event a mouse double click event
308
+ def mouseDoubleClickEvent(self, event):
309
+ event.accept()
310
+ self.onDetachTabSignal.emit(self.tabAt(event.pos()), self.mouseCursor.pos())
311
+
312
+ ##
313
+ # Set the starting position for a drag event when the mouse button is pressed
314
+ #
315
+ # @param event a mouse press event
316
+ def mousePressEvent(self, event):
317
+ if event.button() == QtCore.Qt.LeftButton:
318
+ self.dragStartPos = event.pos()
319
+
320
+ self.dragDropedPos.setX(0)
321
+ self.dragDropedPos.setY(0)
322
+
323
+ self.dragInitiated = False
324
+
325
+ QtWidgets.QTabBar.mousePressEvent(self, event)
326
+
327
+ ##
328
+ # Determine if the current movement is a drag. If it is, convert it into a QDrag. If the
329
+ # drag ends inside the tab bar, emit an onMoveTabSignal. If the drag ends outside the tab
330
+ # bar, emit an onDetachTabSignal.
331
+ #
332
+ # @param event a mouse move event
333
+ def mouseMoveEvent(self, event):
334
+
335
+ # Determine if the current movement is detected as a drag
336
+ if not self.dragStartPos.isNull() and (
337
+ (event.pos() - self.dragStartPos).manhattanLength() < QtWidgets.QApplication.startDragDistance()):
338
+ self.dragInitiated = True
339
+
340
+ # If the current movement is a drag initiated by the left button
341
+ if (((event.buttons() & QtCore.Qt.LeftButton)) and self.dragInitiated):
342
+
343
+ # Stop the move event
344
+ finishMoveEvent = QtGui.QMouseEvent(QtCore.QEvent.MouseMove, event.pos(), QtCore.Qt.NoButton,
345
+ QtCore.Qt.NoButton, QtCore.Qt.NoModifier)
346
+ QtWidgets.QTabBar.mouseMoveEvent(self, finishMoveEvent)
347
+
348
+ # Convert the move event into a drag
349
+ drag = QtGui.QDrag(self)
350
+ mimeData = QtCore.QMimeData()
351
+ mimeData.setData('action', b'application/tab-detach')
352
+ drag.setMimeData(mimeData)
353
+
354
+ # Create the appearance of dragging the tab content
355
+ pixmap = self.parentWidget().currentWidget().grab()
356
+ targetPixmap = QtGui.QPixmap(pixmap.size())
357
+ targetPixmap.fill(QtCore.Qt.transparent)
358
+ painter = QtGui.QPainter(targetPixmap)
359
+ painter.setOpacity(0.85)
360
+ painter.drawPixmap(0, 0, pixmap)
361
+ painter.end()
362
+ drag.setPixmap(targetPixmap)
363
+
364
+ # Initiate the drag
365
+ dropAction = drag.exec_(QtCore.Qt.MoveAction | QtCore.Qt.CopyAction)
366
+
367
+ # For Linux: Here, drag.exec_() will not return MoveAction on Linux. So it
368
+ # must be set manually
369
+ if self.dragDropedPos.x() != 0 and self.dragDropedPos.y() != 0:
370
+ dropAction = QtCore.Qt.MoveAction
371
+
372
+ # If the drag completed outside of the tab bar, detach the tab and move
373
+ # the content to the current cursor position
374
+ if dropAction == QtCore.Qt.IgnoreAction:
375
+ event.accept()
376
+ self.onDetachTabSignal.emit(self.tabAt(self.dragStartPos), self.mouseCursor.pos())
377
+
378
+ # Else if the drag completed inside the tab bar, move the selected tab to the new position
379
+ elif dropAction == QtCore.Qt.MoveAction:
380
+ if not self.dragDropedPos.isNull():
381
+ event.accept()
382
+ self.onMoveTabSignal.emit(self.tabAt(self.dragStartPos), self.tabAt(self.dragDropedPos))
383
+ else:
384
+ QtWidgets.QTabBar.mouseMoveEvent(self, event)
385
+
386
+ ##
387
+ # Determine if the drag has entered a tab position from another tab position
388
+ #
389
+ # @param event a drag enter event
390
+ def dragEnterEvent(self, event):
391
+ mimeData = event.mimeData()
392
+ formats = mimeData.formats()
393
+
394
+ if 'action' in formats and mimeData.data('action') == 'application/tab-detach':
395
+ event.acceptProposedAction()
396
+
397
+ QtWidgets.QTabBar.dragMoveEvent(self, event)
398
+
399
+ ##
400
+ # Get the position of the end of the drag
401
+ #
402
+ # @param event a drop event
403
+ def dropEvent(self, event):
404
+ self.dragDropedPos = event.pos()
405
+ QtWidgets.QTabBar.dropEvent(self, event)
406
+
407
+ ##
408
+ # Determine if the detached tab drop event occurred on an existing tab,
409
+ # then send the event to the DetachableTabWidget
410
+ def detachedTabDrop(self, name, dropPos):
411
+
412
+ tabDropPos = self.mapFromGlobal(dropPos)
413
+
414
+ index = self.tabAt(tabDropPos)
415
+
416
+ self.detachedTabDropSignal.emit(name, index, dropPos)
@@ -0,0 +1,18 @@
1
+ """Helpers for manman"""
2
+ import sys, os, time, glob
3
+ import subprocess
4
+
5
+ ConfigDir = '/operations/app_store/manman'
6
+
7
+ Verbose = 0
8
+
9
+ def printTime(): return time.strftime("%m%d:%H%M%S")
10
+ def printi(msg): print(f'inf_@{printTime()}: {msg}')
11
+ def printw(msg): print(f'WAR_@{printTime()}: {msg}')
12
+ def printe(msg): print(f'ERR_{printTime()}: {msg}')
13
+ def _printv(msg, level):
14
+ if Verbose >= level:
15
+ print(f'dbg{level}: {msg}')
16
+ def printv(msg): _printv(msg, 1)
17
+ def printvv(msg): _printv(msg, 2)
18
+
@@ -0,0 +1,342 @@
1
+ """Tabbed GUI for starting/stopping/monitoring programs.
2
+ """
3
+ __version__ = 'v2.0.0 2025-09-07'# Adopted from manman
4
+ #TODO: xdg_open does not launch if other editors not running.
5
+
6
+ import sys, os, time, subprocess, argparse, threading, glob
7
+ from functools import partial
8
+ from importlib import import_module
9
+
10
+ from qtpy import QtWidgets as QW, QtGui, QtCore
11
+
12
+ from . import helpers as H
13
+ from . import detachable_tabs
14
+
15
+ #``````````````````Constants``````````````````````````````````````````````````
16
+ ManCmds = ['Check', 'Start', 'Stop', 'Command']
17
+ AllManCmds = ['Check All','Start All','Stop All', 'Edit', 'Delete',
18
+ 'Condense', 'Uncondense']#, 'Exit All']
19
+ Col = {'Applications':0, 'status':1, 'response':2}
20
+ BoldFont = QtGui.QFont("Helvetica", 14, QtGui.QFont.Bold)
21
+ FilePrefix = 'proc'
22
+ MinimalRowHeight = 20
23
+ #``````````````````Helpers````````````````````````````````````````````````````
24
+ def select_files_interactively(directory, title=f'Select {FilePrefix}*.py files'):
25
+ dialog = QW.QFileDialog()
26
+ dialog.setFileMode( QW.QFileDialog.FileMode() )
27
+ ffilter = f'procman ({FilePrefix}*.py)'
28
+ files = dialog.getOpenFileNames( None, title, directory, ffilter)[0]
29
+ return files
30
+
31
+ def create_folderMap():
32
+ # create map of {folder1: [file1,...], folder2...} from pargs.files
33
+ #print(f'c,a: {Window.pargs.configDir, Window.pargs.files}')
34
+ folders = {}
35
+ if Window.pargs.configDir is None:
36
+ files = [os.path.abspath(i) for i in Window.pargs.filess]
37
+ else:
38
+ absfolder = os.path.abspath(Window.pargs.configDir)
39
+ if Window.pargs.interactive:
40
+ if len(Window.pargs.files) == 0:
41
+ files = select_files_interactively(absfolder)
42
+ else:
43
+ files = [absfolder+'/'+i for i in Window.pargs.files]
44
+ else:
45
+ s = f'{absfolder}/*)'
46
+ l = glob.glob('proc*.py', root_dir=absfolder)
47
+ files = [absfolder+'/'+i for i in l]
48
+ for f in files:
49
+ folder,tail = os.path.split(f)
50
+ if not (tail.startswith(FilePrefix) and tail.endswith('.py')):
51
+ H.printe(f'Config file should have prefix {FilePrefix} and suffix ".py"')
52
+ sys.exit(1)
53
+ if folder not in folders:
54
+ folders[folder] = []
55
+ folders[folder].append(tail)
56
+
57
+ # sort the file lists
58
+ for folder in folders:
59
+ folders[folder].sort()
60
+ return folders
61
+
62
+ def launch_default_editor(configFile):
63
+ cmd = f'xdg-open {configFile}'
64
+ H.printi(f'Launching editor: {cmd}')
65
+ subprocess.call(cmd.split())
66
+
67
+ def is_process_running(cmdstart):
68
+ try:
69
+ subprocess.check_output(["pgrep", '-f', cmdstart])
70
+ return True
71
+ except subprocess.CalledProcessError:
72
+ return False
73
+
74
+ def setButtonStyleSheet(parent):
75
+ parent.setStyleSheet("QPushButton{"
76
+ #"background-color: lightBlue;"
77
+ "background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1,"
78
+ "stop: 0 white, stop: 1 lightBlue);"
79
+ #'border: 2px solid blue;'
80
+ "border-style: solid;"
81
+ "border-color: Grey;"
82
+ "border-width: 2px;"
83
+ "border-radius: 10px;}"
84
+ #"font-weight: bold;"# no effect
85
+ 'QPushButton::pressed{background-color:pink;}'
86
+ )#{+ButtonStyleSheet)
87
+
88
+ class myPushButton(QW.QPushButton):
89
+ """Custom pushbutton"""
90
+ def __init__(self, text, manName='?', buttons=[]):
91
+ super().__init__()
92
+ self.setText(text)
93
+ self.buttons = buttons
94
+ self.manName = manName
95
+ self.clicked.connect(self.buttonClicked)
96
+
97
+ def buttonClicked(self):
98
+ buttonText = self.text()
99
+ #print(f'Clicked {buttonText, self.manName, self.buttons}')
100
+ if len(self.buttons) != 0:
101
+ dlg = myDialog(self, self.manName, self.buttons)
102
+ r = dlg.exec()
103
+ return
104
+ if self.manName == '':
105
+ return
106
+ #print(f'Executing manAction{self.manName, buttonText}')
107
+ if self.manName == 'All':
108
+ current_mytable().tableWideAction(buttonText)
109
+ else:
110
+ current_mytable().manAction(self.manName, buttonText)
111
+
112
+ class myDialog(QW.QDialog):
113
+ def __init__(self, parent, title, buttons):
114
+ super().__init__(parent)
115
+
116
+ self.setWindowTitle(title)
117
+ layout = QW.QVBoxLayout(self)
118
+ for btnTxt in buttons:
119
+ btn = myPushButton(btnTxt, title)
120
+ btn.clicked.connect(self.accept)
121
+ #self.buttonBox.addButton(btn)
122
+ layout.addWidget(btn)
123
+ self.setLayout(layout)
124
+
125
+ #``````````````````Table Widget```````````````````````````````````````````````
126
+ def current_mytable():
127
+ return Window.tabWidget.currentWidget()
128
+ class MyTable(QW.QTableWidget):
129
+ def __init__(self, folder, fname, tabName):
130
+ super().__init__()
131
+ mname = fname[:-3]
132
+ H.printv(f'importing {mname}')
133
+ try:
134
+ module = import_module(mname)
135
+ except SyntaxError as e:
136
+ H.printe(f'Syntax Error in {fname}: {e}')
137
+ sys.exit(1)
138
+ H.printv(f'imported {mname} {module.__version__}')
139
+ self.startup = module.startup
140
+ self.configFile = folder+'/'+fname
141
+ self.setColumnCount(len(Col))
142
+ self.setHorizontalHeaderLabels(Col.keys())
143
+ self.verticalHeader().setMinimumSectionSize(MinimalRowHeight)
144
+ self.manRow = {}
145
+ setButtonStyleSheet(self)
146
+
147
+ try: title = module.title
148
+ except: title = 'Applications'
149
+
150
+ # Wide button for for tab-wide commands
151
+ #wideRow(self, 0, title, AllManCmds)
152
+ rowPosition=0
153
+ self._insertRow(rowPosition)
154
+ self.setSpan(rowPosition,0,1,2)
155
+ item = myPushButton(title, 'All', AllManCmds)
156
+ item.setToolTip('Commands for all programs in this page')
157
+ self.setCellWidget(rowPosition, Col['Applications'], item)
158
+
159
+ # Set up all rows
160
+ for manName,props in self.startup.items():
161
+ rowPosition = self.rowCount()
162
+ self._insertRow(rowPosition)
163
+ self.manRow[manName] = rowPosition
164
+
165
+ item = myPushButton(manName, manName, buttons=ManCmds)
166
+ try: item.setToolTip(props['help'])
167
+ except: pass
168
+ self.setCellWidget(rowPosition, Col['Applications'], item)
169
+
170
+ self.setItem(rowPosition, Col['status'],
171
+ QW.QTableWidgetItem('?'))
172
+ self.setItem(rowPosition, Col['response'],
173
+ QW.QTableWidgetItem(''))
174
+
175
+ # Set up headers
176
+ header = self.horizontalHeader()
177
+ header.setStretchLastSection(True)
178
+ if Window.pargs.condensed:
179
+ self.set_headersVisibility(False)
180
+
181
+ def _insertRow(self, rowPosition):
182
+ self.insertRow(rowPosition)
183
+ self.setRowHeight(rowPosition, 1)
184
+
185
+ def manAction(self, manName:str, cmd:str):
186
+ # Execute action
187
+ #print(f'manAction: {manName, cmd}')
188
+ rowPosition = self.manRow[manName]
189
+ startup = self.startup
190
+ cmdstart = startup[manName]['cmd']
191
+ process = startup[manName].get('process', f'{cmdstart}')
192
+ #print(f"pos: {rowPosition},{Col['response']}")
193
+
194
+ if cmd == 'Check':
195
+ H.printvv(f'checking process {process} ')
196
+ status = ['not running','is started'][is_process_running(process)]
197
+ item = self.item(rowPosition,Col['status'])
198
+ color = 'lightGreen' if 'started' in status else 'pink'
199
+ item.setBackground(QtGui.QColor(color))
200
+ item.setText(status)
201
+
202
+ elif cmd == 'Start':
203
+ self.item(rowPosition, Col['response']).setText('')
204
+ if is_process_running(process):
205
+ txt = f'Is already running manager {manName}'
206
+ #print(txt)
207
+ self.item(rowPosition, Col['response']).setText(txt)
208
+ return
209
+ H.printv(f'starting {manName}')
210
+ item = self.item(rowPosition, Col['status'])
211
+ item.setText('starting...')
212
+ path = startup[manName].get('cd')
213
+ H.printi('Executing commands:')
214
+ if path:
215
+ path = path.strip()
216
+ expandedPath = os.path.expanduser(path)
217
+ try:
218
+ os.chdir(expandedPath)
219
+ except Exception as e:
220
+ txt = f'ERR: in chdir: {e}'
221
+ self.item(rowPosition, Col['response']).setText(txt)
222
+ return
223
+ print(f'cd {os.getcwd()}')
224
+ print(cmdstart)
225
+ expandedCmd = os.path.expanduser(cmdstart)
226
+ cmdlist = expandedCmd.split()
227
+ shell = startup[manName].get('shell',False)
228
+ H.printv(f'popen: {cmdlist}, shell:{shell}')
229
+ try:
230
+ proc = subprocess.Popen(cmdlist, shell=shell, #close_fds=True,# env=my_env,
231
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
232
+ except Exception as e:
233
+ H.printv(f'Exception: {e}')
234
+ self.item(rowPosition, Col['response']).setText(str(e))
235
+ return
236
+ Window.timer.singleShot(int(Window.pargs.interval*1000),
237
+ partial(self.deferredCheck,(manName,rowPosition)))
238
+
239
+ elif cmd == 'Stop':
240
+ self.item(rowPosition, Col['response']).setText('')
241
+ H.printv(f'stopping {manName}')
242
+ cmd = f'pkill -f "{process}"'
243
+ H.printi(f'Executing:\n{cmd}')
244
+ os.system(cmd)
245
+ time.sleep(0.1)
246
+ self.manAction(manName, ManCmds.index('Check'))
247
+
248
+ elif cmd == 'Command':
249
+ try:
250
+ cd = startup[manName]['cd']
251
+ cmd = f'cd {cd}; {cmdstart}'
252
+ except Exception as e:
253
+ cmd = cmdstart
254
+ #print(f'Command in row {rowPosition}:\n{cmd}')
255
+ self.item(rowPosition, Col['response']).setText(cmd)
256
+ return
257
+
258
+ def set_headersVisibility(self, visible:bool):
259
+ #print(f'set_headersVisibility {visible}')
260
+ self.horizontalHeader().setVisible(visible)
261
+ self.verticalHeader().setVisible(visible)
262
+
263
+ def tableWideAction(self, cmd:str):
264
+ # Execute table-wide action
265
+ #print(f'tableWideAction: {cmd}')
266
+ if cmd == 'Edit':
267
+ launch_default_editor(self.configFile)
268
+ elif cmd == 'Delete':
269
+ idx = Window.tabWidget.currentIndex()
270
+ tabtext = Window.tabWidget.tabText(idx)
271
+ H.printi(f'Deleting tab {idx,tabtext}')
272
+ del Window.tableWidgets[tabtext]
273
+ Window.tabWidget.removeTab(idx)
274
+ self.deleteLater()# it is important to properly delete the associated widget
275
+ elif cmd == 'Condense':
276
+ self.set_headersVisibility(False)
277
+ elif cmd == 'Uncondense':
278
+ self.set_headersVisibility(True)
279
+ elif cmd == 'Exit All':
280
+ self.exit_all()
281
+ else:# Delegate command to managers
282
+ for manName in self.startup:
283
+ cmd = cmd.split()[0]# use first word of the command
284
+ #print(f'man {manName,cmd}')
285
+ if manName.startswith('tst') and cmd != 'Check':
286
+ continue
287
+ self.manAction(manName, cmd)
288
+
289
+ def deferredCheck(self, args):
290
+ #print(f'deferred: {args}')
291
+ manName,rowPosition = args
292
+ self.manAction(manName, ManCmds.index('Check'))
293
+ if 'start' not in self.item(rowPosition, Col['status']).text():
294
+ self.item(rowPosition, Col['response']).setText('Failed to start')
295
+ #``````````````````Main Window````````````````````````````````````````````````
296
+ class Window(QW.QMainWindow):# it may sense to subclass it from QW.QMainWindow
297
+ pargs = None
298
+ tableWidgets = {}
299
+ timer = QtCore.QTimer()
300
+
301
+ def __init__(self):
302
+ super().__init__()
303
+ H.Verbose = Window.pargs.verbose
304
+ folders = create_folderMap()
305
+ if len(folders) == 0:
306
+ sys.exit(1)
307
+ H.printi(f'Configuration files: {folders}')
308
+ self.setWindowTitle('procman')
309
+
310
+ # Create tabWidget
311
+ Window.tabWidget = detachable_tabs.DetachableTabWidget()
312
+ Window.tabWidget.currentChanged.connect(periodicCheck)
313
+ self.setCentralWidget(Window.tabWidget)
314
+ H.printv(f'tabWidget created')
315
+
316
+ # Add tables, configured from files, to tabs
317
+ for folder,files in folders.items():
318
+ sys.path.append(folder)
319
+ for fname in files:
320
+ tabName = fname[len(FilePrefix):-3]
321
+ mytable = MyTable(folder, fname, tabName)
322
+ Window.tableWidgets[tabName] = mytable
323
+ #print(f'Adding tab: {fname}')
324
+ Window.tabWidget.addTab(mytable, tabName)
325
+
326
+ # Update tables and set up periodic check
327
+ periodicCheck()
328
+ if Window.pargs.interval != 0.:
329
+ Window.timer.timeout.connect(periodicCheck)
330
+ Window.timer.setInterval(int(Window.pargs.interval*1000.))
331
+ Window.timer.start()
332
+
333
+ def periodicCheck():
334
+ # execute tableWideAction on current tab
335
+ current_mytable().tableWideAction('Check')
336
+ # execute tableWideAction on all detached tabs
337
+ for tabName,mytable in Window.tableWidgets.items():
338
+ detached = tabName in Window.tabWidget.detachedTabs
339
+ #print(f'periodic for {tabName,detached}')
340
+ if detached:
341
+ mytable.tableWideAction('Check')
342
+
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "procman"
7
+ version = "2.0.0"
8
+ authors = [
9
+ { name="Andrei Sukhanov", email="cyxandr@gmail.com" },
10
+ ]
11
+
12
+ description = "Tabbed GUI to start/stop/monitor programs"
13
+ readme = "README.md"
14
+ requires-python = ">=3.7"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "qtpy",
22
+ ]
23
+
24
+ [project.urls]
25
+ "Homepage" = "https://github.com/ASukhanov/procman"