diff --git a/FluentPython/core/config.py b/FluentPython/core/config.py index 1eb01f5..7067638 100644 --- a/FluentPython/core/config.py +++ b/FluentPython/core/config.py @@ -124,12 +124,20 @@ def list_versions(self) -> list[FluentPyVersion]: try: ver_config = VersionConfig.model_validate_json( ver_config_file.read_text("utf-8")) + except json.JSONDecodeError: logger.error( f"Invalid JSON in {ver_config_file}; skipping") corrupted = True break + except FileNotFoundError: + logger.error( + f"Version directory {version_dir} is missing fluentpy.json; skipping" + ) + corrupted = True + break + ver_name = ver_config.name ver_interp = Path(ver_config.interpreter) if not ver_interp.is_file(): diff --git a/FluentPython/gui/console.py b/FluentPython/gui/console.py index 7730584..c23852a 100644 --- a/FluentPython/gui/console.py +++ b/FluentPython/gui/console.py @@ -2,37 +2,38 @@ import os import subprocess import threading +import time from datetime import datetime from loguru import logger -from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtCore import QEvent, QSize, Qt, Signal from PySide6.QtGui import QColor, QFont, QTextCursor from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QWidget from qfluentwidgets import FluentIcon as FIF -from qfluentwidgets import PushButton +from qfluentwidgets import InfoBar, InfoBarPosition, PushButton class ConsoleExecutionPage(QWidget): + terminalUpdated = Signal(str) - def __init__(self, cmd: list[str], parent=None): + def __init__(self, cmd: list[str], tipbar: str, parent=None): super().__init__(parent) self._parent = parent - self.setObjectName('PythonConsole') self.text_edit = QTextEdit(self) self.text_edit.setFont(QFont('Consolas', 12)) - # self.text_edit.setStyleSheet("background-color: black; color: white;") self.text_edit.setTextColor(QColor(255, 255, 255)) self.text_edit.setStyleSheet( "QTextEdit { background-color: rgb(45, 45, 45); border-radius: 8px; padding: 6px; }" ) self.text_edit.setReadOnly(True) - self.text_edit.setPlainText("[Runner] Not started yet") - self.status_label = QLabel("State: Idle...", self) + self.idle_text = "State: Ready, click 'Start' to run \"" + ( + tipbar or "").strip() + "\"" + self.status_label = QLabel(self.idle_text, self) self.status_label.setFont(QFont('MiSans', 14)) self.status_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) @@ -40,13 +41,10 @@ def __init__(self, cmd: list[str], parent=None): self.button_start = PushButton(FIF.PLAY, "Start", self) self.button_start.clicked.connect(self.start_program) - self.button_end = PushButton(FIF.PAUSE, "Stop ", self) + self.button_end = PushButton(FIF.PAUSE, "Stop", self) self.button_end.clicked.connect(self.stop_program) - self.buttons = [ - self.button_start, - self.button_end, - ] + self.buttons = [self.button_start, self.button_end] for button in self.buttons: button.setFont(QFont('MiSans', 10)) @@ -54,7 +52,6 @@ def __init__(self, cmd: list[str], parent=None): self.child = None self.stopping = False - self.cmd = cmd def updateStatus(self, status: str): @@ -82,7 +79,6 @@ def resizeEvent(self, event): self.reposition() def start_program(self, event): - # run subprocess: python -m pipenv run python adaptive_pipeline.py {config_json} self.child = subprocess.Popen( self.cmd, stderr=subprocess.STDOUT, @@ -102,13 +98,12 @@ def _(): self.updateTerminal(line) - if self.stopping: - self.updateStatus("Stopping...") - else: - self.updateStatus("Running...") + if not self.stopping: + self.updateStatus("Running") + self.updateTerminal( f"[Runner] Program stopped with code {self.child.returncode}") - self.updateStatus("Idle...") + self.updateStatus(self.idle_text) self.child = None self.stopping = False @@ -117,10 +112,33 @@ def _(): def stop_program(self, event): if self.child is not None: - if not self.stopping: - self.stopping = True - self.updateTerminal("Stopping program...") - self.child.terminate() + if self.stopping: + InfoBar.warning(title="请稍等片刻", + content="程序已经进入中止中的状态", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.TOP_RIGHT, + duration=1500, + parent=self.topLevelWidget()) + return + + self.stopping = True + self.updateStatus("Stopping...") + self.updateTerminal("Stopping program...") + InfoBar.info( + title='正在停止程序...', + content= + "有时 JupyterLab 会无法中止,您可以直接在左侧选择其他 Tab,开启新的 Session。(持续优化中)", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.TOP_RIGHT, + duration=1500, + parent=self.topLevelWidget()) + + self.child.terminate() + time.sleep(0.5) + self.child.kill() + self.child.wait() def updateTerminal(self, text: str): text = text.rstrip() + '\n' @@ -129,7 +147,10 @@ def updateTerminal(self, text: str): f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}] {text}") self.text_edit.moveCursor(QTextCursor.MoveOperation.End) - logger.debug(text) + # logger.debug(text) + + # Emit the terminalUpdated signal + self.terminalUpdated.emit(text) def __del__(self): if self.child is not None: diff --git a/FluentPython/gui/jupyter.py b/FluentPython/gui/jupyter.py index 3cf1d56..e704b01 100644 --- a/FluentPython/gui/jupyter.py +++ b/FluentPython/gui/jupyter.py @@ -1,15 +1,16 @@ +import socket import subprocess from dataclasses import dataclass from loguru import logger from PySide6.QtCore import QEvent, QSize, Qt -from PySide6.QtWidgets import (QFrame, QHBoxLayout, QLabel, QLineEdit, - QListWidget, QPushButton, QSizePolicy, - QVBoxLayout, QWidget) +from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QLabel, + QLineEdit, QListWidget, QPushButton, + QSizePolicy, QVBoxLayout, QWidget) from qfluentwidgets import Action, BodyLabel, CommandBar from qfluentwidgets import FluentIcon as FIF from qfluentwidgets import (FluentWindow, InfoBar, InfoBarPosition, LineEdit, - ListWidget, MessageBoxBase, PushButton, + ListWidget, MessageBox, MessageBoxBase, PushButton, SingleDirectionScrollArea, SubtitleLabel, TitleLabel, VBoxLayout, setFont) @@ -17,6 +18,18 @@ from FluentPython.gui.console import ConsoleExecutionPage +def select_first_unused_port_from(start_port: int): + p = start_port + + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(('localhost', p)) + return p + except OSError: + p += 1 + + class PageJupyter(QWidget): def __init__(self, parent=None): @@ -59,6 +72,8 @@ def __init__(self, parent=None): self.reload_versions() + self.clipboard = QApplication.clipboard() + def reload_versions(self): self.version_list.clear() @@ -111,17 +126,65 @@ def on_selecting_version(self, current, previous): jupyterLabBtn.clicked.connect(lambda: self.start_jupyter_lab(ver)) lo.addWidget(jupyterLabBtn) + colabBtn = PushButton("启动 Colab 本地运行时") + colabBtn.clicked.connect(lambda: self.start_colab(ver)) + lo.addWidget(colabBtn) + def start_jupyter_lab(self, ver: FluentPyVersion): - cmd = [ - ver.py_executable, "-m", "pip", "install", "jupyterlab", "--index", - "https://pypi.tuna.tsinghua.edu.cn/simple" - ] - logger.debug(f"Running command: {cmd}") - subprocess.check_output(cmd) + # test if jupyterlab is installed + installed = False + try: + subprocess.check_output( + [ver.py_executable, "-m", "jupyterlab", "--version"]) + logger.info("JupyterLab is already installed") + installed = True + except subprocess.CalledProcessError: + pass + + if not installed: + w = MessageBox("警告", "JupyterLab 未安装,是否现在安装?(确认后请等候一下,完成后对话框自动关闭)", + self.topLevelWidget()) + + if w.exec(): + InfoBar.info(title='安装中', + content="正在安装 JupyterLab,请等待...", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.BOTTOM_LEFT, + duration=100, + parent=self.topLevelWidget()) + + cmd = [ + ver.py_executable, "-m", "pip", "install", "jupyterlab", + "--index", "https://pypi.tuna.tsinghua.edu.cn/simple" + ] + logger.debug(f"Running command: {cmd}") + subprocess.check_output(cmd) + + InfoBar.success(title='安装成功', + content="JupyterLab 安装成功,将启动 Jupyter Lab...", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.BOTTOM_LEFT, + duration=1500, + parent=self.topLevelWidget()) + else: + InfoBar.info(title='已取消', + content="已取消安装,请手动安装 JupyterLab 后再试。", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.BOTTOM_LEFT, + duration=2000, + parent=self.topLevelWidget()) + return cmd = [ver.py_executable, "-m", "jupyter", "lab"] logger.debug(f"Running command: {cmd}") - win = ConsoleExecutionPage(cmd, parent=self.topLevelWidget()) + + win = ConsoleExecutionPage( + cmd, + tipbar=f"JupyterLab[{ver.name}, {'.'.join(map(str, ver.version))}]", + parent=self.topLevelWidget()) win.setObjectName("JupyterLab-tmp123") @@ -137,3 +200,100 @@ def cleanup(): tlw.navigationInterface.removeWidget(win.objectName()) tlw.stackedWidget.currentChanged.connect(lambda: cleanup()) + + def start_colab(self, ver: FluentPyVersion): + # test if jupyterlab is installed + installed = False + try: + subprocess.check_output( + [ver.py_executable, "-m", "jupyterlab", "--version"]) + logger.info("JupyterLab is already installed") + installed = True + except subprocess.CalledProcessError: + pass + + if not installed: + w = MessageBox("警告", "JupyterLab 未安装,是否现在安装?(确认后请等候一下,完成后对话框自动关闭)", + self.topLevelWidget()) + + if w.exec(): + InfoBar.info(title='安装中', + content="正在安装 JupyterLab,请等待...", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.BOTTOM_LEFT, + duration=100, + parent=self.topLevelWidget()) + + cmd = [ + ver.py_executable, "-m", "pip", "install", "jupyterlab", + "--index", "https://pypi.tuna.tsinghua.edu.cn/simple" + ] + logger.debug(f"Running command: {cmd}") + subprocess.check_output(cmd) + + InfoBar.success(title='安装成功', + content="JupyterLab 安装成功", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.BOTTOM_LEFT, + duration=1500, + parent=self.topLevelWidget()) + else: + InfoBar.info(title='已取消', + content="已取消安装,请手动安装 JupyterLab 后再试。", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.BOTTOM_LEFT, + duration=2000, + parent=self.topLevelWidget()) + return + + port = select_first_unused_port_from(8888) + + cmd = [ + ver.py_executable, "-m", "jupyter", "notebook", + "--NotebookApp.allow_origin='https://colab.research.google.com'", + "--port=8888", "--no-browser" + ] + logger.debug(f"Running command: {cmd}") + + win = ConsoleExecutionPage( + cmd, + tipbar=f"ColabRt[{ver.name}, {'.'.join(map(str, ver.version))}]", + parent=self.topLevelWidget()) + + win.setObjectName("ColabRt-tmp123") + + tlw = self.topLevelWidget() + assert isinstance(tlw, FluentWindow), "Invalid top level widget" + tlw.addSubInterface(win, FIF.CODE, "[TMP] Colab Local Runtime") + tlw.switchTo(win) + + tlw.stackedWidget.currentChanged.connect(lambda: cleanup()) + + win.updateTerminal("【提示】启动后,复制连接地址到 Google Colab 即可连接本地运行时。") + win.updateTerminal("【提示 2.0】运行时就绪后会出现一条通知,自动复制连接地址。提示出现后放心粘贴即可。") + + def _termhandler(data: str): + logger.debug(f"Terminal output: {data}".strip()) + if data.startswith("http://localhost:") and "?token=" in data: + data = data.replace("/tree", "/") + data = data.replace("/lab", "/") + self.clipboard.setText(data) + InfoBar.success(title='启动成功', + content="连接成功,连接地址已复制到剪贴板。", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.BOTTOM_LEFT, + duration=1500, + parent=self.topLevelWidget()) + win.updateTerminal("【提示】连接成功,连接地址已复制到剪贴板。") + + win.terminalUpdated.connect(_termhandler) + + def cleanup(): + win.stop_program(None) + + tlw.stackedWidget.view.removeWidget(win) + tlw.navigationInterface.removeWidget(win.objectName())