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.
Files changed (73) hide show
  1. pyqt_code_editor/__init__.py +5 -0
  2. pyqt_code_editor/_settings.py +232 -0
  3. pyqt_code_editor/app.py +438 -0
  4. pyqt_code_editor/code_editors/__init__.py +1 -0
  5. pyqt_code_editor/code_editors/code_editors.py +33 -0
  6. pyqt_code_editor/code_editors/languages/__init__.py +0 -0
  7. pyqt_code_editor/code_editors/languages/generic.py +11 -0
  8. pyqt_code_editor/code_editors/languages/markdown.py +11 -0
  9. pyqt_code_editor/code_editors/languages/python.py +13 -0
  10. pyqt_code_editor/code_editors/languages/text.py +9 -0
  11. pyqt_code_editor/components/__init__.py +0 -0
  12. pyqt_code_editor/components/editor_panel.py +399 -0
  13. pyqt_code_editor/components/find_in_files.py +199 -0
  14. pyqt_code_editor/components/jupyter_console.py +487 -0
  15. pyqt_code_editor/components/project_explorer.py +515 -0
  16. pyqt_code_editor/components/settings_panel.py +162 -0
  17. pyqt_code_editor/components/sigmund.py +75 -0
  18. pyqt_code_editor/components/workspace_explorer.py +162 -0
  19. pyqt_code_editor/mixins/__init__.py +15 -0
  20. pyqt_code_editor/mixins/auto_pair.py +201 -0
  21. pyqt_code_editor/mixins/base.py +142 -0
  22. pyqt_code_editor/mixins/check.py +94 -0
  23. pyqt_code_editor/mixins/comment.py +91 -0
  24. pyqt_code_editor/mixins/complete.py +482 -0
  25. pyqt_code_editor/mixins/execute.py +182 -0
  26. pyqt_code_editor/mixins/file_link.py +166 -0
  27. pyqt_code_editor/mixins/line_number.py +206 -0
  28. pyqt_code_editor/mixins/python_auto_indent.py +239 -0
  29. pyqt_code_editor/mixins/search_replace.py +396 -0
  30. pyqt_code_editor/mixins/shortcuts.py +228 -0
  31. pyqt_code_editor/mixins/smart_backspace_delete.py +120 -0
  32. pyqt_code_editor/mixins/symbols.py +45 -0
  33. pyqt_code_editor/mixins/theme.py +99 -0
  34. pyqt_code_editor/mixins/widgets/__init__.py +0 -0
  35. pyqt_code_editor/mixins/zoom.py +51 -0
  36. pyqt_code_editor/python_utils.py +936 -0
  37. pyqt_code_editor/signal_router.py +116 -0
  38. pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
  39. pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
  40. pyqt_code_editor/syntax_highlighters/languages/generic.py +86 -0
  41. pyqt_code_editor/syntax_highlighters/languages/python.py +62 -0
  42. pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +26 -0
  43. pyqt_code_editor/themes.py +36 -0
  44. pyqt_code_editor/utils.py +96 -0
  45. pyqt_code_editor/watchdog.py +141 -0
  46. pyqt_code_editor/widgets/__init__.py +6 -0
  47. pyqt_code_editor/widgets/calltip_widget.py +44 -0
  48. pyqt_code_editor/widgets/completion_popup.py +89 -0
  49. pyqt_code_editor/widgets/dock.py +15 -0
  50. pyqt_code_editor/widgets/quick_open_dialog.py +196 -0
  51. pyqt_code_editor/widgets/quick_open_file_dialog.py +32 -0
  52. pyqt_code_editor/widgets/quick_symbol_dialog.py +13 -0
  53. pyqt_code_editor/widgets/search_replace_frame.py +128 -0
  54. pyqt_code_editor/widgets/tab_splitter.py +22 -0
  55. pyqt_code_editor/widgets/tabbed_editor.py +351 -0
  56. pyqt_code_editor/worker/__init__.py +0 -0
  57. pyqt_code_editor/worker/languages/__init__.py +0 -0
  58. pyqt_code_editor/worker/languages/generic.py +15 -0
  59. pyqt_code_editor/worker/languages/python.py +17 -0
  60. pyqt_code_editor/worker/manager.py +113 -0
  61. pyqt_code_editor/worker/process.py +128 -0
  62. pyqt_code_editor/worker/providers/__init__.py +1 -0
  63. pyqt_code_editor/worker/providers/codestral.py +56 -0
  64. pyqt_code_editor/worker/providers/jedi.py +115 -0
  65. pyqt_code_editor/worker/providers/ruff.py +45 -0
  66. pyqt_code_editor/worker/providers/sigmund.py +29 -0
  67. pyqt_code_editor/worker/providers/symbol.py +78 -0
  68. pyqt_code_editor/worker/settings.py +1 -0
  69. pyqt_code_editor-0.0.1.dist-info/METADATA +69 -0
  70. pyqt_code_editor-0.0.1.dist-info/RECORD +73 -0
  71. pyqt_code_editor-0.0.1.dist-info/WHEEL +4 -0
  72. pyqt_code_editor-0.0.1.dist-info/entry_points.txt +4 -0
  73. pyqt_code_editor-0.0.1.dist-info/licenses/COPYING +674 -0
@@ -0,0 +1,351 @@
1
+ import os
2
+ import logging
3
+ from qtpy.QtGui import QKeySequence, QIcon, QDrag, QPixmap, QPainter
4
+ from qtpy.QtWidgets import QTabWidget, QShortcut, QMessageBox, QMenu, QStyle, \
5
+ QApplication, QTabBar
6
+ from qtpy.QtCore import Signal, Qt, QMimeData, QPoint, QByteArray, QDataStream
7
+ from ..code_editors import create_editor
8
+ from .. import settings, utils
9
+ from ..signal_router import signal_router
10
+ logging.basicConfig(level=logging.INFO, force=True)
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class DraggableTabBar(QTabBar):
15
+ def __init__(self, parent=None):
16
+ super().__init__(parent)
17
+ # We use manual drag & drop, so disable Qt's built-in movable
18
+ self.setMovable(False)
19
+ self.dragStartPos = None
20
+ self.dragIndex = -1
21
+ self.draggedWidget = None
22
+ self.draggedLabel = None
23
+
24
+ def mousePressEvent(self, event):
25
+ if event.button() == Qt.LeftButton:
26
+ self.dragStartPos = event.position()
27
+ # Save which tab was pressed
28
+ self.dragIndex = self.tabAt(event.pos())
29
+ super().mousePressEvent(event)
30
+
31
+ def mouseMoveEvent(self, event):
32
+ if not (event.buttons() & Qt.LeftButton):
33
+ return super().mouseMoveEvent(event)
34
+
35
+ if self.dragStartPos is None or self.dragIndex < 0:
36
+ return super().mouseMoveEvent(event)
37
+
38
+ # If user moved far enough, start a drag
39
+ distance = (event.position() - self.dragStartPos).manhattanLength()
40
+ if distance > QApplication.startDragDistance():
41
+ self.performDrag()
42
+ return # Once we start a drag, ignore further moves
43
+
44
+ super().mouseMoveEvent(event)
45
+
46
+ def performDrag(self):
47
+ parentTabWidget = self.parentWidget()
48
+ if not isinstance(parentTabWidget, QTabWidget):
49
+ return
50
+
51
+ # Extract the pressed tab's widget and text
52
+ draggedWidget = parentTabWidget.widget(self.dragIndex)
53
+ draggedText = parentTabWidget.tabText(self.dragIndex)
54
+ draggedIcon = parentTabWidget.tabIcon(self.dragIndex)
55
+
56
+ self.draggedWidget = draggedWidget
57
+ # Temporary remove the tab from original location
58
+ parentTabWidget.removeTab(self.dragIndex)
59
+
60
+ # Prepare the Drag object
61
+ drag = QDrag(self)
62
+ mimeData = QMimeData()
63
+
64
+ # we'll store the text, icon, and index in the mime data
65
+ # For instance, we can store them in a QByteArray:
66
+ dataByteArray = QByteArray()
67
+ stream = QDataStream(dataByteArray, QDataStream.WriteOnly)
68
+ # write text
69
+ stream.writeQString(draggedText)
70
+ # write an empty string if no icon
71
+ hasIcon = not draggedIcon.isNull()
72
+ stream.writeBool(hasIcon)
73
+ if hasIcon:
74
+ # For simplicity, store just an indicator that there's an icon
75
+ # (Better approach might be to store a serialized pixmap)
76
+ pass
77
+ mimeData.setData("application/x-tabdata", dataByteArray)
78
+
79
+ drag.setMimeData(mimeData)
80
+
81
+ # Optional: show a preview pixmap
82
+ # A simple approach is to build a pixmap with text:
83
+ pixmap = QPixmap(150, 30)
84
+ pixmap.fill(Qt.transparent)
85
+ painter = QPainter(pixmap)
86
+ painter.drawText(pixmap.rect(), Qt.AlignCenter, draggedText)
87
+ painter.end()
88
+ drag.setPixmap(pixmap)
89
+ drag.setHotSpot(QPoint(pixmap.width() // 2, pixmap.height() // 2))
90
+
91
+ # Start the drag
92
+ dropAction = drag.exec(Qt.MoveAction)
93
+ if dropAction != Qt.MoveAction:
94
+ # If the drag was canceled, re-insert the tab in original location
95
+ parentTabWidget.insertTab(self.dragIndex, draggedWidget, draggedIcon, draggedText)
96
+ self.dragIndex = -1
97
+ self.draggedWidget = None
98
+
99
+ # Notify when the tab widget is empty
100
+ if parentTabWidget.count() == 0:
101
+ parentTabWidget.lastTabClosed.emit(parentTabWidget)
102
+
103
+ def mouseReleaseEvent(self, event):
104
+ # Reset drag info
105
+ self.dragIndex = -1
106
+ self.dragStartPos = None
107
+ super().mouseReleaseEvent(event)
108
+
109
+
110
+ class TabbedEditor(QTabWidget):
111
+ """A tab widget that can hold multiple CodeEditor instances."""
112
+ lastTabClosed = Signal(QTabWidget)
113
+
114
+ def __init__(self, parent=None):
115
+ super().__init__(parent)
116
+ logger.info("TabbedEditor created")
117
+ self._synonyms = {}
118
+ self.tabCloseRequested.connect(self.on_tab_close_requested)
119
+
120
+ # Existing shortcuts
121
+ self._close_shortcut = QShortcut(QKeySequence.Close, self)
122
+ self._close_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
123
+ self._close_shortcut.activated.connect(self.close_tab)
124
+
125
+ # New shortcuts:
126
+ self._close_all_shortcut = QShortcut(
127
+ QKeySequence(settings.shortcut_close_all_tabs), self)
128
+ self._close_all_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
129
+ self._close_all_shortcut.activated.connect(self.close_all_tabs)
130
+
131
+ self._close_other_shortcut = QShortcut(
132
+ QKeySequence(settings.shortcut_close_other_tabs), self)
133
+ self._close_other_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
134
+ self._close_other_shortcut.activated.connect(
135
+ lambda: self.close_other_tabs(self.currentIndex()))
136
+
137
+ # Tab navigation shortcut
138
+ self._prev_tab_shortcut = QShortcut(settings.shortcut_previous_tab, self)
139
+ self._prev_tab_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
140
+ self._prev_tab_shortcut.activated.connect(self.previous_tab)
141
+
142
+ # Replace default tab bar:
143
+ self.setTabBar(DraggableTabBar(self))
144
+ # Accept drops from other TabbedEditors
145
+ self.setAcceptDrops(True)
146
+
147
+ # Enable custom context menus on the tab bar
148
+ self.tabBar().setContextMenuPolicy(Qt.CustomContextMenu)
149
+ self.tabBar().customContextMenuRequested.connect(
150
+ self._on_tabbar_context_menu)
151
+ self.setTabsClosable(True)
152
+
153
+ def relabel_tabs(self, synonyms):
154
+ self._synonyms = synonyms
155
+ for index in range(self.count()):
156
+ editor = self.widget(index)
157
+ path = editor.code_editor_file_path
158
+ self.setTabText(index, self._synonym(path))
159
+
160
+ def _synonym(self, path):
161
+ if path is None:
162
+ return 'Untitled'
163
+ if path in self._synonyms:
164
+ return self._synonyms[path]
165
+ return os.path.basename(path)
166
+
167
+ def previous_tab(self):
168
+ current_index = self.currentIndex()
169
+ if current_index > 0:
170
+ self.setCurrentIndex(current_index - 1)
171
+ elif current_index == 0:
172
+ self.setCurrentIndex(self.count() - 1)
173
+
174
+ # -- New methods for closing multiple tabs --
175
+ def close_all_tabs(self):
176
+ """Close all tabs."""
177
+ for i in reversed(range(self.count())):
178
+ self.on_tab_close_requested(i)
179
+
180
+ def close_other_tabs(self, index):
181
+ """Close all tabs except the one at 'index'."""
182
+ for i in reversed(range(self.count())):
183
+ if i != index:
184
+ self.on_tab_close_requested(i)
185
+
186
+ def close_tab(self):
187
+ current_index = self.currentIndex()
188
+ if current_index >= 0:
189
+ self.on_tab_close_requested(current_index)
190
+
191
+ def on_tab_close_requested(self, index):
192
+ logger.info("Tab close requested for index: %s", index)
193
+ widget = self.widget(index)
194
+ if widget.modified:
195
+ # Ask for confirmation before closing the tab
196
+ result = QMessageBox.question(
197
+ self,
198
+ "Unsaved Changes",
199
+ "Do you want to save the changes before closing?",
200
+ QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
201
+ )
202
+ if result == QMessageBox.Yes:
203
+ widget.save_file()
204
+ elif result == QMessageBox.Cancel:
205
+ return
206
+ if widget:
207
+ widget.deleteLater()
208
+ self.removeTab(index)
209
+
210
+ # If no tabs remain, tell the outside world
211
+ if self.count() == 0:
212
+ logger.info("No tabs left in TabbedEditor => emit lastTabClosed")
213
+ self.lastTabClosed.emit(self)
214
+ else:
215
+ # Making sure that one of the remaining editors gets focus
216
+ if index > 0:
217
+ index -= 1
218
+ self.setCurrentIndex(index)
219
+ self.widget(index).setFocus()
220
+
221
+ def _on_modification_changed(self, editor, changed):
222
+ index = self.indexOf(editor)
223
+ tab_text = self.tabText(index)
224
+ if editor.modified and not tab_text.endswith(' *'):
225
+ tab_text += ' *'
226
+ elif not editor.modified and tab_text.endswith(' *'):
227
+ tab_text = tab_text[:-2]
228
+ self.setTabText(index, tab_text)
229
+
230
+ def _on_file_name_changed(self, editor, from_path, to_path):
231
+ """When a file name has changed in such a way that the language has also
232
+ changed, then we close and reopen the editor to make sure that the editor
233
+ type is correct.
234
+ """
235
+ from_language = utils.guess_language_from_path(from_path)
236
+ to_language = utils.guess_language_from_path(to_path)
237
+ if from_language == to_language:
238
+ return
239
+ logger.info(f'language changed from {from_language} to {to_language}')
240
+ index = self.indexOf(editor)
241
+ self.removeTab(index)
242
+ self.add_code_editor(to_path, index=index)
243
+
244
+ def add_code_editor(self, path=None, index=None):
245
+ editor = create_editor(path, parent=self)
246
+ editor.modification_changed.connect(self._on_modification_changed)
247
+ editor.file_name_changed.connect(self._on_file_name_changed)
248
+ logger.info("Adding new code editor tab")
249
+ title = self._synonym(editor.code_editor_file_path)
250
+ if index is None:
251
+ index = self.addTab(editor, title)
252
+ else:
253
+ self.insertTab(index, editor, title)
254
+ self.setCurrentIndex(index)
255
+ signal_router.register_widget(editor)
256
+ return editor
257
+
258
+ def editors(self):
259
+ """Returns a list of all editors in this tab widget"""
260
+ return [self.widget(i) for i in range(self.count())]
261
+
262
+ def _on_tabbar_context_menu(self, pos):
263
+ """Show context menu with 'Close', 'Close All', 'Close Others' and icons."""
264
+ index = self.tabBar().tabAt(pos)
265
+ menu = QMenu(self)
266
+ close_icon = QIcon.fromTheme(
267
+ "window-close",
268
+ self.style().standardIcon(QStyle.SP_DialogCloseButton))
269
+ if index == -1:
270
+ # Right-clicked on empty area
271
+ close_all_action = menu.addAction("Close All")
272
+ # Standard icon for "close" (fallback if "window-close" is not found)
273
+ close_all_action.setIcon(close_icon)
274
+ close_all_action.triggered.connect(self.close_all_tabs)
275
+ else:
276
+ # Right-clicked on a specific tab
277
+ close_action = menu.addAction("Close")
278
+ close_action.setIcon(close_icon)
279
+ close_action.triggered.connect(
280
+ lambda: self.on_tab_close_requested(index))
281
+
282
+ close_all_action = menu.addAction("Close All")
283
+ close_all_action.setIcon(close_icon)
284
+ close_all_action.triggered.connect(self.close_all_tabs)
285
+
286
+ close_others_action = menu.addAction("Close Others")
287
+ # Using the same icon for "Close Others"
288
+ close_others_action.setIcon(close_icon)
289
+ close_others_action.triggered.connect(
290
+ lambda: self.close_other_tabs(index))
291
+
292
+ menu.exec_(self.tabBar().mapToGlobal(pos))
293
+
294
+ def dragEnterEvent(self, event):
295
+ if event.mimeData().hasFormat("application/x-tabdata") or event.mimeData().hasUrls():
296
+ event.acceptProposedAction()
297
+ else:
298
+ event.ignore()
299
+
300
+ def dragMoveEvent(self, event):
301
+ if event.mimeData().hasFormat("application/x-tabdata") or event.mimeData().hasUrls():
302
+ # We can figure out which tab index we're hovering over
303
+ # to show an appropriate "insertion" indicator.
304
+ # For example, highlight the tab or something similar.
305
+ # For brevity, we'll just accept the event and do no extra highlighting.
306
+ event.acceptProposedAction()
307
+ else:
308
+ event.ignore()
309
+
310
+ def dropEvent(self, event):
311
+ # If a file is dropped, open it in a new tab
312
+ if event.mimeData().hasUrls():
313
+ for url in event.mimeData().urls():
314
+ path = url.toLocalFile()
315
+ self.add_code_editor(path)
316
+
317
+ if not event.mimeData().hasFormat("application/x-tabdata"):
318
+ event.ignore()
319
+ return
320
+
321
+ event.setDropAction(Qt.MoveAction)
322
+ event.accept()
323
+
324
+ # Extract tab data from the mime data
325
+ dataByteArray = event.mimeData().data("application/x-tabdata")
326
+ stream = QDataStream(dataByteArray, QDataStream.ReadOnly)
327
+ tabText = stream.readQString()
328
+ hasIcon = stream.readBool()
329
+ # (We ignore the icon detail in this example or simply set a blank icon.)
330
+ # If you’re handing around pixmaps, you could store them more explicitly.
331
+
332
+ # The source tab's DraggableTabBar has already removed it from its QTabWidget.
333
+ # But we need the actual widget object that was being dragged. It's part
334
+ # of the QDrag's source — we can find it by casting. We must be careful:
335
+ sourceBar = event.source()
336
+ if isinstance(sourceBar, DraggableTabBar):
337
+ draggedWidget = sourceBar.draggedWidget
338
+ else:
339
+ draggedWidget = None
340
+
341
+ # Insert the tab at the position that the user dropped on
342
+ dropPos = event.position().toPoint()
343
+ tabBar = self.tabBar()
344
+ index = tabBar.tabAt(dropPos)
345
+ if index < 0:
346
+ # If the user dropped to the right of all existing tabs
347
+ index = self.count()
348
+
349
+ self.insertTab(index, draggedWidget, tabText)
350
+ self.setCurrentIndex(index)
351
+
File without changes
File without changes
@@ -0,0 +1,15 @@
1
+ from ..providers import symbol, codestral
2
+
3
+ def complete(code, cursor_pos, path, multiline, full):
4
+ if full or multiline:
5
+ completions = codestral.codestral_complete(
6
+ code, cursor_pos, path=path, multiline=multiline)
7
+ else:
8
+ completions = []
9
+ if not multiline:
10
+ completions += symbol.symbol_complete(code, cursor_pos, path=path)
11
+ return completions
12
+
13
+ calltip = None
14
+ check = None
15
+ symbols = None
@@ -0,0 +1,17 @@
1
+ from ..providers import jedi, codestral, ruff
2
+
3
+
4
+ def complete(code, cursor_pos, path, multiline, full):
5
+ if full or multiline:
6
+ completions = codestral.codestral_complete(
7
+ code, cursor_pos, path=path, multiline=multiline)
8
+ else:
9
+ completions = []
10
+ if not multiline:
11
+ completions += jedi.jedi_complete(code, cursor_pos, path=path)
12
+ return completions
13
+
14
+
15
+ calltip = jedi.jedi_signatures
16
+ check = ruff.ruff_check
17
+ symbols = jedi.jedi_symbols
@@ -0,0 +1,113 @@
1
+ import logging
2
+ from multiprocessing import Process, Queue
3
+ from .process import main_worker_process_function
4
+ from .. import watchdog, settings
5
+ logger = logging.getLogger(__name__)
6
+
7
+ _workers = {} # pid -> {"process", "request_queue", "result_queue", "is_free"}
8
+
9
+
10
+ def send_worker_request(**data) -> (Queue, int):
11
+ """
12
+ Send a request to a worker process. If a free worker is available,
13
+ reuse it; otherwise create a new one. Return (result_queue, pid).
14
+
15
+ The caller can poll the result_queue for responses, and once done,
16
+ call mark_worker_as_free(pid) to release this worker for future use.
17
+ """
18
+ # 1. Look for an existing free worker
19
+ for pid, w in list(_workers.items()):
20
+ if w["is_free"] and w["process"].is_alive():
21
+ logger.info(f"Reusing free worker {pid} for request {list(data.keys())}")
22
+ w["is_free"] = False
23
+ w["request_queue"].put(data)
24
+ return w["result_queue"], pid
25
+
26
+ # 2. If no free worker was found, create a new one.
27
+ request_queue = Queue()
28
+ result_queue = Queue()
29
+ p = Process(target=main_worker_process_function,
30
+ args=(request_queue, result_queue))
31
+ p.start()
32
+ pid = p.pid
33
+ watchdog.register_subprocess(pid)
34
+
35
+ _workers[pid] = {
36
+ "process": p,
37
+ "request_queue": request_queue,
38
+ "result_queue": result_queue,
39
+ "is_free": False,
40
+ }
41
+
42
+ logger.info(f"Creating new worker {pid} for request {data['action']}")
43
+ settings_action = {'action': 'set_settings',
44
+ 'settings': {name: value for name, value in settings}}
45
+ request_queue.put(settings_action)
46
+ # 3. Send the request, return the new worker’s result queue and pid.
47
+ request_queue.put(data)
48
+ return result_queue, pid
49
+
50
+ def mark_worker_as_free(pid: int):
51
+ """
52
+ Mark a previously-used worker process (identified by pid)
53
+ as free for reuse.
54
+ """
55
+ w = _workers.get(pid)
56
+ if w and w["process"].is_alive():
57
+ w["is_free"] = True
58
+ logger.info(f"Marking worker {pid} as free")
59
+ else:
60
+ logger.info(f"Attempted to free worker {pid}, but it is not alive or not found.")
61
+
62
+ def check_worker_alive(pid: int) -> bool:
63
+ w = _workers.get(pid)
64
+ return w and w["process"].is_alive()
65
+
66
+ def stop_unused_workers(max_free: int = 1):
67
+ """
68
+ Stop free worker processes until there is at most 'max_free' free processes left.
69
+ This keeps us from accumulating too many idle worker processes.
70
+ """
71
+ # Gather a list of free worker PIDs
72
+ free_pids = [pid for pid, w in _workers.items() if w["is_free"] and w["process"].is_alive()]
73
+ logger.info(f"stop_unused_workers called: max_free={max_free}, found {len(free_pids)} free workers")
74
+
75
+ # Determine how many we need to stop
76
+ to_stop = len(free_pids) - max_free
77
+ if to_stop <= 0:
78
+ logger.info("No free workers to stop.")
79
+ return
80
+
81
+ # Stop some free workers until we have exactly max_free left
82
+ while to_stop > 0 and free_pids:
83
+ pid = free_pids.pop()
84
+ w = _workers[pid]
85
+ logger.info(f"Stopping free worker {pid} because we have too many.")
86
+ w["request_queue"].put({"action": "quit"})
87
+ w["process"].join()
88
+ del _workers[pid]
89
+ to_stop -= 1
90
+
91
+ logger.info("Finished stopping unused workers.")
92
+
93
+ def stop_all_workers():
94
+ """
95
+ Cleanly shut down all worker processes.
96
+ """
97
+ logger.info("Stopping all worker processes...")
98
+ for pid, w in list(_workers.items()):
99
+ if w["process"].is_alive():
100
+ logger.info(f"Stopping worker {pid}.")
101
+ w["request_queue"].put({"action": "quit"})
102
+ w["process"].join()
103
+ del _workers[pid]
104
+ logger.info("All workers stopped.")
105
+
106
+
107
+ def update_setting(name, value):
108
+ settings_action = {'action': 'set_settings', 'settings': {name: value}}
109
+ # Send to all workers
110
+ for pid, w in _workers.items():
111
+ if w["process"].is_alive():
112
+ w["request_queue"].put(settings_action)
113
+ settings.setting_changed.connect(update_setting)
@@ -0,0 +1,128 @@
1
+ import logging
2
+ import importlib
3
+ from . import settings
4
+ logging.basicConfig(level=logging.INFO, force=True)
5
+ logger = logging.getLogger(__name__)
6
+ worker_functions_cache = {}
7
+
8
+
9
+ def main_worker_process_function(request_queue, result_queue):
10
+ """
11
+ Runs in a separate process, handling requests in dict form.
12
+ Supported actions include:
13
+ - 'complete': triggers code completion
14
+ - 'calltip': fetches calltip/signature info
15
+ - 'check': fetches code check/ linting info
16
+ - 'setting': updates settings in the 'settings' module
17
+ - 'quit': shuts down the worker
18
+ """
19
+ logger.info("Started completion worker.")
20
+ while True:
21
+ request = request_queue.get()
22
+ if request is None:
23
+ logger.info("Received None request (possibly legacy or invalid). Skipping.")
24
+ continue
25
+
26
+ # Expect a dict with at least an 'action' field
27
+ if not isinstance(request, dict):
28
+ logger.info(f"Invalid request type: {type(request)}. Skipping.")
29
+ continue
30
+
31
+ action = request.get('action', None)
32
+ if action is None:
33
+ logger.info("Request is missing 'action' field. Skipping.")
34
+ continue
35
+
36
+ logger.info(f"Received request action='{action}'")
37
+ if action == 'set_settings':
38
+ for name, value in request.get('settings', {}).items():
39
+ setattr(settings, name, value)
40
+ continue
41
+ if action == 'quit':
42
+ logger.info("Received 'quit' action. Worker will shut down.")
43
+ break
44
+
45
+ # Load the worker functions depending on the language. We store the
46
+ # imported module in a cache for efficiency
47
+ language = request.get('language', 'text')
48
+ if language not in worker_functions_cache:
49
+ try:
50
+ worker_functions = importlib.import_module(
51
+ f".languages.{language}", package=__package__)
52
+ except ImportError as e:
53
+ from .languages import generic as worker_functions
54
+ logger.info(f'failed to load worker functions for {language}, falling back to generic')
55
+ else:
56
+ logger.info(f'loaded worker functions for {language}')
57
+ worker_functions_cache[language] = worker_functions
58
+ else:
59
+ worker_functions = worker_functions_cache[language]
60
+
61
+ if action == 'complete':
62
+ # Action not supported for language
63
+ if worker_functions.complete is None:
64
+ completions = None
65
+ else:
66
+ code = request.get('code', '')
67
+ cursor_pos = request.get('cursor_pos', 0)
68
+ path = request.get('path', None)
69
+ multiline = request.get('multiline', False)
70
+ full = request.get('full', False)
71
+ logger.info(f"Performing code completion: language='{language}', multiline={multiline}, path={path}")
72
+ completions = worker_functions.complete(
73
+ code, cursor_pos, path=path, multiline=multiline, full=full)
74
+ if not completions:
75
+ logger.info("No completions")
76
+ else:
77
+ logger.info(f"Generated {len(completions)} completions")
78
+ result_queue.put({
79
+ 'action': 'complete',
80
+ 'completions': completions,
81
+ 'cursor_pos': cursor_pos,
82
+ 'multiline': multiline,
83
+ 'full': full
84
+ })
85
+
86
+ elif action == 'calltip':
87
+ if worker_functions.calltip is None:
88
+ signatures = None
89
+ else:
90
+ code = request.get('code', '')
91
+ cursor_pos = request.get('cursor_pos', 0)
92
+ path = request.get('path', None)
93
+ logger.info(f"Performing calltip: language='{language}', path={path}")
94
+ signatures = worker_functions.calltip(
95
+ code, cursor_pos, path=path)
96
+ if signatures is None:
97
+ logger.info("No calltip signatures. Sending result back.")
98
+ else:
99
+ logger.info(f"Retrieved {len(signatures)} calltip signatures.")
100
+ result_queue.put({
101
+ 'action': 'calltip',
102
+ 'signatures': signatures,
103
+ 'cursor_pos': cursor_pos
104
+ })
105
+
106
+ elif action == 'symbols':
107
+ if worker_functions.symbols is None:
108
+ check_results = []
109
+ else:
110
+ code = request.get('code', '')
111
+ symbols_results = worker_functions.symbols(code)
112
+ result_queue.put({
113
+ 'action': 'symbols',
114
+ 'symbols': symbols_results
115
+ })
116
+
117
+ elif action == 'check':
118
+ if worker_functions.check is None:
119
+ check_results = {}
120
+ else:
121
+ code = request.get('code', '')
122
+ check_results = worker_functions.check(code)
123
+ result_queue.put({
124
+ 'action': 'check',
125
+ 'messages': check_results
126
+ })
127
+
128
+ logger.info("Completion worker has shut down.")
@@ -0,0 +1,56 @@
1
+ import logging
2
+ from .. import settings
3
+
4
+ logger = logging.getLogger(__name__)
5
+ client = None
6
+
7
+
8
+ def codestral_complete(code: str, cursor_pos: int, path: str | None,
9
+ multiline: bool = False) -> list[str]:
10
+ global client
11
+
12
+ if not settings.codestral_api_key:
13
+ return []
14
+
15
+ if client is None:
16
+ from mistralai import Mistral
17
+ client = Mistral(api_key=settings.codestral_api_key)
18
+
19
+ if len(code) < settings.codestral_min_context:
20
+ return []
21
+
22
+ start = max(0, cursor_pos - settings.codestral_max_context)
23
+ end = cursor_pos + settings.codestral_max_context
24
+ prompt = code[start: cursor_pos]
25
+ suffix = code[cursor_pos: end]
26
+
27
+ request = dict(
28
+ model=settings.codestral_model,
29
+ server_url=settings.codestral_url,
30
+ prompt=prompt,
31
+ suffix=suffix,
32
+ temperature=0,
33
+ top_p=1,
34
+ timeout_ms=settings.codestral_timeout,
35
+ )
36
+ if not multiline:
37
+ request["stop"] = "\n"
38
+
39
+ try:
40
+ response = client.fim.complete(**request)
41
+ except Exception as e:
42
+ logger.info(f"Codestral exception: {e}")
43
+ return []
44
+
45
+ if response.choices:
46
+ completion = response.choices[0].message.content
47
+ logger.info(f"Codestral completion: {completion}")
48
+ if completion:
49
+ return [{'completion' : completion, 'name': completion}]
50
+ else:
51
+ logger.info("Codestral completion: [empty]")
52
+ else:
53
+ logger.info("Codestral completion: [none]")
54
+
55
+ return []
56
+