pyqt-code-editor 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyqt_code_editor/__init__.py +5 -0
- pyqt_code_editor/_settings.py +232 -0
- pyqt_code_editor/app.py +438 -0
- pyqt_code_editor/code_editors/__init__.py +1 -0
- pyqt_code_editor/code_editors/code_editors.py +33 -0
- pyqt_code_editor/code_editors/languages/__init__.py +0 -0
- pyqt_code_editor/code_editors/languages/generic.py +11 -0
- pyqt_code_editor/code_editors/languages/markdown.py +11 -0
- pyqt_code_editor/code_editors/languages/python.py +13 -0
- pyqt_code_editor/code_editors/languages/text.py +9 -0
- pyqt_code_editor/components/__init__.py +0 -0
- pyqt_code_editor/components/editor_panel.py +399 -0
- pyqt_code_editor/components/find_in_files.py +199 -0
- pyqt_code_editor/components/jupyter_console.py +487 -0
- pyqt_code_editor/components/project_explorer.py +515 -0
- pyqt_code_editor/components/settings_panel.py +162 -0
- pyqt_code_editor/components/sigmund.py +75 -0
- pyqt_code_editor/components/workspace_explorer.py +162 -0
- pyqt_code_editor/mixins/__init__.py +15 -0
- pyqt_code_editor/mixins/auto_pair.py +201 -0
- pyqt_code_editor/mixins/base.py +142 -0
- pyqt_code_editor/mixins/check.py +94 -0
- pyqt_code_editor/mixins/comment.py +91 -0
- pyqt_code_editor/mixins/complete.py +482 -0
- pyqt_code_editor/mixins/execute.py +182 -0
- pyqt_code_editor/mixins/file_link.py +166 -0
- pyqt_code_editor/mixins/line_number.py +206 -0
- pyqt_code_editor/mixins/python_auto_indent.py +239 -0
- pyqt_code_editor/mixins/search_replace.py +396 -0
- pyqt_code_editor/mixins/shortcuts.py +228 -0
- pyqt_code_editor/mixins/smart_backspace_delete.py +120 -0
- pyqt_code_editor/mixins/symbols.py +45 -0
- pyqt_code_editor/mixins/theme.py +99 -0
- pyqt_code_editor/mixins/widgets/__init__.py +0 -0
- pyqt_code_editor/mixins/zoom.py +51 -0
- pyqt_code_editor/python_utils.py +936 -0
- pyqt_code_editor/signal_router.py +116 -0
- pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
- pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
- pyqt_code_editor/syntax_highlighters/languages/generic.py +86 -0
- pyqt_code_editor/syntax_highlighters/languages/python.py +62 -0
- pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +26 -0
- pyqt_code_editor/themes.py +36 -0
- pyqt_code_editor/utils.py +96 -0
- pyqt_code_editor/watchdog.py +141 -0
- pyqt_code_editor/widgets/__init__.py +6 -0
- pyqt_code_editor/widgets/calltip_widget.py +44 -0
- pyqt_code_editor/widgets/completion_popup.py +89 -0
- pyqt_code_editor/widgets/dock.py +15 -0
- pyqt_code_editor/widgets/quick_open_dialog.py +196 -0
- pyqt_code_editor/widgets/quick_open_file_dialog.py +32 -0
- pyqt_code_editor/widgets/quick_symbol_dialog.py +13 -0
- pyqt_code_editor/widgets/search_replace_frame.py +128 -0
- pyqt_code_editor/widgets/tab_splitter.py +22 -0
- pyqt_code_editor/widgets/tabbed_editor.py +351 -0
- pyqt_code_editor/worker/__init__.py +0 -0
- pyqt_code_editor/worker/languages/__init__.py +0 -0
- pyqt_code_editor/worker/languages/generic.py +15 -0
- pyqt_code_editor/worker/languages/python.py +17 -0
- pyqt_code_editor/worker/manager.py +113 -0
- pyqt_code_editor/worker/process.py +128 -0
- pyqt_code_editor/worker/providers/__init__.py +1 -0
- pyqt_code_editor/worker/providers/codestral.py +56 -0
- pyqt_code_editor/worker/providers/jedi.py +115 -0
- pyqt_code_editor/worker/providers/ruff.py +45 -0
- pyqt_code_editor/worker/providers/sigmund.py +29 -0
- pyqt_code_editor/worker/providers/symbol.py +78 -0
- pyqt_code_editor/worker/settings.py +1 -0
- pyqt_code_editor-0.0.1.dist-info/METADATA +69 -0
- pyqt_code_editor-0.0.1.dist-info/RECORD +73 -0
- pyqt_code_editor-0.0.1.dist-info/WHEEL +4 -0
- pyqt_code_editor-0.0.1.dist-info/entry_points.txt +4 -0
- pyqt_code_editor-0.0.1.dist-info/licenses/COPYING +674 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from qtpy.QtCore import QObject, Signal
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SignalRouter(QObject):
|
|
10
|
+
"""
|
|
11
|
+
A central signal router that dynamically discovers and forwards signals.
|
|
12
|
+
"""
|
|
13
|
+
# Generic signal that forwards all signals with metadata
|
|
14
|
+
signal_triggered = Signal(str, object)
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
super().__init__()
|
|
18
|
+
self._registered_widgets = set()
|
|
19
|
+
self._signal_map = {} # Maps signal names to widgets
|
|
20
|
+
self._connections = {} # Maps widget IDs to connection info
|
|
21
|
+
logger.info("Signal router initialized")
|
|
22
|
+
|
|
23
|
+
def register_widget(self, widget):
|
|
24
|
+
"""Register a widget and all its signals for routing"""
|
|
25
|
+
if widget in self._registered_widgets:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
self._registered_widgets.add(widget)
|
|
29
|
+
widget_id = id(widget)
|
|
30
|
+
self._connections[widget_id] = []
|
|
31
|
+
|
|
32
|
+
# Connect to destroyed signal for auto cleanup
|
|
33
|
+
widget.destroyed.connect(lambda: self.unregister_widget(widget))
|
|
34
|
+
|
|
35
|
+
logger.info(f"Registering widget {widget.__class__.__name__} for signal routing")
|
|
36
|
+
|
|
37
|
+
# Discover all signals in the widget
|
|
38
|
+
for name, obj in inspect.getmembers(widget.__class__):
|
|
39
|
+
if isinstance(obj, Signal):
|
|
40
|
+
logger.info(f" Discovered signal: {name}")
|
|
41
|
+
self._signal_map[name] = self._signal_map.get(name, []) + [widget]
|
|
42
|
+
|
|
43
|
+
# Create a forwarder function that will be connected to the signal
|
|
44
|
+
def create_forwarder(signal_name=name):
|
|
45
|
+
forwarder = lambda *args: self.signal_triggered.emit(signal_name, args)
|
|
46
|
+
return forwarder
|
|
47
|
+
|
|
48
|
+
forwarder = create_forwarder()
|
|
49
|
+
|
|
50
|
+
# Connect the widget's signal to our forwarder
|
|
51
|
+
try:
|
|
52
|
+
signal = getattr(widget, name)
|
|
53
|
+
signal.connect(forwarder)
|
|
54
|
+
|
|
55
|
+
# Store connection information for cleanup
|
|
56
|
+
self._connections[widget_id].append({
|
|
57
|
+
'signal': signal,
|
|
58
|
+
'slot': forwarder,
|
|
59
|
+
'name': name
|
|
60
|
+
})
|
|
61
|
+
except (RuntimeError, TypeError) as e:
|
|
62
|
+
logger.warning(f"Failed to connect to signal {name}: {e}")
|
|
63
|
+
|
|
64
|
+
def unregister_widget(self, widget):
|
|
65
|
+
"""Unregister a widget and disconnect all its signals"""
|
|
66
|
+
widget_id = id(widget)
|
|
67
|
+
|
|
68
|
+
if widget_id in self._connections:
|
|
69
|
+
logger.info(f"Unregistering widget {widget.__class__.__name__}")
|
|
70
|
+
|
|
71
|
+
# Disconnect all signals from this widget
|
|
72
|
+
for connection in self._connections[widget_id]:
|
|
73
|
+
try:
|
|
74
|
+
connection['signal'].disconnect(connection['slot'])
|
|
75
|
+
logger.debug(f"Disconnected signal {connection['name']}")
|
|
76
|
+
except (RuntimeError, TypeError):
|
|
77
|
+
# Signal might already be disconnected or invalid
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# Clean up our data structures
|
|
81
|
+
del self._connections[widget_id]
|
|
82
|
+
|
|
83
|
+
# Update signal map to remove this widget
|
|
84
|
+
for signal_name, widgets in list(self._signal_map.items()):
|
|
85
|
+
self._signal_map[signal_name] = [w for w in widgets if id(w) != widget_id]
|
|
86
|
+
# Remove empty entries
|
|
87
|
+
if not self._signal_map[signal_name]:
|
|
88
|
+
del self._signal_map[signal_name]
|
|
89
|
+
|
|
90
|
+
# Try to remove from registered widgets
|
|
91
|
+
try:
|
|
92
|
+
self._registered_widgets.remove(widget)
|
|
93
|
+
except KeyError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def connect_to_signal(self, signal_name, slot):
|
|
97
|
+
"""
|
|
98
|
+
Connect a slot to a specific named signal from any registered widget
|
|
99
|
+
"""
|
|
100
|
+
def filtered_forwarder(name, args):
|
|
101
|
+
if name == signal_name:
|
|
102
|
+
# Use *args to unpack the arguments
|
|
103
|
+
if len(args) == 1:
|
|
104
|
+
slot(args[0])
|
|
105
|
+
else:
|
|
106
|
+
slot(*args)
|
|
107
|
+
|
|
108
|
+
self.signal_triggered.connect(filtered_forwarder)
|
|
109
|
+
logger.info(f"Connected external slot to '{signal_name}' signal")
|
|
110
|
+
|
|
111
|
+
def get_available_signals(self):
|
|
112
|
+
"""Return a list of all available signals from registered widgets"""
|
|
113
|
+
return list(self._signal_map.keys())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
signal_router = SignalRouter()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from pygments.styles import get_style_by_name
|
|
2
|
+
from pygments.formatters.html import HtmlFormatter
|
|
3
|
+
from qtpy.QtGui import QSyntaxHighlighter, QTextCharFormat, QBrush, QColor, QFont
|
|
4
|
+
import logging
|
|
5
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SyntaxHighlighter(QSyntaxHighlighter):
|
|
10
|
+
"""
|
|
11
|
+
A standalone syntax highlighter that uses Pygments to highlight code
|
|
12
|
+
in a QTextDocument. Merged from the original SyntaxHighlighter and
|
|
13
|
+
PygmentsSH, removing PyQode dependencies and extra logic.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, document, lexer, color_scheme=None):
|
|
17
|
+
super().__init__(document)
|
|
18
|
+
self._lexer = lexer
|
|
19
|
+
# If you have a custom ColorScheme, adapt here.
|
|
20
|
+
# If color_scheme is a string, interpret it as a Pygments style name:
|
|
21
|
+
self._color_scheme_name = color_scheme or "default"
|
|
22
|
+
|
|
23
|
+
# Prepare a style from Pygments
|
|
24
|
+
self._setup_style()
|
|
25
|
+
self._token_formats = {}
|
|
26
|
+
self._in_multiline_string = {}
|
|
27
|
+
|
|
28
|
+
def _setup_style(self):
|
|
29
|
+
"""Initialize the Pygments style objects."""
|
|
30
|
+
try:
|
|
31
|
+
self._style = get_style_by_name(self._color_scheme_name)
|
|
32
|
+
except Exception:
|
|
33
|
+
logger.error(f"[PygmentsSyntaxHighlighter] style '{self._color_scheme_name}' not found")
|
|
34
|
+
self._style = get_style_by_name("default")
|
|
35
|
+
self._formatter = HtmlFormatter(style=self._style)
|
|
36
|
+
|
|
37
|
+
def highlightBlock(self, text):
|
|
38
|
+
"""
|
|
39
|
+
Called automatically by QSyntaxHighlighter for each line in the document.
|
|
40
|
+
We tokenize the line with Pygments, then apply the relevant formats.
|
|
41
|
+
"""
|
|
42
|
+
tokens = list(self._lexer.get_tokens(text))
|
|
43
|
+
|
|
44
|
+
index = 0
|
|
45
|
+
for token_type, token_text in tokens:
|
|
46
|
+
length = len(token_text)
|
|
47
|
+
fmt = self._get_format(token_type)
|
|
48
|
+
self.setFormat(index, length, fmt)
|
|
49
|
+
index += length
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_format(self, token_type):
|
|
53
|
+
"""
|
|
54
|
+
Retrieve (or create) a QTextCharFormat for the given Pygments token type.
|
|
55
|
+
"""
|
|
56
|
+
if token_type in self._token_formats:
|
|
57
|
+
return self._token_formats[token_type]
|
|
58
|
+
|
|
59
|
+
fmt = QTextCharFormat()
|
|
60
|
+
style_defs = self._style.style_for_token(token_type)
|
|
61
|
+
|
|
62
|
+
if style_defs['color']:
|
|
63
|
+
color_str = style_defs['color']
|
|
64
|
+
fmt.setForeground(self._make_brush(color_str))
|
|
65
|
+
if style_defs['bgcolor']:
|
|
66
|
+
bg_color_str = style_defs['bgcolor']
|
|
67
|
+
fmt.setBackground(self._make_brush(bg_color_str))
|
|
68
|
+
if style_defs['bold']:
|
|
69
|
+
fmt.setFontWeight(QFont.Bold)
|
|
70
|
+
if style_defs['italic']:
|
|
71
|
+
fmt.setFontItalic(True)
|
|
72
|
+
if style_defs['underline']:
|
|
73
|
+
fmt.setUnderlineStyle(QTextCharFormat.SingleUnderline)
|
|
74
|
+
|
|
75
|
+
self._token_formats[token_type] = fmt
|
|
76
|
+
return fmt
|
|
77
|
+
|
|
78
|
+
def _make_brush(self, color_str):
|
|
79
|
+
"""Convert a hex string (e.g. 'fff' or 'ffffff') to a QBrush."""
|
|
80
|
+
color_str = color_str.lstrip('#')
|
|
81
|
+
if len(color_str) == 3:
|
|
82
|
+
color_str = ''.join(ch * 2 for ch in color_str)
|
|
83
|
+
red = int(color_str[0:2], 16)
|
|
84
|
+
green = int(color_str[2:4], 16)
|
|
85
|
+
blue = int(color_str[4:6], 16)
|
|
86
|
+
return QBrush(QColor(red, green, blue))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from pygments.token import Token
|
|
2
|
+
import logging
|
|
3
|
+
from .generic import SyntaxHighlighter as GenericSyntaxHighlighter
|
|
4
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SyntaxHighlighter(GenericSyntaxHighlighter):
|
|
9
|
+
"""Extends the default highlighter with support for multiline strings."""
|
|
10
|
+
|
|
11
|
+
def highlightBlock(self, text):
|
|
12
|
+
"""
|
|
13
|
+
1) Determine if this line starts as 'in' or 'out' of a triple-quoted string
|
|
14
|
+
from the previous block's state.
|
|
15
|
+
2) Scan for triple-quote transitions, toggling in/out state.
|
|
16
|
+
3) If in triple-quote mode, highlight everything as a string, else use normal
|
|
17
|
+
Pygments highlighting for this line.
|
|
18
|
+
4) Use self.setCurrentBlockState(...) to mark the final state for this line, so
|
|
19
|
+
that QSyntaxHighlighter can handle subsequent lines correctly.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# old_state is typically -1 for the very first block, otherwise what we last set
|
|
23
|
+
old_state = self.previousBlockState()
|
|
24
|
+
previously_in_string = (old_state == 1)
|
|
25
|
+
currently_in_string = previously_in_string
|
|
26
|
+
|
|
27
|
+
# Search line text for triple quotes
|
|
28
|
+
triple_quotes = ('"""', "'''")
|
|
29
|
+
idx = 0
|
|
30
|
+
while True:
|
|
31
|
+
next_quote_pos = -1
|
|
32
|
+
found_quote = None
|
|
33
|
+
for quote in triple_quotes:
|
|
34
|
+
pos = text.find(quote, idx)
|
|
35
|
+
if pos != -1 and (next_quote_pos == -1 or pos < next_quote_pos):
|
|
36
|
+
next_quote_pos = pos
|
|
37
|
+
found_quote = quote
|
|
38
|
+
|
|
39
|
+
if next_quote_pos == -1:
|
|
40
|
+
# No more triple quotes in this line
|
|
41
|
+
break
|
|
42
|
+
else:
|
|
43
|
+
# Toggle the multiline string state
|
|
44
|
+
currently_in_string = not currently_in_string
|
|
45
|
+
# Move index past the found triple quotes
|
|
46
|
+
idx = next_quote_pos + len(found_quote)
|
|
47
|
+
|
|
48
|
+
if currently_in_string:
|
|
49
|
+
# Highlight the entire line as string
|
|
50
|
+
string_format = self._get_format(Token.String)
|
|
51
|
+
self.setFormat(0, len(text), string_format)
|
|
52
|
+
self.setCurrentBlockState(1)
|
|
53
|
+
else:
|
|
54
|
+
# Use normal Pygments line-by-line highlighting
|
|
55
|
+
tokens = list(self._lexer.get_tokens(text))
|
|
56
|
+
index = 0
|
|
57
|
+
for token_type, token_text in tokens:
|
|
58
|
+
token_len = len(token_text)
|
|
59
|
+
fmt = self._get_format(token_type)
|
|
60
|
+
self.setFormat(index, token_len, fmt)
|
|
61
|
+
index += token_len
|
|
62
|
+
self.setCurrentBlockState(0)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
from pygments.lexers import get_lexer_by_name
|
|
4
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
module_cache = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_syntax_highlighter(language, *args, **kwargs):
|
|
10
|
+
try:
|
|
11
|
+
lexer = get_lexer_by_name(language)
|
|
12
|
+
except:
|
|
13
|
+
lexer = get_lexer_by_name('markdown')
|
|
14
|
+
if language not in module_cache:
|
|
15
|
+
try:
|
|
16
|
+
module = importlib.import_module(
|
|
17
|
+
f".languages.{language}", package=__package__)
|
|
18
|
+
except ImportError:
|
|
19
|
+
from .languages import generic as module
|
|
20
|
+
logger.info(f'failed to load syntax highlighter module for {language}, falling back to generic')
|
|
21
|
+
else:
|
|
22
|
+
logger.info(f'loaded editor module for {language}')
|
|
23
|
+
module_cache[language] = module
|
|
24
|
+
else:
|
|
25
|
+
module = module_cache[language]
|
|
26
|
+
return module.SyntaxHighlighter(*args, lexer=lexer, **kwargs)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
THEMES = {
|
|
2
|
+
'light': {
|
|
3
|
+
'background_color': '#FFFFFF',
|
|
4
|
+
'text_color': '#000000',
|
|
5
|
+
'selection_background': '#ADD6FF',
|
|
6
|
+
'selection_color': '#000000',
|
|
7
|
+
},
|
|
8
|
+
'dark': {
|
|
9
|
+
'background_color': '#1E1E1E',
|
|
10
|
+
'text_color': '#D4D4D4',
|
|
11
|
+
'selection_background': '#264F78',
|
|
12
|
+
'selection_color': '#FFFFFF',
|
|
13
|
+
},
|
|
14
|
+
'monokai': {
|
|
15
|
+
'background_color': '#272822',
|
|
16
|
+
'text_color': '#F8F8F2',
|
|
17
|
+
'selection_background': '#49483E',
|
|
18
|
+
'selection_color': '#F8F8F2',
|
|
19
|
+
},
|
|
20
|
+
'solarized_light': {
|
|
21
|
+
'background_color': '#FDF6E3',
|
|
22
|
+
'text_color': '#657B83',
|
|
23
|
+
'selection_background': '#EEE8D5',
|
|
24
|
+
'selection_color': '#586E75',
|
|
25
|
+
},
|
|
26
|
+
'solarized_dark': {
|
|
27
|
+
'background_color': '#002B36',
|
|
28
|
+
'text_color': '#839496',
|
|
29
|
+
'selection_background': '#073642',
|
|
30
|
+
'selection_color': '#93A1A1',
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
OUTER_CONTENT_MARGINS = [6, 6, 6, 6]
|
|
35
|
+
HORIZONTAL_SPACING = 6
|
|
36
|
+
VERTICAL_SPACING = 12
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pygments.lexers import guess_lexer_for_filename
|
|
3
|
+
from pygments.util import ClassNotFound
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def guess_language_from_path(filepath):
|
|
7
|
+
"""
|
|
8
|
+
Detect programming language for a given file using Pygments.
|
|
9
|
+
Returns a lowercase string, e.g. 'python', or falls back to 'text'.
|
|
10
|
+
"""
|
|
11
|
+
if filepath is None:
|
|
12
|
+
return 'text'
|
|
13
|
+
try:
|
|
14
|
+
# Read entire file as text (note: could be slow for very large files)
|
|
15
|
+
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
16
|
+
contents = f.read()
|
|
17
|
+
|
|
18
|
+
# Attempt to guess language
|
|
19
|
+
lexer = guess_lexer_for_filename(filepath, contents)
|
|
20
|
+
return lexer.name.lower()
|
|
21
|
+
except (FileNotFoundError, ClassNotFound, UnicodeDecodeError):
|
|
22
|
+
# If file doesn't exist or we can't guess the language,
|
|
23
|
+
# we fall back to 'text'
|
|
24
|
+
return 'text'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def shorten_paths(paths):
|
|
28
|
+
"""
|
|
29
|
+
Given a list of file paths, return a list of shortened names that are unique,
|
|
30
|
+
but only as long as necessary. Files sharing the same base name will get
|
|
31
|
+
additional path components prepended from the right until they are unique.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Split into lists of path components. We also note if the path is absolute.
|
|
35
|
+
splitted = []
|
|
36
|
+
for p in paths:
|
|
37
|
+
# On some systems, you might want to use os.path.normpath first:
|
|
38
|
+
# p = os.path.normpath(p)
|
|
39
|
+
is_abs = p.startswith(os.path.sep)
|
|
40
|
+
parts = p.strip(os.path.sep).split(os.path.sep)
|
|
41
|
+
splitted.append((is_abs, parts))
|
|
42
|
+
|
|
43
|
+
# Group by base name (last component)
|
|
44
|
+
from collections import defaultdict
|
|
45
|
+
base_map = defaultdict(list)
|
|
46
|
+
for i, (is_abs, parts) in enumerate(splitted):
|
|
47
|
+
base_map[parts[-1]].append(i) # group by last component
|
|
48
|
+
|
|
49
|
+
# For each group of files sharing the same base name, expand as necessary
|
|
50
|
+
expansions = [1] * len(splitted) # how many path parts (from the right) we are using
|
|
51
|
+
|
|
52
|
+
def make_short_name(is_abs, parts, count):
|
|
53
|
+
"""
|
|
54
|
+
Produce the short name using the last `count` components.
|
|
55
|
+
If the original path was absolute and we use more than 1 component, we
|
|
56
|
+
add a leading slash. (You can tweak this logic as desired.)
|
|
57
|
+
"""
|
|
58
|
+
selected = parts[-count:]
|
|
59
|
+
return "/".join(selected)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
for base_name, indices in base_map.items():
|
|
63
|
+
if len(indices) == 1:
|
|
64
|
+
# No conflict, no need to expand
|
|
65
|
+
continue
|
|
66
|
+
# We have multiple files with the same base name
|
|
67
|
+
# so keep expanding path components until unique
|
|
68
|
+
done = False
|
|
69
|
+
while not done:
|
|
70
|
+
# Build short names for all members
|
|
71
|
+
used_names = {}
|
|
72
|
+
collisions = False
|
|
73
|
+
for idx in indices:
|
|
74
|
+
is_abs, parts = splitted[idx]
|
|
75
|
+
short = make_short_name(is_abs, parts, expansions[idx])
|
|
76
|
+
if short in used_names:
|
|
77
|
+
collisions = True # we have a collision
|
|
78
|
+
used_names[short] = idx
|
|
79
|
+
if collisions:
|
|
80
|
+
# Increase expansions for all in this group
|
|
81
|
+
for idx in indices:
|
|
82
|
+
expansions[idx] = min(expansions[idx] + 1, len(splitted[idx][1]))
|
|
83
|
+
# If expansions reach the entire path length, it can't expand further.
|
|
84
|
+
# Next loop check might still find collisions if two paths are identical.
|
|
85
|
+
else:
|
|
86
|
+
done = True
|
|
87
|
+
|
|
88
|
+
# Now build the final short names
|
|
89
|
+
results = []
|
|
90
|
+
for i, (is_abs, parts) in enumerate(splitted):
|
|
91
|
+
count = expansions[i]
|
|
92
|
+
short = make_short_name(is_abs, parts, count)
|
|
93
|
+
results.append(short)
|
|
94
|
+
|
|
95
|
+
return results
|
|
96
|
+
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""This module implements a watchdog that ensures that subprocess are killed
|
|
2
|
+
when the main process is no longer alive. It does so by starting a separate
|
|
3
|
+
watchdog process that occasionally checks whether the main process is still
|
|
4
|
+
alive.
|
|
5
|
+
|
|
6
|
+
To handle unexpected crashes of the application, it periodically checks whether
|
|
7
|
+
the main process is still alive. When the main process has died, it first kills
|
|
8
|
+
all registerd subprocesses, and then shuts itself down.
|
|
9
|
+
|
|
10
|
+
To handle clean application exist, it also listens for an explicit shutdown
|
|
11
|
+
signal, in which case it does the same.
|
|
12
|
+
|
|
13
|
+
In all cases, the watchdog should make sure that no subprocesses, including
|
|
14
|
+
itself, stay alive after the main process has died.
|
|
15
|
+
"""
|
|
16
|
+
import multiprocessing as mp
|
|
17
|
+
import os
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import logging
|
|
22
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Global variables
|
|
27
|
+
_main_pid = os.getpid()
|
|
28
|
+
logger.info(f'main process is {_main_pid}')
|
|
29
|
+
_watchdog_process = None
|
|
30
|
+
_conn = None
|
|
31
|
+
_registered_pids = set()
|
|
32
|
+
_CHECK_INTERVAL = 1.0 # seconds
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _watchdog_main(main_pid, child_conn):
|
|
36
|
+
"""The main function of the watchdog process."""
|
|
37
|
+
|
|
38
|
+
registered_pids = set()
|
|
39
|
+
|
|
40
|
+
while True:
|
|
41
|
+
# Check if main process is still alive
|
|
42
|
+
if not _is_process_alive(main_pid):
|
|
43
|
+
logger.info("watchdog: Main process died, killing all subprocesses")
|
|
44
|
+
_kill_all_processes(registered_pids)
|
|
45
|
+
sys.exit(0)
|
|
46
|
+
|
|
47
|
+
# Check for messages from the main process
|
|
48
|
+
if child_conn.poll():
|
|
49
|
+
msg = child_conn.recv()
|
|
50
|
+
if msg["command"] == "shutdown":
|
|
51
|
+
logger.info("watchdog: Received shutdown command")
|
|
52
|
+
_kill_all_processes(registered_pids)
|
|
53
|
+
sys.exit(0)
|
|
54
|
+
elif msg["command"] == "register":
|
|
55
|
+
pid = msg["pid"]
|
|
56
|
+
if pid not in registered_pids:
|
|
57
|
+
registered_pids.add(pid)
|
|
58
|
+
logger.info(f"watchdog: Registered subprocess {pid}")
|
|
59
|
+
|
|
60
|
+
time.sleep(_CHECK_INTERVAL)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_process_alive(pid):
|
|
64
|
+
"""Check if a process with the given pid is alive."""
|
|
65
|
+
try:
|
|
66
|
+
# Sending signal 0 checks if process exists without affecting it
|
|
67
|
+
os.kill(pid, 0)
|
|
68
|
+
return True
|
|
69
|
+
except OSError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _kill_all_processes(pids):
|
|
74
|
+
"""Kill all processes with the given pids."""
|
|
75
|
+
for pid in pids:
|
|
76
|
+
try:
|
|
77
|
+
# Try SIGTERM first
|
|
78
|
+
os.kill(pid, signal.SIGTERM)
|
|
79
|
+
logger.info(f"watchdog: Sent SIGTERM to {pid}")
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# Give processes time to exit gracefully
|
|
84
|
+
time.sleep(0.5)
|
|
85
|
+
|
|
86
|
+
# Force kill any remaining processes
|
|
87
|
+
for pid in pids:
|
|
88
|
+
try:
|
|
89
|
+
if _is_process_alive(pid):
|
|
90
|
+
os.kill(pid, signal.SIGKILL)
|
|
91
|
+
logger.info(f"watchdog: Sent SIGKILL to {pid}")
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ensure_watchdog_running():
|
|
97
|
+
"""Ensure that the watchdog process is running."""
|
|
98
|
+
global _watchdog_process, _conn
|
|
99
|
+
|
|
100
|
+
if _watchdog_process is None and _main_pid is not None:
|
|
101
|
+
parent_conn, child_conn = mp.Pipe()
|
|
102
|
+
_watchdog_process = mp.Process(
|
|
103
|
+
target=_watchdog_main,
|
|
104
|
+
args=(_main_pid, child_conn),
|
|
105
|
+
name="watchdog-process"
|
|
106
|
+
)
|
|
107
|
+
_watchdog_process.start()
|
|
108
|
+
_conn = parent_conn
|
|
109
|
+
logger.info(f"watchdog: Started watchdog process with PID {_watchdog_process.pid}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def set_main_process(pid: int):
|
|
113
|
+
"""Sets the main process pid. This does not yet launch the watchdog process."""
|
|
114
|
+
global _main_pid
|
|
115
|
+
_main_pid = pid
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def register_subprocess(pid: int):
|
|
119
|
+
"""If no watchdog process is running, this is launched now. The pid is
|
|
120
|
+
registered with the watchdog.
|
|
121
|
+
"""
|
|
122
|
+
global _registered_pids
|
|
123
|
+
|
|
124
|
+
if _main_pid is None:
|
|
125
|
+
raise RuntimeError("Main process PID not set. Call set_main_process() first.")
|
|
126
|
+
|
|
127
|
+
_ensure_watchdog_running()
|
|
128
|
+
_registered_pids.add(pid)
|
|
129
|
+
|
|
130
|
+
# Send registration to watchdog process
|
|
131
|
+
if _conn is not None:
|
|
132
|
+
_conn.send({"command": "register", "pid": pid})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def shutdown():
|
|
136
|
+
"""Explicitly tell the watchdog to kill all subprocesses."""
|
|
137
|
+
logger.info('shutdown')
|
|
138
|
+
if _conn is not None:
|
|
139
|
+
_conn.send({"command": "shutdown"})
|
|
140
|
+
# Give watchdog time to process the shutdown command
|
|
141
|
+
time.sleep(0.1)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from qtpy.QtCore import Qt
|
|
2
|
+
from qtpy.QtWidgets import QFrame, QLabel, QVBoxLayout
|
|
3
|
+
from .. import settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CalltipWidget(QFrame):
|
|
7
|
+
"""
|
|
8
|
+
A small persistent widget to show calltip text so it won't vanish
|
|
9
|
+
until explicitly hidden (unlike QToolTip).
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, editor):
|
|
12
|
+
# We set window flags separately (cannot OR widget attributes).
|
|
13
|
+
super().__init__(editor)
|
|
14
|
+
self.setWindowFlags(Qt.FramelessWindowHint | Qt.Tool)
|
|
15
|
+
# WA_ShowWithoutActivating means the widget won’t take focus
|
|
16
|
+
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
|
17
|
+
# Make background translucent (use setAttribute):
|
|
18
|
+
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
|
19
|
+
self.apply_stylesheet()
|
|
20
|
+
layout = QVBoxLayout(self)
|
|
21
|
+
layout.setContentsMargins(8, 4, 8, 4)
|
|
22
|
+
self._label = QLabel(self)
|
|
23
|
+
self._label.setTextFormat(Qt.RichText)
|
|
24
|
+
self._label.setWordWrap(True)
|
|
25
|
+
layout.addWidget(self._label)
|
|
26
|
+
self.setLayout(layout)
|
|
27
|
+
|
|
28
|
+
def setText(self, text):
|
|
29
|
+
self._label.setText(text)
|
|
30
|
+
self.adjustSize()
|
|
31
|
+
|
|
32
|
+
def apply_stylesheet(self):
|
|
33
|
+
editor = self.parent()
|
|
34
|
+
if editor.code_editor_colors is not None:
|
|
35
|
+
self.setStyleSheet(f'''QFrame {{
|
|
36
|
+
color: {editor.code_editor_colors['text']};
|
|
37
|
+
background-color: {editor.code_editor_colors['background']};
|
|
38
|
+
font: {settings.font_size}pt '{settings.font_family}';
|
|
39
|
+
border-color: {editor.code_editor_colors['border']};
|
|
40
|
+
border-width: 1px;
|
|
41
|
+
border-style: solid;
|
|
42
|
+
border-radius: 4px;
|
|
43
|
+
padding: 8px;
|
|
44
|
+
}}''')
|