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 +21 -0
- procman-2.0.0/PKG-INFO +57 -0
- procman-2.0.0/README.md +43 -0
- procman-2.0.0/config/peakSimPlot_pp.py +40 -0
- procman-2.0.0/config/proc1_test.py +24 -0
- procman-2.0.0/config/proc2_test.py +24 -0
- procman-2.0.0/config/proc3_TST.py +72 -0
- procman-2.0.0/procman/__init__.py +3 -0
- procman-2.0.0/procman/__main__.py +55 -0
- procman-2.0.0/procman/cli.py +106 -0
- procman-2.0.0/procman/detachable_tabs.py +416 -0
- procman-2.0.0/procman/helpers.py +18 -0
- procman-2.0.0/procman/procman.py +342 -0
- procman-2.0.0/pyproject.toml +25 -0
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
|
+
<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
|
+

|
|
57
|
+
|
procman-2.0.0/README.md
ADDED
|
@@ -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
|
+
<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
|
+

|
|
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,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"
|