From b9ad8761b86a63383b24d14a15a6d7dac0bf050b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 4 Apr 2026 11:25:09 +0800 Subject: [PATCH 1/5] Update dev version * Add unit tests * Refactor * Add tab on bottom panel * Add runs panel on top --- .github/workflows/dev.yml | 39 ++++ .github/workflows/dev_python3_10.yml | 32 --- .github/workflows/dev_python3_11.yml | 32 --- .github/workflows/dev_python3_12.yml | 32 --- .github/workflows/stable.yml | 39 ++++ .github/workflows/stable_python3_10.yml | 32 --- .github/workflows/stable_python3_11.yml | 32 --- .github/workflows/stable_python3_12.yml | 32 --- .../mail_thunder_setting.py | 50 ++--- .../api_testka/api_testka_process.py | 21 +- .../auto_control/auto_control_process.py | 33 ++-- .../file_automation_process.py | 21 +- .../process_executor/file_runner_process.py | 10 +- .../load_density/load_density_process.py | 21 +- .../process_executor_utils.py | 11 +- .../python_task_process_manager.py | 96 ++++----- .../test_pioneer_process_manager.py | 71 +++---- .../web_runner/web_runner_process.py | 21 +- .../connect_gui/ssh/ssh_command_widget.py | 9 +- .../connect_gui/url/ai_code_review_gui.py | 14 +- pybreeze/pybreeze_ui/editor_main/main_ui.py | 11 +- .../code_review/code_review_thread.py | 2 +- .../code_review/cot_code_review_gui.py | 10 +- .../extend_ai_gui/skills/skills_send_gui.py | 22 ++- .../jupyter_lab_gui/jupyter_lab_thread.py | 28 ++- .../jupyter_lab_gui/jupyter_lab_widget.py | 3 +- .../api_testka_menu/build_api_testka_menu.py | 153 +++------------ .../build_autocontrol_menu.py | 183 ++++-------------- .../build_automation_file_menu.py | 138 +++---------- .../automation_menu_factory.py | 111 +++++++++++ .../build_load_density_menu.py | 153 +++------------ .../build_mail_thunder_menu.py | 93 ++------- .../web_runner_menu/build_webrunner_menu.py | 135 +++---------- .../show_code_window/code_window.py | 12 +- .../utils/file_process/get_dir_file_list.py | 12 +- pybreeze/utils/json_format/json_process.py | 4 +- pybreeze/utils/logging/logger.py | 27 +-- dev.toml => pyproject.toml | 4 +- requirements.txt | 12 +- stable.toml | 2 +- test/test_utils/__init__.py | 0 test/test_utils/test_exception_tags.py | 55 ++++++ test/test_utils/test_exceptions.py | 48 +++++ test/test_utils/test_get_dir_file_list.py | 79 ++++++++ test/test_utils/test_json_process.py | 58 ++++++ test/test_utils/test_jupyter_helpers.py | 24 +++ test/test_utils/test_logger.py | 47 +++++ test/test_utils/test_package_manager.py | 28 +++ test/test_utils/test_venv_path.py | 58 ++++++ 49 files changed, 1045 insertions(+), 1115 deletions(-) create mode 100644 .github/workflows/dev.yml delete mode 100644 .github/workflows/dev_python3_10.yml delete mode 100644 .github/workflows/dev_python3_11.yml delete mode 100644 .github/workflows/dev_python3_12.yml create mode 100644 .github/workflows/stable.yml delete mode 100644 .github/workflows/stable_python3_10.yml delete mode 100644 .github/workflows/stable_python3_11.yml delete mode 100644 .github/workflows/stable_python3_12.yml create mode 100644 pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py rename dev.toml => pyproject.toml (94%) create mode 100644 test/test_utils/__init__.py create mode 100644 test/test_utils/test_exception_tags.py create mode 100644 test/test_utils/test_exceptions.py create mode 100644 test/test_utils/test_get_dir_file_list.py create mode 100644 test/test_utils/test_json_process.py create mode 100644 test/test_utils/test_jupyter_helpers.py create mode 100644 test/test_utils/test_logger.py create mode 100644 test/test_utils/test_package_manager.py create mode 100644 test/test_utils/test_venv_path.py diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..a62192c --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,39 @@ +name: PyBreeze Dev + +on: + push: + branches: [ "dev" ] + pull_request: + branches: [ "dev" ] + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read + +jobs: + unit-tests: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Update pip wheel setuptools + run: python -m pip install --upgrade pip setuptools wheel + - name: Install dev dependencies + run: python -m pip install -r dev_requirements.txt + - name: Install pytest + run: python -m pip install pytest + - name: Run unit tests (pytest) + run: python -m pytest test/test_utils/ -v --tb=short + - name: Run AutomationEditor With Debug Mode + run: python ./test/unit_test/start_automation/start_automation_test.py + - name: Extend AutomationEditor + run: python ./test/unit_test/start_automation/extend_automation_test.py diff --git a/.github/workflows/dev_python3_10.yml b/.github/workflows/dev_python3_10.yml deleted file mode 100644 index 53a987c..0000000 --- a/.github/workflows/dev_python3_10.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: AutomationEditor Dev Python3.10 - -on: - push: - branches: [ "dev" ] - pull_request: - branches: [ "dev" ] - schedule: - - cron: "0 2 * * *" - -permissions: - contents: read - -jobs: - build_dev_version: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Update pip wheel setuptools - run: python -m pip install --upgrade --user pip setuptools wheel - - name: Run pip dev_requirements.txt - run: python -m pip install --user -r dev_requirements.txt - - name: Run AutomationEditor With Debug Mode - run: python ./test/unit_test/start_automation/start_automation_test.py - - name: Extend AutomationEditor - run: python ./test/unit_test/start_automation/extend_automation_test.py - diff --git a/.github/workflows/dev_python3_11.yml b/.github/workflows/dev_python3_11.yml deleted file mode 100644 index b3ea215..0000000 --- a/.github/workflows/dev_python3_11.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: AutomationEditor Dev Python3.11 - -on: - push: - branches: [ "dev" ] - pull_request: - branches: [ "dev" ] - schedule: - - cron: "0 2 * * *" - -permissions: - contents: read - -jobs: - build_dev_version: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: "3.11" - - name: Update pip wheel setuptools - run: python -m pip install --upgrade --user pip setuptools wheel - - name: Run pip dev_requirements.txt - run: python -m pip install --user -r dev_requirements.txt - - name: Run AutomationEditor With Debug Mode - run: python ./test/unit_test/start_automation/start_automation_test.py - - name: Extend AutomationEditor - run: python ./test/unit_test/start_automation/extend_automation_test.py - diff --git a/.github/workflows/dev_python3_12.yml b/.github/workflows/dev_python3_12.yml deleted file mode 100644 index a4add06..0000000 --- a/.github/workflows/dev_python3_12.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: AutomationEditor Dev Python3.12 - -on: - push: - branches: [ "dev" ] - pull_request: - branches: [ "dev" ] - schedule: - - cron: "0 2 * * *" - -permissions: - contents: read - -jobs: - build_dev_version: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.12 - uses: actions/setup-python@v3 - with: - python-version: "3.12" - - name: Update pip wheel setuptools - run: python -m pip install --upgrade --user pip setuptools wheel - - name: Run pip dev_requirements.txt - run: python -m pip install --user -r dev_requirements.txt - - name: Run AutomationEditor With Debug Mode - run: python ./test/unit_test/start_automation/start_automation_test.py - - name: Extend AutomationEditor - run: python ./test/unit_test/start_automation/extend_automation_test.py - diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml new file mode 100644 index 0000000..49ce283 --- /dev/null +++ b/.github/workflows/stable.yml @@ -0,0 +1,39 @@ +name: PyBreeze Stable + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read + +jobs: + unit-tests: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Update pip wheel setuptools + run: python -m pip install --upgrade pip setuptools wheel + - name: Install dependencies + run: python -m pip install -r requirements.txt + - name: Install pytest + run: python -m pip install pytest + - name: Run unit tests (pytest) + run: python -m pytest test/test_utils/ -v --tb=short + - name: Run AutomationEditor With Debug Mode + run: python ./test/unit_test/start_automation/start_automation_test.py + - name: Extend AutomationEditor + run: python ./test/unit_test/start_automation/extend_automation_test.py diff --git a/.github/workflows/stable_python3_10.yml b/.github/workflows/stable_python3_10.yml deleted file mode 100644 index 9fae812..0000000 --- a/.github/workflows/stable_python3_10.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: AutomationEditor Stable Python3.10 - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: "0 2 * * *" - -permissions: - contents: read - -jobs: - build_stable_version: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Update pip wheel setuptools - run: python -m pip install --upgrade --user pip setuptools wheel - - name: Run pip requirements.txt - run: python -m pip install --user -r requirements.txt - - name: Run AutomationEditor With Debug Mode - run: python ./test/unit_test/start_automation/start_automation_test.py - - name: Extend AutomationEditor - run: python ./test/unit_test/start_automation/extend_automation_test.py - diff --git a/.github/workflows/stable_python3_11.yml b/.github/workflows/stable_python3_11.yml deleted file mode 100644 index 002b665..0000000 --- a/.github/workflows/stable_python3_11.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: AutomationEditor Stable Python3.11 - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: "0 2 * * *" - -permissions: - contents: read - -jobs: - build_stable_version: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: "3.11" - - name: Update pip wheel setuptools - run: python -m pip install --upgrade --user pip setuptools wheel - - name: Run pip requirements.txt - run: python -m pip install --user -r requirements.txt - - name: Run AutomationEditor With Debug Mode - run: python ./test/unit_test/start_automation/start_automation_test.py - - name: Extend AutomationEditor - run: python ./test/unit_test/start_automation/extend_automation_test.py - diff --git a/.github/workflows/stable_python3_12.yml b/.github/workflows/stable_python3_12.yml deleted file mode 100644 index 442eb1b..0000000 --- a/.github/workflows/stable_python3_12.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: AutomationEditor Stable Python3.12 - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: "0 2 * * *" - -permissions: - contents: read - -jobs: - build_stable_version: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.12 - uses: actions/setup-python@v3 - with: - python-version: "3.12" - - name: Update pip wheel setuptools - run: python -m pip install --upgrade --user pip setuptools wheel - - name: Run pip requirements.txt - run: python -m pip install --user -r requirements.txt - - name: Run AutomationEditor With Debug Mode - run: python ./test/unit_test/start_automation/start_automation_test.py - - name: Extend AutomationEditor - run: python ./test/unit_test/start_automation/extend_automation_test.py - diff --git a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py index a144208..9ddf7a1 100644 --- a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py +++ b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py @@ -1,38 +1,40 @@ from __future__ import annotations -import sys +import os from email.mime.multipart import MIMEMultipart from pybreeze.utils.exception.exception_tags import send_html_exception_tag from pybreeze.utils.exception.exceptions import ITESendHtmlReportException +from pybreeze.utils.logging.logger import pybreeze_logger def send_after_test(html_report_path: str | None = None) -> None: try: from je_mail_thunder import SMTPWrapper mail_thunder_smtp: SMTPWrapper = SMTPWrapper() - if html_report_path is None and mail_thunder_smtp.login_state: - user: str = mail_thunder_smtp.user - with open("default_name.html") as file: - html_string: str = file.read() - message = mail_thunder_smtp.create_message_with_attach( - html_string, - {"Subject": "Test Report", "To": user, "From": user}, - "default_name.html", use_html=True) - mail_thunder_smtp.send_message(message) - mail_thunder_smtp.quit() - elif mail_thunder_smtp.login_state: - user: str = mail_thunder_smtp.user - with open(html_report_path) as file: - html_string: str = file.read() - message: MIMEMultipart = mail_thunder_smtp.create_message_with_attach( - html_string, - {"Subject": "Test Report", "To": user, "From": user}, - html_report_path, use_html=True) - mail_thunder_smtp.send_message(message) - mail_thunder_smtp.quit() - else: + + if not mail_thunder_smtp.login_state: raise ITESendHtmlReportException + + # Determine which report file to use + report_path = html_report_path if html_report_path is not None else "default_name.html" + + if not os.path.isfile(report_path): + pybreeze_logger.error(f"Report file not found: {report_path}") + return + + user: str = mail_thunder_smtp.user + with open(report_path, encoding="utf-8") as file: + html_string: str = file.read() + message: MIMEMultipart = mail_thunder_smtp.create_message_with_attach( + html_string, + {"Subject": "Test Report", "To": user, "From": user}, + report_path, use_html=True) + mail_thunder_smtp.send_message(message) + mail_thunder_smtp.quit() except ITESendHtmlReportException as error: - print(repr(error), file=sys.stderr) - print(send_html_exception_tag, file=sys.stderr) + pybreeze_logger.error(f"{repr(error)} {send_html_exception_tag}") + except FileNotFoundError as error: + pybreeze_logger.error(f"Report file not found: {error}") + except Exception as error: + pybreeze_logger.error(f"Failed to send report: {error}") diff --git a/pybreeze/extend/process_executor/api_testka/api_testka_process.py b/pybreeze/extend/process_executor/api_testka/api_testka_process.py index f4a8fac..3d2af9d 100644 --- a/pybreeze/extend/process_executor/api_testka/api_testka_process.py +++ b/pybreeze/extend/process_executor/api_testka/api_testka_process.py @@ -6,9 +6,9 @@ if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys from pybreeze.utils.file_process.get_dir_file_list import ask_and_get_dir_files_as_list +from pybreeze.utils.logging.logger import pybreeze_logger def call_api_testka( @@ -32,18 +32,17 @@ def call_api_testka_multi_file( program_buffer: int = 1024000 ): try: - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None \ - and isinstance(need_to_execute_list, list) and len(need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_api_testka( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"api_testka multi file error: {error}") def call_api_testka_multi_file_and_send( @@ -51,16 +50,14 @@ def call_api_testka_multi_file_and_send( program_buffer: int = 1024000 ): try: - - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None \ - and isinstance(need_to_execute_list, list) and len(need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_api_testka_with_send( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"api_testka multi file and send error: {error}") diff --git a/pybreeze/extend/process_executor/auto_control/auto_control_process.py b/pybreeze/extend/process_executor/auto_control/auto_control_process.py index 9683c59..ecb9028 100644 --- a/pybreeze/extend/process_executor/auto_control/auto_control_process.py +++ b/pybreeze/extend/process_executor/auto_control/auto_control_process.py @@ -6,9 +6,9 @@ if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys from pybreeze.utils.file_process.get_dir_file_list import ask_and_get_dir_files_as_list +from pybreeze.utils.logging.logger import pybreeze_logger def call_auto_control( @@ -31,16 +31,18 @@ def call_auto_control_multi_file( main_window: PyBreezeMainWindow, program_buffer: int = 1024000 ): - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None \ - and isinstance(need_to_execute_list, list) and len(need_to_execute_list) > 0: - for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: - call_auto_control( - main_window, - test_script_json.read(), - program_buffer - ) + try: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: + for execute_file in need_to_execute_list: + with open(execute_file, encoding="utf-8") as test_script_json: + call_auto_control( + main_window, + test_script_json.read(), + program_buffer + ) + except Exception as error: + pybreeze_logger.error(f"auto control multi file error: {error}") def call_auto_control_multi_file_and_send( @@ -48,15 +50,14 @@ def call_auto_control_multi_file_and_send( program_buffer: int = 1024000 ): try: - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None \ - and isinstance(need_to_execute_list, list) and len(need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_auto_control_with_send( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"auto control multi file and send error: {error}") diff --git a/pybreeze/extend/process_executor/file_automation/file_automation_process.py b/pybreeze/extend/process_executor/file_automation/file_automation_process.py index 3635b5b..92fe6db 100644 --- a/pybreeze/extend/process_executor/file_automation/file_automation_process.py +++ b/pybreeze/extend/process_executor/file_automation/file_automation_process.py @@ -6,9 +6,9 @@ if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys from pybreeze.utils.file_process.get_dir_file_list import ask_and_get_dir_files_as_list +from pybreeze.utils.logging.logger import pybreeze_logger def call_file_automation_test( @@ -32,18 +32,17 @@ def call_file_automation_test_multi_file( program_buffer: int = 1024000 ): try: - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None \ - and isinstance(need_to_execute_list, list) and len(need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_file_automation_test( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"file automation multi file error: {error}") def call_file_automation_test_multi_file_and_send( @@ -51,16 +50,14 @@ def call_file_automation_test_multi_file_and_send( program_buffer: int = 1024000 ): try: - - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None \ - and isinstance(need_to_execute_list, list) and len(need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_file_automation_test_with_send( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"file automation multi file and send error: {error}") diff --git a/pybreeze/extend/process_executor/file_runner_process.py b/pybreeze/extend/process_executor/file_runner_process.py index 6d913fb..67f05e3 100644 --- a/pybreeze/extend/process_executor/file_runner_process.py +++ b/pybreeze/extend/process_executor/file_runner_process.py @@ -127,7 +127,7 @@ def _start_process(self, command: list[str], cleanup_binary: str | None = None) self._stderr_thread.start() self.main_window.show() - self.timer = QTimer() + self.timer = QTimer(self.main_window) self.timer.setInterval(50) self.timer.timeout.connect(self._pull_text) self.timer.start() @@ -163,6 +163,14 @@ def _finish(self) -> None: if self.timer and self.timer.isActive(): self.timer.stop() + # Wait for reader threads to finish + if self._stdout_thread is not None: + self._stdout_thread.join(timeout=2) + self._stdout_thread = None + if self._stderr_thread is not None: + self._stderr_thread.join(timeout=2) + self._stderr_thread = None + # Drain remaining output directly (not via _pull_text to avoid recursion) self._drain_queues() diff --git a/pybreeze/extend/process_executor/load_density/load_density_process.py b/pybreeze/extend/process_executor/load_density/load_density_process.py index e172f4a..2d2a04a 100644 --- a/pybreeze/extend/process_executor/load_density/load_density_process.py +++ b/pybreeze/extend/process_executor/load_density/load_density_process.py @@ -6,9 +6,9 @@ if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys from pybreeze.utils.file_process.get_dir_file_list import ask_and_get_dir_files_as_list +from pybreeze.utils.logging.logger import pybreeze_logger def call_load_density( @@ -32,18 +32,17 @@ def call_load_density_multi_file( program_buffer: int = 1024000 ): try: - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None and isinstance(need_to_execute_list, list) and len( - need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_load_density( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"load density multi file error: {error}") def call_load_density_multi_file_and_send( @@ -51,16 +50,14 @@ def call_load_density_multi_file_and_send( program_buffer: int = 1024000 ): try: - - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None and isinstance(need_to_execute_list, list) and len( - need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_load_density_with_send( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"load density multi file and send error: {error}") diff --git a/pybreeze/extend/process_executor/process_executor_utils.py b/pybreeze/extend/process_executor/process_executor_utils.py index b512b31..c968ca6 100644 --- a/pybreeze/extend/process_executor/process_executor_utils.py +++ b/pybreeze/extend/process_executor/process_executor_utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import sys from typing import TYPE_CHECKING from je_editor import EditorWidget @@ -11,6 +10,7 @@ from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager from pybreeze.utils.exception.exception_tags import wrong_test_data_format_exception_tag from pybreeze.utils.exception.exceptions import ITETestExecutorException +from pybreeze.utils.logging.logger import pybreeze_logger if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -31,14 +31,9 @@ def build_process( test_format_code = exec_str start_process(main_window, package, test_format_code, send_mail, program_buffer) except json.decoder.JSONDecodeError as error: - print( - repr(error) + - "\n" - + wrong_test_data_format_exception_tag, - file=sys.stderr - ) + pybreeze_logger.error(f"{repr(error)}\n{wrong_test_data_format_exception_tag}") except ITETestExecutorException as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(repr(error)) def start_process( diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 80ca3c2..2e4e5f8 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import queue import subprocess import sys @@ -9,13 +10,32 @@ from queue import Queue from threading import Thread - from PySide6.QtCore import QTimer from PySide6.QtGui import QTextCharFormat from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from je_editor.utils.venv_check.check_venv import check_and_choose_venv from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow +from pybreeze.utils.logging.logger import pybreeze_logger + + +def find_venv_path() -> Path: + """Find virtual environment path, checking multiple common locations.""" + if sys.platform in ["win32", "cygwin", "msys"]: + candidates = [ + Path.cwd() / "venv" / "Scripts", + Path.cwd() / ".venv" / "Scripts", + ] + else: + candidates = [ + Path.cwd() / "venv" / "bin", + Path.cwd() / ".venv" / "bin", + ] + for path in candidates: + if path.exists(): + return path + # Fallback to first candidate + return candidates[0] class TaskProcessManager: @@ -46,11 +66,7 @@ def __init__( def renew_path(self) -> None: if self.main_window.python_compiler is None: - # Renew compiler path - if sys.platform in ["win32", "cygwin", "msys"]: - venv_path = Path(str(Path.cwd()) + "/venv/Scripts") - else: - venv_path = Path(str(Path.cwd()) + "/venv/bin") + venv_path = find_venv_path() self.compiler_path = check_and_choose_venv(venv_path) else: self.compiler_path = self.main_window.python_compiler @@ -58,7 +74,7 @@ def renew_path(self) -> None: def start_test_process(self, package: str, exec_str: str): self.renew_path() if sys.platform in ["win32", "cygwin", "msys"]: - exec_str = __import__("json").dumps(exec_str) + exec_str = json.dumps(exec_str) args = [ str(self.compiler_path), "-m", @@ -89,44 +105,39 @@ def start_test_process(self, package: str, exec_str: str): # start timer self.main_window.setWindowTitle(package) self.main_window.show() - self.timer = QTimer() + self.timer = QTimer(self.main_window) self.timer.setInterval(100) self.timer.timeout.connect(self.pull_text) self.timer.start() + def _append_text(self, text: str, is_error: bool = False) -> None: + """Append text to the code result widget.""" + text_cursor = self.main_window.code_result.textCursor() + text_format = QTextCharFormat() + color_key = "error_output_color" if is_error else "normal_output_color" + text_format.setForeground(actually_color_dict.get(color_key)) + text_cursor.insertText(text, text_format) + text_cursor.insertBlock() + # Pyside UI update method def pull_text(self): try: if not self.run_output_queue.empty(): - output_message = self.run_output_queue.get_nowait() - output_message = str(output_message).strip() + output_message = str(self.run_output_queue.get_nowait()).strip() if output_message: - text_cursor = self.main_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("normal_output_color")) - text_cursor.insertText(output_message, text_format) - text_cursor.insertBlock() + self._append_text(output_message) if not self.run_error_queue.empty(): - error_message = self.run_error_queue.get_nowait() - error_message = str(error_message).strip() + error_message = str(self.run_error_queue.get_nowait()).strip() if error_message: - text_cursor = self.main_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("error_output_color")) - text_cursor.insertText(error_message, text_format) - text_cursor.insertBlock() + self._append_text(error_message, is_error=True) except queue.Empty: pass if self.process is not None: - if self.process.returncode == 0: - if self.timer.isActive(): - self.timer.stop() - self.exit_program() - elif self.process.returncode is not None: + if self.process.returncode is not None: if self.timer.isActive(): self.timer.stop() self.exit_program() - if self.still_run_program: + elif self.still_run_program: # poll return code self.process.poll() else: @@ -136,48 +147,37 @@ def pull_text(self): # exit program change run flag to false and clean read thread and queue and process def exit_program(self): self.still_run_program = False + # Wait for threads to finish before cleanup if self.read_program_output_from_thread is not None: + self.read_program_output_from_thread.join(timeout=2) self.read_program_output_from_thread = None if self.read_program_error_output_from_thread is not None: + self.read_program_error_output_from_thread.join(timeout=2) self.read_program_error_output_from_thread = None self.drain_and_display_queue() if self.process is not None: self.process.terminate() - text_cursor = self.main_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("normal_output_color")) - text_cursor.insertText(f"Task exit with code {self.process.returncode}", text_format) - text_cursor.insertBlock() + self._append_text(f"Task exit with code {self.process.returncode}") self.process = None if self.task_done_trigger_function is not None: try: self.task_done_trigger_function() except Exception as e: - print(repr(e), file=sys.stderr) + pybreeze_logger.error(f"Task done trigger failed: {e}") def drain_and_display_queue(self): while not self.run_output_queue.empty(): try: - output_message = self.run_output_queue.get_nowait() - output_message = str(output_message).strip() + output_message = str(self.run_output_queue.get_nowait()).strip() if output_message: - text_cursor = self.main_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("normal_output_color")) - text_cursor.insertText(output_message, text_format) - text_cursor.insertBlock() + self._append_text(output_message) except queue.Empty: break while not self.run_error_queue.empty(): try: - error_message = self.run_error_queue.get_nowait() - error_message = str(error_message).strip() + error_message = str(self.run_error_queue.get_nowait()).strip() if error_message: - text_cursor = self.main_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("error_output_color")) - text_cursor.insertText(error_message, text_format) - text_cursor.insertBlock() + self._append_text(error_message, is_error=True) except queue.Empty: break diff --git a/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py b/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py index 68f88eb..cbc5399 100644 --- a/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py +++ b/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py @@ -15,6 +15,7 @@ from je_editor.utils.venv_check.check_venv import check_and_choose_venv from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow +from pybreeze.extend.process_executor.python_task_process_manager import find_venv_path if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -44,11 +45,7 @@ def __init__( self._read_program_output_from_thread: threading.Thread | None = None self._timer: QTimer = QTimer(self._code_window) if self._main_window.python_compiler is None: - # Renew compiler path - if sys.platform in ["win32", "cygwin", "msys"]: - venv_path = Path(str(Path.cwd()) + "/venv/Scripts") - else: - venv_path = Path(str(Path.cwd()) + "/venv/bin") + venv_path = find_venv_path() self._compiler_path = check_and_choose_venv(venv_path) else: self._compiler_path = main_window.python_compiler @@ -66,39 +63,34 @@ def __init__( stderr=subprocess.PIPE, ) + def _append_text(self, text: str, is_error: bool = False) -> None: + """Append text to the code result widget.""" + text_cursor = self._code_window.code_result.textCursor() + text_format = QTextCharFormat() + color_key = "error_output_color" if is_error else "normal_output_color" + text_format.setForeground(actually_color_dict.get(color_key)) + text_cursor.insertText(text, text_format) + text_cursor.insertBlock() + # Pyside UI update method def pull_text(self): try: if not self._run_output_queue.empty(): - output_message = self._run_output_queue.get_nowait() - output_message = str(output_message).strip() + output_message = str(self._run_output_queue.get_nowait()).strip() if output_message: - text_cursor = self._code_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("normal_output_color")) - text_cursor.insertText(output_message, text_format) - text_cursor.insertBlock() + self._append_text(output_message) if not self._run_error_queue.empty(): - error_message = self._run_error_queue.get_nowait() - error_message = str(error_message).strip() + error_message = str(self._run_error_queue.get_nowait()).strip() if error_message: - text_cursor = self._code_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("error_output_color")) - text_cursor.insertText(error_message, text_format) - text_cursor.insertBlock() + self._append_text(error_message, is_error=True) except queue.Empty: pass if self._process is not None: - if self._process.returncode == 0: - if self._timer.isActive(): - self._timer.stop() - self.exit_program() - elif self._process.returncode is not None: + if self._process.returncode is not None: if self._timer.isActive(): self._timer.stop() self.exit_program() - if self._still_run_program: + elif self._still_run_program: # poll return code self._process.poll() else: @@ -108,43 +100,32 @@ def pull_text(self): # exit program change run flag to false and clean read thread and queue and process def exit_program(self): self._still_run_program = False + # Wait for threads to finish before cleanup if self._read_program_output_from_thread is not None: + self._read_program_output_from_thread.join(timeout=2) self._read_program_output_from_thread = None if self._read_program_error_output_from_thread is not None: + self._read_program_error_output_from_thread.join(timeout=2) self._read_program_error_output_from_thread = None self.drain_and_clear_queue() if self._process is not None: self._process.terminate() - text_cursor = self._code_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("normal_output_color")) - text_cursor.insertText(f"Task exit with code {self._process.returncode}", text_format) - text_cursor.insertBlock() + self._append_text(f"Task exit with code {self._process.returncode}") self._process = None def drain_and_clear_queue(self): while not self._run_output_queue.empty(): try: - output_message = self._run_output_queue.get_nowait() - output_message = str(output_message).strip() + output_message = str(self._run_output_queue.get_nowait()).strip() if output_message: - text_cursor = self._code_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("normal_output_color")) - text_cursor.insertText(output_message, text_format) - text_cursor.insertBlock() + self._append_text(output_message) except queue.Empty: break while not self._run_error_queue.empty(): try: - error_message = self._run_error_queue.get_nowait() - error_message = str(error_message).strip() + error_message = str(self._run_error_queue.get_nowait()).strip() if error_message: - text_cursor = self._code_window.code_result.textCursor() - text_format = QTextCharFormat() - text_format.setForeground(actually_color_dict.get("error_output_color")) - text_cursor.insertText(error_message, text_format) - text_cursor.insertBlock() + self._append_text(error_message, is_error=True) except queue.Empty: break @@ -188,7 +169,7 @@ def start_test_pioneer_process(self): # start timer self._code_window.setWindowTitle("Test Pioneer") self._code_window.show() - self._timer = QTimer() + self._timer = QTimer(self._code_window) self._timer.setInterval(100) self._timer.timeout.connect(self.pull_text) self._timer.start() diff --git a/pybreeze/extend/process_executor/web_runner/web_runner_process.py b/pybreeze/extend/process_executor/web_runner/web_runner_process.py index 69a27e2..3285c59 100644 --- a/pybreeze/extend/process_executor/web_runner/web_runner_process.py +++ b/pybreeze/extend/process_executor/web_runner/web_runner_process.py @@ -6,9 +6,9 @@ if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys from pybreeze.utils.file_process.get_dir_file_list import ask_and_get_dir_files_as_list +from pybreeze.utils.logging.logger import pybreeze_logger def call_web_runner_test( @@ -32,18 +32,17 @@ def call_web_runner_test_multi_file( program_buffer: int = 1024000 ): try: - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None and isinstance(need_to_execute_list, list) and len( - need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_web_runner_test( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"web runner multi file error: {error}") def call_web_runner_test_multi_file_and_send( @@ -51,16 +50,14 @@ def call_web_runner_test_multi_file_and_send( program_buffer: int = 1024000 ): try: - - need_to_execute_list: list = ask_and_get_dir_files_as_list(main_window) - if need_to_execute_list is not None and isinstance(need_to_execute_list, list) and len( - need_to_execute_list) > 0: + need_to_execute_list = ask_and_get_dir_files_as_list(main_window) + if need_to_execute_list is not None and len(need_to_execute_list) > 0: for execute_file in need_to_execute_list: - with open(execute_file) as test_script_json: + with open(execute_file, encoding="utf-8") as test_script_json: call_web_runner_test_with_send( main_window, test_script_json.read(), program_buffer ) except Exception as error: - print(repr(error), file=sys.stderr) + pybreeze_logger.error(f"web runner multi file and send error: {error}") diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py index deae551..c6e041c 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -11,6 +11,7 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget +from pybreeze.utils.logging.logger import pybreeze_logger ANSI_ESCAPE_PATTERN = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') @@ -152,7 +153,7 @@ def connect_ssh(self): pkey = KeyType.from_private_key_file(key_path, password if password else None) break except Exception as error: - print(error) + pybreeze_logger.debug(f"SSH key type failed: {error}") continue if pkey is None: raise ValueError( @@ -228,19 +229,19 @@ def _cleanup(self): self.reader_thread.stop() self.reader_thread.wait(1000) except Exception as error: - print(error) + pybreeze_logger.debug(f"SSH reader thread cleanup: {error}") self.reader_thread = None try: if self.shell_channel and not self.shell_channel.closed: self.shell_channel.close() except Exception as error: - print(error) + pybreeze_logger.debug(f"SSH channel cleanup: {error}") self.shell_channel = None try: if self.ssh_client: self.ssh_client.close() except Exception as error: - print(error) + pybreeze_logger.debug(f"SSH client cleanup: {error}") self.ssh_client = None diff --git a/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py b/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py index 1e4804b..72152e4 100644 --- a/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py +++ b/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py @@ -19,8 +19,10 @@ def __init__(self): # 記錄接受/拒絕次數 self.accept_count = 0 self.reject_count = 0 - self.stats_file = "response_stats.txt" - self.url_file = "urls.txt" + data_dir = os.path.join(os.getcwd(), ".pybreeze") + os.makedirs(data_dir, exist_ok=True) + self.stats_file = os.path.join(data_dir, "response_stats.txt") + self.url_file = os.path.join(data_dir, "urls.txt") # 主佈局 (垂直) main_layout = QVBoxLayout() @@ -131,13 +133,13 @@ def send_request(self): try: if method == "GET": - response = requests.get(url) + response = requests.get(url, timeout=30) elif method == "POST": - response = requests.post(url, data={"code": code_content}) + response = requests.post(url, data={"code": code_content}, timeout=30) elif method == "PUT": - response = requests.put(url, data={"code": code_content}) + response = requests.put(url, data={"code": code_content}, timeout=30) elif method == "DELETE": - response = requests.delete(url) + response = requests.delete(url, timeout=30) else: self.response_panel.setPlainText( self.word_dict.get("ai_code_review_gui_message_unsupported_http_method")) diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index e6d7479..3e381e0 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -53,7 +53,7 @@ def __init__(self, debug_mode: bool = False, show_system_tray_ray: bool = False, # Icon if not extend: - self.icon_path = Path(os.getcwd() + "/pybreeze_icon.ico") + self.icon_path = Path(os.getcwd()) / "pybreeze_icon.ico" self.icon = QIcon(str(self.icon_path)) if not self.icon.isNull(): self.setWindowIcon(self.icon) @@ -86,19 +86,22 @@ def debug_close(cls) -> None: sys.exit(0) -def start_editor(debug_mode: bool = False, **kwargs) -> None: +def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwargs) -> None: """ Start editor instance + :param debug_mode: enable debug mode with auto-close timer + :param theme: qt_material theme name (e.g. "dark_amber.xml", "dark_teal.xml", "light_blue.xml") :return: None """ new_ide = QCoreApplication.instance() if new_ide is None: new_ide = QApplication(sys.argv) window = PyBreezeMainWindow(debug_mode=debug_mode, **kwargs) - apply_stylesheet(new_ide, theme="dark_amber.xml") + apply_stylesheet(new_ide, theme=theme) window.showMaximized() try: window.startup_setting() except Exception as error: - print(repr(error)) + from pybreeze.utils.logging.logger import pybreeze_logger + pybreeze_logger.error(f"Startup setting error: {error}") sys.exit(new_ide.exec()) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py index 43f3440..d935445 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py @@ -61,7 +61,7 @@ def run(self): try: # 傳送到指定 URL - resp = requests.post(self.url, json={"prompt": prompt}) + resp = requests.post(self.url, json={"prompt": prompt}, timeout=60) reply_text = resp.text match file: case "first_summary_prompt.md": diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py index 49b0aca..f1e6861 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py @@ -1,3 +1,5 @@ +from urllib.parse import urlparse + from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QComboBox, QPushButton, \ QMessageBox from je_editor import language_wrapper @@ -65,9 +67,11 @@ def start_sending(self): # 取得 URL url = self.url_input.text().strip() if not url: - message_box = QMessageBox() - message_box.warning(self, "Warning", language_wrapper.language_word_dict.get("cot_gui_error_no_url")) - message_box.exec_() + QMessageBox.warning(self, "Warning", language_wrapper.language_word_dict.get("cot_gui_error_no_url")) + return + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + QMessageBox.warning(self, "Warning", language_wrapper.language_word_dict.get("cot_gui_error_no_url")) return # 啟動傳送 Thread diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py index 357e032..2e6cc76 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py @@ -20,9 +20,27 @@ def __init__(self, api_url, code_text): def run(self): try: - response = requests.post(self.api_url, json={"code": self.code_text}) - if response.status_code == 200: + response = requests.post(self.api_url, json={"code": self.code_text}, timeout=30) + if response.ok: self.finished.emit(response.text) + elif response.is_redirect: + self.finished.emit( + language_wrapper.language_word_dict.get( + "skills_error_status").format( + status_code=response.status_code, + text=f"Redirect to {response.headers.get('Location', 'unknown')}")) + elif response.status_code == 401 or response.status_code == 403: + self.error.emit( + language_wrapper.language_word_dict.get( + "skills_error_status").format( + status_code=response.status_code, + text="Authentication/Authorization failed")) + elif response.status_code >= 500: + self.error.emit( + language_wrapper.language_word_dict.get( + "skills_error_status").format( + status_code=response.status_code, + text=f"Server error: {response.text}")) else: self.finished.emit( language_wrapper.language_word_dict.get( diff --git a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py index ef21d06..7b945b1 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -10,8 +10,10 @@ from pybreeze.utils.logging.logger import pybreeze_logger +JUPYTER_STARTUP_TIMEOUT = 60 -def find_free_port(): + +def find_free_port() -> int: s = socket.socket() s.bind(("", 0)) port = s.getsockname()[1] @@ -19,12 +21,12 @@ def find_free_port(): return port -def get_venv_python(): - # 如果在 venv 中 +def get_venv_python() -> str: + # If already in a venv if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix): return sys.executable - # 嘗試從常見位置找 venv + # Try common venv locations if sys.platform in ["win32", "cygwin", "msys"]: possible_paths = [ os.path.join(os.getcwd(), "venv", "Scripts", "python.exe"), @@ -43,7 +45,7 @@ def get_venv_python(): raise RuntimeError("Cannot find venv python executable") -def is_jupyter_installed(python_exe): +def is_jupyter_installed(python_exe: str) -> bool: result = subprocess.run( [python_exe, "-m", "pip", "show", "jupyterlab"], capture_output=True, @@ -56,9 +58,10 @@ class JupyterLauncherThread(QThread): status_update = Signal(str) error_occurred = Signal(str) - def __init__(self, parent=None): + def __init__(self, parent=None, startup_timeout: int = JUPYTER_STARTUP_TIMEOUT): super().__init__(parent) self.process = None + self.startup_timeout = startup_timeout def run(self): try: @@ -98,8 +101,14 @@ def run(self): start_time = time.time() while True: - if time.time() - start_time > 30: - raise TimeoutError("JupyterLab 啟動超時") + elapsed = time.time() - start_time + if elapsed > self.startup_timeout: + raise TimeoutError( + f"JupyterLab startup timeout ({self.startup_timeout}s)") + + self.status_update.emit( + f"{language_wrapper.language_word_dict.get('jupyterlab_loading')} " + f"({int(elapsed)}s / {self.startup_timeout}s)") try: s = socket.create_connection(("localhost", port), timeout=0.5) @@ -112,9 +121,8 @@ def run(self): except Exception: err = traceback.format_exc() - print(err) self.error_occurred.emit(err) - pybreeze_logger.info(err) + pybreeze_logger.error(f"JupyterLab launch failed: {err}") def stop(self): if self.process is not None: diff --git a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py index 4babc75..0e668b0 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py @@ -43,8 +43,7 @@ def load_lab(self, url): def show_error(self, msg): self.status_label.setText(language_wrapper.language_word_dict.get("jupyterlab_init_failed")) - print(msg) - pybreeze_logger.info(msg) + pybreeze_logger.error(msg) def closeEvent(self, event): if self.thread.isRunning(): diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py index 80895a2..5c26d31 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py @@ -3,135 +3,42 @@ from typing import TYPE_CHECKING from je_api_testka.gui.main_widget import APITestkaWidget -from je_editor import language_wrapper -from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( + build_automation_menu, safe_create_project +) if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys -from PySide6.QtGui import QAction - -from pybreeze.extend.process_executor.api_testka.api_testka_process import call_api_testka, \ - call_api_testka_with_send, call_api_testka_multi_file, call_api_testka_multi_file_and_send +from pybreeze.extend.process_executor.api_testka.api_testka_process import ( + call_api_testka, call_api_testka_with_send, + call_api_testka_multi_file, call_api_testka_multi_file_and_send, +) def set_apitestka_menu(ui_we_want_to_set: PyBreezeMainWindow): - """ - Build menu include APITestka feature. - :param ui_we_want_to_set: main window to add menu. - :return: None - """ - ui_we_want_to_set.apitestka_menu = ui_we_want_to_set.automation_menu.addMenu( - language_wrapper.language_word_dict.get("apitestka_menu_label") - ) - ui_we_want_to_set.apitestka_run_menu = ui_we_want_to_set.apitestka_menu.addMenu( - language_wrapper.language_word_dict.get("run_label")) - # Run APITestka Script - ui_we_want_to_set.run_apitestka_action = QAction( - language_wrapper.language_word_dict.get("apitestka_run_script_label")) - ui_we_want_to_set.run_apitestka_action.triggered.connect( - lambda: call_api_testka( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.apitestka_run_menu.addAction(ui_we_want_to_set.run_apitestka_action) - # Run APITestka Script With Send - ui_we_want_to_set.run_apitestka_action_with_send = QAction( - language_wrapper.language_word_dict.get("apitestka_run_script_with_send_label")) - ui_we_want_to_set.run_apitestka_action_with_send.triggered.connect( - lambda: call_api_testka_with_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.apitestka_run_menu.addAction( - ui_we_want_to_set.run_apitestka_action_with_send - ) - # Run Multi APITestka Script - ui_we_want_to_set.run_multi_apitestka_action = QAction( - language_wrapper.language_word_dict.get("apitestka_run_multi_script_label")) - ui_we_want_to_set.run_multi_apitestka_action.triggered.connect( - lambda: call_api_testka_multi_file( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.apitestka_run_menu.addAction( - ui_we_want_to_set.run_multi_apitestka_action - ) - # Run Multi APITestka Script With Send - ui_we_want_to_set.run_multi_apitestka_action_with_send = QAction( - language_wrapper.language_word_dict.get("apitestka_run_multi_script_with_send_label")) - ui_we_want_to_set.run_multi_apitestka_action_with_send.triggered.connect( - lambda: call_api_testka_multi_file_and_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.apitestka_run_menu.addAction( - ui_we_want_to_set.run_multi_apitestka_action_with_send - ) - ui_we_want_to_set.apitestka_help_menu = ui_we_want_to_set.apitestka_menu.addMenu( - language_wrapper.language_word_dict.get("help_label")) - # Open Doc - ui_we_want_to_set.open_apitestka_doc_action = QAction( - language_wrapper.language_word_dict.get("apitestka_doc_label")) - ui_we_want_to_set.open_apitestka_doc_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://apitestka.readthedocs.io/en/latest/", - language_wrapper.language_word_dict.get("apitestka_doc_tab_label") - ) - ) - ui_we_want_to_set.apitestka_help_menu.addAction( - ui_we_want_to_set.open_apitestka_doc_action - ) - # Open Github - ui_we_want_to_set.open_apitestka_github_action = QAction( - language_wrapper.language_word_dict.get("apitestka_github_label")) - ui_we_want_to_set.open_apitestka_github_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://github.com/Intergration-Automation-Testing/APITestka", - language_wrapper.language_word_dict.get("apitestka_github_tab_label") - ) - ) - ui_we_want_to_set.apitestka_help_menu.addAction( - ui_we_want_to_set.open_apitestka_github_action - ) - ui_we_want_to_set.apitestka_project_menu = ui_we_want_to_set.apitestka_menu.addMenu( - language_wrapper.language_word_dict.get("project_label")) - # Create Project - ui_we_want_to_set.create_apitestka_project_action = QAction( - language_wrapper.language_word_dict.get("apitestka_create_project_label")) - ui_we_want_to_set.create_apitestka_project_action.triggered.connect( - create_project - ) - ui_we_want_to_set.apitestka_project_menu.addAction( - ui_we_want_to_set.create_apitestka_project_action - ) - # APITestka GUI - ui_we_want_to_set.api_testka_gui_action = QAction( - "APITestka GUI" - ) - ui_we_want_to_set.api_testka_gui_action.triggered.connect( - lambda: add_api_testka_gui(ui_we_want_to_set) - ) - ui_we_want_to_set.apitestka_menu.addAction( - ui_we_want_to_set.api_testka_gui_action - ) - - -def create_project() -> None: - try: - import je_api_testka - package = je_api_testka - if package is not None: - package.create_project_dir() - except ImportError as error: - print(repr(error), file=sys.stderr) - - -def add_api_testka_gui(ui_we_want_to_set: PyBreezeMainWindow) -> None: - ui_we_want_to_set.tab_widget.addTab( - APITestkaWidget(), "APITestka GUI" + build_automation_menu( + ui=ui_we_want_to_set, + menu_label_key="apitestka_menu_label", + run_actions=[ + {"label_key": "apitestka_run_script_label", + "callback": lambda: call_api_testka(ui_we_want_to_set)}, + {"label_key": "apitestka_run_script_with_send_label", + "callback": lambda: call_api_testka_with_send(ui_we_want_to_set)}, + {"label_key": "apitestka_run_multi_script_label", + "callback": lambda: call_api_testka_multi_file(ui_we_want_to_set)}, + {"label_key": "apitestka_run_multi_script_with_send_label", + "callback": lambda: call_api_testka_multi_file_and_send(ui_we_want_to_set)}, + ], + doc_url="https://apitestka.readthedocs.io/en/latest/", + doc_label_key="apitestka_doc_label", + doc_tab_label_key="apitestka_doc_tab_label", + github_url="https://github.com/Intergration-Automation-Testing/APITestka", + github_label_key="apitestka_github_label", + github_tab_label_key="apitestka_github_tab_label", + create_project_func=safe_create_project("je_api_testka"), + create_project_label_key="apitestka_create_project_label", + gui_widget_class=APITestkaWidget, + gui_label="APITestka GUI", ) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py index 20511fa..5e52f68 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py @@ -2,155 +2,62 @@ from typing import TYPE_CHECKING +import je_auto_control +from PySide6.QtGui import QAction, QTextCharFormat from je_auto_control.gui.main_widget import AutoControlGUIWidget from je_editor import EditorWidget, language_wrapper from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict -from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( + build_automation_menu, safe_create_project +) if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys - -import je_auto_control -from PySide6.QtGui import QAction, QTextCharFormat -from pybreeze.extend.process_executor.auto_control.auto_control_process import \ - call_auto_control, call_auto_control_with_send, call_auto_control_multi_file, \ - call_auto_control_multi_file_and_send +from pybreeze.extend.process_executor.auto_control.auto_control_process import ( + call_auto_control, call_auto_control_with_send, + call_auto_control_multi_file, call_auto_control_multi_file_and_send, +) def set_autocontrol_menu(ui_we_want_to_set: PyBreezeMainWindow): - """ - Build menu include AutoControl feature. - :param ui_we_want_to_set: main window to add menu. - :return: None - """ - ui_we_want_to_set.autocontrol_menu = ui_we_want_to_set.automation_menu.addMenu( - language_wrapper.language_word_dict.get("autocontrol_menu_label")) - ui_we_want_to_set.autocontrol_run_menu = ui_we_want_to_set.autocontrol_menu.addMenu( - language_wrapper.language_word_dict.get("run_label")) - # Run AutoControl Script - ui_we_want_to_set.run_autocontrol_action = QAction( - language_wrapper.language_word_dict.get("autocontrol_run_script_label")) - ui_we_want_to_set.run_autocontrol_action.triggered.connect( - lambda: call_auto_control( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.autocontrol_run_menu.addAction(ui_we_want_to_set.run_autocontrol_action) - # Run AutoControl Script With Send - ui_we_want_to_set.run_autocontrol_action_with_send = QAction( - language_wrapper.language_word_dict.get("autocontrol_run_script_with_send_label")) - ui_we_want_to_set.run_autocontrol_action_with_send.triggered.connect( - lambda: call_auto_control_with_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.autocontrol_run_menu.addAction( - ui_we_want_to_set.run_autocontrol_action_with_send - ) - # Run Multi AutoControl Script - ui_we_want_to_set.run_multi_autocontrol_action = QAction( - language_wrapper.language_word_dict.get("autocontrol_run_multi_script_label")) - ui_we_want_to_set.run_multi_autocontrol_action.triggered.connect( - lambda: call_auto_control_multi_file( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.autocontrol_run_menu.addAction( - ui_we_want_to_set.run_multi_autocontrol_action - ) - # Run Multi AutoControl Script With Send - ui_we_want_to_set.run_multi_autocontrol_action_with_send = QAction( - language_wrapper.language_word_dict.get("autocontrol_run_multi_script_with_send_label") - ) - ui_we_want_to_set.run_multi_autocontrol_action_with_send.triggered.connect( - lambda: call_auto_control_multi_file_and_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.autocontrol_run_menu.addAction( - ui_we_want_to_set.run_multi_autocontrol_action_with_send - ) - ui_we_want_to_set.autocontrol_help_menu = ui_we_want_to_set.autocontrol_menu.addMenu( - language_wrapper.language_word_dict.get("help_label")) - # Open Doc - ui_we_want_to_set.open_autocontrol_doc_action = QAction( - language_wrapper.language_word_dict.get("autocontrol_doc_label")) - ui_we_want_to_set.open_autocontrol_doc_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://autocontrol.readthedocs.io/en/latest/", - language_wrapper.language_word_dict.get("autocontrol_doc_tab_label") - ) - ) - ui_we_want_to_set.autocontrol_help_menu.addAction( - ui_we_want_to_set.open_autocontrol_doc_action - ) - # Open Github - ui_we_want_to_set.open_autocontrol_github_action = QAction( - language_wrapper.language_word_dict.get("autocontrol_github_label")) - ui_we_want_to_set.open_autocontrol_github_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://github.com/Intergration-Automation-Testing/AutoControl", - language_wrapper.language_word_dict.get("autocontrol_github_tab_label") - ) - ) - ui_we_want_to_set.autocontrol_help_menu.addAction( - ui_we_want_to_set.open_autocontrol_github_action - ) - ui_we_want_to_set.autocontrol_project_menu = ui_we_want_to_set.autocontrol_menu.addMenu( - language_wrapper.language_word_dict.get("project_label")) - # Create Project - ui_we_want_to_set.create_autocontrol_project_action = QAction( - language_wrapper.language_word_dict.get("autocontrol_create_project_label")) - ui_we_want_to_set.create_autocontrol_project_action.triggered.connect( - create_project - ) - ui_we_want_to_set.autocontrol_project_menu.addAction( - ui_we_want_to_set.create_autocontrol_project_action - ) - # Record - ui_we_want_to_set.autocontrol_record_menu = ui_we_want_to_set.autocontrol_menu.addMenu( - language_wrapper.language_word_dict.get("autocontrol_record_menu_label")) - ui_we_want_to_set.record_action = QAction( - language_wrapper.language_word_dict.get("autocontrol_record_start_label")) - ui_we_want_to_set.record_action.triggered.connect( - je_auto_control.record - ) - ui_we_want_to_set.autocontrol_record_menu.addAction( - ui_we_want_to_set.record_action - ) - # Stop Record - ui_we_want_to_set.stop_record_action = QAction( - language_wrapper.language_word_dict.get("autocontrol_record_stop_label")) - ui_we_want_to_set.stop_record_action.triggered.connect( - lambda: stop_record(ui_we_want_to_set) - ) - ui_we_want_to_set.autocontrol_record_menu.addAction( - ui_we_want_to_set.stop_record_action - ) - # AutoControl GUI - ui_we_want_to_set.autocontrol_gui_action = QAction( - "AutoControl GUI" - ) - ui_we_want_to_set.autocontrol_gui_action.triggered.connect( - lambda: add_autocontrol_gui(ui_we_want_to_set) - ) - ui_we_want_to_set.autocontrol_menu.addAction( - ui_we_want_to_set.autocontrol_gui_action + menu = build_automation_menu( + ui=ui_we_want_to_set, + menu_label_key="autocontrol_menu_label", + run_actions=[ + {"label_key": "autocontrol_run_script_label", + "callback": lambda: call_auto_control(ui_we_want_to_set)}, + {"label_key": "autocontrol_run_script_with_send_label", + "callback": lambda: call_auto_control_with_send(ui_we_want_to_set)}, + {"label_key": "autocontrol_run_multi_script_label", + "callback": lambda: call_auto_control_multi_file(ui_we_want_to_set)}, + {"label_key": "autocontrol_run_multi_script_with_send_label", + "callback": lambda: call_auto_control_multi_file_and_send(ui_we_want_to_set)}, + ], + doc_url="https://autocontrol.readthedocs.io/en/latest/", + doc_label_key="autocontrol_doc_label", + doc_tab_label_key="autocontrol_doc_tab_label", + github_url="https://github.com/Intergration-Automation-Testing/AutoControl", + github_label_key="autocontrol_github_label", + github_tab_label_key="autocontrol_github_tab_label", + create_project_func=safe_create_project("je_auto_control"), + create_project_label_key="autocontrol_create_project_label", + gui_widget_class=AutoControlGUIWidget, + gui_label="AutoControl GUI", ) + # AutoControl-specific: Record menu + lang = language_wrapper.language_word_dict + record_menu = menu.addMenu(lang.get("autocontrol_record_menu_label")) + + record_action = QAction(lang.get("autocontrol_record_start_label")) + record_action.triggered.connect(je_auto_control.record) + record_menu.addAction(record_action) -def create_project() -> None: - try: - package = je_auto_control - if package is not None: - package.create_project_dir() - except ImportError as error: - print(repr(error), file=sys.stderr) + stop_record_action = QAction(lang.get("autocontrol_record_stop_label")) + stop_record_action.triggered.connect(lambda: stop_record(ui_we_want_to_set)) + record_menu.addAction(stop_record_action) def stop_record(editor_instance: PyBreezeMainWindow): @@ -161,9 +68,3 @@ def stop_record(editor_instance: PyBreezeMainWindow): text_format.setForeground(actually_color_dict.get("normal_output_color")) text_cursor.insertText(str(je_auto_control.stop_record()), text_format) text_cursor.insertBlock() - - -def add_autocontrol_gui(ui_we_want_to_set: PyBreezeMainWindow) -> None: - ui_we_want_to_set.tab_widget.addTab( - AutoControlGUIWidget(), "AutoControl GUI" - ) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/automation_file_menu/build_automation_file_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/automation_file_menu/build_automation_file_menu.py index 2e64537..9990557 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/automation_file_menu/build_automation_file_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/automation_file_menu/build_automation_file_menu.py @@ -2,121 +2,39 @@ from typing import TYPE_CHECKING -from je_editor import language_wrapper - -from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( + build_automation_menu, safe_create_project +) if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys - -from PySide6.QtGui import QAction -from pybreeze.extend.process_executor.file_automation.file_automation_process import call_file_automation_test, \ - call_file_automation_test_with_send, call_file_automation_test_multi_file, \ - call_file_automation_test_multi_file_and_send +from pybreeze.extend.process_executor.file_automation.file_automation_process import ( + call_file_automation_test, call_file_automation_test_with_send, + call_file_automation_test_multi_file, call_file_automation_test_multi_file_and_send, +) def set_automation_file_menu(ui_we_want_to_set: PyBreezeMainWindow): - """ - Build menu include WebRunner feature. - :param ui_we_want_to_set: main window to add menu. - :return: None - """ - ui_we_want_to_set.automation_file_menu = ui_we_want_to_set.automation_menu.addMenu( - language_wrapper.language_word_dict.get("file_automation_menu_label")) - ui_we_want_to_set.automation_run_file_menu = ui_we_want_to_set.automation_file_menu.addMenu( - language_wrapper.language_word_dict.get("run_label")) - # Run FileAutomation Script - ui_we_want_to_set.run_file_automation_action = QAction( - language_wrapper.language_word_dict.get("file_automation_run_script_label")) - ui_we_want_to_set.run_file_automation_action.triggered.connect( - lambda: call_file_automation_test( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.automation_run_file_menu.addAction(ui_we_want_to_set.run_file_automation_action) - # Run FileAutomation Script With Send - ui_we_want_to_set.run_file_automation_action_with_send = QAction( - language_wrapper.language_word_dict.get("file_automation_run_script_with_send_label")) - ui_we_want_to_set.run_file_automation_action_with_send.triggered.connect( - lambda: call_file_automation_test_with_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.automation_run_file_menu.addAction( - ui_we_want_to_set.run_file_automation_action_with_send - ) - # Run Multi FileAutomation Script - ui_we_want_to_set.run_multi_file_automation_action = QAction( - language_wrapper.language_word_dict.get("file_automation_run_multi_script_label")) - ui_we_want_to_set.run_multi_file_automation_action.triggered.connect( - lambda: call_file_automation_test_multi_file( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.automation_run_file_menu.addAction( - ui_we_want_to_set.run_multi_file_automation_action - ) - # Run Multi FileAutomation Script With Send - ui_we_want_to_set.run_multi_file_automation_action_with_send = QAction( - language_wrapper.language_word_dict.get("file_automation_run_multi_script_with_send_label")) - ui_we_want_to_set.run_multi_file_automation_action_with_send.triggered.connect( - lambda: call_file_automation_test_multi_file_and_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.automation_run_file_menu.addAction( - ui_we_want_to_set.run_multi_file_automation_action_with_send - ) - ui_we_want_to_set.file_automation_help_menu = ui_we_want_to_set.automation_file_menu.addMenu( - language_wrapper.language_word_dict.get("help_label") + build_automation_menu( + ui=ui_we_want_to_set, + menu_label_key="file_automation_menu_label", + run_actions=[ + {"label_key": "file_automation_run_script_label", + "callback": lambda: call_file_automation_test(ui_we_want_to_set)}, + {"label_key": "file_automation_run_script_with_send_label", + "callback": lambda: call_file_automation_test_with_send(ui_we_want_to_set)}, + {"label_key": "file_automation_run_multi_script_label", + "callback": lambda: call_file_automation_test_multi_file(ui_we_want_to_set)}, + {"label_key": "file_automation_run_multi_script_with_send_label", + "callback": lambda: call_file_automation_test_multi_file_and_send(ui_we_want_to_set)}, + ], + doc_url="https://fileautomation.readthedocs.io/en/latest/", + doc_label_key="file_automation_doc_label", + doc_tab_label_key="file_automation_doc_tab_label", + github_url="https://github.com/Integration-Automation/FileAutomation", + github_label_key="file_automation_github_label", + github_tab_label_key="file_automation_github_tab_label", + create_project_func=safe_create_project("file_automation"), + create_project_label_key="file_automation_create_project_label", ) - # Open Doc - ui_we_want_to_set.open_file_automation_doc_action = QAction( - language_wrapper.language_word_dict.get("file_automation_doc_label")) - ui_we_want_to_set.open_file_automation_doc_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://fileautomation.readthedocs.io/en/latest/", - language_wrapper.language_word_dict.get("file_automation_doc_tab_label") - ) - ) - ui_we_want_to_set.file_automation_help_menu.addAction( - ui_we_want_to_set.open_file_automation_doc_action - ) - # Open Github - ui_we_want_to_set.open_file_automation_github_action = QAction( - language_wrapper.language_word_dict.get("file_automation_github_label")) - ui_we_want_to_set.open_file_automation_github_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://github.com/Integration-Automation/FileAutomation", - language_wrapper.language_word_dict.get("file_automation_github_tab_label") - ) - ) - ui_we_want_to_set.file_automation_help_menu.addAction( - ui_we_want_to_set.open_file_automation_github_action - ) - ui_we_want_to_set.file_automation_project_menu = ui_we_want_to_set.automation_file_menu.addMenu( - language_wrapper.language_word_dict.get("project_label") - ) - # Create Project - ui_we_want_to_set.create_web_runner_project_action = QAction( - language_wrapper.language_word_dict.get("file_automation_create_project_label")) - ui_we_want_to_set.create_web_runner_project_action.triggered.connect( - create_project - ) - ui_we_want_to_set.file_automation_project_menu.addAction( - ui_we_want_to_set.create_web_runner_project_action - ) - - -def create_project() -> None: - try: - import file_automation - package = file_automation - if package is not None: - package.create_project_dir() - except ImportError as error: - print(repr(error), file=sys.stderr) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py new file mode 100644 index 0000000..b25f92c --- /dev/null +++ b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QMenu +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser +from pybreeze.utils.logging.logger import pybreeze_logger + +if TYPE_CHECKING: + from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow + + +def build_automation_menu( + ui: PyBreezeMainWindow, + menu_label_key: str, + run_actions: list[dict] | None = None, + doc_url: str | None = None, + doc_label_key: str | None = None, + doc_tab_label_key: str | None = None, + github_url: str | None = None, + github_label_key: str | None = None, + github_tab_label_key: str | None = None, + create_project_func: Callable | None = None, + create_project_label_key: str | None = None, + gui_widget_class: type | None = None, + gui_label: str | None = None, +) -> QMenu: + """ + Factory function to build a standard automation sub-menu. + + :param ui: The main window instance. + :param menu_label_key: Language key for the menu label. + :param run_actions: List of dicts with keys: + - "label_key": language key for action label + - "callback": callable to trigger + :param doc_url: Documentation URL. + :param doc_label_key: Language key for doc action label. + :param doc_tab_label_key: Language key for doc tab label. + :param github_url: GitHub URL. + :param github_label_key: Language key for github action label. + :param github_tab_label_key: Language key for github tab label. + :param create_project_func: Callable to create project directory. + :param create_project_label_key: Language key for create project action. + :param gui_widget_class: Optional widget class to add as a tab. + :param gui_label: Label for the GUI tab. + :return: The created QMenu. + """ + lang = language_wrapper.language_word_dict + + # Main menu + menu = ui.automation_menu.addMenu(lang.get(menu_label_key)) + + # Run sub-menu + if run_actions: + run_menu = menu.addMenu(lang.get("run_label")) + for action_config in run_actions: + action = QAction(lang.get(action_config["label_key"])) + callback = action_config["callback"] + action.triggered.connect(callback) + run_menu.addAction(action) + + # Help sub-menu + if doc_url or github_url: + help_menu = menu.addMenu(lang.get("help_label")) + if doc_url and doc_label_key: + doc_action = QAction(lang.get(doc_label_key)) + doc_action.triggered.connect( + lambda checked=False, u=doc_url, t=doc_tab_label_key: + open_web_browser(ui, u, lang.get(t)) + ) + help_menu.addAction(doc_action) + if github_url and github_label_key: + github_action = QAction(lang.get(github_label_key)) + github_action.triggered.connect( + lambda checked=False, u=github_url, t=github_tab_label_key: + open_web_browser(ui, u, lang.get(t)) + ) + help_menu.addAction(github_action) + + # Project sub-menu + if create_project_func and create_project_label_key: + project_menu = menu.addMenu(lang.get("project_label")) + create_action = QAction(lang.get(create_project_label_key)) + create_action.triggered.connect(create_project_func) + project_menu.addAction(create_action) + + # GUI widget tab + if gui_widget_class and gui_label: + gui_action = QAction(gui_label) + gui_action.triggered.connect( + lambda checked=False: ui.tab_widget.addTab(gui_widget_class(), gui_label) + ) + menu.addAction(gui_action) + + return menu + + +def safe_create_project(import_name: str) -> Callable: + """Create a safe project creation function that handles ImportError.""" + def _create(): + try: + import importlib + package = importlib.import_module(import_name) + if package is not None: + package.create_project_dir() + except ImportError as error: + pybreeze_logger.error(f"Failed to import {import_name}: {error}") + return _create diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py index 8b05194..f2409d8 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py @@ -2,136 +2,43 @@ from typing import TYPE_CHECKING -from je_editor import language_wrapper from je_load_density.gui.main_widget import LoadDensityWidget -from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( + build_automation_menu, safe_create_project +) if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys -from PySide6.QtGui import QAction - -from pybreeze.extend.process_executor.load_density.load_density_process import \ - call_load_density, call_load_density_with_send, call_load_density_multi_file, \ - call_load_density_multi_file_and_send +from pybreeze.extend.process_executor.load_density.load_density_process import ( + call_load_density, call_load_density_with_send, + call_load_density_multi_file, call_load_density_multi_file_and_send, +) def set_load_density_menu(ui_we_want_to_set: PyBreezeMainWindow): - """ - Build menu include LoadDensity feature. - :param ui_we_want_to_set: main window to add menu. - :return: None - """ - ui_we_want_to_set.load_density_menu = ui_we_want_to_set.automation_menu.addMenu( - language_wrapper.language_word_dict.get("load_density_menu_label")) - ui_we_want_to_set.load_density_run_menu = ui_we_want_to_set.load_density_menu.addMenu( - language_wrapper.language_word_dict.get("run_label")) - # Run LoadDensity Script - ui_we_want_to_set.run_load_density_action = QAction( - language_wrapper.language_word_dict.get("load_density_run_script_label")) - ui_we_want_to_set.run_load_density_action.triggered.connect( - lambda: call_load_density( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.load_density_run_menu.addAction(ui_we_want_to_set.run_load_density_action) - # Run LoadDensity Script With Send - ui_we_want_to_set.run_load_density_action_with_send = QAction( - language_wrapper.language_word_dict.get("load_density_run_script_with_send_label")) - ui_we_want_to_set.run_load_density_action_with_send.triggered.connect( - lambda: call_load_density_with_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.load_density_run_menu.addAction( - ui_we_want_to_set.run_load_density_action_with_send - ) - # Run Multi LoadDensity Script - ui_we_want_to_set.run_multi_load_density_action = QAction( - language_wrapper.language_word_dict.get("load_density_run_multi_script_label")) - ui_we_want_to_set.run_multi_load_density_action.triggered.connect( - lambda: call_load_density_multi_file( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.load_density_run_menu.addAction( - ui_we_want_to_set.run_multi_load_density_action - ) - # Run Multi LoadDensity Script With Send - ui_we_want_to_set.run_multi_load_density_action_with_send = QAction( - language_wrapper.language_word_dict.get("load_density_run_multi_script_with_send_label")) - ui_we_want_to_set.run_multi_load_density_action_with_send.triggered.connect( - lambda: call_load_density_multi_file_and_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.load_density_run_menu.addAction( - ui_we_want_to_set.run_multi_load_density_action_with_send - ) - ui_we_want_to_set.load_density_help_menu = ui_we_want_to_set.load_density_menu.addMenu( - language_wrapper.language_word_dict.get("help_label")) - # Open Doc - ui_we_want_to_set.open_load_density_doc_action = QAction( - language_wrapper.language_word_dict.get("load_density_doc_label")) - ui_we_want_to_set.open_load_density_doc_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://loaddensity.readthedocs.io/en/latest/", - language_wrapper.language_word_dict.get("load_density_doc_tab_label") - ) - ) - ui_we_want_to_set.load_density_help_menu.addAction( - ui_we_want_to_set.open_load_density_doc_action - ) - # Open Github - ui_we_want_to_set.open_load_density_github_action = QAction( - language_wrapper.language_word_dict.get("load_density_github_label")) - ui_we_want_to_set.open_load_density_github_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://github.com/Intergration-Automation-Testing/LoadDensity", - language_wrapper.language_word_dict.get("load_density_github_tab_label") - ) - ) - ui_we_want_to_set.load_density_help_menu.addAction( - ui_we_want_to_set.open_load_density_github_action - ) - ui_we_want_to_set.load_density_project_menu = ui_we_want_to_set.load_density_menu.addMenu( - language_wrapper.language_word_dict.get("project_label")) - # Create Project - ui_we_want_to_set.create_load_density_project_action = QAction( - language_wrapper.language_word_dict.get("load_density_create_project_label")) - ui_we_want_to_set.create_load_density_project_action.triggered.connect( - create_project - ) - ui_we_want_to_set.load_density_project_menu.addAction( - ui_we_want_to_set.create_load_density_project_action - ) - # AutoControl GUI - ui_we_want_to_set.load_density_gui_action = QAction( - "LoadDensity GUI" - ) - ui_we_want_to_set.load_density_gui_action.triggered.connect( - lambda: add_load_density_gui(ui_we_want_to_set) - ) - ui_we_want_to_set.load_density_menu.addAction( - ui_we_want_to_set.load_density_gui_action - ) - - -def create_project() -> None: - try: - import je_load_density - package = je_load_density - if package is not None: - package.create_project_dir() - except ImportError as error: - print(repr(error), file=sys.stderr) - - -def add_load_density_gui(ui_we_want_to_set: PyBreezeMainWindow) -> None: - ui_we_want_to_set.tab_widget.addTab( - LoadDensityWidget(), "LoadDensity GUI" + build_automation_menu( + ui=ui_we_want_to_set, + menu_label_key="load_density_menu_label", + run_actions=[ + {"label_key": "load_density_run_script_label", + "callback": lambda: call_load_density(ui_we_want_to_set)}, + {"label_key": "load_density_run_script_with_send_label", + "callback": lambda: call_load_density_with_send(ui_we_want_to_set)}, + {"label_key": "load_density_run_multi_script_label", + "callback": lambda: call_load_density_multi_file(ui_we_want_to_set)}, + {"label_key": "load_density_run_multi_script_with_send_label", + "callback": lambda: call_load_density_multi_file_and_send(ui_we_want_to_set)}, + ], + doc_url="https://loaddensity.readthedocs.io/en/latest/", + doc_label_key="load_density_doc_label", + doc_tab_label_key="load_density_doc_tab_label", + github_url="https://github.com/Intergration-Automation-Testing/LoadDensity", + github_label_key="load_density_github_label", + github_tab_label_key="load_density_github_tab_label", + create_project_func=safe_create_project("je_load_density"), + create_project_label_key="load_density_create_project_label", + gui_widget_class=LoadDensityWidget, + gui_label="LoadDensity GUI", ) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/mail_thunder_menu/build_mail_thunder_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/mail_thunder_menu/build_mail_thunder_menu.py index dd78517..6a396a0 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/mail_thunder_menu/build_mail_thunder_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/mail_thunder_menu/build_mail_thunder_menu.py @@ -2,87 +2,30 @@ from typing import TYPE_CHECKING -from je_editor import language_wrapper - -from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( + build_automation_menu, safe_create_project +) if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys - -from PySide6.QtGui import QAction from pybreeze.extend.process_executor.mail_thunder.mail_thunder_process import call_mail_thunder def set_mail_thunder_menu(ui_we_want_to_set: PyBreezeMainWindow): - """ - Build menu include LoadDensity feature. - :param ui_we_want_to_set: main window to add menu. - :return: None - """ - ui_we_want_to_set.mail_thunder_menu = ui_we_want_to_set.automation_menu.addMenu( - language_wrapper.language_word_dict.get("mail_thunder_menu_label")) - ui_we_want_to_set.mail_thunder_run_menu = ui_we_want_to_set.mail_thunder_menu.addMenu( - language_wrapper.language_word_dict.get("run_label")) - # Run MailThunder - ui_we_want_to_set.run_mail_thunder_action = QAction( - language_wrapper.language_word_dict.get("mail_thunder_run_script_label")) - ui_we_want_to_set.run_mail_thunder_action.triggered.connect( - lambda: call_mail_thunder( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.mail_thunder_run_menu.addAction( - ui_we_want_to_set.run_mail_thunder_action - ) - # Help menu - ui_we_want_to_set.mail_thunder_help_menu = ui_we_want_to_set.mail_thunder_menu.addMenu( - language_wrapper.language_word_dict.get("help_label")) - # Open Doc - ui_we_want_to_set.open_mail_thunder_doc_action = QAction( - language_wrapper.language_word_dict.get("mail_thunder_doc_label")) - ui_we_want_to_set.open_mail_thunder_doc_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://mailthunder.readthedocs.io/en/latest/", - language_wrapper.language_word_dict.get("mail_thunder_doc_tab_label") - ) - ) - ui_we_want_to_set.mail_thunder_help_menu.addAction( - ui_we_want_to_set.open_mail_thunder_doc_action - ) - # Open Github - ui_we_want_to_set.open_mail_thunder_github_action = QAction( - language_wrapper.language_word_dict.get("mail_thunder_github_label")) - ui_we_want_to_set.open_mail_thunder_github_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://github.com/Integration-Automation/MailThunder", - language_wrapper.language_word_dict.get("mail_thunder_github_tab_label") - ) + build_automation_menu( + ui=ui_we_want_to_set, + menu_label_key="mail_thunder_menu_label", + run_actions=[ + {"label_key": "mail_thunder_run_script_label", + "callback": lambda: call_mail_thunder(ui_we_want_to_set)}, + ], + doc_url="https://mailthunder.readthedocs.io/en/latest/", + doc_label_key="mail_thunder_doc_label", + doc_tab_label_key="mail_thunder_doc_tab_label", + github_url="https://github.com/Integration-Automation/MailThunder", + github_label_key="mail_thunder_github_label", + github_tab_label_key="mail_thunder_github_tab_label", + create_project_func=safe_create_project("je_mail_thunder"), + create_project_label_key="mail_thunder_create_project_label", ) - ui_we_want_to_set.mail_thunder_help_menu.addAction( - ui_we_want_to_set.open_mail_thunder_github_action - ) - ui_we_want_to_set.mail_thunder_project_menu = ui_we_want_to_set.mail_thunder_menu.addMenu( - language_wrapper.language_word_dict.get("project_label")) - # Create Project - ui_we_want_to_set.create_mail_thunder_project_action = QAction( - language_wrapper.language_word_dict.get("mail_thunder_create_project_label")) - ui_we_want_to_set.create_mail_thunder_project_action.triggered.connect( - create_project - ) - ui_we_want_to_set.mail_thunder_project_menu.addAction( - ui_we_want_to_set.create_mail_thunder_project_action - ) - - -def create_project() -> None: - try: - import je_mail_thunder - package = je_mail_thunder - if package is not None: - package.create_project_dir() - except ImportError as error: - print(repr(error), file=sys.stderr) diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/web_runner_menu/build_webrunner_menu.py b/pybreeze/pybreeze_ui/menu/automation_menu/web_runner_menu/build_webrunner_menu.py index f1733e7..939ed42 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/web_runner_menu/build_webrunner_menu.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/web_runner_menu/build_webrunner_menu.py @@ -2,118 +2,39 @@ from typing import TYPE_CHECKING -from je_editor import language_wrapper - -from pybreeze.pybreeze_ui.menu.menu_utils import open_web_browser +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( + build_automation_menu, safe_create_project +) if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow -import sys - -from PySide6.QtGui import QAction -from pybreeze.extend.process_executor.web_runner.web_runner_process import call_web_runner_test, \ - call_web_runner_test_with_send, call_web_runner_test_multi_file, call_web_runner_test_multi_file_and_send +from pybreeze.extend.process_executor.web_runner.web_runner_process import ( + call_web_runner_test, call_web_runner_test_with_send, + call_web_runner_test_multi_file, call_web_runner_test_multi_file_and_send, +) def set_web_runner_menu(ui_we_want_to_set: PyBreezeMainWindow): - """ - Build menu include WebRunner feature. - :param ui_we_want_to_set: main window to add menu. - :return: None - """ - ui_we_want_to_set.web_runner_menu = ui_we_want_to_set.automation_menu.addMenu( - language_wrapper.language_word_dict.get("web_runner_menu_label")) - ui_we_want_to_set.web_runner_run_menu = ui_we_want_to_set.web_runner_menu.addMenu( - language_wrapper.language_word_dict.get("web_runner_menu_label")) - # Run WEBRunner Script - ui_we_want_to_set.run_web_runner_action = QAction( - language_wrapper.language_word_dict.get("web_runner_run_script_label")) - ui_we_want_to_set.run_web_runner_action.triggered.connect( - lambda: call_web_runner_test( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.web_runner_run_menu.addAction(ui_we_want_to_set.run_web_runner_action) - # Run WEBRunner Script With Send - ui_we_want_to_set.run_web_runner_action_with_send = QAction( - language_wrapper.language_word_dict.get("web_runner_run_script_with_send_label")) - ui_we_want_to_set.run_web_runner_action_with_send.triggered.connect( - lambda: call_web_runner_test_with_send( - ui_we_want_to_set - ) - ) - ui_we_want_to_set.web_runner_run_menu.addAction( - ui_we_want_to_set.run_web_runner_action_with_send - ) - # Run Multi WEBRunner Script - ui_we_want_to_set.run_multi_web_runner_action = QAction( - language_wrapper.language_word_dict.get("web_runner_run_multi_script_label")) - ui_we_want_to_set.run_multi_web_runner_action.triggered.connect( - lambda: call_web_runner_test_multi_file( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.web_runner_run_menu.addAction( - ui_we_want_to_set.run_multi_web_runner_action - ) - # Run Multi WEBRunner Script With Send - ui_we_want_to_set.run_multi_web_runner_action_with_send = QAction( - language_wrapper.language_word_dict.get("web_runner_run_multi_script_with_send_label")) - ui_we_want_to_set.run_multi_web_runner_action_with_send.triggered.connect( - lambda: call_web_runner_test_multi_file_and_send( - ui_we_want_to_set, - ) - ) - ui_we_want_to_set.web_runner_run_menu.addAction( - ui_we_want_to_set.run_multi_web_runner_action_with_send + build_automation_menu( + ui=ui_we_want_to_set, + menu_label_key="web_runner_menu_label", + run_actions=[ + {"label_key": "web_runner_run_script_label", + "callback": lambda: call_web_runner_test(ui_we_want_to_set)}, + {"label_key": "web_runner_run_script_with_send_label", + "callback": lambda: call_web_runner_test_with_send(ui_we_want_to_set)}, + {"label_key": "web_runner_run_multi_script_label", + "callback": lambda: call_web_runner_test_multi_file(ui_we_want_to_set)}, + {"label_key": "web_runner_run_multi_script_with_send_label", + "callback": lambda: call_web_runner_test_multi_file_and_send(ui_we_want_to_set)}, + ], + doc_url="https://webrunner.readthedocs.io/en/latest/", + doc_label_key="web_runner_doc_label", + doc_tab_label_key="web_runner_doc_tab_label", + github_url="https://github.com/Intergration-Automation-Testing/WebRunner", + github_label_key="web_runner_github_label", + github_tab_label_key="web_runner_github_tab_label", + create_project_func=safe_create_project("je_web_runner"), + create_project_label_key="web_runner_create_project_label", ) - ui_we_want_to_set.web_runner_help_menu = ui_we_want_to_set.web_runner_menu.addMenu( - language_wrapper.language_word_dict.get("help_label")) - # Open Doc - ui_we_want_to_set.open_web_runner_doc_action = QAction( - language_wrapper.language_word_dict.get("web_runner_doc_label")) - ui_we_want_to_set.open_web_runner_doc_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://webrunner.readthedocs.io/en/latest/", - language_wrapper.language_word_dict.get("web_runner_doc_tab_label") - ) - ) - ui_we_want_to_set.web_runner_help_menu.addAction( - ui_we_want_to_set.open_web_runner_doc_action - ) - # Open Github - ui_we_want_to_set.open_web_runner_github_action = QAction( - language_wrapper.language_word_dict.get("web_runner_github_label")) - ui_we_want_to_set.open_web_runner_github_action.triggered.connect( - lambda: open_web_browser( - ui_we_want_to_set, - "https://github.com/Intergration-Automation-Testing/WebRunner", - language_wrapper.language_word_dict.get("web_runner_github_tab_label") - ) - ) - ui_we_want_to_set.web_runner_help_menu.addAction( - ui_we_want_to_set.open_web_runner_github_action - ) - ui_we_want_to_set.web_runner_project_menu = ui_we_want_to_set.web_runner_menu.addMenu( - language_wrapper.language_word_dict.get("project_label")) - # Create Project - ui_we_want_to_set.create_web_runner_project_action = QAction( - language_wrapper.language_word_dict.get("web_runner_create_project_label")) - ui_we_want_to_set.create_web_runner_project_action.triggered.connect( - create_project - ) - ui_we_want_to_set.web_runner_project_menu.addAction( - ui_we_want_to_set.create_web_runner_project_action - ) - - -def create_project() -> None: - try: - import je_web_runner - package = je_web_runner - if package is not None: - package.create_project_dir() - except ImportError as error: - print(repr(error), file=sys.stderr) diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index b266e4a..2831c90 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -1,3 +1,4 @@ +from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import QWidget, QGridLayout, QTextEdit, QScrollArea @@ -16,6 +17,15 @@ def __init__(self): self.code_result_scroll_area.setViewportMargins(0, 0, 0, 0) self.code_result_scroll_area.setWidget(self.code_result) self.grid_layout.addWidget(self.code_result_scroll_area, 0, 0) - self.resize(500, 500) + # Adaptive sizing based on screen + screen = QGuiApplication.primaryScreen() + if screen is not None: + screen_size = screen.availableSize() + self.resize( + max(500, screen_size.width() // 3), + max(400, screen_size.height() // 3) + ) + else: + self.resize(500, 500) self.setLayout(self.grid_layout) self.setFocus() diff --git a/pybreeze/utils/file_process/get_dir_file_list.py b/pybreeze/utils/file_process/get_dir_file_list.py index deb2821..f83e162 100644 --- a/pybreeze/utils/file_process/get_dir_file_list.py +++ b/pybreeze/utils/file_process/get_dir_file_list.py @@ -1,4 +1,5 @@ -import sys +from __future__ import annotations + from os import getcwd from os import walk from os.path import abspath @@ -6,6 +7,8 @@ from PySide6.QtWidgets import QFileDialog, QMainWindow +from pybreeze.utils.logging.logger import pybreeze_logger + def get_dir_files_as_list(dir_path: str = getcwd(), default_search_file_extension: str = ".json") -> list: """ @@ -21,9 +24,12 @@ def get_dir_files_as_list(dir_path: str = getcwd(), default_search_file_extensio ] -def ask_and_get_dir_files_as_list(main_window: QMainWindow, default_search_file_extension: str = ".json") -> list: +def ask_and_get_dir_files_as_list( + main_window: QMainWindow, default_search_file_extension: str = ".json" +) -> list | None: choose_dir = QFileDialog(parent=main_window).getExistingDirectory() if choose_dir is not None and choose_dir != "": return get_dir_files_as_list(choose_dir, default_search_file_extension) else: - print("Not select any dir", file=sys.stderr) + pybreeze_logger.warning("Not select any dir") + return None diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index 1d944c0..d9bcc1e 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -1,18 +1,18 @@ import json.decoder -import sys from json import dumps from json import loads from pybreeze.utils.exception.exception_tags import cant_reformat_json_error from pybreeze.utils.exception.exception_tags import wrong_json_data_error from pybreeze.utils.exception.exceptions import ITEJsonException +from pybreeze.utils.logging.logger import pybreeze_logger def __process_json(json_string: str, **kwargs) -> str: try: return dumps(loads(json_string), indent=4, sort_keys=True, **kwargs) except json.JSONDecodeError as error: - print(wrong_json_data_error, file=sys.stderr) + pybreeze_logger.error(wrong_json_data_error) raise error except TypeError: try: diff --git a/pybreeze/utils/logging/logger.py b/pybreeze/utils/logging/logger.py index 4529de5..a7a93d0 100644 --- a/pybreeze/utils/logging/logger.py +++ b/pybreeze/utils/logging/logger.py @@ -1,4 +1,5 @@ import logging +import os from logging.handlers import RotatingFileHandler # 設定 root logger 等級 Set root logger level @@ -12,36 +13,38 @@ "%(asctime)s | %(name)s | %(levelname)s | %(message)s" ) +# Configurable max log size via environment variable (default: 100MB) +DEFAULT_MAX_LOG_BYTES = 100 * 1024 * 1024 # 100MB + class PyBreezeLogger(RotatingFileHandler): """ - AutoControlGUILoggingHandler - 自訂日誌處理器,繼承 RotatingFileHandler - - 支援檔案大小輪替 - - 預設輸出到 AutoControlGUI.log + PyBreezeLoggingHandler + Custom log handler extending RotatingFileHandler + - Supports file size rotation + - Default output to PyBreeze.log + - Max size configurable via PYBREEZE_LOG_MAX_BYTES env var """ def __init__( self, filename: str = "PyBreeze.log", mode: str = "w", - max_bytes: int = 1073741824, # 1GB - backup_count: int = 0, + max_bytes: int | None = None, + backup_count: int = 1, ): + if max_bytes is None: + max_bytes = int(os.environ.get("PYBREEZE_LOG_MAX_BYTES", DEFAULT_MAX_LOG_BYTES)) super().__init__( filename=filename, mode=mode, maxBytes=max_bytes, backupCount=backup_count, ) - self.setFormatter(formatter) # 設定格式器 - self.setLevel(logging.DEBUG) # 設定等級 + self.setFormatter(formatter) + self.setLevel(logging.DEBUG) def emit(self, record: logging.LogRecord) -> None: - """ - Emit log record. - 輸出日誌紀錄 - """ super().emit(record) diff --git a/dev.toml b/pyproject.toml similarity index 94% rename from dev.toml rename to pyproject.toml index dda7f27..ef4afb4 100644 --- a/dev.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "pybreeze_dev" -version = "1.0.12" +version = "1.0.13" authors = [ { name = "JE-Chen", email = "jechenmailman@gmail.com" }, ] @@ -21,7 +21,7 @@ dependencies = [ ] classifiers = [ "Programming Language :: Python :: 3.10", - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 4 - Beta", "Environment :: Win32 (MS Windows)", "Environment :: MacOS X", "Environment :: X11 Applications", diff --git a/requirements.txt b/requirements.txt index c44279d..127144e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,12 @@ pybreeze -PySide6 +PySide6==6.11.0 +je-editor +je_auto_control +je_web_runner +je_load_density +je_api_testka +je-mail-thunder +automation-file +test_pioneer +paramiko +jupyterlab diff --git a/stable.toml b/stable.toml index b2a80b2..f67d831 100644 --- a/stable.toml +++ b/stable.toml @@ -21,7 +21,7 @@ dependencies = [ ] classifiers = [ "Programming Language :: Python :: 3.10", - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 4 - Beta", "Environment :: Win32 (MS Windows)", "Environment :: MacOS X", "Environment :: X11 Applications", diff --git a/test/test_utils/__init__.py b/test/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_utils/test_exception_tags.py b/test/test_utils/test_exception_tags.py new file mode 100644 index 0000000..58383d7 --- /dev/null +++ b/test/test_utils/test_exception_tags.py @@ -0,0 +1,55 @@ +from pybreeze.utils.exception.exception_tags import ( + add_command_type_exception_tag, + add_command_not_allow_package_exception_tag, + send_html_exception_tag, + auto_control_process_executor_exception_tag, + api_testka_process_executor_exception_tag, + web_runner_process_executor_exception_tag, + load_density_process_executor_exception_tag, + not_install_exception, + wrong_test_data_format_exception_tag, + exec_error, + file_not_fond_error, + compiler_not_found_error, + not_install_package_error, + cant_reformat_json_error, + wrong_json_data_error, + cant_read_xml_error, + xml_type_error, +) + + +class TestExceptionTags: + def test_all_tags_are_strings(self): + tags = [ + add_command_type_exception_tag, + add_command_not_allow_package_exception_tag, + send_html_exception_tag, + auto_control_process_executor_exception_tag, + api_testka_process_executor_exception_tag, + web_runner_process_executor_exception_tag, + load_density_process_executor_exception_tag, + not_install_exception, + wrong_test_data_format_exception_tag, + exec_error, + file_not_fond_error, + compiler_not_found_error, + not_install_package_error, + cant_reformat_json_error, + wrong_json_data_error, + cant_read_xml_error, + xml_type_error, + ] + for tag in tags: + assert isinstance(tag, str) + assert len(tag) > 0 + + def test_send_html_tag_has_instructions(self): + assert "je_mail_thunder" in send_html_exception_tag + assert "default_name.html" in send_html_exception_tag + + def test_process_executor_tags_mention_tools(self): + assert "AutoControl" in auto_control_process_executor_exception_tag + assert "APITestka" in api_testka_process_executor_exception_tag + assert "WebRunner" in web_runner_process_executor_exception_tag + assert "LoadDensity" in load_density_process_executor_exception_tag diff --git a/test/test_utils/test_exceptions.py b/test/test_utils/test_exceptions.py new file mode 100644 index 0000000..4e2b81a --- /dev/null +++ b/test/test_utils/test_exceptions.py @@ -0,0 +1,48 @@ +import pytest + +from pybreeze.utils.exception.exceptions import ( + ITEException, + ITEAddCommandException, + ITEExecException, + ITETestExecutorException, + ITESendHtmlReportException, + ITEUIException, + ITEContentFileException, + ITEJsonException, + XMLException, + XMLTypeException, +) + + +class TestExceptionHierarchy: + def test_all_inherit_from_ite_exception(self): + for exc_cls in [ + ITEAddCommandException, + ITEExecException, + ITETestExecutorException, + ITESendHtmlReportException, + ITEUIException, + ITEContentFileException, + ITEJsonException, + ]: + assert issubclass(exc_cls, ITEException) + + def test_xml_exceptions_inherit_from_ite(self): + assert issubclass(XMLException, ITEException) + assert issubclass(XMLTypeException, XMLException) + assert issubclass(XMLTypeException, ITEException) + + def test_ite_exception_is_exception(self): + assert issubclass(ITEException, Exception) + + def test_can_raise_and_catch(self): + with pytest.raises(ITEException): + raise ITEAddCommandException("test") + + def test_exception_message(self): + exc = ITEJsonException("bad json") + assert str(exc) == "bad json" + + def test_xml_type_caught_by_xml_exception(self): + with pytest.raises(XMLException): + raise XMLTypeException("type error") diff --git a/test/test_utils/test_get_dir_file_list.py b/test/test_utils/test_get_dir_file_list.py new file mode 100644 index 0000000..749d04d --- /dev/null +++ b/test/test_utils/test_get_dir_file_list.py @@ -0,0 +1,79 @@ +import os +import tempfile + +import pytest + +from pybreeze.utils.file_process.get_dir_file_list import get_dir_files_as_list + + +@pytest.fixture +def temp_dir_with_files(): + """Create a temp directory with test files.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create test files + for name in ["test1.json", "test2.json", "readme.txt", "data.csv"]: + with open(os.path.join(tmpdir, name), "w") as f: + f.write("{}") + # Create subdirectory with more files + subdir = os.path.join(tmpdir, "subdir") + os.makedirs(subdir) + with open(os.path.join(subdir, "nested.json"), "w") as f: + f.write("{}") + with open(os.path.join(subdir, "other.py"), "w") as f: + f.write("") + yield tmpdir + + +class TestGetDirFilesAsList: + def test_returns_list(self, temp_dir_with_files): + result = get_dir_files_as_list(temp_dir_with_files) + assert isinstance(result, list) + + def test_finds_json_files_by_default(self, temp_dir_with_files): + result = get_dir_files_as_list(temp_dir_with_files) + assert len(result) == 3 # test1.json, test2.json, nested.json + assert all(f.endswith(".json") for f in result) + + def test_custom_extension(self, temp_dir_with_files): + result = get_dir_files_as_list(temp_dir_with_files, ".txt") + assert len(result) == 1 + assert result[0].endswith("readme.txt") + + def test_csv_extension(self, temp_dir_with_files): + result = get_dir_files_as_list(temp_dir_with_files, ".csv") + assert len(result) == 1 + + def test_py_extension(self, temp_dir_with_files): + result = get_dir_files_as_list(temp_dir_with_files, ".py") + assert len(result) == 1 + assert result[0].endswith("other.py") + + def test_no_matching_extension(self, temp_dir_with_files): + result = get_dir_files_as_list(temp_dir_with_files, ".xyz") + assert result == [] + + def test_empty_directory(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = get_dir_files_as_list(tmpdir) + assert result == [] + + def test_returns_absolute_paths(self, temp_dir_with_files): + result = get_dir_files_as_list(temp_dir_with_files) + for path in result: + assert os.path.isabs(path) + + def test_case_insensitive_extension(self, temp_dir_with_files): + # Create a file with uppercase extension + with open(os.path.join(temp_dir_with_files, "upper.JSON"), "w") as f: + f.write("{}") + result = get_dir_files_as_list(temp_dir_with_files, ".json") + # .json should match files with .json extension + # The function uses file.endswith(ext.lower()) so it checks lowercase ext + # But the file "upper.JSON" won't match ".json" because endswith is case-sensitive + json_files = [f for f in result if f.endswith(".json")] + assert len(json_files) == 3 # only lowercase .json files + + def test_walks_subdirectories(self, temp_dir_with_files): + result = get_dir_files_as_list(temp_dir_with_files, ".json") + nested = [f for f in result if "subdir" in f] + assert len(nested) == 1 diff --git a/test/test_utils/test_json_process.py b/test/test_utils/test_json_process.py new file mode 100644 index 0000000..6dc1b7e --- /dev/null +++ b/test/test_utils/test_json_process.py @@ -0,0 +1,58 @@ +import json + +import pytest + +from pybreeze.utils.json_format.json_process import reformat_json +from pybreeze.utils.exception.exceptions import ITEJsonException + + +class TestReformatJson: + def test_valid_json_string(self): + result = reformat_json('{"b": 2, "a": 1}') + parsed = json.loads(result) + assert parsed == {"a": 1, "b": 2} + + def test_sorted_keys(self): + result = reformat_json('{"z": 1, "a": 2, "m": 3}') + lines = result.strip().split("\n") + # Keys should be sorted: a, m, z + assert '"a"' in lines[1] + assert '"m"' in lines[2] + assert '"z"' in lines[3] + + def test_indentation(self): + result = reformat_json('{"key": "value"}') + assert " " in result # 4-space indent + + def test_nested_json(self): + input_json = '{"outer": {"inner": "value"}}' + result = reformat_json(input_json) + parsed = json.loads(result) + assert parsed["outer"]["inner"] == "value" + + def test_json_array(self): + result = reformat_json('[1, 2, 3]') + parsed = json.loads(result) + assert parsed == [1, 2, 3] + + def test_invalid_json_raises_error(self): + with pytest.raises((json.JSONDecodeError, ITEJsonException)): + reformat_json("not valid json {{{") + + def test_empty_object(self): + result = reformat_json("{}") + assert json.loads(result) == {} + + def test_empty_array(self): + result = reformat_json("[]") + assert json.loads(result) == [] + + def test_json_with_special_characters(self): + result = reformat_json('{"key": "value with \\"quotes\\""}') + parsed = json.loads(result) + assert "quotes" in parsed["key"] + + def test_json_with_unicode(self): + result = reformat_json('{"key": "中文"}') + parsed = json.loads(result) + assert parsed["key"] == "中文" diff --git a/test/test_utils/test_jupyter_helpers.py b/test/test_utils/test_jupyter_helpers.py new file mode 100644 index 0000000..bd3049d --- /dev/null +++ b/test/test_utils/test_jupyter_helpers.py @@ -0,0 +1,24 @@ +import pytest + +from pybreeze.pybreeze_ui.jupyter_lab_gui.jupyter_lab_thread import find_free_port, JUPYTER_STARTUP_TIMEOUT + + +class TestFindFreePort: + def test_returns_int(self): + port = find_free_port() + assert isinstance(port, int) + + def test_returns_valid_port_range(self): + port = find_free_port() + assert 1 <= port <= 65535 + + def test_returns_different_ports(self): + ports = {find_free_port() for _ in range(5)} + # At least some should be different + assert len(ports) >= 2 + + +class TestConstants: + def test_default_timeout(self): + assert JUPYTER_STARTUP_TIMEOUT == 60 + assert isinstance(JUPYTER_STARTUP_TIMEOUT, int) diff --git a/test/test_utils/test_logger.py b/test/test_utils/test_logger.py new file mode 100644 index 0000000..d014646 --- /dev/null +++ b/test/test_utils/test_logger.py @@ -0,0 +1,47 @@ +import logging +import os +import tempfile + +import pytest + +from pybreeze.utils.logging.logger import PyBreezeLogger, pybreeze_logger + + +class TestPyBreezeLogger: + def test_logger_exists(self): + assert pybreeze_logger is not None + assert pybreeze_logger.name == "Pybreeze" + + def test_logger_effective_level(self): + assert pybreeze_logger.getEffectiveLevel() == logging.DEBUG + + def test_logger_has_handler(self): + assert len(pybreeze_logger.handlers) > 0 + + def test_custom_handler_creation(self): + with tempfile.TemporaryDirectory() as tmpdir: + log_file = os.path.join(tmpdir, "test.log") + handler = PyBreezeLogger(filename=log_file, max_bytes=1024) + assert handler.maxBytes == 1024 + handler.close() + + def test_custom_handler_writes(self): + with tempfile.TemporaryDirectory() as tmpdir: + log_file = os.path.join(tmpdir, "test.log") + handler = PyBreezeLogger(filename=log_file) + test_logger = logging.getLogger("test_pybreeze") + test_logger.addHandler(handler) + test_logger.setLevel(logging.DEBUG) + test_logger.info("test message") + handler.flush() + handler.close() + with open(log_file) as f: + content = f.read() + assert "test message" in content + + def test_handler_formatter(self): + with tempfile.TemporaryDirectory() as tmpdir: + log_file = os.path.join(tmpdir, "test.log") + handler = PyBreezeLogger(filename=log_file) + assert handler.formatter is not None + handler.close() diff --git a/test/test_utils/test_package_manager.py b/test/test_utils/test_package_manager.py new file mode 100644 index 0000000..f50c270 --- /dev/null +++ b/test/test_utils/test_package_manager.py @@ -0,0 +1,28 @@ +from pybreeze.utils.manager.package_manager.package_manager_class import PackageManager, package_manager + + +class TestPackageManager: + def test_instance_exists(self): + assert package_manager is not None + + def test_syntax_check_list_is_list(self): + assert isinstance(package_manager.syntax_check_list, list) + + def test_syntax_check_list_not_empty(self): + assert len(package_manager.syntax_check_list) > 0 + + def test_syntax_check_list_contains_expected_packages(self): + expected = [ + "je_auto_control", + "je_load_density", + "je_api_testka", + "je_web_runner", + "automation_file", + "mail_thunder", + ] + for pkg in expected: + assert pkg in package_manager.syntax_check_list + + def test_new_instance(self): + pm = PackageManager() + assert isinstance(pm.syntax_check_list, list) diff --git a/test/test_utils/test_venv_path.py b/test/test_utils/test_venv_path.py new file mode 100644 index 0000000..97ff463 --- /dev/null +++ b/test/test_utils/test_venv_path.py @@ -0,0 +1,58 @@ +import os +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pybreeze.extend.process_executor.python_task_process_manager import find_venv_path + + +class TestFindVenvPath: + def test_returns_path_object(self): + result = find_venv_path() + assert isinstance(result, Path) + + def test_prefers_venv_over_dot_venv(self): + with tempfile.TemporaryDirectory() as tmpdir: + # Create both venv and .venv + if sys.platform in ["win32", "cygwin", "msys"]: + venv_dir = os.path.join(tmpdir, "venv", "Scripts") + dot_venv_dir = os.path.join(tmpdir, ".venv", "Scripts") + else: + venv_dir = os.path.join(tmpdir, "venv", "bin") + dot_venv_dir = os.path.join(tmpdir, ".venv", "bin") + os.makedirs(venv_dir) + os.makedirs(dot_venv_dir) + + with patch.object(Path, "cwd", return_value=Path(tmpdir)): + result = find_venv_path() + assert "venv" in str(result) + assert ".venv" not in str(result) + + def test_falls_back_to_dot_venv(self): + with tempfile.TemporaryDirectory() as tmpdir: + if sys.platform in ["win32", "cygwin", "msys"]: + dot_venv_dir = os.path.join(tmpdir, ".venv", "Scripts") + else: + dot_venv_dir = os.path.join(tmpdir, ".venv", "bin") + os.makedirs(dot_venv_dir) + + with patch.object(Path, "cwd", return_value=Path(tmpdir)): + result = find_venv_path() + assert ".venv" in str(result) + + def test_returns_first_candidate_when_none_exist(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch.object(Path, "cwd", return_value=Path(tmpdir)): + result = find_venv_path() + # Should return the first candidate (venv) even if it doesn't exist + assert "venv" in str(result) + + def test_platform_specific_subdirectory(self): + result = find_venv_path() + if sys.platform in ["win32", "cygwin", "msys"]: + assert str(result).endswith("Scripts") + else: + assert str(result).endswith("bin") From b7ac15cfc8b404f7e8430dc235b48a2aa9dc783a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 4 Apr 2026 18:58:24 +0800 Subject: [PATCH 2/5] Update READMEs Update READMEs --- .gitignore | 1 + README.md | 371 ++++++++++++++++-- README/README_zh-CN.md | 333 ++++++++++++++++ README/README_zh-TW.md | 333 ++++++++++++++++ stable.toml => dev.toml | 9 +- .../mail_thunder_setting.py | 16 +- pybreeze/pybreeze_ui/syntax/syntax_keyword.py | 4 +- pyproject.toml | 9 +- 8 files changed, 1025 insertions(+), 51 deletions(-) create mode 100644 README/README_zh-CN.md create mode 100644 README/README_zh-TW.md rename stable.toml => dev.toml (91%) diff --git a/.gitignore b/.gitignore index b54ff75..13e2a15 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,4 @@ dmypy.json bing_cookies.* **/output .claude/settings.local.json +/.claude/ diff --git a/README.md b/README.md index 4ea1928..fa6871e 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,333 @@ -# PyBreeze: The Automation-First IDE - -![Main GUI](images/main_gui.png) - ---- - -## Core Capabilities: Four-Dimensional Automation -PyBreeze features built-in modules tailored for every level of modern automation, allowing developers to tackle complex scenarios without leaving the IDE: - -* **Web Automation**: Deep integration with browser drivers and element locators for rapid web-based interaction simulation and testing. -* **API Automation**: Built-in request builders and response analyzers supporting RESTful API development with advanced assertion verification. -* **GUI Automation**: Specialized support for image recognition and coordinate-based positioning for seamless desktop application automation. -* **Load & Stress Testing**: An integrated performance engine capable of simulating high-concurrency scenarios to monitor system stability under extreme pressure. - ---- - -## IDE Deep Dive: Optimized for Automation Workflows -PyBreeze is more than just a code editor; it is a command center for your automation lifecycle: - -* **Intelligent Completion**: Provides deep syntax hinting and code navigation specifically for popular automation libraries (e.g., Selenium, Requests, PyAutoGUI), significantly boosting script authoring speed. -* **Visual Debugging Suite**: Features breakpoint debugging and real-time variable monitoring, enhanced by "Step-by-Step" execution and "Screenshot Traceback" for faster troubleshooting. -* **Integrated Asset Manager**: Centralized management for Object Repositories (element locators), test data (CSV/JSON), and environment configurations, enabling clean separation of code and data. -* **Real-Time Analytics Dashboard**: Synchronous reporting during script execution, visualizing logs, screenshots, and performance curves in an intuitive interface. - ---- - -## Key Highlights -1. **Native Python Ecosystem**: Built 100% on Python, allowing developers to seamlessly leverage the vast library of third-party packages. -2. **Zero-Config Environment**: Features built-in virtual environment management and automatic driver updates, solving the common frustration of complex environment setups. -3. **Automation-First UI**: Unlike generic IDEs, the layout is specifically designed around the "Develop-Execute-Report" cycle. - ---- - -## Target Audience -* **Python Developers**: Those who want a lightweight, dedicated environment to build Python-based automation scripts without the overhead of heavy, general-purpose IDEs. -* **SDET (Software Development Engineers in Test)**: Professionals needing to maintain Web, API, and Performance tests simultaneously. -* **Automation Beginners**: Users looking for a friendly IDE that lowers the barrier to entry for Python automation. -* **DevOps Teams**: A powerful platform for rapidly building and debugging integration test suites within CI/CD pipelines. \ No newline at end of file +# PyBreeze: The Automation-First IDE + +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![PySide6](https://img.shields.io/badge/GUI-PySide6-green.svg)](https://doc.qt.io/qtforpython/) + +[繁體中文](README/README_zh-TW.md) | [简体中文](README/README_zh-CN.md) + +![Main GUI](images/main_gui.png) + +**PyBreeze** is a Python IDE purpose-built for automation engineers. It integrates Web, API, GUI, and load testing automation into a single unified environment — no plugin hunting, no complex environment setup, just open and start automating. + +--- + +## Table of Contents + +- [Features](#features) + - [Four-Dimensional Automation](#four-dimensional-automation) + - [IDE Core Capabilities](#ide-core-capabilities) + - [Built-in Tools](#built-in-tools) + - [AI-Assisted Development](#ai-assisted-development) + - [Plugin System](#plugin-system) + - [Multi-Language UI](#multi-language-ui) +- [Architecture](#architecture) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Integrated Automation Modules](#integrated-automation-modules) +- [Project Structure](#project-structure) +- [Dependencies](#dependencies) +- [Target Audience](#target-audience) +- [License](#license) + +--- + +## Features + +### Four-Dimensional Automation + +PyBreeze covers the full spectrum of automation testing needs out of the box: + +| Dimension | Module | Description | +|---|---|---| +| **Web Automation** | [WebRunner](https://github.com/Intergration-Automation-Testing/WebRunner) | Browser-based interaction simulation and testing with deep integration of browser drivers and element locators | +| **API Automation** | [APITestka](https://github.com/Intergration-Automation-Testing/APITestka) | RESTful API development and testing with built-in request builders, response analyzers, mock servers, and assertion verification | +| **GUI Automation** | [AutoControl](https://github.com/Intergration-Automation-Testing/AutoControl) | Desktop application automation via image recognition, coordinate-based positioning, keyboard/mouse control, and action recording | +| **Load & Stress Testing** | [LoadDensity](https://github.com/Intergration-Automation-Testing/LoadDensity) | High-concurrency performance testing engine for monitoring system stability under extreme pressure | + +Additionally: + +- **File Automation** — Automated file and directory operations via the [automation-file](https://github.com/Intergration-Automation-Testing/AutomationFile) module +- **Mail Automation** — Automated email sending (e.g., test report delivery) via [MailThunder](https://github.com/Intergration-Automation-Testing/MailThunder) +- **Test Framework** — Structured YAML-driven test execution via [TestPioneer](https://github.com/Intergration-Automation-Testing/TestPioneer) + +### IDE Core Capabilities + +PyBreeze is not just a code editor — it is a command center for the automation lifecycle: + +- **Syntax Highlighting** — Built-in Python syntax highlighting with deep keyword awareness for automation libraries (APITestka, AutoControl, WebRunner, LoadDensity, etc.). Custom syntax rules can be added via plugins. +- **Code Editor** — Built on [JEditor](https://github.com/Intergration-Automation-Testing/JEditor), a full-featured editor with tab management, file tree navigation, and project workspace support. +- **Script Execution** — Run automation scripts directly from the IDE with real-time output. Supports single-script and multi-script batch execution. +- **Report Generation** — Automation modules can generate HTML, JSON, and XML reports after test execution, with optional email delivery. +- **Integrated JupyterLab** — Launch JupyterLab directly as a tab within PyBreeze for interactive notebook-based development. Auto-installs JupyterLab if not present. +- **Virtual Environment Awareness** — Automatically detects and uses the project's virtual environment (`.venv` or `venv`). + +### Built-in Tools + +- **SSH Client** — Full SSH terminal client with: + - Password and private key authentication + - Interactive command execution + - Remote file tree viewer with CRUD operations (create folder, rename, delete, upload, download) +- **Package Manager** — Install automation modules and build tools directly from the IDE menu without leaving the editor. +- **Integrated Documentation** — Quick access to documentation and GitHub pages for each automation module directly from the menu bar. + +### AI-Assisted Development + +- **AI Code Review** — Send code to an LLM API endpoint for automated code review. Accept or reject suggestions directly in the IDE. +- **CoT (Chain-of-Thought) Prompt Editor** — Create and manage multi-step CoT prompts for structured code analysis, including: + - Code review prompts + - Code smell detection + - Linting analysis + - Step-by-step analysis + - Summary generation +- **Skill Prompt Editor** — Define and manage reusable skill-based prompts (code explanation, code review templates) that can be sent to LLM APIs. + +### Plugin System + +PyBreeze supports an extensible plugin architecture for: + +- **Syntax Highlighting** — Add syntax highlighting for any programming language via plugins +- **UI Translation** — Add new interface languages via translation plugins +- **Run Configurations** — Add "Run with..." support for compiled and interpreted languages (C, C++, Go, Java, Rust, etc.) +- **Plugin Browser** — Browse and install plugins from remote repositories directly within the IDE + +Plugins are auto-discovered from the `jeditor_plugins/` directory. See [PLUGIN_GUIDE.md](PLUGIN_GUIDE.md) for full documentation. + +**Bundled plugins:** C, C++, Go, Java, Rust syntax highlighting and run support; French translation. + +### Multi-Language UI + +The IDE interface supports multiple languages: + +- **English** (default) +- **Traditional Chinese** (繁體中文) +- Additional languages can be added via plugins + +--- + +## Architecture + +![Architecture Diagram](architecture_diagram/AutomationEditorArchitectureDiagram.drawio.png) + +PyBreeze follows a modular architecture: + +``` +PyBreeze UI (PySide6) +├── JEditor (Base Editor Engine) +│ ├── Code Editor with Tabs +│ ├── File Tree Navigation +│ ├── Syntax Highlighting Engine +│ └── Plugin System +├── Automation Menu +│ ├── APITestka ──→ APITestka Executor ──→ je_api_testka +│ ├── AutoControl ──→ AutoControl Executor ──→ je_auto_control +│ ├── WebRunner ──→ WebRunner Executor ──→ je_web_runner +│ ├── LoadDensity ──→ LoadDensity Executor ──→ je_load_density +│ ├── FileAutomation ──→ FileAutomation Executor ──→ automation-file +│ ├── MailThunder ──→ MailThunder Executor ──→ je-mail-thunder +│ └── TestPioneer ──→ TestPioneer Executor ──→ test_pioneer +├── Tools +│ ├── SSH Client (paramiko) +│ ├── AI Code Review Client +│ ├── CoT Prompt Editor +│ ├── Skill Prompt Editor +│ └── JupyterLab Integration +└── Install Menu + ├── Automation Module Installers + └── Build Tools Installer +``` + +Each automation module runs in its own subprocess via `PythonTaskProcessManager`, providing process isolation and preventing crashes from affecting the IDE. + +--- + +## Installation + +### From PyPI + +```bash +pip install pybreeze +``` + +### From Source + +```bash +git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git +cd AutomationEditor +pip install -r requirements.txt +``` + +### System Requirements + +- **Python**: 3.10 or higher +- **OS**: Windows, macOS, Linux +- **GUI Framework**: PySide6 6.11.0 (installed automatically) + +--- + +## Quick Start + +### Run via command line + +```bash +python -m pybreeze +``` + +### Run via Python script + +```python +from pybreeze import start_editor + +start_editor() +``` + +### Run from the exe directory + +```bash +python exe/start_pybreeze.py +``` + +Once launched, you can: + +1. **Write automation scripts** in the editor with syntax-aware auto-completion +2. **Execute scripts** via `Automation` menu — choose the target module (APITestka, WebRunner, etc.) +3. **View results** in the integrated output panel +4. **Generate reports** in HTML/JSON/XML formats +5. **Send reports** via email using MailThunder integration + +--- + +## Integrated Automation Modules + +### APITestka — API Testing + +- HTTP method testing (GET, POST, PUT, DELETE, etc.) +- Async HTTP support via httpx +- Mock server creation with Flask +- Report generation (HTML, JSON, XML) +- Scheduler-based event triggering +- Socket server support + +### AutoControl — GUI Automation + +- Mouse control (click, drag, scroll, position tracking) +- Keyboard simulation (type, hotkey, key press/release) +- Image recognition and locate-and-click +- Screenshot capture +- Action recording and playback +- Shell command execution +- Process management + +### WebRunner — Web Automation + +- Browser driver integration +- Element location and interaction +- Web-based test scripting +- Report generation + +### LoadDensity — Load Testing + +- Concurrent request simulation +- Performance metrics collection +- Stress test scenario management +- Report generation + +### MailThunder — Email Automation + +- SMTP email sending +- HTML report delivery +- Attachment support +- Environment variable-based configuration + +### TestPioneer — Test Framework + +- YAML-based test definition +- Template generation +- Structured test execution + +### File Automation + +- Automated file and directory operations +- Batch file processing + +--- + +## Project Structure + +``` +PyBreeze/ +├── pybreeze/ +│ ├── __init__.py # Public API (start_editor, plugin re-exports) +│ ├── __main__.py # Entry point (python -m pybreeze) +│ ├── extend/ +│ │ ├── mail_thunder_extend/ # Email report sending after tests +│ │ ├── process_executor/ # Subprocess managers for each automation module +│ │ │ ├── api_testka/ +│ │ │ ├── auto_control/ +│ │ │ ├── file_automation/ +│ │ │ ├── load_density/ +│ │ │ ├── mail_thunder/ +│ │ │ ├── test_pioneer/ +│ │ │ └── web_runner/ +│ │ └── process_executor/python_task_process_manager.py +│ ├── extend_multi_language/ # Built-in translations (English, Traditional Chinese) +│ ├── pybreeze_ui/ +│ │ ├── editor_main/ # Main window (extends JEditor) +│ │ ├── connect_gui/ssh/ # SSH client widgets +│ │ ├── extend_ai_gui/ # AI code review & prompt editors +│ │ ├── jupyter_lab_gui/ # JupyterLab integration +│ │ ├── menu/ # Menu bar construction +│ │ ├── syntax/ # Automation keyword definitions +│ │ └── show_code_window/ # Code display widgets +│ └── utils/ # Logging, exceptions, file processing, package management +├── exe/ # Standalone launcher & build configs +├── docs/ # Sphinx documentation source +├── test/ # Unit tests +├── images/ # Screenshots +├── architecture_diagram/ # Architecture diagrams +├── PLUGIN_GUIDE.md # Plugin development documentation +├── pyproject.toml # Package configuration +├── requirements.txt # Runtime dependencies +└── dev_requirements.txt # Development dependencies +``` + +--- + +## Dependencies + +### Runtime + +| Package | Purpose | +|---|---| +| `PySide6` (6.11.0) | GUI framework (Qt for Python) | +| `je-editor` | Base code editor engine | +| `je_api_testka` | API testing automation | +| `je_auto_control` | GUI/desktop automation | +| `je_web_runner` | Web browser automation | +| `je_load_density` | Load and stress testing | +| `je-mail-thunder` | Email automation | +| `automation-file` | File operation automation | +| `test_pioneer` | YAML-based test framework | +| `paramiko` | SSH client support | +| `jupyterlab` | Integrated notebook environment | + +### Development + +`build`, `twine`, `sphinx`, `sphinx-rtd-theme`, `auto-py-to-exe` + +--- + +## Target Audience + +- **Python Developers** — A lightweight, dedicated environment for building automation scripts without the overhead of heavy general-purpose IDEs +- **SDET (Software Development Engineers in Test)** — Professionals maintaining Web, API, and Performance tests simultaneously in one tool +- **Automation Beginners** — A friendly IDE that lowers the barrier to entry for Python automation with zero-config environment setup +- **DevOps Teams** — A platform for rapidly building and debugging integration test suites within CI/CD pipelines + +--- + +## License + +This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details. + +Copyright (c) 2022 JE-Chen diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md new file mode 100644 index 0000000..022a4d5 --- /dev/null +++ b/README/README_zh-CN.md @@ -0,0 +1,333 @@ +# PyBreeze:自动化优先的 IDE + +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../LICENSE) +[![PySide6](https://img.shields.io/badge/GUI-PySide6-green.svg)](https://doc.qt.io/qtforpython/) + +[English](../README.md) | [繁體中文](README_zh-TW.md) + +![主界面](../images/main_gui.png) + +**PyBreeze** 是一款专为自动化工程师打造的 Python IDE。它将 Web、API、GUI 和负载测试自动化整合到单一统一环境中——无需寻找插件、无需复杂的环境配置,打开即可开始自动化。 + +--- + +## 目录 + +- [功能特色](#功能特色) + - [四维自动化](#四维自动化) + - [IDE 核心功能](#ide-核心功能) + - [内置工具](#内置工具) + - [AI 辅助开发](#ai-辅助开发) + - [插件系统](#插件系统) + - [多语言界面](#多语言界面) +- [架构设计](#架构设计) +- [安装方式](#安装方式) +- [快速开始](#快速开始) +- [集成自动化模块](#集成自动化模块) +- [项目结构](#项目结构) +- [依赖项](#依赖项) +- [目标用户](#目标用户) +- [许可证](#许可证) + +--- + +## 功能特色 + +### 四维自动化 + +PyBreeze 开箱即用,涵盖自动化测试的完整范围: + +| 维度 | 模块 | 说明 | +|---|---|---| +| **Web 自动化** | [WebRunner](https://github.com/Intergration-Automation-Testing/WebRunner) | 浏览器交互模拟与测试,深度集成浏览器驱动与元素定位器 | +| **API 自动化** | [APITestka](https://github.com/Intergration-Automation-Testing/APITestka) | RESTful API 开发与测试,内置请求构建器、响应分析器、Mock 服务器及断言验证 | +| **GUI 自动化** | [AutoControl](https://github.com/Intergration-Automation-Testing/AutoControl) | 桌面应用程序自动化,支持图像识别、坐标定位、键盘鼠标控制及动作录制 | +| **负载与压力测试** | [LoadDensity](https://github.com/Intergration-Automation-Testing/LoadDensity) | 高并发性能测试引擎,用于监控系统在极端压力下的稳定性 | + +此外还包含: + +- **文件自动化** — 通过 [automation-file](https://github.com/Intergration-Automation-Testing/AutomationFile) 模块实现自动化文件与目录操作 +- **邮件自动化** — 通过 [MailThunder](https://github.com/Intergration-Automation-Testing/MailThunder) 实现自动化邮件发送(例如测试报告投递) +- **测试框架** — 通过 [TestPioneer](https://github.com/Intergration-Automation-Testing/TestPioneer) 实现结构化 YAML 驱动的测试执行 + +### IDE 核心功能 + +PyBreeze 不仅仅是一个代码编辑器——它是自动化生命周期的指挥中心: + +- **语法高亮** — 内置 Python 语法高亮,针对自动化库(APITestka、AutoControl、WebRunner、LoadDensity 等)提供深度关键字识别。可通过插件添加自定义语法规则。 +- **代码编辑器** — 基于 [JEditor](https://github.com/Intergration-Automation-Testing/JEditor) 构建,提供完整的编辑器功能,包含标签页管理、文件树导航与项目工作区支持。 +- **脚本执行** — 直接在 IDE 中执行自动化脚本,并实时显示输出。支持单脚本与多脚本批量执行。 +- **报告生成** — 自动化模块可在测试执行后生成 HTML、JSON 和 XML 报告,并支持可选的电子邮件投递。 +- **集成 JupyterLab** — 在 PyBreeze 中直接以标签页方式启动 JupyterLab,进行交互式笔记本开发。若未安装 JupyterLab 将自动安装。 +- **虚拟环境感知** — 自动检测并使用项目的虚拟环境(`.venv` 或 `venv`)。 + +### 内置工具 + +- **SSH 客户端** — 完整的 SSH 终端客户端,支持: + - 密码与私钥认证 + - 交互式命令执行 + - 远程文件树查看器,支持 CRUD 操作(创建文件夹、重命名、删除、上传、下载) +- **包管理器** — 直接从 IDE 菜单安装自动化模块和构建工具,无需离开编辑器。 +- **集成文档** — 从菜单栏快速访问每个自动化模块的文档和 GitHub 页面。 + +### AI 辅助开发 + +- **AI 代码审查** — 将代码发送到 LLM API 端点进行自动化代码审查。可直接在 IDE 中接受或拒绝建议。 +- **CoT(思维链)提示词编辑器** — 创建和管理多步骤 CoT 提示词,用于结构化代码分析,包含: + - 代码审查提示词 + - Code Smell 检测 + - 代码检查分析 + - 逐步分析 + - 摘要生成 +- **Skill 提示词编辑器** — 定义和管理可重复使用的技能型提示词(代码解说、代码审查模板),可发送至 LLM API。 + +### 插件系统 + +PyBreeze 支持可扩展的插件架构,用于: + +- **语法高亮** — 通过插件为任何编程语言添加语法高亮 +- **UI 翻译** — 通过翻译插件添加新的界面语言 +- **运行配置** — 为编译型和解释型语言添加"以...运行"支持(C、C++、Go、Java、Rust 等) +- **插件浏览器** — 直接在 IDE 中从远程仓库浏览并安装插件 + +插件会从 `jeditor_plugins/` 目录自动发现加载。完整文档请参阅 [PLUGIN_GUIDE.md](../PLUGIN_GUIDE.md)。 + +**内置插件:** C、C++、Go、Java、Rust 语法高亮与运行支持;法语翻译。 + +### 多语言界面 + +IDE 界面支持多种语言: + +- **English**(英语,默认) +- **繁体中文** +- 可通过插件添加其他语言 + +--- + +## 架构设计 + +![架构图](../architecture_diagram/AutomationEditorArchitectureDiagram.drawio.png) + +PyBreeze 采用模块化架构: + +``` +PyBreeze UI (PySide6) +├── JEditor(基础编辑器引擎) +│ ├── 代码编辑器与标签页 +│ ├── 文件树导航 +│ ├── 语法高亮引擎 +│ └── 插件系统 +├── 自动化菜单 +│ ├── APITestka ──→ APITestka 执行器 ──→ je_api_testka +│ ├── AutoControl ──→ AutoControl 执行器 ──→ je_auto_control +│ ├── WebRunner ──→ WebRunner 执行器 ──→ je_web_runner +│ ├── LoadDensity ──→ LoadDensity 执行器 ──→ je_load_density +│ ├── FileAutomation ──→ FileAutomation 执行器 ──→ automation-file +│ ├── MailThunder ──→ MailThunder 执行器 ──→ je-mail-thunder +│ └── TestPioneer ──→ TestPioneer 执行器 ──→ test_pioneer +├── 工具 +│ ├── SSH 客户端(paramiko) +│ ├── AI 代码审查客户端 +│ ├── CoT 提示词编辑器 +│ ├── Skill 提示词编辑器 +│ └── JupyterLab 集成 +└── 安装菜单 + ├── 自动化模块安装器 + └── 构建工具安装器 +``` + +每个自动化模块都通过 `PythonTaskProcessManager` 在独立的子进程中执行,提供进程隔离,防止崩溃影响 IDE。 + +--- + +## 安装方式 + +### 从 PyPI 安装 + +```bash +pip install pybreeze +``` + +### 从源码安装 + +```bash +git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git +cd AutomationEditor +pip install -r requirements.txt +``` + +### 系统要求 + +- **Python**:3.10 或更高版本 +- **操作系统**:Windows、macOS、Linux +- **GUI 框架**:PySide6 6.11.0(自动安装) + +--- + +## 快速开始 + +### 通过命令行运行 + +```bash +python -m pybreeze +``` + +### 通过 Python 脚本运行 + +```python +from pybreeze import start_editor + +start_editor() +``` + +### 从 exe 目录运行 + +```bash +python exe/start_pybreeze.py +``` + +启动后,您可以: + +1. **编写自动化脚本** — 在编辑器中享有语法感知的自动补全 +2. **执行脚本** — 通过 `自动化` 菜单,选择目标模块(APITestka、WebRunner 等) +3. **查看结果** — 在集成式输出面板中查看 +4. **生成报告** — 支持 HTML/JSON/XML 格式 +5. **发送报告** — 使用 MailThunder 集成功能通过电子邮件发送 + +--- + +## 集成自动化模块 + +### APITestka — API 测试 + +- HTTP 方法测试(GET、POST、PUT、DELETE 等) +- 通过 httpx 支持异步 HTTP +- 使用 Flask 创建 Mock 服务器 +- 报告生成(HTML、JSON、XML) +- 基于调度器的事件触发 +- Socket 服务器支持 + +### AutoControl — GUI 自动化 + +- 鼠标控制(点击、拖拽、滚动、位置追踪) +- 键盘模拟(输入、快捷键、按键按下/释放) +- 图像识别与定位点击 +- 屏幕截图 +- 动作录制与回放 +- Shell 命令执行 +- 进程管理 + +### WebRunner — Web 自动化 + +- 浏览器驱动集成 +- 元素定位与交互 +- 基于 Web 的测试脚本 +- 报告生成 + +### LoadDensity — 负载测试 + +- 并发请求模拟 +- 性能指标收集 +- 压力测试场景管理 +- 报告生成 + +### MailThunder — 邮件自动化 + +- SMTP 邮件发送 +- HTML 报告投递 +- 附件支持 +- 基于环境变量的配置 + +### TestPioneer — 测试框架 + +- 基于 YAML 的测试定义 +- 模板生成 +- 结构化测试执行 + +### File Automation — 文件自动化 + +- 自动化文件与目录操作 +- 批量文件处理 + +--- + +## 项目结构 + +``` +PyBreeze/ +├── pybreeze/ +│ ├── __init__.py # 公开 API(start_editor、插件 re-export) +│ ├── __main__.py # 入口点(python -m pybreeze) +│ ├── extend/ +│ │ ├── mail_thunder_extend/ # 测试后邮件报告发送 +│ │ ├── process_executor/ # 各自动化模块的子进程管理器 +│ │ │ ├── api_testka/ +│ │ │ ├── auto_control/ +│ │ │ ├── file_automation/ +│ │ │ ├── load_density/ +│ │ │ ├── mail_thunder/ +│ │ │ ├── test_pioneer/ +│ │ │ └── web_runner/ +│ │ └── process_executor/python_task_process_manager.py +│ ├── extend_multi_language/ # 内置翻译(英语、繁体中文) +│ ├── pybreeze_ui/ +│ │ ├── editor_main/ # 主窗口(扩展 JEditor) +│ │ ├── connect_gui/ssh/ # SSH 客户端组件 +│ │ ├── extend_ai_gui/ # AI 代码审查与提示词编辑器 +│ │ ├── jupyter_lab_gui/ # JupyterLab 集成 +│ │ ├── menu/ # 菜单栏构建 +│ │ ├── syntax/ # 自动化关键字定义 +│ │ └── show_code_window/ # 代码显示组件 +│ └── utils/ # 日志、异常处理、文件处理、包管理 +├── exe/ # 独立启动器与构建配置 +├── docs/ # Sphinx 文档源码 +├── test/ # 单元测试 +├── images/ # 截图 +├── architecture_diagram/ # 架构图 +├── PLUGIN_GUIDE.md # 插件开发文档 +├── pyproject.toml # 包配置 +├── requirements.txt # 运行时依赖项 +└── dev_requirements.txt # 开发依赖项 +``` + +--- + +## 依赖项 + +### 运行时 + +| 包 | 用途 | +|---|---| +| `PySide6` (6.11.0) | GUI 框架(Qt for Python)| +| `je-editor` | 基础代码编辑器引擎 | +| `je_api_testka` | API 测试自动化 | +| `je_auto_control` | GUI/桌面自动化 | +| `je_web_runner` | Web 浏览器自动化 | +| `je_load_density` | 负载与压力测试 | +| `je-mail-thunder` | 邮件自动化 | +| `automation-file` | 文件操作自动化 | +| `test_pioneer` | 基于 YAML 的测试框架 | +| `paramiko` | SSH 客户端支持 | +| `jupyterlab` | 集成式笔记本环境 | + +### 开发 + +`build`、`twine`、`sphinx`、`sphinx-rtd-theme`、`auto-py-to-exe` + +--- + +## 目标用户 + +- **Python 开发者** — 一个轻量、专用的环境,用于构建自动化脚本,无需承受重量级通用 IDE 的负担 +- **SDET(测试开发工程师)** — 需要在同一工具中同时维护 Web、API 和性能测试的专业人士 +- **自动化初学者** — 一个友好的 IDE,通过零配置环境降低 Python 自动化的入门门槛 +- **DevOps 团队** — 一个在 CI/CD 流水线中快速构建和调试集成测试套件的平台 + +--- + +## 许可证 + +本项目采用 MIT 许可证——详情请参阅 [LICENSE](../LICENSE) 文件。 + +Copyright (c) 2022 JE-Chen diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md new file mode 100644 index 0000000..1320edc --- /dev/null +++ b/README/README_zh-TW.md @@ -0,0 +1,333 @@ +# PyBreeze:自動化優先的 IDE + +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../LICENSE) +[![PySide6](https://img.shields.io/badge/GUI-PySide6-green.svg)](https://doc.qt.io/qtforpython/) + +[English](../README.md) | [简体中文](README_zh-CN.md) + +![主介面](../images/main_gui.png) + +**PyBreeze** 是一款專為自動化工程師打造的 Python IDE。它將 Web、API、GUI 和負載測試自動化整合到單一統一環境中——無需尋找插件、無需複雜的環境設定,開啟即可開始自動化。 + +--- + +## 目錄 + +- [功能特色](#功能特色) + - [四維自動化](#四維自動化) + - [IDE 核心功能](#ide-核心功能) + - [內建工具](#內建工具) + - [AI 輔助開發](#ai-輔助開發) + - [插件系統](#插件系統) + - [多語言介面](#多語言介面) +- [架構設計](#架構設計) +- [安裝方式](#安裝方式) +- [快速開始](#快速開始) +- [整合自動化模組](#整合自動化模組) +- [專案結構](#專案結構) +- [依賴項目](#依賴項目) +- [目標使用者](#目標使用者) +- [授權條款](#授權條款) + +--- + +## 功能特色 + +### 四維自動化 + +PyBreeze 開箱即用,涵蓋自動化測試的完整範疇: + +| 維度 | 模組 | 說明 | +|---|---|---| +| **Web 自動化** | [WebRunner](https://github.com/Intergration-Automation-Testing/WebRunner) | 瀏覽器互動模擬與測試,深度整合瀏覽器驅動與元素定位器 | +| **API 自動化** | [APITestka](https://github.com/Intergration-Automation-Testing/APITestka) | RESTful API 開發與測試,內建請求建構器、回應分析器、Mock 伺服器及斷言驗證 | +| **GUI 自動化** | [AutoControl](https://github.com/Intergration-Automation-Testing/AutoControl) | 桌面應用程式自動化,支援圖像辨識、座標定位、鍵盤滑鼠控制及動作錄製 | +| **負載與壓力測試** | [LoadDensity](https://github.com/Intergration-Automation-Testing/LoadDensity) | 高併發效能測試引擎,用於監控系統在極端壓力下的穩定性 | + +此外還包含: + +- **檔案自動化** — 透過 [automation-file](https://github.com/Intergration-Automation-Testing/AutomationFile) 模組實現自動化檔案與目錄操作 +- **郵件自動化** — 透過 [MailThunder](https://github.com/Intergration-Automation-Testing/MailThunder) 實現自動化郵件寄送(例如測試報告傳遞) +- **測試框架** — 透過 [TestPioneer](https://github.com/Intergration-Automation-Testing/TestPioneer) 實現結構化 YAML 驅動的測試執行 + +### IDE 核心功能 + +PyBreeze 不僅僅是一個程式碼編輯器——它是自動化生命週期的指揮中心: + +- **語法高亮** — 內建 Python 語法高亮,針對自動化函式庫(APITestka、AutoControl、WebRunner、LoadDensity 等)提供深度關鍵字識別。可透過插件新增自訂語法規則。 +- **程式碼編輯器** — 基於 [JEditor](https://github.com/Intergration-Automation-Testing/JEditor) 構建,提供完整的編輯器功能,包含分頁管理、檔案樹瀏覽與專案工作區支援。 +- **腳本執行** — 直接在 IDE 中執行自動化腳本,並即時顯示輸出。支援單一腳本與多腳本批次執行。 +- **報告生成** — 自動化模組可在測試執行後生成 HTML、JSON 和 XML 報告,並支援可選的電子郵件傳遞。 +- **整合 JupyterLab** — 在 PyBreeze 中直接以分頁方式啟動 JupyterLab,進行互動式筆記本開發。若未安裝 JupyterLab 將自動安裝。 +- **虛擬環境感知** — 自動偵測並使用專案的虛擬環境(`.venv` 或 `venv`)。 + +### 內建工具 + +- **SSH 用戶端** — 完整的 SSH 終端用戶端,支援: + - 密碼與私鑰驗證 + - 互動式指令執行 + - 遠端檔案樹檢視器,支援 CRUD 操作(建立資料夾、重新命名、刪除、上傳、下載) +- **套件管理器** — 直接從 IDE 選單安裝自動化模組和建構工具,無需離開編輯器。 +- **整合文件** — 從選單列快速存取每個自動化模組的文件和 GitHub 頁面。 + +### AI 輔助開發 + +- **AI 程式碼審查** — 將程式碼傳送到 LLM API 端點進行自動化程式碼審查。可直接在 IDE 中接受或拒絕建議。 +- **CoT(思維鏈)提示詞編輯器** — 建立和管理多步驟 CoT 提示詞,用於結構化程式碼分析,包含: + - 程式碼審查提示詞 + - Code Smell 偵測 + - 程式碼檢查分析 + - 逐步分析 + - 摘要生成 +- **Skill 提示詞編輯器** — 定義和管理可重複使用的技能型提示詞(程式碼解說、程式碼審查範本),可傳送至 LLM API。 + +### 插件系統 + +PyBreeze 支援可擴展的插件架構,用於: + +- **語法高亮** — 透過插件為任何程式語言新增語法高亮 +- **UI 翻譯** — 透過翻譯插件新增新的介面語言 +- **執行設定** — 為編譯式和直譯式語言新增「以...執行」支援(C、C++、Go、Java、Rust 等) +- **插件瀏覽器** — 直接在 IDE 中從遠端儲存庫瀏覽並安裝插件 + +插件會從 `jeditor_plugins/` 目錄自動探索載入。完整文件請參閱 [PLUGIN_GUIDE.md](../PLUGIN_GUIDE.md)。 + +**內建插件:** C、C++、Go、Java、Rust 語法高亮與執行支援;法文翻譯。 + +### 多語言介面 + +IDE 介面支援多種語言: + +- **English**(英文,預設) +- **繁體中文** +- 可透過插件新增其他語言 + +--- + +## 架構設計 + +![架構圖](../architecture_diagram/AutomationEditorArchitectureDiagram.drawio.png) + +PyBreeze 採用模組化架構: + +``` +PyBreeze UI (PySide6) +├── JEditor(基礎編輯器引擎) +│ ├── 程式碼編輯器與分頁 +│ ├── 檔案樹瀏覽 +│ ├── 語法高亮引擎 +│ └── 插件系統 +├── 自動化選單 +│ ├── APITestka ──→ APITestka 執行器 ──→ je_api_testka +│ ├── AutoControl ──→ AutoControl 執行器 ──→ je_auto_control +│ ├── WebRunner ──→ WebRunner 執行器 ──→ je_web_runner +│ ├── LoadDensity ──→ LoadDensity 執行器 ──→ je_load_density +│ ├── FileAutomation ──→ FileAutomation 執行器 ──→ automation-file +│ ├── MailThunder ──→ MailThunder 執行器 ──→ je-mail-thunder +│ └── TestPioneer ──→ TestPioneer 執行器 ──→ test_pioneer +├── 工具 +│ ├── SSH 用戶端(paramiko) +│ ├── AI 程式碼審查用戶端 +│ ├── CoT 提示詞編輯器 +│ ├── Skill 提示詞編輯器 +│ └── JupyterLab 整合 +└── 安裝選單 + ├── 自動化模組安裝器 + └── 建構工具安裝器 +``` + +每個自動化模組都透過 `PythonTaskProcessManager` 在獨立的子行程中執行,提供行程隔離,防止崩潰影響 IDE。 + +--- + +## 安裝方式 + +### 從 PyPI 安裝 + +```bash +pip install pybreeze +``` + +### 從原始碼安裝 + +```bash +git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git +cd AutomationEditor +pip install -r requirements.txt +``` + +### 系統需求 + +- **Python**:3.10 或更高版本 +- **作業系統**:Windows、macOS、Linux +- **GUI 框架**:PySide6 6.11.0(自動安裝) + +--- + +## 快速開始 + +### 透過命令列執行 + +```bash +python -m pybreeze +``` + +### 透過 Python 腳本執行 + +```python +from pybreeze import start_editor + +start_editor() +``` + +### 從 exe 目錄執行 + +```bash +python exe/start_pybreeze.py +``` + +啟動後,您可以: + +1. **撰寫自動化腳本** — 在編輯器中享有語法感知的自動補全 +2. **執行腳本** — 透過 `自動化` 選單,選擇目標模組(APITestka、WebRunner 等) +3. **檢視結果** — 在整合式輸出面板中查看 +4. **生成報告** — 支援 HTML/JSON/XML 格式 +5. **寄送報告** — 使用 MailThunder 整合功能透過電子郵件發送 + +--- + +## 整合自動化模組 + +### APITestka — API 測試 + +- HTTP 方法測試(GET、POST、PUT、DELETE 等) +- 透過 httpx 支援非同步 HTTP +- 使用 Flask 建立 Mock 伺服器 +- 報告生成(HTML、JSON、XML) +- 基於排程器的事件觸發 +- Socket 伺服器支援 + +### AutoControl — GUI 自動化 + +- 滑鼠控制(點擊、拖曳、滾動、位置追蹤) +- 鍵盤模擬(輸入、快捷鍵、按鍵按下/釋放) +- 圖像辨識與定位點擊 +- 螢幕截圖 +- 動作錄製與重播 +- Shell 指令執行 +- 行程管理 + +### WebRunner — Web 自動化 + +- 瀏覽器驅動整合 +- 元素定位與互動 +- 基於 Web 的測試腳本 +- 報告生成 + +### LoadDensity — 負載測試 + +- 併發請求模擬 +- 效能指標收集 +- 壓力測試情境管理 +- 報告生成 + +### MailThunder — 郵件自動化 + +- SMTP 郵件寄送 +- HTML 報告傳遞 +- 附件支援 +- 基於環境變數的設定 + +### TestPioneer — 測試框架 + +- 基於 YAML 的測試定義 +- 範本生成 +- 結構化測試執行 + +### File Automation — 檔案自動化 + +- 自動化檔案與目錄操作 +- 批次檔案處理 + +--- + +## 專案結構 + +``` +PyBreeze/ +├── pybreeze/ +│ ├── __init__.py # 公開 API(start_editor、插件 re-export) +│ ├── __main__.py # 進入點(python -m pybreeze) +│ ├── extend/ +│ │ ├── mail_thunder_extend/ # 測試後郵件報告寄送 +│ │ ├── process_executor/ # 各自動化模組的子行程管理器 +│ │ │ ├── api_testka/ +│ │ │ ├── auto_control/ +│ │ │ ├── file_automation/ +│ │ │ ├── load_density/ +│ │ │ ├── mail_thunder/ +│ │ │ ├── test_pioneer/ +│ │ │ └── web_runner/ +│ │ └── process_executor/python_task_process_manager.py +│ ├── extend_multi_language/ # 內建翻譯(英文、繁體中文) +│ ├── pybreeze_ui/ +│ │ ├── editor_main/ # 主視窗(擴展 JEditor) +│ │ ├── connect_gui/ssh/ # SSH 用戶端元件 +│ │ ├── extend_ai_gui/ # AI 程式碼審查與提示詞編輯器 +│ │ ├── jupyter_lab_gui/ # JupyterLab 整合 +│ │ ├── menu/ # 選單列建構 +│ │ ├── syntax/ # 自動化關鍵字定義 +│ │ └── show_code_window/ # 程式碼顯示元件 +│ └── utils/ # 日誌、例外處理、檔案處理、套件管理 +├── exe/ # 獨立啟動器與建構設定 +├── docs/ # Sphinx 文件原始碼 +├── test/ # 單元測試 +├── images/ # 截圖 +├── architecture_diagram/ # 架構圖 +├── PLUGIN_GUIDE.md # 插件開發文件 +├── pyproject.toml # 套件設定 +├── requirements.txt # 執行階段依賴項 +└── dev_requirements.txt # 開發依賴項 +``` + +--- + +## 依賴項目 + +### 執行階段 + +| 套件 | 用途 | +|---|---| +| `PySide6` (6.11.0) | GUI 框架(Qt for Python)| +| `je-editor` | 基礎程式碼編輯器引擎 | +| `je_api_testka` | API 測試自動化 | +| `je_auto_control` | GUI/桌面自動化 | +| `je_web_runner` | Web 瀏覽器自動化 | +| `je_load_density` | 負載與壓力測試 | +| `je-mail-thunder` | 郵件自動化 | +| `automation-file` | 檔案操作自動化 | +| `test_pioneer` | 基於 YAML 的測試框架 | +| `paramiko` | SSH 用戶端支援 | +| `jupyterlab` | 整合式筆記本環境 | + +### 開發 + +`build`、`twine`、`sphinx`、`sphinx-rtd-theme`、`auto-py-to-exe` + +--- + +## 目標使用者 + +- **Python 開發者** — 一個輕量、專用的環境,用於構建自動化腳本,無需承受重量級通用 IDE 的負擔 +- **SDET(測試開發工程師)** — 需要在同一工具中同時維護 Web、API 和效能測試的專業人士 +- **自動化初學者** — 一個友善的 IDE,透過零設定環境降低 Python 自動化的入門門檻 +- **DevOps 團隊** — 一個在 CI/CD 流水線中快速構建和除錯整合測試套件的平台 + +--- + +## 授權條款 + +本專案採用 MIT 授權條款——詳情請參閱 [LICENSE](../LICENSE) 檔案。 + +Copyright (c) 2022 JE-Chen diff --git a/stable.toml b/dev.toml similarity index 91% rename from stable.toml rename to dev.toml index f67d831..ef4afb4 100644 --- a/stable.toml +++ b/dev.toml @@ -1,12 +1,12 @@ -# Rename to build stable version -# This is stable version +# Rename to dev version +# This is dev version [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] -name = "pybreeze" -version = "1.0.14" +name = "pybreeze_dev" +version = "1.0.13" authors = [ { name = "JE-Chen", email = "jechenmailman@gmail.com" }, ] @@ -36,5 +36,6 @@ Code = "https://github.com/Intergration-Automation-Testing/AutomationEditor" file = "README.md" content-type = "text/markdown" + [tool.setuptools.packages] find = { namespaces = false } diff --git a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py index 9ddf7a1..134faca 100644 --- a/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py +++ b/pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py @@ -10,8 +10,9 @@ def send_after_test(html_report_path: str | None = None) -> None: try: - from je_mail_thunder import SMTPWrapper + from je_mail_thunder import SMTPWrapper, read_output_content, get_mail_thunder_os_environ mail_thunder_smtp: SMTPWrapper = SMTPWrapper() + mail_thunder_smtp.later_init() if not mail_thunder_smtp.login_state: raise ITESendHtmlReportException @@ -23,7 +24,18 @@ def send_after_test(html_report_path: str | None = None) -> None: pybreeze_logger.error(f"Report file not found: {report_path}") return - user: str = mail_thunder_smtp.user + # Resolve user from content file or environment variables + user: str | None = None + user_info = read_output_content() + if user_info is not None and isinstance(user_info, dict): + user = user_info.get("user") + if user is None: + env_info = get_mail_thunder_os_environ() + user = env_info.get("mail_thunder_user") + if user is None: + pybreeze_logger.error("Cannot determine mail user for sending report") + return + with open(report_path, encoding="utf-8") as file: html_string: str = file.read() message: MIMEMultipart = mail_thunder_smtp.create_message_with_attach( diff --git a/pybreeze/pybreeze_ui/syntax/syntax_keyword.py b/pybreeze/pybreeze_ui/syntax/syntax_keyword.py index 93d6dac..60344f4 100644 --- a/pybreeze/pybreeze_ui/syntax/syntax_keyword.py +++ b/pybreeze/pybreeze_ui/syntax/syntax_keyword.py @@ -90,9 +90,9 @@ "LD_add_package_to_executor", "LD_scheduler_event_trigger", "LD_remove_blocking_scheduler_job", "LD_remove_nonblocking_scheduler_job", "LD_start_blocking_scheduler", "LD_start_nonblocking_scheduler", "LD_start_all_scheduler", "LD_shutdown_blocking_scheduler", "LD_shutdown_nonblocking_scheduler", - "create_env", "LD_start_test", "SchedulerManager", + "create_env", "SchedulerManager", "locust_wrapper_proxy", - "prepare_env", "prepare_env", + "prepare_env", "test_record_instance", "execute_action", "execute_files", "executor", "add_command_to_executor", "get_dir_files_as_list", diff --git a/pyproject.toml b/pyproject.toml index ef4afb4..f5ab5eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ -# Rename to dev version -# This is dev version +# Rename to build stable version +# This is stable version [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] -name = "pybreeze_dev" -version = "1.0.13" +name = "pybreeze" +version = "1.0.15" authors = [ { name = "JE-Chen", email = "jechenmailman@gmail.com" }, ] @@ -36,6 +36,5 @@ Code = "https://github.com/Intergration-Automation-Testing/AutomationEditor" file = "README.md" content-type = "text/markdown" - [tool.setuptools.packages] find = { namespaces = false } From 58da9e31bc10175432a94930149f31187453bd02 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 4 Apr 2026 19:16:40 +0800 Subject: [PATCH 3/5] Update both version Update both version --- dev.toml | 2 +- images/main_gui.png | Bin 56081 -> 52721 bytes pybreeze/utils/logging/logger.py | 1 + pyproject.toml => stable.toml | 2 +- 4 files changed, 3 insertions(+), 2 deletions(-) rename pyproject.toml => stable.toml (98%) diff --git a/dev.toml b/dev.toml index ef4afb4..ff0f57e 100644 --- a/dev.toml +++ b/dev.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "pybreeze_dev" -version = "1.0.13" +version = "1.0.14" authors = [ { name = "JE-Chen", email = "jechenmailman@gmail.com" }, ] diff --git a/images/main_gui.png b/images/main_gui.png index eecff5c886222221540a29186f4e4c7b5db4c401..922aad560c26906addf6e0682b066210476ac840 100644 GIT binary patch literal 52721 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 literal 56081 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 bdBAJQ4(Hybd_5ATFMjvip*!oh$DaK=oo|yi diff --git a/pybreeze/utils/logging/logger.py b/pybreeze/utils/logging/logger.py index a7a93d0..2dee1e8 100644 --- a/pybreeze/utils/logging/logger.py +++ b/pybreeze/utils/logging/logger.py @@ -7,6 +7,7 @@ # 建立 AutoControlGUI 專用 logger Create dedicated logger pybreeze_logger = logging.getLogger("Pybreeze") +pybreeze_logger.setLevel(logging.DEBUG) # 日誌格式 Formatter formatter = logging.Formatter( diff --git a/pyproject.toml b/stable.toml similarity index 98% rename from pyproject.toml rename to stable.toml index f5ab5eb..a6e7ecd 100644 --- a/pyproject.toml +++ b/stable.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "pybreeze" -version = "1.0.15" +version = "1.0.16" authors = [ { name = "JE-Chen", email = "jechenmailman@gmail.com" }, ] From 7291a684056017658049df519dc11b307bb72d2c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 4 Apr 2026 20:02:54 +0800 Subject: [PATCH 4/5] Update stable version Update stable version --- pybreeze/pybreeze_ui/editor_main/main_ui.py | 11 +++++++---- stable.toml => pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) rename stable.toml => pyproject.toml (98%) diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 3e381e0..917b5ab 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -77,13 +77,15 @@ def closeEvent(self, event) -> None: widget.close() super().closeEvent(event) - @classmethod - def debug_close(cls) -> None: + @staticmethod + def debug_close() -> None: """ Use to run CI test. :return: None """ - sys.exit(0) + app = QApplication.instance() + if app is not None: + app.quit() def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwargs) -> None: @@ -104,4 +106,5 @@ def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwar except Exception as error: from pybreeze.utils.logging.logger import pybreeze_logger pybreeze_logger.error(f"Startup setting error: {error}") - sys.exit(new_ide.exec()) + ret = new_ide.exec() + os._exit(ret) diff --git a/stable.toml b/pyproject.toml similarity index 98% rename from stable.toml rename to pyproject.toml index a6e7ecd..5a3d1c0 100644 --- a/stable.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "pybreeze" -version = "1.0.16" +version = "1.0.17" authors = [ { name = "JE-Chen", email = "jechenmailman@gmail.com" }, ] From bba1d7600282e2f1b822f620aee9c5c82d86c705 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 4 Apr 2026 20:39:13 +0800 Subject: [PATCH 5/5] Fix github actions Fix github actions --- .github/workflows/dev.yml | 4 ++++ .github/workflows/stable.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index a62192c..32eddba 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -35,5 +35,9 @@ jobs: run: python -m pytest test/test_utils/ -v --tb=short - name: Run AutomationEditor With Debug Mode run: python ./test/unit_test/start_automation/start_automation_test.py + env: + PYTHONPATH: . - name: Extend AutomationEditor run: python ./test/unit_test/start_automation/extend_automation_test.py + env: + PYTHONPATH: . diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index 49ce283..9b51989 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -35,5 +35,9 @@ jobs: run: python -m pytest test/test_utils/ -v --tb=short - name: Run AutomationEditor With Debug Mode run: python ./test/unit_test/start_automation/start_automation_test.py + env: + PYTHONPATH: . - name: Extend AutomationEditor run: python ./test/unit_test/start_automation/extend_automation_test.py + env: + PYTHONPATH: .